feat: implement the PIP claim + validate authorization join
resolve_consume_binding was a `return None` stub, so protocol step 1 of
docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the
validation join never existed. validate_action_authorization had no caller
in src/ at all - it was reachable only from tests. Production fail-closed
was correct, but for an undocumented second reason, and WP-0007-T04's
"what remains is not local engine work" was wrong.
The join now reproduces the exact CheckRequest via build_action_request,
fetches the durable ActionAuthorization, and validates request binding,
digest, validity, authority, policy pin, and distinct-approver threshold
before offering a consume binding. _require_lane_approval threads the exact
field set for provision/rotate/verify/exec so the digest covers the real
proposed action.
Deliberate choices:
- The approval-engine object id is never inferred from a State Hub decision
UUID; flex-auth stated GET /decisions/{uuid} is not the durable object.
- No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is
example vocabulary, not a published package.
- A half-configured join raises rather than returning None, so a partial
deployment cannot be mistaken for an unconfigured one.
Behavior is unchanged today: every new input is absent by default, so
production still fails closed and plan/--dry-run still work. 234 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
This commit is contained in:
parent
ebcc36ecab
commit
627810b478
10 changed files with 456 additions and 25 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
# Approval consumption (PEP)
|
# Approval consumption (PEP)
|
||||||
|
|
||||||
Status: engine consumer implemented; live production remains fail-closed
|
Status: engine consumer and the PIP claim/validate join are implemented; live
|
||||||
until a durable consume binding is served.
|
production remains fail-closed until approval-engine and access-engine actually
|
||||||
|
serve the durable objects.
|
||||||
|
|
||||||
Normative protocol: `gate-house/docs/contracts/approval-consumption.md`
|
Normative protocol: `gate-house/docs/contracts/approval-consumption.md`
|
||||||
(`GH-DEC-2026-003`). Implementation surface:
|
(`GH-DEC-2026-003`). Implementation surface:
|
||||||
|
|
@ -18,6 +19,20 @@ does not redefine it.
|
||||||
4. PEP OpenBao call → only after consume succeeds
|
4. PEP OpenBao call → only after consume succeeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the
|
||||||
|
exact CheckRequest with `build_action_request`, fetches the durable
|
||||||
|
`ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with
|
||||||
|
`validate_action_authorization` before returning a consume binding. Until
|
||||||
|
2026-09-06 that function was a `return None` stub and the validator was
|
||||||
|
unreachable from `src/`; the join now exists.
|
||||||
|
|
||||||
|
The approval-engine object id is never inferred from a State Hub decision UUID.
|
||||||
|
It comes from catalog `approval.authorization_id` or a served
|
||||||
|
`decision.authorization_id`. The Check request `id` is an opaque correlator the
|
||||||
|
PEP cannot regenerate, so the served one is adopted; every security-relevant
|
||||||
|
field is still compared exactly and the binding digest is recomputed against the
|
||||||
|
served request.
|
||||||
|
|
||||||
Every live privileged production handler passes `_require_lane_approval`,
|
Every live privileged production handler passes `_require_lane_approval`,
|
||||||
which calls `require_production_consume` before `OpenBaoClient.resolve`.
|
which calls `require_production_consume` before `OpenBaoClient.resolve`.
|
||||||
Dry-run and `plan` do not consume. Build/test remain fail-open relative to
|
Dry-run and `plan` do not consume. Build/test remain fail-open relative to
|
||||||
|
|
@ -29,6 +44,9 @@ path.
|
||||||
| Condition | Result |
|
| Condition | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| No served consume binding | refuse; no OpenBao |
|
| No served consume binding | refuse; no OpenBao |
|
||||||
|
| URL/token/authorization id all unset | binding is `None` → refuse; no OpenBao |
|
||||||
|
| Partially configured join (missing subject or policy pin) | raise; never degrade to "unconfigured" |
|
||||||
|
| Claim digest, action, or field set mismatch | refuse; no OpenBao |
|
||||||
| Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao |
|
| Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao |
|
||||||
| HTTP 409 / different digest | refuse; no OpenBao |
|
| HTTP 409 / different digest | refuse; no OpenBao |
|
||||||
| Same digest after consume | idempotent success; OpenBao may proceed |
|
| Same digest after consume | idempotent success; OpenBao may proceed |
|
||||||
|
|
@ -39,6 +57,24 @@ The consume response is mutation evidence (`status=consumed` plus the
|
||||||
presented digest). It is not a permission. Evidence records approval id,
|
presented digest). It is not a permission. Evidence records approval id,
|
||||||
digest, idempotence, and consumed-at only. No token, secret, or accessor.
|
digest, idempotence, and consumed-at only. No token, secret, or accessor.
|
||||||
|
|
||||||
|
## Required configuration
|
||||||
|
|
||||||
|
The join is absent by default, so an unconfigured engine behaves exactly as it
|
||||||
|
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_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
|
||||||
|
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin |
|
||||||
|
| `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold |
|
||||||
|
|
||||||
|
There is deliberately no default policy pin. flex-auth stated that the published
|
||||||
|
`secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope,
|
||||||
|
not a live package, so treating them as a default would pin production to a
|
||||||
|
package nobody publishes.
|
||||||
|
|
||||||
## What this does not do
|
## What this does not do
|
||||||
|
|
||||||
- It does not enable live production. Unreachable-engine stance still
|
- It does not enable live production. Unreachable-engine stance still
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ from typing import Any, Callable
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from secrets_engine.authorization import (
|
||||||
|
build_action_request,
|
||||||
|
request_digest,
|
||||||
|
validate_action_authorization,
|
||||||
|
)
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
from secrets_engine.openbao import read_strict_token_file
|
from secrets_engine.openbao import read_strict_token_file
|
||||||
from secrets_engine.pep_stance import demo_exception_enabled
|
from secrets_engine.pep_stance import demo_exception_enabled
|
||||||
|
|
@ -59,18 +64,162 @@ class ConsumedApproval:
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def resolve_consume_binding(
|
def _authorization_id(entry: Any, decision: Any) -> str:
|
||||||
_cfg: Any,
|
"""Non-secret approval-engine object id for this lane, or "" if unbound.
|
||||||
_entry: Any,
|
|
||||||
_action: str,
|
|
||||||
_decision: Any,
|
|
||||||
) -> ConsumeBinding | None:
|
|
||||||
"""Return the served consume binding, or None if it is not available.
|
|
||||||
|
|
||||||
The durable ActionAuthorization / approval serving path is still external
|
flex-auth: ``ActionAuthorization.id`` is the approval-engine object UUID.
|
||||||
(SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). Tests may replace this hook.
|
It is never the State Hub decision UUID, so it is not inferred from one.
|
||||||
"""
|
"""
|
||||||
return None
|
approval = getattr(entry, "approval", None) or {}
|
||||||
|
if isinstance(approval, dict):
|
||||||
|
declared = str(approval.get("authorization_id", "") or "").strip()
|
||||||
|
if declared:
|
||||||
|
return declared
|
||||||
|
for attr in ("authorization_id", "action_authorization_id"):
|
||||||
|
served = str(getattr(decision, attr, "") or "").strip()
|
||||||
|
if served:
|
||||||
|
return served
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _request_purpose(entry: Any) -> str:
|
||||||
|
"""Declared purpose for the request context. Never invented at call time."""
|
||||||
|
approval = getattr(entry, "approval", None) or {}
|
||||||
|
if isinstance(approval, dict):
|
||||||
|
declared = str(approval.get("purpose", "") or "").strip()
|
||||||
|
if declared:
|
||||||
|
return declared
|
||||||
|
for consumer in getattr(entry, "consumers", None) or []:
|
||||||
|
if isinstance(consumer, dict):
|
||||||
|
declared = str(consumer.get("purpose", "") or "").strip()
|
||||||
|
if declared:
|
||||||
|
return declared
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_action_authorization(
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
token_file: Path,
|
||||||
|
authorization_id: str,
|
||||||
|
timeout_seconds: float = 3,
|
||||||
|
opener: Callable[..., Any] = urlopen,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed."""
|
||||||
|
if not base_url or not base_url.startswith(("http://", "https://")):
|
||||||
|
raise DecisionError("approval-engine claim URL is missing or invalid")
|
||||||
|
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")
|
||||||
|
request = Request(
|
||||||
|
base_url.rstrip("/") + f"/v1/approvals/{ident}/claim",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
request.add_header("Authorization", f"Bearer {token}")
|
||||||
|
request.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with opener(request, timeout=timeout_seconds) as response:
|
||||||
|
if getattr(response, "status", 200) != 200:
|
||||||
|
raise DecisionError("approval claim did not return the authorization")
|
||||||
|
payload = json.loads(response.read(_MAX_BODY).decode("utf-8"))
|
||||||
|
except HTTPError as e:
|
||||||
|
raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e
|
||||||
|
except URLError as e:
|
||||||
|
raise DecisionError("approval-engine is unreachable for claim") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise DecisionError("approval claim returned a non-JSON body") from e
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise DecisionError("approval claim returned a non-object body")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_consume_binding(
|
||||||
|
cfg: Any,
|
||||||
|
entry: Any,
|
||||||
|
action: str,
|
||||||
|
decision: Any,
|
||||||
|
*,
|
||||||
|
fields: tuple[str, ...] = (),
|
||||||
|
policy_targets: tuple[str, ...] = (),
|
||||||
|
auth_targets: tuple[str, ...] = (),
|
||||||
|
opener: Callable[..., Any] | None = None,
|
||||||
|
) -> ConsumeBinding | None:
|
||||||
|
"""Join the proposed action to a served ActionAuthorization (PIP + validate).
|
||||||
|
|
||||||
|
Returns None only when this repo holds no configured serving path, which
|
||||||
|
keeps production fail-closed exactly as it was before the join existed.
|
||||||
|
Anything configured-but-wrong raises instead of degrading to None: a
|
||||||
|
half-configured PEP must not look like an unconfigured one.
|
||||||
|
"""
|
||||||
|
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||||
|
token_file = getattr(cfg, "approval_token_file", None)
|
||||||
|
authorization_id = _authorization_id(entry, decision)
|
||||||
|
if not base_url or not token_file or not authorization_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
subject_id = str(getattr(cfg, "authorization_subject_id", "") or "")
|
||||||
|
subject_type = str(getattr(cfg, "authorization_subject_type", "") or "")
|
||||||
|
if not subject_id or not subject_type:
|
||||||
|
raise DecisionError(
|
||||||
|
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
|
||||||
|
"and _SUBJECT_TYPE; the PEP must not assert an unnamed subject"
|
||||||
|
)
|
||||||
|
package = str(getattr(cfg, "authorization_policy_package", "") or "")
|
||||||
|
version = str(getattr(cfg, "authorization_policy_version", "") or "")
|
||||||
|
if not package or not version:
|
||||||
|
raise DecisionError(
|
||||||
|
"authorization join requires an explicitly configured policy "
|
||||||
|
"package/version pin; the published example vocabulary "
|
||||||
|
"(secrets-engine.lifecycle/v1) is not a live pin"
|
||||||
|
)
|
||||||
|
purpose = _request_purpose(entry)
|
||||||
|
if not purpose:
|
||||||
|
raise DecisionError(
|
||||||
|
"authorization join requires a declared approval/consumer purpose"
|
||||||
|
)
|
||||||
|
|
||||||
|
envelope = fetch_action_authorization(
|
||||||
|
base_url=base_url,
|
||||||
|
token_file=Path(token_file),
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
opener=opener or urlopen,
|
||||||
|
)
|
||||||
|
# The Check request id is an opaque correlator chosen by the requester, so
|
||||||
|
# the PEP cannot regenerate it and adopts the served one. Every
|
||||||
|
# security-relevant field (subject, action, resource, context) is still
|
||||||
|
# compared exactly by validate_action_authorization, and the served
|
||||||
|
# binding digest is recomputed against the served request, so adopting the
|
||||||
|
# id cannot let a mismatched request validate.
|
||||||
|
served_request = envelope.get("request")
|
||||||
|
served_id = ""
|
||||||
|
if isinstance(served_request, dict):
|
||||||
|
served_id = str(served_request.get("id", "") or "")
|
||||||
|
expected_request = build_action_request(
|
||||||
|
entry,
|
||||||
|
action,
|
||||||
|
subject_id=subject_id,
|
||||||
|
subject_type=subject_type,
|
||||||
|
purpose=purpose,
|
||||||
|
fields=fields,
|
||||||
|
policy_targets=policy_targets,
|
||||||
|
auth_targets=auth_targets,
|
||||||
|
request_id=served_id,
|
||||||
|
)
|
||||||
|
validated = validate_action_authorization(
|
||||||
|
envelope,
|
||||||
|
expected_request,
|
||||||
|
accepted_policy_packages={package},
|
||||||
|
accepted_policy_versions={version},
|
||||||
|
minimum_approval_count=int(getattr(cfg, "authorization_min_approvals", 1) or 1),
|
||||||
|
)
|
||||||
|
if validated.action != action:
|
||||||
|
raise DecisionError("action authorization does not bind this action")
|
||||||
|
return ConsumeBinding(
|
||||||
|
approval_id=validated.authorization_id,
|
||||||
|
request_digest=request_digest(expected_request),
|
||||||
|
decision_id=validated.decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def consume_approval(
|
def consume_approval(
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ def _require_lane_approval(
|
||||||
entry,
|
entry,
|
||||||
action: str = "",
|
action: str = "",
|
||||||
evidence: PrivilegedActionEvidence | None = None,
|
evidence: PrivilegedActionEvidence | None = None,
|
||||||
|
*,
|
||||||
|
fields: tuple[str, ...] = (),
|
||||||
):
|
):
|
||||||
"""Apply published PEP stance, resolve lane approval, then CAS-consume.
|
"""Apply published PEP stance, resolve lane approval, then CAS-consume.
|
||||||
|
|
||||||
|
|
@ -136,7 +138,9 @@ def _require_lane_approval(
|
||||||
require_production_consume(
|
require_production_consume(
|
||||||
cfg,
|
cfg,
|
||||||
entry,
|
entry,
|
||||||
binding=resolve_consume_binding(cfg, entry, action or "unknown", decision),
|
binding=resolve_consume_binding(
|
||||||
|
cfg, entry, action or "unknown", decision, fields=fields
|
||||||
|
),
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
)
|
)
|
||||||
return decision
|
return decision
|
||||||
|
|
@ -311,7 +315,9 @@ def cmd_provision(cfg: Config, args) -> int:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, "provision", evidence)
|
decision = _require_lane_approval(
|
||||||
|
cfg, entry, "provision", evidence, fields=(field,) if field else ()
|
||||||
|
)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
require_provision_state(cfg.evidence_dir, entry.id)
|
require_provision_state(cfg.evidence_dir, entry.id)
|
||||||
with _open_backend(cfg, args, evidence) as client:
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
|
|
@ -346,7 +352,9 @@ def cmd_rotate(cfg: Config, args) -> int:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, "rotate", evidence)
|
decision = _require_lane_approval(
|
||||||
|
cfg, entry, "rotate", evidence, fields=(field,) if field else ()
|
||||||
|
)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
with _open_backend(cfg, args, evidence) as client:
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
f = rotate_from_file(client, entry, field, Path(args.from_file))
|
f = rotate_from_file(client, entry, field, Path(args.from_file))
|
||||||
|
|
@ -386,7 +394,9 @@ def cmd_verify(cfg: Config, args) -> int:
|
||||||
"negative_requested": negative,
|
"negative_requested": negative,
|
||||||
},
|
},
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry, "verify", evidence)
|
decision = _require_lane_approval(
|
||||||
|
cfg, entry, "verify", evidence, fields=tuple(fields)
|
||||||
|
)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
with _open_backend(cfg, args, evidence) as client:
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
if entry.stores_kv_value() and not fields:
|
if entry.stores_kv_value() and not fields:
|
||||||
|
|
@ -540,7 +550,9 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
},
|
},
|
||||||
) as evidence:
|
) as evidence:
|
||||||
# require approval + readiness before running.
|
# require approval + readiness before running.
|
||||||
decision = _require_lane_approval(cfg, entry, "exec", evidence)
|
decision = _require_lane_approval(
|
||||||
|
cfg, entry, "exec", evidence, fields=(field,) if field else ()
|
||||||
|
)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
|
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
|
||||||
if not args.command:
|
if not args.command:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ def repo_root() -> Path:
|
||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(raw: str, default: int = 1) -> int:
|
||||||
|
"""Parse a positive approval threshold. Anything malformed keeps the default."""
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return value if value >= 1 else default
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Config:
|
class Config:
|
||||||
catalog_dir: Path
|
catalog_dir: Path
|
||||||
|
|
@ -33,6 +42,13 @@ class Config:
|
||||||
keycape_issuer: str = ""
|
keycape_issuer: str = ""
|
||||||
keycape_client_secret_file: Path | None = None
|
keycape_client_secret_file: Path | None = None
|
||||||
openbao_jwt_login_file: Path | None = None
|
openbao_jwt_login_file: Path | None = None
|
||||||
|
# PIP/PDP join (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). All absent by
|
||||||
|
# default: an unset value fails production closed exactly as before.
|
||||||
|
authorization_subject_id: str = ""
|
||||||
|
authorization_subject_type: str = ""
|
||||||
|
authorization_policy_package: str = ""
|
||||||
|
authorization_policy_version: str = ""
|
||||||
|
authorization_min_approvals: int = 1
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> "Config":
|
def load(cls) -> "Config":
|
||||||
|
|
@ -55,4 +71,19 @@ class Config:
|
||||||
keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""),
|
keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""),
|
||||||
keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None,
|
keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None,
|
||||||
openbao_jwt_login_file=Path(jwt_login) if jwt_login else None,
|
openbao_jwt_login_file=Path(jwt_login) if jwt_login else None,
|
||||||
|
authorization_subject_id=os.environ.get(
|
||||||
|
"SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID", ""
|
||||||
|
),
|
||||||
|
authorization_subject_type=os.environ.get(
|
||||||
|
"SECRETS_ENGINE_AUTHORIZATION_SUBJECT_TYPE", ""
|
||||||
|
),
|
||||||
|
authorization_policy_package=os.environ.get(
|
||||||
|
"SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE", ""
|
||||||
|
),
|
||||||
|
authorization_policy_version=os.environ.get(
|
||||||
|
"SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION", ""
|
||||||
|
),
|
||||||
|
authorization_min_approvals=_positive_int(
|
||||||
|
os.environ.get("SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS", "")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,7 @@ def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"resolve_consume_binding",
|
"resolve_consume_binding",
|
||||||
lambda *_args: _binding(),
|
lambda *_args, **_kwargs: _binding(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"secrets_engine.approval_consume.urlopen",
|
"secrets_engine.approval_consume.urlopen",
|
||||||
|
|
@ -328,7 +328,7 @@ def test_confirmed_consume_allows_openbao_resolve(tmp_path, monkeypatch):
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
||||||
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
|
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
|
||||||
monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args: _binding())
|
monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args, **_kwargs: _binding())
|
||||||
monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener)
|
monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener)
|
||||||
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
163
tests/test_consume_binding_join.py
Normal file
163
tests/test_consume_binding_join.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""PIP claim + validate join (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02).
|
||||||
|
|
||||||
|
These cover the seam that was previously a `return None` stub: the engine now
|
||||||
|
reproduces the exact CheckRequest, fetches the durable ActionAuthorization, and
|
||||||
|
validates it before offering a consume binding. A half-configured PEP must
|
||||||
|
raise rather than look like an unconfigured one.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine.approval_consume import resolve_consume_binding
|
||||||
|
from secrets_engine.authorization import build_action_request, request_digest
|
||||||
|
from secrets_engine.catalog import validate_entry
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
from tests.test_action_authorization import _envelope
|
||||||
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
|
AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, token_file, **over):
|
||||||
|
self.approval_url = "https://approval.example"
|
||||||
|
self.approval_token_file = token_file
|
||||||
|
self.authorization_subject_id = "user:alice"
|
||||||
|
self.authorization_subject_type = "Human"
|
||||||
|
self.authorization_policy_package = "secrets-engine.lifecycle"
|
||||||
|
self.authorization_policy_version = "v1"
|
||||||
|
self.authorization_min_approvals = 2
|
||||||
|
for k, v in over.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry():
|
||||||
|
raw = copy.deepcopy(VALID)
|
||||||
|
raw["approval"] = dict(raw.get("approval") or {})
|
||||||
|
raw["approval"]["authorization_id"] = AUTH_ID
|
||||||
|
raw["approval"]["purpose"] = "contract-test"
|
||||||
|
return validate_entry(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _token(tmp_path):
|
||||||
|
f = tmp_path / "approval.token"
|
||||||
|
f.write_text("token-value\n")
|
||||||
|
f.chmod(0o600)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _served(**over):
|
||||||
|
"""A served envelope whose validity window is live now."""
|
||||||
|
env = copy.deepcopy(_envelope())
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
env["validity"] = {
|
||||||
|
"not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
}
|
||||||
|
approved = (now - timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
for approval in env["approvals"]["entries"]:
|
||||||
|
approval["approved_at"] = approved
|
||||||
|
env.update(over)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _opener(envelope, status=200):
|
||||||
|
def _open(request, timeout=None):
|
||||||
|
body = json.dumps(envelope).encode()
|
||||||
|
resp = io.BytesIO(body)
|
||||||
|
resp.status = status
|
||||||
|
resp.__enter__ = lambda s=resp: s
|
||||||
|
resp.__exit__ = lambda s, *a: False
|
||||||
|
return resp
|
||||||
|
return _open
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(cfg, entry, envelope, action="deactivate"):
|
||||||
|
return resolve_consume_binding(
|
||||||
|
cfg, entry, action, None,
|
||||||
|
fields=("api_token",),
|
||||||
|
policy_targets=(entry.policy_name,),
|
||||||
|
auth_targets=(entry.role_name,),
|
||||||
|
opener=_opener(envelope),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_serving_path_stays_fail_closed(tmp_path):
|
||||||
|
"""No URL/token/authorization id: None, exactly as before the join existed."""
|
||||||
|
cfg = _Cfg(None, approval_url="", approval_token_file=None)
|
||||||
|
assert resolve_consume_binding(cfg, _entry(), "deactivate", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
|
||||||
|
entry = _entry()
|
||||||
|
cfg = _Cfg(_token(tmp_path))
|
||||||
|
binding = _resolve(cfg, entry, _served())
|
||||||
|
assert binding is not None
|
||||||
|
assert binding.approval_id == AUTH_ID
|
||||||
|
expected = build_action_request(
|
||||||
|
entry, "deactivate",
|
||||||
|
subject_id="user:alice", subject_type="Human", purpose="contract-test",
|
||||||
|
fields=["api_token"],
|
||||||
|
policy_targets=[entry.policy_name], auth_targets=[entry.role_name],
|
||||||
|
request_id="check:test-lane-deactivate",
|
||||||
|
)
|
||||||
|
assert binding.request_digest == request_digest(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
|
||||||
|
"""Half-configured must not be mistaken for unconfigured."""
|
||||||
|
cfg = _Cfg(_token(tmp_path), authorization_subject_id="")
|
||||||
|
with pytest.raises(DecisionError, match="SUBJECT_ID"):
|
||||||
|
_resolve(cfg, _entry(), _served())
|
||||||
|
|
||||||
|
|
||||||
|
def test_example_policy_names_are_not_an_implicit_pin(tmp_path):
|
||||||
|
"""flex-auth: the published example vocabulary is not a live pin."""
|
||||||
|
cfg = _Cfg(_token(tmp_path), authorization_policy_package="")
|
||||||
|
with pytest.raises(DecisionError, match="policy .*pin"):
|
||||||
|
_resolve(cfg, _entry(), _served())
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_field_set_fails_closed(tmp_path):
|
||||||
|
"""A different proposed field set must not match the served digest."""
|
||||||
|
entry = _entry()
|
||||||
|
cfg = _Cfg(_token(tmp_path))
|
||||||
|
with pytest.raises(DecisionError):
|
||||||
|
resolve_consume_binding(
|
||||||
|
cfg, entry, "deactivate", None,
|
||||||
|
fields=("some_other_field",),
|
||||||
|
policy_targets=(entry.policy_name,),
|
||||||
|
auth_targets=(entry.role_name,),
|
||||||
|
opener=_opener(_served()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_mismatch_fails_closed(tmp_path):
|
||||||
|
"""A destroy must never ride a deactivate authorization."""
|
||||||
|
entry = _entry()
|
||||||
|
cfg = _Cfg(_token(tmp_path))
|
||||||
|
with pytest.raises(DecisionError):
|
||||||
|
_resolve(cfg, entry, _served(), action="destroy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_approval_engine_fails_closed(tmp_path):
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
def _boom(request, timeout=None):
|
||||||
|
raise URLError("no route")
|
||||||
|
|
||||||
|
with pytest.raises(DecisionError, match="unreachable"):
|
||||||
|
resolve_consume_binding(
|
||||||
|
_Cfg(_token(tmp_path)), _entry(), "deactivate", None,
|
||||||
|
fields=("api_token",), opener=_boom,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_superseded_authorization_fails_closed(tmp_path):
|
||||||
|
with pytest.raises(DecisionError):
|
||||||
|
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(status="superseded"))
|
||||||
|
|
@ -80,7 +80,7 @@ def test_verify_defaults_to_every_declared_field_and_one_path_denial(
|
||||||
records = []
|
records = []
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
||||||
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args: None)
|
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
|
||||||
monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object())
|
monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object())
|
||||||
|
|
||||||
def fake_verify(
|
def fake_verify(
|
||||||
|
|
@ -130,7 +130,9 @@ def test_live_destroy_fails_before_approval_or_backend_until_action_contract(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"_require_lane_approval",
|
"_require_lane_approval",
|
||||||
lambda *_args: pytest.fail("coarse lane approval must not authorize destroy"),
|
lambda *_args, **_kwargs: pytest.fail(
|
||||||
|
"coarse lane approval must not authorize destroy"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli.OpenBaoClient,
|
cli.OpenBaoClient,
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ def test_provision_backend_exception_has_attempt_and_terminal_evidence(
|
||||||
):
|
):
|
||||||
entry = validate_entry(copy.deepcopy(VALID))
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
||||||
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args: None)
|
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
|
||||||
monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object())
|
monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
|
|
@ -78,7 +78,7 @@ def test_provision_decision_rejection_is_recorded_before_backend(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli,
|
cli,
|
||||||
"_require_lane_approval",
|
"_require_lane_approval",
|
||||||
lambda *_args: (_ for _ in ()).throw(DecisionError("not approved")),
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(DecisionError("not approved")),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cli.OpenBaoClient,
|
cli.OpenBaoClient,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-23"
|
created: "2026-08-23"
|
||||||
updated: "2026-09-02"
|
updated: "2026-09-06"
|
||||||
state_hub_workstream_id: "68a39be1-bd9c-5133-ad64-e7bca892aaf3"
|
state_hub_workstream_id: "68a39be1-bd9c-5133-ad64-e7bca892aaf3"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -243,6 +243,36 @@ exception. Live destroy remains disabled independently. State Hub endpoint and
|
||||||
authenticated approval storage are still outstanding. flex-auth corrected its
|
authenticated approval storage are still outstanding. flex-auth corrected its
|
||||||
example digest and added a complete binding regression assertion in `d402408`.
|
example digest and added a complete binding regression assertion in `d402408`.
|
||||||
|
|
||||||
|
Correction 2026-09-06. The 2026-08-29 note above ("What remains is not local
|
||||||
|
engine work") was wrong. `resolve_consume_binding` was a `return None` stub, so
|
||||||
|
protocol step 1 (PIP `GET /v1/approvals/{id}/claim`) and the validation join
|
||||||
|
were never implemented, and `validate_action_authorization` — the validator this
|
||||||
|
plan called "shipped" — had no caller in `src/` at all. Only step 3 (CAS
|
||||||
|
consume) and the OpenBao gate were real. Production fail-closed was therefore
|
||||||
|
correct but for a second, undocumented reason.
|
||||||
|
|
||||||
|
The join is now implemented. `resolve_consume_binding` reproduces the exact
|
||||||
|
CheckRequest via `build_action_request`, fetches the durable
|
||||||
|
ActionAuthorization, and validates request binding, digest, validity, authority,
|
||||||
|
policy pin, and distinct-approver threshold before returning a binding.
|
||||||
|
`_require_lane_approval` threads the exact field set for provision, rotate,
|
||||||
|
verify, and exec so the digest covers the real proposed action. Eight tests in
|
||||||
|
`tests/test_consume_binding_join.py` cover unconfigured, half-configured, digest
|
||||||
|
mismatch, action mismatch, unreachable, and superseded paths.
|
||||||
|
|
||||||
|
Two deliberate choices, both recorded in `docs/approval-consumption.md`:
|
||||||
|
the approval-engine object id is never inferred from a State Hub decision UUID
|
||||||
|
(flex-auth: `GET /decisions/{uuid}` is not the durable object); and there is no
|
||||||
|
default policy pin, because flex-auth stated `secrets-engine.lifecycle`/`v1` is
|
||||||
|
example vocabulary rather than a published package.
|
||||||
|
|
||||||
|
Behavior today is bit-for-bit unchanged: every new input is absent by default,
|
||||||
|
so an unconfigured engine still fails production closed, and `plan`/`--dry-run`
|
||||||
|
still work. This task stays `wait`, but the remaining constraint is now purely
|
||||||
|
deployment: approval-engine must serve the claim endpoint and access-engine must
|
||||||
|
serve Check. Probed 2026-09-06 — neither is reachable, and State Hub exposes
|
||||||
|
only `/decisions/`.
|
||||||
|
|
||||||
Define and enforce the decision contract needed by production commands. A
|
Define and enforce the decision contract needed by production commands. A
|
||||||
resolved approval must bind at least:
|
resolved approval must bind at least:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-29"
|
created: "2026-08-29"
|
||||||
updated: "2026-09-02"
|
updated: "2026-09-06"
|
||||||
state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0"
|
state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -103,6 +103,14 @@ binding fail closed with no OpenBao call. Production still also fail-closes
|
||||||
on the unpublished durable ActionAuthorization / consume-binding serving
|
on the unpublished durable ActionAuthorization / consume-binding serving
|
||||||
path, so this task remains `wait`.
|
path, so this task remains `wait`.
|
||||||
|
|
||||||
|
Correction 2026-09-06. See `SECRETS-WP-0007-T04` for the full note. Summary: the
|
||||||
|
consume-before-OpenBao gate was real, but the PIP claim + validate join was a
|
||||||
|
`return None` stub and `validate_action_authorization` had no production caller,
|
||||||
|
so this task's "blocked on the durable serving path" framing hid an unimplemented
|
||||||
|
local seam. The join is now implemented and tested; the remaining blocker is
|
||||||
|
genuinely external (approval-engine claim endpoint and access-engine Check, both
|
||||||
|
unreachable as of 2026-09-06).
|
||||||
|
|
||||||
Blocked on the durable ActionAuthorization serving path owned with
|
Blocked on the durable ActionAuthorization serving path owned with
|
||||||
`SECRETS-WP-0007-T04` / State Hub / `access-engine`.
|
`SECRETS-WP-0007-T04` / State Hub / `access-engine`.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue