Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion agent-templates/a2a/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions agent-templates/a2a/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
},
Expand All @@ -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"
)
10 changes: 5 additions & 5 deletions agent-templates/a2a/tests/test_runtime_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
],
)
Expand Down
26 changes: 25 additions & 1 deletion deploy/helm/a2a-shared/values-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 22 additions & 2 deletions deploy/helm/fuzeagent/templates/orchestrator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions deploy/helm/fuzeagent/values-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion deploy/helm/fuzeagent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
17 changes: 14 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
33 changes: 26 additions & 7 deletions hierarchy_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down
Loading
Loading