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
103 lines
5.3 KiB
Python
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)
|