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
|
|
@ -18,6 +18,11 @@ from typing import Any, Callable
|
|||
from urllib.error import HTTPError, URLError
|
||||
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.openbao import read_strict_token_file
|
||||
from secrets_engine.pep_stance import demo_exception_enabled
|
||||
|
|
@ -59,18 +64,162 @@ class ConsumedApproval:
|
|||
return payload
|
||||
|
||||
|
||||
def resolve_consume_binding(
|
||||
_cfg: Any,
|
||||
_entry: Any,
|
||||
_action: str,
|
||||
_decision: Any,
|
||||
) -> ConsumeBinding | None:
|
||||
"""Return the served consume binding, or None if it is not available.
|
||||
def _authorization_id(entry: Any, decision: Any) -> str:
|
||||
"""Non-secret approval-engine object id for this lane, or "" if unbound.
|
||||
|
||||
The durable ActionAuthorization / approval serving path is still external
|
||||
(SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). Tests may replace this hook.
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ def _require_lane_approval(
|
|||
entry,
|
||||
action: str = "",
|
||||
evidence: PrivilegedActionEvidence | None = None,
|
||||
*,
|
||||
fields: tuple[str, ...] = (),
|
||||
):
|
||||
"""Apply published PEP stance, resolve lane approval, then CAS-consume.
|
||||
|
||||
|
|
@ -136,7 +138,9 @@ def _require_lane_approval(
|
|||
require_production_consume(
|
||||
cfg,
|
||||
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,
|
||||
)
|
||||
return decision
|
||||
|
|
@ -311,7 +315,9 @@ def cmd_provision(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
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)
|
||||
require_provision_state(cfg.evidence_dir, entry.id)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
|
|
@ -346,7 +352,9 @@ def cmd_rotate(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
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)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
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,
|
||||
},
|
||||
) 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)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
if entry.stores_kv_value() and not fields:
|
||||
|
|
@ -540,7 +550,9 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
},
|
||||
) as evidence:
|
||||
# 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)
|
||||
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
|
||||
if not args.command:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ def repo_root() -> Path:
|
|||
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)
|
||||
class Config:
|
||||
catalog_dir: Path
|
||||
|
|
@ -33,6 +42,13 @@ class Config:
|
|||
keycape_issuer: str = ""
|
||||
keycape_client_secret_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
|
||||
def load(cls) -> "Config":
|
||||
|
|
@ -55,4 +71,19 @@ class Config:
|
|||
keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""),
|
||||
keycape_client_secret_file=Path(keycape_secret) if keycape_secret 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", "")
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue