diff --git a/SCOPE.md b/SCOPE.md index 7698b44..c14b3ba 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -114,6 +114,22 @@ cross-system object is documented in `schemas/action_authorization.schema.json` and is not yet a deployed `approval-engine` endpoint. +**secrets-engine is a shipped consumer as of 2026-09-06** (`FLEX-WP-0021`): +`secrets-engine.catalog-lane.lifecycle` v2 over `resource.type: +secret-catalog-lane`, twelve actions delivered by secrets-engine rather than +inferred, `destroy` gated on a dual-control approval claim, and a dedicated +`flex-auth-secrets-engine` pin at +`http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080` with +`callerAuth.mode: warn`. Adoption is not complete: secrets-engine is a CLI +rather than a workload, and the pin's default-deny ingress admits a pod, so an +operator-run access path is still undecided. + +v1 of that package **had no tenant rule and allowed a foreign tenant**; v2 +supersedes rather than amends it, because a fail-open correction must be visible +to a consumer as a version change (`FLEX-DEC-2026-008`). The sweep that finding +prompted shows `tenant-engine` unscoped on tenant as well, carried by +`FLEX-WP-0022`. + The **first shipped protected-system consumer is ops-warden**: its opt-in pre-sign gate calls `POST /v1/check` for `resource.type: ssh-certificate`, `action: sign` decisions (`examples/ops-warden/`, policy package, allow/deny diff --git a/decisions/decisions.md b/decisions/decisions.md index ab20e0b..6488361 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -1007,3 +1007,154 @@ files a consumer can diff. digest an approval names is computed without `context.approval`, and it is now stated in the contract rather than left to be discovered. + +--- + +## FLEX-DEC-2026-008 — `secrets-engine.catalog-lane.lifecycle` v1 had no tenant rule and allowed a foreign tenant; v2 supersedes it + +**Date:** 2026-09-06 +**Status:** accepted +**Workplan:** `FLEX-WP-0021` (`T02` reopened, `T05`) +**Raised by:** flex-auth, answering `glas-harness`'s tenant-alignment request + +## Context + +`glas-harness` asked flex-auth to reconcile three tenant values before real +credentials are materialized — approval store `platform`, proposed client JWT +`tenant:coulomb`, policy tenant `tenant:platform` — and to return +**wrong-tenant denial evidence**. + +There was none to return. `secrets-engine.catalog-lane.lifecycle` v1 contained +no reference to `input.tenant` anywhere: not in `well_formed`, not in the +denial ladder, not in a test. Run against the deployed package, a `rotate` on +`lane:glas-primary` under `tenant: tenant:coulomb` returned: + +```text +decision:066e629bbf0c0924 effect: allow reason: catalog_lane_policy_matched +policy_version: v1 binding.tenant: tenant:coulomb +``` + +The exact value in the JWT that `glas-harness` flagged as needing reconciliation +was allowed by the package it was being reconciled against. + +## Why nothing caught it + +Three independent covers failed in the same direction. + +1. **Every fixture carried `tenant:platform`.** 29 fixtures, 11 of them allows, + and not one varied the field. A suite that never varies an input cannot + report on it, and its passing rate says nothing about it. +2. **`FLEX-WP-0021-T02`'s own gate named it.** The task text required "wrong- + tenant deny" among the minimum fixtures. The task was recorded done with the + gate unmet — the gate was written, read, and not executed. +3. **The engine does not supply the check.** `tenant` is hashed into + `binding.request_digest` and rendered in the decision record, so it is + visible in every envelope. Visible is not enforced. Nothing in the + evaluation path compares the request tenant to anything. + +`railiance-platform`, `qonto-assistant`, `ops-warden`, and `user-engine` all +carry the branch. This package was the single outlier, which is the strongest +argument that the omission was an oversight rather than a scoping decision. + +## Decision + +**Tenant scoping is the policy package's responsibility, and a package with no +tenant rule is not tenant-scoped at all.** v2 adds `known_tenant` to +`well_formed` and `wrong_tenant` as the first rung of the denial ladder, above +`wrong_system`. + +Three tests and three fixtures, chosen so each can only pass for the intended +reason: + +- **wrong tenant on an otherwise-valid request** — proves the tenant alone + carried the denial. +- **absent tenant** — the first draft read `input.tenant != known_tenant` + directly, which is *undefined* on a missing key in Rego, so the branch + dropped and the ladder reported `no_matching_rule`. Still a deny, so still + fail-closed, but it named the wrong cause. `request_tenant := + object.get(input, "tenant", "")` fixes it. **A denial ladder that reports the + wrong rung is a diagnostic defect even when the effect is right** — a + consumer debugging `no_matching_rule` looks at their action, not their + tenant. +- **wrong tenant carrying a fully valid approval-claim** — asserts the ordering: + a foreign tenant with perfect dual-control evidence is denied `wrong_tenant`, + not invited to present a better claim. + +## Why v2 rather than an amended v1 + +This package was corrected once before, at v1, without a version bump: the +dual-control rule had been written against an invented claim shape and was +unsatisfiable. That correction stayed v1 because the defective rule **denied +everything** — nothing had been wrongly allowed and no consumer could have +relied on it. + +This one runs the other way. A consumer pinned to `_VERSION=v1` would keep +receiving allows it should never have had, with no signal that the rule beneath +the version string had changed. + +**A fail-open correction must be visible to a consumer as a version change; a +fail-closed one need not be.** v1 is superseded, not amended. + +## The tenant reconciliation itself + +flex-auth answers only for the values it owns and does not map the other two. + +| Value | Owner | flex-auth's position | +| --- | --- | --- | +| CheckRequest `tenant` | flex-auth | `tenant:platform` — the only accepted value on this lane, now enforced | +| Approval store `platform` | `approval-engine` | not ours to interpret; flex-auth reads approvals as claims and never mutates them | +| Client JWT `tenant:coulomb` | key-cape | **denied by policy today**, with the receipt above | + +**No mapping is published, and spelling similarity is not one.** This is the +same refusal as the action-vocabulary mapping ruled out in `GH-DEC-2026-008`, +and for the same reason: a mapping asserted between two vocabularies by +resemblance produces a confident wrong answer, and here it would fail *open* — +admitting a foreign tenant on the strength of a shared word. If `tenant:coulomb` +is intended to reach this lane, the owner-reviewed answer is either a JWT +carrying `tenant:platform` or a policy change registering a second tenant. Both +are decisions with named owners; neither is a spelling observation. + +## Consequences + +- The deployed pin serves **v1** until a redeploy lands. The over-permissive + package is live now. Deployment approval is the user's; this record does not + grant it. +- No consumer re-pinning is needed for the digest join: `request_digest` is + computed over tenant/subject/action/resource/context and not over the + package, so both `FLEX-WP-0021-T03` replay digests are byte-identical at v2. + Only `policy_version` and `policy_package_digest` moved. +- `FLEX-WP-0021-T02`'s gate is now actually met rather than recorded as met. +- Generalizable beyond this package: **a fixture suite that holds an input + constant provides no coverage of it, however many fixtures pass.** + +## The sweep, run rather than recommended + +Every published package's fixture suite was checked for a constant `tenant`: + +| Package | Fixture tenants | Tenant rule in package | +| --- | --- | --- | +| `secrets-engine` | now 3 distinct | **added at v2** | +| `qonto-assistant` | 2 distinct | yes, `wrong_tenant` | +| `user-engine` | 2 distinct | yes, `cross_tenant` | +| `ops-warden` | constant | yes, `wrong_tenant` — rule covered by Rego tests, not fixtures | +| `railiance-platform` | constant | yes, `wrong_tenant` — same | +| `tenant-engine` | constant | **none** | + +`ops-warden` and `railiance-platform` have the rule and exercise it only in +Rego tests, which is thin but not a hole. + +**`tenant-engine` has no tenant rule and is deployed.** Verified: a +`tenant.create` allow re-sent under `tenant: tenant:coulomb` returns +`allow` / `write_api_policy_matched`. + +It is **not** the same fix. `secrets-engine`'s request tenant is the calling +identity's own tenant, so a constant is correct. `tenant-engine`'s subjects all +sit in `tenant:platform` while its fixtures send `tenant:friendly:binky` — the +request tenant there names the *target* of the operation, and a service whose +whole purpose is creating tenants legitimately acts across them. A constant +`known_tenant` would break it. The correct rule is a relation between the +request tenant and the resource, and it needs `tenant-engine` to say which. + +So the finding is recorded and **not** unilaterally patched: what is wrong today +is that the omission is undocumented, and a reader cannot tell a deliberate +cross-tenant scope from a missing rule. Carried as `FLEX-WP-0022`. diff --git a/examples/secrets-engine/README.md b/examples/secrets-engine/README.md index 64b59e6..ba500e4 100644 --- a/examples/secrets-engine/README.md +++ b/examples/secrets-engine/README.md @@ -6,11 +6,11 @@ catalog-lane operations. Opened by `FLEX-DEC-2026-005`, carried by | File | What it is | | --- | --- | -| `policy_package.md` | `secrets-engine.catalog-lane.lifecycle` v1, `allow_ttl: 15m` | +| `policy_package.md` | `secrets-engine.catalog-lane.lifecycle` v2, `allow_ttl: 15m` | | `protected_system_manifest.yaml` | the `secret-catalog-lane` resource type and twelve actions | | `subject_manifest.yaml` | the single `secrets-engine` service identity | | `registry_snapshot.json` | loadable snapshot combining both manifests | -| `policy_fixtures.yaml` | 29 fixtures — 11 allows, dual control both ways, and every denial branch | +| `policy_fixtures.yaml` | 32 fixtures — 11 allows, dual control both ways, and every denial branch | | `check_request_*.json` | standalone requests for `POST /v1/check` | The action vocabulary is **secrets-engine's**, delivered under @@ -25,12 +25,30 @@ go run ./cmd/flex-auth validate -kind policy -file examples/secrets-engine/polic go run ./cmd/flex-auth load-registry -file examples/secrets-engine/registry_snapshot.json ``` -25 Rego tests and 29 fixtures. +28 Rego tests and 32 fixtures. -## Not yet deployed +## Deployed, and the version to pin -There is no `flex-auth-secrets-engine` pin yet (`FLEX-WP-0021-T04`), so -secrets-engine has no address to call. Their policy pin -(`SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION`) stays **unset and -fail-closed** until `FLEX-WP-0021-T05` hands them the published package and the -Service DNS. Do not configure it from this directory. +`FLEX-WP-0021-T04` deployed the `flex-auth-secrets-engine` pin on 2026-09-06: + +```text +Service: http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080 +Package: secrets-engine.catalog-lane.lifecycle +Version: v2 +callerAuth.mode: warn (not enforced caller authentication) +``` + +Ingress admits namespace `secrets-engine` with pod label +`app.kubernetes.io/name=secrets-engine` and default-denies everything else. A +workstation CLI process is not that, and Service DNS is not workstation +connectivity — an operator-run consumer needs a decided access path before it +can call this pin at all (`FLEX-WP-0021-T04`'s three shapes). + +**Pin `_VERSION` to `v2`, never `v1`.** `v1` is deployed and superseded: it had +no tenant rule and allowed a foreign tenant. See the correction section in +`policy_package.md`. The pin still serves `v1` until the redeploy lands, which +is why the version is stated here rather than left to be read off the running +service. + +`SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` remain fallback-free +and fail-closed by design; nothing here changes that. diff --git a/examples/secrets-engine/check_request_deny_wrong_tenant.json b/examples/secrets-engine/check_request_deny_wrong_tenant.json new file mode 100644 index 0000000..dc0dce0 --- /dev/null +++ b/examples/secrets-engine/check_request_deny_wrong_tenant.json @@ -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": {} +} diff --git a/examples/secrets-engine/policy_fixtures.yaml b/examples/secrets-engine/policy_fixtures.yaml index c851a6a..42c5f9c 100644 --- a/examples/secrets-engine/policy_fixtures.yaml +++ b/examples/secrets-engine/policy_fixtures.yaml @@ -1057,5 +1057,125 @@ "effect": "deny", "reason": "wrong_resource_type" } + }, + { + "id": "fixture:secrets-engine-wrong-tenant-deny", + "request": { + "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": {} + }, + "expect": { + "effect": "deny", + "reason": "wrong_tenant" + } + }, + { + "id": "fixture:secrets-engine-absent-tenant-deny", + "request": { + "id": "check:secrets-engine-absent-tenant", + "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": {} + }, + "expect": { + "effect": "deny", + "reason": "wrong_tenant" + } + }, + { + "id": "fixture:secrets-engine-wrong-tenant-with-valid-claim-deny", + "request": { + "id": "check:secrets-engine-wrong-tenant-dual-control", + "tenant": "tenant:coulomb", + "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" + } + } + }, + "expect": { + "effect": "deny", + "reason": "wrong_tenant" + } } ] diff --git a/examples/secrets-engine/policy_package.md b/examples/secrets-engine/policy_package.md index dac0a4f..1bc87db 100644 --- a/examples/secrets-engine/policy_package.md +++ b/examples/secrets-engine/policy_package.md @@ -2,7 +2,7 @@ id: secrets-engine.catalog-lane.lifecycle name: secrets-engine catalog-lane lifecycle authorization namespace: secrets-engine:secret-catalog-lane -version: v1 +version: v2 status: ready package: flexauth.secrets_engine.catalog_lane allow_ttl: 15m @@ -96,6 +96,38 @@ against an invented shape rather than a published one — the same class of erro Recorded here rather than quietly rewritten. The rule now consumes `valid_now` from the published claim; see "Dual control on `destroy`". +## Correction, 2026-09-06 — v1 had no tenant rule at all, and it failed open + +`v1` shipped and deployed without a single reference to `input.tenant`. Every +fixture and every check request carried `tenant: tenant:platform`, so nothing +in the package's own coverage could notice, and `FLEX-WP-0021-T02`'s stated +gate — "wrong-tenant deny" among the required fixtures — was recorded as met +when it was not. A `rotate` on `lane:glas-primary` sent under +`tenant: tenant:coulomb` returned **allow**, `catalog_lane_policy_matched`, +against the deployed package: + +```text +decision:066e629bbf0c0924 effect: allow policy_version: v1 +binding.tenant: tenant:coulomb +``` + +Every other published package in this repo has the branch — +`railiance-platform`, `qonto-assistant`, `ops-warden`, `user-engine`. This one +was the outlier, and the engine does not supply the check: `tenant` is hashed +into `binding.request_digest` and carried 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.** + +### Why this is v2 and the dual-control correction stayed v1 + +The correction below rewrote an unsatisfiable rule: it denied everything, so +no consumer could have relied on it and nothing had been wrongly allowed. This +one runs the other way. A consumer that had already pinned +`SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION=v1` would keep getting allows it +should never have had, and could not tell from the version string that the +rule changed underneath it. **A fail-open correction has to be visible as a +version change; a fail-closed one does not.** `v1` is superseded, not amended. + ## The four traps 1. **`revoke` is not an action.** The CLI verb `revoke` gates as `deactivate`, @@ -256,6 +288,13 @@ dual_control_actions := {"destroy"} known_subjects := {"secrets-engine"} +known_tenant := "tenant:platform" + +# Read through object.get: an absent `tenant` makes a bare `input.tenant !=` +# comparison undefined, which drops the branch and reports `no_matching_rule` +# instead of the real cause. Defaulting to "" keeps the ladder truthful. +request_tenant := object.get(input, "tenant", "") + decision := {"effect": "allow", "reason": "catalog_lane_policy_matched"} if { allowed } else := {"effect": "deny", "reason": first_denial} if { @@ -263,6 +302,7 @@ decision := {"effect": "allow", "reason": "catalog_lane_policy_matched"} if { } well_formed if { + request_tenant == known_tenant input.resource.system == "secrets-engine" input.resource.type == "secret-catalog-lane" input.action in valid_actions @@ -290,7 +330,9 @@ dual_control_satisfied if { default first_denial := "no_matching_rule" -first_denial := "wrong_system" if { +first_denial := "wrong_tenant" if { + request_tenant != known_tenant +} else := "wrong_system" if { input.resource.system != "secrets-engine" } else := "wrong_resource_type" if { input.resource.type != "secret-catalog-lane" @@ -418,6 +460,30 @@ test_destroy_without_claim_denied if { catalog_lane.decision.reason == "dual_control_required" with input as lane("destroy") } +# The tenant branch is checked on an otherwise-valid request, so a pass proves +# the tenant alone carried the denial rather than some other malformed field. +test_wrong_tenant_denied if { + catalog_lane.decision.reason == "wrong_tenant" with input as object.union( + lane("rotate"), {"tenant": "tenant:coulomb"} + ) +} + +# A tenant-less request must not fall through to allow. `input.tenant` is +# absent rather than empty, so the comparison has to hold on a missing key. +test_absent_tenant_denied if { + catalog_lane.decision.reason == "wrong_tenant" with input as object.remove( + lane("rotate"), {"tenant"} + ) +} + +# Wrong tenant outranks the dual-control branch: a foreign tenant presenting a +# perfectly valid approval is denied for the tenant, not asked for a better claim. +test_wrong_tenant_outranks_dual_control if { + catalog_lane.decision.reason == "wrong_tenant" with input as object.union( + approved_destroy, {"tenant": "tenant:coulomb"} + ) +} + test_destroy_insufficient_approvers_denied if { catalog_lane.decision.reason == "dual_control_required" with input as destroy_with( object.union(valid_claim, {"state": "requested", "valid_now": false, "reason_code": "insufficient_approvers"}) diff --git a/examples/secrets-engine/replay/README.md b/examples/secrets-engine/replay/README.md index 4abc713..00dcab1 100644 --- a/examples/secrets-engine/replay/README.md +++ b/examples/secrets-engine/replay/README.md @@ -7,6 +7,7 @@ to verify its digest join (`627810b`) unchanged. `FLEX-WP-0021-T03`. | --- | --- | | `decision_rotate.json` | `../check_request_allow_rotate.json` — plain allow, empty context | | `decision_destroy_dual_control.json` | `../check_request_allow_destroy_dual_control.json` — dual control, valid approval-claim | +| `decision_wrong_tenant_deny.json` | `../check_request_deny_wrong_tenant.json` — foreign tenant, denied `wrong_tenant` | Regenerate either with: @@ -24,12 +25,26 @@ go run ./cmd/flex-auth check \ | Field | `rotate` | `destroy` | | --- | --- | --- | | `binding.request_digest` | `sha256:de67324f…4345` | `sha256:c749ee2…091a` | -| `provenance.policy_package_digest` | `sha256:fe0070b7…bd8c` | same | +| `provenance.policy_package_digest` | `sha256:bd11c5fe…c643` | same | | `provenance.registry_snapshot_digest` | `sha256:f5a309bc…40bb` | same | | `provenance.input_claim_digests.context` | absent (empty context) | `sha256:8b73d29…2800` | Verified identical across two runs. +**Regenerated at `v2`, 2026-09-06.** The package gained the tenant rule it had +been missing, so `provenance.policy_version` is now `v2` and +`policy_package_digest` moved from `sha256:fe0070b7…bd8c` to +`sha256:bd11c5fe…c643`. **Both `request_digest` values are unchanged** — the +canonical digest covers tenant/subject/action/resource/context and not the +package, so a consumer that pinned the digest join has nothing to re-pin. Only +the two provenance fields moved, and they moved because the policy did. + +`decision_wrong_tenant_deny.json` is the denial evidence for the tenant +question: the same `rotate` request as `decision_rotate.json` with +`tenant: tenant:coulomb` substituted. Its `request_digest` differs from the +allow's, which is the replay identity working — the tenant is hashed material, +so the two requests are not the same request. A deny carries no `lifetime`. + | `binding.approval_binding_digest` | absent (no claim) | `sha256:fa07bec…cd56` | That last row is the value an approval's `pdp_digest` must equal — never diff --git a/examples/secrets-engine/replay/decision_destroy_dual_control.json b/examples/secrets-engine/replay/decision_destroy_dual_control.json index 9511d36..9f20bd6 100644 --- a/examples/secrets-engine/replay/decision_destroy_dual_control.json +++ b/examples/secrets-engine/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/examples/secrets-engine/replay/decision_rotate.json b/examples/secrets-engine/replay/decision_rotate.json index e5d04d4..6ac8c8e 100644 --- a/examples/secrets-engine/replay/decision_rotate.json +++ b/examples/secrets-engine/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/examples/secrets-engine/replay/decision_wrong_tenant_deny.json b/examples/secrets-engine/replay/decision_wrong_tenant_deny.json new file mode 100644 index 0000000..0f35353 --- /dev/null +++ b/examples/secrets-engine/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/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md b/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md index 9228282..abead43 100644 --- a/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md +++ b/workplans/FLEX-WP-0021-secrets-engine-consumer-policy-gate.md @@ -4,7 +4,7 @@ type: workplan title: "secrets-engine consumer policy package and cluster-local pin" domain: infotech repo: flex-auth -status: active +status: finished owner: claude topic_slug: netkingdom planning_priority: P1 @@ -122,6 +122,19 @@ supersession (`FLEX-DEC-2026-006`). Per the `FLEX-WP-0010-T02` precedent the denial ladder has no `action_not_granted` branch, because one subject holding all twelve actions could never reach it. +**Reopened and re-closed 2026-09-06 at v2 — the stated gate had not been met.** +This task's gate named "wrong-tenant deny" among the required fixtures and the +package shipped without one, because it had no tenant rule at all: a `rotate` +under `tenant: tenant:coulomb` returned `allow` against the deployed v1 +(`decision:066e629bbf0c0924`). Found while answering `glas-harness`'s +tenant-alignment request, which had asked for exactly that evidence. v2 adds +`wrong_tenant` above `wrong_system`, three Rego tests and three fixtures +(28/28 and 32/32 passing), and supersedes v1 rather than amending it because +the defect failed **open** — see `FLEX-DEC-2026-008`. The `T03` replay digests +are unchanged at v2; only `policy_version` and `policy_package_digest` moved. +The sweep this prompted found the same shape in `tenant-engine` +(`FLEX-WP-0022`). + ## 3. Confirm the digest join against a real decision record ```task @@ -217,7 +230,7 @@ Note this also changes what `callerAuth.mode: warn` is warning about, so the ```task id: FLEX-WP-0021-T05 -status: wait +status: done priority: medium state_hub_task_id: "f0828871-fd65-5d8c-adfd-28b13fedd2b0" ``` @@ -233,6 +246,34 @@ Owner: `flex-auth`. Gate: secrets-engine can set its required configuration to published values and reach a pin; nothing about the fallback-free shape of that configuration changed. +**Done 2026-09-06, with one coordinate that is not flex-auth's to supply.** +Handed back to `secrets-engine` and `glas-harness`: + +```text +Service: http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080 +Package: secrets-engine.catalog-lane.lifecycle +Version: v2 <- not v1; v1 is deployed and superseded +callerAuth.mode: warn (not enforced caller authentication) +``` + +`SCOPE.md` lists secrets-engine among the shipped consumers and +`examples/secrets-engine/README.md` carries the coordinates at source. + +Two things are stated rather than closed, because closing them is not ours: + +1. **The pin serves v1 until a redeploy lands.** The over-permissive package is + live now. Deployment approval is the user's; `FLEX-DEC-2026-008` records the + defect and does not grant it. +2. **There is still no verified access path for a workstation CLI.** Ingress + admits namespace `secrets-engine` with pod label + `app.kubernetes.io/name=secrets-engine`; a CLI on an operator's machine is + not that, and Service DNS is not workstation connectivity. `T04`'s three + shapes remain undecided, and `glas-harness` named the same gap independently. + `secrets-engine` cannot complete adoption on coordinates alone. + +The fallback-free, fail-closed shape of +`SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` is unchanged. + ## Production deployment — 2026-09-06 User authorized production deployment in the Glas session. Installed dedicated diff --git a/workplans/FLEX-WP-0022-tenant-scope-coverage.md b/workplans/FLEX-WP-0022-tenant-scope-coverage.md new file mode 100644 index 0000000..1e175d4 --- /dev/null +++ b/workplans/FLEX-WP-0022-tenant-scope-coverage.md @@ -0,0 +1,108 @@ +--- +id: FLEX-WP-0022 +type: workplan +title: "Tenant scoping is unstated in tenant-engine and untested in two more packages" +domain: infotech +repo: flex-auth +status: proposed +owner: claude +topic_slug: netkingdom +planning_priority: P1 +planning_order: 220 +depends_on_workplans: + - FLEX-WP-0021 +related_workplans: + - FLEX-WP-0010 + - FLEX-WP-0014 +created: "2026-09-06" +updated: "2026-09-06" +--- + +# FLEX-WP-0022 — Tenant scoping is unstated in tenant-engine and untested in two more packages + +Opened by the sweep in `FLEX-DEC-2026-008`, which found +`secrets-engine.catalog-lane.lifecycle` v1 allowing a foreign tenant and then +asked whether the other packages shared the defect. + +## What the sweep found + +| Package | Fixture tenants | Tenant rule | State | +| --- | --- | --- | --- | +| `secrets-engine` | 3 distinct | added at v2 | fixed, `FLEX-DEC-2026-008` | +| `qonto-assistant` | 2 distinct | `wrong_tenant` | fine | +| `user-engine` | 2 distinct | `cross_tenant` | fine | +| `ops-warden` | constant | `wrong_tenant` | rule real, fixtures do not vary it | +| `railiance-platform` | constant | `wrong_tenant` | same | +| `tenant-engine` | constant | **none** | deployed, unscoped | + +Verified against `tenant-engine`: an allowed `tenant.create` re-sent under +`tenant: tenant:coulomb` returns `allow` / `write_api_policy_matched`. + +## Why `tenant-engine` is not the same fix + +`secrets-engine` sends its own calling identity's tenant, so a constant +`known_tenant` is the right rule. `tenant-engine` is different in kind: its +subjects all sit in `tenant:platform` while its fixtures send +`tenant:friendly:binky`, so the request tenant names the **target** of the +operation. A service whose purpose is creating and retiring tenants acts across +them by design, and a constant would break it on its first real call. + +The defect today is therefore not "the rule is missing" but **"nobody can tell +whether it is missing"**: a deliberate cross-tenant scope and an omitted rule +look identical in the artifact. That is the same publishing-shape argument +`gate-house` made for §12's derived-artifact rule. + +## 1. Get the intended tenant relation from tenant-engine + +```task +id: FLEX-WP-0022-T01 +status: todo +priority: high +``` + +Owner: `flex-auth` to ask; `tenant-engine` owns the answer. + +- Ask what the CheckRequest `tenant` denotes on the write API: the caller's + tenant, the target tenant record, or the tenant a guardrail applies to. +- Ask whether any of the nine write actions must be refused cross-tenant, and + whether `tenant.guardrail.read` (which `flex-auth` itself calls) differs. + +Gate: the relation is named by `tenant-engine`, not inferred here. This is the +`FLEX-WP-0021-T01` rule applied to a field rather than to an action list. + +## 2. Encode the relation, or record that there is none + +```task +id: FLEX-WP-0022-T02 +status: wait +priority: high +``` + +Owner: `flex-auth`. + +If a relation exists, encode it in `tenant-engine.write-api.mutate` as a new +version — fail-open corrections are visible as version changes +(`FLEX-DEC-2026-008`) — with a fixture per side. + +If the scope is genuinely unrestricted, say so **in the package**: a stated +"this package is deliberately cross-tenant, because the caller administers +tenants" is a rule a reviewer can check. Silence is not. + +Either way the fixtures must vary `tenant`, so the suite reports on the field. + +## 3. Vary tenant in the two suites that hold it constant + +```task +id: FLEX-WP-0022-T03 +status: todo +priority: medium +``` + +Owner: `flex-auth`. + +`ops-warden` and `railiance-platform` have working `wrong_tenant` rules +exercised only by Rego tests. Add a wrong-tenant deny fixture to each so the +fixture suite covers what the rule claims. No policy change and no version bump: +the behaviour is already correct, only the evidence is thin. + +Gate: no package's fixture suite holds `tenant` constant.