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.

View file

@ -0,0 +1,134 @@
# Operator caller access path
**Status:** design published, live receipts outstanding
**Opened by:** `glas-harness` (`GLAS-WP-0015`, 2026-09-06), carried by `FLEX-WP-0023`
**Supersedes:** the workload assumption in `FLEX-WP-0021-T04`
`secrets-engine` is an operator CLI, not a Kubernetes workload. Every existing
flex-auth pin assumes a workload: default-deny ingress admitting one pod
selector, and a `--caller-binding` naming that pod's ServiceAccount. Neither
half transfers unexamined, and `glas-harness` ruled out the two shortcuts by
name — **Service DNS is not connectivity, and a permanent operator token is not
an identity**. Both refusals are correct.
## The two gates are not one gate
`FLEX-WP-0021-T04` noted that `callerAuth` "becomes the real boundary" for an
operator path. That was understated. For this path the network gate provides
**no protection at all**, and it is worth being exact about why.
`kubectl port-forward` does not traverse a `NetworkPolicy`. The connection is
proxied through the API server to the kubelet and delivered on the pod's own
loopback interface, so it never appears as pod-to-pod ingress and no policy
selector is consulted. The pin's default-deny `NetworkPolicy` is therefore not
a partial control for an operator caller — it is silent.
So the honest statement of the current posture is stronger than "warn mode":
> With `callerAuth.mode: warn` and a port-forwarded connection, the operator
> path to the `secrets-engine` pin is **unauthenticated and unfiltered**. The
> only reason it is not reachable today is that nobody has forwarded the port.
That is not a supported access path. It is the absence of one.
## The supported path
Kubernetes already issues exactly the credential shape being asked for, and
flex-auth already accepts it. Nothing needs inventing and no code changes.
```bash
# short-lived, audience-scoped, cluster-issued, bound to one ServiceAccount
kubectl -n secrets-engine create token secrets-engine \
--audience=flex-auth \
--duration=10m
```
The `TokenRequest` API mints a token for a ServiceAccount **without a pod**.
Checked against what the deployed pin actually requires:
| Requirement | How this satisfies it |
| --- | --- |
| authenticated | `TokenReview` validates signature, audience, and expiry server-side |
| bound to one system | token `sub` is `system:serviceaccount:secrets-engine:secrets-engine`, matched against `--caller-binding` by exact string |
| stated lifetime | `--duration`; the token carries `exp` and `TokenReview` refuses it after |
| not a permanent operator token | expires on its own; the operator stores no long-lived secret |
| audience-scoped | `--audience=flex-auth` matches the pin's `--caller-audience` default; a token minted for any other audience fails |
The deployed pin already names the identity:
```text
--caller-binding secrets-engine=system:serviceaccount:secrets-engine:secrets-engine
--caller-audience flex-auth (default)
```
## Two blockers, one of them load-bearing
**1. The ServiceAccount named by the binding does not exist.** Namespace
`secrets-engine` was created under `FLEX-WP-0021-T04` with no workload and no
identity; it holds only `default`. So there is nothing to mint a token *for*,
and flipping to `enforce` today would deny every request rather than
authenticate one. Creating it is a one-object production write, and an SA with
no `RoleBinding` grants nothing in the cluster — its only function is to be the
name in the binding above.
**2. `warn` cannot be the mode for this path.** For a workload, `warn` is a safe
migration state because the `NetworkPolicy` still admits only one pod. For an
operator caller the policy is silent, so `warn` means *no* control. The
migration order that applied to `ops-warden` (`FLEX-WP-0016`: adopt identity,
clean warn logs, then enforce) still applies, but the warn window here is a
window with nothing in it — it proves the token works and protects nothing while
it runs. **Keep it short and treat `enforce` as the deliverable, not the
follow-up.**
## Positive and negative tests
Run against the pin through a port-forward, then remove the forward.
```bash
kubectl -n flex-auth port-forward svc/flex-auth-secrets-engine 8080:8080 &
# positive — correct identity, correct audience, inside lifetime
TOKEN=$(kubectl -n secrets-engine create token secrets-engine --audience=flex-auth --duration=10m)
curl -s -X POST localhost:8080/v1/check -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d @examples/secrets-engine/check_request_allow_rotate.json
# expect: 200, effect allow, reason catalog_lane_policy_matched, policy_version v2
```
Four negatives, each isolating one property. Under `enforce` all four must be
refused before the request reaches policy; under `warn` all four are *allowed*,
which is the finding rather than a passing test.
| # | Credential | Expected under `enforce` |
| --- | --- | --- |
| N1 | no `Authorization` header | 401 — `ErrUnauthenticated` |
| N2 | token for `secrets-engine:default` (wrong SA, right audience) | 403 — principal cannot represent system |
| N3 | token minted without `--audience=flex-auth` | 401 — audience not in token |
| N4 | token past `exp` (mint `--duration=10m`, use after) | 401 — `TokenReview` refuses |
N2 and N4 are the two that matter. N2 proves the binding is a binding and not
mere presence of a valid cluster token — any of thousands of ServiceAccounts
could produce a well-formed token, and only one may represent `secrets-engine`.
N4 proves the lifetime is enforced by the issuer rather than asserted by the
caller.
**These receipts are not in this document because this session could not mint
tokens.** `kubectl create token` is credential issuance and was refused here, as
it should be. The commands above are exact and the expectations are derived from
`internal/callerauth/auth.go`, not guessed; running them is an operator action.
Nothing below the design line is claimed as verified.
## What the record will not show
Even with all four negatives passing, **the decision record does not say who
called.** `flex-auth.decision-record.v1` has no caller field: `provenance`
carries the evaluator, mode, policy and registry digests, and decision time, and
`binding` carries the normalized request. The authenticated caller principal —
the thing these tests are about — appears nowhere in the artifact.
So a decision record proves the *subject* was allowed. It cannot prove the
*caller* who obtained it was authenticated, or under what lifetime. For
`glas-harness`, whose ask is a scoped delivery receipt, that is the difference
between "this decision permits the action" and "this caller was permitted to
obtain this decision". Recorded as `FLEX-DEC-2026-009`; it is a gap in
flex-auth's own §17 contract, not in the deployment.

View file

@ -0,0 +1,179 @@
---
id: FLEX-WP-0023
type: workplan
title: "Operator caller access path and caller identity in the decision record"
domain: infotech
repo: flex-auth
status: ready
owner: claude
topic_slug: netkingdom
planning_priority: P1
planning_order: 230
depends_on_workplans:
- FLEX-WP-0021
related_workplans:
- FLEX-WP-0016
- SECRETS-WP-0009
created: "2026-09-06"
updated: "2026-09-06"
---
# FLEX-WP-0023 — Operator caller access path and caller identity in the decision record
Opened by `glas-harness` (`GLAS-WP-0015`, 2026-09-06), which asked flex-auth to
keep the access-path gate live work despite `FLEX-WP-0021-T05` closing, and to
return "a supported owner access path and authenticated caller binding/lifetime
with positive and negative tests". They ruled out Service DNS and a permanent
operator token by name. Both refusals are correct.
Design: [`../docs/operator-caller-access-path.md`](../docs/operator-caller-access-path.md).
Contract gap: `FLEX-DEC-2026-009`.
## The finding that reframes the task
For an operator caller the pin's default-deny `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 with `callerAuth.mode: warn`,
the operator path is unauthenticated *and* unfiltered, and the only reason it is
not reachable today is that nobody has forwarded the port.
`warn` was a safe migration state for `ops-warden` because a workload pin still
admitted exactly one pod. Here the warn window protects nothing while it runs.
**`enforce` is the deliverable, not the follow-up.**
## 1. Create the ServiceAccount the deployed binding already names
```task
id: FLEX-WP-0023-T01
status: todo
priority: high
```
Owner: `flex-auth`; production write, operator approval required.
The pin runs with
`--caller-binding secrets-engine=system:serviceaccount:secrets-engine:secrets-engine`
and namespace `secrets-engine` holds only `default`. There is nothing to mint a
token for, and `enforce` today would deny every request rather than
authenticate one.
- Create ServiceAccount `secrets-engine` in namespace `secrets-engine`.
- No `Role`, no `RoleBinding`, no `automountServiceAccountToken`. An SA with no
binding grants nothing in the cluster; its only function is to be the name in
the binding above.
- Add it to the overlay rather than applying it loose, following
`deploy/caller-auth-rbac.yaml`.
Gate: `kubectl -n secrets-engine create token secrets-engine --audience=flex-auth
--duration=10m` returns a token whose `sub` equals the bound principal exactly.
## 2. Run the positive and negative tests and return the receipts
```task
id: FLEX-WP-0023-T02
status: wait
priority: high
```
Owner: `flex-auth` to run; `glas-harness` to receive.
Procedure and expectations are in the design doc. One positive (correct SA,
correct audience, inside lifetime, expecting allow at `policy_version: v2`) and
four negatives, each isolating one property:
| # | Credential | Expected under `enforce` |
| --- | --- | --- |
| N1 | no `Authorization` header | 401 |
| N2 | token for `secrets-engine:default` | 403, principal cannot represent system |
| N3 | token minted without `--audience=flex-auth` | 401 |
| N4 | token past `exp` | 401 |
N2 and N4 are the load-bearing ones. N2 proves the binding is a binding rather
than mere possession of a valid cluster token — thousands of ServiceAccounts can
produce a well-formed one and only one may represent `secrets-engine`. N4 proves
the lifetime is enforced by the issuer rather than asserted by the caller.
**Blocked on credential issuance, not on design.** `kubectl create token` is
credential minting and was refused in the 2026-09-06 session that wrote this
plan, correctly. The commands are exact and the expectations are read out of
`internal/callerauth/auth.go`; running them is an operator action. Under `warn`
all four negatives are *allowed*, so running them before `T03` records the
finding rather than a passing test.
Gate: five receipts returned to `glas-harness`, each naming which property it
isolates. Nothing is reported as verified that was not run.
## 3. Flip `callerAuth.mode` to enforce
```task
id: FLEX-WP-0023-T03
status: wait
priority: high
```
Owner: `flex-auth`; deployment approval required.
- Flip only the `flex-auth-secrets-engine` pin. Do not roll the other three;
they are pinned to their own digests so one change does not move another
consumer (`FLEX-WP-0016`).
- Confirm the warn logs are clean for the adopted identity first, as
`FLEX-WP-0016` did for `ops-warden` — but keep the window short, per the
finding above.
- Re-run `T02`'s four negatives after the flip. Before it they document the
gap; after it they are the test.
Gate: N1N4 refused, positive still allowed, other three pins unchanged.
`glas-harness`'s standing instruction — do not turn warn into enforce before
caller adoption is proved — is satisfied by `T02`, not bypassed by this task.
## 4. Record the authenticated caller in the decision record
```task
id: FLEX-WP-0023-T04
status: todo
priority: high
```
Owner: `flex-auth`.
`FLEX-DEC-2026-009`: the verification in `T02`/`T03` is real and leaves no
trace. `flex-auth.decision-record.v1` has no caller field, so a record proves
the subject was allowed and cannot prove the caller who obtained it was
authenticated.
- Add `provenance.caller``mode` (required), `principal`, `audience`,
`not_after`. Not `binding`: the caller is deliberately not decision material,
and putting it there would change `request_digest` and break every consumer's
replay join.
- `mode` is load-bearing. A `principal` recorded under `warn` was observed, not
enforced, and a reader who cannot tell those apart reads an unverified string
as verification. Under `disabled`, emit `{"mode": "disabled"}` — absence
stated, following the `pdp_digest` precedent.
- Capture the reviewed token's `exp` for `not_after`.
`internal/callerauth.Identity` carries only `Username` and `Audiences`, so the
expiry is validated by `TokenReview` and then discarded. This is the one real
code change.
- Update `schemas/decision_envelope.schema.json` and
`docs/decision-record-contract.md`; state in both that `request_digest` is
unaffected, so nobody re-pins in response.
Gate: a decision obtained under `enforce` names its caller and lifetime; the
same request's `request_digest` is byte-identical to the pre-change value.
## 5. Report the gap to gate-house as a v0.8 finding
```task
id: FLEX-WP-0023-T05
status: wait
priority: medium
```
Owner: `flex-auth`.
§17 makes the decision-record schema flex-auth's, so this gap is ours to have
missed — and the standard does not ask for what §9.7.2's own argument implies.
§9.7.2 promoted registry-snapshot provenance to a conformance prerequisite
because 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.** Carry it into the outstanding v0.8 assent
review rather than as a separate message.