Skip to content

feat(security): route identity + authorization through the FuzeFront Security API - #142

Open
github-actions[bot] wants to merge 1 commit into
mainfrom
claude/fuze-security-migration
Open

feat(security): route identity + authorization through the FuzeFront Security API#142
github-actions[bot] wants to merge 1 commit into
mainfrom
claude/fuze-security-migration

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Goal

FuzeAgent knows nothing about any identity provider or policy engine. Identity and
authorization are asked of FuzeFront's Security API (@fuzefront/security-client
contract, same-origin /api/v1/security), using the bare resource/action keys
FuzeAgent 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:

Class Files Verdict
No vendor SDK anywhere grep for import permit, permit.check, authentik_client etc. returns nothing. FuzeAgent never called Permit.io or Authentik from code.
Machine-to-machine (A2A) deployment config 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 Agents authenticating to other agents. A different problem from user identity — see below.
Governance-managed (synced from FuzeSDLC) .claude/agents/{fuzefront-expert,backend-engineer,contract-designer}.md, .semgrep/fuze-authz.yml, .github/workflows/nightly-integration.yml Not editable here — governance-sync.yml reconciles them from the canonical repo.
Out of scope by instruction registration/README.md, registration/register.sh Separate workstream owns it.
False positive agent-templates/A2A-BRIEF.md ("Permitted:") The English word.

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.py verified JWTs itself against a shared JWT_SECRET, and
    authorized org access from a token claim with a standing TODO to replace it.
  • The React UI sent no Authorization header on any API call and had no sign-in surface.
  • pages/IdentityPage.tsx mounted @fuzefront/identity-ui with baseUrl: '/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 the
    published 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 — with FUZEFRONT_SECURITY_BASE_URL 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 keys (Organization, Team, Agent,
    Task, Goal × read/create/update/delete/deploy/assign) and forward the
    caller'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
    SecurityProvider whose can() is fail-closed.
  • components/auth/ (new) — SignIn is a redirect surface only: it renders whatever
    /v1/security/methods advertises and hands off to FuzeFront's brokered
    /social/{provider}/start. Deliberately no password form — collecting credentials in a
    product is what delegating identity is meant to prevent, and .semgrep/fuze-authz.yml
    forbids it. AuthGate shows it only when a security surface actually answers.
  • Every fetch and both WebSocket paths now carry the session token (WS via the
    bearer, <token> subprotocol, not a ?token= query param that would leak into access logs).
  • IdentityPage fixed to same-origin + getToken, 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.yml, .env.example, README.md, CLAUDE.md. JWT_SECRET is now
optional 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.yaml still points at an in-cluster
OIDC discovery/JWKS URL. That authenticates other agents calling this server (caller
repo claim, aud: a2a) — not users. A machine credential is not a user session and must
never 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 have
been removed from its code and test fixtures, and the remaining config reference is
documented in place rather than hidden.

⚠️ Contract gaps — reported, not worked around

  1. No JWKS/discovery endpoint in the Security contract. The A2A server validates JWTs
    offline. The contract (v0.4.0) publishes POST /v1/security/tokens/introspect, which
    would 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 is
    contract-designer's + a2a-maintainer's call, not a config edit. Either add a
    JWKS/discovery endpoint, or land an additive auth.introspectionUrl the way
    oidcDiscoveryUrl was added. No direct call was substituted to paper over this.
  2. @fuzefront/security-client could not be added as a dependency. It publishes to the
    private npm.pkg.github.com registry; this workspace has no .npmrc and CI runs
    npm ci with no credentials, so declaring it would fail every build — the same reason
    @fuzefront/identity-ui and the SDK are optionally required. The contract types are
    vendored verbatim in src/lib/security/contract.ts with a note naming the exact
    import to swap in once the registry is wired. Every path and shape comes from the
    published contract; none is invented.
  3. @fuzefront/identity-ui is 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.
  4. React 19 floor. identity-ui peers react@^19; services/ui-react is on
    react@^18.3.1 (with @types/react@^19 already — a pre-existing mismatch). Bumping the
    MF 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_URL is unset, so standalone and
offline deployments keep working. AuthGate renders the app rather than a dead end when no
security surface exists — the gate is cosmetic; the orchestrator still fails closed server-side.

Verification

Gate Result
services/orchestrator pytest (auth suites, CI collection order) 3 failed / 75 passed — vs 6 failed / 46 passed on main
tests/ (A2A) 68 passed, 22 skipped
agent-templates/a2a/tests 115 passed
ui-react npm run test:run 119 passed / 119 (34 new)
tsc --noEmit · eslint · vite build clean · 0 errors · builds
black --check · isort --check-only · flake8 clean
helm lint (both charts) pass

Fixed on the way: three tests were RED on mainsimple_main.py asserted authN it
did not have and reflected "*" with credentials. Now green.

Still red (pre-existing, not caused here): 3 tests in test_hierarchy_ws_authz.py fail
only in combination with sibling modules — those test files set JWT_SECRET and
importlib.reload(auth) at import time, so whichever reloads last wins. They pass in
isolation, and main fails the same way.

Not verified

  • No running stack. No real login, no real authz/check against a live platform, no
    browser session was exercised. Every platform interaction is proven against
    httpx.MockTransport / stubbed fetch asserting the exact contract paths and bodies.
  • The prod securityBaseUrl was not reached. It needs a NetworkPolicy admitting
    fuzeagent -> fuzefront-backend:3001, exactly as the A2A JWKS path was admitted. That is
    FuzeFront/FuzeInfra's to grant — flagged in values-prod.yaml. Until it exists, every
    decision fails closed (403), not open.
  • @fuzefront/identity-ui was not exercised — it cannot be installed here. The
    baseUrl/getToken fix is verified by reading the package source in FuzeFront master,
    not by running it.
  • No UI runtime validation (Chrome DevTools MCP) — no plugin and no running stack.

…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
@izzywdev
izzywdev marked this pull request as ready for review August 2, 2026 17:08
@izzywdev
izzywdev self-requested a review as a code owner August 2, 2026 17:08
@izzywdev izzywdev added the auto-merge label Aug 2, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant