feat(security): route identity + authorization through the FuzeFront Security API - #142
Open
github-actions[bot] wants to merge 1 commit into
Open
feat(security): route identity + authorization through the FuzeFront Security API#142github-actions[bot] wants to merge 1 commit into
github-actions[bot] wants to merge 1 commit into
Conversation
…Security API
FuzeAgent now asks the platform who the caller is and what they may do, instead
of verifying tokens itself and inferring authorization from claims. It names no
identity provider and no policy engine anywhere in its user-auth path.
Backend
- services/orchestrator/fuze_security.py: provider-agnostic client for the
published FuzeFront Security contract — GET /v1/security/session,
POST /v1/security/authz/{check,bulk-check}, GET /v1/security/authz/permissions.
Fail-closed on every path: transport error, timeout, non-2xx, malformed body,
or a misaligned bulk decision list all DENY. No vendor is named or imported.
- auth.py: when FUZEFRONT_SECURITY_BASE_URL is set, `get_current_user` and
`authenticate_websocket` resolve the caller through /v1/security/session and
need no signing key at all. New async `authorize` / `require_permission` /
`require_org_permission` send FuzeAgent's OWN bare resource/action keys from
registration/policy.json, forwarding the caller's token so the decision is
made for the real subject. Legacy local JWT verification is retained as the
documented, logged fallback so standalone deployments lose nothing.
- hierarchy_endpoints.py: the two org-scoped BOLA checks now go through
`require_org_permission` (Organization:read, Team:read).
- simple_main.py: mount the global auth dependency and a non-wildcard CORS
allowlist. This fixes three tests that had been RED on main — the published
app asserted authN it did not actually have, and reflected "*" with
credentials.
Frontend
- src/lib/security/: contract types + a same-origin `/api/v1/security` client
(session, exchange, methods, social start, logout, authz check/bulk-check,
permissions) and a SecurityProvider whose `can()` is fail-closed.
- components/auth/: SignIn is a redirect surface only — it renders whatever
/v1/security/methods advertises and hands off to FuzeFront's brokered
/social/{provider}/start. No password form: collecting credentials in a
product is exactly what delegating identity prevents. AuthGate shows it only
when a security surface actually exists, so a standalone deployment is not
locked out of a UI it could previously open.
- The UI previously sent NO credentials on any API call. Every fetch and both
WebSocket paths now carry the session token (WS via the `bearer, <token>`
subprotocol, not a query param that would leak into logs).
- IdentityPage: `baseUrl: '/api/identity'` double-prefixed the identity client's
own `/api/organizations/...` paths, so every request 404'd; it also passed no
token. Fixed to same-origin + getToken, and scoped to the caller's real tenant
instead of the literal string 'default'.
Config
- FUZEFRONT_SECURITY_BASE_URL / _TIMEOUT_SECONDS / _SERVICE_TOKEN wired through
Helm, docker-compose, .env.example, README and CLAUDE.md. JWT_SECRET is now
optional in the chart — it is the fallback, not the requirement.
Machine auth is deliberately NOT changed
- deploy/helm/a2a-shared/values-prod.yaml still points A2A at an in-cluster OIDC
discovery/JWKS URL. That authenticates OTHER AGENTS calling this server, not
users, and it cannot move to the Security contract yet: the contract exposes
no JWKS/discovery endpoint. The alternative, /v1/security/tokens/introspect,
is a change to the FROZEN values-interface and is contract-designer's call.
Documented in place rather than hidden; vendor names removed from the A2A
Python code and test fixtures, which were already provider-neutral in shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
FuzeAgent knows nothing about any identity provider or policy engine. Identity and
authorization are asked of FuzeFront's Security API (
@fuzefront/security-clientcontract, same-origin
/api/v1/security), using the bare resource/action keysFuzeAgent already declares in
registration/policy.json.Inventory — before
grep -rn "authentik|permit\.io|permitio|AUTHENTIK|PERMIT"over*.ts/tsx/py/yaml/json/md/sh(excluding
node_modules) returned 17 files. Classified:grepforimport permit,permit.check,authentik_clientetc. returns nothing. FuzeAgent never called Permit.io or Authentik from code.deploy/helm/a2a-shared/values-prod.yaml,agent-templates/a2a/{runtime.py,tests/*},agent-templates/contracts/.../values-interface.schema.json,docs/a2a/*,deploy/helm/a2a-shared/GO-LIVE.md.claude/agents/{fuzefront-expert,backend-engineer,contract-designer}.md,.semgrep/fuze-authz.yml,.github/workflows/nightly-integration.ymlgovernance-sync.ymlreconciles them from the canonical repo.registration/README.md,registration/register.shagent-templates/A2A-BRIEF.md("Permitted:")The real finding is what the grep did not show. FuzeAgent's user-auth path was not
coupled to a vendor — it simply did not use the platform at all:
services/orchestrator/auth.pyverified JWTs itself against a sharedJWT_SECRET, andauthorized org access from a token claim with a standing TODO to replace it.
Authorizationheader on any API call and had no sign-in surface.pages/IdentityPage.tsxmounted@fuzefront/identity-uiwithbaseUrl: '/api/identity',double-prefixing the client's own
/api/organizations/...paths so every request 404'd —and passed no token, so a correctly-routed call would have 401'd anyway.
What changed
Backend
services/orchestrator/fuze_security.py(new) — provider-agnostic client for thepublished contract:
GET /v1/security/session,POST /v1/security/authz/check,POST /v1/security/authz/bulk-check,GET /v1/security/authz/permissions.Fail-closed on every path: transport error, timeout, non-2xx, malformed body, and a
bulk response whose decision count does not match the request all DENY. A test asserts no
vendor string appears in the module.
auth.py— withFUZEFRONT_SECURITY_BASE_URLset,get_current_userandauthenticate_websocketresolve the caller through/v1/security/sessionand needno signing key at all. New async
authorize/require_permission/require_org_permissionsend FuzeAgent's own bare keys (Organization,Team,Agent,Task,Goal×read/create/update/delete/deploy/assign) and forward thecaller's token so the decision is made for the real subject, not for FuzeAgent.
hierarchy_endpoints.py— the two org-scoped BOLA checks now go through the platform.simple_main.py— mounts the global auth dependency and a non-wildcard CORS allowlist.Frontend
src/lib/security/(new) — contract types + a same-origin client (session, exchange,methods, social start, logout, authz check/bulk-check, permissions) and a
SecurityProviderwhosecan()is fail-closed.components/auth/(new) —SignInis a redirect surface only: it renders whatever/v1/security/methodsadvertises and hands off to FuzeFront's brokered/social/{provider}/start. Deliberately no password form — collecting credentials in aproduct is what delegating identity is meant to prevent, and
.semgrep/fuze-authz.ymlforbids it.
AuthGateshows it only when a security surface actually answers.fetchand both WebSocket paths now carry the session token (WS via thebearer, <token>subprotocol, not a?token=query param that would leak into access logs).IdentityPagefixed to same-origin +getToken, scoped to the caller's real tenantinstead of the literal string
'default'.Config
FUZEFRONT_SECURITY_BASE_URL/_TIMEOUT_SECONDS/_SERVICE_TOKENwired through Helm,docker-compose.yml,.env.example,README.md,CLAUDE.md.JWT_SECRETis nowoptional in the chart — the fallback, not the requirement.
User auth vs machine auth — kept separate on purpose
The A2A block in
deploy/helm/a2a-shared/values-prod.yamlstill points at an in-clusterOIDC discovery/JWKS URL. That authenticates other agents calling this server (caller
repoclaim,aud: a2a) — not users. A machine credential is not a user session and mustnever be traded for one, so the two paths are not merged. The A2A Python
(
config.py,identity.py) was already provider-neutral in shape; the vendor names havebeen removed from its code and test fixtures, and the remaining config reference is
documented in place rather than hidden.
offline. The contract (v0.4.0) publishes
POST /v1/security/tokens/introspect, whichwould remove the coupling — but introspection is a per-request network call and a
change to the frozen
values-interface(contract v1.1.0), so it iscontract-designer's +a2a-maintainer's call, not a config edit. Either add aJWKS/discovery endpoint, or land an additive
auth.introspectionUrlthe wayoidcDiscoveryUrlwas added. No direct call was substituted to paper over this.@fuzefront/security-clientcould not be added as a dependency. It publishes to theprivate
npm.pkg.github.comregistry; this workspace has no.npmrcand CI runsnpm ciwith no credentials, so declaring it would fail every build — the same reason@fuzefront/identity-uiand the SDK are optionallyrequired. The contract types arevendored verbatim in
src/lib/security/contract.tswith a note naming the exactimport to swap in once the registry is wired. Every path and shape comes from the
published contract; none is invented.
@fuzefront/identity-uiis not a sign-in surface. It is the org-member / role /API-token management UI. The brief described it as the sign-in/sign-up redirect surface;
it is not, so sign-in was built against the contract's own social-start + exchange
endpoints instead.
identity-uipeersreact@^19;services/ui-reactis onreact@^18.3.1(with@types/react@^19already — a pre-existing mismatch). Bumping theMF singleton is a family-wide decision, not this PR's.
Nothing dropped silently
Legacy local JWT verification and the claims-based org check are retained as the
documented, logged fallback when
FUZEFRONT_SECURITY_BASE_URLis unset, so standalone andoffline deployments keep working.
AuthGaterenders the app rather than a dead end when nosecurity surface exists — the gate is cosmetic; the orchestrator still fails closed server-side.
Verification
services/orchestratorpytest (auth suites, CI collection order)maintests/(A2A)agent-templates/a2a/testsui-reactnpm run test:runtsc --noEmit·eslint·vite buildblack --check·isort --check-only·flake8helm lint(both charts)Fixed on the way: three tests were RED on
main—simple_main.pyasserted authN itdid not have and reflected
"*"with credentials. Now green.Still red (pre-existing, not caused here): 3 tests in
test_hierarchy_ws_authz.pyfailonly in combination with sibling modules — those test files set
JWT_SECRETandimportlib.reload(auth)at import time, so whichever reloads last wins. They pass inisolation, and
mainfails the same way.Not verified
authz/checkagainst a live platform, nobrowser session was exercised. Every platform interaction is proven against
httpx.MockTransport/ stubbedfetchasserting the exact contract paths and bodies.securityBaseUrlwas not reached. It needs a NetworkPolicy admittingfuzeagent -> fuzefront-backend:3001, exactly as the A2A JWKS path was admitted. That isFuzeFront/FuzeInfra's to grant — flagged in
values-prod.yaml. Until it exists, everydecision fails closed (403), not open.
@fuzefront/identity-uiwas not exercised — it cannot be installed here. ThebaseUrl/getTokenfix is verified by reading the package source in FuzeFront master,not by running it.