feat: exchange scoped approval service tokens per request
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
3a19069b4b
commit
7688445184
14 changed files with 859 additions and 35 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
83
docs/approval-service-auth.md
Normal file
83
docs/approval-service-auth.md
Normal file
|
|
@ -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/<new-metadata-receipt>.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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
34
docs/evidence/2026-09-09-approval-identity-exercise.json
Normal file
34
docs/evidence/2026-09-09-approval-identity-exercise.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
113
src/secrets_engine/approval_auth.py
Normal file
113
src/secrets_engine/approval_auth.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
237
tests/test_approval_auth.py
Normal file
237
tests/test_approval_auth.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
293
tools/exercise_approval_identity.py
Normal file
293
tools/exercise_approval_identity.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue