Adoption asked for by flex-auth (FLEX-WP-0021-T05) and glas-harness (GLAS-WP-0015), plus the first real decision this engine has obtained from the deployed pin -- which found a defect the fixtures could not. ACCESS PATH. require_supported_pdp_address refuses in-cluster Service names and any non-loopback host. This is no longer a unilateral call: the owner path is documented as loopback kubectl port-forward over the authenticated Kubernetes API, which is what authenticates the responder transitively (FLEX-DEC-2026-010). A Service name from a workstation does not fail, it resolves through the DNS search suffix to an unrelated public host, and since decision records carry no signature, a responder knowing the published package and version can return an allow that passes every check we make. Fail-closed protects against a PDP that is absent, not one that lies. The guard runs before the token is read, so a misdirected request cannot leak it; a test pins that ordering. LIVE PROOF. Minted a 10-minute TokenRequest token (audience flex-auth, SA secrets-engine/secrets-engine, mode 0600 outside the worktree, shredded after), forwarded to the named pod, and sent a real CheckRequest for glas-claude-agent-dev-anthropic. Result: allow, catalog_lane_policy_matched, served by v2 (sha256:bd11c5fe...) -- so the redeploy flex-auth flagged as outstanding has landed and the pin no longer serves the tenant-blind v1. Our tenant fix is confirmed against the real service: binding.tenant is tenant:platform. THE DEFECT IT FOUND. The evaluator enriches from its registry before hashing -- subject gains attributes and tenant, resource gains tenant -- so binding.request_digest is over material we never sent and cannot reproduce. validate_decision_envelope rejects every real allow. Every replay test passes because _request_from() rebuilds the request out of the binding, i.e. the enriched form: a self-consistent fake agreeing with itself, which hid this through three rounds of digest work. Third time a real artifact has beaten a fake in this integration. NOT FIXED, DELIBERATELY. Rejecting a valid allow is wrong in the safe direction. Which fields may be enriched is flex-auth's contract to publish; inferring it means accepting a binding that differs from our proposal in a way we decided was benign -- the fail-open shape GH-DEC-2026-008 rejected for vocabularies and FLEX-DEC-2026-007 for digests. Raised with them. Tenant question closed by operator decision 5ed3fb35: tenant:platform exactly, and service_auth.TENANT stays tenant:coulomb because the two identity layers are to remain distinct. Declining to author that mapping was right -- the answer was neither reading offered. 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.9 KiB
Markdown
163 lines
7.9 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**: `binding` carries registry-enriched
|
|
`subject.attributes`, `subject.tenant` and `resource.tenant` that we did not
|
|
send, so the digest join fails against a real request. flex-auth's to publish.
|
|
|
|
## 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.
|