Implement approval engine production readiness
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
This commit is contained in:
parent
ebce5abb27
commit
2bd2d19a98
30 changed files with 1679 additions and 53 deletions
169
approval_engine/auth.py
Normal file
169
approval_engine/auth.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue