fix(secrets-engine): v2 adds the tenant rule v1 never had
secrets-engine.catalog-lane.lifecycle v1 contained no reference to input.tenant — not in well_formed, not in the denial ladder, not in a test. A rotate on lane:glas-primary under tenant:coulomb returned allow against the deployed package (decision:066e629bbf0c0924). Found while answering glas-harness's tenant-alignment request, which had asked for wrong-tenant denial evidence. There was none to return. Three covers failed the same way: every one of the 29 fixtures carried tenant:platform, so the suite could not report on the field; T02's own gate named "wrong-tenant deny" and was recorded done unmet; and the engine hashes tenant into request_digest but never compares it. Four other published packages carry the branch — this one was the outlier. v2 adds wrong_tenant above wrong_system, three Rego tests and three fixtures (28/28, 32/32). The absent-tenant test caught a second defect in the first draft: a bare input.tenant != comparison is undefined on a missing key, so the branch dropped and the ladder reported the wrong rung. request_tenant := object.get(input, "tenant", "") fixes it. v2 supersedes rather than amends v1 because the defect failed open: a consumer pinned to _VERSION=v1 would keep receiving allows with no signal the rule beneath the version string had changed. The earlier dual-control correction stayed at v1 because it denied everything. Replay envelopes regenerated at v2; both request_digest values are byte-identical, so secrets-engine's digest join needs no re-pinning. The sweep this prompted found tenant-engine unscoped on tenant as well — deployed, and verified allowing tenant:coulomb. Not the same fix: its request tenant names the target rather than the caller, so a constant would break it. Recorded and carried by FLEX-WP-0022 rather than patched unilaterally. FLEX-WP-0021 closes at T05; the pin still serves v1 until a redeploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014aQMM1dPXaPiXVn6DwwtLd Assistant: claude-code Assistant-Model: opus Assistant-Process: 715613@bnt-lap001 Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
This commit is contained in:
parent
a81697a589
commit
d98323b2bb
12 changed files with 690 additions and 28 deletions
16
SCOPE.md
16
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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
examples/secrets-engine/check_request_deny_wrong_tenant.json
Normal file
23
examples/secrets-engine/check_request_deny_wrong_tenant.json
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": {}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
104
examples/secrets-engine/replay/decision_wrong_tenant_deny.json
Normal file
104
examples/secrets-engine/replay/decision_wrong_tenant_deny.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
108
workplans/FLEX-WP-0022-tenant-scope-coverage.md
Normal file
108
workplans/FLEX-WP-0022-tenant-scope-coverage.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue