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
This commit is contained in:
parent
e8144f315c
commit
f62d3fe789
9 changed files with 675 additions and 44 deletions
|
|
@ -19,7 +19,12 @@ from urllib.error import HTTPError, URLError
|
|||
from urllib.request import Request, urlopen
|
||||
|
||||
from secrets_engine.approval_claim import validate_approval_claim
|
||||
from secrets_engine.authorization import build_action_request, request_digest
|
||||
from secrets_engine.decision_check import check_decision
|
||||
from secrets_engine.authorization import (
|
||||
build_action_request,
|
||||
request_digest,
|
||||
validate_decision_envelope,
|
||||
)
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.openbao import read_strict_token_file
|
||||
from secrets_engine.pep_stance import demo_exception_enabled
|
||||
|
|
@ -37,6 +42,26 @@ class ConsumeBinding:
|
|||
decision_id: str = ""
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsumedApproval:
|
||||
"""Non-secret confirmation that consume succeeded for this request."""
|
||||
|
|
@ -94,6 +119,44 @@ def _request_purpose(entry: Any) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def fetch_approval_claim(
|
||||
*,
|
||||
base_url: str,
|
||||
|
|
@ -147,18 +210,12 @@ def resolve_consume_binding(
|
|||
auth_targets: tuple[str, ...] = (),
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> ConsumeBinding | None:
|
||||
"""Join the proposed action to a served approval-claim (step 1 of GH-DEC-2026-003).
|
||||
"""Join the proposed action to a served approval-claim (step 1).
|
||||
|
||||
Returns None only when no serving path is configured at all, keeping
|
||||
production fail-closed exactly as it was before the join existed. Anything
|
||||
configured-but-wrong raises: a half-configured PEP must not look like an
|
||||
unconfigured one.
|
||||
|
||||
Step 2 (the flex-auth DecisionEnvelope from POST /v1/check) is validated by
|
||||
``authorization.validate_decision_envelope``. No PDP is reachable for this
|
||||
consumer yet -- flex-auth runs per-consumer cluster-local pins and
|
||||
``flex-auth-secrets-engine`` has not been created -- so that call is not
|
||||
wired here and production stays closed at the stance gate regardless.
|
||||
"""
|
||||
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||
token_file = getattr(cfg, "approval_token_file", None)
|
||||
|
|
@ -166,29 +223,11 @@ def resolve_consume_binding(
|
|||
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"
|
||||
)
|
||||
purpose = _request_purpose(entry)
|
||||
if not purpose:
|
||||
raise DecisionError(
|
||||
"authorization join requires a declared approval/consumer purpose"
|
||||
)
|
||||
|
||||
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,
|
||||
expected_request = _expected_request(
|
||||
cfg, entry, action,
|
||||
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
|
||||
)
|
||||
|
||||
# Two different digests over the same proposed action, by contract; they are
|
||||
# never compared to each other.
|
||||
#
|
||||
|
|
@ -215,9 +254,11 @@ def resolve_consume_binding(
|
|||
approval_id=authorization_id,
|
||||
expected_pdp_digest=pdp_digest,
|
||||
)
|
||||
binding = claim.get("binding") or {}
|
||||
if isinstance(binding, dict) and binding.get("action") not in (None, action):
|
||||
raise DecisionError("approval claim does not bind this action")
|
||||
# No action comparison here. The claim's binding.action is approval-engine
|
||||
# vocabulary ("secrets.kv.destroy") and ours is the catalog's ("destroy");
|
||||
# comparing them would fail against every real claim, which is the same
|
||||
# cross-vocabulary mistake the native digest made. The tie to this exact
|
||||
# action is pdp_digest, checked above.
|
||||
return ConsumeBinding(
|
||||
approval_id=authorization_id,
|
||||
request_digest=pdp_digest,
|
||||
|
|
@ -225,6 +266,80 @@ def resolve_consume_binding(
|
|||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
binding = resolve_consume_binding(
|
||||
cfg, entry, action, decision,
|
||||
fields=fields,
|
||||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
opener=opener,
|
||||
)
|
||||
if binding is None:
|
||||
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"
|
||||
)
|
||||
|
||||
expected_request = _expected_request(
|
||||
cfg, entry, action,
|
||||
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
|
||||
)
|
||||
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},
|
||||
)
|
||||
if validated.action != action:
|
||||
raise DecisionError("access-engine decision does not bind this action")
|
||||
return AuthorizedAction(
|
||||
binding=ConsumeBinding(
|
||||
approval_id=binding.approval_id,
|
||||
request_digest=binding.request_digest,
|
||||
decision_id=validated.decision_id,
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
)
|
||||
|
||||
|
||||
def consume_approval(
|
||||
*,
|
||||
base_url: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue