From eb9614c2677c7f07c9d78eee7543f95f329d2c83 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 4 Oct 2026 11:21:04 +0200 Subject: [PATCH] Add rotating create-only requester provider and owner review Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1046b-5629-7f52-badd-f718eadb6042 --- docs/requester-workload-owner-review.md | 88 ++++++++++++++++++ informed_decision/requester.py | 91 +++++++++++++++++++ tests/test_requester.py | 90 ++++++++++++++++++ ...FD-WP-0005-frictionless-decision-intake.md | 15 ++- 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 docs/requester-workload-owner-review.md create mode 100644 informed_decision/requester.py create mode 100644 tests/test_requester.py diff --git a/docs/requester-workload-owner-review.md b/docs/requester-workload-owner-review.md new file mode 100644 index 0000000..0b75a3a --- /dev/null +++ b/docs/requester-workload-owner-review.md @@ -0,0 +1,88 @@ +# Requester workload owner source review — 2026-10-04 + +Owning task: INFD-WP-0005-T05. Platform request: +2c1507b2-b361-4ef9-b5d4-3245014c7c52, thread +f84637fa-2355-4971-a2dc-7f1fd01710a8. Reviewed platform source: +6b91d510395215d178d60ee8884e7fc00930a0cf, proposed CCR-2026-0032. + +## Source binding acceptance + +The founder-approved namespace trust boundary matches the existing create-only +requester registration. Accept the dedicated ESO auth identity +informed-decision/informed-decision-eso, audience openbao, exact-path policy, +900-second reader limits and namespace-restricted store for owner integration. +The receiving review pod keeps ServiceAccount review and UID/GID/fsGroup 10001. +Accept the required read-only 0440 projected key client-secret at +/var/run/secrets/informed-decision/requester/client-secret, without subPath. +No new issuer registration, custody seed, scope, browser route or human act is +introduced. This is source acceptance; it does not confirm live auth claims. + +## Implemented composition seam + +informed_decision/requester.py implements RequesterTokenProvider and +compose_intake. The provider reopens bounded projected custody per exchange, +never strips/caches the secret or caches access tokens, and requests exactly +approval:create through fixed HTTPS /token. TLS validation, five-second timeouts, +response bounds, proxy suppression and redirect refusal use JSONTransport. +RS256 signature verification against the fixed /jwks pins issuer, sole audience +approval-engine, sub informed-decision, tenant tenant:platform, service principal, +exact requester role and exact create scope. JWT lifetime must be positive and +at most 900 seconds. Failure exposes only a fixed refusal string. +The existing issuer is https://kc.coulomb.social; owner configuration must use +that exact origin (no discovery or caller-selected URL). + +compose_intake binds this callback to IntakeApprovalClient and joins the existing +IntakeService ledger with a supplied TokenReview transport and reviewed registries. +It starts no listener and supplies no transport implementation for TokenReview. +No runtime JSON setting or deployment entrypoint consumes this factory yet. + +Focused offline verification: 37 tests pass across requester, intake service and +bounded transport. Synthetic projected-file replacement changes the next exchange; +missing custody sends no exchange. Wrong identity, tenant, service profile, +scopes, issuer, audience, signature, expiry and excessive lifetime refuse. +This proves local behavior, not deployed projection or issuer rotation. + +## Remaining technical deliveries + +1. Compose the separate private intake listener in the process lifecycle and + explicit runtime config. Implement bounded, authenticated TokenReview using + API-server CA validation. Review API RBAC and submitter token audience. +2. Verify ESO's actual TokenRequest permission for the dedicated auth account; + retain boolean/identity receipt. Review exact private Service, NetworkPolicy, + projected registries and submitter grants. Keep it absent from public ingress. +3. Build and publish the final owner image only after composition is complete; + retain immutable registry manifest digest, rendered configuration digest and + owner source revision. None exists for this new provider yet. Existing deployed + image/config references cannot be claimed to contain it. +4. Owner acceptance of receiving verification/abort attendance remains required. + This worker owns source review only, and cannot assign a human attendance role. + +## Bounded native proof and abort plan + +Before promotion, record exact ESO auth role, effective policy and all TTL bounds; +prove SecretSynced, exact file projection and safe annotation metadata with the +platform maintained helper. Refuse sibling data GET, parent metadata LIST, wrong +namespace/account/audience. Obtain one create-only service exchange and native +approve/read/consume refusal. Keep proof to non-secret identities, counts, +digests, timestamps and capability booleans. + +Coordinated issuer rotation must prove the actual mounted-file refresh reaches +this callback and the predecessor ceases working. File replacement in an offline +test is not issuer rotation/revocation evidence. Check reader expiry/revocation, +materialized credential persistence and attended admin cleanup separately. +The runtime verifier/abort operator must retain these receipts; do not print a +Secret, bearer token, accessor, annotations, upstream body or raw child error. + +On partial failure, stop, retain non-secret actual state and reconcile; never +repost an unresolved approval create. Platform abort withdraws the new projection +and disables/revokes only the new ESO reader via reviewed admin custody. Target +Secret removal is separate because Retain preserves it. The owner rollback uses +the prior admitted image/config and closes the new private listener. Existing +issuer, browser and attended requester lanes remain independent. + +## Human decisions + +No repeated design decision is needed for this accepted boundary. The remaining +human action is separate founder approval of the final concrete live packet and +named verification/abort attendance, after these technical gaps are closed. +No live apply, credential read, rotation, issuer mutation or deployment occurred. diff --git a/informed_decision/requester.py b/informed_decision/requester.py new file mode 100644 index 0000000..03c7894 --- /dev/null +++ b/informed_decision/requester.py @@ -0,0 +1,91 @@ +"""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) diff --git a/tests/test_requester.py b/tests/test_requester.py new file mode 100644 index 0000000..b079a86 --- /dev/null +++ b/tests/test_requester.py @@ -0,0 +1,90 @@ +import json +import time +from urllib.parse import parse_qs + +from cryptography.hazmat.primitives.asymmetric import rsa +import jwt +import pytest + +from informed_decision.requester import RequesterError, RequesterTokenProvider, CLIENT_ID + +ISSUER = "https://identity.example.test" + + +@pytest.fixture +def lane(tmp_path): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(private.public_key())) + jwk.update(kid="test", alg="RS256", use="sig") + now = int(time.time()) + claims = dict(iss=ISSUER, aud="approval-engine", sub="informed-decision", iat=now, + exp=now + 900, tenant="tenant:platform", principal_type="service", + roles=[CLIENT_ID], scope="approval:create") + secret = tmp_path / "client-secret" + secret.write_text("synthetic-first") + class Transport: + seen = [] + def request(self, method, url, *, headers=None, body=None): + if method == "GET": + assert url == ISSUER + "/jwks" + return 200, {"keys": [jwk]} + assert url == ISSUER + "/token" + self.seen.append(parse_qs(body.decode())) + return 200, {"token_type": "Bearer", "access_token": jwt.encode( + claims, private, algorithm="RS256", headers={"kid": "test"})} + transport = Transport() + return RequesterTokenProvider(ISSUER, secret, transport=transport), secret, claims, transport, jwk + + +def test_exchange_reopens_projected_rotation(lane, tmp_path): + provider, secret, _, transport, _ = lane + provider() + replacement = tmp_path / "replacement" + replacement.write_text("synthetic-second") + replacement.replace(secret) + provider() + assert [call["client_secret"] for call in transport.seen] == [["synthetic-first"], ["synthetic-second"]] + assert all(call["scope"] == ["approval:create"] and call["grant_type"] == ["client_credentials"] + for call in transport.seen) + secret.unlink() + with pytest.raises(RequesterError, match="^requester exchange refused$"): + provider() + assert len(transport.seen) == 2 + + +@pytest.mark.parametrize("field,value", [ + ("aud", "other"), ("sub", "other"), ("tenant", "other"), + ("principal_type", "human"), ("roles", []), + ("scope", "approval:create approval:approve"), ("scope", "approval:read"), + ("scope", "approval:consume"), ("iss", "https://wrong.example.test"), + ("exp", 1), ("iat", True), +]) +def test_refuses_wrong_profile(lane, field, value): + provider, _, claims, _, _ = lane + claims[field] = value + with pytest.raises(RequesterError, match="^requester exchange refused$"): + provider() + + +def test_refuses_long_lifetime(lane): + provider, _, claims, _, _ = lane + claims["exp"] = claims["iat"] + 901 + with pytest.raises(RequesterError): + provider() + + +def test_refuses_signature_mismatch(lane): + provider, _, _, _, jwk = lane + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk.update(json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(other.public_key()))) + with pytest.raises(RequesterError): + provider() + + +@pytest.mark.parametrize("secret", ["", "synthetic\n", "x" * 4097]) +def test_invalid_custody_never_exchanges(lane, secret): + provider, path, _, transport, _ = lane + path.write_text(secret) + with pytest.raises(RequesterError): + provider() + assert transport.seen == [] diff --git a/workplans/INFD-WP-0005-frictionless-decision-intake.md b/workplans/INFD-WP-0005-frictionless-decision-intake.md index d4e3ef6..b1c68c6 100644 --- a/workplans/INFD-WP-0005-frictionless-decision-intake.md +++ b/workplans/INFD-WP-0005-frictionless-decision-intake.md @@ -113,7 +113,7 @@ status: wait priority: high blocked_on: message-from:railiance-platform needs_human: true -blocking_reason: "Requester CLIENT_SECRET must reach the review pod by ESO (railiance-platform, msg cd811073) and the custody change needs the founder's go" +blocking_reason: "Founder-approved ESO binding/mount accepted for source integration; private listener and CA-verified TokenReview runtime composition, immutable image/config, native custody proof and separate live go remain" state_hub_task_id: "3b0f536c-e8ee-5493-b663-2f60f5f429ad" decision_id: "0d6792b6-59e4-4fa0-887a-c4e51ea8e131" ``` @@ -246,3 +246,16 @@ preserved. No founder ruling or deployment permission is inferred. 2026-10-04 CUST-WP-0075 owner routing: `INFD-WP-0005-T05` remains a human wait under open decision `0d6792b6-59e4-4fa0-887a-c4e51ea8e131`. Exact scoped human act and native receipts remain required; this metadata records no approval or execution. Completed predecessor edges removed: INFD-WP-0005-T04; completed source evidence remains in the history and does not satisfy the human gate. 2026-10-04 CUST-WP-0075 owner routing: `INFD-WP-0005-T10` remains a human wait under open decision `24b0c846-a58e-43bc-ab80-988180ddd2f9`. Exact scoped human act and native receipts remain required; this metadata records no approval or execution. Completed predecessor edges removed: INFD-WP-0005-T07; completed source evidence remains in the history and does not satisfy the human gate. + + +2026-10-04 requester owner worker: reviewed platform source +`6b91d510395215d178d60ee8884e7fc00930a0cf` and accepted the approved dedicated +ESO binding and exact read-only mount for source integration; see +`docs/requester-workload-owner-review.md`. Implemented `requester.py` rotating +file-backed client-credentials callback with RS256/JWKS and exact service profile, +and `compose_intake` joining the existing ledger/client/TokenReview seam. +37 focused offline tests pass. No deployed runtime invokes this factory yet; +private listener, CA-verified TokenReview transport, ESO TokenRequest check, +immutable owner image/config and native rotation/revocation proof remain. +T05 stays wait and the wider human decision stays open for the concrete live +packet. No live action, credential retrieval or issuer mutation occurred.