feat(authz): wrap Permit behind FuzeFront Security API for chat-service (#254) - #836
Conversation
chat-service's agent-tool permission check (agent/executor.ts) called the Permit.io PDP directly over REST (agent/permit.ts) — the exact "consumer product calls Permit/PDP directly" leak #254 targets. Add a flag-gated wrapped path that asks the same question of FuzeFront's own Security API instead, via the already-published, already-production-proven @fuzefront/auth createAuthzClient (the same client config-service's middleware/authz.ts uses for its own Permit-free authorization). - agent/securityApiPermitAdapter.ts: adapts createAuthzClient's AuthzClient to the PermitClient-compatible check() shape. Fail-closed: no token -> deny, AuthzError/any throw -> deny. - agent/authzGateway.ts: the flag-gated selector executor.ts now depends on in place of PermitClient directly — OFF routes to the unchanged direct-PDP PermitClient, ON routes to the wrapped adapter. Fails closed to OFF even if the injected flag lookup itself throws. - agent/authzFlag.ts: fuzefront.authz.chat-agent-security-api, release flag, default OFF (registered in packages/feature-flags/flag-registry.yaml). Lazy-required @fuzefront/feature-flags, mirrors backend/security/utils/rootMembershipFlag.ts. - agent/permit.ts: PermitCheck gains an optional `token` field (carries the caller's bearer token to whichever implementation needs it); PermitClient itself is UNCHANGED and stays live as the flag-OFF default, per the issue's "keep the direct-PDP path working until the wrapped API is proven" ask. - executor.ts: forwards PendingExecution.token through the check; widens ToolExecutorDeps.permit to the new AuthzCheckable interface (structurally compatible with the existing PermitClient/mock-based tests, which are unchanged and still pass). - config.ts / Helm (chat-service.yaml, values.yaml, values-prod.yaml): SECURITY_SERVICE_URL (defaults to the in-cluster fuzefront-security:3002, same convention as config-service/provisioning-service) + the Unleash featureFlags block (mirrors security.yaml's wiring; degrades to in-code default when unwired, same as every other service's flag block). Both flag states are unit-tested (authzGateway.test.ts), including the flag-lookup-throws-safely-to-OFF case; the wrapped adapter is tested against both an injected AuthzClient and a real createAuthzClient + mocked fetch (request shape, allow/deny, fail-closed on non-2xx). Existing permit.test.ts and executor.test.ts are unchanged and still pass. Scope: this PR migrates chat-service only — see the PR description for the full audit of remaining direct-Permit call sites in this repo (billing-service ABAC attribute sync, backend/src's pre-extraction Permit config) and why each is deliberately deferred rather than folded in here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
…#836) PR #836's own defect, in three places, all required: 1. package-lock.json — the ROOT lockfile's services/chat-service entry never gained @fuzefront/auth / @fuzefront/feature-flags as dependency edges when they were added to chat-service's package.json (I edited package.json after the last `npm install`, never reran it). `npm ci` is lockfile-only and silently installs nothing for a workspace whose lockfile entry is stale, unlike `npm install`'s live re-resolution — which is exactly why my local `tsc`/jest passed (ran via `npm install`) while CI's `npm ci` did not. Regenerated via `npm install -w services/chat-service`; diff is 3 lines. 2. .github/workflows/ci.yml (`chat-service-tests` job) — @fuzefront/auth ships its types from dist/ (tsup, not committed), and securityApiPermitAdapter.ts imports createAuthzClient/AuthzClient statically, so both the type-check step and the jest run (ts-jest resolves the same way) failed TS2307 "Cannot find module '@fuzefront/auth'" until it's built first. Added the same prebuild pattern the file already uses for @fuzefront/custom-hostname-client / @fuzefront/security-client. @fuzefront/feature-flags needs no such step: agent/authzFlag.ts only ever `require()`s it at runtime inside a try/catch, no static import, so tsc never needs its types — confirmed by grep (no `import` of it anywhere) and by design (the "package absent" branch is exactly what authzFlag.test.ts's fail-safe-OFF case exercises). 3. services/chat-service/Dockerfile — the build stage never copied or built packages/auth or packages/feature-flags source, so the image's own `npm run build` hit the identical TS2307. Added COPY + `npm install --ignore-scripts && npm run build` for both, mirroring line 100's existing treatment of packages/identity. The production stage copies each package's package.json + dist (populating the `file:`- linked symlink target, same as config-service's and selection-list-service's Dockerfiles) plus, for @fuzefront/auth, packages/auth/node_modules from the base (--omit=dev) stage — its runtime dep `jose@^5.10.0` is NOT hoisted to /app/node_modules (root pins jose to 4.15.9 for other consumers), so it only exists at packages/auth/node_modules/jose; omitting it is the exact "builds green, pod dies at module load" bug already fixed for selection-list-service in #817. packages/feature-flags/node_modules is copied too, even though this repo's dependency graph currently hoists its runtime deps (@openfeature/*, unleash-client) to root regardless — scripts/check-dockerfile-lockfile.mjs (FFRNT-254) flags any dist-only copy of a file:-linked package with runtime deps unconditionally, on the (correct) theory that hoisting outcomes are exactly the kind of thing this repo has been bitten by before and should never be trusted implicitly per Dockerfile. Verified without a Docker daemon by replicating every stage outside Docker (per #817's own verification approach): staged the Dockerfile's manifest COPY list into a scratch dir, ran the identical `npm ci --workspace=services/chat-service` (both --omit=dev and full) against the real committed lockfile, ran the identical per-package build commands, assembled a "production" directory from exactly the COPY list's source/destination pairs, then had Node itself `require.resolve` and actually `require()` the built adapter/flag modules AND `@fuzefront/feature-flags` from that assembled tree — proving real module load, not just path resolution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
|
Pushed a fix for the CI/Docker build defect flagged on this PR (own-PR bug, not the unrelated config-service contract-conformance failure). Root cause (three places, all required — none alone was sufficient):
Verified (no Docker daemon available), per #817's own approach:
Remote HEAD Generated by Claude Code |
The production stage failed the build outright: > [production 15/19] COPY --from=base /app/packages/feature-flags/node_modules ERROR: failed to compute cache key: "/app/packages/feature-flags/node_modules": not found npm creates packages/<x>/node_modules only when something could NOT be hoisted. packages/auth gets one because jose@5 conflicts with the root's jose@4, which is why the sibling COPY a few lines up works. packages/feature-flags' runtime deps (@openfeature/server-sdk, @openfeature/web-sdk, unleash-client) have no such conflict, so `npm ci --omit=dev` hoists all of them into /app/node_modules and creates no per-package directory at all — and COPY on a missing source is a hard build failure, not a no-op. The COPY line itself is correct and must stay: Docker has no conditional COPY, and scripts/check-dockerfile-lockfile.mjs requires it precisely so the image does not silently depend on what one particular resolution happened to hoist. So make the SOURCE exist instead — `mkdir -p` in the base stage after npm ci. An empty directory is the right outcome when everything hoisted, and if a future resolution stops hoisting, the real contents land there and get copied without anyone needing to remember why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
|
Verified rather than assumed: The fix exists: #840 implements both endpoints ( Not ported into this PR, deliberately. The protocol says to port an existing fix rather No re-run spent — a re-run cannot fix a deterministic contract gap. Generated by Claude Code |
…te-identifier are not among them (#843) * docs(governance): master requires 11 of 45 checks; gate-authz and gate-identifier are not among them CLAUDE.md states the gate set IS the production guard, now that CI may approve its own PRs: "gate-ds-conformance, gate-frames-first, gate-authz, gate-identifier, and the full CI matrix ... A bot-authored, bot-approved PR that clears every gate ships to prod." The ruleset does not enforce that. Measured against the live ruleset: 11 of 45 checks are required. gate-authz and gate-identifier — both named above — are not required. Neither is any backend, integration or e2e suite, nor CodeQL, Snyk, Container Security Scan, gate-toolchain, gate-version, gate-pagination or gate-vacuous-check. auto-merge.yml arms `gh pr merge --auto --squash`, and GitHub's auto-merge waits on REQUIRED contexts only. Master is deploy-on-push. So a PR merges and deploys the moment those 11 pass, while the rest are still running or already red. On 2026-08-27 the bot armed auto-merge on #836 while `Build chat-service` was failing on an unbuildable production image; that check is not required, so nothing would have stopped the merge. This commit is the applicable change, not a report of one: the rulesets API is write-blocked through the agent proxy (403), so it needs repo-admin rights. The desired set is data in governance/required-status-checks.json and the runbook carries the exact PUT, the verification, and the rollback. Audited every workflow trigger before choosing the set, because a required check that does not run on every PR is never created and pins the PR at "Expected" forever — this repo hit that on 2026-08-23 across four sampled PRs. Only unfiltered checks are included (39 contexts). `Generate SBOM` is excluded as it always concludes skipped. Two findings worth more than the count: - The most valuable gates CANNOT be required as they stand. `shipped code matches the frozen contract` — which would have caught config-service shipping two declared-but-unimplemented endpoints — is path-filtered, along with gate-frames-first, gate-route-ownership, image-reproducibility and helm-validate. Step 4 is to unfilter the trigger and filter inside the job, the correction workspace-deps-check.yml and gate-sealed-keys.yml already carry. - Merge queue must NOT be enabled yet, though it is the right fix for the release-commit treadmill. Zero of 58 PR-triggered jobs have a `merge_group` trigger, so queue entries would get no checks at all and time out; and gates that ratchet on the PR base would compute over an empty diff under `merge_group`, reporting green vacuously. Trigger support and base-ref handling land first, proven by a gate still FAILING under a merge_group event. Health-checked before proposing: 29 candidate contexts across all 20 open PRs produced 0 failures, so widening stalls nothing in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc * docs(governance): drop gate-code-review from the required set — it can hang, not fail Correcting my own recommendation in the previous commit. gate-code-review is `runs-on: fuzefront` — a SELF-HOSTED runner — with no `timeout-minutes`. Requiring it would let one offline runner block every merge in the repo, permanently, with no red anywhere to look at. Not hypothetical. Measured 2026-08-27 while draining the PR queue: the job completes in 12-14 seconds when the runner picks it up (five PRs did), but NOTHING has completed since 09:08Z and 17 PRs sit queued for up to an hour — including #840, the PR at the front of the merge queue, queued since 08:52Z. The runner is not slow; it stopped taking work. Meanwhile in-progress GitHub-hosted runs fell from 14 to 5, so this is not shared capacity. The health check in the previous commit read gate-code-review as 5 success / 0 fail / 15 pending and I counted it safe. That was the wrong reading: those pendings were not "slow", they were never going to complete. 0 failures is not evidence a check can fail. Hence the general rule now stated in the runbook, which is what I actually got wrong: A REQUIRED CHECK MUST BE ABLE TO FAIL. A check that can only pass or hang is worse than no check, because the deadlock it produces is indistinguishable from CI still running, so nobody knows to go look. gate-code-review and the path-filtered gates both fail that test, in different ways — one hangs waiting for a runner, the others are never created at all. Requireable again once it has a GitHub-hosted fallback, or a timeout that fails rather than hangs. Required contexts 39 -> 38; the verification count in step 3 is updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc * docs(governance): widening the required set leaves #828's guard covering 2 of 38 #828 (issue #286) adds governance/required-check-triggers.json — a gate that fails if a required context's workflow ever gains a `paths:` filter on `pull_request:`. It is what stops the deadlock in §2 from being reintroduced, and its own header states the limitation: Adding a NEW required context to the ruleset? Add its workflow here in the same PR — the gate is only as complete as this list. It ships listing 2 workflows. Step 3 of this runbook takes the required set to 38. Applied without extending that list, the guard silently covers 2 of 38 and the other 36 can be re-filtered by anyone with nothing to catch it — a green gate measuring almost nothing, which is the shape of failure gate-vacuous-check exists to prevent. Adds step 3a: the full context -> producing-workflow mapping (seven workflows cover all 38), plus a verification snippet that prints required / guarded / UNGUARDED counts so the two files cannot drift silently. Mapping verified against the workflow files rather than written from memory. Two contexts are matrix expansions and needed checking by hand: `CodeQL Analysis (javascript)` comes from security.yml's `codeql-analysis` job (matrix: language) and `Lint & Test (24.x)` from ci.yml's `lint-and-test` (matrix: node-version). Also notes that gate-openapi-conformance.yml and gate-route-ownership.yml join the list once #844 lands, since unfiltering them is precisely the property this guard protects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Closes the FuzeFront-repo side of #254 for one real, live consumer: chat-service no longer needs to call the Permit PDP directly to get the same authorization answer.
Key finding before writing any code: most of what #254 asks for — a FuzeFront-owned authorization API fronting Permit, a generated client, fail-closed semantics — already exists, delivered as part of the unified Security API (
packages/security/openapi.yaml,backend/security/src/routes/authz.ts, published as@fuzefront/security-clientv0.7.0, and consumed via@fuzefront/auth'screateAuthzClient).config-service'ssrc/middleware/authz.tsis already running this in production instead of an embedded Permit SDK. The contract's own "Delivery phasing" note says it plainly: "AuthN is delivered first…AuthZ is designed here in full and its consumer rollout is sequenced after AuthN." Rollout — not the API — was the open item.So this PR is that rollout, for one concrete, currently-in-repo direct-Permit consumer, using the proven pattern rather than inventing a new one.
What changed
services/chat-service's agent-tool permission check (agent/executor.ts) calledPOST {PERMIT_PDP_URL}/alloweddirectly (agent/permit.ts) — exactly the "consumer product calls the PDP directly" leak #254 names. Added a flag-gated wrapped path:agent/securityApiPermitAdapter.ts— adapts@fuzefront/auth'screateAuthzClient(AuthzClient) to the samecheck()shapePermitClientexposes. Fail-closed: no bearer token → deny (never guesses an identity); anyAuthzError/transport failure → deny.agent/authzGateway.ts— the new dependencyexecutor.tstakes instead ofPermitClientdirectly. Selectsdirect(unchanged PDP call) vswrapped(Security API) per the flag; fails closed todirecteven if the flag lookup itself throws.agent/authzFlag.ts—fuzefront.authz.chat-agent-security-api, release flag, default OFF, registered inpackages/feature-flags/flag-registry.yaml. Lazy-required@fuzefront/feature-flags, mirrorsbackend/security/src/utils/rootMembershipFlag.tsexactly (degrades to OFF if the package/provider is absent — never throws).agent/permit.ts—PermitCheckgains an optionaltokenfield (threads the caller's bearer token to whichever implementation needs it).PermitClientitself is byte-for-byte unchanged and stays live as the flag-OFF default — per the issue's explicit "keep the direct-PDP path working until the wrapped API is proven, so nothing regresses."executor.ts— forwardsPendingExecution.tokenthrough the check; widenedToolExecutorDeps.permitto a smallAuthzCheckableinterface (structurally identical to the oldPick<PermitClient,'check'>, so the existing mock-based tests are untouched and still pass).config.ts+ Helm (chat-service.yaml,values.yaml,values-prod.yaml) —SECURITY_SERVICE_URL(defaults to in-clusterfuzefront-security:3002, same conventionconfig-service/provisioning-servicealready use) + the UnleashfeatureFlagsenv block, mirroringsecurity.yaml's wiring exactly (renders nothing when unwired → safe in-code default).Both flag states are tested (
authzGateway.test.ts), including the "flag lookup itself throws" case falling back to OFF. The wrapped adapter is tested against both an injectedAuthzClient(unit) and a realcreateAuthzClient+ mockedfetch(proves the actual request shape: URL,Authorization: Bearer <token>, body).permit.test.tsandexecutor.test.tsare unchanged and still green.Deferred, and why (not folded into this PR)
services/billing-service'spermit.service.tscalls the Permit cloud API (api.users.update/api.tenants.update) to sync ABAC attributes (plan_tier,plan_status) for policy evaluation. There is no equivalent operation in the frozen Security API contract (AuthzClientcoverscheck/bulkCheck/grant/revoke/listGrants— role/permission assignment, not arbitrary subject-attribute writes). Wrapping this needs a new contract surface, which per the baseline's contract-first rule iscontract-designerterritory, not something to freehand here. Open question for the owner: should attribute-sync get a newPUT /authz/subjects/{id}/attributes-shaped endpoint on the Security API, or is ABAC attribute sync intentionally out of the Security API's scope (a different kind of "Permit fact" than an authorization decision or grant)?backend/src/config/permit.ts,backend/src/permit/*,backend/applications/src/app-registry/permit.ts— the pre-extraction monolith's own direct Permit usage.backend/securityis clearly the intended replacement (it's live, deployed, and other services already consume it), but cutting the main backend's own request path over to calling its own extracted service is the actualbackend→backend/securityextraction — a large, separate, already-implied initiative, not a same-PR change I should make unreviewed.datasets-servicemigration is explicitly out of this repo per the issue itself (tracked in a companion MendysRobotics issue).Verification
npx tsc --noEmit(chat-service) — clean.npx jest(chat-service, full suite) — 27/27 suites, 153 passed / 2 pre-existing skipped, including the 4 new/changed suites (authzGateway,authzFlag,securityApiPermitAdapter,executor,permit).npm run build -w services/chat-service(realtscbuild, not just--noEmit) — clean.helm lint deploy/helm/fuzefront -f values.yaml— clean.helm template … --output-dirfor all three overlays (values.yaml,values-local.yaml,values-prod.yaml) with every optional workload enabled (mirrorshelm-validate.yml's exact CI invocation) → piped throughkubeconform -strict -kubernetes-version 1.29.0 -skip CustomResourceDefinition,Middleware,SealedSecret→ 0 Invalid, 0 Errors on all three.SECURITY_SERVICE_URLresolves tohttp://fuzefront-security:3002in both base and prod values; the UnleashfeatureFlagsblock renders in prod (wheresecurityService.enabled: true, so the wrapped path is reachable in-cluster once the flag flips) and correctly does not render on basevalues.yaml(emptyunleashUrldefault).packages/authandpackages/feature-flagsbuilt cleanly viatsup(scoped install, not a full monorepo install, per the disk-constraint instruction).Gates
gate-authz(semgrep.semgrep/fuze-authz.yml+ OWASP/secrets packs): no new code matches any of the four seeded rules (nofindById(req.params.x), noObject.assign(obj, req.body), nojwt.sign, nobcrypt.*).@fuzefront/auth's frozenauthzTypes.tscontract is consumed as-is, not modified.services/chat-service'spackage.jsonbumped1.1.0→1.2.0(source changed) pergate-version's SemVer-bump check.Open questions for the owner
agent/executor.tsconfirm/execute path meant to be wired into a live route soon? It currently isn't reachable from any route (index.tsnever constructs aToolExecutor) — pre-existing and unrelated to feat(authz): wrap Permit behind a FuzeFront authorization API (consumer products must not call the PDP/Permit cloud directly) #254, but it means this migration, while real and tested, has no live traffic yet to prove itself against in production before the flag flips. Worth knowing before treating "flip the flag" as low-risk in practice.backend/src's (main backend, notbackend/security) own direct Permit config be tracked as an explicit follow-up issue for thebackend→backend/securityextraction, separate from feat(authz): wrap Permit behind a FuzeFront authorization API (consumer products must not call the PDP/Permit cloud directly) #254?🤖 Generated with Claude Code
https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
Generated by Claude Code