Public API: External-Tenant Hardening Findings
Findings register from the 2026-07-07 gateway audit (Build on Agend / AHRI enablement). These are the gates between "internal apps use the API" and "an external client holds a live key". Status: OPEN · Owner: unassigned · Cross-refs:
writeback-two-way-sync.md§8,ADR-001-object-registry-system-and-custom-objects.md(Security)
Context
The gateway (apps/api, 169 /v1 routes) is production-grade in structure: fail-closed unified-key auth (ag_live_*/ag_test_*), scopes, entitlement checks, audit logging and billing metering on every request. The findings below are the known gaps that become material once an external tenant (AHRI first) builds against it — and become critical once write endpoints open (a tenancy bug on reads is a leak; on writes it is data corruption).
Findings
F1 · Rate limiter is in-memory per-process — P1
- Evidence:
packages/@agend/public-api/src/rate-limit.ts:1— per-key token bucket held in process memory. Default 60 req/min applied inpackages/@agend/public-api/src/middleware.ts:541. - Impact: On serverless/multi-instance deploys each instance keeps its own bucket, so the effective limit is 60 × N instances and is not deterministic. An external client cannot be meaningfully throttled; a runaway client cannot be reliably contained.
- Fix: Back the limiter with a shared store (Upstash/Redis). Note a distributed limiter already exists in the LMS app — evaluate reuse rather than a new build.
- Gate: Before first external live key.
F2 · No RLS backstop behind the admin-client tenancy model — P1
- Evidence: All gateway routes use the RLS-bypassing Supabase admin client (
apps/api/lib/gateway-client.ts:27, with an explicit justification comment). Tenant isolation depends entirely on each handler passingauth.context.accountId(e.g.apps/api/app/v1/crm/contacts/route.ts:37). - Impact: A single handler that forgets or mis-threads
accountIdleaks (reads) or corrupts (writes) across tenants. There is no database-level second line of defence on this path. - Fix options (either or both):
- Session-scoped RLS: set the account context per request (
set_config) and run gateway queries under a non-admin role with account-scoped policies. - Invariant enforcement in CI: a lint rule / contract test asserting every gateway service call receives
auth.context.accountId, plus cross-tenant probe tests per route.
- Session-scoped RLS: set the account context per request (
- Gate: Hard prerequisite for external write access (also recorded in writeback-two-way-sync.md §8 and ADR-001).
F3 · ignoreBuildErrors: true in the API app — P2
- Evidence:
apps/api/next.config.mjssetstypescript: { ignoreBuildErrors: true }. - Impact: Type errors ship silently in the one app whose contract external developers depend on.
- Fix: Burn down existing errors, flip the flag, keep it flipped in CI.
F4 · X-RateLimit-Reset documented but never sent — P3
- Evidence: The OpenAPI intro (
apps/api/app/v1/openapi/route.ts:43region) documentsX-RateLimit-Reset; middleware only setsX-RateLimit-Remaining(middleware.ts). - Impact: Spec/implementation drift; client backoff logic written to the docs will misbehave.
- Fix: Send the header (trivial once F1 lands, since reset time comes from the shared store) or remove it from the docs until then.
F5 · /v1/health requires auth — P3 (observed 2026-07-07)
- Evidence:
curl https://api.agend.com.au/v1/health→ HTTP 401 (domain live on Vercel DNS). - Impact: External clients cannot run unauthenticated uptime checks against the endpoint the docs advertise as infrastructure.
- Fix: Exempt health from key auth (return no tenant data), or document that health requires a key.
Suggested sequencing
- F2 invariant tests (fast, no infra) → F1 distributed limiter → F2 RLS backstop proper
- F3 alongside, F4/F5 as one-line fixes in the same sprint
Unchecked areas (honesty list — not audited, not cleared)
- Webhook egress security: client-defined webhook delivery URLs are an SSRF/exfiltration surface (internal-network targets, cloud metadata endpoints). The audit did not examine
@agend/webhooksdelivery for URL allow/deny rules, private-range blocking or redirect handling. Audit before external tenants can register webhook endpoints. - Dynamic scope issuance UX: the key-management UI's behaviour when custom-object scopes (
crm.{type}.*, ADR-001) become account-dynamic was not reviewed.
Verification confirmations (what's already good)
Fail-closed middleware (throws at construction without requiredScopes), HMAC-hashed keys via validate_unified_api_key RPC, per-key CORS origin allowlists, audit + metering via after() — none of these need work for external tenancy.