From bf649ee17d1c0c50d9ee2b13ab8d9ebf4bda5409 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 22:33:21 +0200 Subject: [PATCH] docs: operator caller access path, and the caller is absent from the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- decisions/decisions.md | 146 ++++++++++++++ docs/operator-caller-access-path.md | 134 +++++++++++++ ...LEX-WP-0023-operator-caller-access-path.md | 179 ++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 docs/operator-caller-access-path.md create mode 100644 workplans/FLEX-WP-0023-operator-caller-access-path.md diff --git a/decisions/decisions.md b/decisions/decisions.md index 6488361..1db2a96 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -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. diff --git a/docs/operator-caller-access-path.md b/docs/operator-caller-access-path.md new file mode 100644 index 0000000..39e46d3 --- /dev/null +++ b/docs/operator-caller-access-path.md @@ -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. diff --git a/workplans/FLEX-WP-0023-operator-caller-access-path.md b/workplans/FLEX-WP-0023-operator-caller-access-path.md new file mode 100644 index 0000000..b1b0945 --- /dev/null +++ b/workplans/FLEX-WP-0023-operator-caller-access-path.md @@ -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: N1–N4 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.