approval-engine/approval_engine/auth.py
tegwick 2bd2d19a98 Implement approval engine production readiness
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
2026-09-02 00:52:04 +02:00

169 lines
5.6 KiB
Python

"""Verified caller identity for approval-engine's HTTP boundary.
The engine enforces scopes issued by the identity owner; it does not decide who
ought to hold them. Production uses KeyCape's RS256/JWKS contract. Tests use an
explicit static verifier so no unverified claim can accidentally become a
production fallback.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Mapping, Protocol
import jwt
from .errors import Forbidden, Unauthenticated
@dataclass(frozen=True)
class Identity:
subject: str
issuer: str
audiences: tuple[str, ...]
principal_type: str
tenant: str
roles: frozenset[str]
scopes: frozenset[str]
assurance: dict[str, Any]
evidence_ref: str
def require(self, scope: str) -> "Identity":
if scope not in self.scopes:
raise Forbidden(f"caller lacks required scope {scope!r}")
return self
class Authenticator(Protocol):
def authenticate(self, authorization: str | None) -> Identity: ...
def _bearer(authorization: str | None) -> str:
value = str(authorization or "")
if not value.startswith("Bearer "):
raise Unauthenticated("bearer token is required")
token = value[7:].strip()
if not token or any(ch.isspace() for ch in token):
raise Unauthenticated("bearer token is invalid")
return token
def _texts(value: object, name: str, *, allow_empty: bool = False) -> tuple[str, ...]:
if isinstance(value, str):
items = tuple(item for item in value.split() if item)
elif isinstance(value, (list, tuple)):
items = tuple(item for item in value if isinstance(item, str) and item)
if len(items) != len(value):
raise Unauthenticated(f"token claim {name!r} is invalid")
else:
raise Unauthenticated(f"token claim {name!r} is invalid")
if not items and not allow_empty:
raise Unauthenticated(f"token claim {name!r} is empty")
return items
def identity_from_claims(claims: Mapping[str, Any], token: str) -> Identity:
try:
subject = str(claims["sub"])
issuer = str(claims["iss"])
principal_type = str(claims["principal_type"])
tenant = str(claims["tenant"])
assurance = claims["assurance"]
except (KeyError, TypeError) as exc:
raise Unauthenticated("token is missing required identity claims") from exc
if not subject or not issuer or principal_type not in {"human", "service", "agent"}:
raise Unauthenticated("token identity claims are invalid")
if not tenant or not isinstance(assurance, dict):
raise Unauthenticated("token tenant or assurance claim is invalid")
audiences = _texts(claims.get("aud"), "aud")
roles = frozenset(_texts(claims.get("roles"), "roles", allow_empty=True))
scopes = frozenset(_texts(claims.get("scope", claims.get("scp")), "scope"))
fingerprint = hashlib.sha256(token.encode("utf-8")).hexdigest()
return Identity(
subject=subject,
issuer=issuer,
audiences=audiences,
principal_type=principal_type,
tenant=tenant,
roles=roles,
scopes=scopes,
assurance=dict(assurance),
evidence_ref=f"jwt-sha256:{fingerprint}",
)
class JWTAuthenticator:
"""Verify KeyCape JWT signature, issuer, audience, time, and profile claims."""
def __init__(
self,
*,
issuer: str,
audience: str,
jwks_url: str,
leeway_seconds: int = 30,
timeout_seconds: int = 3,
jwks_client: Any | None = None,
) -> None:
if not issuer or not audience or not jwks_url:
raise ValueError("issuer, audience, and jwks_url are required")
self.issuer = issuer
self.audience = audience
self.leeway_seconds = leeway_seconds
self.jwks_client = jwks_client or jwt.PyJWKClient(
jwks_url,
cache_keys=True,
cache_jwk_set=True,
lifespan=300,
timeout=timeout_seconds,
)
def authenticate(self, authorization: str | None) -> Identity:
token = _bearer(authorization)
try:
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=self.issuer,
audience=self.audience,
leeway=self.leeway_seconds,
options={
"require": [
"iss",
"sub",
"aud",
"exp",
"iat",
"tenant",
"principal_type",
"roles",
"scope",
"assurance",
]
},
)
except Exception as exc:
raise Unauthenticated("token verification failed") from exc
return identity_from_claims(claims, token)
class StaticTokenAuthenticator:
"""Explicit test/development verifier; never selected implicitly."""
def __init__(self, identities: Mapping[str, Identity]) -> None:
self.identities = dict(identities)
def authenticate(self, authorization: str | None) -> Identity:
token = _bearer(authorization)
identity = self.identities.get(token)
if identity is None:
raise Unauthenticated("token verification failed")
return identity
class DenyAllAuthenticator:
def authenticate(self, authorization: str | None) -> Identity:
raise Unauthenticated("caller authentication is not configured")