diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index 3ff0473..e3c644f 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -154,7 +154,8 @@ did before. Production additionally needs: | Variable | Meaning | | --- | --- | | `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) | -| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git | +| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | explicit bearer credential outside Git; mutually exclusive with the client-secret provider | +| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | protected temporary KeyCape approval-client secret; fresh in-memory token per request ([contract](approval-service-auth.md)) | | `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal | | `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin (step 2) | | `SECRETS_ENGINE_PDP_URL` / `_PDP_TOKEN_FILE` | the per-consumer access-engine pin | diff --git a/docs/approval-service-auth.md b/docs/approval-service-auth.md new file mode 100644 index 0000000..bb30c5b --- /dev/null +++ b/docs/approval-service-auth.md @@ -0,0 +1,83 @@ +# Approval consumer identity + +Implemented for SECRETS-WP-0009-T03 on 2026-09-09. KeyCape's live verifier +registration is accepted under KEY-WP-0013-T02 and CCR-2026-0017; this consumer +implementation does not grant retrieval of its confidential client secret. + +The CLI exchanges a separately supplied `secrets-engine-approval` client secret +for a fresh access token immediately before each approval HTTP request. Claim +requests ask for `approval:read`; consume requests ask for `approval:consume`. +The token exists only in memory for that request. There is no refresh token, +access-token file, background renewal, retry loop, or fallback identity. + +| Input | Value | +| --- | --- | +| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | Explicit, protected temporary file outside Git, no group/other access | +| `SECRETS_ENGINE_KEYCAPE_ISSUER` | `https://kc.coulomb.social` | +| `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | `https://kc.coulomb.social/token` | +| `SECRETS_ENGINE_APPROVAL_URL` | Admitted HTTPS endpoint or literal loopback owner tunnel | +| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | Unset when using the client-secret provider | + +The existing token-file provider remains explicitly selectable by configuring +only `SECRETS_ENGINE_APPROVAL_TOKEN_FILE`. Configuring both providers fails +before either credential is read. An exchange failure never reads that file, +`BAO_TOKEN`, or `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` (the different +OpenBao identity's credential). + +The approval profile fixes client `secrets-engine-approval`, audience +`approval-engine`, subject `service:secrets-engine`, tenant `tenant:platform`, +role `secrets-engine`, service principal, empty groups and one requested scope. +It enforces RS256 shape, at most 900 seconds of life, strict expiry and at most +30 seconds of future issue time. The KeyCape assurance shape is +`level: aal1`, `methods: [client_secret]`, `mfa: false`, `source: key-cape`. +The same wire-shape correction applies to the existing OpenBao exchange; its +client, audience, tenant and authorization remain separate. + +Claim parsing is a preflight, not signature verification or authorization. +Approval Engine verifies signature, issuer, audience and claims against JWKS. +The existing exact-action claim, access-engine decision and atomic consume gates +still precede a production OpenBao operation. + +Credentials are sent through a non-redirecting transport. The KeyCape endpoint +must be the configured HTTPS issuer's `/token`. The new consumer refuses +cluster Service DNS from this workstation and plaintext non-loopback approval +endpoints. A literal loopback address alone cannot identify the responder: the +operator must establish a `kubectl port-forward` for the admitted cluster and +named Approval Engine pod. No tunnel or cluster deployment is created here. + +## Reproducible component exercise + +Run from this repository with Docker available and the owner source checkouts: + +```bash +uv run --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval_identity.py \ + --keycape-source /home/worsch/key-cape \ + --approval-engine-source /home/worsch/approval-engine \ + --receipt /tmp/.json +``` + +The exercise starts the immutable admitted KeyCape image behind local HTTPS, +uses synthetic signing keys/client secrets, and serves the real Approval Engine +source with its JWT/JWKS verifier and SQLite store. It drives the actual Secrets +Engine production gate, with only the PDP represented by a sequencing double. +Operator create/approve, consumer claim/consume, same-digest retry, different +digest, spent claim, wrong action, wrong secret and operator-scope denial are +checked. All temporary processes and synthetic credentials are removed. + +This exercise caught the previous fixture-only `aal`/`method` assumption: +the real issuer has always emitted `level`/`methods`. The regression suite now +refuses that obsolete shape. See +[the component receipt](evidence/2026-09-09-approval-identity-exercise.json). + +## Live handoff + +RPF-WP-0035-T06 owns the separate client-side read admission for the existing +`platform/workloads/secrets-engine/approval-client`, field `CLIENT_SECRET`. +The admission must name the actual consumer/placement, bounded reader, protected +temporary delivery and cleanup, refusal/revocation checks and lifecycle owner. +Do not use the verifier's `sso` Secret or reseed version-1 custody. + +Approval Engine's operator is a separate identity and read lane. Audit receiver +and sender custody remains AUDIT-WP-0009-T09 / APPROVAL-WP-0002-T01. The live +Approval Engine deployment and native OpenBao delivery remain open. Synthetic +acceptance grants no production access, action approval or model spending. diff --git a/docs/cli.md b/docs/cli.md index 94530a9..e4a7c07 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,6 +39,8 @@ secrets-engine --version | `SECRETS_ENGINE_CATALOG` | `./catalog` | catalog directory | | `SECRETS_ENGINE_EVIDENCE` | `./.evidence` | local non-secret evidence log | | `SECRETS_ENGINE_UNSAFE_DEMO` | _(unset)_ | allow a prod-labeled lane only when Hub is disabled and OpenBao is loopback; throwaway demos only | +| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | _(unset)_ | separate temporary approval-client secret; [per-request exchange](approval-service-auth.md) | +| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | _(unset)_ | explicit approval bearer file; conflicts with approval client-secret provider | | `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | _(unset)_ | KeyCape token endpoint for `service-jwt` | | `SECRETS_ENGINE_KEYCAPE_ISSUER` | _(unset)_ | KeyCape issuer; must match the JWT login contract | | `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` | _(unset)_ | mode-0600 out-of-repo client secret | diff --git a/docs/evidence/2026-09-09-approval-identity-exercise.json b/docs/evidence/2026-09-09-approval-identity-exercise.json new file mode 100644 index 0000000..e796fa0 --- /dev/null +++ b/docs/evidence/2026-09-09-approval-identity-exercise.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "target": "disposable local processes; synthetic credentials", + "started_at": "2026-09-09T01:49:09.187005+00:00", + "keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611", + "approval_engine_commit": "b46b0f26669dc83c944ee5145426bad03d5ef720", + "keycape_contract_commit": "0f5535eed95f1223c83a28f5a0bd6fa594cecae8", + "consumer_source_sha256": { + "approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5", + "approval_consume.py": "cd79b3e4534a5abf55af2b5ab2939c485a3140a1174f593e0aa7719250051d92", + "config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955", + "service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c" + }, + "limitations": [ + "PDP sequencing double", + "local Approval Engine source, not deployed image", + "no live custody or client-side read grant", + "no OpenBao effect or model execution" + ], + "checks": { + "operator_issued_and_approved_via_verified_jwt": true, + "wrong_action_refused_before_consume": true, + "actual_consumer_claim_check_consume": true, + "same_digest_retry_idempotent": true, + "different_digest_refused": true, + "spent_claim_refused": true, + "operator_consume_scope_denied_by_issuer": true, + "wrong_secret_refused": true, + "no_access_token_file_created": true + }, + "status": "passed", + "cleanup_complete": true, + "finished_at": "2026-09-09T01:49:11.402008+00:00" +} diff --git a/docs/service-auth.md b/docs/service-auth.md index 06208c1..a999c2d 100644 --- a/docs/service-auth.md +++ b/docs/service-auth.md @@ -31,3 +31,11 @@ those named providers and `--auth service-jwt` fail-closes. See Canonical provider contract: `key-cape/docs/openbao-service-auth-contract.md` (reviewed 2026-08-23). + +## Approval identity is separate + +[Approval service authentication](approval-service-auth.md) uses its own +client-secret input, resource audience, exact tenant:platform and per-request +read/consume scopes. Never put that credential into the OpenBao provider. +Both providers now validate KeyCape’s actual `level`/`methods` assurance shape; +the old `aal`/`method` fixture was incompatible with the issuer. diff --git a/src/secrets_engine/approval_auth.py b/src/secrets_engine/approval_auth.py new file mode 100644 index 0000000..ba65f2e --- /dev/null +++ b/src/secrets_engine/approval_auth.py @@ -0,0 +1,113 @@ +"""The approval consumer's own KeyCape identity, distinct from OpenBao login. + +Access tokens stay in memory for one request. Claim preflight is not signature +verification or an authorization decision; approval-engine verifies the JWT. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, build_opener + +from secrets_engine.errors import BackendError, DecisionError +from secrets_engine.openbao import read_strict_token_file +from secrets_engine.service_auth import KeyCapeServiceAuthConfig, KeyCapeServiceAuthProvider + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # urllib otherwise forwards Basic/Bearer headers on a redirected GET. + return None + + +def credential_urlopen(request, *, timeout): + return build_opener(_NoRedirect()).open(request, timeout=timeout) + + +@dataclass(frozen=True) +class KeyCapeApprovalAuthConfig(KeyCapeServiceAuthConfig): + client_id: str = "secrets-engine-approval" + audience: str = "approval-engine" + tenant: str = "tenant:platform" + scope: str = "approval:read" + max_future_iat_seconds: int = field(default=30, init=False) + + def __post_init__(self) -> None: + # Deliberately do not relax the separate OpenBao profile's validator. + if ( + self.client_id != "secrets-engine-approval" + or self.audience != "approval-engine" + or self.subject != "service:secrets-engine" + or self.tenant != "tenant:platform" + or self.required_role != "secrets-engine" + or self.scope not in {"approval:read", "approval:consume"} + or self.max_future_iat_seconds != 30 + ): + raise BackendError("KeyCape approval identity does not match accepted contract") + try: + issuer = urlsplit(self.issuer) + valid = ( + issuer.scheme == "https" and bool(issuer.hostname) + and issuer.username is None and issuer.password is None + and not issuer.query and not issuer.fragment + and issuer.path in {"", "/"} + and self.token_url == self.issuer.rstrip("/") + "/token" + and issuer.port != 0 + ) + except ValueError: + valid = False + if not valid: + raise BackendError("KeyCape approval exchange requires the issuer's HTTPS /token") + if self.timeout_seconds <= 0: + raise BackendError("KeyCape timeout must be positive") + + +def approval_auth_configured(cfg: Any) -> bool: + token_file = getattr(cfg, "approval_token_file", None) + secret_file = getattr(cfg, "approval_client_secret_file", None) + if token_file and secret_file: + raise DecisionError("approval auth requires one provider; token and client-secret files conflict") + return bool(token_file or secret_file) + + +def require_approval_address(base_url: str) -> None: + """The first CLI consumer uses TLS or the owner's literal loopback tunnel. + + A loopback address alone does not establish the tunnel's target identity; + the owner must bind kubectl port-forward to the admitted cluster and pod. + """ + try: + url = urlsplit(base_url) + host = (url.hostname or "").rstrip(".").lower() + valid = ( + bool(host) and url.username is None and url.password is None + and not url.query and not url.fragment and url.path in {"", "/"} + and url.port != 0 + and not host.endswith((".svc", ".svc.cluster.local", ".cluster.local")) + and (url.scheme == "https" or ( + url.scheme == "http" and host in {"127.0.0.1", "::1"} + )) + ) + except ValueError: + valid = False + if not valid: + raise DecisionError("approval service auth requires HTTPS or an owner-bound literal loopback tunnel") + + +def approval_token(cfg: Any, *, scope: str) -> str: + """Select one explicit provider. Never fall back after exchange failure.""" + if not approval_auth_configured(cfg): + raise DecisionError("approval credential is unconfigured") + secret_file = getattr(cfg, "approval_client_secret_file", None) + if secret_file: + require_approval_address(str(getattr(cfg, "approval_url", "") or "")) + config = KeyCapeApprovalAuthConfig( + token_url=str(getattr(cfg, "keycape_token_url", "") or ""), + issuer=str(getattr(cfg, "keycape_issuer", "") or ""), + client_secret_file=Path(secret_file), + scope=scope, + ) + return KeyCapeServiceAuthProvider(config, transport=credential_urlopen).exchange().token + return read_strict_token_file(Path(cfg.approval_token_file), purpose="approval credential") diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index 4b13cbc..1538fed 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -18,6 +18,7 @@ from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen from secrets_engine.approval_claim import validate_approval_claim from secrets_engine.decision_check import check_decision from secrets_engine.authorization import ( @@ -160,10 +161,11 @@ def _expected_request( def fetch_approval_claim( *, base_url: str, - token_file: Path, + token_file: Path | None = None, + token_provider: Callable[[], str] | None = None, authorization_id: str, timeout_seconds: float = 3, - opener: Callable[..., Any] = urlopen, + opener: Callable[..., Any] = credential_urlopen, ) -> dict[str, Any]: """GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed. @@ -176,7 +178,7 @@ def fetch_approval_claim( ident = authorization_id.strip() if not ident or "/" in ident or any(ch.isspace() for ch in ident): raise DecisionError("approval claim requires a concrete authorization id") - token = read_strict_token_file(Path(token_file), purpose="approval claim credential") + token = _request_token(token_file, token_provider) request = Request( base_url.rstrip("/") + f"/v1/approvals/{ident}/claim", method="GET", @@ -218,9 +220,9 @@ def resolve_consume_binding( unconfigured one. """ base_url = str(getattr(cfg, "approval_url", "") or "") - token_file = getattr(cfg, "approval_token_file", None) + auth_configured = approval_auth_configured(cfg) authorization_id = _authorization_id(entry, decision) - if not base_url or not token_file or not authorization_id: + if not base_url or not auth_configured or not authorization_id: return None expected_request = _expected_request( @@ -245,9 +247,9 @@ def resolve_consume_binding( claim = fetch_approval_claim( base_url=base_url, - token_file=Path(token_file), + token_provider=lambda: approval_token(cfg, scope="approval:read"), authorization_id=authorization_id, - opener=opener or urlopen, + opener=opener or credential_urlopen, ) validate_approval_claim( claim, @@ -347,10 +349,11 @@ def authorize_action( def consume_approval( *, base_url: str, - token_file: Path, + token_file: Path | None = None, + token_provider: Callable[[], str] | None = None, binding: ConsumeBinding, timeout_seconds: float = 3, - opener: Callable[..., Any] = urlopen, + opener: Callable[..., Any] = credential_urlopen, ) -> ConsumedApproval: """POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use.""" if not base_url or not base_url.startswith(("http://", "https://")): @@ -361,7 +364,7 @@ def consume_approval( if not DIGEST_RE.fullmatch(binding.request_digest): raise DecisionError("approval consume requires the canonical request digest") - token = read_strict_token_file(Path(token_file), purpose="approval consume credential") + token = _request_token(token_file, token_provider) body: dict[str, str] = {"request_digest": binding.request_digest} if binding.decision_id: body["decision_id"] = binding.decision_id @@ -448,22 +451,22 @@ def require_production_consume( "after an access-engine ALLOW; no durable consume binding is served" ) base_url = str(getattr(cfg, "approval_url", "") or "") - token_file = getattr(cfg, "approval_token_file", None) + auth_configured = approval_auth_configured(cfg) if not base_url: raise DecisionError( "production OpenBao call requires approval-engine consume; " "SECRETS_ENGINE_APPROVAL_URL is unset" ) - if not token_file: + if not auth_configured: raise DecisionError( "production OpenBao call requires approval-engine consume; " - "SECRETS_ENGINE_APPROVAL_TOKEN_FILE is unset" + "SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset" ) consumed = consume_approval( base_url=base_url, - token_file=Path(token_file), + token_provider=lambda: approval_token(cfg, scope="approval:consume"), binding=binding, - opener=opener or urlopen, + opener=opener or credential_urlopen, ) if evidence is not None and hasattr(evidence, "mark_consumed"): evidence.mark_consumed(consumed) @@ -482,3 +485,17 @@ def _status_message(status: int) -> str: if status == 0: return "approval-engine unreachable; OpenBao must not be called" return "approval consume failed; OpenBao must not be called" + + +def _request_token( + token_file: Path | None, token_provider: Callable[[], str] | None, +) -> str: + if (token_file is None) == (token_provider is None): + raise DecisionError("approval request requires exactly one credential provider") + token = ( + token_provider() if token_provider is not None + else read_strict_token_file(Path(token_file), purpose="approval credential") + ) + if not isinstance(token, str) or not token or any(ch.isspace() for ch in token): + raise DecisionError("approval credential is invalid") + return token diff --git a/src/secrets_engine/config.py b/src/secrets_engine/config.py index de2743d..d93ad2c 100644 --- a/src/secrets_engine/config.py +++ b/src/secrets_engine/config.py @@ -38,6 +38,7 @@ class Config: topic_id: str approval_url: str = "" approval_token_file: Path | None = None + approval_client_secret_file: Path | None = None keycape_token_url: str = "" keycape_issuer: str = "" keycape_client_secret_file: Path | None = None @@ -56,6 +57,7 @@ class Config: def load(cls) -> "Config": root = repo_root() token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "") + approval_secret = os.environ.get("SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "") keycape_secret = os.environ.get("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", "") jwt_login = os.environ.get("SECRETS_ENGINE_OPENBAO_JWT_LOGIN", "") pdp_token = os.environ.get("SECRETS_ENGINE_PDP_TOKEN_FILE", "") @@ -70,6 +72,7 @@ class Config: ), approval_url=os.environ.get("SECRETS_ENGINE_APPROVAL_URL", ""), approval_token_file=Path(token_file) if token_file else None, + approval_client_secret_file=Path(approval_secret) if approval_secret else None, keycape_token_url=os.environ.get("SECRETS_ENGINE_KEYCAPE_TOKEN_URL", ""), keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""), keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None, diff --git a/src/secrets_engine/service_auth.py b/src/secrets_engine/service_auth.py index b465b62..ad125b0 100644 --- a/src/secrets_engine/service_auth.py +++ b/src/secrets_engine/service_auth.py @@ -1,12 +1,9 @@ -"""Explicit KeyCape service-JWT provider for future OpenBao JWT login. +"""KeyCape service-JWT exchange shared by two separately validated profiles. -This module implements the accepted KeyCape consumer contract without wiring it -into OpenBao yet. The platform owner still needs to publish the exact OpenBao -JWT auth mount and role. Keeping provider selection separate prevents an auth -failure from falling back to bootstrap, operator, or AppRole credentials. - -JWT parsing here is a claim preflight, not signature verification. OpenBao must -verify the RS256 signature against the configured issuer before issuing a token. +The OpenBao login profile and approval consumer profile have distinct clients, +audiences, tenants and scopes. Provider selection never falls back to another +identity. Token parsing is a claim preflight, not signature verification: the +receiving OpenBao or approval-engine service verifies RS256 before accepting it. """ from __future__ import annotations @@ -46,6 +43,7 @@ class KeyCapeServiceAuthConfig: required_role: str = ROLE scope: str = SCOPE timeout_seconds: float = 10.0 + max_future_iat_seconds: int = field(default=60, init=False) def __post_init__(self) -> None: if not self.token_url.startswith("https://"): @@ -95,7 +93,7 @@ def preflight_service_jwt( *, now: datetime | None = None, ) -> ServiceJWT: - """Validate non-cryptographic JWT shape/claims before OpenBao login.""" + """Validate non-cryptographic JWT shape/claims before resource-server use.""" parts = token.split(".") if len(parts) != 3 or not all(parts): raise BackendError("KeyCape access token is not a compact JWT") @@ -122,8 +120,8 @@ def preflight_service_jwt( assurance = claims.get("assurance") if not isinstance(assurance, dict) or ( - assurance.get("aal") != "AAL1" - or assurance.get("method") != "client_secret" + assurance.get("level") != "aal1" + or assurance.get("methods") != ["client_secret"] or assurance.get("mfa") is not False or assurance.get("source") != "key-cape" ): @@ -139,7 +137,7 @@ def preflight_service_jwt( ): raise BackendError("KeyCape JWT iat/exp must be integer timestamps") current = int((now or datetime.now(timezone.utc)).timestamp()) - if issued_at > current + 60: + if issued_at > current + config.max_future_iat_seconds: raise BackendError("KeyCape JWT issue time is in the future") if expires_at <= current: raise BackendError("KeyCape JWT has expired") @@ -187,7 +185,9 @@ class KeyCapeServiceAuthProvider: response = self.transport(request, timeout=self.config.timeout_seconds) with response: status = getattr(response, "status", 200) - raw = response.read() + raw = response.read(65537) + if len(raw) > 65536: + raise BackendError("KeyCape token response exceeds size bound") except HTTPError as e: raise BackendError(f"KeyCape token exchange failed with HTTP {e.code}") from e except (URLError, TimeoutError, OSError) as e: diff --git a/tests/test_approval_auth.py b/tests/test_approval_auth.py new file mode 100644 index 0000000..077bb46 --- /dev/null +++ b/tests/test_approval_auth.py @@ -0,0 +1,237 @@ +"""Approval credentials must never select the OpenBao or operator identity.""" +import base64 +import copy +import json +import threading +from dataclasses import replace +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO +from pathlib import Path +from types import SimpleNamespace +from urllib.error import HTTPError +from urllib.parse import parse_qs +from urllib.request import Request + +import pytest + +from secrets_engine import approval_auth, cli +from secrets_engine.approval_auth import ( + KeyCapeApprovalAuthConfig, + approval_token, + credential_urlopen, + require_approval_address, +) +from secrets_engine.catalog import validate_entry +from secrets_engine.config import Config +from secrets_engine.errors import BackendError, DecisionError, ProvisioningError +from secrets_engine.service_auth import KeyCapeServiceAuthConfig, preflight_service_jwt +from tests.authorization_stub import AuthorizationStub +from tests.test_catalog import VALID +from tests.test_service_auth import _jwt + +ISSUER = "https://keycape.example.test" + + +def _cfg(tmp_path, **changes): + secret = tmp_path / "approval-client.secret" + secret.write_text("synthetic-client-secret") + secret.chmod(0o600) + return SimpleNamespace( + **({"approval_client_secret_file": secret, "approval_token_file": None, + "keycape_issuer": ISSUER, "keycape_token_url": ISSUER + "/token", + "approval_url": "http://127.0.0.1:18081"} | changes) + ) + + +def _token(_requested_scope="approval:read", **overrides): + now = int(datetime.now(timezone.utc).timestamp()) + return _jwt(**({"iat": now, "exp": now + 900, "aud": "approval-engine", + "tenant": "tenant:platform", "scope": _requested_scope} | overrides)) + + +def _transport(seen, **overrides): + def send(request, *, timeout): + assert request.full_url == ISSUER + "/token" + auth = base64.b64decode(request.get_header("Authorization").split()[1]).decode() + assert auth == "secrets-engine-approval:synthetic-client-secret" + body = parse_qs(request.data.decode()) + assert body["grant_type"] == ["client_credentials"] + scope = body["scope"][0] + seen.append(scope) + payload = {"access_token": _token(scope, **overrides), "token_type": "Bearer", "expires_in": 900} + response = BytesIO(json.dumps(payload).encode()) + response.status = 200 + return response + return send + + +def test_read_and_consume_each_exchange_their_own_minimal_scope(tmp_path, monkeypatch): + cfg, seen = _cfg(tmp_path), [] + monkeypatch.setattr(approval_auth, "credential_urlopen", _transport(seen)) + for scope in ("approval:read", "approval:consume", "approval:read"): + result = approval_token(cfg, scope=scope) + claims = json.loads(base64.urlsafe_b64decode(result.split(".")[1] + "==")) + assert claims["scope"] == scope + assert seen == ["approval:read", "approval:consume", "approval:read"] + assert sorted(p.name for p in tmp_path.iterdir()) == ["approval-client.secret"] + + +@pytest.mark.parametrize("claims", [ + {"aud": "secrets-engine-openbao"}, {"tenant": "tenant:coulomb"}, + {"sub": "service:approval-engine-operator"}, {"roles": ["secrets-engine", "admin"]}, + {"scope": "approval:read approval:consume"}, {"principal_type": "human"}, + {"exp": 1}, {"iss": "https://another-issuer.test"}, + {"assurance": {"aal": "AAL1", "method": "client_secret", "mfa": False, "source": "key-cape"}}, + {"assurance": {"level": "aal1", "methods": ["client_secret", "password"], "mfa": False, "source": "key-cape"}}, +]) +def test_wrong_returned_identity_is_refused_without_fallback(tmp_path, monkeypatch, claims): + cfg = _cfg(tmp_path) + # An OpenBao identity credential exists, but must never be read here. + cfg.keycape_client_secret_file = Path("/must/not/read/openbao-client") + monkeypatch.setenv("BAO_TOKEN", "synthetic-forbidden-fallback") + monkeypatch.setattr(approval_auth, "credential_urlopen", _transport([], **claims)) + with pytest.raises(BackendError): + approval_token(cfg, scope="approval:read") + + +def test_exchange_failure_is_bounded_and_never_falls_back(tmp_path, monkeypatch): + cfg = _cfg(tmp_path) + seen = [] + def refuse(request, *, timeout): + seen.append(request.full_url) + raise HTTPError(request.full_url, 401, "private error", {}, BytesIO(b"secret-like error body")) + monkeypatch.setattr(approval_auth, "credential_urlopen", refuse) + with pytest.raises(BackendError, match="HTTP 401") as error: + approval_token(cfg, scope="approval:consume") + assert "secret-like" not in str(error.value) + assert len(seen) == 1 + + +def test_mixed_providers_refused_before_either_file_read(tmp_path): + cfg = _cfg(tmp_path, approval_token_file=Path("/not/read/token")) + with pytest.raises(DecisionError, match="conflict"): + approval_token(cfg, scope="approval:read") + + +def test_secret_file_protection_is_required(tmp_path): + cfg = _cfg(tmp_path) + cfg.approval_client_secret_file.chmod(0o644) + with pytest.raises(ProvisioningError, match="accessible"): + approval_token(cfg, scope="approval:read") + + +@pytest.mark.parametrize("url", [ + "http://keycape.example.test/token", "https://other.test/token", + ISSUER + "/token?next=elsewhere", ISSUER + "/token#fragment", +]) +def test_exchange_url_must_be_the_issuer_endpoint(url): + with pytest.raises(BackendError, match="HTTPS /token"): + KeyCapeApprovalAuthConfig(token_url=url, issuer=ISSUER, client_secret_file=Path("/unused")) + + +@pytest.mark.parametrize("url", [ + "http://approval.example", "http://localhost:8000", "https://approval-engine.svc.cluster.local", + "https://user:password@approval.test", "http://127.0.0.1:8000?next=other", "https://approval.test/#fragment", +]) +def test_unsafe_approval_destinations_refused_before_exchange(url): + with pytest.raises(DecisionError): + require_approval_address(url) + + +def test_approval_profile_cannot_relax_the_openbao_contract(): + with pytest.raises(BackendError): + KeyCapeServiceAuthConfig(token_url=ISSUER + "/token", issuer=ISSUER, + client_secret_file=Path("/unused"), audience="approval-engine") + cfg = KeyCapeApprovalAuthConfig(token_url=ISSUER + "/token", issuer=ISSUER, client_secret_file=Path("/unused")) + for changes in ({"client_id": "approval-engine-operator"}, {"scope": "approval:create"}, {"tenant": "tenant:coulomb"}): + with pytest.raises(BackendError): + replace(cfg, **changes) + now = datetime.now(timezone.utc) + stamp = int(now.timestamp()) + preflight_service_jwt(_token(iat=stamp + 30, exp=stamp + 930), cfg, now=now) + for changes in ({"iat": stamp + 31, "exp": stamp + 931}, {"exp": stamp}, {"exp": stamp + 901}): + with pytest.raises(BackendError): + preflight_service_jwt(_token(**changes), cfg, now=now) + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_redirect_cannot_forward_credentials(method): + seen = [] + class Redirect(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + def do_GET(self): + seen.append(self.path) + if self.path == "/start": + self.send_response(302) + self.send_header("Location", "/sink") + else: + self.send_response(200) + self.end_headers() + do_POST = do_GET + server = ThreadingHTTPServer(("127.0.0.1", 0), Redirect) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + req = Request(f"http://127.0.0.1:{server.server_port}/start", method=method, + headers={"Authorization": "Bearer synthetic-token"}) + with pytest.raises(HTTPError) as error: + credential_urlopen(req, timeout=2) + assert error.value.code == 302 + assert seen == ["/start"] + finally: + server.shutdown() + server.server_close() + thread.join() + + +def test_environment_selects_separate_approval_secret(monkeypatch): + monkeypatch.setenv("SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "/protected/approval.secret") + monkeypatch.setenv("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", "/protected/openbao.secret") + cfg = Config.load() + assert cfg.approval_client_secret_file == Path("/protected/approval.secret") + assert cfg.keycape_client_secret_file == Path("/protected/openbao.secret") + + +@pytest.mark.parametrize("failure", [None, "exchange", "conflict", "wrong-action"]) +def test_cli_gate_exchanges_for_claim_and_consume(tmp_path, monkeypatch, failure): + raw = copy.deepcopy(VALID) + raw["stage"] = "prod" + raw["approval"] = {"model": "bootstrap-only", "authorization_id": "approval-test", "purpose": "synthetic proof"} + entry = validate_entry(raw) + cfg = _cfg(tmp_path) + cfg.authorization_subject_id = "secrets-engine" + cfg.authorization_subject_type = "service" + cfg.authorization_policy_package = "secrets-engine.catalog-lane.lifecycle" + cfg.authorization_policy_version = "v2" + cfg.evidence_dir = tmp_path / "evidence" + cfg.pdp_token_file = tmp_path / "pdp.token" + cfg.pdp_token_file.write_text("synthetic-pdp-token") + cfg.pdp_token_file.chmod(0o600) + monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) + stub = AuthorizationStub(approval_id="approval-test", package=cfg.authorization_policy_package, version="v2").start() + seen = [] + transport = _transport(seen) + def exchange(request, *, timeout): + if failure == "exchange": + raise HTTPError(request.full_url, 401, "denied", {}, BytesIO(b"")) + return transport(request, timeout=timeout) + monkeypatch.setattr(approval_auth, "credential_urlopen", exchange) + cfg.approval_url = cfg.pdp_url = stub.url + from secrets_engine.approval_consume import _expected_request + stub.bind_request(_expected_request(cfg, entry, "apply")) + if failure == "conflict": + stub.consume_status = 409 + try: + if failure: + with pytest.raises((DecisionError, BackendError)): + cli._require_lane_approval(cfg, entry, "destroy" if failure == "wrong-action" else "apply") + assert not stub.consumed + else: + cli._require_lane_approval(cfg, entry, "apply") + assert stub.calls == ["claim", "check", "consume"] + assert seen == ["approval:read", "approval:consume"] + assert stub.consumed + finally: + stub.stop() diff --git a/tests/test_approval_consume.py b/tests/test_approval_consume.py index 6b58cd4..7d2bb4d 100644 --- a/tests/test_approval_consume.py +++ b/tests/test_approval_consume.py @@ -304,7 +304,7 @@ def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch): lambda *_args, **_kwargs: _authorized(), ) monkeypatch.setattr( - "secrets_engine.approval_consume.urlopen", + "secrets_engine.approval_consume.credential_urlopen", lambda *_args, **_kwargs: (_ for _ in ()).throw(_http_error(409)), ) monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) @@ -337,7 +337,7 @@ def test_confirmed_consume_allows_openbao_resolve(tmp_path, monkeypatch): monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance) monkeypatch.setattr(cli, "authorize_action", lambda *_args, **_kwargs: _authorized()) - monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener) + monkeypatch.setattr("secrets_engine.approval_consume.credential_urlopen", opener) monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) monkeypatch.setattr( cli.OpenBaoClient, diff --git a/tests/test_service_auth.py b/tests/test_service_auth.py index ca2cafc..3616d61 100644 --- a/tests/test_service_auth.py +++ b/tests/test_service_auth.py @@ -35,8 +35,8 @@ def _jwt(**overrides): "groups": [], "scope": "openbao:login", "assurance": { - "aal": "AAL1", - "method": "client_secret", + "level": "aal1", + "methods": ["client_secret"], "mfa": False, "source": "key-cape", }, @@ -91,7 +91,7 @@ class _Response: def __exit__(self, *_args): return False - def read(self): + def read(self, _size=-1): return json.dumps(self.payload).encode() diff --git a/tools/exercise_approval_identity.py b/tools/exercise_approval_identity.py new file mode 100644 index 0000000..afd1514 --- /dev/null +++ b/tools/exercise_approval_identity.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine. + +Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call. +The PDP is a sequencing double; this is not production authorization evidence. +""" +from __future__ import annotations + +import argparse +import base64 +import copy +import hashlib +import http.server +import ipaddress +import json +import os +from pathlib import Path +import secrets +import socket +import ssl +import subprocess +import sys +import tempfile +import threading +import time +from datetime import datetime, timedelta, timezone +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from unittest.mock import patch +from wsgiref.simple_server import WSGIRequestHandler, make_server + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +import yaml + +KEYCAPE_IMAGE = "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611" +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "src"), str(ROOT)] + + +def run(args): + result = subprocess.run(args, capture_output=True, timeout=45) + if result.returncode: + raise RuntimeError("contained command failed") + return result.stdout.decode().strip() + + +def commit(path): + return run(["git", "-C", str(path), "rev-parse", "HEAD"]) + + +def private_write(path, value): + with path.open("x") as out: + os.chmod(path, 0o600) + out.write(value) + + +def exercise(args): + sys.path.insert(0, str(args.approval_engine_source)) + from approval_engine.api import App + from approval_engine.auth import JWTAuthenticator + from approval_engine.store import Engine + from secrets_engine import cli + from secrets_engine.approval_auth import approval_token, credential_urlopen + from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval + from secrets_engine.catalog import validate_entry + from secrets_engine.config import Config + from secrets_engine.errors import BackendError, DecisionError + from tests.authorization_stub import AuthorizationStub + from tests.test_catalog import VALID + + receipt = { + "schema_version": 1, "target": "disposable local processes; synthetic credentials", + "started_at": datetime.now(timezone.utc).isoformat(), + "keycape_image": KEYCAPE_IMAGE, + "approval_engine_commit": commit(args.approval_engine_source), + "keycape_contract_commit": commit(args.keycape_source), + "consumer_source_sha256": { + name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest() + for name in ("approval_auth.py", "approval_consume.py", "config.py", "service_auth.py") + }, + "limitations": ["PDP sequencing double", "local Approval Engine source, not deployed image", + "no live custody or client-side read grant", "no OpenBao effect or model execution"], + "checks": {}, + } + with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary: + root = Path(temporary) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()) + now = datetime.now(timezone.utc) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "local approval identity exercise")]) + cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(hours=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), True) + .sign(key, hashes.SHA256())) + (root / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + keycape_port = sock.getsockname()[1] + + class Proxy(http.server.BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + def proxy(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if self.command == "POST" else None + req = Request(f"http://127.0.0.1:{keycape_port}" + self.path, data=body, + headers={k: self.headers[k] for k in ("Content-Type", "Authorization") if k in self.headers}) + try: + response = urlopen(req, timeout=10) + except HTTPError as error: + response = error + with response: + self.send_response(response.code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(response.read()) + do_GET = proxy + do_POST = proxy + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Proxy) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(root / "cert.pem", root / "key.pem") + server.socket = context.wrap_socket(server.socket, server_side=True) + issuer = f"https://127.0.0.1:{server.server_port}" + proxy_thread = threading.Thread(target=server.serve_forever, daemon=True) + proxy_thread.start() + config = yaml.safe_load((args.keycape_source / "config/dev-config.yaml").read_text()) + clients = yaml.safe_load((args.keycape_source / "config/service-clients.example.yaml").read_text())["clients"] + clients = [c for c in clients if c["clientId"] in ("secrets-engine-approval", "approval-engine-operator")] + assert len(clients) == 2 + config.update(issuer=issuer, clients=clients) + config["authelia"]["issuer"] = "https://synthetic-upstream.invalid" + private_write(root / "config.yaml", yaml.safe_dump(config, sort_keys=False)) + values = {c["clientId"]: secrets.token_urlsafe(48) for c in clients} + private_write(root / "env", "KEYCAPE_CONFIG=/etc/keycape/config.yaml\n" + "".join( + c["secretRef"].removeprefix("env:") + "=" + values[c["clientId"]] + "\n" for c in clients)) + private_write(root / "approval.secret", values["secrets-engine-approval"]) + private_write(root / "pdp.token", "synthetic-pdp-token") + container = "approval-identity-exercise-" + secrets.token_hex(5) + created = False + engine = None + api_server = None + pdp = None + try: + run(["docker", "run", "-d", "--rm", "--name", container, "--user", str(os.getuid()), + "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", + "--publish", f"127.0.0.1:{keycape_port}:8080", "--env-file", str(root / "env"), + "--mount", "type=bind,source=" + temporary + ",target=/etc/keycape,readonly", KEYCAPE_IMAGE]) + created = True + with patch.dict(os.environ, {"SSL_CERT_FILE": str(root / "cert.pem"), "SECRETS_ENGINE_UNSAFE_DEMO": ""}): + for _ in range(40): + try: + with credential_urlopen(issuer + "/jwks", timeout=1) as response: + if response.status == 200: + break + except Exception: + time.sleep(0.25) + else: + raise RuntimeError("disposable KeyCape did not start") + engine = Engine(root / "approval.sqlite") + app = App(engine, JWTAuthenticator(issuer=issuer, audience="approval-engine", jwks_url=issuer + "/jwks")) + class Quiet(WSGIRequestHandler): + def log_message(self, *_args): + pass + api_server = make_server("127.0.0.1", 0, app, handler_class=Quiet) + threading.Thread(target=api_server.serve_forever, daemon=True).start() + api = f"http://127.0.0.1:{api_server.server_port}" + + def operator_token(scope): + credential = base64.b64encode(("approval-engine-operator:" + values["approval-engine-operator"]).encode()).decode() + req = Request(issuer + "/token", data=urlencode({"grant_type": "client_credentials", "scope": scope}).encode(), + headers={"Authorization": "Basic " + credential}) + with credential_urlopen(req, timeout=3) as response: + return json.load(response)["access_token"] + + def operator_request(path, payload, scope): + req = Request(api + path, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": "Bearer " + operator_token(scope)}) + with credential_urlopen(req, timeout=3) as response: + return json.load(response) + + raw = copy.deepcopy(VALID) + raw["stage"] = "prod" + raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"} + entry = validate_entry(raw) + pdp = AuthorizationStub(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start() + cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="", + approval_url=api, approval_client_secret_file=root / "approval.secret", + keycape_issuer=issuer, keycape_token_url=issuer + "/token", + authorization_subject_id="secrets-engine", authorization_subject_type="service", + authorization_policy_package="secrets-engine.catalog-lane.lifecycle", authorization_policy_version="v2", + pdp_url=pdp.url, pdp_token_file=root / "pdp.token") + request = _expected_request(cfg, entry, "apply") + digest = pdp.bind_request(request) + obj = operator_request("/v1/approvals", { + "id": "synthetic-approval", + "binding": {"action": "secrets.apply", "target": {"id": entry.id, "stage": "prod"}, + "actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"}, + "validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()}, + "pdp_digest": digest, "pdp_path": True}, "approval:create") + operator_request(f"/v1/approvals/{obj['id']}/entries", {}, "approval:approve") + receipt["checks"]["operator_issued_and_approved_via_verified_jwt"] = True + try: + cli._require_lane_approval(cfg, entry, "destroy") + except DecisionError: + assert not engine.claim(obj["id"])["consumed"] + else: + raise AssertionError("wrong action accepted") + receipt["checks"]["wrong_action_refused_before_consume"] = True + cli._require_lane_approval(cfg, entry, "apply") + assert engine.claim(obj["id"])["consumed"] + assert pdp.calls == ["check"] + receipt["checks"]["actual_consumer_claim_check_consume"] = True + binding = ConsumeBinding(obj["id"], digest) + retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume")) + assert retry.idempotent + receipt["checks"]["same_digest_retry_idempotent"] = True + try: + consume_approval(base_url=api, binding=ConsumeBinding(obj["id"], "sha256:" + "f" * 64), + token_provider=lambda: approval_token(cfg, scope="approval:consume")) + except DecisionError: + pass + else: + raise AssertionError("different digest accepted") + receipt["checks"]["different_digest_refused"] = True + try: + cli._require_lane_approval(cfg, entry, "apply") + except DecisionError: + pass + else: + raise AssertionError("spent claim accepted") + receipt["checks"]["spent_claim_refused"] = True + try: + operator_token("approval:consume") + except HTTPError as error: + assert error.code in (400, 401) + assert json.load(error)["error"] == "invalid_profile_usage" + else: + raise AssertionError("operator consume scope accepted") + receipt["checks"]["operator_consume_scope_denied_by_issuer"] = True + (root / "approval.secret").write_text("synthetic-wrong-secret") + try: + approval_token(cfg, scope="approval:read") + except BackendError: + pass + else: + raise AssertionError("wrong secret accepted") + receipt["checks"]["wrong_secret_refused"] = True + assert not (root / "approval.token").exists() + receipt["checks"]["no_access_token_file_created"] = True + finally: + if api_server: + api_server.shutdown() + api_server.server_close() + if pdp: + pdp.stop() + if engine: + engine.close() + if created: + run(["docker", "stop", "--time=5", container]) + server.shutdown() + server.server_close() + proxy_thread.join() + receipt.update(status="passed", cleanup_complete=True, finished_at=datetime.now(timezone.utc).isoformat()) + private_write(args.receipt, json.dumps(receipt, indent=2) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--keycape-source", required=True, type=Path) + parser.add_argument("--approval-engine-source", required=True, type=Path) + parser.add_argument("--receipt", required=True, type=Path) + args = parser.parse_args() + if args.receipt.exists(): + raise SystemExit("receipt path must be new") + try: + exercise(args) + except Exception as error: + # Never dump a request, client environment or credential-bearing frame. + print("Synthetic approval identity exercise failed: " + type(error).__name__, file=sys.stderr) + return 1 + print("Synthetic approval identity exercise passed; temporary resources removed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md b/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md index d591fee..93ca2c2 100644 --- a/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md +++ b/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md @@ -7,7 +7,7 @@ repo: secrets-engine status: blocked owner: codex created: "2026-09-05" -updated: "2026-09-05" +updated: "2026-09-09" state_hub_workstream_id: "40ccc3b4-d046-5a58-8649-e7935f45c974" --- @@ -148,3 +148,36 @@ and negative access, register delivery-ready evidence, bind owner exec with service authentication, and return verified pins/evidence to SAND-WP-0015 and GLAS-WP-0012. Provider scope/budget and expiry remain explicit acceptance inputs. Keep the catalog route inactive until real verification passes. + + +### 2026-09-09 consumer identity implementation and component proof + +The "one external dependency" statement above concealed another local seam: +claim and consume could only read a manually supplied bearer-token file. The +OpenBao service-JWT provider cannot be reused with approval credentials because +its audience, tenant and scope are different. + +Implemented the separate secrets-engine-approval provider and wired it into +both actual claim/consume call sites. It requests only read or consume for the +current request, keeps the token in memory, rejects mixed providers, and never +falls back on exchange failure. The exact service:secrets-engine / approval-engine / +tenant:platform identity and scope/time bounds are preflighted. Credential-bearing +redirects and unsupported approval endpoint shapes are refused. + +The pinned real KeyCape image exposed a second defect: the shared consumer +expected assurance.aal/method but the issuer emits assurance.level/methods. +Corrected to the issuer's actual source contract, with regression coverage. +The real Approval Engine JWT verifier and SQLite store then accepted the actual +consumer's claim/check/consume chain in a disposable component exercise. Wrong +action/secret, spent claim, different digest and operator consume scope are +refused; same-digest consume retries are idempotent. Only the PDP was a test +double. The suite passed 350 tests; no production credential was read. + +Contract and repeatable exercise: docs/approval-service-auth.md; receipt: +docs/evidence/2026-09-09-approval-identity-exercise.json. + +T03 remains wait for RPF-WP-0035-T06's separately admitted client-side reader, +AUDIT-WP-0009-T09 audit custody, APPROVAL-WP-0002 live endpoint/claim/consume, +and the existing exact native OpenBao authority/delivery returns. The synthetic +proof does not grant these. KEY-WP-0013-T02 and verifier CCR-2026-0017/0018 +are complete and must not be requested again. No new workplan duplicates T03.