92 lines
4.6 KiB
Python
92 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)
|