fix(svc-gateway): stop forwarding client-asserted x-user-* identity headers - #9
Conversation
The permission middleware that actually guards the platform reads its subject
and roles straight off the request: `@openfoundry/permissions` takes `x-user-id`
and `x-user-roles` as given, and `resolvePermissionsForRoles("ADMIN")` then
grants everything. The gateway proxy forwarded those headers verbatim, stripping
only hop-by-hop entries and `Host`, so the caller supplied its own authorization.
Reproduced end to end against the real gateway and svc-ontology with
ENFORCE_PERMISSIONS=true, the value deploy/.env.production and
deploy/docker-compose.yml both set:
no token, no headers -> 403
no token, X-User-Roles: ADMIN -> 201 Created
same, reading it back -> 200, data returned
no token, X-User-Roles: VIEWER (control) -> 403, so the check is live
valid Bearer token, no role headers -> 403
A complete inversion: the authenticated user denied, the anonymous
header-spoofer admitted.
The gateway is the public edge, so it now drops every `x-user-*` header arriving
from a client. This only removes an inbound trust; it grants no one access and
does not populate the headers either.
Measured against every registered route of the five guarded services, driving
each one through the real gateway and recording what requirePermission actually
decided (112 of the 118 call sites reachable with synthetic URLs):
start.sh config, ordinary client 112 ALLOW -> 112 ALLOW
start.sh config, spoofed ADMIN 112 ALLOW -> 112 ALLOW
ENFORCE_PERMISSIONS=true, ordinary 112 DENY -> 112 DENY
ENFORCE_PERMISSIONS=true, spoofed 112 ALLOW -> 112 DENY
No route stops working; one cell changes, and it is the attacker's. Under
start.sh the HTTP status distribution is identical before and after. The
enforcing-mode denial for ordinary clients already existed, because
`request.claims` is never set: authPlugin is registered unencapsulated in
server.ts without fastify-plugin, so its onRequest hook applies to no route.
Seed and sync scripts are unaffected - they call the service ports directly
rather than through the proxy, verified by running seed-healthcare.sh against
the patched stack. The console sends only `Authorization: Bearer`. Service
tests inject headers straight into the service, not through the gateway.
`x-uploaded-by` is deliberately left alone: it is attribution rather than an
authorization decision, and nothing else supplies it.
This closes the escalation only on the gateway path. Reaching a service port
directly still bypasses it, which is why those ports must not be public.
Read this before assuming the gateway now enforces rolesAdding the parts of the picture that do not fit in a diff summary, so the state of this area is understandable without the originating conversation. Role-based enforcement is still NOT active after this PRWhat this PR removed was fake enforcement, not enforcement. Before it, the identity a request was judged on came from headers the client itself supplied, so any caller could decide its own roles. That was measured end to end against a real gateway with
A complete inversion: the authenticated user denied, the anonymous header-spoofer admitted. An identity source anyone can forge is the opposite of enforcement, so deleting it is a net gain even though it leaves enforcement at zero. Real enforcement needs a trusted identity source, and that can only be set once the gateway actually validates JWTs. That work is in flight on a separate branch and will set Where to pick up the thread:
Known consequence:
|
PR #9 landed the client-header strip and documented, accurately at the time, that role enforcement still did not work: nothing set x-user-id / x-user-roles from a verified token, `request.claims` was never populated, and issued tokens carried no roles claim. This change is the trusted hop those notes were waiting for, so AGENTS.md, deploy/.env.production and deploy/docker-compose.yml said the opposite of what the code now does. ENFORCE_PERMISSIONS=true no longer means "every gateway-proxied route answers 403"; it means the roles the caller's token carries are enforced, given AUTH_PUBLIC_KEY and a token minted with a roles claim.
#12) * fix(gateway): actually enforce JWT auth, after giving every caller credentials The gateway never validated a JWT. `server.ts` called `app.register(authPlugin)`, which encapsulates the plugin, so its `onRequest` hook applied only to routes registered inside that child context - and there were none. healthRoutes, v2Routes, v1Routes and multipassRoutes are siblings on the parent, so the hook never ran: with AUTH_PUBLIC_KEY configured, GET /api/v2/ontologies still answered 200 with real data, `request.claims` and `request.orgRid` stayed empty, and nothing downstream could enforce anything. Credentials land before enforcement does, so no commit leaves a caller broken. Callers first: - scripts/lib/auth.sh (`of_curl`) and scripts/lib/auth.ts (`authHeaders`) resolve a bearer token from OPENFOUNDRY_TOKEN, or from OPENFOUNDRY_CLIENT_ID/_CLIENT_SECRET via the client_credentials grant, read from the environment or the gitignored .env. Nothing is baked into the repo. With neither set they send no header, which is exactly today's behaviour. - Wired into the Kelava sync scripts, every seed script, seed-dev-data.ts, seed-kelava-alerts.sh, test-integration.sh, the CI readiness probe and tests/integration/kelava-sync.test.ts. - The console has ~80 bare `fetch` call sites, so one interceptor in lib/authFetch.ts attaches the stored token to gateway requests. - bootstrap-admin.sh keeps no credential on purpose: it runs before one exists, against the signup route the middleware already exempts. - .env.example registers AUTH_PUBLIC_KEY, JWT_PRIVATE_KEY/JWT_PUBLIC_KEY and the OPENFOUNDRY_* credential keys, all empty. Then enforcement: - Call authPlugin directly, like rateLimitPlugin, with a comment explaining why so the next person does not re-register it. - Import AUTH_PUBLIC_KEY as a key object via the new `importVerificationKey`. The PEM's raw bytes were being handed to jose as an HMAC secret, which cannot verify an ES256 token, so enforcement alone would have rejected every caller. A malformed key now fails at startup. - Default AUTH_ISSUER to "openfoundry-multipass", the only issuer this platform mints. The old "openfoundry" matched nothing. - Plugin state moved into the closure; module-level state leaked between server instances in one process. Trusted identity for downstream services: - proxy.ts always drops client-supplied X-User-Id / X-User-Roles and re-asserts them from the verified claims, so role-based enforcement finally has an identity source a client cannot forge. - Both headers are set together or not at all: the permission middleware denies a request with a user id but no roles, so a half-filled identity would 403 every downstream route. - Roles come from a new optional `roles` claim minted by svc-multipass. A token without it conveys no roles and downstream behaves exactly as before. - The proxy also stops forwarding the incoming Content-Length, which no longer describes the re-serialised body; a pretty-printed payload was failing with a 502 (this is why seed-kelava-alerts.sh never actually created its monitors). - `local` used outside a function aborted sync-kelava-incremental.sh. Verified against a gateway with AUTH_PUBLIC_KEY set: unauthenticated /api/v2/ontologies is 401 and the same request with a token is 200; the seed scripts, seed-dev-data.ts, the alert seeder and all 12 integration suites pass; the console logs in and every page loads. The Kelava sync was exercised against a local stand-in for Sanocare's Postgres - only its auth path is proven, not a sync against the real source. * docs: correct the identity notes that this change makes false PR #9 landed the client-header strip and documented, accurately at the time, that role enforcement still did not work: nothing set x-user-id / x-user-roles from a verified token, `request.claims` was never populated, and issued tokens carried no roles claim. This change is the trusted hop those notes were waiting for, so AGENTS.md, deploy/.env.production and deploy/docker-compose.yml said the opposite of what the code now does. ENFORCE_PERMISSIONS=true no longer means "every gateway-proxied route answers 403"; it means the roles the caller's token carries are enforced, given AUTH_PUBLIC_KEY and a token minted with a roles claim. * no-mistakes(review): gate dev OAuth clients on NODE_ENV; source token roles from user * no-mistakes(review): stop minting roles from unauthenticated authorize; harden console token handling * no-mistakes(review): drop gateway identity assertion and roleless refresh; fix token restore * no-mistakes(review): fix deploy issuer drift, persist absolute token expiry * no-mistakes(review): plumb JWT signing keys into deploy; fix stale test comments * no-mistakes(review): share PEM normalisation, fix empty env fallback, drop parseRoles * no-mistakes(review): treat empty env vars as unset in gateway config * no-mistakes(review): clear unrecoverable expired console session on restore * no-mistakes(review): extract and test console session restore decision * no-mistakes(document): document gateway auth enforcement in GUIDE, refresh lint counts * no-mistakes(review): clear orphaned half-sessions; drop duplicate content-length skip * no-mistakes(review): register gateway routes after auth guard; pin /metrics 401 * ci(integration): give CI callers the dev OAuth credentials sdk-compat's Admin.Users.getCurrent() reads identity out of the bearer token, so it 401s whenever the caller has none. The suite used to hardcode admin/admin123; now that every caller resolves credentials from the environment through scripts/lib/auth.*, CI has to supply them. Set the seeded dev client_id/secret as job-level env so the readiness probe, the seed script and the vitest suites all authenticate the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Michael Santoso <michael@Michaels-MacBook-Air.local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intent
Kapten mengembangkan OpenFoundry menuju kesetaraan dengan Palantir Foundry. Salah satu arah kerja yang sedang berjalan di sana adalah multi-penyewa - beberapa organisasi klien berbagi satu server dengan aman - dan panduan produknya sendiri menjanjikan: "Client 1 TIDAK BISA lihat data Client 2. Dijamin database."
Pemeriksaan mutu menemukan bahwa jaminan itu belum berdiri sepenuhnya, dan salah satu bagiannya adalah ini: kendali akses berbasis peran sudah ditulis lengkap tetapi TIDAK DIPANGGIL SIAPA PUN. Middleware-nya ada, mengekspor requirePermission, grantPermission, dan revokePermission - dan pencarian di seluruh repositori tidak menemukan satu pun pemanggil. Jadi gateway saat ini tidak menegakkan peran sama sekali, meski kodenya tampak lengkap.
What Changed
proxyRequestnow drops every inboundx-user-*header (case-insensitive) alongside the existing hop-by-hop andhoststrips, so a client can no longer assert its own user id, roles, or groups to downstream services that treat those headers as trusted;Authorizationand other non-identity headers still pass through.x-user-*headers and asserting unrelated headers are preserved.proxy.ts,AGENTS.md,deploy/.env.production, anddeploy/docker-compose.yml: no trusted hop sets these headers from a verified token yet, soENFORCE_PERMISSIONS=truemakes guarded gateway-proxied routes answer 403.Risk Assessment
✅ Low: The fix round is documentation-only and its factual claims check out against the code, and the underlying header strip is a narrow, well-tested removal of a forgeable trust input that the author explicitly authorized as containment pending a separate gateway-JWT branch.
Testing
Installed deps and built the gateway's workspace dependencies (neither existed in the worktree), ran the targeted gateway proxy suite green, then reproduced the security claim end to end with a real gateway process in front of a real upstream that honours x-user-roles: at base commit a tokenless client asserting "X-User-Roles: ADMIN" received 201 and the forged ontology appeared on the next read, while at the target commit the same request arrives upstream with no identity headers and is refused 403 - with Authorization: Bearer and an unrelated x-uploaded-by header still forwarded intact, and the direct service port still trusting the header as documented. This is a gateway/API-level change with no rendered UI surface, so the reviewer-visible evidence is the CLI transcript pair rather than a screenshot. Everything passes; the one open item is that nothing yet invokes role enforcement at all, which the author has explicitly deferred to a separate branch.
Evidence: E2E transcript BEFORE fix (base 3bf60a1) - forged ADMIN role accepted, 201 Created
$ curl -XPOST -H "X-User-Id: mallory" -H "X-User-Roles: ADMIN" :9400/api/v2/ontologies upstream saw: x-user-id=mallory x-user-roles=ADMIN authorization=<none> -> HTTP 201 {"rid":"ri.ontology.main.ontology.forged","displayName":"Client2Secrets"} $ curl :9400/api/v2/ontologies # did the forged write land? -> HTTP 200 {"data":[{"rid":"ri.ontology.main.ontology.forged"}]}Evidence: E2E transcript AFTER fix (41a2041) - identity headers stripped, forged write refused 403
$ curl -XPOST -H "X-User-Id: mallory" -H "X-User-Roles: ADMIN" :9400/api/v2/ontologies upstream saw: x-user-id=<none> x-user-roles=<none> authorization=<none> -> HTTP 403 {"error":"PermissionDenied","permission":"ontology:create"} $ curl :9400/api/v2/ontologies # did the forged write land? -> HTTP 200 {"data":[]} $ curl -H "Authorization: Bearer token-123" -H "X-Uploaded-By: alice" :9400/api/v2/ontologies upstream saw: x-user-id=<none> x-user-roles=<none> authorization=Bearer token-123 -> HTTP 200 {"data":[]} === direct service port (not through the gateway) still trusts the header === upstream saw: x-user-roles=ADMIN -> HTTP 201 {"rid":"ri.ontology.main.ontology.forged",...}Evidence: E2E harness script used to produce both transcripts
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
services/svc-gateway/src/proxy.ts:70- Intent conformance gap. The intent requires that role-based access control actually be invoked - "kendali akses berbasis peran sudah ditulis lengkap tetapi TIDAK DIPANGGIL SIAPA PUN ... gateway saat ini tidak menegakkan peran sama sekali". This change moves in the opposite direction of enforcement: it removes the only inbound source ofx-user-id/x-user-roles(services/svc-gateway/src/proxy.ts:70) without adding a trusted hop that sets them from a verified token. After it, no proxied request reachescreateHeaderBasedHook(packages/permissions/src/middleware.ts:126) with any identity:request.claimsis also never populated becauseauthPluginis registered unencapsulated at services/svc-gateway/src/server.ts, so with the defaultENFORCE_PERMISSIONSunset the hook hits theif (!userId && !hasClaims) returnbranch and silently allows every request. The gateway therefore still enforces no roles, so the intent's required behavior is absent from the change. The remedy (populating x-user-* from the verified bearer token at the gateway, and fixing the auth plugin encapsulation) extends this change beyond its stated containment scope, so it needs the author's authorization rather than an automatic fix.services/svc-gateway/src/proxy.ts:70- Fail-closed regression when enforcement is turned on. With this strip in place and no hop setting identity, running any service withENFORCE_PERMISSIONS=truemakes every gateway-proxied route 403:createHeaderBasedHooksees neitherx-user-idnorrequest.claimsand throwspermissionDenied(packages/permissions/src/middleware.ts:136-140). Concretely: GET /api/v2/ontologies through the gateway with a validAuthorization: Bearertoken returns 403, where before this commit an operator could at least pass explicit headers from a trusted network position. Direct service ports still work, which makes the failure look gateway-specific and confusing. The fix is to have the gateway derivex-user-id/x-user-rolesfrom the verified token before forwarding - new behavior beyond this commit's scope, hence ask-user rather than auto-fix.services/svc-gateway/src/proxy.ts:37- The prefix strip also drops any future non-identity header that happens to start withx-user-(e.g. ax-user-agent-hintstyle header). That is the right default for a security boundary, but the intent is identity headers specifically; if a narrower allowlist of exactlyx-user-id,x-user-roles,x-user-groupsis preferred it should be an explicit decision. Noting the tradeoff, no action required.🔧 Fix: document known consequences of x-user-* header strip
2 issues (1 warning, 1 info) still open:
services/svc-gateway/src/proxy.ts:38- Intent conformance, re-stated for the record only. The intent requires that "kendali akses berbasis peran ... TIDAK DIPANGGIL SIAPA PUN" be resolved so the gateway actually enforces roles. After this fix round the change is still documentation plus the header strip: no code populates x-user-id / x-user-roles from a verified token, request.claims remains unset (authPlugin registered unencapsulated in services/svc-gateway/src/server.ts), so createHeaderBasedHook (packages/permissions/src/middleware.ts:136-143) takes the!userId && !hasClaimsearly-return under the default ENFORCE_PERMISSIONS. The author has explicitly authorized this as containment and assigned the durable fix (gateway JWT validation setting the trusted hop) to a separate in-flight branch, and the consequence is now documented in proxy.ts:38-45 and AGENTS.md:112-113 accurately. Flagged because the required behavior is still absent from this change, not because the containment is wrong; no code action is requested here.services/svc-gateway/src/proxy.ts:43- Minor doc precision: the new comment (and AGENTS.md:113) says ENFORCE_PERMISSIONS=true makes "every gateway-proxied route" answer 403. Enforcement is per-route viapreHandler: requirePermission(...)on the 118 guarded routes; unguarded proxied paths (e.g. /multipass/api/oauth2/token, health) still succeed. The claim is right in spirit and the wording was prescribed by the author, so noting the tradeoff rather than requesting an edit.services/svc-gateway/src/proxy.ts:38- Evidence gap on the intent's core ask: the strip is proven to work, but role-based access control is still not invoked by anyone on a gateway-proxied request. With no hop setting x-user-id/x-user-roles and request.claims never populated, I could not produce any evidence of roles being enforced end-to-end - only evidence that forged roles are no longer accepted. The author has recorded this as accepted containment (documentation-only round, JWT hop owned by a separate branch), so no code action is requested; flagged so the missing enforcement evidence is an explicit user decision rather than an implied pass.pnpm install --frozen-lockfile+npx turbo run build --filter=@openfoundry/svc-gateway^...(deps/workspace builds were absent in the worktree)npx vitest run src/proxy.test.tsin services/svc-gateway (11 tests, includes the two new strip cases)Manual E2E: real gateway on 127.0.0.1:9400 proxying to a real upstream on :9401 that grants writes onx-user-roles: ADMIN; forged-role POST, follow-up read, entitled-header pass-through, and direct-service-port control - run against the target commit and again with proxy.ts reverted to base 3bf60a1 for contrast (file restored, worktree clean)deploy/docker-compose.yml:33- deploy/.env.production and deploy/docker-compose.yml both set ENFORCE_PERMISSIONS=true, which - per the consequence this change documents - makes guarded gateway-proxied routes answer 403 until a trusted hop sets identity from validated claims. I added warning comments pointing at the authoritative note in proxy.ts but deliberately did not change the values, since flipping them is a behavioral/deployment decision for the author (and the in-flight gateway JWT task may make them correct again).✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.