fix: bind approval consumption to actual Flex Auth submissions
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
89bc31460f
commit
ee4e901611
23 changed files with 612 additions and 1023 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# Approval consumption (PEP)
|
||||
|
||||
Status: engine consumer and the PIP claim/validate join are implemented; live
|
||||
production remains fail-closed until approval-engine and access-engine actually
|
||||
serve the durable objects.
|
||||
Status: consumer replay and claim/check/consume join are implemented and exercised
|
||||
against real local components. Live activation requires current PDP contract
|
||||
adoption, client-side and audit admission, and the deployed approval service.
|
||||
|
||||
Normative protocol: `gate-house/docs/contracts/approval-consumption.md`
|
||||
(`GH-DEC-2026-003`). Implementation surface:
|
||||
|
|
@ -24,7 +24,7 @@ does not redefine it.
|
|||
|
||||
| Step | Artifact | Owner | Validated by |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` |
|
||||
| 1 | approval-claim | approval-engine | `approval_claim.observe_pdp_approval_claim`, then decision-time correspondence |
|
||||
| 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` |
|
||||
|
||||
The claim carries the *approval fact*: issuer, `valid_now`, consumption state,
|
||||
|
|
@ -36,81 +36,43 @@ policy package/version pin. Neither republishes the other's data.
|
|||
(`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:
|
||||
### Replay, approval correspondence and consume identity
|
||||
|
||||
- `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.
|
||||
FLEX-DEC-2026-012 distinguishes three decision bindings:
|
||||
|
||||
`claim.binding.pdp_digest` is the **only** comparison usable, and its absence
|
||||
fails the action closed. The claim's `binding.action` and `binding.target` speak
|
||||
approval-engine's vocabulary (`secrets.kv.destroy`, `{"id": ..., "stage": ...}`)
|
||||
while ours speaks the catalog's (`destroy`, `catalog:<id>`). No mapping between
|
||||
them exists and none is coming: gate-house rejected one outright in
|
||||
`GH-DEC-2026-008`, because a translation can be confidently wrong and fails
|
||||
**open** by silently accepting a claim approved for a different action. An
|
||||
identity check cannot. So this engine computes no native digest.
|
||||
| Field | Use |
|
||||
| --- | --- |
|
||||
| `submitted_request_digest` | Compare to the canonical request actually sent, including the full `context.approval` claim. Missing or different refuses. |
|
||||
| `approval_binding_digest` | Compare to the claim's issuer-recorded `binding.pdp_digest`. Both values originate at the evaluator, before and after approval respectively. |
|
||||
| `request_digest` | Preserve the evaluator's enriched request identity for audit and the consume CAS request, alongside its decision id. |
|
||||
|
||||
### The two gates on the pdp path
|
||||
The consumer first observes issuer, approval id, validity, freshness, unconsumed
|
||||
state, reason and explicit `pdp_path: true` with a canonical `pdp_digest`.
|
||||
`resolve_approval_observation` returns an observation, never a consume binding.
|
||||
It carries that exact claim into `context.approval` for the PDP call. Step 2
|
||||
checks allow, submitted replay identity, policy package/version, decision
|
||||
lifetime and the evaluator-origin approval pair. Claim freshness is rechecked
|
||||
after the call. Only then does `authorize_action` produce a consume binding.
|
||||
|
||||
**1. `binding.pdp_path` must be `true`.** This is approval-engine's declaration
|
||||
(schema v3) that the approval was requested against a bound CheckRequest. Their
|
||||
`create()` refuses `pdp_path` true without a `pdp_digest`, so the declaration
|
||||
*guarantees* the digest. The converse does not hold, and we must not infer it: a
|
||||
digest recorded for some other reason is no declaration anybody made, and every
|
||||
approval issued before schema v3 carries `pdp_path` false regardless of any
|
||||
digest it holds.
|
||||
Registry facts can override caller attributes. The consumer does not duplicate
|
||||
that evaluation or reconstruct the enriched request/approval digest. The native
|
||||
approval digest uses a different vocabulary and is never a fallback. A
|
||||
claim-free decision cannot satisfy the approval join. Changing or removing a
|
||||
claim changes the submitted replay identity even when the approval-free digest
|
||||
stays constant.
|
||||
|
||||
The cost is real and is ours to carry — an approval requested without a bound
|
||||
CheckRequest is not usable here and never becomes usable later. `GH-DEC-2026-008`
|
||||
holds that correct: an approval granted against an unspecified action does not
|
||||
become an approval for a specific one because a consumer later found a use for
|
||||
it. If a real destroy workflow cannot bind at issue, that is the falsifier
|
||||
gate-house wrote into the reversal, and it should be **raised**, not worked
|
||||
around.
|
||||
Canonical hashing covers tenant, subject, action, resource and nonempty context;
|
||||
request id, policy_version and caring_context are excluded by the producer's
|
||||
contract. Independent producer input/output fixtures verify this boundary.
|
||||
|
||||
**2. The digest identity, against the right comparand.**
|
||||
CAS retry resends the **same validated enriched request digest**. Fetching a new
|
||||
claim or changing a request is not the same consume operation. Consumption is
|
||||
not backend exactly-once evidence: a response-lost OpenBao operation still needs
|
||||
its own reconciliation before any repeated side effect.
|
||||
|
||||
```text
|
||||
claim.binding.pdp_digest == decision.binding.approval_binding_digest correct
|
||||
claim.binding.pdp_digest == decision.binding.request_digest can never pass
|
||||
```
|
||||
|
||||
`pdp_digest` is recorded at *issue* time, and issue precedes the decision. A
|
||||
request that carries the claim inside its hashed `context` therefore has a
|
||||
different `request_digest` by construction — the claim is part of the material
|
||||
being hashed. This engine found that circularity against the T03 replay fixture;
|
||||
flex-auth fixed it in `FLEX-DEC-2026-007` by publishing
|
||||
`binding.approval_binding_digest`, the same canonical digest with
|
||||
`context.approval` removed, which is stable across attaching the claim.
|
||||
|
||||
`approval_binding_digest` is **not** a replay identity. `request_digest` still
|
||||
covers the claim and still moves when it changes, because two requests differing
|
||||
only in which approval was presented must not share a replay identity — one
|
||||
allows, the other denies `dual_control_required`. Collapsing them would let an
|
||||
allow obtained with a valid claim be replayed against a request carrying none.
|
||||
|
||||
Our own CheckRequest is claim-free today (`context` is `{"purpose": ...}`), so
|
||||
flex-auth emits no `approval_binding_digest` for it and the identity holds
|
||||
transitively: step 1 compares the claim's `pdp_digest` to the canonical digest of
|
||||
the exact claim-free request we are about to send, and step 2 confirms the
|
||||
decision's `request_digest` is that same value. `approval_binding_digest(request)`
|
||||
implements the published exclusion rule so that the check is already correct if
|
||||
we ever carry the claim in context; when the field is present it is recomputed
|
||||
and never taken on faith.
|
||||
|
||||
### What is hashed in the flex-auth digest
|
||||
|
||||
`tenant`, `subject`, `action`, `resource`, `context` — and nothing else.
|
||||
`id` is correlation only, `policy_version` lives in provenance, and
|
||||
`caring_context` is hashed separately. Including any of them yields a digest
|
||||
that matches no issued decision. `digest_material` enforces the exclusion and
|
||||
`tests/test_decision_replay.py` pins it against two real envelopes.
|
||||
|
||||
A consumer re-hashing the *original unenriched* request will not match a
|
||||
decision that turned on registry attributes: the binding is the evaluator's
|
||||
statement of what it hashed, so recompute from the binding tuple.
|
||||
The supported owner loopback forwarding path remains required for PDP transport.
|
||||
A matching digest does not authenticate a malicious responder. Detached signed
|
||||
decisions remain FLEX-WP-0024; this correction grants no new network trust.
|
||||
|
||||
The approval-engine object id is never inferred from a State Hub decision UUID.
|
||||
It comes from catalog `approval.authorization_id`, and is the value in the
|
||||
|
|
|
|||
|
|
@ -53,13 +53,14 @@ Run from this repository with Docker available and the owner source checkouts:
|
|||
uv run --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval_identity.py \
|
||||
--keycape-source /home/worsch/key-cape \
|
||||
--approval-engine-source /home/worsch/approval-engine \
|
||||
--flex-auth-source /home/worsch/flex-auth \
|
||||
--receipt /tmp/<new-metadata-receipt>.json
|
||||
```
|
||||
|
||||
The exercise starts the immutable admitted KeyCape image behind local HTTPS,
|
||||
uses synthetic signing keys/client secrets, and serves the real Approval Engine
|
||||
source with its JWT/JWKS verifier and SQLite store. It drives the actual Secrets
|
||||
Engine production gate, with only the PDP represented by a sequencing double.
|
||||
Engine production gate through the actual locally compiled Flex Auth evaluator.
|
||||
Operator create/approve, consumer claim/consume, same-digest retry, different
|
||||
digest, spent claim, wrong action, wrong secret and operator-scope denial are
|
||||
checked. All temporary processes and synthetic credentials are removed.
|
||||
|
|
@ -82,6 +83,15 @@ and sender custody remains AUDIT-WP-0009-T09 / APPROVAL-WP-0002-T01. The live
|
|||
Approval Engine deployment and native OpenBao delivery remain open. Synthetic
|
||||
acceptance grants no production access, action approval or model spending.
|
||||
|
||||
The newly consumed FLEX-DEC-2026-012 return remains SECRETS-WP-0008-T02:
|
||||
adopt submitted_request_digest and the evaluator-to-evaluator approval digest
|
||||
join before live activation. The current PDP double does not validate it.
|
||||
SECRETS-WP-0008-T02 now supplies the implemented FLEX-DEC-2026-012 replay
|
||||
and approval join. Verify the deployed PDP implements it before live activation.
|
||||
|
||||
|
||||
### 2026-09-09 replay correction accepted locally
|
||||
|
||||
The updated exercise requires `--flex-auth-source` and compiles its actual Go
|
||||
evaluator. It no longer substitutes a decision double. Thirteen checks cover
|
||||
signed issuer/claim/consume, registry precedence, changed submission replay,
|
||||
wrong action, same/different consume digest and actual dual-control policy.
|
||||
See `docs/evidence/2026-09-09-replay-contract-exercise.json`. Earlier nine-check
|
||||
PDP-double evidence remains historical. Live custody/deployment gates still apply.
|
||||
|
|
|
|||
47
docs/evidence/2026-09-09-replay-contract-exercise.json
Normal file
47
docs/evidence/2026-09-09-replay-contract-exercise.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"target": "disposable local processes; synthetic credentials",
|
||||
"started_at": "2026-09-09T06:38:34.485308+00:00",
|
||||
"keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611",
|
||||
"approval_engine_commit": "b46b0f26669dc83c944ee5145426bad03d5ef720",
|
||||
"flex_auth_commit": "88b354377c8e26b162f1234e673072f1c06dcd89",
|
||||
"keycape_contract_commit": "0f5535eed95f1223c83a28f5a0bd6fa594cecae8",
|
||||
"consumer_source_sha256": {
|
||||
"approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5",
|
||||
"approval_consume.py": "9ccc6997fd4c1c82af8cf3dd440daf7b695d6272bb302eb083b73a2fe3674345",
|
||||
"approval_claim.py": "54dd469e4e3ac9472c4e21222d5a08f70723a63f01904a35922fcc0a07a41541",
|
||||
"authorization.py": "75c472f30cce1c3d66be1ded1eb6dcc62684668b0da5bf83d5fcdd37e92d1935",
|
||||
"config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955",
|
||||
"service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c",
|
||||
"cli.py": "a789fcea59e9a03d7ef07bf3ec1baf58234058585149511333c3fb7570f5916a"
|
||||
},
|
||||
"limitations": [
|
||||
"standalone Flex Auth source, not deployed pin",
|
||||
"local Approval Engine source, not deployed image",
|
||||
"no live custody or client-side read grant",
|
||||
"no OpenBao effect or model execution"
|
||||
],
|
||||
"checks": {
|
||||
"operator_issued_and_approved_via_verified_jwt": true,
|
||||
"wrong_action_refused_before_consume": true,
|
||||
"producer_origin_join_with_carried_claim": true,
|
||||
"actual_consumer_claim_check_consume": true,
|
||||
"same_digest_retry_idempotent": true,
|
||||
"different_digest_refused": true,
|
||||
"spent_claim_refused": true,
|
||||
"operator_consume_scope_denied_by_issuer": true,
|
||||
"wrong_secret_refused": true,
|
||||
"no_access_token_file_created": true,
|
||||
"registry_override_accepts_exact_submission": true,
|
||||
"same_enriched_result_different_submission_refused": true,
|
||||
"real_dual_control_denial_then_claim_check_consume": true
|
||||
},
|
||||
"flex_auth_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa",
|
||||
"producer_input_sha256": {
|
||||
"policy_package.md": "657fa9312c9dcabee059ec31c89ee13396fe9aa54d852edc9e6559a74bbc139a",
|
||||
"registry_snapshot.json": "37fba44ec0e0fd9b1e17a05ad1b7e4f9bb6ec9e0fc0bb3133e8af2ac6f832d7c"
|
||||
},
|
||||
"status": "passed",
|
||||
"cleanup_complete": true,
|
||||
"finished_at": "2026-09-09T06:38:44.956593+00:00"
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -97,21 +98,13 @@ def _parse_time(value: object, name: str) -> datetime:
|
|||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def validate_approval_claim(
|
||||
def _validate_observation(
|
||||
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.
|
||||
"""
|
||||
"""Validate issuer, shape, validity and freshness; no action correspondence."""
|
||||
if not isinstance(claim, dict):
|
||||
raise DecisionError("approval claim must be an object")
|
||||
if claim.get("schema_version") != SCHEMA_VERSION:
|
||||
|
|
@ -142,42 +135,6 @@ def validate_approval_claim(
|
|||
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: it is expressed in flex-auth's vocabulary, which is
|
||||
# the one the caller actually proposed. The native digest is over
|
||||
# approval-engine's own vocabulary and is only comparable when the caller
|
||||
# supplies a binding built in that vocabulary (see the note in
|
||||
# resolve_consume_binding about the missing mapping).
|
||||
if expected_pdp_digest:
|
||||
# GH-DEC-2026-008 / approval-engine schema v3: pdp_path is the issuer's
|
||||
# DECLARATION that this approval was requested against a bound
|
||||
# CheckRequest, and it guarantees pdp_digest is non-null. Path intent is
|
||||
# never inferred from a pdp_digest that merely happens to be present --
|
||||
# a digest recorded for some other reason is not a declaration anybody
|
||||
# made, and approvals issued before schema v3 carry pdp_path false
|
||||
# regardless of any digest they hold.
|
||||
if binding.get("pdp_path") is not True:
|
||||
raise DecisionError(
|
||||
"approval claim does not declare binding.pdp_path; it was not "
|
||||
"issued against a bound CheckRequest and cannot authorize this "
|
||||
"action (GH-DEC-2026-008). Request an approval bound at issue."
|
||||
)
|
||||
if not pdp:
|
||||
raise DecisionError(
|
||||
"approval claim declares pdp_path but records no pdp_digest, and "
|
||||
"no published mapping exists between approval-engine and "
|
||||
"secrets-engine action/target vocabularies; the claim cannot be "
|
||||
"tied to this exact action"
|
||||
)
|
||||
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):
|
||||
|
|
@ -194,3 +151,41 @@ def validate_approval_claim(
|
|||
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
|
||||
raise DecisionError("approval claim is not yet valid")
|
||||
return claim
|
||||
|
||||
|
||||
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Validate a fresh fact for submission to the PDP, not authority to consume.
|
||||
|
||||
Action correspondence cannot be established until the evaluator returns its
|
||||
enriched approval binding. This function deliberately makes no such claim.
|
||||
"""
|
||||
observed = _validate_observation(claim, approval_id=approval_id, now=now)
|
||||
binding = observed["binding"]
|
||||
if binding.get("pdp_path") is not True:
|
||||
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
|
||||
pdp = binding.get("pdp_digest")
|
||||
if not isinstance(pdp, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", pdp):
|
||||
raise DecisionError("approval claim records no canonical pdp_digest; no published mapping permits a native fallback")
|
||||
return observed
|
||||
|
||||
|
||||
def validate_approval_claim(claim: object, *, approval_id: str,
|
||||
expected_binding_digest: str = "", expected_pdp_digest: str = "",
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Validate the fact and compare an independently supplied binding.
|
||||
|
||||
A PDP digest supplied here must come from the evaluated decision, never a
|
||||
local reconstruction of the unenriched request.
|
||||
"""
|
||||
observed = _validate_observation(claim, approval_id=approval_id, now=now)
|
||||
if expected_pdp_digest:
|
||||
observe_pdp_approval_claim(observed, approval_id=approval_id, now=now)
|
||||
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
|
||||
raise DecisionError("approval claim pdp digest does not match the request")
|
||||
elif expected_binding_digest:
|
||||
if observed["binding"].get("digest") != expected_binding_digest:
|
||||
raise DecisionError("approval claim binding digest does not match the request")
|
||||
else:
|
||||
raise DecisionError("approval claim comparison requires an expected digest")
|
||||
return observed
|
||||
|
|
|
|||
|
|
@ -12,18 +12,17 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
||||
from secrets_engine.approval_claim import validate_approval_claim
|
||||
from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim
|
||||
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
|
||||
|
|
@ -43,6 +42,15 @@ class ConsumeBinding:
|
|||
decision_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalObservation:
|
||||
"""Fresh approval input awaiting PDP correspondence; never a consume binding."""
|
||||
|
||||
approval_id: str
|
||||
request: dict[str, Any] = field(repr=False)
|
||||
claim: dict[str, Any] = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizedAction:
|
||||
"""A validated claim and decision for one exact proposed action.
|
||||
|
|
@ -201,7 +209,7 @@ def fetch_approval_claim(
|
|||
return payload
|
||||
|
||||
|
||||
def resolve_consume_binding(
|
||||
def resolve_approval_observation(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
|
|
@ -211,13 +219,11 @@ def resolve_consume_binding(
|
|||
policy_targets: tuple[str, ...] = (),
|
||||
auth_targets: tuple[str, ...] = (),
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> ConsumeBinding | None:
|
||||
"""Join the proposed action to a served approval-claim (step 1).
|
||||
) -> ApprovalObservation | None:
|
||||
"""Observe a fresh fact and prepare the claim-bearing request (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.
|
||||
Missing serving coordinates return None, preserving the production refusal.
|
||||
Correspondence and a consume binding require authorize_action's PDP step.
|
||||
"""
|
||||
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||
auth_configured = approval_auth_configured(cfg)
|
||||
|
|
@ -230,42 +236,15 @@ def resolve_consume_binding(
|
|||
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.
|
||||
#
|
||||
# Only the PDP digest is usable for the action/target correspondence today.
|
||||
# The claim's binding.action and binding.target speak approval-engine's
|
||||
# vocabulary ("secrets.kv.destroy", {"id": ..., "stage": ...}) while ours
|
||||
# speaks the catalog's ("destroy", "catalog:<id>"), and no mapping between
|
||||
# them is published. flex-auth makes no cross-check either and states the
|
||||
# correspondence is ours, via pdp_digest. Computing a native digest from our
|
||||
# own vocabulary would compare two different languages and never match --
|
||||
# the same unsatisfiable-rule defect flex-auth fixed in 68ad039 -- so we do
|
||||
# not compute one, and validate_approval_claim fails closed with a named
|
||||
# reason when the issuer recorded no pdp_digest.
|
||||
pdp_digest = request_digest(expected_request)
|
||||
|
||||
claim = fetch_approval_claim(
|
||||
base_url=base_url,
|
||||
token_provider=lambda: approval_token(cfg, scope="approval:read"),
|
||||
authorization_id=authorization_id,
|
||||
opener=opener or credential_urlopen,
|
||||
)
|
||||
validate_approval_claim(
|
||||
claim,
|
||||
approval_id=authorization_id,
|
||||
expected_pdp_digest=pdp_digest,
|
||||
)
|
||||
# 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,
|
||||
decision_id="",
|
||||
)
|
||||
observe_pdp_approval_claim(claim, approval_id=authorization_id)
|
||||
expected_request["context"]["approval"] = claim
|
||||
return ApprovalObservation(authorization_id, expected_request, claim)
|
||||
|
||||
|
||||
def authorize_action(
|
||||
|
|
@ -287,14 +266,14 @@ def authorize_action(
|
|||
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(
|
||||
observation = resolve_approval_observation(
|
||||
cfg, entry, action, decision,
|
||||
fields=fields,
|
||||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
opener=opener,
|
||||
)
|
||||
if binding is None:
|
||||
if observation is None:
|
||||
return None
|
||||
|
||||
pdp_url = str(getattr(cfg, "pdp_url", "") or "")
|
||||
|
|
@ -313,10 +292,7 @@ def authorize_action(
|
|||
"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,
|
||||
)
|
||||
expected_request = observation.request
|
||||
envelope = check_decision(
|
||||
base_url=pdp_url,
|
||||
token_file=Path(pdp_token),
|
||||
|
|
@ -328,17 +304,19 @@ def authorize_action(
|
|||
expected_request,
|
||||
accepted_policy_packages={package},
|
||||
accepted_policy_versions={version},
|
||||
# The claim's pdp_digest, established in step 1. If this request carried
|
||||
# the claim in context, the decision must name the same claim-free
|
||||
# envelope in binding.approval_binding_digest (FLEX-DEC-2026-007).
|
||||
expected_approval_binding_digest=binding.request_digest,
|
||||
expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"],
|
||||
)
|
||||
# Recheck freshness after the network call before issuing a consume binding.
|
||||
validate_approval_claim(
|
||||
observation.claim, approval_id=observation.approval_id,
|
||||
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
|
||||
)
|
||||
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,
|
||||
approval_id=observation.approval_id,
|
||||
request_digest=validated.request_digest,
|
||||
decision_id=validated.decision_id,
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Fail-closed consumer validation for flex-auth action authorizations.
|
||||
|
||||
The canonical contract is flex-auth revision c473f19. State Hub does not yet
|
||||
provide the durable authoritative endpoint, so this module validates supplied
|
||||
objects but does not resolve or enable production actions by itself.
|
||||
Replay identity follows FLEX-DEC-2026-012. The authenticated evaluator owns
|
||||
registry enrichment; the consumer binds its exact submitted request and checks
|
||||
policy, lifetime and approval correspondence before consumption.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -50,6 +50,8 @@ class ValidatedDecision:
|
|||
action: str
|
||||
subject_id: str
|
||||
expires_at: str
|
||||
request_digest: str
|
||||
submitted_request_digest: str
|
||||
|
||||
|
||||
def build_action_request(
|
||||
|
|
@ -272,158 +274,30 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _check_approval_binding_digest(
|
||||
binding: dict[str, Any],
|
||||
expected: dict[str, Any],
|
||||
expected_digest: str,
|
||||
binding: dict[str, Any], expected: dict[str, Any], expected_digest: str,
|
||||
) -> None:
|
||||
"""Tie an approval claim to this exact request (FLEX-DEC-2026-007).
|
||||
|
||||
``binding.approval_binding_digest`` is the canonical request digest computed
|
||||
with ``context.approval`` removed, and it appears only when the request
|
||||
carried a claim there. It is the ONLY sound comparand for a claim's
|
||||
``pdp_digest``: a digest recorded at issue time can never equal the
|
||||
``request_digest`` of the request that carries the claim, because the claim
|
||||
is part of the hashed material. Comparing against ``request_digest`` fails
|
||||
closed forever; comparing against nothing fails open.
|
||||
|
||||
It is deliberately NOT a replay identity -- two requests differing only in
|
||||
which approval was presented share it while their decisions differ -- so it
|
||||
is checked here in addition to ``request_digest``, never instead of it.
|
||||
|
||||
When our own request is claim-free the field is absent by contract, and the
|
||||
identity already holds transitively: step 1 compared the claim's pdp_digest
|
||||
to this same canonical digest of the claim-free request.
|
||||
"""
|
||||
"""Compare evaluator-origin values; never reproduce registry enrichment."""
|
||||
context = expected.get("context", {})
|
||||
carried = APPROVAL_CONTEXT_KEY in context
|
||||
present = binding.get("approval_binding_digest")
|
||||
if present is not None:
|
||||
if not isinstance(present, str) or not DIGEST_RE.fullmatch(present):
|
||||
raise DecisionError("flex-auth approval binding digest is malformed")
|
||||
if present != approval_binding_digest(expected):
|
||||
raise DecisionError(
|
||||
"flex-auth approval binding digest does not match this request "
|
||||
"with the approval claim removed"
|
||||
)
|
||||
if expected_digest:
|
||||
if present is None:
|
||||
context = expected.get("context")
|
||||
if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context:
|
||||
# Claim-free request: no approval_binding_digest is emitted and
|
||||
# step 1 already bound the claim to this canonical digest.
|
||||
return
|
||||
raise DecisionError(
|
||||
"request carried an approval claim but the decision records no "
|
||||
"approval_binding_digest; the claim cannot be tied to it"
|
||||
)
|
||||
if present != expected_digest:
|
||||
raise DecisionError(
|
||||
"approval claim pdp digest does not match the decision's "
|
||||
"approval binding digest"
|
||||
)
|
||||
|
||||
|
||||
#: Fields the evaluator's documented normalization may add to a ref, per
|
||||
#: canonical-request-digest.md "Normalization": the request tenant is copied onto
|
||||
#: subject and resource when they omit it, and a registry hit copies type,
|
||||
#: tenant, and selected attributes onto the refs the digest sees.
|
||||
_ENRICHABLE_REF_FIELDS = ("type", "tenant", "attributes")
|
||||
|
||||
|
||||
def _require_ref_corresponds(bound: Any, sent: dict[str, Any], tenant: str, name: str) -> None:
|
||||
"""The bound ref must be our ref, plus only documented enrichment."""
|
||||
if not isinstance(bound, dict):
|
||||
raise DecisionError(f"flex-auth decision binding {name} must be an object")
|
||||
for key, value in sent.items():
|
||||
if key == "attributes":
|
||||
continue
|
||||
if bound.get(key) != value:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.{key} does not match the "
|
||||
"proposed action"
|
||||
)
|
||||
for key in bound:
|
||||
if key not in sent and key not in _ENRICHABLE_REF_FIELDS:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name} carries unexpected field "
|
||||
f"'{key}'; enrichment may add only {_ENRICHABLE_REF_FIELDS}"
|
||||
)
|
||||
# Normalization rule 1: an enriched tenant is the REQUEST's tenant. A ref
|
||||
# bearing some other tenant would be a cross-tenant binding wearing our
|
||||
# request's clothes.
|
||||
bound_tenant = bound.get("tenant")
|
||||
if bound_tenant is not None and tenant and bound_tenant != tenant:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.tenant '{bound_tenant}' is not "
|
||||
f"the request tenant '{tenant}'"
|
||||
)
|
||||
sent_attributes = sent.get("attributes")
|
||||
if isinstance(sent_attributes, dict):
|
||||
bound_attributes = bound.get("attributes")
|
||||
if not isinstance(bound_attributes, dict):
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.attributes must be an object"
|
||||
)
|
||||
# Every attribute we proposed must survive unchanged. The registry may
|
||||
# add its own; it may not restate ours differently. stage, fields,
|
||||
# policy_targets and auth_targets are security-bearing and are ours.
|
||||
for key, value in sent_attributes.items():
|
||||
if bound_attributes.get(key) != value:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.attributes.{key} does "
|
||||
"not match the proposed action"
|
||||
)
|
||||
|
||||
|
||||
def _require_binding_corresponds(
|
||||
binding: dict[str, Any], expected: dict[str, Any]
|
||||
) -> None:
|
||||
"""Compare structured binding fields to the proposed action.
|
||||
|
||||
This is what canonical-request-digest.md tells a consumer to do, and why it
|
||||
is not a digest comparison: "A consumer that re-hashes the original
|
||||
unenriched request will not match a decision that turned on registry
|
||||
attributes. Compare structured binding fields to the proposed action, and
|
||||
treat request_digest as the evaluator's statement of what it hashed."
|
||||
|
||||
The evaluator normalizes before hashing -- it copies the request tenant onto
|
||||
subject and resource, and a registry hit copies type, tenant and selected
|
||||
attributes onto the refs. Those additions are material we never sent and
|
||||
cannot reproduce, so byte-equality against our own request is unsatisfiable
|
||||
against every real decision.
|
||||
|
||||
What must still hold, and is enforced here: everything we DID propose
|
||||
survives unchanged, enrichment appears only where the contract permits it,
|
||||
and an enriched tenant is our request's tenant rather than another one.
|
||||
"""
|
||||
tenant = str(expected.get("tenant", "") or "")
|
||||
if tenant and binding.get("tenant") != tenant:
|
||||
raise DecisionError("flex-auth decision binding tenant does not match request")
|
||||
if binding.get("action") != expected["action"]:
|
||||
raise DecisionError("flex-auth decision binding action does not match request")
|
||||
if binding.get("context", {}) != expected.get("context", {}):
|
||||
raise DecisionError("flex-auth decision binding context does not match request")
|
||||
_require_ref_corresponds(binding.get("subject"), expected["subject"], tenant, "subject")
|
||||
_require_ref_corresponds(binding.get("resource"), expected["resource"], tenant, "resource")
|
||||
|
||||
|
||||
def binding_tuple(binding: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The normalized tuple the binding carries, for independent re-hashing.
|
||||
|
||||
canonical-request-digest.md: "To recompute independently, hash the same
|
||||
normalized tuple the binding carries (tenant, subject, action, resource,
|
||||
context)."
|
||||
"""
|
||||
tuple_: dict[str, Any] = {}
|
||||
if binding.get("tenant"):
|
||||
tuple_["tenant"] = binding["tenant"]
|
||||
tuple_.update(
|
||||
{
|
||||
"subject": binding.get("subject"),
|
||||
"action": binding.get("action"),
|
||||
"resource": binding.get("resource"),
|
||||
"context": binding.get("context", {}),
|
||||
}
|
||||
)
|
||||
return tuple_
|
||||
if not carried:
|
||||
if present is not None or expected_digest:
|
||||
raise DecisionError("approval binding requires a carried approval claim")
|
||||
return
|
||||
if present is None:
|
||||
raise DecisionError("request carried an approval claim but the decision records no approval_binding_digest")
|
||||
if not isinstance(present, str) or not DIGEST_RE.fullmatch(present):
|
||||
raise DecisionError("flex-auth approval binding digest is malformed")
|
||||
claim = context[APPROVAL_CONTEXT_KEY]
|
||||
claim_binding = claim.get("binding", {}) if isinstance(claim, dict) else {}
|
||||
if not isinstance(claim_binding, dict):
|
||||
raise DecisionError("carried approval claim binding must be an object")
|
||||
recorded = claim_binding.get("pdp_digest")
|
||||
if (claim_binding.get("pdp_path") is not True
|
||||
or not isinstance(recorded, str) or not DIGEST_RE.fullmatch(recorded)):
|
||||
raise DecisionError("carried approval claim requires a canonical pdp_path binding")
|
||||
if present != recorded or (expected_digest and present != expected_digest):
|
||||
raise DecisionError("approval claim pdp digest does not match the decision's approval binding digest")
|
||||
|
||||
|
||||
def validate_decision_envelope(
|
||||
|
|
@ -466,25 +340,15 @@ def validate_decision_envelope(
|
|||
raise DecisionError("flex-auth decision request id does not match request")
|
||||
|
||||
binding = _required_dict(envelope, "binding")
|
||||
_require_binding_corresponds(binding, expected)
|
||||
# The digest is the evaluator's statement of what it hashed, so it is
|
||||
# verified for self-consistency against the tuple the binding carries --
|
||||
# never against our unenriched request, which no real decision can match.
|
||||
bound_tuple = binding_tuple(binding)
|
||||
if binding.get("request_digest") != request_digest(bound_tuple):
|
||||
raise DecisionError(
|
||||
"flex-auth request digest does not match the binding it carries"
|
||||
)
|
||||
_check_approval_binding_digest(binding, bound_tuple, expected_approval_binding_digest)
|
||||
# The envelope's top-level refs are enriched too, so they get the same
|
||||
# correspondence rule rather than byte-equality against what we sent.
|
||||
_tenant = str(expected.get("tenant", "") or "")
|
||||
_require_ref_corresponds(
|
||||
_subject_ref(envelope.get("subject")), expected["subject"], _tenant, "subject"
|
||||
)
|
||||
_require_ref_corresponds(
|
||||
_resource_ref(envelope.get("resource")), expected["resource"], _tenant, "resource"
|
||||
)
|
||||
submitted = binding.get("submitted_request_digest")
|
||||
if submitted != request_digest(expected):
|
||||
raise DecisionError("flex-auth submitted request digest is missing or does not match the proposed action")
|
||||
# Keep the evaluator's enriched digest as audit/consume evidence. It is not
|
||||
# locally computable, and registry facts may override caller attributes.
|
||||
evaluated = binding.get("request_digest")
|
||||
if not isinstance(evaluated, str) or not DIGEST_RE.fullmatch(evaluated):
|
||||
raise DecisionError("flex-auth evaluated request digest is malformed")
|
||||
_check_approval_binding_digest(binding, expected, expected_approval_binding_digest)
|
||||
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
lifetime = _required_dict(envelope, "lifetime")
|
||||
|
|
@ -506,4 +370,6 @@ def validate_decision_envelope(
|
|||
action=expected["action"],
|
||||
subject_id=expected["subject"]["id"],
|
||||
expires_at=expires.isoformat(),
|
||||
request_digest=evaluated,
|
||||
submitted_request_digest=submitted,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from secrets_engine.apply import apply_plan
|
|||
from secrets_engine.approval_consume import (
|
||||
authorize_action,
|
||||
require_production_consume,
|
||||
resolve_consume_binding,
|
||||
)
|
||||
from secrets_engine.catalog import get_entry, load_catalog
|
||||
from secrets_engine.config import Config, repo_root
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from datetime import datetime, timedelta, timezone
|
|||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
from secrets_engine.approval_claim import binding_from_check_request
|
||||
from secrets_engine.authorization import request_digest
|
||||
from secrets_engine.authorization import request_digest, approval_binding_digest
|
||||
|
||||
|
||||
def _now():
|
||||
|
|
@ -152,6 +152,9 @@ class AuthorizationStub:
|
|||
if request.get("context") is not None:
|
||||
binding["context"] = request["context"]
|
||||
binding["request_digest"] = request_digest(request)
|
||||
binding["submitted_request_digest"] = request_digest(request)
|
||||
if "approval" in request.get("context", {}):
|
||||
binding["approval_binding_digest"] = approval_binding_digest(request)
|
||||
return {
|
||||
"id": "decision:stub-" + request["action"],
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
|
|
|
|||
66
tests/fixtures/flex-auth-replay/PROVENANCE.md
vendored
66
tests/fixtures/flex-auth-replay/PROVENANCE.md
vendored
|
|
@ -1,52 +1,22 @@
|
|||
# flex-auth T03 replay fixtures
|
||||
# Flex Auth replay contract fixtures
|
||||
|
||||
Copied verbatim from `flex-auth/examples/secrets-engine/replay/` (commit
|
||||
`9e10d1c`, `FLEX-WP-0021-T03`). Real `DecisionEnvelope`s emitted by the
|
||||
published `secrets-engine.catalog-lane.lifecycle` v1 package via
|
||||
`go run ./cmd/flex-auth check`, from `flex-auth/local` in `standalone` mode.
|
||||
Copied byte-for-byte on 2026-09-09 from flex-auth revision
|
||||
`88b354377c8e26b162f1234e673072f1c06dcd89`, examples/secrets-engine/ and its
|
||||
replay/ directory. Contract: FLEX-DEC-2026-012. These are standalone evaluator
|
||||
outputs, not deployment evidence.
|
||||
|
||||
Vendored so the digest contract test is hermetic. Regenerate upstream and
|
||||
re-copy if the contract version changes.
|
||||
Each output is paired with its independently supplied check_request input.
|
||||
Tests compute submitted_request_digest from that input; they do not reconstruct
|
||||
it from the enriched response. Existing request_digest, approval_binding_digest,
|
||||
policy-package and registry-snapshot pins are unchanged. Policy remains v2.
|
||||
Approval correspondence compares claim.binding.pdp_digest with the evaluator's
|
||||
approval_binding_digest. Neither is computed from the unenriched request.
|
||||
|
||||
Re-copied 2026-09-06 (twice, both upstream regenerations):
|
||||
Tests inject the actual decision time instead of modifying captured lifetimes.
|
||||
The older flex-auth-live fixture remains untouched and must fail the new
|
||||
contract because it has no submitted_request_digest.
|
||||
|
||||
1. commit `9f3e7e3` completed the approval-claim on `context.approval`. Because
|
||||
`context` is hashed material, completing the claim moved the request digest.
|
||||
2. commit `dd3ce4c` (`FLEX-DEC-2026-007`) published
|
||||
`binding.approval_binding_digest` and set the embedded claim's
|
||||
`binding.pdp_digest` to it with `binding.pdp_path` true. The request digest
|
||||
moved once more with the claim's contents; the approval-binding digest did
|
||||
**not**, which is the property the fixture now demonstrates rather than
|
||||
asserts.
|
||||
|
||||
3. commit `d98323b` published **v2**, which adds the `input.tenant` rule v1
|
||||
never had, and a third fixture: `decision_wrong_tenant_deny.json`. The
|
||||
request digests did **not** move — every allow fixture already carried
|
||||
`tenant: tenant:platform` — but `provenance.policy_version` is now `v2` and
|
||||
`policy_package_digest` moved to `sha256:bd11c5fe…`.
|
||||
|
||||
`decision_rotate.json` carries no `approval_binding_digest` — the field is
|
||||
omitted on claim-free decisions rather than duplicated onto them, and a test
|
||||
pins that omission.
|
||||
|
||||
`decision_wrong_tenant_deny.json` is an `effect: deny` envelope and carries no
|
||||
`lifetime`, which is legal: the schema requires `lifetime` only for an allow.
|
||||
It is the wrong-tenant denial evidence `GLAS-WP-0015` asked for. Do not
|
||||
lifetime-refresh it in tests.
|
||||
|
||||
**v1 must not be pinned.** It had no tenant rule and failed open; flex-auth
|
||||
superseded rather than amended it so the change is visible in the version
|
||||
string. See `docs/tenant-alignment.md`.
|
||||
|
||||
**Pinned here** (stable across runs, per the upstream README):
|
||||
`binding.request_digest`, `binding.approval_binding_digest`,
|
||||
`provenance.policy_package_digest`, `provenance.registry_snapshot_digest`, and
|
||||
the presence/absence of `provenance.input_claim_digests.context`.
|
||||
|
||||
`approval_binding_digest` is not only pinned but **rederived** by
|
||||
`test_pdp_digest_equals_the_published_approval_binding_digest`: our canonical
|
||||
implementation must reproduce it from the fixture's own request. A pin asserts
|
||||
the constant; rederiving it proves we hash the same material flex-auth does.
|
||||
|
||||
**Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`,
|
||||
`lifetime.expires_at` — all move with the clock.
|
||||
`tools/exercise_approval_identity.py` additionally compiles the owner evaluator
|
||||
and runs real KeyCape/Approval Engine/Flex Auth joins with synthetic credentials,
|
||||
including registry override and destructive-action dual control. Its receipt
|
||||
pins source, binary and producer inputs; it makes no live admission claim.
|
||||
|
|
|
|||
54
tests/fixtures/flex-auth-replay/check_request_allow_destroy_dual_control.json
vendored
Normal file
54
tests/fixtures/flex-auth-replay/check_request_allow_destroy_dual_control.json
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "destroy",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "valid",
|
||||
"valid_now": true,
|
||||
"consumed": false,
|
||||
"binding": {
|
||||
"action": "secrets.kv.destroy",
|
||||
"target": {
|
||||
"id": "lane-openbao-root",
|
||||
"stage": "prod"
|
||||
},
|
||||
"actor": "agt-secrets-engine",
|
||||
"principal": "bernd",
|
||||
"purpose": "rotate-exposed-key",
|
||||
"digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
|
||||
"pdp_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56",
|
||||
"pdp_path": true
|
||||
},
|
||||
"freshness": {
|
||||
"observed_at": "2026-09-06T12:00:00+00:00",
|
||||
"ttl_seconds": 30,
|
||||
"not_after": "2026-09-06T12:00:30+00:00"
|
||||
},
|
||||
"validity": {
|
||||
"not_before": "2026-09-06T11:00:00+00:00",
|
||||
"expires_at": "2026-09-06T15:00:00+00:00"
|
||||
},
|
||||
"reason_code": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
tests/fixtures/flex-auth-replay/check_request_allow_rotate.json
vendored
Normal file
23
tests/fixtures/flex-auth-replay/check_request_allow_rotate.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "check:secrets-engine-rotate",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {}
|
||||
}
|
||||
23
tests/fixtures/flex-auth-replay/check_request_deny_wrong_tenant.json
vendored
Normal file
23
tests/fixtures/flex-auth-replay/check_request_deny_wrong_tenant.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "check:secrets-engine-wrong-tenant",
|
||||
"tenant": "tenant:coulomb",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {}
|
||||
}
|
||||
|
|
@ -100,19 +100,21 @@
|
|||
}
|
||||
},
|
||||
"request_digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a",
|
||||
"approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
|
||||
"approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56",
|
||||
"submitted_request_digest": "sha256:c605a9ecd5711d0a1d59e7b29f3a16b53fd09d104dd077766f098e7bc5895435"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-06T18:35:19Z",
|
||||
"expires_at": "2026-09-06T18:50:19Z"
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "destroy",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
|
|
@ -126,7 +128,7 @@
|
|||
"input_claim_digests": {
|
||||
"context": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800"
|
||||
},
|
||||
"decision_time": "2026-09-06T18:35:19Z"
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
|
|
|
|||
|
|
@ -69,19 +69,21 @@
|
|||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-06T18:35:18Z",
|
||||
"expires_at": "2026-09-06T18:50:18Z"
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
|
|
@ -92,7 +94,7 @@
|
|||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-06T18:35:18Z"
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
|
|
|
|||
|
|
@ -69,13 +69,15 @@
|
|||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20"
|
||||
"request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20",
|
||||
"submitted_request_digest": "sha256:9aab6de9069e1e811a52835ca00bc5e9cb38166444df068eb60a26923b1c9175"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
|
|
@ -86,7 +88,7 @@
|
|||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-06T18:35:20Z"
|
||||
"decision_time": "2026-09-07T07:10:24Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ def _envelope(request=None):
|
|||
"resource": copy.deepcopy(request["resource"]),
|
||||
"context": copy.deepcopy(request["context"]),
|
||||
"request_digest": request_digest(request),
|
||||
"submitted_request_digest": request_digest(request),
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "bounded",
|
||||
|
|
@ -135,24 +136,14 @@ def test_state_hub_authority_is_no_longer_required():
|
|||
[
|
||||
(lambda d: d.update(effect="deny"), "effect is not allow"),
|
||||
(lambda d: d.update(effect="audit_only"), "effect is not allow"),
|
||||
(lambda d: d["binding"].update(action="destroy"), "binding action does not match"),
|
||||
(lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64),
|
||||
"request digest does not match"),
|
||||
(lambda d: d["binding"].update(submitted_request_digest="sha256:" + "0" * 64), "submitted request digest"),
|
||||
(lambda d: d["binding"].update(request_digest="malformed"), "evaluated request digest"),
|
||||
(lambda d: d["provenance"].update(policy_package="other.package"),
|
||||
"policy package is not accepted"),
|
||||
(lambda d: d["provenance"].update(policy_version="v1"),
|
||||
"policy version is not accepted"),
|
||||
(lambda d: d.update(contract_version="flex-auth.decision-record.v2"),
|
||||
"contract version"),
|
||||
(lambda d: d["subject"].update(id="user:mallory"),
|
||||
"subject.id does not match"),
|
||||
# Enrichment may add attributes; it may never restate what we sent.
|
||||
(lambda d: d["binding"]["resource"]["attributes"].update(stage="build"),
|
||||
"resource.attributes.stage does not match"),
|
||||
(lambda d: d["binding"]["subject"].update(tenant="tenant:coulomb"),
|
||||
"subject.tenant 'tenant:coulomb' is not the request tenant"),
|
||||
(lambda d: d["binding"]["subject"].update(surprise="x"),
|
||||
"carries unexpected field"),
|
||||
],
|
||||
)
|
||||
def test_invalid_envelopes_fail_closed(mutation, match):
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from secrets_engine.approval_consume import (
|
|||
ConsumeBinding,
|
||||
consume_approval,
|
||||
require_production_consume,
|
||||
resolve_consume_binding,
|
||||
resolve_approval_observation,
|
||||
)
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.config import Config
|
||||
|
|
@ -87,8 +87,8 @@ def _authorized():
|
|||
)
|
||||
|
||||
|
||||
def test_resolve_consume_binding_is_unserved():
|
||||
assert resolve_consume_binding(object(), object(), "apply", None) is None
|
||||
def test_resolve_approval_observation_is_unserved():
|
||||
assert resolve_approval_observation(object(), object(), "apply", None) is None
|
||||
|
||||
|
||||
def test_consume_success_and_same_digest_retry(tmp_path):
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ 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_approval_observation, authorize_action
|
||||
from tests.authorization_stub import AuthorizationStub
|
||||
from secrets_engine.authorization import build_action_request, request_digest
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
|
@ -34,6 +35,8 @@ class _Cfg:
|
|||
self.authorization_subject_type = "Human"
|
||||
self.authorization_policy_package = "secrets-engine.lifecycle"
|
||||
self.authorization_policy_version = "v1"
|
||||
self.pdp_url = "http://127.0.0.1:1234"
|
||||
self.pdp_token_file = token_file
|
||||
self.authorization_min_approvals = 2
|
||||
for k, v in over.items():
|
||||
setattr(self, k, v)
|
||||
|
|
@ -113,19 +116,19 @@ def _opener(envelope, status=200):
|
|||
|
||||
|
||||
def _resolve(cfg, entry, envelope, action="deactivate"):
|
||||
return resolve_consume_binding(
|
||||
cfg, entry, action, None,
|
||||
fields=("api_token",),
|
||||
policy_targets=(entry.policy_name,),
|
||||
auth_targets=(entry.role_name,),
|
||||
opener=_opener(envelope),
|
||||
)
|
||||
pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version)
|
||||
def check(request, timeout=None):
|
||||
submitted = json.loads(request.data)
|
||||
return _opener(pdp.decision(submitted))(request)
|
||||
return authorize_action(cfg, entry, action, None, fields=("api_token",),
|
||||
policy_targets=(entry.policy_name,), auth_targets=(entry.role_name,),
|
||||
opener=_opener(envelope), pdp_opener=check)
|
||||
|
||||
|
||||
def test_unconfigured_serving_path_stays_fail_closed(tmp_path):
|
||||
"""No URL/token/authorization id: None, exactly as before the join existed."""
|
||||
cfg = _Cfg(None, approval_url="", approval_token_file=None)
|
||||
assert resolve_consume_binding(cfg, _entry(), "deactivate", None) is None
|
||||
assert resolve_approval_observation(cfg, _entry(), "deactivate", None) is None
|
||||
|
||||
|
||||
def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
|
||||
|
|
@ -133,8 +136,8 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
|
|||
cfg = _Cfg(_token(tmp_path))
|
||||
binding = _resolve(cfg, entry, _served())
|
||||
assert binding is not None
|
||||
assert binding.approval_id == AUTH_ID
|
||||
assert binding.request_digest == request_digest(_expected_request(entry))
|
||||
assert binding.binding.approval_id == AUTH_ID
|
||||
assert binding.binding.request_digest != request_digest(_expected_request(entry))
|
||||
|
||||
|
||||
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
|
||||
|
|
@ -149,21 +152,13 @@ def test_policy_pin_is_not_enforced_on_the_claim_path(tmp_path):
|
|||
# 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="")
|
||||
assert _resolve(cfg, _entry(), _served()) is not None
|
||||
assert resolve_approval_observation(cfg, _entry(), "deactivate", None, opener=_opener(_served())) is not None
|
||||
|
||||
|
||||
def test_wrong_field_set_fails_closed(tmp_path):
|
||||
"""A different proposed field set must not match the served digest."""
|
||||
entry = _entry()
|
||||
cfg = _Cfg(_token(tmp_path))
|
||||
with pytest.raises(DecisionError):
|
||||
resolve_consume_binding(
|
||||
cfg, entry, "deactivate", None,
|
||||
fields=("some_other_field",),
|
||||
policy_targets=(entry.policy_name,),
|
||||
auth_targets=(entry.role_name,),
|
||||
opener=_opener(_served()),
|
||||
)
|
||||
claim = _served(fields=("other_field",))
|
||||
with pytest.raises(DecisionError, match="pdp digest does not match"):
|
||||
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
|
||||
|
||||
|
||||
def test_action_mismatch_fails_closed(tmp_path):
|
||||
|
|
@ -181,7 +176,7 @@ def test_unreachable_approval_engine_fails_closed(tmp_path):
|
|||
raise URLError("no route")
|
||||
|
||||
with pytest.raises(DecisionError, match="unreachable"):
|
||||
resolve_consume_binding(
|
||||
resolve_approval_observation(
|
||||
_Cfg(_token(tmp_path)), _entry(), "deactivate", None,
|
||||
fields=("api_token",), opener=_boom,
|
||||
)
|
||||
|
|
@ -236,3 +231,24 @@ def test_wrong_pdp_digest_fails_closed(tmp_path):
|
|||
claim["binding"]["pdp_digest"] = "sha256:" + "c" * 64
|
||||
with pytest.raises(DecisionError, match="pdp digest does not match"):
|
||||
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
|
||||
|
||||
|
||||
def test_observation_is_not_a_consume_binding(tmp_path):
|
||||
claim = _served()
|
||||
claim["binding"]["pdp_digest"] = "sha256:" + "e" * 64
|
||||
observation = resolve_approval_observation(_Cfg(_token(tmp_path)), _entry(), "deactivate", None,
|
||||
opener=_opener(claim))
|
||||
assert observation.claim == claim
|
||||
assert observation.request["context"]["approval"] == claim
|
||||
assert not hasattr(observation, "request_digest")
|
||||
assert not hasattr(observation, "decision_id")
|
||||
|
||||
|
||||
def test_claim_that_expires_during_check_cannot_yield_consume_binding(tmp_path, monkeypatch):
|
||||
import secrets_engine.approval_consume as consumer
|
||||
from secrets_engine.approval_claim import validate_approval_claim
|
||||
def after_check(*args, **kwargs):
|
||||
return validate_approval_claim(*args, **kwargs, now=datetime.now(timezone.utc) + timedelta(seconds=31))
|
||||
monkeypatch.setattr(consumer, "validate_approval_claim", after_check)
|
||||
with pytest.raises(DecisionError, match="observation is stale"):
|
||||
_resolve(_Cfg(_token(tmp_path)), _entry(), _served())
|
||||
|
|
|
|||
|
|
@ -1,438 +1,133 @@
|
|||
"""Digest join verified against real flex-auth DecisionEnvelopes (FLEX-WP-0021-T03).
|
||||
|
||||
These replace a hand-maintained pin that was computed *including* the request
|
||||
`id`. docs/canonical-request-digest.md excludes `id`, `policy_version` and
|
||||
`caring_context` from the hashed material, and both real envelopes confirm it:
|
||||
a digest computed over the old material matches no issued decision, which would
|
||||
have failed closed against every correct allow.
|
||||
|
||||
Both fixtures are required. `provenance.input_claim_digests.context` appears
|
||||
only when the request carries a non-empty context, so a validator asserting it
|
||||
is always present passes `destroy` and fails `rotate`.
|
||||
"""
|
||||
"""FLEX-DEC-2026-012 against independent producer request/output fixtures."""
|
||||
import copy
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from secrets_engine.authorization import (
|
||||
REQUEST_TENANT,
|
||||
approval_binding_digest,
|
||||
build_action_request,
|
||||
digest_material,
|
||||
digest_material,
|
||||
request_digest,
|
||||
validate_decision_envelope,
|
||||
)
|
||||
from secrets_engine.decision_check import (
|
||||
check_decision,
|
||||
require_supported_pdp_address,
|
||||
)
|
||||
from secrets_engine.authorization import REQUEST_TENANT, build_action_request, request_digest, validate_decision_envelope
|
||||
from secrets_engine.decision_check import check_decision, require_supported_pdp_address
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay"
|
||||
|
||||
PACKAGE_DIGEST = "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4"
|
||||
SNAPSHOT_DIGEST = "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb"
|
||||
|
||||
CASES = {
|
||||
"rotate": {
|
||||
"file": "decision_rotate.json",
|
||||
"digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"action": "rotate",
|
||||
"context_claim_digest": None,
|
||||
},
|
||||
"destroy": {
|
||||
"file": "decision_destroy_dual_control.json",
|
||||
"digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a",
|
||||
"action": "destroy",
|
||||
"context_claim_digest": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800",
|
||||
},
|
||||
"rotate": ("check_request_allow_rotate.json", "decision_rotate.json", "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"),
|
||||
"destroy": ("check_request_allow_destroy_dual_control.json", "decision_destroy_dual_control.json", "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a"),
|
||||
"deny": ("check_request_deny_wrong_tenant.json", "decision_wrong_tenant_deny.json", None),
|
||||
}
|
||||
|
||||
|
||||
def _envelope(name):
|
||||
return json.loads((FIXTURES / CASES[name]["file"]).read_text())
|
||||
def pair(name):
|
||||
inp, out, _ = CASES[name]
|
||||
return json.loads((FIXTURES / inp).read_text()), json.loads((FIXTURES / out).read_text())
|
||||
|
||||
|
||||
def _request_from(envelope):
|
||||
"""Rebuild the normalized tuple the binding carries.
|
||||
|
||||
Per the contract, a consumer re-hashing the *original unenriched* request
|
||||
will not match a decision that turned on registry attributes; the binding is
|
||||
the evaluator's statement of what it hashed.
|
||||
"""
|
||||
binding = envelope["binding"]
|
||||
request = {"id": envelope["request_id"]}
|
||||
for key in ("tenant", "subject", "action", "resource"):
|
||||
if binding.get(key) is not None:
|
||||
request[key] = binding[key]
|
||||
if binding.get("context") is not None:
|
||||
request["context"] = binding["context"]
|
||||
return request
|
||||
def validate(request, decision, **over):
|
||||
# Validate at the actual producer decision time; do not rewrite evidence.
|
||||
now = datetime.fromisoformat(decision["provenance"]["decision_time"].replace("Z", "+00:00"))
|
||||
return validate_decision_envelope(decision, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, now=now, **over)
|
||||
|
||||
|
||||
def _refresh_lifetime(envelope):
|
||||
"""Lifetime moves with the clock and must never be pinned."""
|
||||
now = datetime.now(timezone.utc)
|
||||
envelope["lifetime"]["not_before"] = (now - timedelta(minutes=1)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
envelope["lifetime"]["expires_at"] = (now + timedelta(minutes=14)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
return envelope
|
||||
@pytest.mark.parametrize("name", ["rotate", "destroy"])
|
||||
def test_real_producer_request_validates(name):
|
||||
request, decision = pair(name)
|
||||
assert request_digest(request) == decision["binding"]["submitted_request_digest"]
|
||||
assert request_digest(request) != decision["binding"]["request_digest"]
|
||||
assert decision["binding"]["request_digest"] == CASES[name][2]
|
||||
assert decision["provenance"]["registry_snapshot_digest"] == "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb"
|
||||
assert decision["provenance"]["policy_package_digest"] == "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4"
|
||||
assert validate(request, decision).action == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_request_digest_matches_the_issued_decision(name):
|
||||
envelope = _envelope(name)
|
||||
expected = CASES[name]["digest"]
|
||||
assert envelope["binding"]["request_digest"] == expected, "fixture drifted"
|
||||
assert request_digest(_request_from(envelope)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_correlation_fields_are_not_hashed(name):
|
||||
"""id, policy_version and caring_context must not move the digest."""
|
||||
request = _request_from(envelope := _envelope(name))
|
||||
@pytest.mark.parametrize("name", ["rotate", "destroy"])
|
||||
def test_correlation_fields_are_excluded(name):
|
||||
request, _ = pair(name)
|
||||
baseline = request_digest(request)
|
||||
assert baseline == envelope["binding"]["request_digest"]
|
||||
for field, value in (
|
||||
("id", "check:some-other-correlation-id"),
|
||||
("policy_version", "v99"),
|
||||
("caring_context", {"anything": "here"}),
|
||||
):
|
||||
assert request_digest({**request, field: value}) == baseline, field
|
||||
stripped = {k: v for k, v in request.items() if k != "id"}
|
||||
assert request_digest(stripped) == baseline
|
||||
for key, value in (("id", "other"), ("policy_version", "v99"), ("caring_context", {"extra": "context"})):
|
||||
assert request_digest({**request, key: value}) == baseline
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_digest_material_is_exactly_the_published_tuple(name):
|
||||
material = digest_material(_request_from(_envelope(name)))
|
||||
assert set(material) <= {"tenant", "subject", "action", "resource", "context"}
|
||||
assert "id" not in material
|
||||
def test_approval_join_is_between_evaluator_origin_values():
|
||||
request, decision = pair("destroy")
|
||||
pdp = request["context"]["approval"]["binding"]["pdp_digest"]
|
||||
assert pdp == decision["binding"]["approval_binding_digest"] == "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
|
||||
without = copy.deepcopy(request)
|
||||
del without["context"]["approval"]
|
||||
assert request_digest(without) != pdp
|
||||
assert validate(request, decision, expected_approval_binding_digest=pdp).action == "destroy"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_real_envelope_validates_against_the_published_package(name):
|
||||
envelope = _refresh_lifetime(_envelope(name))
|
||||
result = validate_decision_envelope(
|
||||
envelope,
|
||||
_request_from(envelope),
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
)
|
||||
assert result.action == CASES[name]["action"]
|
||||
assert result.subject_id == "secrets-engine"
|
||||
@pytest.mark.parametrize("change", ["claim_id", "claim_absent", "purpose", "action", "tenant", "field", "target"])
|
||||
def test_allow_cannot_be_replayed_for_changed_submitted_material(change):
|
||||
request, decision = pair("destroy")
|
||||
if change == "claim_id":
|
||||
request["context"]["approval"]["approval_id"] = "other-approval"
|
||||
elif change == "claim_absent":
|
||||
del request["context"]["approval"]
|
||||
elif change == "purpose":
|
||||
request["context"]["purpose"] = "other-purpose"
|
||||
elif change in ("action", "tenant"):
|
||||
request[change] = "other"
|
||||
elif change == "field":
|
||||
request["resource"]["attributes"]["fields"] = ["other"]
|
||||
else:
|
||||
request["resource"]["id"] = "catalog:other"
|
||||
with pytest.raises(DecisionError, match="submitted request digest"):
|
||||
validate(request, decision)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_provenance_digests_are_pinned(name):
|
||||
provenance = _envelope(name)["provenance"]
|
||||
assert provenance["policy_package_digest"] == PACKAGE_DIGEST
|
||||
assert provenance["registry_snapshot_digest"] == SNAPSHOT_DIGEST
|
||||
assert provenance["evaluator"] == "flex-auth/local"
|
||||
assert provenance["mode"] == "standalone"
|
||||
@pytest.mark.parametrize("value", [None, "", "sha256:" + "f" * 64])
|
||||
def test_missing_or_wrong_submitted_binding_refused(value):
|
||||
request, decision = pair("rotate")
|
||||
if value is None:
|
||||
del decision["binding"]["submitted_request_digest"]
|
||||
else:
|
||||
decision["binding"]["submitted_request_digest"] = value
|
||||
with pytest.raises(DecisionError, match="submitted request digest"):
|
||||
validate(request, decision)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_input_claim_digest_is_present_only_with_a_context(name):
|
||||
"""The reason two fixtures exist: this field is conditional."""
|
||||
provenance = _envelope(name)["provenance"]
|
||||
expected = CASES[name]["context_claim_digest"]
|
||||
actual = (provenance.get("input_claim_digests") or {}).get("context")
|
||||
assert actual == expected
|
||||
@pytest.mark.parametrize("value", [None, "bad", "sha256:" + "f" * 64])
|
||||
def test_approval_binding_missing_malformed_or_wrong_refused(value):
|
||||
request, decision = pair("destroy")
|
||||
decision["binding"]["approval_binding_digest"] = value
|
||||
with pytest.raises(DecisionError, match="approval[_ ]binding_digest|approval binding digest"):
|
||||
validate(request, decision)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CASES))
|
||||
def test_a_tampered_binding_field_breaks_the_digest(name):
|
||||
envelope = _envelope(name)
|
||||
request = _request_from(envelope)
|
||||
request["action"] = "handoff"
|
||||
assert request_digest(request) != envelope["binding"]["request_digest"]
|
||||
def test_claim_free_shortcut_refused():
|
||||
request, decision = pair("rotate")
|
||||
with pytest.raises(DecisionError, match="carried approval"):
|
||||
validate(request, decision, expected_approval_binding_digest=decision["binding"]["request_digest"])
|
||||
|
||||
|
||||
def test_expired_real_envelope_fails_closed():
|
||||
"""The shipped lifetime is 15m from allow_ttl and has long since passed."""
|
||||
with pytest.raises(DecisionError, match="lifetime has expired"):
|
||||
envelope = _envelope("rotate")
|
||||
validate_decision_envelope(
|
||||
envelope,
|
||||
_request_from(envelope),
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
)
|
||||
|
||||
|
||||
def test_embedded_claim_uses_approval_engine_vocabulary_not_ours():
|
||||
"""The published vocabulary gap, asserted rather than described.
|
||||
|
||||
The dual-control request carries a complete approval-claim in
|
||||
context.approval. Its binding speaks approval-engine's language while the
|
||||
decision speaks ours, and no mapping between them is published -- which is
|
||||
why this engine compares pdp_digest and refuses to derive a native digest
|
||||
from its own vocabulary.
|
||||
"""
|
||||
envelope = _envelope("destroy")
|
||||
claim = envelope["binding"]["context"]["approval"]
|
||||
assert claim["kind"] == "approval-claim"
|
||||
assert claim["issuer"] == "approval-engine"
|
||||
assert claim["valid_now"] is True
|
||||
assert claim["binding"]["action"] == "secrets.kv.destroy"
|
||||
assert envelope["binding"]["action"] == "destroy"
|
||||
assert claim["binding"]["action"] != envelope["binding"]["action"]
|
||||
assert set(claim["binding"]["target"]) == {"id", "stage"}
|
||||
assert claim["binding"]["target"]["id"] != envelope["resource"]["id"]
|
||||
|
||||
|
||||
APPROVAL_BINDING_DIGEST = (
|
||||
"sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
|
||||
)
|
||||
|
||||
|
||||
def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest():
|
||||
"""Carrying the claim inside a hashed context makes the two unequal.
|
||||
|
||||
context is part of the digest material, so embedding an approval-claim
|
||||
changes the request digest of the very request that carries it. A
|
||||
pdp_digest recorded at issue time therefore cannot equal the final digest
|
||||
of the dual-control request. This is why comparing pdp_digest against
|
||||
request_digest can never pass and would fail destroy closed forever.
|
||||
"""
|
||||
envelope = _envelope("destroy")
|
||||
binding = envelope["binding"]
|
||||
pdp = binding["context"]["approval"]["binding"]["pdp_digest"]
|
||||
request = _request_from(envelope)
|
||||
assert request_digest(request) == binding["request_digest"]
|
||||
assert pdp != binding["request_digest"]
|
||||
|
||||
|
||||
def test_pdp_digest_equals_the_published_approval_binding_digest():
|
||||
"""FLEX-DEC-2026-007 closed the circularity, and we reproduce the value.
|
||||
|
||||
``approval_binding_digest`` is the canonical digest with context.approval
|
||||
removed. Recomputing it here from our own canonical implementation is the
|
||||
hermetic proof that this engine hashes the same material flex-auth does --
|
||||
a pin alone would only assert the constant, not that we can derive it.
|
||||
"""
|
||||
envelope = _envelope("destroy")
|
||||
binding = envelope["binding"]
|
||||
pdp = binding["context"]["approval"]["binding"]["pdp_digest"]
|
||||
assert binding["approval_binding_digest"] == APPROVAL_BINDING_DIGEST
|
||||
assert pdp == APPROVAL_BINDING_DIGEST
|
||||
|
||||
assert approval_binding_digest(_request_from(envelope)) == APPROVAL_BINDING_DIGEST
|
||||
|
||||
|
||||
def test_approval_binding_digest_is_not_a_replay_identity():
|
||||
"""It must not collapse into request_digest, or an allow becomes replayable.
|
||||
|
||||
Two requests differing only in which approval was presented share an
|
||||
approval_binding_digest while their decisions differ -- one allows, one
|
||||
denies dual_control_required. The fixture asserts the two digests disagree
|
||||
on a claim-bearing request so the distinction stays real.
|
||||
"""
|
||||
envelope = _envelope("destroy")
|
||||
binding = envelope["binding"]
|
||||
assert binding["approval_binding_digest"] != binding["request_digest"]
|
||||
|
||||
|
||||
def test_ordinary_decision_carries_no_approval_binding_digest():
|
||||
"""The field is omitted, not duplicated, on a claim-free decision."""
|
||||
binding = _envelope("rotate")["binding"]
|
||||
assert "approval" not in binding.get("context", {})
|
||||
assert "approval_binding_digest" not in binding
|
||||
|
||||
|
||||
def test_embedded_claim_declares_the_pdp_path():
|
||||
"""pdp_path is the issuer's declaration, and it is what our PEP requires.
|
||||
|
||||
Path intent is never inferred from a pdp_digest that happens to be present;
|
||||
approvals issued before approval-engine schema v3 carry pdp_path false
|
||||
regardless of any digest they hold.
|
||||
"""
|
||||
claim = _envelope("destroy")["binding"]["context"]["approval"]
|
||||
assert claim["binding"]["pdp_path"] is True
|
||||
|
||||
|
||||
def test_decision_validation_ties_the_claim_to_the_approval_binding_digest():
|
||||
"""Step 2 checks the identity against the real envelope, not a local guess.
|
||||
|
||||
The PEP knows the claim's pdp_digest from step 1. When the request it sent
|
||||
carried that claim, the decision must name the same claim-free envelope, or
|
||||
the approval was issued against some other request.
|
||||
"""
|
||||
envelope = _refresh_lifetime(_envelope("destroy"))
|
||||
request = _request_from(envelope)
|
||||
result = validate_decision_envelope(
|
||||
envelope,
|
||||
request,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
expected_approval_binding_digest=APPROVAL_BINDING_DIGEST,
|
||||
)
|
||||
assert result.action == "destroy"
|
||||
|
||||
with pytest.raises(DecisionError, match="approval binding digest"):
|
||||
validate_decision_envelope(
|
||||
envelope,
|
||||
request,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
expected_approval_binding_digest="sha256:" + "c" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_claim_bearing_request_without_a_binding_digest_fails_closed():
|
||||
"""A decision that records no binding digest cannot tie the claim to itself.
|
||||
|
||||
Comparing against request_digest instead would be the fail-open direction
|
||||
the whole field exists to prevent, so the absence is refused outright.
|
||||
"""
|
||||
envelope = _refresh_lifetime(_envelope("destroy"))
|
||||
request = _request_from(envelope)
|
||||
del envelope["binding"]["approval_binding_digest"]
|
||||
with pytest.raises(DecisionError, match="records no approval_binding_digest"):
|
||||
validate_decision_envelope(
|
||||
envelope,
|
||||
request,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
expected_approval_binding_digest=APPROVAL_BINDING_DIGEST,
|
||||
)
|
||||
|
||||
|
||||
def test_a_forged_binding_digest_is_recomputed_not_trusted():
|
||||
"""The field is verified against our own canonical digest, never taken on faith."""
|
||||
envelope = _refresh_lifetime(_envelope("destroy"))
|
||||
request = _request_from(envelope)
|
||||
envelope["binding"]["approval_binding_digest"] = "sha256:" + "d" * 64
|
||||
with pytest.raises(DecisionError, match="does not match this request"):
|
||||
validate_decision_envelope(
|
||||
envelope,
|
||||
request,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
)
|
||||
|
||||
|
||||
# --- tenant scoping (v2) -----------------------------------------------------
|
||||
#
|
||||
# GLAS-WP-0015 asked for the exact CheckRequest tenant and wrong-tenant denial
|
||||
# evidence. These are that evidence, taken from a real deny envelope rather than
|
||||
# asserted, plus the pin that stops our request tenant drifting from the package
|
||||
# it is scoped by.
|
||||
|
||||
WRONG_TENANT_FIXTURE = "decision_wrong_tenant_deny.json"
|
||||
|
||||
|
||||
def test_our_request_tenant_is_the_package_known_tenant():
|
||||
"""The tenant we send must be the one the package allows.
|
||||
|
||||
v2 reads request_tenant := object.get(input, "tenant", "") and allows only
|
||||
known_tenant := "tenant:platform". Pinning our constant against the real
|
||||
allow envelopes means a package retenanting shows up here rather than as a
|
||||
wrong_tenant denial in production.
|
||||
"""
|
||||
for name in ("rotate", "destroy"):
|
||||
assert _envelope(name)["binding"]["tenant"] == REQUEST_TENANT
|
||||
|
||||
|
||||
def test_built_request_carries_the_tenant_and_hashes_it():
|
||||
"""An absent tenant is a denial, not an ignored field, so it must be sent.
|
||||
|
||||
tenant is part of the digest material, so omitting it also produces a digest
|
||||
that matches no correctly issued decision -- the same class of defect as
|
||||
hashing excluded fields, arriving from the other direction.
|
||||
"""
|
||||
entry = SimpleNamespace(id="glas-primary", stage="prod")
|
||||
request = build_action_request(
|
||||
entry,
|
||||
"rotate",
|
||||
subject_id="secrets-engine",
|
||||
subject_type="service",
|
||||
purpose="rotate-exposed-key",
|
||||
fields=["password"],
|
||||
)
|
||||
assert request["tenant"] == REQUEST_TENANT
|
||||
assert "tenant" in digest_material(request)
|
||||
|
||||
untenanted = dict(request)
|
||||
del untenanted["tenant"]
|
||||
assert request_digest(untenanted) != request_digest(request)
|
||||
|
||||
|
||||
def test_build_action_request_refuses_an_empty_tenant():
|
||||
entry = SimpleNamespace(id="glas-primary", stage="prod")
|
||||
with pytest.raises(DecisionError, match="requires a tenant"):
|
||||
build_action_request(
|
||||
entry,
|
||||
"rotate",
|
||||
subject_id="secrets-engine",
|
||||
subject_type="service",
|
||||
purpose="rotate-exposed-key",
|
||||
tenant="",
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_tenant_deny_envelope_fails_closed():
|
||||
"""Wrong-tenant denial evidence, from a real v2 deny envelope.
|
||||
|
||||
flex-auth sent tenant:coulomb on an otherwise-valid rotate and the package
|
||||
denied it with matched_rule wrong_tenant, so the tenant alone carried the
|
||||
denial. Our consumer must refuse it on effect before anything else -- a deny
|
||||
is not a decision we may act on, whatever else it validates.
|
||||
"""
|
||||
# Not lifetime-refreshed: a deny carries no lifetime at all, which is the
|
||||
# property test_wrong_tenant_deny_carries_no_lifetime pins.
|
||||
envelope = json.loads((FIXTURES / WRONG_TENANT_FIXTURE).read_text())
|
||||
assert envelope["effect"] == "deny"
|
||||
assert envelope["reason"] == "wrong_tenant"
|
||||
assert envelope["matched_rule"] == "wrong_tenant"
|
||||
assert envelope["binding"]["tenant"] == "tenant:coulomb"
|
||||
assert envelope["binding"]["tenant"] != REQUEST_TENANT
|
||||
|
||||
def test_wrong_tenant_and_superseded_policy_refused():
|
||||
request, decision = pair("deny")
|
||||
assert decision["reason"] == "wrong_tenant"
|
||||
assert decision.get("lifetime") is None
|
||||
with pytest.raises(DecisionError, match="effect is not allow"):
|
||||
validate_decision_envelope(
|
||||
envelope,
|
||||
_request_from(envelope),
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v2"},
|
||||
)
|
||||
validate(request, decision)
|
||||
request, decision = pair("rotate")
|
||||
decision["provenance"]["policy_version"] = "v1"
|
||||
with pytest.raises(DecisionError, match="policy version"):
|
||||
validate(request, decision)
|
||||
|
||||
|
||||
def test_wrong_tenant_deny_carries_no_lifetime():
|
||||
"""A deny has no lifetime, which is why effect must be checked first.
|
||||
|
||||
DecisionEnvelope requires lifetime only when effect is allow. A consumer
|
||||
that validated lifetime before effect would raise a confusing missing-field
|
||||
error on a perfectly well-formed denial.
|
||||
"""
|
||||
envelope = json.loads((FIXTURES / WRONG_TENANT_FIXTURE).read_text())
|
||||
assert envelope.get("lifetime") is None
|
||||
def test_real_expired_allow_refused():
|
||||
request, decision = pair("rotate")
|
||||
with pytest.raises(DecisionError, match="lifetime has expired"):
|
||||
validate_decision_envelope(decision, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, now=datetime(2100, 1, 1, tzinfo=timezone.utc))
|
||||
|
||||
|
||||
def test_the_superseded_v1_package_is_not_accepted():
|
||||
"""v1 had no tenant rule and failed open; pinning it must not be possible.
|
||||
|
||||
A consumer still pinned to v1 would keep getting allows it should never
|
||||
have had and could not tell from the version string that the rule changed
|
||||
underneath it, which is exactly why flex-auth superseded v1 rather than
|
||||
amending it.
|
||||
"""
|
||||
envelope = _refresh_lifetime(_envelope("rotate"))
|
||||
with pytest.raises(DecisionError, match="policy version is not accepted"):
|
||||
validate_decision_envelope(
|
||||
envelope,
|
||||
_request_from(envelope),
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
||||
accepted_policy_versions={"v1"},
|
||||
)
|
||||
def test_request_builder_tenant_boundary():
|
||||
entry = SimpleNamespace(id="glas-primary", stage="prod")
|
||||
args = dict(subject_id="secrets-engine", subject_type="service", purpose="rotation")
|
||||
request = build_action_request(entry, "rotate", **args)
|
||||
assert request["tenant"] == REQUEST_TENANT
|
||||
with pytest.raises(DecisionError, match="requires a tenant"):
|
||||
build_action_request(entry, "rotate", tenant="", **args)
|
||||
|
||||
|
||||
# --- supported PDP address (FLEX-DEC-2026-010) -------------------------------
|
||||
|
|
|
|||
|
|
@ -1,181 +1,24 @@
|
|||
"""The digest join against a REAL request, not one rebuilt from the binding.
|
||||
|
||||
tests/test_decision_replay.py passes every digest assertion because
|
||||
``_request_from()`` reconstructs the request out of ``envelope["binding"]`` --
|
||||
which is the *enriched* form the evaluator hashed. That is a self-consistent
|
||||
fake agreeing with itself, and it hid a real defect through three rounds of
|
||||
digest work: the validator required byte-equality between the binding and our
|
||||
unenriched request, which no real decision can satisfy.
|
||||
|
||||
The rule was already published in flex-auth's canonical-request-digest.md
|
||||
("Normalization"): the evaluator copies the request tenant onto subject and
|
||||
resource, a registry hit copies type/tenant/selected attributes onto the refs,
|
||||
and a consumer is told to compare structured binding fields to the proposed
|
||||
action rather than re-hash its own request. This module builds the request the
|
||||
way the engine actually builds it, compares it to a decision the engine actually
|
||||
received, and proves the corrected check accepts it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
"""Historical live evidence remains immutable and cannot satisfy the new contract."""
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.authorization import (
|
||||
binding_tuple,
|
||||
build_action_request,
|
||||
canonical_check_request,
|
||||
request_digest,
|
||||
validate_decision_envelope,
|
||||
)
|
||||
from secrets_engine.authorization import build_action_request, validate_decision_envelope
|
||||
from secrets_engine.catalog import load_catalog
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
LIVE = Path(__file__).parent / "fixtures" / "flex-auth-live" / "decision_rotate_glas_live.json"
|
||||
CATALOG = Path(__file__).resolve().parents[1] / "catalog"
|
||||
|
||||
PACKAGE = "secrets-engine.catalog-lane.lifecycle"
|
||||
VERSION = "v2"
|
||||
|
||||
|
||||
def _live():
|
||||
return json.loads(LIVE.read_text())
|
||||
|
||||
|
||||
def _our_request():
|
||||
entry = load_catalog(CATALOG)["glas-claude-agent-dev-anthropic"]
|
||||
return build_action_request(
|
||||
entry,
|
||||
"rotate",
|
||||
subject_id="secrets-engine",
|
||||
subject_type="service",
|
||||
purpose="live-adoption-proof",
|
||||
fields=["api_key"],
|
||||
request_id="check:secrets-engine-adoption-proof",
|
||||
)
|
||||
|
||||
|
||||
def test_the_live_decision_is_a_real_v2_allow():
|
||||
"""Provenance of the artifact these assertions rest on."""
|
||||
env = _live()
|
||||
assert env["effect"] == "allow"
|
||||
assert env["reason"] == "catalog_lane_policy_matched"
|
||||
provenance = env["provenance"]
|
||||
assert provenance["policy_package"] == PACKAGE
|
||||
assert provenance["policy_version"] == VERSION
|
||||
assert env["binding"]["tenant"] == "tenant:platform"
|
||||
|
||||
|
||||
def test_the_evaluator_enriches_subject_and_resource_before_hashing():
|
||||
"""Names exactly which fields appeared that we never sent.
|
||||
|
||||
If flex-auth publishes an enrichment rule that differs from this, this test
|
||||
fails and tells us the shape moved -- which is the point. It asserts the
|
||||
observed gap, not a rule we invented.
|
||||
"""
|
||||
sent = _our_request()
|
||||
bound = _live()["binding"]
|
||||
|
||||
assert "attributes" not in sent["subject"]
|
||||
assert set(bound["subject"]["attributes"]) == {
|
||||
"description", "display_name", "groups", "organization_relation", "roles",
|
||||
}
|
||||
assert "tenant" not in sent["subject"]
|
||||
assert bound["subject"]["tenant"] == sent["tenant"]
|
||||
|
||||
assert "tenant" not in sent["resource"]
|
||||
assert bound["resource"]["tenant"] == sent["tenant"]
|
||||
|
||||
# Everything we DID send survived unchanged. The enrichment is additive, so
|
||||
# the decision is about the action we proposed -- which is why staying
|
||||
# fail-closed here costs correctness nothing today.
|
||||
assert bound["action"] == sent["action"]
|
||||
assert bound["tenant"] == sent["tenant"]
|
||||
assert bound["context"] == sent["context"]
|
||||
assert bound["subject"]["id"] == sent["subject"]["id"]
|
||||
assert bound["subject"]["type"] == sent["subject"]["type"]
|
||||
for key in ("id", "type", "system", "attributes"):
|
||||
assert bound["resource"][key] == sent["resource"][key]
|
||||
|
||||
|
||||
def test_our_digest_cannot_match_a_real_binding():
|
||||
"""The join is unsatisfiable against a real request, not merely mismatched.
|
||||
|
||||
We hash what we sent; the evaluator hashed what it enriched. No amount of
|
||||
care on our side closes that, because the registry material is not ours.
|
||||
"""
|
||||
sent = _our_request()
|
||||
bound = _live()["binding"]
|
||||
assert bound["request_digest"] != request_digest(sent)
|
||||
assert canonical_check_request(bound) != canonical_check_request(sent)
|
||||
|
||||
|
||||
def test_the_live_allow_now_validates_under_the_documented_rule():
|
||||
"""The real decision validates -- this is the fix, proved against the artifact.
|
||||
|
||||
canonical-request-digest.md "Normalization" is the published rule, and it
|
||||
says a consumer must compare structured binding fields to the proposed
|
||||
action rather than re-hash its own request. Under that rule this envelope,
|
||||
which our previous byte-equality check rejected outright, is accepted.
|
||||
|
||||
Only the lifetime is refreshed: the decision was issued on 2026-09-07 with a
|
||||
bounded lifetime, and pinning a clock-dependent field is what the fixture
|
||||
provenance forbids.
|
||||
"""
|
||||
env = _live()
|
||||
now = datetime.now(timezone.utc)
|
||||
env["lifetime"] = {
|
||||
"kind": "bounded",
|
||||
"not_before": (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
result = validate_decision_envelope(
|
||||
env,
|
||||
_our_request(),
|
||||
accepted_policy_packages={PACKAGE},
|
||||
accepted_policy_versions={VERSION},
|
||||
)
|
||||
assert result.action == "rotate"
|
||||
assert result.subject_id == "secrets-engine"
|
||||
assert result.decision_id == "decision:0f9c98f14545c42d"
|
||||
|
||||
|
||||
def test_the_unrefreshed_live_decision_is_refused_on_lifetime():
|
||||
"""It is a real expired allow, so it must be refused -- and for that reason.
|
||||
|
||||
Reaching the lifetime check at all is the evidence that every binding check
|
||||
before it now passes against a real decision.
|
||||
"""
|
||||
with pytest.raises(DecisionError, match="lifetime has expired"):
|
||||
validate_decision_envelope(
|
||||
_live(),
|
||||
_our_request(),
|
||||
accepted_policy_packages={PACKAGE},
|
||||
accepted_policy_versions={VERSION},
|
||||
)
|
||||
|
||||
|
||||
def test_the_digest_is_verified_against_the_binding_it_carries():
|
||||
"""Independent recomputation, per the contract's own instruction.
|
||||
|
||||
"To recompute independently, hash the same normalized tuple the binding
|
||||
carries." So the digest is still checked -- it is simply checked for
|
||||
self-consistency rather than against material we never sent.
|
||||
"""
|
||||
binding = _live()["binding"]
|
||||
assert request_digest(binding_tuple(binding)) == binding["request_digest"]
|
||||
|
||||
|
||||
def test_a_tampered_binding_still_fails_the_digest():
|
||||
"""Self-consistency is a real check, not a formality."""
|
||||
env = _live()
|
||||
env["binding"]["resource"]["attributes"]["stage"] = "build"
|
||||
with pytest.raises(DecisionError, match="does not match"):
|
||||
validate_decision_envelope(
|
||||
env,
|
||||
_our_request(),
|
||||
accepted_policy_packages={PACKAGE},
|
||||
accepted_policy_versions={VERSION},
|
||||
)
|
||||
def test_old_live_pin_without_submitted_binding_refuses_even_during_its_lifetime():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
decision = json.loads((root / "tests/fixtures/flex-auth-live/decision_rotate_glas_live.json").read_text())
|
||||
entry = load_catalog(root / "catalog")["glas-claude-agent-dev-anthropic"]
|
||||
request = build_action_request(entry, "rotate", subject_id="secrets-engine", subject_type="service",
|
||||
purpose="live-adoption-proof", fields=["api_key"], request_id="check:secrets-engine-adoption-proof")
|
||||
assert decision["effect"] == "allow"
|
||||
assert "submitted_request_digest" not in decision["binding"]
|
||||
with pytest.raises(DecisionError, match="submitted request digest"):
|
||||
validate_decision_envelope(decision, request,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"},
|
||||
now=datetime.fromisoformat(decision["provenance"]["decision_time"].replace("Z", "+00:00")))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
"""Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine.
|
||||
|
||||
Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call.
|
||||
The PDP is a sequencing double; this is not production authorization evidence.
|
||||
The actual Flex Auth evaluator runs locally with its published policy/registry.
|
||||
This is component conformance evidence, not production authorization evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ def exercise(args):
|
|||
from secrets_engine import cli
|
||||
from secrets_engine.approval_auth import approval_token, credential_urlopen
|
||||
from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval
|
||||
from secrets_engine.authorization import request_digest, validate_decision_envelope
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.config import Config
|
||||
from secrets_engine.errors import BackendError, DecisionError
|
||||
|
|
@ -77,17 +79,44 @@ def exercise(args):
|
|||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"keycape_image": KEYCAPE_IMAGE,
|
||||
"approval_engine_commit": commit(args.approval_engine_source),
|
||||
"flex_auth_commit": commit(args.flex_auth_source),
|
||||
"keycape_contract_commit": commit(args.keycape_source),
|
||||
"consumer_source_sha256": {
|
||||
name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest()
|
||||
for name in ("approval_auth.py", "approval_consume.py", "config.py", "service_auth.py")
|
||||
for name in ("approval_auth.py", "approval_consume.py", "approval_claim.py", "authorization.py", "config.py", "service_auth.py", "cli.py")
|
||||
},
|
||||
"limitations": ["PDP sequencing double", "local Approval Engine source, not deployed image",
|
||||
"limitations": ["standalone Flex Auth source, not deployed pin", "local Approval Engine source, not deployed image",
|
||||
"no live custody or client-side read grant", "no OpenBao effect or model execution"],
|
||||
"checks": {},
|
||||
}
|
||||
with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary:
|
||||
root = Path(temporary)
|
||||
source = args.flex_auth_source.resolve()
|
||||
run(["go", "build", "-C", str(source), "-o", str(root / "flex-auth"), "./cmd/flex-auth"])
|
||||
producer_files = source / "examples/secrets-engine"
|
||||
receipt["flex_auth_binary_sha256"] = hashlib.sha256((root / "flex-auth").read_bytes()).hexdigest()
|
||||
receipt["producer_input_sha256"] = {
|
||||
name: hashlib.sha256((producer_files / name).read_bytes()).hexdigest()
|
||||
for name in ("policy_package.md", "registry_snapshot.json")
|
||||
}
|
||||
|
||||
class ProducerPDP(AuthorizationStub):
|
||||
"""Loopback transport only; every decision comes from the Go evaluator."""
|
||||
last_decision = None
|
||||
def decision(self, request):
|
||||
path = root / "check-request.json"
|
||||
path.write_text(json.dumps(request))
|
||||
path.chmod(0o600)
|
||||
self.last_decision = json.loads(run([str(root / "flex-auth"), "check",
|
||||
"-policy", str(producer_files / "policy_package.md"),
|
||||
"-registry", str(producer_files / "registry_snapshot.json"), "-request", str(path)]))
|
||||
return self.last_decision
|
||||
|
||||
def bind_request(self, request):
|
||||
# At issue, record the evaluator's claim-free digest, even if
|
||||
# the destructive action denies until approval is supplied.
|
||||
return self.decision(request)["binding"]["request_digest"]
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode())
|
||||
|
|
@ -189,7 +218,7 @@ def exercise(args):
|
|||
raw["stage"] = "prod"
|
||||
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"}
|
||||
entry = validate_entry(raw)
|
||||
pdp = AuthorizationStub(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
|
||||
pdp = ProducerPDP(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
|
||||
cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="",
|
||||
approval_url=api, approval_client_secret_file=root / "approval.secret",
|
||||
keycape_issuer=issuer, keycape_token_url=issuer + "/token",
|
||||
|
|
@ -215,9 +244,15 @@ def exercise(args):
|
|||
receipt["checks"]["wrong_action_refused_before_consume"] = True
|
||||
cli._require_lane_approval(cfg, entry, "apply")
|
||||
assert engine.claim(obj["id"])["consumed"]
|
||||
assert pdp.calls == ["check"]
|
||||
assert pdp.calls == ["check", "check"]
|
||||
accepted = pdp.last_decision
|
||||
assert accepted["binding"]["approval_binding_digest"] == digest
|
||||
assert accepted["binding"]["request_digest"] != digest
|
||||
assert accepted["binding"]["submitted_request_digest"] != accepted["binding"]["request_digest"]
|
||||
assert accepted["binding"]["context"]["approval"]["approval_id"] == obj["id"]
|
||||
receipt["checks"]["producer_origin_join_with_carried_claim"] = True
|
||||
receipt["checks"]["actual_consumer_claim_check_consume"] = True
|
||||
binding = ConsumeBinding(obj["id"], digest)
|
||||
binding = ConsumeBinding(obj["id"], accepted["binding"]["request_digest"], accepted["id"])
|
||||
retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume"))
|
||||
assert retry.idempotent
|
||||
receipt["checks"]["same_digest_retry_idempotent"] = True
|
||||
|
|
@ -254,6 +289,44 @@ def exercise(args):
|
|||
receipt["checks"]["wrong_secret_refused"] = True
|
||||
assert not (root / "approval.token").exists()
|
||||
receipt["checks"]["no_access_token_file_created"] = True
|
||||
|
||||
# Independent input -> evaluator -> consumer: registry may
|
||||
# override a caller's same-key facts without breaking replay.
|
||||
request = json.loads((producer_files / "check_request_allow_rotate.json").read_text())
|
||||
request["subject"]["attributes"] = {"roles": ["UntrustedCallerRole"]}
|
||||
decision = pdp.decision(request)
|
||||
assert decision["binding"]["subject"]["attributes"]["roles"] == ["Operator"]
|
||||
assert decision["binding"]["submitted_request_digest"] == request_digest(request)
|
||||
validate_decision_envelope(decision, request,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"})
|
||||
receipt["checks"]["registry_override_accepts_exact_submission"] = True
|
||||
replay = copy.deepcopy(request)
|
||||
replay["subject"]["attributes"]["roles"] = ["Operator"]
|
||||
try:
|
||||
validate_decision_envelope(decision, replay,
|
||||
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"})
|
||||
except DecisionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("changed submission replayed")
|
||||
receipt["checks"]["same_enriched_result_different_submission_refused"] = True
|
||||
# Real dual-control policy: empty claim denies; fresh issued
|
||||
# claim passes. No destructive backend is connected.
|
||||
raw["approval"]["authorization_id"] = "synthetic-destroy"
|
||||
destroy_entry = validate_entry(raw)
|
||||
destroy_request = _expected_request(cfg, destroy_entry, "destroy")
|
||||
denied = pdp.decision(destroy_request)
|
||||
assert denied["effect"] == "deny" and denied["reason"] == "dual_control_required"
|
||||
operator_request("/v1/approvals", {
|
||||
"id": "synthetic-destroy", "binding": {"action": "secrets.kv.destroy", "target": {"id": destroy_entry.id, "stage": "prod"},
|
||||
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
|
||||
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
|
||||
"pdp_digest": denied["binding"]["request_digest"], "pdp_path": True}, "approval:create")
|
||||
operator_request("/v1/approvals/synthetic-destroy/entries", {}, "approval:approve")
|
||||
(root / "approval.secret").write_text(values["secrets-engine-approval"])
|
||||
cli._require_lane_approval(cfg, destroy_entry, "destroy")
|
||||
assert engine.claim("synthetic-destroy")["consumed"]
|
||||
receipt["checks"]["real_dual_control_denial_then_claim_check_consume"] = True
|
||||
finally:
|
||||
if api_server:
|
||||
api_server.shutdown()
|
||||
|
|
@ -275,6 +348,7 @@ def main():
|
|||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--keycape-source", required=True, type=Path)
|
||||
parser.add_argument("--approval-engine-source", required=True, type=Path)
|
||||
parser.add_argument("--flex-auth-source", required=True, type=Path)
|
||||
parser.add_argument("--receipt", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.receipt.exists():
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ Acceptance:
|
|||
|
||||
```task
|
||||
id: SECRETS-WP-0008-T02
|
||||
status: progress
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "3eb9cff8-1441-5437-9e92-a2b655c82d04"
|
||||
```
|
||||
|
|
@ -194,6 +194,30 @@ The current source has not yet implemented the changes in this return; live
|
|||
PDP rollout/adoption evidence remains explicit too. No synthetic fixture or Hub
|
||||
decision substitutes for a runtime allow and consume.
|
||||
|
||||
### 2026-09-09 replay consumer implementation and real component acceptance
|
||||
|
||||
The returned correction is implemented. The PEP now checks the exact submitted
|
||||
request digest including the full carried approval, permits registry-owned
|
||||
attribute replacement, joins the evaluator-origin approval digests and rechecks
|
||||
claim freshness after Check. The consume CAS uses the validated evaluator's
|
||||
request_digest and decision id; an observation alone is not a consume binding.
|
||||
|
||||
334 regression tests pass. The disposable KeyCape + actual Approval Engine +
|
||||
compiled Flex Auth exercise passes 13 checks, including real dual-control denial
|
||||
then claim/check/consume, registry override, changed submitted request, wrong
|
||||
action, consumed claim and same/different CAS digest. Independent producer
|
||||
inputs replace response-reconstructed replay fixtures; existing digest pins
|
||||
remain unchanged. Receipt: docs/evidence/2026-09-09-replay-contract-exercise.json.
|
||||
No OpenBao side effect, live credential read or model request occurred.
|
||||
|
||||
T02 is wait for the live return: deployed PDP contract adoption and the admitted
|
||||
Approval Engine claim/consume path (APPROVAL-WP-0002-T03 and RPF-WP-0035-T06 /
|
||||
AUDIT-WP-0009-T09). SECRETS-WP-0009-T03 owns native delivery acceptance. The old
|
||||
live decision lacks submitted_request_digest and is explicitly refused; its
|
||||
historical evidence was not rewritten. Local conformance does not close the
|
||||
remaining live task. This supersedes earlier additive-only and claim-free
|
||||
transitive correspondence descriptions above.
|
||||
|
||||
## Emit evidence under the §9.6 bound
|
||||
|
||||
```task
|
||||
|
|
|
|||
|
|
@ -186,3 +186,13 @@ Owner-return clarification: SECRETS-WP-0008-T02 is now progress for the publishe
|
|||
FLEX-DEC-2026-012 submitted-request replay identity and approval-digest join.
|
||||
The real-component identity exercise used a PDP double and does not close that
|
||||
local implementation. T03 also consumes that correction before live activation.
|
||||
|
||||
|
||||
## Replay-contract return — 2026-09-09
|
||||
|
||||
SECRETS-WP-0008-T02 supplies the implemented FLEX-DEC-2026-012 consumer join.
|
||||
334 tests and 13 real local KeyCape/Approval Engine/Flex Auth checks pass;
|
||||
receipt: docs/evidence/2026-09-09-replay-contract-exercise.json. The former PDP
|
||||
double limitation is resolved for component conformance. T03 remains wait for
|
||||
current deployed PDP adoption, separately admitted client-side/audit custody,
|
||||
actual Approval Engine deployment, and the scoped native OpenBao delivery proof.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue