135 lines
6.7 KiB
Markdown
135 lines
6.7 KiB
Markdown
|
|
# 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.
|