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
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue