informed-decision/informed_decision/requester.py

105 lines
5.4 KiB
Python
Raw Permalink Normal View History

"""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,
issuer=issuer)