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
32 changes: 26 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<key>:<tenant>" pairs. Each key authenticates
# as exactly one tenant. Send it as "Authorization: Bearer <key>".
# 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=
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
152 changes: 152 additions & 0 deletions agentforge/api/auth.py
Original file line number Diff line number Diff line change
@@ -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 ``<key>:<tenant>`` 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>,<key>:<tenant>"`` -> ``{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 '<key>:<tenant>'"
)
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",
)
29 changes: 21 additions & 8 deletions agentforge/api/tenancy.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions agentforge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<key>:<tenant>,..." 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.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading