informed-decision/informed_decision/requester.py
tegwick 0e66a33389 INFD-WP-0010: present_existing intake profile (S1) and owner answer to decision services alignment
A registered submitter with a present_existing profile may pose a memo that
pins an approval it created itself in approval-engine. Intake verifies the
object read-only (requested, no entries, human_control, actor == profile
actor, digest == memo, binding == intent, required_count 1, unexpired, one
memo per approval) and never creates, enters or consumes. The recorded
requester reaches review and the access-engine context. Existing submitters
are unchanged.

Answer doc accepts S1 (profile, not route; packet carries the admission),
S4 in principle (waits on gate-house classes and policy) and S3 (exists
behind INFD_HUB_WRITEBACK). Workplan INFD-WP-0010 proposed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
2026-10-31 23:07:43 +01:00

103 lines
5.3 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.
"""
import base64
from pathlib import Path
from urllib.parse import urlencode, quote_plus
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, scope="approval:create"):
if scope not in ("approval:create", "approval:read"):
raise ValueError("requester scope is create or read only")
self.scope = scope
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()
credentials = base64.b64encode(
(quote_plus(CLIENT_ID) + ":" + quote_plus(secret)).encode("ascii")
).decode("ascii")
status, result = self.transport.request("POST", self.issuer + "/token",
headers={"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + credentials},
body=urlencode({"grant_type": "client_credentials",
"scope": self.scope}).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") != self.scope
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, IntakeApprovalReader
from .intake_service import IntakeService, TokenReview
provider = RequesterTokenProvider(issuer, secret_file, transport=transport)
creator = IntakeApprovalClient(approval_origin, provider, allow_internal_http=True)
# present_existing only; needs approval:read on the requester client (KeyCape registration).
# Until granted, that exchange is refused and only present_existing submitters fail closed.
read_provider = RequesterTokenProvider(issuer, secret_file, transport=transport, scope="approval:read")
reader = IntakeApprovalReader(approval_origin, read_provider, allow_internal_http=True)
return IntakeService(store, TokenReview(token_review_call), creator,
submitters_path=submitters_path, groups_path=groups_path, reader=reader)