The live proof in 03c0569 showed validate_decision_envelope rejecting every
real allow. The evaluator normalizes before hashing -- the request tenant is
copied onto subject and resource, and a registry hit copies type, tenant and
selected attributes onto the refs -- so binding.request_digest covers
material we never sent. Byte-equality against our unenriched request was
unsatisfiable, not merely mismatched.
THE RULE WAS ALREADY PUBLISHED. flex-auth's canonical-request-digest.md
section "Normalization" states the enrichment and tells consumers what to do
instead: compare structured binding fields to the proposed action, treat
request_digest as the evaluator's statement of what it hashed, and recompute
independently over the tuple the binding carries. I raised this with them as
an unpublished gap and asked them to pick between three shapes; it was in
their contract already and the answer was the first of the three. Nothing
was blocked on them, and this follows the published rule rather than one I
inferred.
- _require_binding_corresponds: everything we proposed must survive
unchanged -- tenant, action, context, subject.id/type,
resource.id/type/system, and every attribute we sent.
- Enrichment may add only type, tenant, attributes. Any other added field is
refused, and an enriched tenant must be the request tenant, so a
cross-tenant binding cannot arrive wearing our request's clothes.
- request_digest is still verified, now against binding_tuple(binding) for
self-consistency rather than against material we never sent.
- The envelope's top-level subject/resource get the same rule; they are
enriched too.
Proved against the artifact: the real decision:0f9c98f14545c42d now
validates, and the unrefreshed envelope is refused on lifetime -- reaching
the lifetime check at all is the evidence the binding checks pass on a real
decision. Negatives cover a restated resource.attributes.stage, a foreign
subject.tenant, and an unexpected enrichment field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715726@bnt-lap001
Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092
163 lines
7.8 KiB
Markdown
163 lines
7.8 KiB
Markdown
# Tenant alignment
|
|
|
|
Answer to the `GLAS-WP-0015` production-dependency handoff question, which asked
|
|
this engine to state its exact tenant values and produce wrong-tenant denial
|
|
evidence rather than assume a mapping. Related: `KEY-WP-0013-T02`,
|
|
`APPROVAL-WP-0002-T01`, `SECRETS-WP-0009-T03`, `FLEX-WP-0021-T05`.
|
|
|
|
The handoff's framing is adopted here without reservation: **spelling similarity
|
|
is not a mapping**, and JWT/store comparison is exact. A tenant "mapping"
|
|
invented by a consumer is the same fail-open shape gate-house rejected for
|
|
action vocabularies in `GH-DEC-2026-008` — a translation can be confidently
|
|
wrong — arriving by a different road.
|
|
|
|
## The three values, as this repo actually holds them
|
|
|
|
| Value | Where it lives | Owner |
|
|
| --- | --- | --- |
|
|
| CheckRequest tenant | `authorization.REQUEST_TENANT` = `tenant:platform` | flex-auth package |
|
|
| KeyCape JWT tenant | `service_auth.TENANT` = `tenant:coulomb` | key-cape |
|
|
| Approval store tenant | `platform` (not held here) | approval-engine |
|
|
|
|
### CheckRequest tenant: `tenant:platform`, and it was missing entirely
|
|
|
|
`secrets-engine.catalog-lane.lifecycle` **v2** reads
|
|
|
|
```rego
|
|
known_tenant := "tenant:platform"
|
|
request_tenant := object.get(input, "tenant", "")
|
|
```
|
|
|
|
and its `wrong_tenant` first-denial branch fires on anything else. `object.get`
|
|
with a `""` default is deliberate: **an absent tenant is a denial, not an
|
|
ignored field.**
|
|
|
|
`build_action_request` emitted no `tenant` field at all. Every gated action
|
|
this engine sent would have been denied `wrong_tenant` by the deployed v2
|
|
package — and, worse, the omission also produced a `request_digest` that could
|
|
match no correctly issued decision, because `tenant` is hashed material. Fixed:
|
|
the request now carries `REQUEST_TENANT`, an empty tenant is refused at build
|
|
time, and the constant is pinned against the vendored allow envelopes so a
|
|
package retenanting surfaces as a test failure rather than a production denial.
|
|
|
|
### KeyCape JWT tenant: `tenant:coulomb` — and that is the value the package denies
|
|
|
|
This is the uncomfortable part and it is stated plainly rather than smoothed
|
|
over. `service_auth.TENANT` is `tenant:coulomb`, preflighted on the KeyCape
|
|
`client_credentials` token for the accepted `secrets-engine-openbao` service
|
|
identity. `tenant:coulomb` is **exactly** the value flex-auth used to
|
|
demonstrate a wrong-tenant denial (`decision_wrong_tenant_deny.json`,
|
|
`matched_rule: wrong_tenant`).
|
|
|
|
Two readings are possible and this engine does not choose between them:
|
|
|
|
1. They name **two different layers** — an identity/tenancy tenant for the
|
|
KeyCape client, and a resource-scoping tenant for the policy package — which
|
|
happen to use one namespace format.
|
|
2. One of the two constants is **wrong**.
|
|
|
|
Reading 1 is plausible and is probably right, but "probably right" is not a
|
|
contract. Both constants stay as they are, deliberately not unified behind one
|
|
symbol, until an owner-reviewed mapping exists with a decision reference. This
|
|
engine will consume that mapping; it will not author it.
|
|
|
|
### Approval store tenant: `platform`
|
|
|
|
Not held in this repo. Noted only because the handoff asked for all three: the
|
|
comparison between `platform` and `tenant:platform` is a prefix difference, and
|
|
a prefix difference is precisely the kind of similarity that must not be treated
|
|
as identity without an owner saying so.
|
|
|
|
## Wrong-tenant denial evidence
|
|
|
|
From flex-auth's real v2 deny envelope, vendored at
|
|
`tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json`:
|
|
|
|
```text
|
|
decision:7d56b7fc274ddfd6 effect: deny reason: wrong_tenant
|
|
matched_rule: wrong_tenant policy_version: v2
|
|
binding.tenant: tenant:coulomb
|
|
binding.request_digest: sha256:c9c6e6f8...0d20
|
|
```
|
|
|
|
flex-auth's fixture varies the tenant on an otherwise-valid `rotate`, so a deny
|
|
proves the tenant alone carried it. `tests/test_decision_replay.py` asserts our
|
|
consumer refuses it on `effect` before anything else, and separately pins that a
|
|
deny carries **no** `lifetime` — a consumer checking lifetime before effect would
|
|
raise a confusing missing-field error on a well-formed denial.
|
|
|
|
## Why v1 must not be pinned
|
|
|
|
`v1` shipped and was deployed with no reference to `input.tenant` at all. Every
|
|
fixture carried `tenant:platform`, so the package's own coverage could not
|
|
notice, and `FLEX-WP-0021-T02`'s "wrong-tenant deny" gate was recorded as met
|
|
when it was not. A `rotate` under `tenant:coulomb` returned **allow** against the
|
|
deployed v1 package (`decision:066e629bbf0c0924`).
|
|
|
|
flex-auth superseded v1 rather than amending it, on the principle that **a
|
|
fail-open correction has to be visible as a version change; a fail-closed one
|
|
does not.** A consumer still pinned to `v1` would keep receiving allows it should
|
|
never have had, unable to tell from the version string that the rule moved
|
|
underneath it. `test_the_superseded_v1_package_is_not_accepted` pins that this
|
|
engine refuses a v1 decision.
|
|
|
|
Note the ownership point flex-auth recorded with it: the evaluator hashes
|
|
`tenant` and carries it 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.** Our own omission was the
|
|
mirror-image defect on the consumer side, and neither side's tests could see it
|
|
alone.
|
|
|
|
## Still open
|
|
|
|
- The owner-reviewed JWT/store/CheckRequest mapping, with a decision reference.
|
|
Until it exists, no live client or policy subject changes here.
|
|
- ~~A supported owner access path~~ — **delivered**. Loopback `kubectl
|
|
port-forward` plus a bounded `TokenRequest` token; see
|
|
`docs/pdp-access-path.md`. Adopted and enforced in
|
|
`decision_check.require_supported_pdp_address`.
|
|
- ~~The evaluator's enrichment rule~~ — **already published** in flex-auth's
|
|
`canonical-request-digest.md` ("Normalization"), and now implemented. See
|
|
`docs/pdp-access-path.md`.
|
|
|
|
## Hazard: the Service DNS name resolves here, to the wrong host
|
|
|
|
Probed from the workstation 2026-09-06. This is worse than "not reachable" and
|
|
is the reason the access path must be handed over explicitly rather than assumed
|
|
from a Service name.
|
|
|
|
```text
|
|
$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local
|
|
80.158.43.29 flex-auth-secrets-engine.flex-auth.svc.cluster.local.ad.binect.de
|
|
|
|
$ getent hosts this-service-does-not-exist.flex-auth.svc.cluster.local
|
|
80.158.43.29 this-service-does-not-exist.flex-auth.svc.cluster.local.ad.binect.de
|
|
|
|
$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local. # trailing dot
|
|
(no resolution)
|
|
```
|
|
|
|
`/etc/resolv.conf` carries `search ad.binect.de fritz.box`, and `ad.binect.de`
|
|
answers wildcard, so **every** `*.svc.cluster.local` name resolves on this
|
|
workstation to one unrelated public address. A nonexistent service resolves
|
|
identically, which proves it is suffix expansion rather than any real record.
|
|
|
|
Consequences if `SECRETS_ENGINE_PDP_URL` were pointed at the Service name from a
|
|
workstation:
|
|
|
|
- The `POST /v1/check` body — subject id/type, tenant, lane/resource ids, stage,
|
|
declared field names, purpose — would go to an arbitrary internet host. It
|
|
contains no secret values, but it is a structural map of the estate's
|
|
credential lanes.
|
|
- The static Bearer token would be sent to that host.
|
|
- Decision envelopes are structurally validated but **not signed**. A responder
|
|
that knows the package name and version can return a well-formed
|
|
`effect: allow`, and this engine's checks would pass it. The fail-closed
|
|
production posture assumes the PDP is the PDP.
|
|
|
|
This is not fixed here, because choosing the transport control is the owners'
|
|
call, not this consumer's. Recommended for `FLEX-WP-0021-T05`: hand over a
|
|
trailing-dot FQDN or an explicit address, and state the authentication of the
|
|
response channel (mTLS, or a signed envelope) rather than leaving an unsigned
|
|
allow over plaintext HTTP as the contract. This engine keeps its pin unset in
|
|
the meantime, which is why the hazard is currently theoretical.
|