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.