informed-decision/informed_decision/requester.py
tegwick eb9614c267 Add rotating create-only requester provider and owner review
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1046b-5629-7f52-badd-f718eadb6042
2026-10-04 11:21:04 +02:00

91 lines
4.6 KiB
Python

"""Create-only requester exchange from rotating projected custody.
No access token or credential cache, environment delivery, refresh grant or
browser identity. Public issuer endpoints and claims are pinned locally.
"""
from pathlib import Path
from urllib.parse import urlencode
import time
import jwt
from .http_transport import JSONTransport, https_origin
CLIENT_ID = "informed-decision-sitting-requester"
class RequesterError(Exception):
"""Fixed refusal; never retains an upstream body, credential or token."""
class RequesterTokenProvider:
def __init__(self, issuer, secret_file, *, transport=None, clock=time.time):
self.issuer = https_origin(issuer)
self.secret_file = Path(secret_file)
if not self.secret_file.is_absolute():
raise ValueError("absolute requester credential path required")
self.transport = transport or JSONTransport()
self.clock = clock
def __call__(self):
try:
# Reopen Kubernetes's projected symlink every exchange. Never copy
# custody to the evidence volume, strip it, or hold a cached token.
with self.secret_file.open("rb") as handle:
raw = handle.read(4097)
secret = raw.decode("ascii")
if not secret or len(raw) > 4096 or any(c.isspace() for c in secret):
raise ValueError()
status, result = self.transport.request("POST", self.issuer + "/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body=urlencode({"grant_type": "client_credentials", "client_id": CLIENT_ID,
"client_secret": secret, "scope": "approval:create"}).encode())
if (status != 200 or not isinstance(result.get("token_type"), str)
or result["token_type"].lower() != "bearer"
or "refresh_token" in result):
raise ValueError()
token = result.get("access_token")
if not isinstance(token, str) or not token or len(token) > 16384:
raise ValueError()
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str) or not header["kid"]:
raise ValueError()
status, jwks = self.transport.request("GET", self.issuer + "/jwks")
if status != 200 or not isinstance(jwks.get("keys"), list):
raise ValueError()
keys = [key for key in jwks["keys"] if key.get("kid") == header["kid"]]
if (len(keys) != 1 or keys[0].get("kty") != "RSA"
or keys[0].get("use", "sig") != "sig" or keys[0].get("alg", "RS256") != "RS256"):
raise ValueError()
key = jwt.PyJWK.from_dict(keys[0], algorithm="RS256").key
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
audience="approval-engine", options={"require": ["iss", "aud", "sub", "iat", "exp"],
"strict_aud": True})
now = self.clock()
if (claims.get("sub") != "informed-decision"
or claims.get("tenant") != "tenant:platform"
or claims.get("principal_type") != "service"
or claims.get("roles") != [CLIENT_ID]
or claims.get("scope") != "approval:create"
or type(claims["iat"]) is not int or type(claims["exp"]) is not int
or claims["iat"] > now or claims["exp"] <= now
or not 0 < claims["exp"] - claims["iat"] <= 900):
raise ValueError()
return token
except Exception:
raise RequesterError("requester exchange refused") from None
def compose_intake(store, token_review_call, *, issuer, secret_file,
approval_origin, submitters_path, groups_path, transport=None):
"""Owner composition seam; caller owns CA-verified TokenReview and listener.
This factory grants no submitter and starts no socket. The separate private
listener and deployment remain reviewed owner configuration.
"""
from .intake_approval import IntakeApprovalClient
from .intake_service import IntakeService, TokenReview
provider = RequesterTokenProvider(issuer, secret_file, transport=transport)
creator = IntakeApprovalClient(approval_origin, provider, allow_internal_http=True)
return IntakeService(store, TokenReview(token_review_call), creator,
submitters_path=submitters_path, groups_path=groups_path)