From 1ed0c6a236ac9e844d3a84a9e3aa8d3d93d71787 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 17:06:07 +0000 Subject: [PATCH] feat(security): route identity + authorization through the FuzeFront Security API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, ` 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 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- .env.example | 15 +- CLAUDE.md | 13 +- README.md | 11 +- agent-templates/a2a/runtime.py | 3 +- agent-templates/a2a/tests/test_config.py | 4 +- .../a2a/tests/test_runtime_verifier.py | 10 +- deploy/helm/a2a-shared/values-prod.yaml | 26 +- .../fuzeagent/templates/orchestrator.yaml | 24 +- deploy/helm/fuzeagent/values-prod.yaml | 12 + deploy/helm/fuzeagent/values.yaml | 12 +- docker-compose.yml | 17 +- hierarchy_endpoints.py | 33 +- services/orchestrator/auth.py | 325 +++++++++- services/orchestrator/fuze_security.py | 364 +++++++++++ services/orchestrator/simple_main.py | 21 +- .../tests/test_platform_security.py | 596 ++++++++++++++++++ services/ui-react/src/App.tsx | 4 +- .../ui-react/src/components/auth/AuthGate.tsx | 81 +++ .../ui-react/src/components/auth/SignIn.tsx | 89 +++ services/ui-react/src/config/api.ts | 36 +- services/ui-react/src/hooks/useWebSocket.tsx | 10 +- .../lib/security/SecurityProvider.test.tsx | 296 +++++++++ .../src/lib/security/SecurityProvider.tsx | 161 +++++ .../ui-react/src/lib/security/client.test.ts | 292 +++++++++ services/ui-react/src/lib/security/client.ts | 296 +++++++++ .../ui-react/src/lib/security/contract.ts | 170 +++++ services/ui-react/src/main.tsx | 13 +- services/ui-react/src/pages/IdentityPage.tsx | 63 +- 28 files changed, 2919 insertions(+), 78 deletions(-) create mode 100644 services/orchestrator/fuze_security.py create mode 100644 services/orchestrator/tests/test_platform_security.py create mode 100644 services/ui-react/src/components/auth/AuthGate.tsx create mode 100644 services/ui-react/src/components/auth/SignIn.tsx create mode 100644 services/ui-react/src/lib/security/SecurityProvider.test.tsx create mode 100644 services/ui-react/src/lib/security/SecurityProvider.tsx create mode 100644 services/ui-react/src/lib/security/client.test.ts create mode 100644 services/ui-react/src/lib/security/client.ts create mode 100644 services/ui-react/src/lib/security/contract.ts diff --git a/.env.example b/.env.example index 3f69451..c1211bf 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,20 @@ REDIS_PASSWORD=your-secure-redis-password ANTHROPIC_API_KEY=your-anthropic-api-key-here OPENAI_API_KEY=your-openai-api-key-here -# Security +# Security — identity and authorization come from the FuzeFront Security API. +# Set FUZEFRONT_SECURITY_BASE_URL to the FuzeFront API base (INCLUDING /api) and +# FuzeAgent resolves callers via GET /v1/security/session and asks +# POST /v1/security/authz/check for every decision. It then needs NO signing key, +# and knows nothing about whichever identity or policy engine sits behind that API. +# e.g. http://fuzefront-backend.fuzefront.svc.cluster.local:3001/api +FUZEFRONT_SECURITY_BASE_URL= +FUZEFRONT_SECURITY_TIMEOUT_SECONDS=5 +# Machine-to-machine token for calls FuzeAgent makes on its OWN behalf (agents +# talking to services), NOT for user identity. Leave empty unless issued one. +FUZEFRONT_SECURITY_SERVICE_TOKEN= + +# Legacy local-verification fallback. Used ONLY when FUZEFRONT_SECURITY_BASE_URL +# is empty (standalone / offline). Prefer the platform. JWT_SECRET=your-jwt-secret-key ENCRYPTION_KEY=your-base64-encoded-encryption-key diff --git a/CLAUDE.md b/CLAUDE.md index 17c3b1a..5940d68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -600,7 +600,12 @@ RABBITMQ_URL=amqp://admin:password@rabbitmq:5672/ # Cache REDIS_URL=redis://redis:6379 -# Security +# Security — identity + authorization delegated to the FuzeFront Security API. +# Set this to the FuzeFront API base INCLUDING /api and the orchestrator resolves +# callers via GET /v1/security/session and asks POST /v1/security/authz/check for +# every decision; it then needs no signing key and names no identity/policy vendor. +FUZEFRONT_SECURITY_BASE_URL= +# Legacy local-verification fallback, used only when the above is empty. JWT_SECRET=your-jwt-secret ``` @@ -659,7 +664,11 @@ JWT_SECRET=your-jwt-secret ## Security Best Practices ### API Security -- JWT-based authentication for all endpoints +- Authentication on all endpoints. Identity is resolved by the FuzeFront Security + API (`GET /v1/security/session`) when `FUZEFRONT_SECURITY_BASE_URL` is configured; + local JWT verification is the standalone fallback. Authorization decisions come + from `POST /v1/security/authz/check` using the bare resource/action keys declared + in `registration/policy.json` — FuzeAgent ships no policy engine and calls none. - Rate limiting on agent creation and task assignment - Input validation for all agent configurations - Audit logging for administrative actions diff --git a/README.md b/README.md index ba5864f..eaa3fec 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,16 @@ See `.env.example` for all configuration options: - `ANTHROPIC_API_KEY`: Your Claude API key (required) - `POSTGRES_PASSWORD`: Database password (auto-generated) - `RABBITMQ_PASSWORD`: Message queue password (auto-generated) -- `JWT_SECRET`: Security token secret (auto-generated) +- `FUZEFRONT_SECURITY_BASE_URL`: FuzeFront API base **including `/api`** (e.g. + `http://fuzefront-backend.fuzefront.svc.cluster.local:3001/api`). When set, + identity and authorization come from the FuzeFront Security API — FuzeAgent + resolves callers via `GET /v1/security/session` and asks + `POST /v1/security/authz/check` for every decision, so it holds no signing key + and knows nothing about whichever identity or policy engine sits behind it. +- `FUZEFRONT_SECURITY_SERVICE_TOKEN`: optional machine-to-machine token for calls + FuzeAgent makes on its **own** behalf (not a user's). Distinct from user identity. +- `JWT_SECRET`: legacy local-verification fallback, used **only** when + `FUZEFRONT_SECURITY_BASE_URL` is empty (standalone/offline) (auto-generated) ### Claude Code Configuration The system uses both global and project-specific Claude Code configurations: diff --git a/agent-templates/a2a/runtime.py b/agent-templates/a2a/runtime.py index 074bf33..b83465e 100644 --- a/agent-templates/a2a/runtime.py +++ b/agent-templates/a2a/runtime.py @@ -116,7 +116,8 @@ def _resolve_jwks_url(auth, discovery_fetcher) -> str: """Decide where signing keys are fetched from. * ``oidc_discovery_url`` set -> fetch that discovery document and use its - ``jwks_uri`` (typically an in-cluster Authentik URL). Keys come from in-cluster. + ``jwks_uri`` (typically an in-cluster identity-provider URL — the provider is + deployment config, never named here). Keys come from in-cluster. * ``oidc_discovery_url`` unset -> issuer-derived certs path — the UNCHANGED default. Both the discovery URL and the ``jwks_uri`` it yields are scheme-guarded to http(s) diff --git a/agent-templates/a2a/tests/test_config.py b/agent-templates/a2a/tests/test_config.py index cca1cc1..9f8bb01 100644 --- a/agent-templates/a2a/tests/test_config.py +++ b/agent-templates/a2a/tests/test_config.py @@ -92,7 +92,7 @@ def test_oidc_discovery_url_parsed_when_set(): "auth": { "oidcIssuerUrl": "https://auth.prod.fuzefront.com", "oidcDiscoveryUrl": ( - "http://authentik-server.identity.svc.cluster.local:9000" + "http://idp-server.identity.svc.cluster.local:9000" "/application/o/fuzeagent-a2a/.well-known/openid-configuration" ), }, @@ -101,6 +101,6 @@ def test_oidc_discovery_url_parsed_when_set(): ) assert cfg.auth.oidc_issuer_url == "https://auth.prod.fuzefront.com" assert cfg.auth.oidc_discovery_url == ( - "http://authentik-server.identity.svc.cluster.local:9000" + "http://idp-server.identity.svc.cluster.local:9000" "/application/o/fuzeagent-a2a/.well-known/openid-configuration" ) diff --git a/agent-templates/a2a/tests/test_runtime_verifier.py b/agent-templates/a2a/tests/test_runtime_verifier.py index 8ef9053..c18a279 100644 --- a/agent-templates/a2a/tests/test_runtime_verifier.py +++ b/agent-templates/a2a/tests/test_runtime_verifier.py @@ -15,10 +15,10 @@ ISSUER = "https://auth.prod.fuzefront.com" DISCOVERY = ( - "http://authentik-server.identity.svc.cluster.local:9000" + "http://idp-server.identity.svc.cluster.local:9000" "/application/o/fuzeagent-a2a/.well-known/openid-configuration" ) -IN_CLUSTER_JWKS = "http://authentik-server.identity.svc.cluster.local:9000/application/o/fuzeagent-a2a/jwks/" +IN_CLUSTER_JWKS = "http://idp-server.identity.svc.cluster.local:9000/application/o/fuzeagent-a2a/jwks/" class _FakeSigningKey: @@ -71,7 +71,7 @@ def test_discovery_override_is_used_for_key_fetch_when_set(): def discovery_fetcher(url: str) -> dict: fetched.append(url) - return {"issuer": "http://authentik-internal", "jwks_uri": IN_CLUSTER_JWKS} + return {"issuer": "http://idp-internal", "jwks_uri": IN_CLUSTER_JWKS} verify = _build_verifier( _config(discovery_url=DISCOVERY), @@ -114,7 +114,7 @@ def test_iss_still_validated_against_issuer_even_with_discovery_override(): jwk_client_factory=_FakeJwkClient, discovery_fetcher=lambda url: {"jwks_uri": IN_CLUSTER_JWKS}, decoder=_claims_decoder( - {"iss": "http://authentik-server.identity.svc.cluster.local:9000", "sub": "FuzeSales"} + {"iss": "http://idp-server.identity.svc.cluster.local:9000", "sub": "FuzeSales"} ), ) with pytest.raises(Exception): @@ -151,7 +151,7 @@ def test_no_auth_returns_none_fail_closed(): "bad_url", [ "file:///etc/passwd", - "ftp://authentik/discovery", + "ftp://idp/discovery", "/etc/passwd", # no scheme ], ) diff --git a/deploy/helm/a2a-shared/values-prod.yaml b/deploy/helm/a2a-shared/values-prod.yaml index e4a2f9d..f54c0e1 100644 --- a/deploy/helm/a2a-shared/values-prod.yaml +++ b/deploy/helm/a2a-shared/values-prod.yaml @@ -28,7 +28,31 @@ a2a: protocolVersion: "1.0" - # Identity (authz.md). The family OIDC issuer is FuzeFront's Authentik. + # =========================================================================== + # MACHINE-TO-MACHINE identity — NOT user identity. + # =========================================================================== + # These values authenticate OTHER AGENTS calling this A2A server (caller `repo` + # claim, `aud: a2a`). That is a different problem from user sign-in, which is + # delegated wholesale to the FuzeFront Security API and names no vendor + # (services/orchestrator/fuze_security.py, services/ui-react/src/lib/security/). + # Conflating the two would be a security mistake: a machine credential is not a + # user session and must never be traded for one. + # + # This block is the one place FuzeAgent still names an identity product, and it + # is DEPLOYMENT CONFIG, not code — agent-templates/a2a/{config,identity}.py are + # already provider-neutral (`oidcIssuerUrl` / `oidcDiscoveryUrl` / `callerClaim`). + # + # WHY IT CANNOT MOVE TO THE SECURITY CONTRACT YET — a genuine contract gap: + # the A2A server validates JWTs offline by fetching OIDC discovery + JWKS, and + # the FuzeFront Security contract (v0.4.0) exposes NO discovery or JWKS endpoint. + # It does publish POST /v1/security/tokens/introspect, which would remove this + # coupling entirely — 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 to the Security contract, or land an additive + # `auth.introspectionUrl` the way `oidcDiscoveryUrl` was added in v1.1.0. + # + # Identity (authz.md). The family OIDC issuer is FuzeFront's. # Source: FuzeFront deploy/helm/fuzefront/values-prod.yaml (issuerUrl). Prod token `iss` # uses the app.fuzefront.com host (Host-forwarding), not the auth.fuzefront.com admin host. auth: diff --git a/deploy/helm/fuzeagent/templates/orchestrator.yaml b/deploy/helm/fuzeagent/templates/orchestrator.yaml index 7941cc8..1cdb0ce 100644 --- a/deploy/helm/fuzeagent/templates/orchestrator.yaml +++ b/deploy/helm/fuzeagent/templates/orchestrator.yaml @@ -67,13 +67,33 @@ spec: name: {{ include "fuzeagent.secretName" . }} key: ENCRYPTION_KEY optional: true - # main:app enforces global auth (Depends(get_current_user)) and fails - # CLOSED without JWT material (PR #6/#7). JWT_SECRET is required. + # Identity + authorization delegated to the FuzeFront Security API. + # When securityBaseUrl is set the orchestrator resolves callers via + # GET /v1/security/session and asks POST /v1/security/authz/check for + # every decision — no signing key, and no identity/policy vendor, is + # known to FuzeAgent. Leave empty to keep the legacy local-JWT path. + {{- with .Values.orchestrator.auth.securityBaseUrl }} + - name: FUZEFRONT_SECURITY_BASE_URL + value: {{ . | quote }} + {{- end }} + - name: FUZEFRONT_SECURITY_TIMEOUT_SECONDS + value: {{ .Values.orchestrator.auth.securityTimeoutSeconds | quote }} + # Optional machine token for calls FuzeAgent makes on its OWN behalf + # (not on behalf of a signed-in user). Distinct from user identity. + - name: FUZEFRONT_SECURITY_SERVICE_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "fuzeagent.secretName" . }} + key: FUZEFRONT_SECURITY_SERVICE_TOKEN + optional: true + # Legacy fallback: used ONLY when FUZEFRONT_SECURITY_BASE_URL is unset. + # main:app fails CLOSED without one of the two (PR #6/#7). - name: JWT_SECRET valueFrom: secretKeyRef: name: {{ include "fuzeagent.secretName" . }} key: JWT_SECRET + optional: true - name: JWT_ALGORITHM value: {{ .Values.orchestrator.auth.jwtAlgorithm | quote }} - name: JWT_AUDIENCE diff --git a/deploy/helm/fuzeagent/values-prod.yaml b/deploy/helm/fuzeagent/values-prod.yaml index 83c11de..6210734 100644 --- a/deploy/helm/fuzeagent/values-prod.yaml +++ b/deploy/helm/fuzeagent/values-prod.yaml @@ -25,6 +25,18 @@ orchestrator: # the orchestrator, so the browser Origin is the ingress host). runMigrations: true auth: + # Identity + authorization are the FuzeFront platform's. The orchestrator calls + # the FuzeFront Security API in-cluster (GET /v1/security/session, + # POST /v1/security/authz/check) — it holds no signing key and names no + # identity/policy vendor. The public app.fuzefront.com host is deliberately NOT + # used: server-to-server traffic stays inside the cluster. + # + # PREREQ (FuzeFront/FuzeInfra, delegate via @claude): a NetworkPolicy admitting + # fuzeagent -> fuzefront-backend:3001, alongside the existing allowances, exactly + # as the A2A JWKS path was admitted. Without it every decision fails CLOSED + # (403) rather than degrading open. + securityBaseUrl: "http://fuzefront-backend.fuzefront.svc.cluster.local:3001/api" + securityTimeoutSeconds: 5 corsAllowOrigins: "https://fuzeagent.prod.fuzefront.com" hierarchyApi: diff --git a/deploy/helm/fuzeagent/values.yaml b/deploy/helm/fuzeagent/values.yaml index 9a5792f..e2ce001 100644 --- a/deploy/helm/fuzeagent/values.yaml +++ b/deploy/helm/fuzeagent/values.yaml @@ -40,8 +40,18 @@ orchestrator: fileOps: false mcp: true runMigrations: true - # main:app fails closed without JWT material; CORS must be non-wildcard. + # Identity + authorization come from the FuzeFront Security API when + # `securityBaseUrl` is set: the orchestrator resolves callers via + # GET /v1/security/session and asks POST /v1/security/authz/check for every + # decision, so it holds no signing key and names no identity/policy vendor. + # Empty here because the default chart is the standalone/local shape — the + # platform value lives in values-prod.yaml. + # main:app fails closed without EITHER a security base URL or JWT material; + # CORS must be non-wildcard. auth: + securityBaseUrl: "" + securityTimeoutSeconds: 5 + # Legacy local-verification fallback — used only when securityBaseUrl is empty. jwtAlgorithm: HS256 jwtAudience: "" jwtIssuer: "" diff --git a/docker-compose.yml b/docker-compose.yml index 495a8aa..e84a903 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,10 +58,21 @@ services: ENABLE_MCP_INTEGRATION: "true" # Security and encryption ENCRYPTION_KEY: ${ENCRYPTION_KEY} - # Authentication (issue #6 / PR #7): JWT verification material so the + # Identity + authorization via the FuzeFront Security API. Set this to the + # FuzeFront API base (including /api) and the orchestrator resolves callers + # with GET /v1/security/session and asks POST /v1/security/authz/check for + # every decision — no signing key, no identity/policy vendor. Empty by + # default so a standalone `docker compose up` keeps working on the legacy + # local-verification path below. + FUZEFRONT_SECURITY_BASE_URL: ${FUZEFRONT_SECURITY_BASE_URL:-} + FUZEFRONT_SECURITY_TIMEOUT_SECONDS: ${FUZEFRONT_SECURITY_TIMEOUT_SECONDS:-5} + # Machine token for calls FuzeAgent makes on its OWN behalf (not a user's). + FUZEFRONT_SECURITY_SERVICE_TOKEN: ${FUZEFRONT_SECURITY_SERVICE_TOKEN:-} + # Legacy fallback (issue #6 / PR #7): local JWT verification material so the # global Depends(get_current_user) fails CLOSED (401) for unauthenticated - # requests on the published :8000 surface. Required — the app rejects all - # non-public routes when no secret/key is configured. + # requests on the published :8000 surface. Used ONLY when + # FUZEFRONT_SECURITY_BASE_URL is empty — the app rejects all non-public + # routes when neither is configured. JWT_SECRET: ${JWT_SECRET} JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} JWT_AUDIENCE: ${JWT_AUDIENCE:-} diff --git a/hierarchy_endpoints.py b/hierarchy_endpoints.py index acaa613..8d71891 100644 --- a/hierarchy_endpoints.py +++ b/hierarchy_endpoints.py @@ -22,12 +22,21 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5434/ai_context") ORCHESTRATOR_URL = os.getenv("ORCHESTRATOR_URL", "http://localhost:8000") +# Identity + authorization come from the FuzeFront Security API when the platform is +# configured (services/orchestrator/fuze_security.py); `require_org_permission` asks it +# for the decision using FuzeAgent's own bare policy keys from registration/policy.json, +# and degrades to the legacy claim check only when the platform is absent. try: - from auth import get_current_user, require_user, require_org_access, CurrentUser, authenticate_websocket + from auth import ( + ACTION_READ, RESOURCE_ORGANIZATION, RESOURCE_TEAM, + get_current_user, require_user, require_org_access, require_org_permission, + CurrentUser, authenticate_websocket, + ) except Exception: # pragma: no cover - allow import from repo root or service dir from services.orchestrator.auth import ( # type: ignore - get_current_user, require_user, require_org_access, CurrentUser, - authenticate_websocket, + ACTION_READ, RESOURCE_ORGANIZATION, RESOURCE_TEAM, + get_current_user, require_user, require_org_access, require_org_permission, + CurrentUser, authenticate_websocket, ) @asynccontextmanager @@ -205,8 +214,11 @@ async def get_organization( user: CurrentUser = Depends(require_user), ): # SECURITY (issue #6 HIGH-2 / BOLA): authorize the specific org id from the - # path; bare ``WHERE id = $1`` is not an authorization boundary. - require_org_access(organization_id, user) + # path; bare ``WHERE id = $1`` is not an authorization boundary. The decision is + # the platform's — Organization:read scoped to this org id as the tenant. + await require_org_permission( + organization_id, user, ACTION_READ, resource=RESOURCE_ORGANIZATION + ) async with db_pool.acquire() as conn: row = await conn.fetchrow(""" SELECT @@ -321,8 +333,15 @@ async def get_team( if not row: raise HTTPException(status_code=404, detail="Team not found") - # SECURITY (issue #6 HIGH-2): authorize via the team's parent org. - require_org_access(row['organization_id'], user) + # SECURITY (issue #6 HIGH-2): authorize via the team's parent org — the org + # is the tenant scope, the team instance is the resource key. + await require_org_permission( + row['organization_id'], + user, + ACTION_READ, + resource=RESOURCE_TEAM, + resource_key=team_id, + ) return Team( id=row['id'], organization_id=row['organization_id'], diff --git a/services/orchestrator/auth.py b/services/orchestrator/auth.py index b434dcb..68f767d 100644 --- a/services/orchestrator/auth.py +++ b/services/orchestrator/auth.py @@ -1,6 +1,20 @@ """ Authentication and authorization for the FuzeAgent orchestrator API. +Identity and authorization are the **FuzeFront platform's** to answer, not FuzeAgent's. +When ``FUZEFRONT_SECURITY_BASE_URL`` is configured this module resolves the caller by +asking the FuzeFront Security API (``GET /v1/security/session``) and asks that same API +for authorization decisions (``POST /v1/security/authz/check``) using the bare +resource/action keys FuzeAgent declares in ``registration/policy.json``. No identity +provider and no policy engine is named, imported, or configured anywhere in FuzeAgent — +see ``fuze_security.py``. + +Legacy mode (platform not configured): when ``FUZEFRONT_SECURITY_BASE_URL`` is unset the +module keeps its previous behaviour — local HS*/RS* token verification against +``JWT_SECRET``/``JWT_PUBLIC_KEY`` plus claims-based org scoping. That path is retained +deliberately so standalone/offline deployments do not lose capability. It is logged, and +it is the fallback, never the preference. + This module closes the CRITICAL authorization gaps reported in izzywdev/FuzeAgent#6 (appsec BOLA/authz audit). It provides: @@ -28,15 +42,20 @@ Configuration (env) ------------------- - JWT_SECRET HMAC secret for HS* tokens (required in prod). - JWT_PUBLIC_KEY PEM public key for RS*/ES* tokens (alternative). - JWT_ALGORITHM Default "HS256". - JWT_AUDIENCE Optional expected audience. - JWT_ISSUER Optional expected issuer. - AUTH_DISABLED If "true" AND no secret/key is configured, auth is - bypassed *only* for local dev. This NEVER bypasses in - production where a secret/key is set. It is logged - loudly. Do not set this in any deployed environment. + FUZEFRONT_SECURITY_BASE_URL + Absolute FuzeFront API base including ``/api``. When set, + identity + authorization come from the platform and NONE of the + JWT_* settings below are consulted or required. + JWT_SECRET Legacy mode only. HMAC secret for HS* tokens. + JWT_PUBLIC_KEY Legacy mode only. PEM public key for RS*/ES* tokens. + JWT_ALGORITHM Legacy mode only. Default "HS256". + JWT_AUDIENCE Legacy mode only. Optional expected audience. + JWT_ISSUER Legacy mode only. Optional expected issuer. + AUTH_DISABLED If "true" AND neither the platform nor a secret/key is + configured, auth is bypassed *only* for local dev. This NEVER + bypasses in production where the platform or a secret/key is + set. It is logged loudly. Do not set this in any deployed + environment. """ from __future__ import annotations @@ -54,6 +73,14 @@ jwt = None # type: ignore JWTError = Exception # type: ignore +try: # the FuzeFront Security client — the only auth dependency we want to have + from . import fuze_security # type: ignore[attr-defined] +except (ImportError, ValueError): # pragma: no cover - flat sys.path import (tests/app) + try: + import fuze_security # type: ignore[no-redef] + except Exception: # pragma: no cover + fuze_security = None # type: ignore[assignment] + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -67,6 +94,35 @@ JWT_ISSUER = os.getenv("JWT_ISSUER") or None _AUTH_DISABLED = os.getenv("AUTH_DISABLED", "false").lower() == "true" + +def platform_security_enabled() -> bool: + """Whether identity/authorization is delegated to the FuzeFront Security API. + + Read live (not cached at import) so a deployment can turn the platform path on + without a code change and so tests can toggle it. + """ + return bool(fuze_security is not None and fuze_security.is_configured()) + + +# --------------------------------------------------------------------------- +# Policy vocabulary — the BARE keys FuzeAgent registers in registration/policy.json. +# These are the strings sent as `resource.type` / `action` to the platform's +# /v1/security/authz/check. They are FuzeAgent's own vocabulary, not any engine's. +# --------------------------------------------------------------------------- + +RESOURCE_ORGANIZATION = "Organization" +RESOURCE_TEAM = "Team" +RESOURCE_AGENT = "Agent" +RESOURCE_TASK = "Task" +RESOURCE_GOAL = "Goal" + +ACTION_READ = "read" +ACTION_CREATE = "create" +ACTION_UPDATE = "update" +ACTION_DELETE = "delete" +ACTION_DEPLOY = "deploy" +ACTION_ASSIGN = "assign" + # Explicit public allowlist — these paths are reachable WITHOUT a token. # Keep this list as small as possible; everything else is authenticated. PUBLIC_PATHS: Set[str] = { @@ -120,13 +176,24 @@ def is_public_path(path: str) -> bool: class CurrentUser: - """A verified principal extracted from the JWT. + """A verified principal. + + Produced either from the FuzeFront Security API's normalized ``Identity`` + (platform mode) or from locally verified JWT claims (legacy mode). Both paths + populate the SAME fields, so handlers cannot tell — and must not care — which + verifier ran. - Carries the claims needed for object-level authorization decisions. + Carries the claims needed for object-level authorization decisions, plus the + caller's raw bearer token so platform authorization checks can be made *as the + real subject* rather than as FuzeAgent. """ - def __init__(self, claims: Dict[str, Any]): + def __init__(self, claims: Dict[str, Any], token: Optional[str] = None): self.claims = claims + #: The raw bearer token this principal presented, when available. Forwarded to + #: the platform's /v1/security/authz/check so the decision is made for the + #: caller. Never logged, never persisted. + self.token: Optional[str] = token # ------------------------------------------------------------------ # @fuzefront/auth Identity-aligned fields (userId, tenantId, roles, # email, authMode, issuedAt, expiresAt, issuer). @@ -178,6 +245,16 @@ def __init__(self, claims: Dict[str, Any]): self.expires_at: Optional[int] = claims.get("exp") self.issuer: Optional[str] = claims.get("iss") + @classmethod + def from_identity(cls, identity: Any, token: Optional[str] = None) -> "CurrentUser": + """Build a principal from the platform's normalized ``Identity``. + + ``identity`` is a ``fuze_security.Identity``. Its ``as_claims()`` projection uses + exactly the claim names this class already reads, so platform-mode and legacy-mode + principals are indistinguishable downstream. + """ + return cls(identity.as_claims(), token=token) + def can_access_org(self, organization_id: str) -> bool: """Object-level check: may this principal act on ``organization_id``? @@ -273,6 +350,31 @@ def _bearer_from_request(request: Request) -> Optional[str]: return None +async def _principal_from_token(token: str) -> Optional[CurrentUser]: + """Resolve a bearer token to a principal, preferring the FuzeFront platform. + + Platform mode (``FUZEFRONT_SECURITY_BASE_URL`` set): the token is handed to + ``GET /v1/security/session`` and the platform answers with the normalized + ``Identity``. FuzeAgent holds no signing key and knows nothing about how the token + was minted or by whom. + + Legacy mode: the token is verified locally against ``JWT_SECRET``/``JWT_PUBLIC_KEY``, + exactly as before. + + Returns ``None`` when the token cannot be resolved (fail-closed). Raises the same + 401 ``HTTPException`` as before on a locally-rejected token so legacy call sites see + unchanged behaviour. + """ + if platform_security_enabled(): + identity = await fuze_security.get_session(token) # type: ignore[union-attr] + if identity is None: + return None + return CurrentUser.from_identity(identity, token=token) + + claims = _decode_token(token) + return CurrentUser(claims, token=token) + + async def get_current_user( request: Request = None, # type: ignore[assignment] websocket: WebSocket = None, # type: ignore[assignment] @@ -297,19 +399,24 @@ async def get_current_user( if is_public_path(request.url.path): return None - # Local-dev escape hatch — only when NO verification material is configured. - # In any prod-like environment a secret/key is set and this never triggers. - if _AUTH_DISABLED and not _auth_configured(): + platform = platform_security_enabled() + + # Local-dev escape hatch — only when NEITHER the platform NOR verification + # material is configured. In any prod-like environment one of them is set and + # this never triggers. + if _AUTH_DISABLED and not platform and not _auth_configured(): logger.warning( - "AUTH_DISABLED is set and no JWT secret/key configured — " - "authentication is BYPASSED. This must never happen in production." + "AUTH_DISABLED is set with no platform security base URL and no JWT " + "secret/key configured — authentication is BYPASSED. This must never " + "happen in production." ) return CurrentUser({"sub": "dev-bypass", "is_admin": True}) # Fail closed if auth is not configured in a deployed environment. - if not _auth_configured(): + if not platform and not _auth_configured(): logger.error( - "No JWT_SECRET/JWT_PUBLIC_KEY configured; rejecting request to %s", + "Neither FUZEFRONT_SECURITY_BASE_URL nor JWT_SECRET/JWT_PUBLIC_KEY is " + "configured; rejecting request to %s", request.url.path, ) raise _UNAUTHENTICATED @@ -318,12 +425,11 @@ async def get_current_user( if not token: raise _UNAUTHENTICATED - claims = _decode_token(token) - user = CurrentUser(claims) - if not user.id: + user = await _principal_from_token(token) + if user is None or not user.id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token missing subject claim", + detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) # Stash for downstream handlers / logging. @@ -367,6 +473,150 @@ async def handler(organization_id: str, return user +# --------------------------------------------------------------------------- +# Platform authorization — decisions come from FuzeFront, never from a local policy +# --------------------------------------------------------------------------- +# +# `authorize` / `require_permission` are the preferred authorization API. They send +# FuzeAgent's OWN bare resource/action keys (registration/policy.json) to the platform's +# /v1/security/authz/check as the CALLER's subject, so the policy that decides lives +# with the platform and FuzeAgent ships none. +# +# Legacy fallback: when the platform is not configured they degrade to the pre-existing +# claims/role checks so a standalone deployment keeps working. This is a documented +# downgrade, not a silent one — it is logged once per process. + +_LEGACY_AUTHZ_WARNED = False + + +def _warn_legacy_authz_once() -> None: + global _LEGACY_AUTHZ_WARNED + if not _LEGACY_AUTHZ_WARNED: + _LEGACY_AUTHZ_WARNED = True + logger.warning( + "FUZEFRONT_SECURITY_BASE_URL is not configured — authorization is falling " + "back to local token-claim checks. Platform policy (registration/policy.json) " + "is NOT being consulted." + ) + + +async def authorize( + user: CurrentUser, + resource: str, + action: str, + *, + tenant: Optional[str] = None, + resource_key: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, +) -> bool: + """Ask FuzeFront whether ``user`` may perform ``action`` on ``resource``. + + ``resource``/``action`` are FuzeAgent's bare policy keys (``RESOURCE_*``/``ACTION_*`` + above), matching ``registration/policy.json``. ``tenant`` defaults to the principal's + own tenant; a tenant-scoped decision with no resolvable tenant is DENIED (the + platform contract's documented fail-closed behaviour for a null tenant). + + Returns ``True``/``False``. Never raises for a denial. + """ + scope = tenant or user.tenant_id + if platform_security_enabled(): + if not scope: + logger.info( + "Denying %s:%s for %s — no tenant scope resolvable (fail-closed)", + resource, + action, + user.id, + ) + return False + return await fuze_security.authz_check( # type: ignore[union-attr] + subject=user.id, + tenant=scope, + resource=resource, + action=action, + resource_key=resource_key, + context=context, + token=user.token, + ) + + _warn_legacy_authz_once() + # Legacy: admins/service principals pass; otherwise the caller must be scoped to the + # tenant. This is strictly the pre-migration behaviour — no new capability. + if user.is_admin or user.is_service: + return True + return bool(scope) and user.can_access_org(scope) + + +async def require_permission( + user: CurrentUser, + resource: str, + action: str, + *, + tenant: Optional[str] = None, + resource_key: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, +) -> CurrentUser: + """:func:`authorize`, but raises 403 on denial. Use this in handlers.""" + allowed = await authorize( + user, + resource, + action, + tenant=tenant, + resource_key=resource_key, + context=context, + ) + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Not authorized to {action} {resource}", + ) + return user + + +async def require_org_permission( + organization_id: str, + user: CurrentUser, + action: str = ACTION_READ, + *, + resource: str = RESOURCE_ORGANIZATION, + resource_key: Optional[str] = None, +) -> CurrentUser: + """Org-scoped object-level authorization, decided by the platform. + + The async replacement for :func:`require_org_access`. The org id from the path is the + tenant scope, so a principal is authorized *for that specific organization* rather + than merely "logged in". In legacy mode this reduces exactly to the previous + ``require_org_access`` claim check, so no capability is lost when the platform is + absent. + """ + if not platform_security_enabled(): + _warn_legacy_authz_once() + user.require_org_access(organization_id) + return user + return await require_permission( + user, + resource, + action, + tenant=organization_id, + resource_key=resource_key or organization_id, + ) + + +async def effective_permissions( + user: CurrentUser, tenant: Optional[str] = None +) -> List[str]: + """The caller's effective ``Resource:action`` grants, for UI capability gating. + + Empty list when the platform is not configured or the call fails — read that as + "no permissions known", never as "unrestricted". + """ + scope = tenant or user.tenant_id + if not platform_security_enabled() or not scope: + return [] + return await fuze_security.get_permissions( # type: ignore[union-attr] + subject=user.id, tenant=scope, token=user.token + ) + + # --------------------------------------------------------------------------- # WebSocket authentication # --------------------------------------------------------------------------- @@ -417,19 +667,29 @@ async def authenticate_websocket(websocket: WebSocket) -> Optional[CurrentUser]: ``accept()``. On failure it closes the socket (code 1008) and returns ``None`` — the handler must ``return`` immediately when it gets ``None``. + Identity resolution is the same as HTTP: the FuzeFront Security API when the + platform is configured, local verification otherwise. + The local-dev escape hatch mirrors :func:`get_current_user`: it only ever - bypasses when ``AUTH_DISABLED`` is set AND no verification material is - configured (never in a deployed environment). + bypasses when ``AUTH_DISABLED`` is set AND neither the platform nor verification + material is configured (never in a deployed environment). """ - # Local-dev bypass — only when NO secret/key configured. - if _AUTH_DISABLED and not _auth_configured(): + platform = platform_security_enabled() + + # Local-dev bypass — only when NEITHER the platform NOR a secret/key is configured. + if _AUTH_DISABLED and not platform and not _auth_configured(): logger.warning( - "AUTH_DISABLED set and no JWT material — WS authentication BYPASSED." + "AUTH_DISABLED set with no platform security base URL and no JWT material " + "— WS authentication BYPASSED." ) return CurrentUser({"sub": "dev-bypass", "is_admin": True}) - if not _auth_configured(): - logger.error("No JWT material configured; rejecting WS %s", websocket.url.path) + if not platform and not _auth_configured(): + logger.error( + "Neither FUZEFRONT_SECURITY_BASE_URL nor JWT material configured; " + "rejecting WS %s", + websocket.url.path, + ) await websocket.close(code=status.WS_1008_POLICY_VIOLATION) return None @@ -439,14 +699,13 @@ async def authenticate_websocket(websocket: WebSocket) -> Optional[CurrentUser]: return None try: - claims = _decode_token(token) + user = await _principal_from_token(token) except HTTPException: # Invalid/expired token — close instead of raising (no HTTP response on WS). await websocket.close(code=status.WS_1008_POLICY_VIOLATION) return None - user = CurrentUser(claims) - if not user.id: + if user is None or not user.id: await websocket.close(code=status.WS_1008_POLICY_VIOLATION) return None return user diff --git a/services/orchestrator/fuze_security.py b/services/orchestrator/fuze_security.py new file mode 100644 index 0000000..db4ddf0 --- /dev/null +++ b/services/orchestrator/fuze_security.py @@ -0,0 +1,364 @@ +"""FuzeFront Security API client — FuzeAgent's ONLY identity/authorization dependency. + +Provider-agnostic by construction. This module speaks the FuzeFront-owned Security +contract (``@fuzefront/security-client`` / ``openapi.yaml``, served same-origin under +``/api/v1/security``) and names **no** identity provider and **no** policy engine. +Whichever federation/MFA engine or ReBAC engine FuzeFront runs behind that contract is +invisible here — swapping it must not require a change in this file. + +Endpoints used (all published in the FuzeFront Security contract v0.4.0): + + ``GET /v1/security/session`` -> normalized ``Identity`` for a token + ``POST /v1/security/authz/check`` -> ``{ allow: bool }`` + ``POST /v1/security/authz/bulk-check`` -> ``{ decisions: [{ allow }] }`` + ``GET /v1/security/authz/permissions`` -> ``{ permissions: ["Resource:action"] }`` + +The ``resource``/``action`` keys sent to ``authz/check`` are the **bare keys FuzeAgent +already declares in** ``registration/policy.json`` (``Organization``, ``Team``, +``Agent``, ``Task``, ``Goal`` x ``read``/``create``/``update``/``delete``/``deploy``/ +``assign``). FuzeAgent registers that policy with the platform once, and thereafter asks +the platform for decisions — it never evaluates, stores, or ships policy itself. + +Configuration (env, all vendor-neutral) +--------------------------------------- + ``FUZEFRONT_SECURITY_BASE_URL`` + Absolute base URL of the FuzeFront API *including* the ``/api`` prefix, e.g. + ``http://fuzefront-backend.fuzefront.svc.cluster.local:3001/api``. Server-side + calls need an absolute host (there is no "same origin" for a backend process); + browser callers use the same-origin ``/api`` base instead. When this is UNSET the + security service is considered **not configured** and callers fall back to their + documented legacy behaviour — see ``auth.py``. + ``FUZEFRONT_SECURITY_TIMEOUT_SECONDS`` + Per-request timeout. Default ``5.0``. + ``FUZEFRONT_SECURITY_SERVICE_TOKEN`` + Optional machine token used for calls FuzeAgent makes on its own behalf rather + than on behalf of a signed-in user (e.g. a background reconciliation deciding + whether a service principal may deploy an agent). NOT used for user requests — + those forward the user's own bearer token so the decision is made for the real + subject. + +Fail-closed +----------- +Every helper here denies on any error: transport failure, timeout, non-2xx, malformed +body, missing config. There is no permissive fallback and no "allow on error" path. A +denial is returned as ``False``/``None``; the caller turns that into 401/403. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List, Optional, Sequence, Tuple + +logger = logging.getLogger(__name__) + +try: # httpx is a declared dependency (requirements.txt) + import httpx +except Exception: # pragma: no cover - import guard for partial envs + httpx = None # type: ignore + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +#: Contract path prefix. Versioned by the FuzeFront Security contract, not by us. +SECURITY_PREFIX = "/v1/security" + +#: Major version of the FuzeFront Security contract this client is written against. +SECURITY_CONTRACT_MAJOR = 0 + + +def base_url() -> Optional[str]: + """Configured FuzeFront API base (including ``/api``), or ``None``.""" + raw = (os.getenv("FUZEFRONT_SECURITY_BASE_URL") or "").strip() + return raw.rstrip("/") or None + + +def is_configured() -> bool: + """Whether the FuzeFront Security service is wired up for this process.""" + return bool(base_url()) and httpx is not None + + +def timeout_seconds() -> float: + try: + return float(os.getenv("FUZEFRONT_SECURITY_TIMEOUT_SECONDS", "5.0")) + except ValueError: + return 5.0 + + +def service_token() -> Optional[str]: + return (os.getenv("FUZEFRONT_SECURITY_SERVICE_TOKEN") or "").strip() or None + + +def _url(path: str) -> Optional[str]: + base = base_url() + if not base: + return None + return f"{base}{SECURITY_PREFIX}{path}" + + +def _headers(token: Optional[str]) -> Dict[str, str]: + headers = {"Accept": "application/json"} + bearer = token or service_token() + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + return headers + + +# --------------------------------------------------------------------------- +# Normalized identity (mirrors the contract's `Identity`) +# --------------------------------------------------------------------------- + + +class Identity: + """The stable, provider-neutral identity returned by ``GET /v1/security/session``. + + Field names mirror the contract's ``Identity`` schema exactly so drift is obvious. + """ + + __slots__ = ( + "userId", + "tenantId", + "roles", + "email", + "authMode", + "issuedAt", + "expiresAt", + "issuer", + "raw", + ) + + def __init__(self, data: Dict[str, Any]): + self.raw = data + self.userId: str = str(data.get("userId") or "") + tenant = data.get("tenantId") + self.tenantId: Optional[str] = str(tenant) if tenant else None + roles = data.get("roles") or [] + self.roles: List[str] = ( + [str(r) for r in roles] if isinstance(roles, (list, tuple)) else [] + ) + self.email: Optional[str] = data.get("email") + self.authMode: str = str(data.get("authMode") or "federated-jwks") + self.issuedAt: Optional[int] = data.get("issuedAt") + self.expiresAt: Optional[int] = data.get("expiresAt") + self.issuer: Optional[str] = data.get("issuer") + + def as_claims(self) -> Dict[str, Any]: + """Project onto the claim names ``auth.CurrentUser`` already understands.""" + claims: Dict[str, Any] = { + "sub": self.userId, + "userId": self.userId, + "tenantId": self.tenantId, + "roles": list(self.roles), + "authMode": self.authMode, + } + if self.email: + claims["email"] = self.email + if self.issuedAt is not None: + claims["iat"] = self.issuedAt + if self.expiresAt is not None: + claims["exp"] = self.expiresAt + if self.issuer: + claims["iss"] = self.issuer + return claims + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + + +def _default_client(): # pragma: no cover - trivial factory + return httpx.AsyncClient(timeout=timeout_seconds()) + + +#: Injectable async-client factory. Production uses :func:`_default_client`; tests swap +#: in an ``httpx.AsyncClient`` backed by a ``MockTransport`` so no network is touched. +client_factory = _default_client + + +async def _request( + method: str, + path: str, + *, + token: Optional[str] = None, + json_body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, str]] = None, +) -> Optional[Any]: + """Issue one call. Returns the decoded body, or ``None`` on ANY failure. + + ``None`` is the fail-closed sentinel — callers must treat it as deny/unauthenticated, + never as "no opinion". + """ + url = _url(path) + if url is None or httpx is None: + logger.debug("Security service not configured; %s %s skipped", method, path) + return None + try: + async with client_factory() as client: + response = await client.request( + method, url, headers=_headers(token), json=json_body, params=params + ) + except Exception as exc: # transport/timeout/DNS — fail closed + logger.warning("Security service %s %s failed: %s", method, path, exc) + return None + + if response.status_code == 401: + logger.info("Security service rejected the token for %s %s", method, path) + return None + if response.status_code >= 400: + logger.warning( + "Security service %s %s returned %s", method, path, response.status_code + ) + return None + if response.status_code == 204 or not response.content: + return {} + try: + return response.json() + except Exception as exc: # malformed body — fail closed + logger.warning( + "Security service %s %s returned non-JSON: %s", method, path, exc + ) + return None + + +# --------------------------------------------------------------------------- +# AuthN — session +# --------------------------------------------------------------------------- + + +async def get_session(token: str) -> Optional[Identity]: + """Resolve the presented bearer token to a normalized ``Identity``. + + ``GET /v1/security/session``. Returns ``None`` when the token is not valid, or when + the service is unreachable — both are "not authenticated" (fail-closed). FuzeAgent + performs NO signature verification of its own on this path and therefore needs no + signing key, no JWKS URL, and no knowledge of the issuing provider. + """ + if not token: + return None + body = await _request("GET", "/session", token=token) + if not isinstance(body, dict): + return None + identity = body.get("identity") + if not isinstance(identity, dict): + return None + resolved = Identity(identity) + if not resolved.userId: + return None + return resolved + + +async def revoke_session(token: str) -> bool: + """Log out — ``DELETE /v1/security/session``. Idempotent; ``False`` on failure.""" + if not token: + return False + return await _request("DELETE", "/session", token=token) is not None + + +# --------------------------------------------------------------------------- +# AuthZ — check / bulk-check / permissions +# --------------------------------------------------------------------------- + + +def _check_body( + subject: str, + tenant: str, + resource: str, + action: str, + resource_key: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build one ``AuthzCheckRequest`` using FuzeAgent's bare policy keys.""" + ref: Dict[str, Any] = {"type": resource} + if resource_key: + ref["key"] = resource_key + body: Dict[str, Any] = { + "subject": subject, + "tenant": tenant, + "resource": ref, + "action": action, + } + if context: + body["context"] = context + return body + + +async def authz_check( + *, + subject: str, + tenant: str, + resource: str, + action: str, + resource_key: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + token: Optional[str] = None, +) -> bool: + """One authorization decision. ``POST /v1/security/authz/check``. Fail-closed.""" + if not subject or not tenant or not resource or not action: + return False + body = await _request( + "POST", + "/authz/check", + token=token, + json_body=_check_body(subject, tenant, resource, action, resource_key, context), + ) + return bool(isinstance(body, dict) and body.get("allow") is True) + + +async def authz_bulk_check( + checks: Sequence[Tuple[str, str, str, str]], + *, + token: Optional[str] = None, +) -> List[bool]: + """Many decisions in one round trip. ``POST /v1/security/authz/bulk-check``. + + ``checks`` is a sequence of ``(subject, tenant, resource, action)`` tuples. The + returned list is index-aligned with the input, exactly as the contract guarantees. + Fail-closed: on any failure — including a response whose decision count does not + match the request — every element is ``False``. + """ + if not checks: + return [] + payload = { + "checks": [ + _check_body(subject, tenant, resource, action) + for (subject, tenant, resource, action) in checks + ] + } + body = await _request("POST", "/authz/bulk-check", token=token, json_body=payload) + denied = [False] * len(checks) + if not isinstance(body, dict): + return denied + decisions = body.get("decisions") + if not isinstance(decisions, list) or len(decisions) != len(checks): + logger.warning( + "authz/bulk-check returned a misaligned decision list; denying all" + ) + return denied + return [bool(isinstance(d, dict) and d.get("allow") is True) for d in decisions] + + +async def get_permissions( + *, subject: str, tenant: str, token: Optional[str] = None +) -> List[str]: + """Effective ``Resource:action`` grants for a subject in a tenant. + + ``GET /v1/security/authz/permissions``. Fail-closed: ``[]`` on any failure, which a + caller must read as "no permissions known", never as "unrestricted". + """ + if not subject or not tenant: + return [] + body = await _request( + "GET", + "/authz/permissions", + token=token, + params={"subject": subject, "tenant": tenant}, + ) + if not isinstance(body, dict): + return [] + permissions = body.get("permissions") + if not isinstance(permissions, list): + return [] + return [str(p) for p in permissions] diff --git a/services/orchestrator/simple_main.py b/services/orchestrator/simple_main.py index 3021f0e..6d1361a 100644 --- a/services/orchestrator/simple_main.py +++ b/services/orchestrator/simple_main.py @@ -6,10 +6,11 @@ from datetime import datetime from typing import Dict, List, Optional -from fastapi import FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from agent_templates import AgentCategory, template_manager +from auth import get_current_user # Simple in-memory storage for demonstration agents_db = {} @@ -66,11 +67,27 @@ async def lifespan(app: FastAPI): description="AI Team Orchestration Platform - Simple Version", version="1.0.0", lifespan=lifespan, + # Authenticated by default. `get_current_user` short-circuits the small public + # allowlist (health/readiness/docs) and 401s everything else without a valid + # token — identity being resolved by the FuzeFront Security API when the platform + # is configured, locally otherwise. See services/orchestrator/auth.py. + dependencies=[Depends(get_current_user)], ) +# CORS allowlist from the environment. NEVER "*" together with credentials: a +# wildcard reflected with `allow_credentials=True` lets any origin ride the user's +# session. Defaults to the local dev UI origins only. +_CORS_ALLOW_ORIGINS = [ + origin.strip() + for origin in os.getenv( + "CORS_ALLOW_ORIGINS", "http://localhost:3000,http://localhost:3031" + ).split(",") + if origin.strip() and origin.strip() != "*" +] + app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Allow all origins for development + allow_origins=_CORS_ALLOW_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/services/orchestrator/tests/test_platform_security.py b/services/orchestrator/tests/test_platform_security.py new file mode 100644 index 0000000..b5db039 --- /dev/null +++ b/services/orchestrator/tests/test_platform_security.py @@ -0,0 +1,596 @@ +"""FuzeFront Security API migration — identity and authorization come from the platform. + +These tests pin the behaviour that makes FuzeAgent provider-agnostic: + + 1. ``fuze_security`` speaks ONLY the published FuzeFront Security contract paths + (``/v1/security/session``, ``/authz/check``, ``/authz/bulk-check``, + ``/authz/permissions``) and never any identity/policy vendor. + 2. Every decision is FAIL-CLOSED — transport error, timeout, 4xx/5xx, malformed + body, or a misaligned bulk response all deny. + 3. ``auth.get_current_user`` resolves the caller through the platform when + ``FUZEFRONT_SECURITY_BASE_URL`` is set, and needs NO local signing key to do it. + 4. Legacy mode (platform unset) keeps the previous local-verification behaviour, so + standalone deployments lose nothing. + +No network is touched: an ``httpx.MockTransport`` is injected via +``fuze_security.client_factory``. +""" + +import json +import os +import sys + +import httpx +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import fuze_security # noqa: E402 + +BASE = "http://fuzefront.test/api" +SECURITY = f"{BASE}/v1/security" + +USER_ID = "user-42" +TENANT = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT = "22222222-2222-2222-2222-222222222222" +LEGACY_SECRET = "legacy-mode-test-secret" # nosec B105 - test fixture, not a credential + +IDENTITY = { + "userId": USER_ID, + "tenantId": TENANT, + "roles": ["operator"], + "email": "agent-operator@example.com", + "authMode": "federated-jwks", + "issuedAt": 1700000000, + "expiresAt": 1700003600, + "issuer": "fuzefront", +} + + +# --------------------------------------------------------------------------- +# Harness +# --------------------------------------------------------------------------- + + +def install_transport(monkeypatch, handler): + """Point ``fuze_security`` at a MockTransport and configure the base URL.""" + monkeypatch.setenv("FUZEFRONT_SECURITY_BASE_URL", BASE) + monkeypatch.setattr( + fuze_security, + "client_factory", + lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + +def recording_handler(routes, seen): + """Build a handler that records requests and replies from ``routes``.""" + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + key = f"{request.method} {request.url.path}" + responder = routes.get(key) + if responder is None: + return httpx.Response(404, json={"error": "no route"}) + return responder(request) + + return handler + + +# --------------------------------------------------------------------------- +# 1. Configuration +# --------------------------------------------------------------------------- + + +def test_not_configured_without_base_url(monkeypatch): + monkeypatch.delenv("FUZEFRONT_SECURITY_BASE_URL", raising=False) + assert fuze_security.is_configured() is False + assert fuze_security.base_url() is None + + +def test_configured_with_base_url(monkeypatch): + monkeypatch.setenv("FUZEFRONT_SECURITY_BASE_URL", BASE + "/") + assert fuze_security.is_configured() is True + # Trailing slash normalized so paths do not double up. + assert fuze_security.base_url() == BASE + + +def test_no_vendor_name_in_the_client_source(): + """The whole point of the migration: no identity/policy vendor is named here.""" + with open(fuze_security.__file__, "r", encoding="utf-8") as handle: + source = handle.read().lower() + for vendor in ("authentik", "permit.io", "permitio", "keycloak", "auth0", "okta"): + assert vendor not in source, f"{vendor} must not appear in fuze_security.py" + + +# --------------------------------------------------------------------------- +# 2. Session resolution — GET /v1/security/session +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_session_returns_normalized_identity(monkeypatch): + seen = [] + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/session": lambda r: httpx.Response( + 200, json={"identity": IDENTITY, "user": {"id": USER_ID}} + ) + }, + seen, + ), + ) + + identity = await fuze_security.get_session("caller-token") + + assert identity is not None + assert identity.userId == USER_ID + assert identity.tenantId == TENANT + assert identity.roles == ["operator"] + # The caller's own token is forwarded — the platform decides for the real subject. + assert seen[0].headers["authorization"] == "Bearer caller-token" + + +@pytest.mark.asyncio +async def test_get_session_denies_on_401(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + {"GET /api/v1/security/session": lambda r: httpx.Response(401)}, [] + ), + ) + assert await fuze_security.get_session("bad-token") is None + + +@pytest.mark.asyncio +async def test_get_session_denies_on_transport_error(monkeypatch): + def boom(request): + raise httpx.ConnectError("security service unreachable", request=request) + + install_transport(monkeypatch, boom) + assert await fuze_security.get_session("any-token") is None + + +@pytest.mark.asyncio +async def test_get_session_denies_on_malformed_body(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/session": lambda r: httpx.Response( + 200, content=b"not-json" + ) + }, + [], + ), + ) + assert await fuze_security.get_session("any-token") is None + + +@pytest.mark.asyncio +async def test_get_session_denies_identity_without_subject(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/session": lambda r: httpx.Response( + 200, + json={"identity": {"userId": "", "tenantId": None, "roles": []}}, + ) + }, + [], + ), + ) + assert await fuze_security.get_session("any-token") is None + + +# --------------------------------------------------------------------------- +# 3. Authorization — POST /v1/security/authz/check +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_authz_check_sends_bare_policy_keys(monkeypatch): + seen = [] + install_transport( + monkeypatch, + recording_handler( + { + "POST /api/v1/security/authz/check": lambda r: httpx.Response( + 200, json={"allow": True} + ) + }, + seen, + ), + ) + + allowed = await fuze_security.authz_check( + subject=USER_ID, + tenant=TENANT, + resource="Agent", + action="deploy", + resource_key="agent-7", + token="caller-token", + ) + + assert allowed is True + body = json.loads(seen[0].content) + # Exactly the shape of the contract's AuthzCheckRequest, with FuzeAgent's own + # bare keys from registration/policy.json. + assert body == { + "subject": USER_ID, + "tenant": TENANT, + "resource": {"type": "Agent", "key": "agent-7"}, + "action": "deploy", + } + + +@pytest.mark.asyncio +async def test_authz_check_denies_when_allow_is_false(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + { + "POST /api/v1/security/authz/check": lambda r: httpx.Response( + 200, json={"allow": False} + ) + }, + [], + ), + ) + assert ( + await fuze_security.authz_check( + subject=USER_ID, tenant=TENANT, resource="Organization", action="delete" + ) + is False + ) + + +@pytest.mark.asyncio +async def test_authz_check_denies_on_server_error(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + {"POST /api/v1/security/authz/check": lambda r: httpx.Response(500)}, [] + ), + ) + assert ( + await fuze_security.authz_check( + subject=USER_ID, tenant=TENANT, resource="Task", action="assign" + ) + is False + ) + + +@pytest.mark.asyncio +async def test_authz_check_denies_on_missing_arguments(monkeypatch): + install_transport(monkeypatch, recording_handler({}, [])) + assert ( + await fuze_security.authz_check( + subject=USER_ID, tenant="", resource="Task", action="read" + ) + is False + ) + + +@pytest.mark.asyncio +async def test_bulk_check_is_index_aligned(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + { + "POST /api/v1/security/authz/bulk-check": lambda r: httpx.Response( + 200, + json={ + "decisions": [ + {"allow": True}, + {"allow": False}, + {"allow": True}, + ] + }, + ) + }, + [], + ), + ) + decisions = await fuze_security.authz_bulk_check( + [ + (USER_ID, TENANT, "Agent", "read"), + (USER_ID, TENANT, "Agent", "delete"), + (USER_ID, TENANT, "Task", "read"), + ] + ) + assert decisions == [True, False, True] + + +@pytest.mark.asyncio +async def test_bulk_check_denies_everything_on_misaligned_response(monkeypatch): + """A short decision list must never be read as 'the rest were allowed'.""" + install_transport( + monkeypatch, + recording_handler( + { + "POST /api/v1/security/authz/bulk-check": lambda r: httpx.Response( + 200, json={"decisions": [{"allow": True}]} + ) + }, + [], + ), + ) + decisions = await fuze_security.authz_bulk_check( + [(USER_ID, TENANT, "Agent", "read"), (USER_ID, TENANT, "Agent", "delete")] + ) + assert decisions == [False, False] + + +@pytest.mark.asyncio +async def test_permissions_returns_resource_action_pairs(monkeypatch): + seen = [] + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/authz/permissions": lambda r: httpx.Response( + 200, + json={ + "subject": USER_ID, + "tenant": TENANT, + "permissions": ["Agent:read", "Task:assign"], + }, + ) + }, + seen, + ), + ) + permissions = await fuze_security.get_permissions(subject=USER_ID, tenant=TENANT) + assert permissions == ["Agent:read", "Task:assign"] + assert seen[0].url.params["subject"] == USER_ID + assert seen[0].url.params["tenant"] == TENANT + + +@pytest.mark.asyncio +async def test_permissions_empty_on_failure(monkeypatch): + install_transport( + monkeypatch, + recording_handler( + {"GET /api/v1/security/authz/permissions": lambda r: httpx.Response(503)}, + [], + ), + ) + assert await fuze_security.get_permissions(subject=USER_ID, tenant=TENANT) == [] + + +# --------------------------------------------------------------------------- +# 4. auth.py wiring — platform mode needs NO local signing key +# --------------------------------------------------------------------------- + + +@pytest.fixture +def platform_auth(monkeypatch): + """``auth`` with the platform configured and NO local JWT material. + + The module's config attributes are patched in place rather than re-imported: other + test modules in the same session hold direct references into ``auth``, and reloading + it under them swaps the class objects out from beneath their fixtures. + """ + import auth as auth_module + + monkeypatch.setenv("FUZEFRONT_SECURITY_BASE_URL", BASE) + monkeypatch.setattr(auth_module, "JWT_SECRET", None) + monkeypatch.setattr(auth_module, "JWT_PUBLIC_KEY", None) + monkeypatch.setattr(auth_module, "_AUTH_DISABLED", False) + return auth_module + + +def _app_with(auth_module): + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + + app = FastAPI(dependencies=[Depends(auth_module.get_current_user)]) + + @app.get("/health") + async def health(): + return {"ok": True} + + @app.get("/whoami") + async def whoami(user=Depends(auth_module.require_user)): + return {"id": user.id, "tenant": user.tenant_id, "roles": user.roles} + + @app.post("/organizations/{organization_id}/agents") + async def create_agent( + organization_id: str, user=Depends(auth_module.require_user) + ): + await auth_module.require_org_permission( + organization_id, + user, + auth_module.ACTION_CREATE, + resource=auth_module.RESOURCE_AGENT, + ) + return {"created": True} + + return TestClient(app) + + +def test_platform_mode_is_enabled_without_any_jwt_material(platform_auth): + assert platform_auth.platform_security_enabled() is True + assert platform_auth._auth_configured() is False + + +def test_platform_mode_health_is_public(platform_auth, monkeypatch): + install_transport(monkeypatch, recording_handler({}, [])) + assert _app_with(platform_auth).get("/health").status_code == 200 + + +def test_platform_mode_without_token_is_401(platform_auth, monkeypatch): + install_transport(monkeypatch, recording_handler({}, [])) + assert _app_with(platform_auth).get("/whoami").status_code == 401 + + +def test_platform_mode_resolves_identity_from_the_security_api( + platform_auth, monkeypatch +): + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/session": lambda r: httpx.Response( + 200, json={"identity": IDENTITY, "user": {"id": USER_ID}} + ) + }, + [], + ), + ) + response = _app_with(platform_auth).get( + "/whoami", headers={"Authorization": "Bearer opaque-platform-token"} + ) + assert response.status_code == 200 + assert response.json() == { + "id": USER_ID, + "tenant": TENANT, + "roles": ["operator"], + } + + +def test_platform_mode_rejects_a_token_the_platform_rejects(platform_auth, monkeypatch): + install_transport( + monkeypatch, + recording_handler( + {"GET /api/v1/security/session": lambda r: httpx.Response(401)}, [] + ), + ) + response = _app_with(platform_auth).get( + "/whoami", headers={"Authorization": "Bearer forged"} + ) + assert response.status_code == 401 + + +def test_platform_mode_org_permission_allows_when_platform_allows( + platform_auth, monkeypatch +): + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/session": lambda r: httpx.Response( + 200, json={"identity": IDENTITY, "user": {"id": USER_ID}} + ), + "POST /api/v1/security/authz/check": lambda r: httpx.Response( + 200, json={"allow": True} + ), + }, + [], + ), + ) + response = _app_with(platform_auth).post( + f"/organizations/{TENANT}/agents", headers={"Authorization": "Bearer tok"} + ) + assert response.status_code == 200 + + +def test_platform_mode_org_permission_is_403_when_platform_denies( + platform_auth, monkeypatch +): + """The org id in the path is the tenant scope — a foreign org must be refused.""" + install_transport( + monkeypatch, + recording_handler( + { + "GET /api/v1/security/session": lambda r: httpx.Response( + 200, json={"identity": IDENTITY, "user": {"id": USER_ID}} + ), + "POST /api/v1/security/authz/check": lambda r: httpx.Response( + 200, json={"allow": False} + ), + }, + [], + ), + ) + response = _app_with(platform_auth).post( + f"/organizations/{OTHER_TENANT}/agents", headers={"Authorization": "Bearer tok"} + ) + assert response.status_code == 403 + + +def test_platform_mode_org_permission_is_403_when_platform_is_unreachable( + platform_auth, monkeypatch +): + """Fail-closed end to end: a broken security service denies, it does not admit.""" + + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/session"): + return httpx.Response( + 200, json={"identity": IDENTITY, "user": {"id": USER_ID}} + ) + calls["n"] += 1 + raise httpx.ConnectError("authz down", request=request) + + install_transport(monkeypatch, handler) + response = _app_with(platform_auth).post( + f"/organizations/{TENANT}/agents", headers={"Authorization": "Bearer tok"} + ) + assert response.status_code == 403 + assert calls["n"] == 1 + + +# --------------------------------------------------------------------------- +# 5. Legacy mode is preserved — no capability is dropped when the platform is absent +# --------------------------------------------------------------------------- + + +@pytest.fixture +def legacy_auth(monkeypatch): + """``auth`` with NO platform configured — the pre-migration local-verification path.""" + import auth as auth_module + + monkeypatch.delenv("FUZEFRONT_SECURITY_BASE_URL", raising=False) + monkeypatch.setattr( + auth_module, "JWT_SECRET", LEGACY_SECRET # nosec B105 - test fixture + ) + monkeypatch.setattr(auth_module, "JWT_PUBLIC_KEY", None) + monkeypatch.setattr(auth_module, "JWT_ALGORITHM", "HS256") + monkeypatch.setattr(auth_module, "JWT_AUDIENCE", None) + monkeypatch.setattr(auth_module, "JWT_ISSUER", None) + monkeypatch.setattr(auth_module, "_AUTH_DISABLED", False) + monkeypatch.setitem(auth_module._VERIFY_OPTIONS, "verify_aud", False) + return auth_module + + +def test_legacy_mode_still_verifies_locally(legacy_auth): + from jose import jwt + + assert legacy_auth.platform_security_enabled() is False + token = jwt.encode( + {"sub": USER_ID, "organizations": [TENANT]}, + LEGACY_SECRET, + algorithm="HS256", + ) + response = _app_with(legacy_auth).get( + "/whoami", headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 200 + assert response.json()["id"] == USER_ID + + +def test_legacy_mode_org_permission_falls_back_to_claims(legacy_auth): + from jose import jwt + + client = _app_with(legacy_auth) + token = jwt.encode( + {"sub": USER_ID, "organizations": [TENANT]}, + LEGACY_SECRET, + algorithm="HS256", + ) + headers = {"Authorization": f"Bearer {token}"} + assert ( + client.post(f"/organizations/{TENANT}/agents", headers=headers).status_code + == 200 + ) + assert ( + client.post( + f"/organizations/{OTHER_TENANT}/agents", headers=headers + ).status_code + == 403 + ) diff --git a/services/ui-react/src/App.tsx b/services/ui-react/src/App.tsx index 8b82240..49d2be7 100644 --- a/services/ui-react/src/App.tsx +++ b/services/ui-react/src/App.tsx @@ -8,7 +8,7 @@ import StatsCards from './components/StatsCards' import OrganizationSelector from './components/OrganizationSelector' import TeamSelector from './components/TeamSelector' import HierarchyView from './components/HierarchyView' -import { api, API_ENDPOINTS } from './config/api' +import { api, createWebSocket } from './config/api' import type { Agent, Task, AgentTemplate, Organization, Team, @@ -104,7 +104,7 @@ function App() { useEffect(() => { let ws: WebSocket | null = null try { - ws = new WebSocket(`${API_ENDPOINTS.WEBSOCKET_BASE}/ws`) + ws = createWebSocket('/ws') } catch (e) { console.warn('WebSocket unavailable, continuing without realtime updates') return diff --git a/services/ui-react/src/components/auth/AuthGate.tsx b/services/ui-react/src/components/auth/AuthGate.tsx new file mode 100644 index 0000000..99e0b0b --- /dev/null +++ b/services/ui-react/src/components/auth/AuthGate.tsx @@ -0,0 +1,81 @@ +/** + * AuthGate — decides whether to show the app or the sign-in surface. + * + * The rule, and why it is not simply "no identity => sign-in": + * + * authenticated -> render the app. + * anonymous, platform PRESENT -> render . The platform advertises real + * sign-in methods, so there is something to do. + * anonymous, platform ABSENT -> render the app. `GET /v1/security/methods` did + * not answer, so no sign-in exists to offer; + * blocking here would lock a standalone/offline + * deployment out of a UI it used to be able to + * open, and would replace a working screen with a + * dead end. + * + * That is NOT a security downgrade. The gate is cosmetic; the orchestrator enforces + * authentication and authorization server-side and fails closed on every request + * (`services/orchestrator/auth.py`). A UI that renders without a session simply gets + * 401s from the API — which is the correct, honest failure — rather than a + * self-inflicted white screen when the platform is not deployed. + */ + +import { useEffect, useState, type ReactNode } from 'react' +import type { AuthMethods } from '../../lib/security/contract' +import { getAuthMethods } from '../../lib/security/client' +import { useSecurity } from '../../lib/security/SecurityProvider' +import { SignIn } from './SignIn' + +type Probe = + | { state: 'probing' } + | { state: 'present'; methods: AuthMethods } + | { state: 'absent' } + +export function AuthGate({ children }: { children: ReactNode }) { + const { status, error } = useSecurity() + const [probe, setProbe] = useState({ state: 'probing' }) + + const needsProbe = status === 'anonymous' || status === 'error' + + useEffect(() => { + if (!needsProbe) return + let cancelled = false + getAuthMethods() + .then((methods) => { + if (!cancelled) setProbe({ state: 'present', methods }) + }) + .catch(() => { + // No security surface on this origin — standalone/offline deployment. + if (!cancelled) setProbe({ state: 'absent' }) + }) + return () => { + cancelled = true + } + }, [needsProbe]) + + if (status === 'loading') { + return ( +
+

Signing in…

+
+ ) + } + + if (status === 'authenticated') return <>{children} + + if (probe.state === 'probing') { + return ( +
+

Checking sign-in…

+
+ ) + } + + if (probe.state === 'present') { + return + } + + return <>{children} +} + +export default AuthGate diff --git a/services/ui-react/src/components/auth/SignIn.tsx b/services/ui-react/src/components/auth/SignIn.tsx new file mode 100644 index 0000000..729c829 --- /dev/null +++ b/services/ui-react/src/components/auth/SignIn.tsx @@ -0,0 +1,89 @@ +/** + * SignIn — the sign-in surface, rendered only in STANDALONE mode by `AuthGate`. + * + * FuzeAgent does not authenticate anybody. This screen is a redirect surface: it + * renders whatever methods the platform advertises (`GET /api/v1/security/methods`, + * fetched by `AuthGate`) and hands the browser to FuzeFront's own server-brokered + * start endpoint (`/api/v1/security/social/{provider}/start`). FuzeFront brokers + * onward and returns a single-use `code`, which `SecurityProvider` exchanges for a + * session. + * + * There is deliberately NO password form here. `POST /v1/security/session` (password + * login) exists in the contract, but a product-side password form would mean FuzeAgent + * handling user credentials — precisely what delegating identity is meant to prevent, + * and what `.semgrep/fuze-authz.yml` forbids. Password sign-in belongs on FuzeFront's + * own surface; when the deployment advertises `password: true` we say so and point + * there instead of collecting the password ourselves. + * + * Embedded in the FuzeFront shell this never renders: the host has already + * authenticated the user and `SecurityProvider` picks the identity up from the shell. + */ + +import type { AuthMethods } from '../../lib/security/contract' +import { socialStartUrl } from '../../lib/security/client' + +const PROVIDER_LABELS: Record = { + google: 'Continue with Google', +} + +function providerLabel(provider: string): string { + return PROVIDER_LABELS[provider] ?? `Continue with ${provider}` +} + +export interface SignInProps { + /** What the platform advertises. Never hard-code a provider list. */ + methods: AuthMethods + /** Non-fatal message from a previous sign-in attempt (e.g. a spent broker code). */ + error?: string | null +} + +export function SignIn({ methods, error }: SignInProps) { + const returnTo = typeof window === 'undefined' ? '/' : window.location.href + + return ( +
+
+
+

Sign in to FuzeAgent

+

+ FuzeAgent uses your FuzeFront account. +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ {methods.social.map((provider) => ( + + {providerLabel(provider)} + + ))} + + {methods.password && ( +

+ Password sign-in is handled by FuzeFront. Sign in there, then return here. +

+ )} + + {/* Empty state — auth is configured but no method is switched on. */} + {methods.social.length === 0 && !methods.password && ( +

+ No sign-in method is enabled for this deployment. Contact your + administrator. +

+ )} +
+
+
+ ) +} + +export default SignIn diff --git a/services/ui-react/src/config/api.ts b/services/ui-react/src/config/api.ts index dc7192d..10d082a 100644 --- a/services/ui-react/src/config/api.ts +++ b/services/ui-react/src/config/api.ts @@ -1,4 +1,12 @@ // API Configuration for FuzeAgent Frontend +// +// Every call carries the FuzeFront session token. Before this, the UI sent NO +// credentials at all, so it could only ever talk to an unauthenticated backend — +// the orchestrator fails closed (401) on every non-public route. `authHeader()` +// reads the token the FuzeFront shell provides when embedded, or the one +// `/v1/security/session/exchange` stored when standalone. + +import { authHeader, getToken } from '../lib/security/client' // Environment-based API endpoints const getAPIEndpoints = () => { @@ -41,7 +49,9 @@ export const api = { // Hierarchy API calls (organizations, teams, agents structure) hierarchy: { get: async (endpoint: string) => { - const response = await fetch(`${API_ENDPOINTS.HIERARCHY_API_BASE}${endpoint}`) + const response = await fetch(`${API_ENDPOINTS.HIERARCHY_API_BASE}${endpoint}`, { + headers: { ...authHeader() }, + }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } @@ -53,6 +63,7 @@ export const api = { method: 'POST', headers: { 'Content-Type': 'application/json', + ...authHeader(), }, body: JSON.stringify(data), }) @@ -67,6 +78,7 @@ export const api = { method: 'PUT', headers: { 'Content-Type': 'application/json', + ...authHeader(), }, body: JSON.stringify(data), }) @@ -79,6 +91,7 @@ export const api = { delete: async (endpoint: string) => { const response = await fetch(`${API_ENDPOINTS.HIERARCHY_API_BASE}${endpoint}`, { method: 'DELETE', + headers: { ...authHeader() }, }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -90,7 +103,9 @@ export const api = { // Orchestrator API calls (agent management, tasks, containers, etc.) orchestrator: { get: async (endpoint: string) => { - const response = await fetch(`${API_ENDPOINTS.ORCHESTRATOR_API_BASE}${endpoint}`) + const response = await fetch(`${API_ENDPOINTS.ORCHESTRATOR_API_BASE}${endpoint}`, { + headers: { ...authHeader() }, + }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } @@ -102,6 +117,7 @@ export const api = { method: 'POST', headers: { 'Content-Type': 'application/json', + ...authHeader(), }, body: JSON.stringify(data), }) @@ -116,6 +132,7 @@ export const api = { method: 'PUT', headers: { 'Content-Type': 'application/json', + ...authHeader(), }, body: JSON.stringify(data), }) @@ -128,6 +145,7 @@ export const api = { delete: async (endpoint: string) => { const response = await fetch(`${API_ENDPOINTS.ORCHESTRATOR_API_BASE}${endpoint}`, { method: 'DELETE', + headers: { ...authHeader() }, }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -140,6 +158,7 @@ export const api = { upload: async (endpoint: string, formData: FormData) => { const response = await fetch(`${API_ENDPOINTS.ORCHESTRATOR_API_BASE}${endpoint}`, { method: 'POST', + headers: { ...authHeader() }, body: formData, }) if (!response.ok) { @@ -149,9 +168,18 @@ export const api = { } } -// WebSocket utility +// WebSocket utility. +// +// A browser cannot set an `Authorization` header on a WS handshake, so the session +// token rides the `Sec-WebSocket-Protocol` subprotocol as `bearer, ` — the +// browser-friendly form the orchestrator's `authenticate_websocket` accepts (it also +// takes a `?token=` query param, but that leaks the token into access logs and +// referrers, so we do not use it). Without a token we connect unauthenticated and the +// server closes with 1008, which is the correct fail-closed outcome. export const createWebSocket = (endpoint: string) => { - return new WebSocket(`${API_ENDPOINTS.WEBSOCKET_BASE}${endpoint}`) + const url = `${API_ENDPOINTS.WEBSOCKET_BASE}${endpoint}` + const token = getToken() + return token ? new WebSocket(url, ['bearer', token]) : new WebSocket(url) } export default API_ENDPOINTS \ No newline at end of file diff --git a/services/ui-react/src/hooks/useWebSocket.tsx b/services/ui-react/src/hooks/useWebSocket.tsx index 587c1a4..4b380ad 100644 --- a/services/ui-react/src/hooks/useWebSocket.tsx +++ b/services/ui-react/src/hooks/useWebSocket.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' +import { getToken } from '../lib/security/client' export interface WebSocketMessage { id: string @@ -79,7 +80,14 @@ export function useWebSocket(url: string, options: WebSocketOptions = {}): WebSo try { const wsUrl = buildUrl() - wsRef.current = new WebSocket(wsUrl) + // A browser cannot set an Authorization header on a WS handshake, so the + // FuzeFront session token rides the subprotocol as `bearer, ` — the form + // the orchestrator's authenticate_websocket() accepts. Unauthenticated + // handshakes are closed server-side with 1008 (fail-closed), not upgraded. + const wsToken = getToken() + wsRef.current = wsToken + ? new WebSocket(wsUrl, ['bearer', wsToken]) + : new WebSocket(wsUrl) wsRef.current.onopen = () => { setIsConnected(true) diff --git a/services/ui-react/src/lib/security/SecurityProvider.test.tsx b/services/ui-react/src/lib/security/SecurityProvider.test.tsx new file mode 100644 index 0000000..6546ad3 --- /dev/null +++ b/services/ui-react/src/lib/security/SecurityProvider.test.tsx @@ -0,0 +1,296 @@ +/** + * SecurityProvider / AuthGate — identity resolution and fail-closed UI gating. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { SecurityProvider, useSecurity } from './SecurityProvider' +import { TOKEN_STORAGE_KEY } from './client' +import AuthGate from '../../components/auth/AuthGate' + +function Probe() { + const { status, identity, can } = useSecurity() + return ( +
+ {status} + {identity?.userId ?? '-'} + {String(can('Agent', 'read'))} + {String(can('Agent', 'delete'))} +
+ ) +} + +const IDENTITY = { + userId: 'u1', + tenantId: 't1', + roles: ['operator'], + authMode: 'federated-jwks' as const, +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** Route by URL so each test only declares the endpoints it cares about. */ +function routeFetch(routes: Record Response | Promise>) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + for (const [prefix, responder] of Object.entries(routes)) { + if (url.startsWith(prefix)) return responder() + } + return jsonResponse({ error: 'not found' }, 404) + }) +} + +beforeEach(() => { + window.localStorage.clear() + delete (window as unknown as Record).__FUZEFRONT__ + window.history.replaceState({}, '', '/') + vi.restoreAllMocks() +}) + +describe('identity resolution', () => { + it('is anonymous with no token and no shell', async () => { + vi.stubGlobal('fetch', routeFetch({})) + render( + + + + ) + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('anonymous') + ) + expect(screen.getByTestId('can-read-agent').textContent).toBe('false') + }) + + it('takes the identity from the FuzeFront shell when embedded', async () => { + ;(window as unknown as Record).__FUZEFRONT__ = { + token: 'shell-token', + user: { userId: 'shell-user', tenantId: 't1', roles: ['admin'] }, + } + const fetchImpl = routeFetch({ + '/api/v1/security/authz/permissions': () => + jsonResponse({ subject: 'shell-user', tenant: 't1', permissions: ['Agent:read'] }), + }) + vi.stubGlobal('fetch', fetchImpl) + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('authenticated') + ) + expect(screen.getByTestId('user').textContent).toBe('shell-user') + // The shell identity is used as-is; no /session round trip is needed. + expect( + fetchImpl.mock.calls.some((c) => String(c[0]).includes('/security/session')) + ).toBe(false) + }) + + it('resolves the identity from GET /session when standalone', async () => { + window.localStorage.setItem(TOKEN_STORAGE_KEY, 'tok') + vi.stubGlobal( + 'fetch', + routeFetch({ + '/api/v1/security/session': () => + jsonResponse({ identity: IDENTITY, user: { id: 'u1' } }), + '/api/v1/security/authz/permissions': () => + jsonResponse({ subject: 'u1', tenant: 't1', permissions: ['Agent:read'] }), + }) + ) + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('authenticated') + ) + expect(screen.getByTestId('user').textContent).toBe('u1') + }) +}) + +describe('can() is fail closed', () => { + it('grants only what the platform actually returned', async () => { + window.localStorage.setItem(TOKEN_STORAGE_KEY, 'tok') + vi.stubGlobal( + 'fetch', + routeFetch({ + '/api/v1/security/session': () => + jsonResponse({ identity: IDENTITY, user: { id: 'u1' } }), + '/api/v1/security/authz/permissions': () => + jsonResponse({ subject: 'u1', tenant: 't1', permissions: ['Agent:read'] }), + }) + ) + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('can-read-agent').textContent).toBe('true') + ) + expect(screen.getByTestId('can-delete-agent').textContent).toBe('false') + }) + + it('grants nothing when the permissions call fails', async () => { + window.localStorage.setItem(TOKEN_STORAGE_KEY, 'tok') + vi.stubGlobal( + 'fetch', + routeFetch({ + '/api/v1/security/session': () => + jsonResponse({ identity: IDENTITY, user: { id: 'u1' } }), + '/api/v1/security/authz/permissions': () => jsonResponse({ error: 'down' }, 503), + }) + ) + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('authenticated') + ) + expect(screen.getByTestId('can-read-agent').textContent).toBe('false') + }) + + it('grants nothing when the identity has no tenant scope', async () => { + window.localStorage.setItem(TOKEN_STORAGE_KEY, 'tok') + const fetchImpl = routeFetch({ + '/api/v1/security/session': () => + jsonResponse({ identity: { ...IDENTITY, tenantId: null }, user: { id: 'u1' } }), + }) + vi.stubGlobal('fetch', fetchImpl) + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('authenticated') + ) + expect(screen.getByTestId('can-read-agent').textContent).toBe('false') + // No tenant means no tenant-scoped decision is even attempted. + expect( + fetchImpl.mock.calls.some((c) => String(c[0]).includes('/authz/permissions')) + ).toBe(false) + }) +}) + +describe('broker code exchange', () => { + it('exchanges a ?code= from the social callback and scrubs the URL', async () => { + window.history.replaceState({}, '', '/agents?code=broker-code-1') + vi.stubGlobal( + 'fetch', + routeFetch({ + '/api/v1/security/session/exchange': () => + jsonResponse({ status: 'authenticated', token: 'fresh', user: {} }), + '/api/v1/security/session': () => + jsonResponse({ identity: IDENTITY, user: { id: 'u1' } }), + '/api/v1/security/authz/permissions': () => + jsonResponse({ subject: 'u1', tenant: 't1', permissions: [] }), + }) + ) + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('status').textContent).toBe('authenticated') + ) + // Single-use code must not survive a refresh. + expect(window.location.search).not.toContain('code=') + }) +}) + +describe('AuthGate', () => { + it('shows sign-in when anonymous AND the platform advertises methods', async () => { + vi.stubGlobal( + 'fetch', + routeFetch({ + '/api/v1/security/methods': () => + jsonResponse({ + password: false, + social: ['google'], + mfa: { enabled: false, types: [] }, + verification: { email: false, sms: false }, + }), + }) + ) + + render( + + +
protected content
+
+
+ ) + + expect(await screen.findByText('Continue with Google')).toBeTruthy() + expect(screen.queryByText('protected content')).toBeNull() + }) + + it('renders the app when no security surface exists (standalone/offline)', async () => { + // /methods 404s -> there is no sign-in to offer. Blocking here would be a + // self-inflicted dead end; the server still fails closed on every API call. + vi.stubGlobal('fetch', routeFetch({})) + + render( + + +
protected content
+
+
+ ) + + expect(await screen.findByText('protected content')).toBeTruthy() + }) + + it('renders the app for an authenticated user', async () => { + window.localStorage.setItem(TOKEN_STORAGE_KEY, 'tok') + vi.stubGlobal( + 'fetch', + routeFetch({ + '/api/v1/security/session': () => + jsonResponse({ identity: IDENTITY, user: { id: 'u1' } }), + '/api/v1/security/authz/permissions': () => + jsonResponse({ subject: 'u1', tenant: 't1', permissions: [] }), + }) + ) + + render( + + +
protected content
+
+
+ ) + + expect(await screen.findByText('protected content')).toBeTruthy() + }) +}) + +describe('useSecurity outside a provider', () => { + it('degrades to anonymous with no permissions, never to "permitted"', () => { + render() + expect(screen.getByTestId('status').textContent).toBe('anonymous') + expect(screen.getByTestId('can-read-agent').textContent).toBe('false') + }) +}) diff --git a/services/ui-react/src/lib/security/SecurityProvider.tsx b/services/ui-react/src/lib/security/SecurityProvider.tsx new file mode 100644 index 0000000..0250293 --- /dev/null +++ b/services/ui-react/src/lib/security/SecurityProvider.tsx @@ -0,0 +1,161 @@ +/** + * SecurityProvider — the single source of "who is the user, and what may they do". + * + * Identity comes from the FuzeFront platform, in this order: + * + * 1. Embedded in the FuzeFront shell → the host's `window.__FUZEFRONT__` sentinel. + * The shell already authenticated the user; we do not re-authenticate. + * 2. Standalone → `GET /api/v1/security/session` with the stored session token. + * + * Permissions come from `GET /api/v1/security/authz/permissions` as a single + * `Resource:action` set, so a screen can gate many controls without N round trips. + * `can()` is FAIL-CLOSED: unknown, unloaded, or errored means "not permitted". + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import type { ActionKey, Identity, ResourceKey } from './contract' +import { + BROKER_CODE_PARAM, + exchangeCode, + getPermissions, + getSession, + getToken, + hostedIdentity, + logout as revokeSession, +} from './client' + +export type SecurityStatus = 'loading' | 'authenticated' | 'anonymous' | 'error' + +export interface SecurityContextValue { + status: SecurityStatus + identity: Identity | null + /** Effective `Resource:action` grants for the current tenant. Empty until loaded. */ + permissions: string[] + error: string | null + /** Fail-closed capability check for UI gating. Server-side authz still decides. */ + can: (resource: ResourceKey | string, action: ActionKey | string) => boolean + signOut: () => Promise + refresh: () => Promise +} + +const SecurityContext = createContext(null) + +/** + * Consume a single-use broker `code` from the URL if the social callback returned one, + * exchanging it for a session and cleaning the address bar so a refresh cannot replay + * an already-spent code. + */ +async function consumeBrokerCode(): Promise { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + const code = url.searchParams.get(BROKER_CODE_PARAM) + if (!code) return + try { + await exchangeCode(code) + } finally { + url.searchParams.delete(BROKER_CODE_PARAM) + window.history.replaceState({}, '', url.toString()) + } +} + +export function SecurityProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading') + const [identity, setIdentity] = useState(null) + const [permissions, setPermissions] = useState([]) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setError(null) + try { + await consumeBrokerCode() + + // 1. Embedded in the FuzeFront shell — the host owns the session. + const hosted = hostedIdentity() + let resolved: Identity | null = hosted + + // 2. Standalone — ask the platform who we are. + if (!resolved && getToken()) { + const session = await getSession() + resolved = session?.identity ?? null + } + + if (!resolved) { + setIdentity(null) + setPermissions([]) + setStatus('anonymous') + return + } + + setIdentity(resolved) + setStatus('authenticated') + + // Effective grants for the tenant. A null tenant means no tenant-scoped + // decision is possible — the contract says fail closed, so: no permissions. + if (resolved.tenantId) { + setPermissions(await getPermissions(resolved.userId, resolved.tenantId)) + } else { + setPermissions([]) + } + } catch (err) { + setIdentity(null) + setPermissions([]) + setError(err instanceof Error ? err.message : 'Sign-in failed') + setStatus('error') + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + const signOut = useCallback(async () => { + await revokeSession() + setIdentity(null) + setPermissions([]) + setStatus('anonymous') + }, []) + + const can = useCallback( + (resource: ResourceKey | string, action: ActionKey | string) => { + if (status !== 'authenticated') return false + return permissions.includes(`${resource}:${action}`) + }, + [permissions, status] + ) + + const value = useMemo( + () => ({ status, identity, permissions, error, can, signOut, refresh: load }), + [status, identity, permissions, error, can, signOut, load] + ) + + return {children} +} + +/** + * Access the current security state. + * + * Returns a fail-closed anonymous state when no provider is mounted, so a component + * rendered outside the tree degrades to "no identity, no permissions" rather than + * throwing or — far worse — appearing permitted. + */ +export function useSecurity(): SecurityContextValue { + const ctx = useContext(SecurityContext) + if (ctx) return ctx + return { + status: 'anonymous', + identity: null, + permissions: [], + error: null, + can: () => false, + signOut: async () => {}, + refresh: async () => {}, + } +} diff --git a/services/ui-react/src/lib/security/client.test.ts b/services/ui-react/src/lib/security/client.test.ts new file mode 100644 index 0000000..09dc6c8 --- /dev/null +++ b/services/ui-react/src/lib/security/client.test.ts @@ -0,0 +1,292 @@ +/** + * FuzeFront Security client — contract adherence and fail-closed behaviour. + * + * These pin the two properties the migration exists for: + * 1. Only published FuzeFront Security paths are called, same-origin under `/api`. + * No identity or policy vendor host is ever contacted. + * 2. Every authorization answer fails CLOSED — errors, non-2xx, and misaligned + * bulk responses all deny. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + API_BASE, + SECURITY_BASE, + TOKEN_STORAGE_KEY, + authHeader, + authzBulkCheck, + authzCheck, + exchangeCode, + getAuthMethods, + getPermissions, + getSession, + getToken, + logout, + setToken, + socialStartUrl, +} from './client' +import type { AuthzCheckRequest } from './contract' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +beforeEach(() => { + window.localStorage.clear() + delete (window as unknown as Record).__FUZEFRONT__ +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('same-origin base', () => { + it('is relative, never an absolute host (mixed content under TLS)', () => { + expect(API_BASE).toBe('/api') + expect(SECURITY_BASE).toBe('/api/v1/security') + expect(SECURITY_BASE.startsWith('http')).toBe(false) + }) + + it('builds the social start URL from the contract path', () => { + const url = socialStartUrl('google', 'https://app.example/agents') + expect(url).toBe( + '/api/v1/security/social/google/start?returnTo=https%3A%2F%2Fapp.example%2Fagents' + ) + }) +}) + +describe('token storage', () => { + it('prefers the FuzeFront shell token when embedded', () => { + window.localStorage.setItem(TOKEN_STORAGE_KEY, 'standalone-token') + ;(window as unknown as Record).__FUZEFRONT__ = { + token: 'shell-token', + } + expect(getToken()).toBe('shell-token') + }) + + it('falls back to its own stored token when standalone', () => { + setToken('standalone-token') + expect(getToken()).toBe('standalone-token') + expect(authHeader()).toEqual({ Authorization: 'Bearer standalone-token' }) + }) + + it('sends no Authorization header when signed out', () => { + expect(authHeader()).toEqual({}) + }) +}) + +describe('getSession', () => { + it('does not call the API at all when there is no token', async () => { + const fetchImpl = vi.fn() + await expect(getSession(fetchImpl as unknown as typeof fetch)).resolves.toBeNull() + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('calls GET /api/v1/security/session with the bearer token', async () => { + setToken('tok') + const identity = { + userId: 'u1', + tenantId: 't1', + roles: ['operator'], + authMode: 'federated-jwks' as const, + } + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ identity, user: { id: 'u1', email: 'u@x', roles: [] } }) + ) + + const session = await getSession(fetchImpl as unknown as typeof fetch) + + expect(session?.identity.userId).toBe('u1') + const [url, init] = fetchImpl.mock.calls[0] + expect(url).toBe('/api/v1/security/session') + expect(init.method).toBe('GET') + expect(init.headers.Authorization).toBe('Bearer tok') + }) + + it('treats 401 as "signed out", not as an error', async () => { + setToken('expired') + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ error: 'nope' }, 401)) + await expect(getSession(fetchImpl as unknown as typeof fetch)).resolves.toBeNull() + }) +}) + +describe('exchangeCode', () => { + it('POSTs the broker code to /session/exchange and stores the token', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ status: 'authenticated', token: 'new-token', user: {} }) + ) + + const result = await exchangeCode('abc123', fetchImpl as unknown as typeof fetch) + + expect(result.status).toBe('authenticated') + expect(getToken()).toBe('new-token') + const [url, init] = fetchImpl.mock.calls[0] + expect(url).toBe('/api/v1/security/session/exchange') + expect(JSON.parse(init.body)).toEqual({ code: 'abc123' }) + }) + + it('does not store a token for an mfa_required result', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ status: 'mfa_required', challengeId: 'c1', factors: [] }) + ) + const result = await exchangeCode('abc', fetchImpl as unknown as typeof fetch) + expect(result.status).toBe('mfa_required') + expect(getToken()).toBeNull() + }) +}) + +describe('logout', () => { + it('revokes server-side and clears the local token', async () => { + setToken('tok') + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + await logout(fetchImpl as unknown as typeof fetch) + expect(fetchImpl.mock.calls[0][0]).toBe('/api/v1/security/session') + expect(fetchImpl.mock.calls[0][1].method).toBe('DELETE') + expect(getToken()).toBeNull() + }) + + it('still clears the local token when the server call fails', async () => { + setToken('tok') + const fetchImpl = vi.fn().mockRejectedValue(new Error('offline')) + await expect(logout(fetchImpl as unknown as typeof fetch)).rejects.toThrow() + expect(getToken()).toBeNull() + }) +}) + +describe('getAuthMethods', () => { + it('reads the advertised methods rather than hard-coding providers', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + password: false, + social: ['google'], + mfa: { enabled: true, types: ['totp'] }, + verification: { email: true, sms: false }, + }) + ) + const methods = await getAuthMethods(fetchImpl as unknown as typeof fetch) + expect(fetchImpl.mock.calls[0][0]).toBe('/api/v1/security/methods') + expect(methods.social).toEqual(['google']) + }) +}) + +describe('authzCheck — fail closed', () => { + it('sends the bare policy keys from registration/policy.json', async () => { + setToken('tok') + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ allow: true })) + + const allowed = await authzCheck( + { + subject: 'u1', + tenant: 't1', + resource: { type: 'Agent', key: 'agent-7' }, + action: 'deploy', + }, + fetchImpl as unknown as typeof fetch + ) + + expect(allowed).toBe(true) + const [url, init] = fetchImpl.mock.calls[0] + expect(url).toBe('/api/v1/security/authz/check') + expect(JSON.parse(init.body)).toEqual({ + subject: 'u1', + tenant: 't1', + resource: { type: 'Agent', key: 'agent-7' }, + action: 'deploy', + }) + }) + + it('denies when the platform says allow:false', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ allow: false })) + await expect( + authzCheck( + { subject: 'u1', tenant: 't1', resource: { type: 'Task' }, action: 'assign' }, + fetchImpl as unknown as typeof fetch + ) + ).resolves.toBe(false) + }) + + it('denies on a transport error', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('network down')) + await expect( + authzCheck( + { subject: 'u1', tenant: 't1', resource: { type: 'Task' }, action: 'assign' }, + fetchImpl as unknown as typeof fetch + ) + ).resolves.toBe(false) + }) + + it('denies on a 5xx', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ error: 'boom' }, 503)) + await expect( + authzCheck( + { subject: 'u1', tenant: 't1', resource: { type: 'Task' }, action: 'assign' }, + fetchImpl as unknown as typeof fetch + ) + ).resolves.toBe(false) + }) + + it('denies with no tenant scope — a null tenant is never "unrestricted"', async () => { + const fetchImpl = vi.fn() + await expect( + authzCheck( + { subject: 'u1', tenant: '', resource: { type: 'Task' }, action: 'read' }, + fetchImpl as unknown as typeof fetch + ) + ).resolves.toBe(false) + expect(fetchImpl).not.toHaveBeenCalled() + }) +}) + +describe('authzBulkCheck — fail closed', () => { + const checks: AuthzCheckRequest[] = [ + { subject: 'u1', tenant: 't1', resource: { type: 'Agent' }, action: 'read' }, + { subject: 'u1', tenant: 't1', resource: { type: 'Agent' }, action: 'delete' }, + ] + + it('returns decisions index-aligned with the request', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ decisions: [{ allow: true }, { allow: false }] })) + await expect( + authzBulkCheck(checks, fetchImpl as unknown as typeof fetch) + ).resolves.toEqual([true, false]) + }) + + it('denies everything when the decision list is misaligned', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ decisions: [{ allow: true }] })) + await expect( + authzBulkCheck(checks, fetchImpl as unknown as typeof fetch) + ).resolves.toEqual([false, false]) + }) + + it('denies everything on error', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('offline')) + await expect( + authzBulkCheck(checks, fetchImpl as unknown as typeof fetch) + ).resolves.toEqual([false, false]) + }) +}) + +describe('getPermissions — fail closed', () => { + it('requests the effective grants for subject + tenant', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ subject: 'u1', tenant: 't1', permissions: ['Agent:read'] }) + ) + await expect( + getPermissions('u1', 't1', fetchImpl as unknown as typeof fetch) + ).resolves.toEqual(['Agent:read']) + expect(fetchImpl.mock.calls[0][0]).toBe( + '/api/v1/security/authz/permissions?subject=u1&tenant=t1' + ) + }) + + it('returns [] on failure — "no permissions known", never "unrestricted"', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ error: 'down' }, 500)) + await expect( + getPermissions('u1', 't1', fetchImpl as unknown as typeof fetch) + ).resolves.toEqual([]) + }) +}) diff --git a/services/ui-react/src/lib/security/client.ts b/services/ui-react/src/lib/security/client.ts new file mode 100644 index 0000000..7b031f3 --- /dev/null +++ b/services/ui-react/src/lib/security/client.ts @@ -0,0 +1,296 @@ +/** + * FuzeFront Security API client (browser). + * + * Speaks ONLY the published FuzeFront Security contract, same-origin under `/api`: + * + * GET /api/v1/security/session current identity ("me") + * DELETE /api/v1/security/session logout + * POST /api/v1/security/session/exchange broker code -> session + * GET /api/v1/security/methods which sign-in methods exist + * GET /api/v1/security/social/{provider}/start server-brokered social login + * POST /api/v1/security/authz/check one decision + * POST /api/v1/security/authz/bulk-check many decisions + * GET /api/v1/security/authz/permissions effective grants + * + * No identity provider and no policy engine is named here, and none is contacted + * directly: the browser only ever talks to its own origin. Per the contract's boundary + * guarantee, the sign-in redirect goes to FuzeFront's own `/social/{provider}/start`, + * which brokers onward — no FuzeFront-internal identity host is ever visible. + * + * SAME-ORIGIN ONLY. The base is the relative string `/api`; never an absolute host. + * An absolute `http://` base is mixed content under TLS and breaks in prod ingress. + */ + +import type { + AuthMethods, + AuthzBulkDecision, + AuthzCheckRequest, + AuthzDecision, + Identity, + PermissionSet, + ResourceRef, + SessionInfo, + SessionResult, +} from './contract' + +/** Same-origin API base. Deliberately relative — do not make this configurable. */ +export const API_BASE = '/api' +export const SECURITY_BASE = `${API_BASE}/v1/security` + +/** localStorage key holding the FuzeFront session token for standalone mode. */ +export const TOKEN_STORAGE_KEY = 'fuzefront.session.token' + +/** Query param FuzeFront's social callback appends when returning to us. */ +export const BROKER_CODE_PARAM = 'code' + +export class SecurityError extends Error { + status: number + body: unknown + constructor(status: number, message: string, body: unknown) { + super(message) + this.name = 'SecurityError' + this.status = status + this.body = body + } +} + +// --------------------------------------------------------------------------- +// Token storage +// --------------------------------------------------------------------------- +// +// When FuzeAgent runs INSIDE the FuzeFront shell the host owns the session and +// exposes it on `window.__FUZEFRONT__`; we read it and never write it. Standalone, +// we hold the token ourselves after a `/session/exchange`. + +interface FuzeFrontSentinel { + token?: string + user?: { userId?: string; email?: string; tenantId?: string | null; roles?: string[] } +} + +function sentinel(): FuzeFrontSentinel | undefined { + if (typeof window === 'undefined') return undefined + return (window as unknown as Record).__FUZEFRONT__ as + | FuzeFrontSentinel + | undefined +} + +export function getToken(): string | null { + const hosted = sentinel()?.token + if (hosted) return hosted + if (typeof window === 'undefined') return null + try { + return window.localStorage.getItem(TOKEN_STORAGE_KEY) + } catch { + return null + } +} + +export function setToken(token: string | null): void { + if (typeof window === 'undefined') return + try { + if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token) + else window.localStorage.removeItem(TOKEN_STORAGE_KEY) + } catch { + /* storage unavailable (private mode) — the session is then request-scoped only */ + } +} + +/** Authorization header for any FuzeAgent API call, or `{}` when unauthenticated. */ +export function authHeader(): Record { + const token = getToken() + return token ? { Authorization: `Bearer ${token}` } : {} +} + +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + +type FetchImpl = typeof fetch + +async function request( + method: string, + path: string, + init: { body?: unknown; fetchImpl?: FetchImpl } = {} +): Promise { + const headers: Record = { Accept: 'application/json', ...authHeader() } + let payload: string | undefined + if (init.body !== undefined) { + headers['Content-Type'] = 'application/json' + payload = JSON.stringify(init.body) + } + const doFetch = init.fetchImpl ?? globalThis.fetch + const response = await doFetch(`${SECURITY_BASE}${path}`, { + method, + headers, + body: payload, + }) + + const text = await response.text() + let data: unknown + try { + data = text ? JSON.parse(text) : undefined + } catch { + data = text + } + + if (!response.ok) { + const message = + data && typeof data === 'object' && 'error' in (data as Record) + ? String((data as Record).error) + : response.statusText || `Request failed with ${response.status}` + throw new SecurityError(response.status, message, data) + } + return data as T +} + +// --------------------------------------------------------------------------- +// AuthN +// --------------------------------------------------------------------------- + +/** + * Current identity. Returns `null` when the caller has no valid session (401) — + * that is the normal "signed out" answer, not an error. + */ +export async function getSession(fetchImpl?: FetchImpl): Promise { + if (!getToken()) return null + try { + return await request('GET', '/session', { fetchImpl }) + } catch (error) { + if (error instanceof SecurityError && error.status === 401) return null + throw error + } +} + +/** Which sign-in methods this deployment offers. Never hard-code a provider list. */ +export function getAuthMethods(fetchImpl?: FetchImpl): Promise { + return request('GET', '/methods', { fetchImpl }) +} + +/** + * URL that begins a server-brokered social sign-in. This is a full-page navigation, + * not a fetch: FuzeFront redirects the browser onward and back to `returnTo` with a + * single-use `code`. + */ +export function socialStartUrl(provider: string, returnTo: string): string { + const params = new URLSearchParams({ returnTo }) + return `${SECURITY_BASE}/social/${encodeURIComponent(provider)}/start?${params.toString()}` +} + +/** + * Exchange the opaque, single-use broker `code` from the callback for a session. + * On `status: 'authenticated'` the token is stored. An `mfa_required` result is + * returned as-is for the caller to complete via the contract's `/mfa/*` endpoints. + */ +export async function exchangeCode( + code: string, + fetchImpl?: FetchImpl +): Promise { + const result = await request('POST', '/session/exchange', { + body: { code }, + fetchImpl, + }) + if (result.status === 'authenticated') setToken(result.token) + return result +} + +/** Revoke the current session server-side and drop the local token. Idempotent. */ +export async function logout(fetchImpl?: FetchImpl): Promise { + try { + if (getToken()) await request('DELETE', '/session', { fetchImpl }) + } finally { + setToken(null) + } +} + +// --------------------------------------------------------------------------- +// AuthZ — decisions are the platform's; FuzeAgent ships no policy +// --------------------------------------------------------------------------- + +/** + * One authorization decision. Fail-closed: any error resolves to `false`, so a broken + * or unreachable security service hides capability rather than exposing it. + */ +export async function authzCheck( + input: { + subject: string + tenant: string + resource: ResourceRef + action: string + context?: Record + }, + fetchImpl?: FetchImpl +): Promise { + if (!input.subject || !input.tenant) return false + try { + const body: AuthzCheckRequest = input + const decision = await request('POST', '/authz/check', { + body, + fetchImpl, + }) + return decision?.allow === true + } catch { + return false + } +} + +/** + * Many decisions in one round trip, index-aligned with the input. Fail-closed: any + * error — including a decision list whose length does not match the request — denies + * everything rather than letting a short list read as "allowed". + */ +export async function authzBulkCheck( + checks: AuthzCheckRequest[], + fetchImpl?: FetchImpl +): Promise { + if (checks.length === 0) return [] + const denied = checks.map(() => false) + try { + const result = await request('POST', '/authz/bulk-check', { + body: { checks }, + fetchImpl, + }) + if (!Array.isArray(result?.decisions) || result.decisions.length !== checks.length) { + return denied + } + return result.decisions.map((d) => d?.allow === true) + } catch { + return denied + } +} + +/** + * Effective `Resource:action` grants for a subject in a tenant — one round trip that + * lets the UI gate many controls without N checks. Fail-closed: `[]` on any error, + * which means "no permissions known", never "unrestricted". + */ +export async function getPermissions( + subject: string, + tenant: string, + fetchImpl?: FetchImpl +): Promise { + if (!subject || !tenant) return [] + try { + const params = new URLSearchParams({ subject, tenant }) + const result = await request( + 'GET', + `/authz/permissions?${params.toString()}`, + { fetchImpl } + ) + return Array.isArray(result?.permissions) ? result.permissions : [] + } catch { + return [] + } +} + +/** Identity exposed by the FuzeFront shell, when FuzeAgent is running embedded. */ +export function hostedIdentity(): Identity | null { + const user = sentinel()?.user + if (!user?.userId) return null + return { + userId: user.userId, + tenantId: user.tenantId ?? null, + roles: user.roles ?? [], + email: user.email, + authMode: 'federated-jwks', + } +} diff --git a/services/ui-react/src/lib/security/contract.ts b/services/ui-react/src/lib/security/contract.ts new file mode 100644 index 0000000..15a9619 --- /dev/null +++ b/services/ui-react/src/lib/security/contract.ts @@ -0,0 +1,170 @@ +/** + * FuzeFront Security contract — types. + * + * These mirror `@fuzefront/security-client` (the package generated from FuzeFront's + * `packages/security/openapi.yaml`) one-for-one. They are NOT a FuzeAgent invention: + * every shape and every path here exists in the published contract. + * + * WHY VENDORED RATHER THAN IMPORTED + * --------------------------------- + * `@fuzefront/security-client` publishes to the private GitHub Packages registry + * (`npm.pkg.github.com`, access "restricted"). This workspace's CI runs `npm ci` with + * no registry credentials and no `.npmrc`, so adding it to `package.json` would fail + * the install for every build — the same reason `@fuzefront/identity-ui` and + * `@izzywdev/fuzefront-sdk-react` are `require()`d optionally rather than declared. + * + * When the private registry is wired up for this repo, delete this file and replace the + * imports with: + * + * import type { Identity, AuthMethods, SessionResult } from '@fuzefront/security-client' + * + * The names below are deliberately identical so that swap is mechanical. + * + * Contract: FuzeFront Security API v0.4.0 / `@fuzefront/security-client` v0.2.0. + */ + +/** Which verifier produced an identity. Provider-neutral by contract. */ +export type AuthMode = 'legacy-hs256' | 'federated-jwks' + +/** + * The stable, normalized identity every consumer receives regardless of which verifier + * produced it. The contract's keystone — invariant across token-format migrations. + */ +export interface Identity { + /** Stable subject identifier. Always present. */ + userId: string + /** + * Tenant/organization scope. `null` when unknown; consumers fail closed on + * tenant-scoped decisions when this is null. + */ + tenantId: string | null + /** Role slugs. Always an array; empty means "no roles known". */ + roles: string[] + email?: string + authMode: AuthMode + issuedAt?: number + expiresAt?: number + issuer?: string +} + +/** Hydrated user record returned alongside the identity by `GET /session`. */ +export interface SecurityUser { + id: string + email: string + roles: string[] + [key: string]: unknown +} + +/** `GET /v1/security/session` response. */ +export interface SessionInfo { + identity: Identity + user: SecurityUser +} + +/** Supported social provider slugs. Extensible; `google` is first. */ +export type SocialProvider = 'google' + +/** Neutral MFA factor type. */ +export type MfaFactorType = 'totp' | 'sms' | 'email' | 'webauthn' + +/** + * Neutral capability descriptor for the auth surface — what sign-in methods this + * deployment actually offers. Read it; never hard-code a provider list. + */ +export interface AuthMethods { + password: boolean + social: SocialProvider[] + mfa: { enabled: boolean; types: MfaFactorType[] } + verification: { email: boolean; sms: boolean } +} + +/** + * Discriminated login/exchange outcome: an authenticated session, or an MFA-required + * challenge. Narrow on `status` before reading variant fields. + */ +export type SessionResult = + | { status: 'authenticated'; token: string; sessionId?: string; user: unknown } + | { + status: 'mfa_required' + challengeId: string + factors: { factorId: string; type: MfaFactorType }[] + } + +/** A resource-instance reference for a (possibly ReBAC-scoped) decision. */ +export interface ResourceRef { + type: string + key?: string +} + +/** `POST /v1/security/authz/check` request. */ +export interface AuthzCheckRequest { + subject: string + tenant: string + resource: ResourceRef + action: string + context?: Record +} + +/** `POST /v1/security/authz/check` response. */ +export interface AuthzDecision { + allow: boolean +} + +/** `POST /v1/security/authz/bulk-check` response — index-aligned with the request. */ +export interface AuthzBulkDecision { + decisions: AuthzDecision[] +} + +/** `GET /v1/security/authz/permissions` response. Effective `Resource:action` grants. */ +export interface PermissionSet { + subject: string + tenant: string + permissions: string[] +} + +/** Stable, provider-neutral error codes. Fail-closed. */ +export type SecurityErrorCode = + | 'NO_TOKEN' + | 'MALFORMED' + | 'INVALID_SIGNATURE' + | 'EXPIRED' + | 'NOT_ACTIVE' + | 'INVALID_ISSUER' + | 'INVALID_AUDIENCE' + | 'MISSING_CLAIM' + | 'JWKS_UNAVAILABLE' + | 'VERIFIER_UNAVAILABLE' + | 'INVALID_CREDENTIALS' + | 'INVALID_CODE' + | 'CONFLICT' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'PROVIDER_UNAVAILABLE' + | 'UNKNOWN' + +// --------------------------------------------------------------------------- +// FuzeAgent's own policy vocabulary — the BARE keys registered in +// registration/policy.json. These are what we send as `resource.type` / `action`. +// They are FuzeAgent's vocabulary; the engine that evaluates them is the platform's +// business and is never named here. +// --------------------------------------------------------------------------- + +export const RESOURCE = { + Organization: 'Organization', + Team: 'Team', + Agent: 'Agent', + Task: 'Task', + Goal: 'Goal', +} as const + +export const ACTION = { + read: 'read', + create: 'create', + update: 'update', + delete: 'delete', + deploy: 'deploy', + assign: 'assign', +} as const + +export type ResourceKey = (typeof RESOURCE)[keyof typeof RESOURCE] +export type ActionKey = (typeof ACTION)[keyof typeof ACTION] diff --git a/services/ui-react/src/main.tsx b/services/ui-react/src/main.tsx index e84e30a..6cd55e7 100644 --- a/services/ui-react/src/main.tsx +++ b/services/ui-react/src/main.tsx @@ -3,6 +3,8 @@ import { StrictMode, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import AppRouter from './components/AppRouter.tsx' +import { SecurityProvider } from './lib/security/SecurityProvider' +import AuthGate from './components/auth/AuthGate' // --------------------------------------------------------------------------- // Optional FuzeFront platform integration @@ -35,7 +37,16 @@ function inPlatform(): boolean { } function AppWithPlatform(): React.ReactElement { - const app = + // Identity + permissions come from the FuzeFront Security API (or, when embedded, + // from the shell). AuthGate shows the sign-in redirect surface only when the + // platform actually advertises sign-in methods — see AuthGate for why. + const app = ( + + + + + + ) if (PlatformProvider && inPlatform()) { return {app} diff --git a/services/ui-react/src/pages/IdentityPage.tsx b/services/ui-react/src/pages/IdentityPage.tsx index d1c22bc..1d48f76 100644 --- a/services/ui-react/src/pages/IdentityPage.tsx +++ b/services/ui-react/src/pages/IdentityPage.tsx @@ -1,10 +1,33 @@ /** * IdentityOrgPage — renders the @fuzefront/identity-ui IdentityPage. - * Degrades gracefully when the package is absent. + * + * `@fuzefront/identity-ui` is the platform's ORG-MEMBER / ROLE / API-TOKEN management + * surface (members table, invitations, roles & permissions, API tokens). It is not a + * sign-in screen — sign-in lives in `components/auth/SignIn.tsx` and redirects to + * FuzeFront's own server-brokered start endpoint. + * + * Two defects are fixed here: + * + * 1. `baseUrl: '/api/identity'`. The identity client's own paths already begin with + * `/api/organizations/...`, so every request went to + * `/api/identity/api/organizations/...` and 404'd. The correct base is `''` — + * same origin, which is also what the host contract requires (never an absolute + * API host: that is mixed content under TLS). + * 2. No `getToken`. Requests carried no Authorization header, so even a + * correctly-routed call would have been rejected. The token now comes from the + * FuzeFront session — the shell's when embedded, ours when standalone. + * + * The org id defaults to the caller's real tenant from the platform identity rather + * than the literal string 'default', so the page addresses the org the user is in. + * + * The package stays optionally `require`d: it publishes to the private GitHub Packages + * registry, which this workspace's CI has no credentials for. */ /* eslint-disable @typescript-eslint/no-require-imports */ import React from 'react' +import { getToken } from '../lib/security/client' +import { useSecurity } from '../lib/security/SecurityProvider' // --------------------------------------------------------------------------- // Try to import identity-ui at module level @@ -16,7 +39,10 @@ type IdentityClient = any type IdentityComponents = { IdentityPage: React.ComponentType<{ client: IdentityClient; orgId: string }> IdentityI18nProvider: React.ComponentType<{ children: React.ReactNode }> - createIdentityClient: (options: { baseUrl: string }) => IdentityClient + createIdentityClient: (options: { + baseUrl?: string + getToken?: () => string | null | undefined + }) => IdentityClient } let identityPkg: IdentityComponents | null = null @@ -30,19 +56,22 @@ try { // --------------------------------------------------------------------------- // Create the client once (outside the component) so it's stable across renders. // Only constructed when the package is present. +// +// `baseUrl: ''` = same origin. `getToken` is read lazily on every request, so a +// sign-in or sign-out that happens after mount is picked up without rebuilding +// the client. // --------------------------------------------------------------------------- -const identityClient: IdentityClient = - identityPkg?.createIdentityClient - ? identityPkg.createIdentityClient({ baseUrl: '/api/identity' }) - : null +const identityClient: IdentityClient = identityPkg?.createIdentityClient + ? identityPkg.createIdentityClient({ baseUrl: '', getToken }) + : null // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface IdentityOrgPageProps { - /** Organisation to render identity settings for. Defaults to 'default'. */ + /** Organisation to render identity settings for. Defaults to the caller's tenant. */ orgId?: string } @@ -50,7 +79,10 @@ interface IdentityOrgPageProps { // Component // --------------------------------------------------------------------------- -export function IdentityOrgPage({ orgId = 'default' }: IdentityOrgPageProps): React.ReactElement { +export function IdentityOrgPage({ orgId }: IdentityOrgPageProps): React.ReactElement { + const { identity } = useSecurity() + const resolvedOrgId = orgId ?? identity?.tenantId ?? null + if (!identityPkg || !identityClient) { return (
@@ -62,11 +94,24 @@ export function IdentityOrgPage({ orgId = 'default' }: IdentityOrgPageProps): Re ) } + // No tenant means no org-scoped page is addressable. Say so rather than requesting + // a made-up 'default' org and rendering someone else's members list or an error. + if (!resolvedOrgId) { + return ( +
+

Identity

+

+ No organization is associated with your session yet. +

+
+ ) + } + const { IdentityI18nProvider, IdentityPage: IdentityPageComponent } = identityPkg return ( - + ) }