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
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue