2026-09-02 01:06:50 +02:00
|
|
|
"""PEP consume-before-side-effect client (GH-DEC-2026-003).
|
|
|
|
|
|
|
|
|
|
The PEP that is about to cause a protected OpenBao write MUST obtain a
|
|
|
|
|
successful approval-engine CAS consume first. Holding a claim or an ALLOW is
|
|
|
|
|
not authority to act. Conflict, unavailability, or a missing binding means
|
|
|
|
|
do not call OpenBao.
|
|
|
|
|
|
|
|
|
|
This module does not render an authorization decision. The consume response
|
|
|
|
|
is mutation evidence, never a permission.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import re
|
2026-09-09 08:55:46 +02:00
|
|
|
from dataclasses import dataclass, field
|
2026-09-02 01:06:50 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any, Callable
|
|
|
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
2026-09-09 07:06:05 +02:00
|
|
|
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
2026-09-09 08:55:46 +02:00
|
|
|
from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
from secrets_engine.decision_check import check_decision
|
|
|
|
|
from secrets_engine.authorization import (
|
|
|
|
|
build_action_request,
|
|
|
|
|
validate_decision_envelope,
|
|
|
|
|
)
|
2026-09-02 01:06:50 +02:00
|
|
|
from secrets_engine.errors import DecisionError
|
|
|
|
|
from secrets_engine.openbao import read_strict_token_file
|
|
|
|
|
from secrets_engine.pep_stance import demo_exception_enabled
|
|
|
|
|
|
|
|
|
|
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
|
|
|
_MAX_BODY = 256 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ConsumeBinding:
|
|
|
|
|
"""Inputs the PEP presents to approval-engine consume."""
|
|
|
|
|
|
|
|
|
|
approval_id: str
|
|
|
|
|
request_digest: str
|
|
|
|
|
decision_id: str = ""
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 08:55:46 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ApprovalObservation:
|
|
|
|
|
"""Fresh approval input awaiting PDP correspondence; never a consume binding."""
|
|
|
|
|
|
|
|
|
|
approval_id: str
|
|
|
|
|
request: dict[str, Any] = field(repr=False)
|
|
|
|
|
claim: dict[str, Any] = field(repr=False)
|
|
|
|
|
|
|
|
|
|
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AuthorizedAction:
|
|
|
|
|
"""A validated claim and decision for one exact proposed action.
|
|
|
|
|
|
|
|
|
|
Holding this is not authority to act: GH-DEC-2026-003 still requires a
|
|
|
|
|
successful CAS consume before the OpenBao call.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
binding: ConsumeBinding
|
|
|
|
|
decision_id: str
|
|
|
|
|
expires_at: str
|
|
|
|
|
|
|
|
|
|
def as_evidence(self) -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"authorization_decision_id": self.decision_id,
|
|
|
|
|
"authorization_expires_at": self.expires_at,
|
|
|
|
|
"request_digest": self.binding.request_digest,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 01:06:50 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ConsumedApproval:
|
|
|
|
|
"""Non-secret confirmation that consume succeeded for this request."""
|
|
|
|
|
|
|
|
|
|
approval_id: str
|
|
|
|
|
request_digest: str
|
|
|
|
|
decision_id: str = ""
|
|
|
|
|
idempotent: bool = False
|
|
|
|
|
consumed_at: str = ""
|
|
|
|
|
|
|
|
|
|
def as_evidence(self) -> dict[str, object]:
|
|
|
|
|
payload: dict[str, object] = {
|
|
|
|
|
"approval_consumed": True,
|
|
|
|
|
"approval_id": self.approval_id,
|
|
|
|
|
"request_digest": self.request_digest,
|
|
|
|
|
"approval_consume_idempotent": self.idempotent,
|
|
|
|
|
}
|
|
|
|
|
if self.decision_id:
|
|
|
|
|
payload["decision_id"] = self.decision_id
|
|
|
|
|
if self.consumed_at:
|
|
|
|
|
payload["approval_consumed_at"] = self.consumed_at
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
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
2026-09-06 01:01:55 +02:00
|
|
|
def _authorization_id(entry: Any, decision: Any) -> str:
|
|
|
|
|
"""Non-secret approval-engine object id for this lane, or "" if unbound.
|
|
|
|
|
|
|
|
|
|
flex-auth: ``ActionAuthorization.id`` is the approval-engine object UUID.
|
|
|
|
|
It is never the State Hub decision UUID, so it is not inferred from one.
|
|
|
|
|
"""
|
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
def _expected_request(
|
|
|
|
|
cfg: Any,
|
|
|
|
|
entry: Any,
|
|
|
|
|
action: str,
|
|
|
|
|
*,
|
|
|
|
|
fields: tuple[str, ...] = (),
|
|
|
|
|
policy_targets: tuple[str, ...] = (),
|
|
|
|
|
auth_targets: tuple[str, ...] = (),
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Build the exact CheckRequest both steps must agree on.
|
|
|
|
|
|
|
|
|
|
Steps 1 and 2 must describe the same proposed action or the digests cannot
|
|
|
|
|
correspond, so neither builds its own.
|
|
|
|
|
"""
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
purpose = _request_purpose(entry)
|
|
|
|
|
if not purpose:
|
|
|
|
|
raise DecisionError(
|
|
|
|
|
"authorization join requires a declared approval/consumer purpose"
|
|
|
|
|
)
|
|
|
|
|
return 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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
def fetch_approval_claim(
|
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
2026-09-06 01:01:55 +02:00
|
|
|
*,
|
|
|
|
|
base_url: str,
|
2026-09-09 07:06:05 +02:00
|
|
|
token_file: Path | None = None,
|
|
|
|
|
token_provider: Callable[[], str] | None = None,
|
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
2026-09-06 01:01:55 +02:00
|
|
|
authorization_id: str,
|
|
|
|
|
timeout_seconds: float = 3,
|
2026-09-09 07:06:05 +02:00
|
|
|
opener: Callable[..., Any] = credential_urlopen,
|
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
2026-09-06 01:01:55 +02:00
|
|
|
) -> dict[str, Any]:
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed.
|
|
|
|
|
|
|
|
|
|
The body is approval-engine's approval-claim, not a flex-auth
|
|
|
|
|
ActionAuthorization -- that object is deferred and was never ratified
|
|
|
|
|
(GH-DEC-2026-005 / FLEX-DEC-2026-006).
|
|
|
|
|
"""
|
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
2026-09-06 01:01:55 +02:00
|
|
|
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")
|
2026-09-09 07:06:05 +02:00
|
|
|
token = _request_token(token_file, token_provider)
|
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
2026-09-06 01:01:55 +02:00
|
|
|
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:
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
raise DecisionError("approval claim did not return a claim")
|
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
2026-09-06 01:01:55 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 08:55:46 +02:00
|
|
|
def resolve_approval_observation(
|
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
2026-09-06 01:01:55 +02:00
|
|
|
cfg: Any,
|
|
|
|
|
entry: Any,
|
|
|
|
|
action: str,
|
|
|
|
|
decision: Any,
|
|
|
|
|
*,
|
|
|
|
|
fields: tuple[str, ...] = (),
|
|
|
|
|
policy_targets: tuple[str, ...] = (),
|
|
|
|
|
auth_targets: tuple[str, ...] = (),
|
|
|
|
|
opener: Callable[..., Any] | None = None,
|
2026-09-09 08:55:46 +02:00
|
|
|
) -> ApprovalObservation | None:
|
|
|
|
|
"""Observe a fresh fact and prepare the claim-bearing request (step 1).
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
|
2026-09-09 08:55:46 +02:00
|
|
|
Missing serving coordinates return None, preserving the production refusal.
|
|
|
|
|
Correspondence and a consume binding require authorize_action's PDP step.
|
2026-09-02 01:06:50 +02:00
|
|
|
"""
|
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
2026-09-06 01:01:55 +02:00
|
|
|
base_url = str(getattr(cfg, "approval_url", "") or "")
|
2026-09-09 07:06:05 +02:00
|
|
|
auth_configured = approval_auth_configured(cfg)
|
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
2026-09-06 01:01:55 +02:00
|
|
|
authorization_id = _authorization_id(entry, decision)
|
2026-09-09 07:06:05 +02:00
|
|
|
if not base_url or not auth_configured or not authorization_id:
|
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
2026-09-06 01:01:55 +02:00
|
|
|
return None
|
|
|
|
|
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
expected_request = _expected_request(
|
|
|
|
|
cfg, entry, action,
|
|
|
|
|
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
|
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
2026-09-06 01:01:55 +02:00
|
|
|
)
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
claim = fetch_approval_claim(
|
|
|
|
|
base_url=base_url,
|
2026-09-09 07:06:05 +02:00
|
|
|
token_provider=lambda: approval_token(cfg, scope="approval:read"),
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
authorization_id=authorization_id,
|
2026-09-09 07:06:05 +02:00
|
|
|
opener=opener or credential_urlopen,
|
feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 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
2026-09-06 08:02:01 +02:00
|
|
|
)
|
2026-09-09 08:55:46 +02:00
|
|
|
observe_pdp_approval_claim(claim, approval_id=authorization_id)
|
|
|
|
|
expected_request["context"]["approval"] = claim
|
|
|
|
|
return ApprovalObservation(authorization_id, expected_request, claim)
|
2026-09-02 01:06:50 +02:00
|
|
|
|
|
|
|
|
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
def authorize_action(
|
|
|
|
|
cfg: Any,
|
|
|
|
|
entry: Any,
|
|
|
|
|
action: str,
|
|
|
|
|
decision: Any = None,
|
|
|
|
|
*,
|
|
|
|
|
fields: tuple[str, ...] = (),
|
|
|
|
|
policy_targets: tuple[str, ...] = (),
|
|
|
|
|
auth_targets: tuple[str, ...] = (),
|
|
|
|
|
opener: Callable[..., Any] | None = None,
|
|
|
|
|
pdp_opener: Callable[..., Any] | None = None,
|
|
|
|
|
) -> AuthorizedAction | None:
|
|
|
|
|
"""Run steps 1 and 2 for one proposed action, or return None if unserved.
|
|
|
|
|
|
|
|
|
|
Step 1 validates the approval-claim; step 2 obtains and validates the
|
|
|
|
|
flex-auth DecisionEnvelope. Returning None means no serving path is
|
|
|
|
|
configured at all, which leaves production fail-closed. A configured but
|
|
|
|
|
failing path raises: a partial deployment must not read as an absent one.
|
|
|
|
|
"""
|
2026-09-09 08:55:46 +02:00
|
|
|
observation = resolve_approval_observation(
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
cfg, entry, action, decision,
|
|
|
|
|
fields=fields,
|
|
|
|
|
policy_targets=policy_targets,
|
|
|
|
|
auth_targets=auth_targets,
|
|
|
|
|
opener=opener,
|
|
|
|
|
)
|
2026-09-09 08:55:46 +02:00
|
|
|
if observation is None:
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
pdp_url = str(getattr(cfg, "pdp_url", "") or "")
|
|
|
|
|
pdp_token = getattr(cfg, "pdp_token_file", None)
|
|
|
|
|
if not pdp_url or not pdp_token:
|
|
|
|
|
raise DecisionError(
|
|
|
|
|
"production action requires an access-engine decision; "
|
|
|
|
|
"SECRETS_ENGINE_PDP_URL / _PDP_TOKEN_FILE are unset"
|
|
|
|
|
)
|
|
|
|
|
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 reserved coordinate is a reservation, "
|
|
|
|
|
"not a publication, and must not be used as a default"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-09 08:55:46 +02:00
|
|
|
expected_request = observation.request
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
envelope = check_decision(
|
|
|
|
|
base_url=pdp_url,
|
|
|
|
|
token_file=Path(pdp_token),
|
|
|
|
|
request=expected_request,
|
|
|
|
|
opener=pdp_opener or urlopen,
|
|
|
|
|
)
|
|
|
|
|
validated = validate_decision_envelope(
|
|
|
|
|
envelope,
|
|
|
|
|
expected_request,
|
|
|
|
|
accepted_policy_packages={package},
|
|
|
|
|
accepted_policy_versions={version},
|
2026-09-09 08:55:46 +02:00
|
|
|
expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"],
|
|
|
|
|
)
|
|
|
|
|
# Recheck freshness after the network call before issuing a consume binding.
|
|
|
|
|
validate_approval_claim(
|
|
|
|
|
observation.claim, approval_id=observation.approval_id,
|
|
|
|
|
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
)
|
|
|
|
|
if validated.action != action:
|
|
|
|
|
raise DecisionError("access-engine decision does not bind this action")
|
|
|
|
|
return AuthorizedAction(
|
|
|
|
|
binding=ConsumeBinding(
|
2026-09-09 08:55:46 +02:00
|
|
|
approval_id=observation.approval_id,
|
|
|
|
|
request_digest=validated.request_digest,
|
feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.
- decision_check.check_decision performs the PDP call; an unreachable or
non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
AuthorizedAction. Both steps build the same CheckRequest via a shared
_expected_request, since two descriptions of the action cannot produce
corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
defines fail_closed as no side effect WITHOUT a durable decision record,
so holding a validated one means the residue does not apply. Not a bypass:
both steps must have succeeded and CAS consume still precedes OpenBao.
Unconfigured still returns None and fails closed.
The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.
Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.
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
2026-09-06 14:56:02 +02:00
|
|
|
decision_id=validated.decision_id,
|
|
|
|
|
),
|
|
|
|
|
decision_id=validated.decision_id,
|
|
|
|
|
expires_at=validated.expires_at,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 01:06:50 +02:00
|
|
|
def consume_approval(
|
|
|
|
|
*,
|
|
|
|
|
base_url: str,
|
2026-09-09 07:06:05 +02:00
|
|
|
token_file: Path | None = None,
|
|
|
|
|
token_provider: Callable[[], str] | None = None,
|
2026-09-02 01:06:50 +02:00
|
|
|
binding: ConsumeBinding,
|
|
|
|
|
timeout_seconds: float = 3,
|
2026-09-09 07:06:05 +02:00
|
|
|
opener: Callable[..., Any] = credential_urlopen,
|
2026-09-02 01:06:50 +02:00
|
|
|
) -> ConsumedApproval:
|
|
|
|
|
"""POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use."""
|
|
|
|
|
if not base_url or not base_url.startswith(("http://", "https://")):
|
|
|
|
|
raise DecisionError("approval-engine consume URL is missing or invalid")
|
|
|
|
|
approval_id = binding.approval_id.strip()
|
|
|
|
|
if not approval_id or "/" in approval_id or any(ch.isspace() for ch in approval_id):
|
|
|
|
|
raise DecisionError("approval consume requires a concrete approval id")
|
|
|
|
|
if not DIGEST_RE.fullmatch(binding.request_digest):
|
|
|
|
|
raise DecisionError("approval consume requires the canonical request digest")
|
|
|
|
|
|
2026-09-09 07:06:05 +02:00
|
|
|
token = _request_token(token_file, token_provider)
|
2026-09-02 01:06:50 +02:00
|
|
|
body: dict[str, str] = {"request_digest": binding.request_digest}
|
|
|
|
|
if binding.decision_id:
|
|
|
|
|
body["decision_id"] = binding.decision_id
|
|
|
|
|
encoded = json.dumps(body).encode("utf-8")
|
|
|
|
|
request = Request(
|
|
|
|
|
base_url.rstrip("/") + f"/v1/approvals/{approval_id}/consume",
|
|
|
|
|
data=encoded,
|
|
|
|
|
method="POST",
|
|
|
|
|
headers={
|
|
|
|
|
"Authorization": f"Bearer {token}",
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = opener(request, timeout=timeout_seconds)
|
|
|
|
|
try:
|
|
|
|
|
status = int(response.getcode())
|
|
|
|
|
raw = response.read(_MAX_BODY + 1)
|
|
|
|
|
finally:
|
|
|
|
|
response.close()
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
status = int(getattr(exc, "code", 0) or 0)
|
|
|
|
|
try:
|
|
|
|
|
exc.read(_MAX_BODY)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
raise DecisionError(_status_message(status)) from None
|
|
|
|
|
except (URLError, TimeoutError, OSError):
|
|
|
|
|
raise DecisionError(
|
|
|
|
|
"approval-engine unreachable; OpenBao must not be called"
|
|
|
|
|
) from None
|
|
|
|
|
|
|
|
|
|
if status != 200 or len(raw) > _MAX_BODY:
|
|
|
|
|
raise DecisionError(_status_message(status if status != 200 else 502))
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise DecisionError("approval consume returned invalid JSON") from exc
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
raise DecisionError("approval consume returned invalid payload")
|
|
|
|
|
if payload.get("status") != "consumed":
|
|
|
|
|
raise DecisionError("approval consumption was not confirmed")
|
|
|
|
|
if payload.get("request_digest") != binding.request_digest:
|
|
|
|
|
raise DecisionError("approval consume digest does not match the request")
|
|
|
|
|
decision_id = payload.get("decision_id")
|
|
|
|
|
if decision_id is not None and (
|
|
|
|
|
not isinstance(decision_id, str) or not decision_id
|
|
|
|
|
):
|
|
|
|
|
raise DecisionError("approval consume returned an invalid decision id")
|
|
|
|
|
consumed_at = payload.get("consumed_at")
|
|
|
|
|
if consumed_at is not None and not isinstance(consumed_at, str):
|
|
|
|
|
raise DecisionError("approval consume returned an invalid consumed_at")
|
|
|
|
|
return ConsumedApproval(
|
|
|
|
|
approval_id=str(payload.get("approval_id") or approval_id),
|
|
|
|
|
request_digest=binding.request_digest,
|
|
|
|
|
decision_id=decision_id or binding.decision_id,
|
|
|
|
|
idempotent=bool(payload.get("idempotent")),
|
|
|
|
|
consumed_at=consumed_at or "",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_production_consume(
|
|
|
|
|
cfg: Any,
|
|
|
|
|
entry: Any,
|
|
|
|
|
*,
|
|
|
|
|
binding: ConsumeBinding | None,
|
|
|
|
|
evidence: Any = None,
|
|
|
|
|
opener: Callable[..., Any] | None = None,
|
|
|
|
|
) -> ConsumedApproval | None:
|
|
|
|
|
"""CAS-consume before a production OpenBao call. No-op off the prod path.
|
|
|
|
|
|
|
|
|
|
Build/test remain fail-open relative to approval-engine. The three-factor
|
|
|
|
|
unsafe-demo exception is not a consume path. Missing binding, URL, or
|
|
|
|
|
credential fail closed so a stance bypass cannot reach OpenBao.
|
|
|
|
|
"""
|
|
|
|
|
if getattr(entry, "stage", "") != "prod":
|
|
|
|
|
return None
|
|
|
|
|
if demo_exception_enabled(cfg):
|
|
|
|
|
return None
|
|
|
|
|
if binding is None:
|
|
|
|
|
raise DecisionError(
|
|
|
|
|
"production OpenBao call requires CAS consume of an approval "
|
|
|
|
|
"after an access-engine ALLOW; no durable consume binding is served"
|
|
|
|
|
)
|
|
|
|
|
base_url = str(getattr(cfg, "approval_url", "") or "")
|
2026-09-09 07:06:05 +02:00
|
|
|
auth_configured = approval_auth_configured(cfg)
|
2026-09-02 01:06:50 +02:00
|
|
|
if not base_url:
|
|
|
|
|
raise DecisionError(
|
|
|
|
|
"production OpenBao call requires approval-engine consume; "
|
|
|
|
|
"SECRETS_ENGINE_APPROVAL_URL is unset"
|
|
|
|
|
)
|
2026-09-09 07:06:05 +02:00
|
|
|
if not auth_configured:
|
2026-09-02 01:06:50 +02:00
|
|
|
raise DecisionError(
|
|
|
|
|
"production OpenBao call requires approval-engine consume; "
|
2026-09-09 07:06:05 +02:00
|
|
|
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset"
|
2026-09-02 01:06:50 +02:00
|
|
|
)
|
|
|
|
|
consumed = consume_approval(
|
|
|
|
|
base_url=base_url,
|
2026-09-09 07:06:05 +02:00
|
|
|
token_provider=lambda: approval_token(cfg, scope="approval:consume"),
|
2026-09-02 01:06:50 +02:00
|
|
|
binding=binding,
|
2026-09-09 07:06:05 +02:00
|
|
|
opener=opener or credential_urlopen,
|
2026-09-02 01:06:50 +02:00
|
|
|
)
|
|
|
|
|
if evidence is not None and hasattr(evidence, "mark_consumed"):
|
|
|
|
|
evidence.mark_consumed(consumed)
|
|
|
|
|
return consumed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _status_message(status: int) -> str:
|
|
|
|
|
if status == 409:
|
|
|
|
|
return "approval consume conflict; OpenBao must not be called"
|
|
|
|
|
if status == 404:
|
|
|
|
|
return "approval not found; OpenBao must not be called"
|
|
|
|
|
if status in {401, 403}:
|
|
|
|
|
return "approval consume unauthorized; OpenBao must not be called"
|
|
|
|
|
if status == 503:
|
|
|
|
|
return "approval-engine unavailable; OpenBao must not be called"
|
|
|
|
|
if status == 0:
|
|
|
|
|
return "approval-engine unreachable; OpenBao must not be called"
|
|
|
|
|
return "approval consume failed; OpenBao must not be called"
|
2026-09-09 07:06:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|