Add rotating create-only requester provider and owner review

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1046b-5629-7f52-badd-f718eadb6042
This commit is contained in:
tegwick 2026-10-04 11:21:04 +02:00
parent b65363527e
commit eb9614c267
4 changed files with 283 additions and 1 deletions

View file

@ -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.

View file

@ -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)

90
tests/test_requester.py Normal file
View file

@ -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 == []

View file

@ -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.