diff --git a/.env.example b/.env.example index ef4ad31..117174c 100644 --- a/.env.example +++ b/.env.example @@ -44,15 +44,35 @@ CHUNK_OVERLAP=150 # Guardrails. REDACT_PII=true -# Multi-tenancy — tenant id comes from the X-Tenant-ID header and namespaces -# graph threads, the approval queue, and the knowledge base (chunks are tagged -# at ingest, retrieval is filtered per tenant). Missing header falls back to -# DEFAULT_TENANT (single-tenant deploys need nothing here; legacy untagged -# chunks are auto-backfilled to DEFAULT_TENANT on startup). Set REQUIRE_TENANT -# to reject requests without a valid tenant id. +# Multi-tenancy — the tenant namespaces graph threads, the approval queue, and +# the knowledge base (chunks are tagged at ingest, retrieval is filtered per +# tenant). Where the tenant comes from depends on AUTH_BACKEND below. With +# AUTH_BACKEND=none a missing header falls back to DEFAULT_TENANT (single-tenant +# deploys need nothing here; legacy untagged chunks are auto-backfilled to +# DEFAULT_TENANT on startup). Set REQUIRE_TENANT to reject requests without a +# valid tenant id (only meaningful when AUTH_BACKEND=none). DEFAULT_TENANT=default REQUIRE_TENANT=false +# Authentication — how callers are identified and where the tenant comes from. +# none — no auth; the X-Tenant-ID header is trusted (dev / single-tenant). +# api_key — bearer key from a static map; the key fixes the tenant. +# oidc — RS256 JWT; the tenant is read from OIDC_TENANT_CLAIM. +# With api_key/oidc the credential carries the tenant, so X-Tenant-ID is ignored. +AUTH_BACKEND=none +# api_key backend: comma-separated ":" pairs. Each key authenticates +# as exactly one tenant. Send it as "Authorization: Bearer ". +# API_KEYS=sk-acme-123:acme,sk-globex-456:globex +API_KEYS= +# oidc backend: validate RS256 JWTs. Provide a JWKS URL (production) OR a static +# PEM public key; the tenant is read from OIDC_TENANT_CLAIM. ISSUER/AUDIENCE are +# verified when set. +OIDC_JWKS_URL= +OIDC_PUBLIC_KEY= +OIDC_ISSUER= +OIDC_AUDIENCE= +OIDC_TENANT_CLAIM=tenant + # Observability — "langsmith" | "langfuse" | "none". OBSERVABILITY_BACKEND=none LANGCHAIN_API_KEY= diff --git a/README.md b/README.md index 924a4a4..4040c0c 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,10 @@ Chat UI + admin views: live traces, eval scores, approval queue. The full-stack - ✅ Multi-agent / supervisor graphs - ✅ Qdrant + alternative vector-store adapters - ✅ Additional reference examples (insurance, support) -- Multi-tenancy +- ✅ Multi-tenancy - ✅ Per-tenant thread + approval isolation - ✅ Per-tenant knowledge-base isolation - - Pluggable auth (replace the trusted X-Tenant-ID header) + - ✅ Pluggable auth — API key / OIDC, replacing the trusted X-Tenant-ID header --- diff --git a/agentforge/api/auth.py b/agentforge/api/auth.py new file mode 100644 index 0000000..a1ac8bd --- /dev/null +++ b/agentforge/api/auth.py @@ -0,0 +1,152 @@ +"""Pluggable authentication: turn a request into a verified ``Principal``. + +``AUTH_BACKEND`` selects the scheme: + +- ``"none"`` (default) — no authentication; the caller's ``X-Tenant-ID`` header + is trusted (dev / single-tenant). ``authenticate`` returns ``None`` so the + tenant falls back to header / default resolution in ``tenancy``. +- ``"api_key"`` — a static ``:`` map (``API_KEYS``). The bearer key + both authenticates the caller and fixes its tenant. +- ``"oidc"`` — an RS256 JWT validated against a JWKS URL (``OIDC_JWKS_URL``) or a + static PEM public key (``OIDC_PUBLIC_KEY``); the tenant is read from the + ``OIDC_TENANT_CLAIM`` claim. + +In every authenticated backend the credential *carries* the tenant, so the +``X-Tenant-ID`` header is ignored once auth is on — a caller can only ever act +as the tenant its credential grants. ``authenticate`` either returns a +``Principal`` or raises ``401`` (bad/missing credential) / ``403`` (authenticated +but the credential carries no usable tenant). The returned tenant id is *not* +shape-validated here; ``tenancy.resolve_tenant`` applies the same validation to +both the header and credential paths. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import HTTPException, Request + +from agentforge.config import Settings, get_settings + +_BEARER_CHALLENGE = {"WWW-Authenticate": "Bearer"} + + +@dataclass(frozen=True) +class Principal: + """An authenticated caller and the tenant its credential grants.""" + + subject: str + tenant_id: str + + +def authenticate(request: Request) -> Principal | None: + """Verify the request's credential and return its ``Principal``. + + Returns ``None`` only for ``AUTH_BACKEND=none`` (no auth configured), + signalling the caller to fall back to trusted-header tenant resolution. + Every other backend either returns a ``Principal`` or raises. + """ + settings = get_settings() + backend = settings.auth_backend.lower() + if backend == "none": + return None + if backend == "api_key": + return _authenticate_api_key(request, settings) + if backend == "oidc": + return _authenticate_oidc(request, settings) + raise HTTPException( + status_code=500, detail=f"Unknown AUTH_BACKEND {settings.auth_backend!r}" + ) + + +def _bearer_token(request: Request) -> str: + """Extract the bearer token, or 401 if the Authorization header is absent.""" + header = request.headers.get("Authorization", "") + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + raise HTTPException( + status_code=401, + detail="Missing or malformed Authorization: Bearer header", + headers=_BEARER_CHALLENGE, + ) + return token.strip() + + +def parse_api_keys(raw: str) -> dict[str, str]: + """``":,:"`` -> ``{key: tenant}``. + + Whitespace and blank entries are ignored; a malformed pair raises so a + misconfiguration surfaces at startup rather than silently dropping a key. + """ + mapping: dict[str, str] = {} + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + key, sep, tenant = entry.partition(":") + if not sep or not key.strip() or not tenant.strip(): + raise ValueError( + f"Malformed API_KEYS entry {entry!r}; expected ':'" + ) + mapping[key.strip()] = tenant.strip() + return mapping + + +def _authenticate_api_key(request: Request, settings: Settings) -> Principal: + keys = parse_api_keys(settings.api_keys) + if not keys: + raise HTTPException( + status_code=500, detail="AUTH_BACKEND=api_key but API_KEYS is empty" + ) + token = _bearer_token(request) + tenant = keys.get(token) + if tenant is None: + raise HTTPException( + status_code=401, detail="Invalid API key", headers=_BEARER_CHALLENGE + ) + # Don't echo the secret in the subject; the tenant is identity enough here. + return Principal(subject=f"apikey:{tenant}", tenant_id=tenant) + + +def _authenticate_oidc(request: Request, settings: Settings) -> Principal: + import jwt # PyJWT + + token = _bearer_token(request) + try: + claims = jwt.decode( + token, + _oidc_signing_key(settings, token), + algorithms=["RS256"], + issuer=settings.oidc_issuer, + audience=settings.oidc_audience, + options={ + "require": ["exp"], + "verify_iss": settings.oidc_issuer is not None, + "verify_aud": settings.oidc_audience is not None, + }, + ) + except jwt.InvalidTokenError as exc: + raise HTTPException( + status_code=401, detail=f"Invalid token: {exc}", headers=_BEARER_CHALLENGE + ) from exc + tenant = claims.get(settings.oidc_tenant_claim) + if not isinstance(tenant, str) or not tenant: + raise HTTPException( + status_code=403, + detail=f"Token has no usable {settings.oidc_tenant_claim!r} claim", + ) + return Principal(subject=str(claims.get("sub", "")), tenant_id=tenant) + + +def _oidc_signing_key(settings: Settings, token: str): + """Resolve the RS256 verification key: JWKS endpoint or a static PEM.""" + if settings.oidc_jwks_url: + from jwt import PyJWKClient + + return PyJWKClient(settings.oidc_jwks_url).get_signing_key_from_jwt(token).key + if settings.oidc_public_key: + return settings.oidc_public_key + raise HTTPException( + status_code=500, + detail="AUTH_BACKEND=oidc requires OIDC_JWKS_URL or OIDC_PUBLIC_KEY", + ) diff --git a/agentforge/api/tenancy.py b/agentforge/api/tenancy.py index b2886cc..2897a8e 100644 --- a/agentforge/api/tenancy.py +++ b/agentforge/api/tenancy.py @@ -1,11 +1,13 @@ """Tenant identity + per-tenant scoping for graph threads and approvals. -The tenant is taken from the ``X-Tenant-ID`` header (trusted for now — -authentication arrives in a later step) and used to namespace checkpoint -threads and the approval registry, so one tenant can never see or resume -another's runs. The knowledge base is scoped separately (chunks are tagged with -tenant_id at ingest and retrieval is filtered to the caller's tenant); see -``agentforge.rag.store.tenant_filter``. +The tenant is derived from the request's verified identity (see +``agentforge.api.auth``): with a real ``AUTH_BACKEND`` the authenticated +credential carries the tenant; with ``AUTH_BACKEND=none`` the ``X-Tenant-ID`` +header is trusted (dev / single-tenant). Either way the resolved tenant +namespaces checkpoint threads and the approval registry, so one tenant can +never see or resume another's runs. The knowledge base is scoped separately +(chunks are tagged with tenant_id at ingest and retrieval is filtered to the +caller's tenant); see ``agentforge.rag.store.tenant_filter``. """ from __future__ import annotations @@ -14,6 +16,7 @@ from fastapi import HTTPException, Request +from agentforge.api.auth import authenticate from agentforge.config import get_settings TENANT_HEADER = "X-Tenant-ID" @@ -25,15 +28,25 @@ def resolve_tenant(request: Request) -> str: """FastAPI dependency returning the validated tenant id for the request. - Missing header -> ``default_tenant`` (unless ``require_tenant`` is set, in - which case it's a 400). An ill-formed id is always a 400. + When an auth backend is configured the tenant comes from the verified + credential (the ``X-Tenant-ID`` header is ignored). With ``AUTH_BACKEND=none`` + the header is trusted: missing header -> ``default_tenant`` (unless + ``require_tenant`` is set, in which case it's a 400). An ill-formed id is + always a 400, regardless of where it came from. """ + principal = authenticate(request) + if principal is not None: + return _validate_tenant_id(principal.tenant_id) settings = get_settings() raw = request.headers.get(TENANT_HEADER) if not raw: if settings.require_tenant: raise HTTPException(status_code=400, detail=f"Missing {TENANT_HEADER} header") return settings.default_tenant + return _validate_tenant_id(raw) + + +def _validate_tenant_id(raw: str) -> str: if not _VALID_ID.match(raw): raise HTTPException(status_code=400, detail="Invalid tenant id") return raw diff --git a/agentforge/config.py b/agentforge/config.py index cd119f1..7bec973 100644 --- a/agentforge/config.py +++ b/agentforge/config.py @@ -81,6 +81,24 @@ class Settings(BaseSettings): default_tenant: str = Field(default="default") require_tenant: bool = Field(default=False) + # --- Auth ------------------------------------------------------------ + # How callers are authenticated, and where the tenant comes from: + # "none" — no auth; the X-Tenant-ID header is trusted (dev / single- + # tenant). This is the zero-config default. + # "api_key" — a static ":,..." map (api_keys); the bearer + # key authenticates the caller and fixes its tenant. + # "oidc" — an RS256 JWT validated against oidc_jwks_url or a static + # oidc_public_key; the tenant is read from oidc_tenant_claim. + # With api_key/oidc the credential carries the tenant, so X-Tenant-ID is + # ignored — a caller can only act as the tenant its credential grants. + auth_backend: str = Field(default="none") + api_keys: str = Field(default="") + oidc_jwks_url: str | None = None + oidc_public_key: str | None = None + oidc_issuer: str | None = None + oidc_audience: str | None = None + oidc_tenant_claim: str = Field(default="tenant") + # --- Guardrails ------------------------------------------------------ redact_pii: bool = Field(default=True) # Tools that always require human approval before execution. diff --git a/pyproject.toml b/pyproject.toml index ff99f10..d5c769c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.30", "sse-starlette>=2.1", + # Auth: validate OIDC/JWT bearer tokens (RS256 needs the crypto extra). + "pyjwt[crypto]>=2.9", # --- Config / utils --- "pydantic>=2.7", "pydantic-settings>=2.3", diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..0f84bda --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,254 @@ +"""Pluggable authentication: the AUTH_BACKEND seam in front of tenant resolution. + +Covers the three backends offline — ``none`` (trusted header), ``api_key`` +(static key->tenant map), and ``oidc`` (RS256 JWT verified against a locally +minted keypair, no network) — plus the guarantee that an authenticated +credential's tenant overrides any X-Tenant-ID the caller sends. +""" + +from __future__ import annotations + +import datetime as dt +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +import agentforge.api.auth as auth +import agentforge.api.tenancy as tenancy + + +class _Req: + """Minimal Starlette Request stand-in (only ``.headers.get`` is used).""" + + def __init__(self, headers: dict | None = None): + self.headers = headers or {} + + +def _settings(**overrides): + base = dict( + auth_backend="none", + api_keys="", + oidc_jwks_url=None, + oidc_public_key=None, + oidc_issuer=None, + oidc_audience=None, + oidc_tenant_claim="tenant", + # consulted by tenancy.resolve_tenant on the none/header path + default_tenant="default", + require_tenant=False, + ) + base.update(overrides) + return SimpleNamespace(**base) + + +def _use(monkeypatch, **overrides): + settings = _settings(**overrides) + monkeypatch.setattr(auth, "get_settings", lambda: settings) + monkeypatch.setattr(tenancy, "get_settings", lambda: settings) + return settings + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# --- parse_api_keys --------------------------------------------------------- + + +def test_parse_api_keys_maps_each_key_to_its_tenant(): + assert auth.parse_api_keys("k1:acme, k2:globex") == {"k1": "acme", "k2": "globex"} + + +def test_parse_api_keys_ignores_blanks(): + assert auth.parse_api_keys(" ,k1:acme, ") == {"k1": "acme"} + + +@pytest.mark.parametrize("raw", ["nocolon", "k1:", ":tenant"]) +def test_parse_api_keys_rejects_malformed(raw): + with pytest.raises(ValueError): + auth.parse_api_keys(raw) + + +# --- none backend ----------------------------------------------------------- + + +def test_none_backend_returns_no_principal(monkeypatch): + _use(monkeypatch, auth_backend="none") + assert auth.authenticate(_Req(_bearer("anything"))) is None + + +# --- api_key backend -------------------------------------------------------- + + +def test_api_key_valid_key_yields_its_tenant(monkeypatch): + _use(monkeypatch, auth_backend="api_key", api_keys="sk-acme:acme,sk-glx:globex") + principal = auth.authenticate(_Req(_bearer("sk-glx"))) + assert principal is not None + assert principal.tenant_id == "globex" + + +def test_api_key_invalid_key_is_401(monkeypatch): + _use(monkeypatch, auth_backend="api_key", api_keys="sk-acme:acme") + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer("wrong"))) + assert exc.value.status_code == 401 + + +def test_api_key_missing_header_is_401(monkeypatch): + _use(monkeypatch, auth_backend="api_key", api_keys="sk-acme:acme") + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req()) + assert exc.value.status_code == 401 + + +def test_api_key_empty_config_is_500(monkeypatch): + _use(monkeypatch, auth_backend="api_key", api_keys="") + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer("sk-acme"))) + assert exc.value.status_code == 500 + + +def test_api_key_tenant_overrides_x_tenant_header(monkeypatch): + # The credential carries the tenant; a spoofed X-Tenant-ID must not win. + _use(monkeypatch, auth_backend="api_key", api_keys="sk-acme:acme") + headers = {**_bearer("sk-acme"), "X-Tenant-ID": "globex"} + assert tenancy.resolve_tenant(_Req(headers)) == "acme" + + +def test_unknown_backend_is_500(monkeypatch): + _use(monkeypatch, auth_backend="weird") + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer("x"))) + assert exc.value.status_code == 500 + + +# --- oidc backend (RS256, locally minted keypair) --------------------------- + + +@pytest.fixture(scope="module") +def rsa_keys(): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + public_pem = ( + key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return private_pem, public_pem + + +def _sign(private_pem: str, claims: dict) -> str: + import jwt + + return jwt.encode(claims, private_pem, algorithm="RS256") + + +def _exp(minutes: int = 5) -> int: + now = dt.datetime.now(tz=dt.timezone.utc) + return int((now + dt.timedelta(minutes=minutes)).timestamp()) + + +def test_oidc_valid_token_yields_claim_tenant(monkeypatch, rsa_keys): + private_pem, public_pem = rsa_keys + _use(monkeypatch, auth_backend="oidc", oidc_public_key=public_pem) + token = _sign(private_pem, {"sub": "u-1", "tenant": "acme", "exp": _exp()}) + principal = auth.authenticate(_Req(_bearer(token))) + assert principal is not None + assert principal.subject == "u-1" + assert principal.tenant_id == "acme" + + +def test_oidc_custom_tenant_claim(monkeypatch, rsa_keys): + private_pem, public_pem = rsa_keys + _use( + monkeypatch, + auth_backend="oidc", + oidc_public_key=public_pem, + oidc_tenant_claim="org_id", + ) + token = _sign(private_pem, {"org_id": "globex", "exp": _exp()}) + assert auth.authenticate(_Req(_bearer(token))).tenant_id == "globex" + + +def test_oidc_bad_signature_is_401(monkeypatch, rsa_keys): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + private_pem, public_pem = rsa_keys + # A different private key signs — verification against public_pem must fail. + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + other_pem = other.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + _use(monkeypatch, auth_backend="oidc", oidc_public_key=public_pem) + token = _sign(other_pem, {"tenant": "acme", "exp": _exp()}) + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer(token))) + assert exc.value.status_code == 401 + + +def test_oidc_expired_token_is_401(monkeypatch, rsa_keys): + private_pem, public_pem = rsa_keys + _use(monkeypatch, auth_backend="oidc", oidc_public_key=public_pem) + token = _sign(private_pem, {"tenant": "acme", "exp": _exp(minutes=-5)}) + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer(token))) + assert exc.value.status_code == 401 + + +def test_oidc_missing_tenant_claim_is_403(monkeypatch, rsa_keys): + private_pem, public_pem = rsa_keys + _use(monkeypatch, auth_backend="oidc", oidc_public_key=public_pem) + token = _sign(private_pem, {"sub": "u-1", "exp": _exp()}) + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer(token))) + assert exc.value.status_code == 403 + + +def test_oidc_wrong_issuer_is_401(monkeypatch, rsa_keys): + private_pem, public_pem = rsa_keys + _use( + monkeypatch, + auth_backend="oidc", + oidc_public_key=public_pem, + oidc_issuer="https://expected.example", + ) + token = _sign( + private_pem, {"tenant": "acme", "iss": "https://attacker.example", "exp": _exp()} + ) + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer(token))) + assert exc.value.status_code == 401 + + +def test_oidc_without_key_or_jwks_is_500(monkeypatch, rsa_keys): + private_pem, _ = rsa_keys + _use(monkeypatch, auth_backend="oidc") # neither jwks_url nor public_key + token = _sign(private_pem, {"tenant": "acme", "exp": _exp()}) + with pytest.raises(HTTPException) as exc: + auth.authenticate(_Req(_bearer(token))) + assert exc.value.status_code == 500 + + +# --- tenancy integration ---------------------------------------------------- + + +def test_resolve_tenant_rejects_ill_formed_credential_tenant(monkeypatch): + # A credential carrying a tenant with the composite delimiter is a 400. + _use(monkeypatch, auth_backend="api_key", api_keys="sk:bad:tenant") + with pytest.raises(HTTPException) as exc: + tenancy.resolve_tenant(_Req(_bearer("sk"))) + assert exc.value.status_code == 400