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 ( - + ) }