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
This commit is contained in:
parent
dbd3694f71
commit
7b4b9e386e
8 changed files with 694 additions and 325 deletions
|
|
@ -19,19 +19,43 @@ does not redefine it.
|
||||||
4. PEP OpenBao call → only after consume succeeds
|
4. PEP OpenBao call → only after consume succeeds
|
||||||
```
|
```
|
||||||
|
|
||||||
Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the
|
**Each artifact is validated by the layer that owns its data**
|
||||||
exact CheckRequest with `build_action_request`, fetches the durable
|
(`GH-DEC-2026-005`). There is no single bundled object:
|
||||||
`ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with
|
|
||||||
`validate_action_authorization` before returning a consume binding. Until
|
| Step | Artifact | Owner | Validated by |
|
||||||
2026-09-06 that function was a `return None` stub and the validator was
|
| --- | --- | --- | --- |
|
||||||
unreachable from `src/`; the join now exists.
|
| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` |
|
||||||
|
| 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` |
|
||||||
|
|
||||||
|
The claim carries the *approval fact*: issuer, `valid_now`, consumption state,
|
||||||
|
binding digest, freshness, `reason_code`. The envelope carries the *decision*:
|
||||||
|
effect, exact CheckRequest binding, canonical request digest, lifetime, and the
|
||||||
|
policy package/version pin. Neither republishes the other's data.
|
||||||
|
|
||||||
|
`ActionAuthorization` is **deferred and was never ratified**
|
||||||
|
(`FLEX-DEC-2026-006`). It cannot be served from a step-1 call, and nothing here
|
||||||
|
validates it. A ratified post-decision form remains a deferred option.
|
||||||
|
|
||||||
|
There are **two different digests** over the same proposed action, and they are
|
||||||
|
never compared to each other:
|
||||||
|
|
||||||
|
- `claim.binding.digest` — approval-engine native, `sha256` over canonical JSON
|
||||||
|
of `{action, actor, principal, purpose, target}`.
|
||||||
|
- `decision.binding.request_digest` — flex-auth canonical CheckRequest digest.
|
||||||
|
|
||||||
|
When the issuer recorded `claim.binding.pdp_digest`, that comparison is
|
||||||
|
preferred. The CheckRequest mapping onto the claim binding is published in
|
||||||
|
`approval-engine/docs/approval-claim.md`.
|
||||||
|
|
||||||
The approval-engine object id is never inferred from a State Hub decision UUID.
|
The approval-engine object id is never inferred from a State Hub decision UUID.
|
||||||
It comes from catalog `approval.authorization_id` or a served
|
It comes from catalog `approval.authorization_id`, and is the value in the
|
||||||
`decision.authorization_id`. The Check request `id` is an opaque correlator the
|
`{id}` path segment echoed back as `approval_id`.
|
||||||
PEP cannot regenerate, so the served one is adopted; every security-relevant
|
|
||||||
field is still compared exactly and the binding digest is recomputed against the
|
**There is no authority constant.** The engine previously required
|
||||||
served request.
|
`provenance.authority == "state-hub"`, which contradicted its own source: State
|
||||||
|
Hub is a read model and holds no runtime approval authority, so that check
|
||||||
|
failed closed against every correctly issued record. The approval fact's
|
||||||
|
authority is approval-engine (checked as `issuer`); the decision's is flex-auth.
|
||||||
|
|
||||||
Every live privileged production handler passes `_require_lane_approval`,
|
Every live privileged production handler passes `_require_lane_approval`,
|
||||||
which calls `require_production_consume` before `OpenBaoClient.resolve`.
|
which calls `require_production_consume` before `OpenBaoClient.resolve`.
|
||||||
|
|
@ -67,13 +91,23 @@ did before. Production additionally needs:
|
||||||
| `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) |
|
| `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) |
|
||||||
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git |
|
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git |
|
||||||
| `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
|
| `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
|
||||||
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin |
|
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin (step 2) |
|
||||||
| `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold |
|
|
||||||
|
|
||||||
There is deliberately no default policy pin. flex-auth stated that the published
|
The distinct-approver threshold is no longer a consumer-side check. The claim
|
||||||
`secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope,
|
does not expose approver entries; approval-engine folds that requirement into
|
||||||
not a live package, so treating them as a default would pin production to a
|
`valid_now`, which is true only when enough distinct authenticated approvers
|
||||||
package nobody publishes.
|
have been recorded and the object is not consumed, superseded, revoked, or
|
||||||
|
expired.
|
||||||
|
|
||||||
|
There is deliberately no default policy pin. The reserved coordinate is
|
||||||
|
`secrets-engine.catalog-lane.lifecycle` / `v1`, but that is a *reservation, not
|
||||||
|
a publication* (`FLEX-DEC-2026-005`) and must not be configured until
|
||||||
|
`FLEX-WP-0021-T02` publishes the package. `docs/gated-actions.md` supplies the
|
||||||
|
action vocabulary that package is built from.
|
||||||
|
|
||||||
|
Step 2 additionally needs the `flex-auth-secrets-engine` Service DNS. No
|
||||||
|
estate-wide PDP exists by design — flex-auth runs per-consumer cluster-local
|
||||||
|
pins — and that pin has not been created, so the PDP call is not wired yet.
|
||||||
|
|
||||||
## What this does not do
|
## What this does not do
|
||||||
|
|
||||||
|
|
|
||||||
172
src/secrets_engine/approval_claim.py
Normal file
172
src/secrets_engine/approval_claim.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""approval-engine approval-claim consumer (GH-DEC-2026-005, step 1).
|
||||||
|
|
||||||
|
The claim is a *fact about an approval object*, never a decision. It carries no
|
||||||
|
`effect`, `allow`, or `deny`, and holding one with ``valid_now: true`` is not
|
||||||
|
authority to act -- it is one input the decision point weighs.
|
||||||
|
|
||||||
|
Contract: ``approval-engine/docs/approval-claim.md`` (schema 0.1). The native
|
||||||
|
binding digest here is NOT the flex-auth CheckRequest digest: it is taken over
|
||||||
|
``{action, actor, principal, purpose, target}``. The two are deliberately
|
||||||
|
different functions and must not be compared to each other.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "0.1"
|
||||||
|
KIND = "approval-claim"
|
||||||
|
ISSUER = "approval-engine"
|
||||||
|
VALID_REASON = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def claim_binding_digest(
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor: str,
|
||||||
|
principal: str,
|
||||||
|
purpose: str,
|
||||||
|
target: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
"""sha256 over canonical JSON of the approval-engine binding.
|
||||||
|
|
||||||
|
Sorted keys at every level, no insignificant whitespace. Wrong action,
|
||||||
|
target, or scope changes this JSON and therefore the digest, which is what
|
||||||
|
stops a decision rendered for request R being replayed for request R'.
|
||||||
|
"""
|
||||||
|
canonical = json.dumps(
|
||||||
|
{
|
||||||
|
"action": action,
|
||||||
|
"actor": actor,
|
||||||
|
"principal": principal,
|
||||||
|
"purpose": purpose,
|
||||||
|
"target": target,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def binding_from_check_request(request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map a flex-auth CheckRequest onto the claim binding fields.
|
||||||
|
|
||||||
|
Mapping is published in ``approval-claim.md``: target is the resource
|
||||||
|
object, actor is ``subject.id``, principal is ``subject.attributes.principal``
|
||||||
|
when present and ``subject.id`` otherwise, purpose is ``context.purpose``.
|
||||||
|
"""
|
||||||
|
subject = request.get("subject") or {}
|
||||||
|
if not isinstance(subject, dict):
|
||||||
|
raise DecisionError("check request subject must be an object")
|
||||||
|
attributes = subject.get("attributes") or {}
|
||||||
|
principal = ""
|
||||||
|
if isinstance(attributes, dict):
|
||||||
|
principal = str(attributes.get("principal", "") or "")
|
||||||
|
actor = str(subject.get("id", "") or "")
|
||||||
|
context = request.get("context") or {}
|
||||||
|
purpose = ""
|
||||||
|
if isinstance(context, dict):
|
||||||
|
purpose = str(context.get("purpose", "") or "")
|
||||||
|
resource = request.get("resource")
|
||||||
|
if not isinstance(resource, dict):
|
||||||
|
raise DecisionError("check request resource must be an object")
|
||||||
|
return {
|
||||||
|
"action": str(request.get("action", "") or ""),
|
||||||
|
"actor": actor,
|
||||||
|
"principal": principal or actor,
|
||||||
|
"purpose": purpose,
|
||||||
|
"target": resource,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: object, name: str) -> datetime:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise DecisionError(f"approval claim {name} must be a timestamp")
|
||||||
|
text = value.strip().replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError as e:
|
||||||
|
raise DecisionError(f"approval claim {name} is not a valid timestamp") from e
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise DecisionError(f"approval claim {name} must carry a timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_approval_claim(
|
||||||
|
claim: object,
|
||||||
|
*,
|
||||||
|
approval_id: str,
|
||||||
|
expected_binding_digest: str = "",
|
||||||
|
expected_pdp_digest: str = "",
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run the published consumer checks. Any failure means do not act.
|
||||||
|
|
||||||
|
Implements ``approval-claim.md`` "Required verification": issuer, valid_now,
|
||||||
|
not consumed, binding digest match (native or PDP), freshness, reason_code.
|
||||||
|
The distinct-approver threshold is folded into ``valid_now`` by the issuer;
|
||||||
|
the claim does not expose approver entries, so it cannot be re-checked here.
|
||||||
|
"""
|
||||||
|
if not isinstance(claim, dict):
|
||||||
|
raise DecisionError("approval claim must be an object")
|
||||||
|
if claim.get("schema_version") != SCHEMA_VERSION:
|
||||||
|
raise DecisionError("unsupported approval claim schema version")
|
||||||
|
if claim.get("kind") != KIND:
|
||||||
|
raise DecisionError("approval claim kind is not approval-claim")
|
||||||
|
if claim.get("issuer") != ISSUER:
|
||||||
|
raise DecisionError("approval claim issuer is not approval-engine")
|
||||||
|
for forbidden in ("effect", "decision", "allow", "deny"):
|
||||||
|
if forbidden in claim:
|
||||||
|
raise DecisionError(
|
||||||
|
f"approval claim carries '{forbidden}'; a claim is not a decision"
|
||||||
|
)
|
||||||
|
|
||||||
|
served_id = str(claim.get("approval_id", "") or "")
|
||||||
|
if not served_id or served_id != approval_id:
|
||||||
|
raise DecisionError("approval claim is for a different approval object")
|
||||||
|
if claim.get("valid_now") is not True:
|
||||||
|
raise DecisionError(
|
||||||
|
"approval claim is not valid now "
|
||||||
|
f"(reason_code={claim.get('reason_code', 'unknown')})"
|
||||||
|
)
|
||||||
|
if claim.get("consumed") is not False:
|
||||||
|
raise DecisionError("approval claim is already consumed")
|
||||||
|
if claim.get("reason_code") != VALID_REASON:
|
||||||
|
raise DecisionError("approval claim reason code is not ok")
|
||||||
|
|
||||||
|
binding = claim.get("binding")
|
||||||
|
if not isinstance(binding, dict):
|
||||||
|
raise DecisionError("approval claim binding must be an object")
|
||||||
|
native = str(binding.get("digest", "") or "")
|
||||||
|
pdp = str(binding.get("pdp_digest", "") or "")
|
||||||
|
# Prefer the PDP digest when the issuer recorded one at issue time.
|
||||||
|
if expected_pdp_digest and pdp:
|
||||||
|
if pdp != expected_pdp_digest:
|
||||||
|
raise DecisionError("approval claim pdp digest does not match the request")
|
||||||
|
elif expected_binding_digest:
|
||||||
|
if native != expected_binding_digest:
|
||||||
|
raise DecisionError("approval claim binding digest does not match the request")
|
||||||
|
else:
|
||||||
|
raise DecisionError("approval claim comparison requires an expected digest")
|
||||||
|
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
freshness = claim.get("freshness")
|
||||||
|
if not isinstance(freshness, dict):
|
||||||
|
raise DecisionError("approval claim freshness must be an object")
|
||||||
|
if _parse_time(freshness.get("not_after"), "freshness.not_after") <= current:
|
||||||
|
raise DecisionError("approval claim observation is stale; re-fetch")
|
||||||
|
|
||||||
|
validity = claim.get("validity")
|
||||||
|
if not isinstance(validity, dict):
|
||||||
|
raise DecisionError("approval claim validity must be an object")
|
||||||
|
if _parse_time(validity.get("expires_at"), "validity.expires_at") <= current:
|
||||||
|
raise DecisionError("approval claim validity window has expired")
|
||||||
|
if validity.get("not_before") is not None:
|
||||||
|
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
|
||||||
|
raise DecisionError("approval claim is not yet valid")
|
||||||
|
return claim
|
||||||
|
|
@ -18,11 +18,12 @@ from typing import Any, Callable
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from secrets_engine.authorization import (
|
from secrets_engine.approval_claim import (
|
||||||
build_action_request,
|
binding_from_check_request,
|
||||||
request_digest,
|
claim_binding_digest,
|
||||||
validate_action_authorization,
|
validate_approval_claim,
|
||||||
)
|
)
|
||||||
|
from secrets_engine.authorization import build_action_request, request_digest
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
from secrets_engine.openbao import read_strict_token_file
|
from secrets_engine.openbao import read_strict_token_file
|
||||||
from secrets_engine.pep_stance import demo_exception_enabled
|
from secrets_engine.pep_stance import demo_exception_enabled
|
||||||
|
|
@ -97,7 +98,7 @@ def _request_purpose(entry: Any) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def fetch_action_authorization(
|
def fetch_approval_claim(
|
||||||
*,
|
*,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
token_file: Path,
|
token_file: Path,
|
||||||
|
|
@ -105,7 +106,12 @@ def fetch_action_authorization(
|
||||||
timeout_seconds: float = 3,
|
timeout_seconds: float = 3,
|
||||||
opener: Callable[..., Any] = urlopen,
|
opener: Callable[..., Any] = urlopen,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed."""
|
"""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).
|
||||||
|
"""
|
||||||
if not base_url or not base_url.startswith(("http://", "https://")):
|
if not base_url or not base_url.startswith(("http://", "https://")):
|
||||||
raise DecisionError("approval-engine claim URL is missing or invalid")
|
raise DecisionError("approval-engine claim URL is missing or invalid")
|
||||||
ident = authorization_id.strip()
|
ident = authorization_id.strip()
|
||||||
|
|
@ -121,7 +127,7 @@ def fetch_action_authorization(
|
||||||
try:
|
try:
|
||||||
with opener(request, timeout=timeout_seconds) as response:
|
with opener(request, timeout=timeout_seconds) as response:
|
||||||
if getattr(response, "status", 200) != 200:
|
if getattr(response, "status", 200) != 200:
|
||||||
raise DecisionError("approval claim did not return the authorization")
|
raise DecisionError("approval claim did not return a claim")
|
||||||
payload = json.loads(response.read(_MAX_BODY).decode("utf-8"))
|
payload = json.loads(response.read(_MAX_BODY).decode("utf-8"))
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e
|
raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e
|
||||||
|
|
@ -145,12 +151,18 @@ def resolve_consume_binding(
|
||||||
auth_targets: tuple[str, ...] = (),
|
auth_targets: tuple[str, ...] = (),
|
||||||
opener: Callable[..., Any] | None = None,
|
opener: Callable[..., Any] | None = None,
|
||||||
) -> ConsumeBinding | None:
|
) -> ConsumeBinding | None:
|
||||||
"""Join the proposed action to a served ActionAuthorization (PIP + validate).
|
"""Join the proposed action to a served approval-claim (step 1 of GH-DEC-2026-003).
|
||||||
|
|
||||||
Returns None only when this repo holds no configured serving path, which
|
Returns None only when no serving path is configured at all, keeping
|
||||||
keeps production fail-closed exactly as it was before the join existed.
|
production fail-closed exactly as it was before the join existed. Anything
|
||||||
Anything configured-but-wrong raises instead of degrading to None: a
|
configured-but-wrong raises: a half-configured PEP must not look like an
|
||||||
half-configured PEP must not look like an unconfigured one.
|
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 "")
|
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||||
token_file = getattr(cfg, "approval_token_file", None)
|
token_file = getattr(cfg, "approval_token_file", None)
|
||||||
|
|
@ -165,36 +177,12 @@ def resolve_consume_binding(
|
||||||
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
|
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
|
||||||
"and _SUBJECT_TYPE; the PEP must not assert an unnamed subject"
|
"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)
|
purpose = _request_purpose(entry)
|
||||||
if not purpose:
|
if not purpose:
|
||||||
raise DecisionError(
|
raise DecisionError(
|
||||||
"authorization join requires a declared approval/consumer purpose"
|
"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(
|
expected_request = build_action_request(
|
||||||
entry,
|
entry,
|
||||||
action,
|
action,
|
||||||
|
|
@ -204,21 +192,32 @@ def resolve_consume_binding(
|
||||||
fields=fields,
|
fields=fields,
|
||||||
policy_targets=policy_targets,
|
policy_targets=policy_targets,
|
||||||
auth_targets=auth_targets,
|
auth_targets=auth_targets,
|
||||||
request_id=served_id,
|
|
||||||
)
|
)
|
||||||
validated = validate_action_authorization(
|
# Two different digests over the same proposed action, by contract: the
|
||||||
envelope,
|
# approval-engine native binding digest, and the flex-auth CheckRequest
|
||||||
expected_request,
|
# digest. They are not interchangeable and are never compared to each other.
|
||||||
accepted_policy_packages={package},
|
native_digest = claim_binding_digest(**binding_from_check_request(expected_request))
|
||||||
accepted_policy_versions={version},
|
pdp_digest = request_digest(expected_request)
|
||||||
minimum_approval_count=int(getattr(cfg, "authorization_min_approvals", 1) or 1),
|
|
||||||
|
claim = fetch_approval_claim(
|
||||||
|
base_url=base_url,
|
||||||
|
token_file=Path(token_file),
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
opener=opener or urlopen,
|
||||||
)
|
)
|
||||||
if validated.action != action:
|
validate_approval_claim(
|
||||||
raise DecisionError("action authorization does not bind this action")
|
claim,
|
||||||
|
approval_id=authorization_id,
|
||||||
|
expected_binding_digest=native_digest,
|
||||||
|
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")
|
||||||
return ConsumeBinding(
|
return ConsumeBinding(
|
||||||
approval_id=validated.authorization_id,
|
approval_id=authorization_id,
|
||||||
request_digest=request_digest(expected_request),
|
request_digest=pdp_digest,
|
||||||
decision_id=validated.decision_id,
|
decision_id="",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,11 @@ from secrets_engine.catalog import CatalogEntry
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
|
|
||||||
SCHEMA_VERSION = "0.1"
|
SCHEMA_VERSION = "0.1"
|
||||||
AUTHORITY = "state-hub"
|
CONTRACT_VERSION = "flex-auth.decision-record.v1"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ValidatedActionAuthorization:
|
class ValidatedDecision:
|
||||||
authorization_id: str
|
|
||||||
decision_id: str
|
decision_id: str
|
||||||
action: str
|
action: str
|
||||||
subject_id: str
|
subject_id: str
|
||||||
|
|
@ -182,91 +181,45 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_action_authorization(
|
def validate_decision_envelope(
|
||||||
envelope: object,
|
envelope: object,
|
||||||
expected_request: object,
|
expected_request: object,
|
||||||
*,
|
*,
|
||||||
accepted_policy_packages: set[str],
|
accepted_policy_packages: set[str],
|
||||||
accepted_policy_versions: set[str],
|
accepted_policy_versions: set[str],
|
||||||
minimum_approval_count: int = 1,
|
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> ValidatedActionAuthorization:
|
) -> ValidatedDecision:
|
||||||
"""Validate exact request binding and dual control; never parse prose."""
|
"""Validate a flex-auth DecisionEnvelope against the proposed action.
|
||||||
if minimum_approval_count < 1:
|
|
||||||
raise DecisionError("minimum approval count must be positive")
|
This is step 2 of GH-DEC-2026-003. It owns exactly the decision-layer
|
||||||
|
checks: effect, exact CheckRequest binding, canonical request digest,
|
||||||
|
lifetime, and the policy package/version pin. The approval fact -- validity,
|
||||||
|
supersession, consumption, distinct approvers -- belongs to the
|
||||||
|
approval-engine claim and is NOT re-checked here (GH-DEC-2026-005: a PIP
|
||||||
|
must not republish the PDP's decision, and neither layer republishes the
|
||||||
|
other's data).
|
||||||
|
|
||||||
|
There is deliberately no authority constant. State Hub is a read model and
|
||||||
|
holds no runtime approval authority; requiring it fails closed against every
|
||||||
|
correctly issued record.
|
||||||
|
"""
|
||||||
if not accepted_policy_packages or not accepted_policy_versions:
|
if not accepted_policy_packages or not accepted_policy_versions:
|
||||||
raise DecisionError("accepted flex-auth policy package/version is required")
|
raise DecisionError("accepted flex-auth policy package/version is required")
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
raise DecisionError("action authorization must be an object")
|
raise DecisionError("decision envelope must be an object")
|
||||||
if envelope.get("schema_version") != SCHEMA_VERSION:
|
contract = envelope.get("contract_version")
|
||||||
raise DecisionError("unsupported action authorization schema version")
|
if contract is not None and contract != CONTRACT_VERSION:
|
||||||
authorization_id = _required_text(envelope, "id")
|
raise DecisionError("unsupported decision envelope contract version")
|
||||||
try:
|
if envelope.get("effect") != "allow":
|
||||||
parsed_authorization_id = uuid.UUID(authorization_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise DecisionError("action authorization id must be a canonical UUID") from e
|
|
||||||
if str(parsed_authorization_id) != authorization_id:
|
|
||||||
raise DecisionError("action authorization id must be a canonical UUID")
|
|
||||||
if envelope.get("status") != "approved":
|
|
||||||
raise DecisionError("action authorization status is not approved")
|
|
||||||
if envelope.get("superseded_by"):
|
|
||||||
raise DecisionError("action authorization is superseded")
|
|
||||||
provenance = _required_dict(envelope, "provenance")
|
|
||||||
if provenance.get("authority") != AUTHORITY:
|
|
||||||
raise DecisionError("action authorization authority is not State Hub")
|
|
||||||
|
|
||||||
request = canonical_check_request(envelope.get("request"))
|
|
||||||
expected = canonical_check_request(expected_request)
|
|
||||||
_require_exact_target_sets(request)
|
|
||||||
_require_exact_target_sets(expected)
|
|
||||||
if request != expected:
|
|
||||||
raise DecisionError("action authorization request does not exactly match action")
|
|
||||||
|
|
||||||
validity = _required_dict(envelope, "validity")
|
|
||||||
expires = _parse_time(validity.get("expires_at"), "expires_at")
|
|
||||||
not_before = (
|
|
||||||
_parse_time(validity.get("not_before"), "not_before")
|
|
||||||
if validity.get("not_before") is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
|
||||||
if not_before is not None and current < not_before:
|
|
||||||
raise DecisionError("action authorization window has not started")
|
|
||||||
if current >= expires:
|
|
||||||
raise DecisionError("action authorization has expired")
|
|
||||||
|
|
||||||
approvals = _required_dict(envelope, "approvals")
|
|
||||||
required_count = approvals.get("required_count")
|
|
||||||
entries = approvals.get("entries")
|
|
||||||
if not isinstance(required_count, int) or required_count < 1:
|
|
||||||
raise DecisionError("action authorization approval count is invalid")
|
|
||||||
if required_count < minimum_approval_count:
|
|
||||||
raise DecisionError("action authorization approval threshold is insufficient")
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
raise DecisionError("action authorization approval entries are invalid")
|
|
||||||
approvers: set[str] = set()
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise DecisionError("action authorization approval entry is invalid")
|
|
||||||
subject_id = _required_text(entry, "subject_id")
|
|
||||||
approved_at = _parse_time(entry.get("approved_at"), "approved_at")
|
|
||||||
if approved_at > current or approved_at >= expires:
|
|
||||||
raise DecisionError("action authorization approval time is outside window")
|
|
||||||
if not_before is not None and approved_at < not_before:
|
|
||||||
raise DecisionError("action authorization approval time is outside window")
|
|
||||||
if subject_id in approvers:
|
|
||||||
raise DecisionError("action authorization contains duplicate approver")
|
|
||||||
approvers.add(subject_id)
|
|
||||||
if len(approvers) < required_count:
|
|
||||||
raise DecisionError("action authorization has insufficient distinct approvals")
|
|
||||||
|
|
||||||
decision = _required_dict(envelope, "decision")
|
|
||||||
if decision.get("effect") != "allow":
|
|
||||||
raise DecisionError("flex-auth decision effect is not allow")
|
raise DecisionError("flex-auth decision effect is not allow")
|
||||||
decision_id = _required_text(decision, "id")
|
decision_id = _required_text(envelope, "id")
|
||||||
if request.get("id") and decision.get("request_id") != request["id"]:
|
|
||||||
|
expected = canonical_check_request(expected_request)
|
||||||
|
_require_exact_target_sets(expected)
|
||||||
|
if expected.get("id") and envelope.get("request_id") not in (None, expected["id"]):
|
||||||
raise DecisionError("flex-auth decision request id does not match request")
|
raise DecisionError("flex-auth decision request id does not match request")
|
||||||
binding = _required_dict(decision, "binding")
|
|
||||||
|
binding = _required_dict(envelope, "binding")
|
||||||
bound_request: dict[str, Any] = {}
|
bound_request: dict[str, Any] = {}
|
||||||
if binding.get("tenant"):
|
if binding.get("tenant"):
|
||||||
bound_request["tenant"] = binding["tenant"]
|
bound_request["tenant"] = binding["tenant"]
|
||||||
|
|
@ -279,36 +232,43 @@ def validate_action_authorization(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
expected_bound: dict[str, Any] = {}
|
expected_bound: dict[str, Any] = {}
|
||||||
if request.get("tenant"):
|
if expected.get("tenant"):
|
||||||
expected_bound["tenant"] = request["tenant"]
|
expected_bound["tenant"] = expected["tenant"]
|
||||||
expected_bound.update(
|
expected_bound.update(
|
||||||
{
|
{
|
||||||
"subject": request["subject"],
|
"subject": expected["subject"],
|
||||||
"action": request["action"],
|
"action": expected["action"],
|
||||||
"resource": request["resource"],
|
"resource": expected["resource"],
|
||||||
"context": request.get("context", {}),
|
"context": expected.get("context", {}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if canonical_check_request(bound_request) != canonical_check_request(
|
if canonical_check_request(bound_request) != canonical_check_request(expected_bound):
|
||||||
expected_bound
|
|
||||||
):
|
|
||||||
raise DecisionError("flex-auth decision binding does not match request")
|
raise DecisionError("flex-auth decision binding does not match request")
|
||||||
if binding.get("request_digest") != request_digest(request):
|
if binding.get("request_digest") != request_digest(expected):
|
||||||
raise DecisionError("flex-auth request digest does not match request")
|
raise DecisionError("flex-auth request digest does not match request")
|
||||||
if _subject_ref(decision.get("subject")) != request["subject"]:
|
if _subject_ref(envelope.get("subject")) != expected["subject"]:
|
||||||
raise DecisionError("flex-auth decision subject does not match request")
|
raise DecisionError("flex-auth decision subject does not match request")
|
||||||
if _resource_ref(decision.get("resource")) != request["resource"]:
|
if _resource_ref(envelope.get("resource")) != expected["resource"]:
|
||||||
raise DecisionError("flex-auth decision resource does not match request")
|
raise DecisionError("flex-auth decision resource does not match request")
|
||||||
decision_provenance = _required_dict(decision, "provenance")
|
|
||||||
if decision_provenance.get("policy_package") not in accepted_policy_packages:
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
lifetime = _required_dict(envelope, "lifetime")
|
||||||
|
expires = _parse_time(lifetime.get("expires_at"), "expires_at")
|
||||||
|
if current >= expires:
|
||||||
|
raise DecisionError("flex-auth decision lifetime has expired")
|
||||||
|
if lifetime.get("not_before") is not None:
|
||||||
|
if current < _parse_time(lifetime.get("not_before"), "not_before"):
|
||||||
|
raise DecisionError("flex-auth decision lifetime has not started")
|
||||||
|
|
||||||
|
provenance = _required_dict(envelope, "provenance")
|
||||||
|
if provenance.get("policy_package") not in accepted_policy_packages:
|
||||||
raise DecisionError("flex-auth policy package is not accepted")
|
raise DecisionError("flex-auth policy package is not accepted")
|
||||||
if decision_provenance.get("policy_version") not in accepted_policy_versions:
|
if provenance.get("policy_version") not in accepted_policy_versions:
|
||||||
raise DecisionError("flex-auth policy version is not accepted")
|
raise DecisionError("flex-auth policy version is not accepted")
|
||||||
|
|
||||||
return ValidatedActionAuthorization(
|
return ValidatedDecision(
|
||||||
authorization_id=authorization_id,
|
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
action=request["action"],
|
action=expected["action"],
|
||||||
subject_id=request["subject"]["id"],
|
subject_id=expected["subject"]["id"],
|
||||||
expires_at=expires.isoformat(),
|
expires_at=expires.isoformat(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
|
"""flex-auth DecisionEnvelope consumer (step 2 of GH-DEC-2026-003).
|
||||||
|
|
||||||
|
The ActionAuthorization envelope these tests used to cover is deferred and was
|
||||||
|
never ratified (FLEX-DEC-2026-006); the approval fact moved to
|
||||||
|
tests/test_approval_claim.py. The canonical request digest is unchanged and its
|
||||||
|
contract test below is preserved verbatim -- flex-auth confirmed only the
|
||||||
|
envelope went away, not the digest join.
|
||||||
|
"""
|
||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from secrets_engine.authorization import (
|
from secrets_engine.authorization import (
|
||||||
build_action_request,
|
build_action_request,
|
||||||
request_digest,
|
request_digest,
|
||||||
validate_action_authorization,
|
validate_decision_envelope,
|
||||||
)
|
)
|
||||||
from secrets_engine.catalog import validate_entry
|
from secrets_engine.catalog import validate_entry
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
from tests.test_catalog import VALID
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 23, 10, 5, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _request():
|
def _request():
|
||||||
entry = validate_entry(copy.deepcopy(VALID))
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
return build_action_request(
|
return build_action_request(
|
||||||
|
|
@ -31,62 +36,44 @@ def _request():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _envelope():
|
def _envelope(request=None):
|
||||||
request = _request()
|
"""A flex-auth DecisionEnvelope shaped by schemas/decision_envelope.schema.json."""
|
||||||
|
request = request or _request()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
return {
|
return {
|
||||||
"schema_version": "0.1",
|
"id": "decision:test-lane-deactivate",
|
||||||
"id": "8bfc20be-47a4-4fb0-97a2-bf0a920afad8",
|
"contract_version": "flex-auth.decision-record.v1",
|
||||||
"status": "approved",
|
"request_id": request["id"],
|
||||||
"request": request,
|
"effect": "allow",
|
||||||
"validity": {
|
"subject": copy.deepcopy(request["subject"]),
|
||||||
"not_before": "2026-08-23T10:00:00Z",
|
"resource": copy.deepcopy(request["resource"]),
|
||||||
"expires_at": "2026-08-23T10:15:00Z",
|
"binding": {
|
||||||
},
|
|
||||||
"approvals": {
|
|
||||||
"required_count": 2,
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"subject_id": "user:alice",
|
|
||||||
"approved_at": "2026-08-23T10:01:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"subject_id": "user:bob",
|
|
||||||
"approved_at": "2026-08-23T10:02:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"decision": {
|
|
||||||
"id": "decision:test-lane-deactivate",
|
|
||||||
"request_id": request["id"],
|
|
||||||
"effect": "allow",
|
|
||||||
"resource": copy.deepcopy(request["resource"]),
|
|
||||||
"subject": copy.deepcopy(request["subject"]),
|
"subject": copy.deepcopy(request["subject"]),
|
||||||
"binding": {
|
"action": request["action"],
|
||||||
"subject": copy.deepcopy(request["subject"]),
|
"resource": copy.deepcopy(request["resource"]),
|
||||||
"action": request["action"],
|
"context": copy.deepcopy(request["context"]),
|
||||||
"resource": copy.deepcopy(request["resource"]),
|
"request_digest": request_digest(request),
|
||||||
"context": copy.deepcopy(request["context"]),
|
},
|
||||||
"request_digest": request_digest(request),
|
"lifetime": {
|
||||||
},
|
"kind": "bounded",
|
||||||
"provenance": {
|
"not_before": (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"evaluator": "flex-auth/local",
|
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"mode": "standalone",
|
},
|
||||||
"policy_package": "secrets-engine.lifecycle",
|
"provenance": {
|
||||||
"policy_version": "v1",
|
"evaluator": "flex-auth/secrets-engine",
|
||||||
},
|
"mode": "cluster-local",
|
||||||
|
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||||
|
"policy_version": "v1",
|
||||||
},
|
},
|
||||||
"provenance": {"authority": "state-hub"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validate(envelope, expected=None):
|
def _validate(envelope, expected=None):
|
||||||
return validate_action_authorization(
|
return validate_decision_envelope(
|
||||||
envelope,
|
envelope,
|
||||||
expected or _request(),
|
expected or _request(),
|
||||||
accepted_policy_packages={"secrets-engine.lifecycle"},
|
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||||
accepted_policy_versions={"v1"},
|
accepted_policy_versions={"v1"},
|
||||||
minimum_approval_count=2,
|
|
||||||
now=NOW,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,111 +102,86 @@ def test_digest_matches_flex_auth_contract_example():
|
||||||
"sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3"
|
"sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_valid_allow_envelope_passes():
|
||||||
def test_valid_exact_action_authorization_passes():
|
|
||||||
result = _validate(_envelope())
|
result = _validate(_envelope())
|
||||||
assert result.authorization_id == "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
assert result.decision_id == "decision:test-lane-deactivate"
|
||||||
assert result.action == "deactivate"
|
assert result.action == "deactivate"
|
||||||
assert result.subject_id == "user:alice"
|
assert result.subject_id == "user:alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_hub_authority_is_no_longer_required():
|
||||||
|
"""GH-DEC-2026-005: State Hub holds no runtime approval authority.
|
||||||
|
|
||||||
|
A correctly issued record naming any other authority (or none) must pass;
|
||||||
|
the old AUTHORITY constant failed closed against every real record.
|
||||||
|
"""
|
||||||
|
env = _envelope()
|
||||||
|
env["provenance"]["authority"] = "approval-engine"
|
||||||
|
assert _validate(env).action == "deactivate"
|
||||||
|
env["provenance"].pop("authority")
|
||||||
|
assert _validate(env).action == "deactivate"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("mutation", "match"),
|
("mutation", "match"),
|
||||||
[
|
[
|
||||||
(lambda doc: doc.update(status="superseded"), "status is not approved"),
|
(lambda d: d.update(effect="deny"), "effect is not allow"),
|
||||||
(
|
(lambda d: d.update(effect="audit_only"), "effect is not allow"),
|
||||||
lambda doc: doc["request"]["resource"].update(id="catalog:wrong"),
|
(lambda d: d["binding"].update(action="destroy"), "binding does not match"),
|
||||||
"does not exactly match",
|
(lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64),
|
||||||
),
|
"request digest does not match"),
|
||||||
(
|
(lambda d: d["provenance"].update(policy_package="other.package"),
|
||||||
lambda doc: doc["request"].update(action="destroy"),
|
"policy package is not accepted"),
|
||||||
"does not exactly match",
|
(lambda d: d["provenance"].update(policy_version="v2"),
|
||||||
),
|
"policy version is not accepted"),
|
||||||
(
|
(lambda d: d.update(contract_version="flex-auth.decision-record.v2"),
|
||||||
lambda doc: doc["request"]["resource"]["attributes"].update(
|
"contract version"),
|
||||||
fields=["other"]
|
(lambda d: d["subject"].update(id="user:mallory"), "subject does not match"),
|
||||||
),
|
|
||||||
"does not exactly match",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["request"]["context"].update(purpose="wrong"),
|
|
||||||
"does not exactly match",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["decision"].update(effect="deny"),
|
|
||||||
"effect is not allow",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["decision"]["binding"].update(
|
|
||||||
request_digest="sha256:" + "0" * 64
|
|
||||||
),
|
|
||||||
"digest does not match",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["approvals"]["entries"][1].update(
|
|
||||||
subject_id="user:alice"
|
|
||||||
),
|
|
||||||
"duplicate approver",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["approvals"].update(required_count=1),
|
|
||||||
"threshold is insufficient",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["decision"].update(request_id="check:wrong"),
|
|
||||||
"request id does not match",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
lambda doc: doc["provenance"].update(authority="local-fixture"),
|
|
||||||
"authority is not State Hub",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_invalid_authorizations_fail_closed(mutation, match):
|
def test_invalid_envelopes_fail_closed(mutation, match):
|
||||||
envelope = _envelope()
|
env = _envelope()
|
||||||
mutation(envelope)
|
mutation(env)
|
||||||
with pytest.raises(DecisionError, match=match):
|
with pytest.raises(DecisionError, match=match):
|
||||||
_validate(envelope)
|
_validate(env)
|
||||||
|
|
||||||
|
|
||||||
def test_expired_authorization_fails_closed():
|
def test_expired_lifetime_fails_closed():
|
||||||
with pytest.raises(DecisionError, match="expired"):
|
env = _envelope()
|
||||||
validate_action_authorization(
|
past = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
_envelope(),
|
env["lifetime"]["expires_at"] = past.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
_request(),
|
with pytest.raises(DecisionError, match="lifetime has expired"):
|
||||||
accepted_policy_packages={"secrets-engine.lifecycle"},
|
_validate(env)
|
||||||
accepted_policy_versions={"v1"},
|
|
||||||
now=datetime(2026, 8, 23, 10, 15, tzinfo=timezone.utc),
|
|
||||||
|
def test_lifetime_not_yet_started_fails_closed():
|
||||||
|
env = _envelope()
|
||||||
|
future = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
|
env["lifetime"]["not_before"] = future.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
with pytest.raises(DecisionError, match="has not started"):
|
||||||
|
_validate(env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unaccepted_policy_pin_is_required():
|
||||||
|
with pytest.raises(DecisionError, match="package/version is required"):
|
||||||
|
validate_decision_envelope(
|
||||||
|
_envelope(), _request(),
|
||||||
|
accepted_policy_packages=set(), accepted_policy_versions={"v1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_noncanonical_authorization_uuid_is_rejected():
|
|
||||||
envelope = _envelope()
|
|
||||||
envelope["id"] = envelope["id"].replace("-", "")
|
|
||||||
with pytest.raises(DecisionError, match="canonical UUID"):
|
|
||||||
_validate(envelope)
|
|
||||||
|
|
||||||
|
|
||||||
def test_approval_timestamp_must_be_inside_current_authorization_window():
|
|
||||||
envelope = _envelope()
|
|
||||||
envelope["approvals"]["entries"][1]["approved_at"] = "2026-08-23T10:06:00Z"
|
|
||||||
with pytest.raises(DecisionError, match="approval time is outside window"):
|
|
||||||
_validate(envelope)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unsorted_or_duplicate_target_sets_are_rejected():
|
def test_unsorted_or_duplicate_target_sets_are_rejected():
|
||||||
envelope = _envelope()
|
request = _request()
|
||||||
envelope["request"]["resource"]["attributes"]["fields"] = [
|
request["resource"]["attributes"]["policy_targets"] = ["b", "a"]
|
||||||
"second",
|
|
||||||
"first",
|
|
||||||
"first",
|
|
||||||
]
|
|
||||||
with pytest.raises(DecisionError, match="sorted and unique"):
|
with pytest.raises(DecisionError, match="sorted and unique"):
|
||||||
_validate(envelope, expected=envelope["request"])
|
_validate(_envelope(), request)
|
||||||
|
|
||||||
|
|
||||||
def test_unaccepted_policy_revision_is_rejected():
|
def test_approval_fact_is_not_rechecked_here():
|
||||||
envelope = _envelope()
|
"""GH-DEC-2026-005: a PIP must not republish the PDP's decision, and the
|
||||||
envelope["decision"]["provenance"]["policy_version"] = "v2"
|
decision layer must not restate the approval fact. Consumption, supersession
|
||||||
with pytest.raises(DecisionError, match="policy version is not accepted"):
|
and approver counts belong to the claim; adding them here would fail closed
|
||||||
_validate(envelope)
|
against a valid envelope that simply does not carry them."""
|
||||||
|
env = _envelope()
|
||||||
|
assert "approvals" not in env and "status" not in env
|
||||||
|
assert _validate(env).action == "deactivate"
|
||||||
|
|
|
||||||
180
tests/test_approval_claim.py
Normal file
180
tests/test_approval_claim.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""approval-engine approval-claim consumer (step 1 of GH-DEC-2026-003).
|
||||||
|
|
||||||
|
Covers the published "Required verification" list in
|
||||||
|
approval-engine/docs/approval-claim.md. The claim is a fact, never a decision.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine.approval_claim import (
|
||||||
|
binding_from_check_request,
|
||||||
|
claim_binding_digest,
|
||||||
|
validate_approval_claim,
|
||||||
|
)
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
|
||||||
|
APPROVAL_ID = "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f"
|
||||||
|
|
||||||
|
|
||||||
|
def _binding():
|
||||||
|
return {
|
||||||
|
"action": "secrets.kv.destroy",
|
||||||
|
"target": {"id": "lane-openbao-root", "stage": "prod"},
|
||||||
|
"actor": "agt-secrets-engine",
|
||||||
|
"principal": "bernd",
|
||||||
|
"purpose": "rotate-exposed-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _claim(**over):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
binding = dict(_binding())
|
||||||
|
binding["digest"] = claim_binding_digest(**_binding())
|
||||||
|
claim = {
|
||||||
|
"schema_version": "0.1",
|
||||||
|
"kind": "approval-claim",
|
||||||
|
"issuer": "approval-engine",
|
||||||
|
"approval_id": APPROVAL_ID,
|
||||||
|
"state": "valid",
|
||||||
|
"valid_now": True,
|
||||||
|
"consumed": False,
|
||||||
|
"binding": binding,
|
||||||
|
"freshness": {
|
||||||
|
"observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"ttl_seconds": 30,
|
||||||
|
"not_after": (now + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
"validity": {
|
||||||
|
"not_before": (now - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"expires_at": (now + timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
"reason_code": "ok",
|
||||||
|
}
|
||||||
|
claim.update(over)
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(claim, **kw):
|
||||||
|
kw.setdefault("approval_id", APPROVAL_ID)
|
||||||
|
kw.setdefault("expected_binding_digest", claim_binding_digest(**_binding()))
|
||||||
|
return validate_approval_claim(claim, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_claim_passes():
|
||||||
|
assert _validate(_claim())["approval_id"] == APPROVAL_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_digest_is_canonical_sorted_json():
|
||||||
|
"""Sorted keys at every level, no insignificant whitespace."""
|
||||||
|
digest = claim_binding_digest(**_binding())
|
||||||
|
assert digest.startswith("sha256:") and len(digest) == 71
|
||||||
|
shuffled = {
|
||||||
|
"purpose": "rotate-exposed-key",
|
||||||
|
"actor": "agt-secrets-engine",
|
||||||
|
"target": {"stage": "prod", "id": "lane-openbao-root"},
|
||||||
|
"action": "secrets.kv.destroy",
|
||||||
|
"principal": "bernd",
|
||||||
|
}
|
||||||
|
assert claim_binding_digest(**shuffled) == digest
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_digest_is_not_the_flex_auth_request_digest():
|
||||||
|
"""The two digest functions are different by contract and must not be mixed."""
|
||||||
|
from secrets_engine.authorization import request_digest
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"subject": {"id": "agt-secrets-engine"},
|
||||||
|
"action": "secrets.kv.destroy",
|
||||||
|
"resource": {"id": "lane-openbao-root", "type": "t", "system": "s"},
|
||||||
|
"context": {"purpose": "rotate-exposed-key"},
|
||||||
|
}
|
||||||
|
assert claim_binding_digest(**binding_from_check_request(request)) != request_digest(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_request_maps_onto_claim_binding():
|
||||||
|
request = {
|
||||||
|
"subject": {"id": "user:alice", "attributes": {"principal": "bernd"}},
|
||||||
|
"action": "deactivate",
|
||||||
|
"resource": {"id": "catalog:x", "type": "secret-catalog-lane"},
|
||||||
|
"context": {"purpose": "contract-test"},
|
||||||
|
}
|
||||||
|
mapped = binding_from_check_request(request)
|
||||||
|
assert mapped["actor"] == "user:alice"
|
||||||
|
assert mapped["principal"] == "bernd"
|
||||||
|
assert mapped["purpose"] == "contract-test"
|
||||||
|
assert mapped["target"] == request["resource"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_principal_falls_back_to_actor_when_absent():
|
||||||
|
request = {
|
||||||
|
"subject": {"id": "user:alice"},
|
||||||
|
"action": "deactivate",
|
||||||
|
"resource": {"id": "catalog:x"},
|
||||||
|
"context": {"purpose": "p"},
|
||||||
|
}
|
||||||
|
assert binding_from_check_request(request)["principal"] == "user:alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdp_digest_is_preferred_when_the_issuer_recorded_one():
|
||||||
|
claim = _claim()
|
||||||
|
claim["binding"]["pdp_digest"] = "sha256:" + "a" * 64
|
||||||
|
claim["binding"]["digest"] = "sha256:" + "b" * 64 # native no longer matches
|
||||||
|
assert _validate(claim, expected_pdp_digest="sha256:" + "a" * 64)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mutation", "match"),
|
||||||
|
[
|
||||||
|
(lambda c: c.update(issuer="state-hub"), "issuer is not approval-engine"),
|
||||||
|
(lambda c: c.update(kind="decision"), "kind is not approval-claim"),
|
||||||
|
(lambda c: c.update(schema_version="0.2"), "schema version"),
|
||||||
|
(lambda c: c.update(valid_now=False, reason_code="revoked"), "not valid now"),
|
||||||
|
(lambda c: c.update(consumed=True), "already consumed"),
|
||||||
|
(lambda c: c.update(reason_code="superseded"), "reason code is not ok"),
|
||||||
|
(lambda c: c.update(effect="allow"), "a claim is not a decision"),
|
||||||
|
(lambda c: c.update(approval_id="00000000-0000-0000-0000-000000000000"),
|
||||||
|
"different approval object"),
|
||||||
|
(lambda c: c["binding"].update(digest="sha256:" + "f" * 64),
|
||||||
|
"binding digest does not match"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_claims_fail_closed(mutation, match):
|
||||||
|
claim = _claim()
|
||||||
|
mutation(claim)
|
||||||
|
with pytest.raises(DecisionError, match=match):
|
||||||
|
_validate(claim)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_observation_is_rejected_even_when_still_valid():
|
||||||
|
"""Stale is not the same as valid_now false; the object may still be good."""
|
||||||
|
claim = _claim()
|
||||||
|
past = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
claim["freshness"]["not_after"] = past.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
assert claim["valid_now"] is True
|
||||||
|
with pytest.raises(DecisionError, match="stale"):
|
||||||
|
_validate(claim)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_validity_window_fails_closed():
|
||||||
|
claim = _claim()
|
||||||
|
past = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
|
claim["validity"]["expires_at"] = past.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
with pytest.raises(DecisionError, match="validity window has expired"):
|
||||||
|
_validate(claim)
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_yet_valid_fails_closed():
|
||||||
|
claim = _claim()
|
||||||
|
future = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
|
claim["validity"]["not_before"] = future.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
with pytest.raises(DecisionError, match="not yet valid"):
|
||||||
|
_validate(claim)
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_requires_an_expected_digest():
|
||||||
|
with pytest.raises(DecisionError, match="requires an expected digest"):
|
||||||
|
validate_approval_claim(_claim(), approval_id=APPROVAL_ID)
|
||||||
|
|
@ -13,11 +13,14 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine.approval_claim import (
|
||||||
|
binding_from_check_request,
|
||||||
|
claim_binding_digest,
|
||||||
|
)
|
||||||
from secrets_engine.approval_consume import resolve_consume_binding
|
from secrets_engine.approval_consume import resolve_consume_binding
|
||||||
from secrets_engine.authorization import build_action_request, request_digest
|
from secrets_engine.authorization import build_action_request, request_digest
|
||||||
from secrets_engine.catalog import validate_entry
|
from secrets_engine.catalog import validate_entry
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
from tests.test_action_authorization import _envelope
|
|
||||||
from tests.test_catalog import VALID
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
||||||
|
|
@ -51,19 +54,43 @@ def _token(tmp_path):
|
||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
def _served(**over):
|
def _expected_request(entry, action="deactivate", fields=("api_token",)):
|
||||||
"""A served envelope whose validity window is live now."""
|
return build_action_request(
|
||||||
env = copy.deepcopy(_envelope())
|
entry, action,
|
||||||
|
subject_id="user:alice", subject_type="Human", purpose="contract-test",
|
||||||
|
fields=list(fields),
|
||||||
|
policy_targets=[entry.policy_name], auth_targets=[entry.role_name],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _served(entry=None, action="deactivate", fields=("api_token",), **over):
|
||||||
|
"""An approval-engine approval-claim bound to the proposed action."""
|
||||||
|
entry = entry or _entry()
|
||||||
|
request = _expected_request(entry, action, fields)
|
||||||
|
binding = binding_from_check_request(request)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
env["validity"] = {
|
claim = {
|
||||||
"not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"schema_version": "0.1",
|
||||||
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"kind": "approval-claim",
|
||||||
|
"issuer": "approval-engine",
|
||||||
|
"approval_id": AUTH_ID,
|
||||||
|
"state": "valid",
|
||||||
|
"valid_now": True,
|
||||||
|
"consumed": False,
|
||||||
|
"binding": {**binding, "digest": claim_binding_digest(**binding)},
|
||||||
|
"freshness": {
|
||||||
|
"observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"ttl_seconds": 30,
|
||||||
|
"not_after": (now + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
"validity": {
|
||||||
|
"not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
"reason_code": "ok",
|
||||||
}
|
}
|
||||||
approved = (now - timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
claim.update(over)
|
||||||
for approval in env["approvals"]["entries"]:
|
return claim
|
||||||
approval["approved_at"] = approved
|
|
||||||
env.update(over)
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _opener(envelope, status=200):
|
def _opener(envelope, status=200):
|
||||||
|
|
@ -99,14 +126,7 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
|
||||||
binding = _resolve(cfg, entry, _served())
|
binding = _resolve(cfg, entry, _served())
|
||||||
assert binding is not None
|
assert binding is not None
|
||||||
assert binding.approval_id == AUTH_ID
|
assert binding.approval_id == AUTH_ID
|
||||||
expected = build_action_request(
|
assert binding.request_digest == request_digest(_expected_request(entry))
|
||||||
entry, "deactivate",
|
|
||||||
subject_id="user:alice", subject_type="Human", purpose="contract-test",
|
|
||||||
fields=["api_token"],
|
|
||||||
policy_targets=[entry.policy_name], auth_targets=[entry.role_name],
|
|
||||||
request_id="check:test-lane-deactivate",
|
|
||||||
)
|
|
||||||
assert binding.request_digest == request_digest(expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
|
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
|
||||||
|
|
@ -116,11 +136,12 @@ def test_missing_subject_raises_instead_of_returning_none(tmp_path):
|
||||||
_resolve(cfg, _entry(), _served())
|
_resolve(cfg, _entry(), _served())
|
||||||
|
|
||||||
|
|
||||||
def test_example_policy_names_are_not_an_implicit_pin(tmp_path):
|
def test_policy_pin_is_not_enforced_on_the_claim_path(tmp_path):
|
||||||
"""flex-auth: the published example vocabulary is not a live pin."""
|
"""flex-auth: the published example vocabulary is not a live pin."""
|
||||||
|
# The pin is a step-2 (DecisionEnvelope) concern after GH-DEC-2026-005 and
|
||||||
|
# is asserted in tests/test_action_authorization.py, not on the claim path.
|
||||||
cfg = _Cfg(_token(tmp_path), authorization_policy_package="")
|
cfg = _Cfg(_token(tmp_path), authorization_policy_package="")
|
||||||
with pytest.raises(DecisionError, match="policy .*pin"):
|
assert _resolve(cfg, _entry(), _served()) is not None
|
||||||
_resolve(cfg, _entry(), _served())
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_field_set_fails_closed(tmp_path):
|
def test_wrong_field_set_fails_closed(tmp_path):
|
||||||
|
|
@ -158,6 +179,6 @@ def test_unreachable_approval_engine_fails_closed(tmp_path):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_superseded_authorization_fails_closed(tmp_path):
|
def test_superseded_claim_fails_closed(tmp_path):
|
||||||
with pytest.raises(DecisionError):
|
with pytest.raises(DecisionError):
|
||||||
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(status="superseded"))
|
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded"))
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,47 @@ artifact and must not be extended until gate-house rules.
|
||||||
pins, and `flex-auth-secrets-engine` has not been created yet, so the
|
pins, and `flex-auth-secrets-engine` has not been created yet, so the
|
||||||
2026-09-06 probe finding was the design working rather than an outage.
|
2026-09-06 probe finding was the design working rather than an outage.
|
||||||
|
|
||||||
|
Resolved 2026-09-06 by gate-house `GH-DEC-2026-005` (GH-IN-0002), settling
|
||||||
|
approval-engine's `APPROVAL-IN-0002`. Both changes it assigned to this repo are
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
1. The validator is split by owning layer. `approval_claim.validate_approval_claim`
|
||||||
|
consumes approval-engine's approval-claim for the approval fact (issuer,
|
||||||
|
`valid_now`, consumption state, binding digest, freshness, `reason_code`).
|
||||||
|
`authorization.validate_decision_envelope` consumes the flex-auth
|
||||||
|
DecisionEnvelope for the decision (effect, exact CheckRequest binding,
|
||||||
|
canonical request digest, lifetime, policy package/version pin). Neither
|
||||||
|
republishes the other's data. `ActionAuthorization` is deferred and never
|
||||||
|
ratified (`FLEX-DEC-2026-006`); nothing validates it any more.
|
||||||
|
2. `AUTHORITY = "state-hub"` and the `provenance.authority` requirement are
|
||||||
|
gone. flex-auth traced the constant to their own fixture
|
||||||
|
(`examples/caring/action_authorization.json`), which contradicted their
|
||||||
|
ownership section — their bug, fixed at source. State Hub is a read model and
|
||||||
|
holds no runtime approval authority, so the check failed closed against every
|
||||||
|
correctly issued record.
|
||||||
|
|
||||||
|
Two consequences worth stating rather than burying:
|
||||||
|
|
||||||
|
- There are now **two different digests** over the same proposed action, by
|
||||||
|
contract, never compared to each other: the approval-engine native binding
|
||||||
|
digest over `{action, actor, principal, purpose, target}`, and the flex-auth
|
||||||
|
canonical CheckRequest digest. `claim.binding.pdp_digest` is preferred when
|
||||||
|
the issuer recorded one. The CheckRequest digest itself is unchanged and its
|
||||||
|
contract test is preserved verbatim — flex-auth confirmed only the envelope
|
||||||
|
went away, not the digest join.
|
||||||
|
- The distinct-approver threshold is **no longer a consumer-side check**. The
|
||||||
|
claim does not expose approver entries; approval-engine folds that requirement
|
||||||
|
into `valid_now`. We now rely on the issuer for it, which is the correct layer
|
||||||
|
but is a real reduction in what this engine verifies independently.
|
||||||
|
|
||||||
|
Still outstanding, unchanged by the ruling: step 2 needs the
|
||||||
|
`flex-auth-secrets-engine` Service DNS (not created — per-consumer cluster-local
|
||||||
|
pins are the design, so the earlier "no reachable PDP" probe was not an outage)
|
||||||
|
and the published package from `FLEX-WP-0021-T02`. The pin stays unset.
|
||||||
|
`docs/gated-actions.md` delivered the twelve-action vocabulary that unblocks
|
||||||
|
`FLEX-WP-0021-T01`. Separately tracked: production requires a KeyCape RS256 JWT,
|
||||||
|
not the static Bearer token this engine currently sends.
|
||||||
|
|
||||||
Define and enforce the decision contract needed by production commands. A
|
Define and enforce the decision contract needed by production commands. A
|
||||||
resolved approval must bind at least:
|
resolved approval must bind at least:
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue