docs: operator caller access path, and the caller is absent from the record
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

glas-harness asked for a supported owner access path with authenticated
caller binding and lifetime, ruling out Service DNS and a permanent
operator token. Both refusals are correct and the answer needs no new
mechanism: a TokenRequest token is short-lived, audience-scoped, and
bound by exact sub to the ServiceAccount the deployed pin already names.

Two findings came out of designing it.

First, for an operator caller the pin's NetworkPolicy is not a partial
control, it is silent: kubectl port-forward is proxied to the pod's own
loopback and no policy selector is consulted. So under warn the operator
path is unauthenticated and unfiltered, and the only reason it is not
reachable is that nobody has forwarded the port. warn was safe for
ops-warden because a workload pin still admitted one pod; here the warn
window protects nothing while it runs. enforce is the deliverable.

Second, the ServiceAccount named by the deployed binding does not exist.
Namespace secrets-engine holds only default, so enforce today would deny
every request rather than authenticate one.

Third, and this is the contract one: the decision record has no caller
field. provenance carries evaluator, mode, policy and registry digests
and decision time; binding carries the normalized request. So the four
negative tests can all pass and no artifact retains that they passed for
the request that mattered. Same seam as FLEX-DEC-2026-008 one layer up —
there a tenant was carried into the digest and never compared, visible
but not enforced; here a caller is authenticated and never recorded,
enforced but not visible.

provenance.caller, not binding.caller: the same request from a different
authenticated caller must decide identically, so the caller is not
replay identity and must not move request_digest. FLEX-DEC-2026-009.

Live receipts are not included. kubectl create token is credential
minting and was refused in this session, correctly; the commands are
exact and the expectations read out of internal/callerauth/auth.go, but
nothing is claimed as verified that was not run.

Also records the operator's tenant:platform decision
(5ed3fb35-eca9-413a-82b9-95171ba85bf6) and verifies that v2 already
enforces exactly it — exact string equality, no alias, no normalisation.

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 22:33:21 +02:00
parent a96e970007
commit bf649ee17d
3 changed files with 459 additions and 0 deletions

View file

@ -1105,6 +1105,26 @@ flex-auth answers only for the values it owns and does not map the other two.
| 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 |
**Resolved by the operator, 2026-09-06.** `glas-harness` decision
`5ed3fb35-eca9-413a-82b9-95171ba85bf6`
(`glas-harness/docs/platform-tenant-decision.md`): `tenant:platform` is accepted
as the platform management, administration and services tenant — the landlord
zone — with exact spelling required across the approval store, both proposed
service-client JWT claims, and the lifecycle CheckRequest. No alias to
`platform` or `tenant:coulomb`, and no implicit cross-tenant grant.
**The operator's choice is what v2 already enforces, and flex-auth widens
nothing.** Verified rather than asserted: `known_tenant := "tenant:platform"`
compared by exact string equality, no case folding and no normalisation of
`tenant` anywhere in the evaluation path. The only occurrences of
`tenant:coulomb` in the package are the correction narrative and the three deny
tests. `tenant:platform` is a single constant, so registering a second tenant
would be a visible edit to it rather than a configuration change.
This resolves the *choice*. It does not resolve verification or the remaining
admission gates, as `glas-harness` was explicit about — the caller access path
is `FLEX-WP-0023`.
**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
@ -1158,3 +1178,129 @@ 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`.
---
## FLEX-DEC-2026-009 — The decision record cannot show who called: caller identity is absent from `flex-auth.decision-record.v1`
**Date:** 2026-09-06
**Status:** accepted, gap open
**Workplan:** `FLEX-WP-0023`
**Raised by:** flex-auth, while designing the operator access path `glas-harness` asked for
## Context
`glas-harness` asked for "a supported owner access path and authenticated caller
binding/lifetime with positive and negative tests", and ruled out Service DNS
and a permanent operator token. Designing that path
(`docs/operator-caller-access-path.md`) produced the answer — a `TokenRequest`
token, short-lived, audience-scoped, bound by exact `sub` to one
ServiceAccount — and then produced a question the design cannot answer.
**Where does the authenticated caller appear in the artifact?** It does not.
`flex-auth.decision-record.v1` has no caller field. `provenance` carries
`evaluator`, `mode`, `policy_package_digest`, `registry_snapshot_digest`,
`policy_version`, and `decision_time`. `binding` carries the normalized tenant,
subject, action, resource, context and the request digest. The word `caller`
does not occur in the schema.
## Why this is not the same as the subject
It is tempting to answer that the subject *is* the caller, and for the
`secrets-engine` lane it happens to be: `subject.id` is `secrets-engine` and the
bound principal is `system:serviceaccount:secrets-engine:secrets-engine`. That
coincidence is exactly what makes the gap easy to miss.
They are different claims, and `callerAuth` exists because they are:
- **`subject`** is an input to the decision. It is asserted by the caller and
the policy reasons about it.
- **the caller principal** is authenticated by flex-auth against the cluster
and is *not* an input to the decision. It gates whether the request may be
made at all, per resource system.
A caller bound to `secrets-engine` may present any `subject` its policy
permits, and a request whose `subject` is `secrets-engine` may arrive from an
unauthenticated caller — which is precisely the state under `warn`. Reading the
subject as evidence of the caller reads an input as if it were a verification.
## The consequence, in the terms the consumer asked in
`glas-harness` wants a scoped delivery receipt. What the record supports today
is:
> this decision permits this action on this resource under this policy version
What it does not support, and what was asked for:
> this caller was authenticated, under this identity, with this lifetime, when
> it obtained this decision
The four negative tests in the access-path design (missing header, wrong
ServiceAccount, wrong audience, expired token) can all pass, and **no artifact
retains the fact that they passed for the request that mattered.** The
verification is real and leaves no trace. That is the defect.
It is also the same shape as `FLEX-DEC-2026-008`, one layer up: there, a tenant
was carried into the digest and never compared — visible but not enforced. Here,
a caller is authenticated and never recorded — enforced but not visible. A
control that leaves no evidence and evidence that exerts no control are the two
ways the same seam opens.
## Decision
**The caller principal belongs in `provenance`, not in `binding`.**
`binding` is the §6.4.2 replay identity: the material the decision is a function
of. The caller is deliberately not that material — the same request from a
different authenticated caller must decide identically, or the caller has become
a policy input by the back door. Putting it in `binding` would change
`request_digest`, break every consumer's replay join, and assert something
false about what the decision depends on.
`provenance` is where flex-auth records how the decision was produced, which is
exactly what an authentication event is. The shape, additive:
```json
"provenance": {
"caller": {
"mode": "enforce",
"principal": "system:serviceaccount:secrets-engine:secrets-engine",
"audience": "flex-auth",
"not_after": "2026-09-06T20:10:00Z"
}
}
```
`mode` is required and load-bearing: a record from a `warn`-mode pin must say
so, because a `principal` recorded under `warn` was observed and not enforced,
and a reader who cannot tell those apart will read an unverified string as
verification. Under `disabled` the object is `{"mode": "disabled"}` with no
principal — absence stated rather than a missing key, following the
`pdp_digest` precedent from `approval-engine` (`FLEX-DEC-2026-007`).
`not_after` comes from the reviewed token's `exp`. `internal/callerauth.Identity`
carries only `Username` and `Audiences` today, so the expiry is validated by
`TokenReview` and then discarded. It has to be captured to be recorded, which is
the one real code change here.
## Consequences
- Additive to `flex-auth.decision-record.v1`. No digest changes meaning and no
existing consumer field moves — the same reasoning that let
`approval_binding_digest` be additive in `FLEX-DEC-2026-007`.
- Every consumer's replay join is unaffected, because `request_digest` is
computed over `binding` and the caller is not in `binding`. Stated explicitly
so nobody re-pins in response to this record, as they nearly did for v2.
- `security-layer-model` §17 makes the decision-record schema flex-auth's, so
this is ours to change and ours to have missed. Worth reporting to
`gate-house` as a finding against the v0.8 round rather than only fixing:
§9.7.2 promoted registry-snapshot provenance to a conformance prerequisite on
the argument that a decision turning on registry content must be replayable
from its own record. A decision **gated** by caller authentication is not
auditable from its own record by the same argument, and the standard does not
ask for it.
- Does not block the access path. The `TokenRequest` shape stands on its own;
this makes its result visible in the artifact rather than only in the pin's
logs.