diff --git a/docs/tenant-alignment.md b/docs/tenant-alignment.md new file mode 100644 index 0000000..29fb950 --- /dev/null +++ b/docs/tenant-alignment.md @@ -0,0 +1,117 @@ +# Tenant alignment + +Answer to the `GLAS-WP-0015` production-dependency handoff question, which asked +this engine to state its exact tenant values and produce wrong-tenant denial +evidence rather than assume a mapping. Related: `KEY-WP-0013-T02`, +`APPROVAL-WP-0002-T01`, `SECRETS-WP-0009-T03`, `FLEX-WP-0021-T05`. + +The handoff's framing is adopted here without reservation: **spelling similarity +is not a mapping**, and JWT/store comparison is exact. A tenant "mapping" +invented by a consumer is the same fail-open shape gate-house rejected for +action vocabularies in `GH-DEC-2026-008` — a translation can be confidently +wrong — arriving by a different road. + +## The three values, as this repo actually holds them + +| Value | Where it lives | Owner | +| --- | --- | --- | +| CheckRequest tenant | `authorization.REQUEST_TENANT` = `tenant:platform` | flex-auth package | +| KeyCape JWT tenant | `service_auth.TENANT` = `tenant:coulomb` | key-cape | +| Approval store tenant | `platform` (not held here) | approval-engine | + +### CheckRequest tenant: `tenant:platform`, and it was missing entirely + +`secrets-engine.catalog-lane.lifecycle` **v2** reads + +```rego +known_tenant := "tenant:platform" +request_tenant := object.get(input, "tenant", "") +``` + +and its `wrong_tenant` first-denial branch fires on anything else. `object.get` +with a `""` default is deliberate: **an absent tenant is a denial, not an +ignored field.** + +`build_action_request` emitted no `tenant` field at all. Every gated action +this engine sent would have been denied `wrong_tenant` by the deployed v2 +package — and, worse, the omission also produced a `request_digest` that could +match no correctly issued decision, because `tenant` is hashed material. Fixed: +the request now carries `REQUEST_TENANT`, an empty tenant is refused at build +time, and the constant is pinned against the vendored allow envelopes so a +package retenanting surfaces as a test failure rather than a production denial. + +### KeyCape JWT tenant: `tenant:coulomb` — and that is the value the package denies + +This is the uncomfortable part and it is stated plainly rather than smoothed +over. `service_auth.TENANT` is `tenant:coulomb`, preflighted on the KeyCape +`client_credentials` token for the accepted `secrets-engine-openbao` service +identity. `tenant:coulomb` is **exactly** the value flex-auth used to +demonstrate a wrong-tenant denial (`decision_wrong_tenant_deny.json`, +`matched_rule: wrong_tenant`). + +Two readings are possible and this engine does not choose between them: + +1. They name **two different layers** — an identity/tenancy tenant for the + KeyCape client, and a resource-scoping tenant for the policy package — which + happen to use one namespace format. +2. One of the two constants is **wrong**. + +Reading 1 is plausible and is probably right, but "probably right" is not a +contract. Both constants stay as they are, deliberately not unified behind one +symbol, until an owner-reviewed mapping exists with a decision reference. This +engine will consume that mapping; it will not author it. + +### Approval store tenant: `platform` + +Not held in this repo. Noted only because the handoff asked for all three: the +comparison between `platform` and `tenant:platform` is a prefix difference, and +a prefix difference is precisely the kind of similarity that must not be treated +as identity without an owner saying so. + +## Wrong-tenant denial evidence + +From flex-auth's real v2 deny envelope, vendored at +`tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json`: + +```text +decision:7d56b7fc274ddfd6 effect: deny reason: wrong_tenant +matched_rule: wrong_tenant policy_version: v2 +binding.tenant: tenant:coulomb +binding.request_digest: sha256:c9c6e6f8...0d20 +``` + +flex-auth's fixture varies the tenant on an otherwise-valid `rotate`, so a deny +proves the tenant alone carried it. `tests/test_decision_replay.py` asserts our +consumer refuses it on `effect` before anything else, and separately pins that a +deny carries **no** `lifetime` — a consumer checking lifetime before effect would +raise a confusing missing-field error on a well-formed denial. + +## Why v1 must not be pinned + +`v1` shipped and was deployed with no reference to `input.tenant` at all. Every +fixture carried `tenant:platform`, so the package's own coverage could not +notice, and `FLEX-WP-0021-T02`'s "wrong-tenant deny" gate was recorded as met +when it was not. A `rotate` under `tenant:coulomb` returned **allow** against the +deployed v1 package (`decision:066e629bbf0c0924`). + +flex-auth superseded v1 rather than amending it, on the principle that **a +fail-open correction has to be visible as a version change; a fail-closed one +does not.** A consumer still pinned to `v1` would keep receiving allows it should +never have had, unable to tell from the version string that the rule moved +underneath it. `test_the_superseded_v1_package_is_not_accepted` pins that this +engine refuses a v1 decision. + +Note the ownership point flex-auth recorded with it: the evaluator hashes +`tenant` and carries it in the decision record, but nothing in the evaluation +path compares it. **Tenant scoping is the policy package's job, and a package +that omits it is not scoped to a tenant at all.** Our own omission was the +mirror-image defect on the consumer side, and neither side's tests could see it +alone. + +## Still open + +- The owner-reviewed JWT/store/CheckRequest mapping, with a decision reference. + Until it exists, no live client or policy subject changes here. +- A supported owner access path to `flex-auth-secrets-engine` for a workstation + CLI. Service DNS is not workstation connectivity, so the tenant fix above is + necessary but not sufficient for activation (`FLEX-WP-0021-T05`). diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index dd6be9a..70aa86a 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -19,6 +19,27 @@ from secrets_engine.errors import DecisionError DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +#: The single tenant the published policy package scopes this engine to. +#: +#: `secrets-engine.catalog-lane.lifecycle` v2 reads +#: ``request_tenant := object.get(input, "tenant", "")`` and allows only +#: ``known_tenant := "tenant:platform"``; anything else, **an absent tenant +#: included**, matches its ``wrong_tenant`` first-denial branch. Sending no +#: tenant is therefore not a neutral omission, it is a denial. +#: +#: v1 had no tenant rule at all and failed open -- a `rotate` sent under +#: ``tenant:coulomb`` returned allow against the deployed package. That is why +#: v1 is superseded rather than amended, and why this value is pinned against +#: the vendored replay fixtures in ``tests/test_decision_replay.py`` instead of +#: being left to a deployment to supply correctly. +#: +#: This is not the KeyCape JWT tenant. ``service_auth.TENANT`` is +#: ``tenant:coulomb``, which is the exact value this package denies. Whether +#: those name one tenant or two layers is an open owner question -- see +#: ``docs/tenant-alignment.md`` -- and is deliberately not resolved by reusing +#: one constant for both. +REQUEST_TENANT = "tenant:platform" + SCHEMA_VERSION = "0.1" CONTRACT_VERSION = "flex-auth.decision-record.v1" @@ -42,17 +63,30 @@ def build_action_request( policy_targets: list[str] | tuple[str, ...] = (), auth_targets: list[str] | tuple[str, ...] = (), request_id: str = "", + tenant: str = REQUEST_TENANT, ) -> dict[str, Any]: - """Build the exact normalized secrets-engine profile for flex-auth.""" + """Build the exact normalized secrets-engine profile for flex-auth. + + ``tenant`` is required and defaults to the package's published + ``known_tenant``. It is hashed into the request digest and compared by the + package's ``wrong_tenant`` branch, so an omitted tenant is a denial rather + than a field the evaluator ignores. + """ if not action or not subject_id or not subject_type or not purpose: raise DecisionError( "action request requires action, subject id/type, and purpose" ) + if not tenant: + raise DecisionError( + "action request requires a tenant; the policy package denies an " + "absent tenant as wrong_tenant rather than ignoring it" + ) request: dict[str, Any] = {} if request_id: request["id"] = request_id request.update( { + "tenant": tenant, "subject": {"id": subject_id, "type": subject_type}, "action": action, "resource": { diff --git a/tests/fixtures/flex-auth-replay/PROVENANCE.md b/tests/fixtures/flex-auth-replay/PROVENANCE.md index d5f423f..46e17b5 100644 --- a/tests/fixtures/flex-auth-replay/PROVENANCE.md +++ b/tests/fixtures/flex-auth-replay/PROVENANCE.md @@ -19,8 +19,24 @@ Re-copied 2026-09-06 (twice, both upstream regenerations): **not**, which is the property the fixture now demonstrates rather than asserts. -`decision_rotate.json` is unchanged and carries no `approval_binding_digest` — -the field is omitted on claim-free decisions rather than duplicated onto them. +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`, diff --git a/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json b/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json index 9511d36..9f20bd6 100644 --- a/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json +++ b/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json @@ -1,10 +1,10 @@ { - "id": "decision:44a40c339a020772", + "id": "decision:4e327202e2aee41c", "contract_version": "flex-auth.decision-record.v1", "request_id": "check:secrets-engine-destroy", "effect": "allow", "reason": "catalog_lane_policy_matched", - "matched_policy_version": "v1", + "matched_policy_version": "v2", "matched_rule": "catalog_lane_policy_matched", "resource": { "id": "lane:glas-primary", @@ -105,8 +105,8 @@ "lifetime": { "kind": "ttl", "ttl": "15m", - "not_before": "2026-09-06T12:51:16Z", - "expires_at": "2026-09-06T13:06:16Z" + "not_before": "2026-09-06T18:35:19Z", + "expires_at": "2026-09-06T18:50:19Z" }, "diagnostics": { "action": "destroy", @@ -120,13 +120,13 @@ "evaluator": "flex-auth/local", "mode": "standalone", "policy_package": "secrets-engine.catalog-lane.lifecycle", - "policy_version": "v1", - "policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", "input_claim_digests": { "context": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800" }, - "decision_time": "2026-09-06T12:51:16Z" + "decision_time": "2026-09-06T18:35:19Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/tests/fixtures/flex-auth-replay/decision_rotate.json b/tests/fixtures/flex-auth-replay/decision_rotate.json index e5d04d4..6ac8c8e 100644 --- a/tests/fixtures/flex-auth-replay/decision_rotate.json +++ b/tests/fixtures/flex-auth-replay/decision_rotate.json @@ -1,10 +1,10 @@ { - "id": "decision:49309356905a2ad3", + "id": "decision:414734bb30381ff7", "contract_version": "flex-auth.decision-record.v1", "request_id": "check:secrets-engine-rotate", "effect": "allow", "reason": "catalog_lane_policy_matched", - "matched_policy_version": "v1", + "matched_policy_version": "v2", "matched_rule": "catalog_lane_policy_matched", "resource": { "id": "lane:glas-primary", @@ -74,8 +74,8 @@ "lifetime": { "kind": "ttl", "ttl": "15m", - "not_before": "2026-09-06T06:13:21Z", - "expires_at": "2026-09-06T06:28:21Z" + "not_before": "2026-09-06T18:35:18Z", + "expires_at": "2026-09-06T18:50:18Z" }, "diagnostics": { "action": "rotate", @@ -89,10 +89,10 @@ "evaluator": "flex-auth/local", "mode": "standalone", "policy_package": "secrets-engine.catalog-lane.lifecycle", - "policy_version": "v1", - "policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", - "decision_time": "2026-09-06T06:13:21Z" + "decision_time": "2026-09-06T18:35:18Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json b/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json new file mode 100644 index 0000000..0f35353 --- /dev/null +++ b/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json @@ -0,0 +1,104 @@ +{ + "id": "decision:7d56b7fc274ddfd6", + "contract_version": "flex-auth.decision-record.v1", + "request_id": "check:secrets-engine-wrong-tenant", + "effect": "deny", + "reason": "wrong_tenant", + "matched_policy_version": "v2", + "matched_rule": "wrong_tenant", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:coulomb", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "binding": { + "tenant": "tenant:coulomb", + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "action": "rotate", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:coulomb", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20" + }, + "diagnostics": { + "action": "rotate", + "matched_relationship": "", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_status": "ready", + "registry_resource": false, + "registry_subject": true + }, + "provenance": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", + "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", + "decision_time": "2026-09-06T18:35:20Z" + }, + "caring": { + "profile": "caring-0.4.0-rc2", + "conformance_findings": [ + { + "code": "CARING-DESCRIPTOR-MISSING", + "severity": "warning", + "message": "no CARING descriptor matched the request", + "fields": [ + "caring_context" + ] + } + ] + } +} diff --git a/tests/test_action_authorization.py b/tests/test_action_authorization.py index 5f06695..49b4ae9 100644 --- a/tests/test_action_authorization.py +++ b/tests/test_action_authorization.py @@ -48,6 +48,7 @@ def _envelope(request=None): "subject": copy.deepcopy(request["subject"]), "resource": copy.deepcopy(request["resource"]), "binding": { + "tenant": request["tenant"], "subject": copy.deepcopy(request["subject"]), "action": request["action"], "resource": copy.deepcopy(request["resource"]), @@ -63,7 +64,7 @@ def _envelope(request=None): "evaluator": "flex-auth/secrets-engine", "mode": "cluster-local", "policy_package": "secrets-engine.catalog-lane.lifecycle", - "policy_version": "v1", + "policy_version": "v2", }, } @@ -73,7 +74,7 @@ def _validate(envelope, expected=None): envelope, expected or _request(), accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, + accepted_policy_versions={"v2"}, ) @@ -139,7 +140,7 @@ def test_state_hub_authority_is_no_longer_required(): "request digest does not match"), (lambda d: d["provenance"].update(policy_package="other.package"), "policy package is not accepted"), - (lambda d: d["provenance"].update(policy_version="v2"), + (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"), @@ -173,7 +174,7 @@ def test_unaccepted_policy_pin_is_required(): with pytest.raises(DecisionError, match="package/version is required"): validate_decision_envelope( _envelope(), _request(), - accepted_policy_packages=set(), accepted_policy_versions={"v1"}, + accepted_policy_packages=set(), accepted_policy_versions={"v2"}, ) diff --git a/tests/test_decision_replay.py b/tests/test_decision_replay.py index 704f874..b334ab3 100644 --- a/tests/test_decision_replay.py +++ b/tests/test_decision_replay.py @@ -16,8 +16,13 @@ from pathlib import Path 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, @@ -26,7 +31,7 @@ from secrets_engine.errors import DecisionError FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay" -PACKAGE_DIGEST = "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c" +PACKAGE_DIGEST = "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4" SNAPSHOT_DIGEST = "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb" CASES = { @@ -116,7 +121,7 @@ def test_real_envelope_validates_against_the_published_package(name): envelope, _request_from(envelope), accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, + accepted_policy_versions={"v2"}, ) assert result.action == CASES[name]["action"] assert result.subject_id == "secrets-engine" @@ -156,7 +161,7 @@ def test_expired_real_envelope_fails_closed(): envelope, _request_from(envelope), accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, + accepted_policy_versions={"v2"}, ) @@ -264,7 +269,7 @@ def test_decision_validation_ties_the_claim_to_the_approval_binding_digest(): envelope, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, + accepted_policy_versions={"v2"}, expected_approval_binding_digest=APPROVAL_BINDING_DIGEST, ) assert result.action == "destroy" @@ -274,7 +279,7 @@ def test_decision_validation_ties_the_claim_to_the_approval_binding_digest(): envelope, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, + accepted_policy_versions={"v2"}, expected_approval_binding_digest="sha256:" + "c" * 64, ) @@ -293,7 +298,7 @@ def test_claim_bearing_request_without_a_binding_digest_fails_closed(): envelope, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, + accepted_policy_versions={"v2"}, expected_approval_binding_digest=APPROVAL_BINDING_DIGEST, ) @@ -308,5 +313,119 @@ def test_a_forged_binding_digest_is_recomputed_not_trusted(): 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 + + 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"}, + ) + + +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_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"}, )