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:
parent
b65363527e
commit
eb9614c267
4 changed files with 283 additions and 1 deletions
88
docs/requester-workload-owner-review.md
Normal file
88
docs/requester-workload-owner-review.md
Normal 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.
|
||||
91
informed_decision/requester.py
Normal file
91
informed_decision/requester.py
Normal 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
90
tests/test_requester.py
Normal 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 == []
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue