Skip to content

fix(svc-gateway): stop forwarding client-asserted x-user-* identity headers - #9

Merged
Przyval merged 3 commits into
masterfrom
fm/openfoundry-rbac-mati
Sep 11, 2026
Merged

Przyval merged 3 commits into
masterfrom
fm/openfoundry-rbac-mati

Conversation

@Przyval

@Przyval Przyval commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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

  • proxyRequest now drops every inbound x-user-* header (case-insensitive) alongside the existing hop-by-hop and host strips, so a client can no longer assert its own user id, roles, or groups to downstream services that treat those headers as trusted; Authorization and other non-identity headers still pass through.
  • Added two gateway proxy tests covering the strip for lowercase and mixed-case x-user-* headers and asserting unrelated headers are preserved.
  • Documented the known consequence in proxy.ts, AGENTS.md, deploy/.env.production, and deploy/docker-compose.yml: no trusted hop sets these headers from a verified token yet, so ENFORCE_PERMISSIONS=true makes 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"}]}

(Use `node --trace-deprecation ...` to show where the warning was created)
=== anonymous client forging admin roles at the public gateway edge ===

$ 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?
  upstream saw: x-user-id=<none> x-user-roles=<none> authorization=<none>
  -> HTTP 200 {"data":[{"rid":"ri.ontology.main.ontology.forged"}]}

=== headers a caller is entitled to send still reach upstream ===

$ 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":[{"rid":"ri.ontology.main.ontology.forged"}]}

=== direct service port (not through the gateway) still trusts the header ===
  upstream saw: x-user-id=<none> x-user-roles=ADMIN authorization=<none>
  -> HTTP 201 {"rid":"ri.ontology.main.ontology.forged","displayName":"Client2Secrets"}
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",...}

(node:26981) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
=== anonymous client forging admin roles at the public gateway edge ===

$ 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?
  upstream saw: x-user-id=<none> x-user-roles=<none> authorization=<none>
  -> HTTP 200 {"data":[]}

=== headers a caller is entitled to send still reach upstream ===

$ 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-id=<none> x-user-roles=ADMIN authorization=<none>
  -> HTTP 201 {"rid":"ri.ontology.main.ontology.forged","displayName":"Client2Secrets"}
Evidence: E2E harness script used to produce both transcripts
// End-to-end: a real gateway process in front of a real upstream service that
// trusts x-user-roles the way @openfoundry/permissions does.
import http from "node:http";
import { createServer } from "../../worktrees/481c218a8e4b/01M273TWZFT6D9FMR1ZFB3YQNV/services/svc-gateway/src/server.js";

const created: string[] = [];

// Stand-in for svc-ontology guarded by requirePermission("ontology:create"),
// i.e. the header-based hook in packages/permissions/src/middleware.ts.
const upstream = http.createServer((req, res) => {
  const roles = String(req.headers["x-user-roles"] ?? "").split(",").map(s => s.trim());
  console.log(`  upstream saw: x-user-id=${req.headers["x-user-id"] ?? "<none>"} x-user-roles=${req.headers["x-user-roles"] ?? "<none>"} authorization=${req.headers["authorization"] ?? "<none>"}`);
  if (req.method === "POST") {
    if (!roles.includes("ADMIN") && !roles.includes("OWNER")) {
      res.writeHead(403, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "PermissionDenied", permission: "ontology:create" }));
      return;
    }
    created.push("ri.ontology.main.ontology.forged");
    res.writeHead(201, { "content-type": "application/json" });
    res.end(JSON.stringify({ rid: created.at(-1), displayName: "Client2Secrets" }));
    return;
  }
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ data: created.map(rid => ({ rid })) }));
});
await new Promise<void>(r => upstream.listen(9401, "127.0.0.1", r));

const app = await createServer({ config: {
  port: 0, host: "127.0.0.1", authPublicKey: "", authIssuer: "t", authAudience: "t",
  services: { multipass: "http://127.0.0.1:9401", ontology: "http://127.0.0.1:9401",
    objects: "http://127.0.0.1:9401", actions: "http://127.0.0.1:9401",
    datasets: "http://127.0.0.1:9401", compass: "http://127.0.0.1:9401",
    admin: "http://127.0.0.1:9401", functions: "http://127.0.0.1:9401",
    webhooks: "http://127.0.0.1:9401", media: "http://127.0.0.1:9401",
    sentinel: "http://127.0.0.1:9401", aip: "http://127.0.0.1:9401" },
  rateLimit: { enabled: false, maxRequests: 100, windowMs: 60000 },
  logLevel: "silent", nodeEnv: "test",
} as any });
await app.listen({ port: 9400, host: "127.0.0.1" });

async function step(label: string, init: RequestInit, path = "/api/v2/ontologies") {
  console.log(`\n$ ${label}`);
  const res = await fetch(`http://127.0.0.1:9400${path}`, init);
  console.log(`  -> HTTP ${res.status} ${await res.text()}`);
}

console.log("=== anonymous client forging admin roles at the public gateway edge ===");
await step('curl -XPOST -H "X-User-Id: mallory" -H "X-User-Roles: ADMIN" :9400/api/v2/ontologies', {
  method: "POST", headers: { "X-User-Id": "mallory", "X-User-Roles": "ADMIN", "content-type": "application/json" },
  body: JSON.stringify({ displayName: "Client2Secrets" }),
});
await step("curl :9400/api/v2/ontologies   # did the forged write land?", { method: "GET" });

console.log("\n=== headers a caller is entitled to send still reach upstream ===");
await step('curl -H "Authorization: Bearer token-123" -H "X-Uploaded-By: alice" :9400/api/v2/ontologies', {
  method: "GET", headers: { authorization: "Bearer token-123", "X-Uploaded-By": "alice" },
});

console.log("\n=== direct service port (not through the gateway) still trusts the header ===");
const direct = await fetch("http://127.0.0.1:9401/api/v2/ontologies", {
  method: "POST", headers: { "X-User-Roles": "ADMIN", "content-type": "application/json" }, body: "{}",
});
console.log(`  -> HTTP ${direct.status} ${await direct.text()}`);

await app.close(); upstream.close();
- Outcome: ⚠️ 1 warning across 1 run (2m20s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 issues (1 warning, 1 info)
  • 🚨 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 of x-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 reaches createHeaderBasedHook (packages/permissions/src/middleware.ts:126) with any identity: request.claims is also never populated because authPlugin is registered unencapsulated at services/svc-gateway/src/server.ts, so with the default ENFORCE_PERMISSIONS unset the hook hits the if (!userId &amp;&amp; !hasClaims) return branch 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 with ENFORCE_PERMISSIONS=true makes every gateway-proxied route 403: createHeaderBasedHook sees neither x-user-id nor request.claims and throws permissionDenied (packages/permissions/src/middleware.ts:136-140). Concretely: GET /api/v2/ontologies through the gateway with a valid Authorization: Bearer token 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 derive x-user-id/x-user-roles from 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 with x-user- (e.g. a x-user-agent-hint style header). That is the right default for a security boundary, but the intent is identity headers specifically; if a narrower allowlist of exactly x-user-id, x-user-roles, x-user-groups is 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 &amp;&amp; !hasClaims early-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 via preHandler: 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.
⚠️ **Test** - 1 warning
  • ⚠️ 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.ts in 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 on x-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)
⚠️ **Document** - 1 info
  • ℹ️ 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.

Michael Santoso added 3 commits September 11, 2026 09:13
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.
@Przyval

Przyval commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Read this before assuming the gateway now enforces roles

Adding 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 PR

What 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 ENFORCE_PERMISSIONS=true:

client result
no token, no headers 403
no token, X-User-Roles: ADMIN 201 Created, and readable back
no token, X-User-Roles: VIEWER (control) 403, so the check really was live
valid Authorization: Bearer token 403

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 x-user-id / x-user-roles from validated claims, reusing these same header names so the existing permission layer starts working immediately. Do not add an identity source here: request.claims is never populated today, so it would be written on top of a value that is always empty.

Where to pick up the thread:

  • services/svc-gateway/src/proxy.ts:39 - the known-consequence note, and :47 / :79 for the strip itself
  • packages/permissions/src/middleware.ts:131 reads the headers; :138 is the !userId && !hasClaims early return that silently allows when enforcement is off
  • services/svc-gateway/src/server.ts:153 - authPlugin is registered without fastify-plugin, so its onRequest hook applies to no route and request.claims is never set
  • AGENTS.md:106 - the "Identity and permissions" section

Known consequence: ENFORCE_PERMISSIONS=true denies guarded routes

Until that trusted hop exists, the 118 role-guarded routes answer 403 through the gateway, because the permission hook sees neither x-user-id nor request.claims. Measured across every reachable guarded route (112 of the 118 were reachable with synthetic URLs): 403: 112, 400: 3, 404: 5. Unguarded proxied paths such as /multipass/api/oauth2/token and the health endpoints still succeed, so "every proxied route" in the code comments is a slight overstatement worth tightening later.

This predates this PR: with a valid Bearer token those routes already returned 403, because of the server.ts:153 encapsulation bug. deploy/.env.production and deploy/docker-compose.yml both set ENFORCE_PERMISSIONS=true; whether to flip that while the auth work lands is an open deployment decision, deliberately not made here.

Under the default that start.sh uses, enforcement is off and nothing changes: 112 of 112 guarded routes behave exactly as before, with an identical HTTP status distribution. No route stops working.

Neither permission model in this repo is Foundry's model

Relevant to product direction, and the reason this PR does not simply wire up the unused middleware.

Two requirePermission exports coexist. services/svc-gateway/src/middleware/rbac.ts has zero importers repo-wide; the one that actually guards 118 routes is @openfoundry/permissions. Neither matches Foundry:

  • Foundry grants roles on a resource. POST /v2/filesystem/resources/{resourceRid}/roles/add, model ResourceRole = (principal, role, resource), inherited by a container's contents, with principals that may be groups. Roles themselves are platform-defined and read-only over the API: only GET /v2/admin/roles/{roleId} and getBatch exist, with no create, replace, or delete, and the available set is per-organization via listAvailableRoles.
  • @openfoundry/permissions roles are global. resolvePermissionsForRoles("EDITOR") grants ontology:write everywhere, so an EDITOR may write every ontology in the deployment. Foundry has no such thing at the resource layer.
  • rbac.ts is a bare ACL, (subject, resource, permission), with no role, no container inheritance, no group expansion, and no owner. Its own comment promises that a resource owner keeps access before any grant exists, but there is no owner column anywhere: neither permission_grants nor the ontologies table records a creator, so that rule has no data behind it. It also keys grants by claims.sub, which holds a username rather than a RID, and its admin bypass reads a roles claim that issued tokens never carry.

createPgStoreHook in packages/permissions/src/middleware.ts is the one piece already shaped correctly: admin bypass, then resource grant, then role fallback.

Tenant isolation is a separate, mandatory layer in Foundry (Organizations) and is org_rid plus row-level security here. No permission check delivers it, and none should be described as doing so.

@Przyval
Przyval merged commit 5789d4b into master Sep 11, 2026
5 checks passed
Przyval pushed a commit that referenced this pull request Sep 11, 2026
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.
Przyval added a commit that referenced this pull request Sep 11, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant