docs: operator caller access path, and the caller is absent from the record
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:
parent
a96e970007
commit
bf649ee17d
3 changed files with 459 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue