fix(secrets-engine): v2 adds the tenant rule v1 never had
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 41s

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:
tegwick 2026-09-06 20:38:45 +02:00
parent a81697a589
commit d98323b2bb
12 changed files with 690 additions and 28 deletions

View file

@ -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`.