Skip to content

Commit 666c9bc

Browse files
authored
docs(workload-identity): make the Kubernetes story accurate (#92)
* docs(workload-identity): make the Kubernetes story accurate Verified against server source, not against prior docs. Whether this feature works on a cluster is decided by one thing: the issuer that cluster publishes. EKS, GKE and AKS publish a public https issuer with public OIDC discovery by default, so they work with no configuration beyond registering the trusted issuer. Only clusters left on the upstream default (kubeadm, kind, k3d) publish private addresses the SSRF guard refuses. The page did not distinguish these, so an operator could not tell which case they were in. Adds: - A matrix keyed on `kubectl get --raw /.well-known/openid-configuration`, so an operator self-diagnoses in one command. - The JWKS-mirror recipe for default-issuer clusters, with the reason it works: issuer_url is matched against `iss` and NEVER fetched under static_jwks_url — only oidc_discovery dials it. Pinned server-side by TestPrivateIssuerURLIsNeverDialedUnderStaticJWKS. - A rotation warning on the mirror. It fails in both directions: a stale mirror stops validating new tokens, and one still serving a retired key keeps validating tokens it should not. Authorizer's own 10-minute JWKS cache bounds its staleness; the mirror's is the operator's to manage. Corrects: - spiffe_bundle_endpoint is now REJECTED at write time, not "accepted but inactive". - TokenReview has no mirror equivalent — it is a live call, so a cluster with no reachable API endpoint should leave it off rather than look for a workaround. Offline JWKS validation still authenticates the workload. - kubernetes_api_server_url is security-sensitive: the configured host receives Authorizer's own ServiceAccount token, so it is a trusted-host field and the ClusterRole should stay system:auth-delegator. * docs: correct the jti claim in the replay rule The replay row said Kubernetes SA tokens carry no jti and used that to justify the (iss, sub, iat, exp) fallback. Verified against a live cluster: a projected token's claims are aud, exp, iat, iss, jti, kubernetes.io, nbf, sub — it does carry one, and keys on it. The fallback is still correct to document: RFC 7523 permits an issuer to omit jti, and without it such an assertion would have no single-use key. Just not for the reason given. * docs: key the cluster matrix on the right variables The matrix keyed every column on the issuer, which is only correct for one of them. Two consequences, both in the default-issuer row: - 'Key fetch ❌' and 'TokenReview ❌' used the same mark for two different things. Key fetch has a remedy — the very next column said 'mirror the JWKS' — while TokenReview has none. Same symbol, opposite meanings, in one row. - 'TokenReview ❌ private address' does not follow from the issuer at all. A kubeadm cluster on the default issuer can have a publicly reachable control plane, and then TokenReview works. The row directly above already said 'depends on the endpoint' for exactly this reason, so the table contradicted itself. Restructured so each column names the address that decides it: the issuer for oidc_discovery, any reachable copy of the JWKS for static_jwks_url, the apiserver for TokenReview. They are independent on the same cluster. Also scopes the mirror section, which presented a mirror as the answer for default-issuer clusters generally. If the apiserver is reachable, jwks_url can point straight at it and no mirror is needed. * docs: explain why the default issuer is unreachable, both ways The page said the default issuer is 'unresolvable outside the cluster', which is only half of it and hides the fact that an operator sees two different errors. kubernetes.default.svc.cluster.local is a cluster-internal DNS record served by CoreDNS to pods; .cluster.local is not a public zone. So from outside it is NXDOMAIN — 'failed to resolve host'. From inside it resolves fine, to the kubernetes Service ClusterIP (10.96.0.1 on a default --service-cluster-ip-range), which is RFC 1918 and refused by the SSRF guard — 'requests to private/internal networks are not allowed'. Both verified against a live cluster.
1 parent 10947c8 commit 666c9bc

1 file changed

Lines changed: 81 additions & 5 deletions

File tree

‎docs/enterprise/workload-identity.md‎

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,17 @@ Fields:
4949
| Field | Notes |
5050
|-------|-------|
5151
| `service_account_id` | Internal `id` of the `service_account` client this issuer authenticates |
52-
| `issuer_url` | Must equal the assertion's `iss` claim exactly. Globally unique across all trusted issuers (including per-org SSO connections) |
52+
| `issuer_url` | Must equal the assertion's `iss` claim exactly. Globally unique across all trusted issuers (including per-org SSO connections). **Under `static_jwks_url` this is a matching key, not an address — Authorizer never fetches it.** Only `oidc_discovery` dials it (for `{issuer_url}/.well-known/openid-configuration`). That is what lets a private cluster issuer like `https://kubernetes.default.svc` work with a [mirrored JWKS](#clusters-on-the-default-issuer) |
5353
| `key_source_type` | `oidc_discovery` (fetch `jwks_uri` from `{issuer_url}/.well-known/openid-configuration`) or `static_jwks_url` (fetch `jwks_url` directly — required when the issuer's discovery document is not reachable) |
5454
| `jwks_url` | Required for `static_jwks_url` |
5555
| `expected_aud` | The `aud` the assertion **must** contain exactly — set it to your Authorizer URL and mint tokens with that audience, so a token minted for another service can never be replayed here |
5656
| `subject_claim` | Claim that identifies the workload; defaults to `sub` |
5757
| `allowed_subjects` | Comma-separated **exact-match** subject allow-list. **Empty = deny-all** — a row with no subjects authenticates nobody |
5858
| `issuer_type` | `kubernetes_sa` \| `spiffe_jwt` \| `oidc` \| `cloud_oidc` |
5959

60-
> JWKS/discovery fetches use an SSRF-hardened HTTP client — host-pinned, redirects refused, response size capped, and private/loopback addresses rejected. The issuer's key endpoint must therefore be reachable at a publicly-routable address. The `spiffe_bundle_endpoint` key source (and its `spiffe_refresh_hint_seconds`) is accepted in the API but its fetcher is not active yet — use `oidc_discovery` or `static_jwks_url` for SPIFFE issuers today.
60+
> JWKS/discovery fetches use an SSRF-hardened HTTP client — host-pinned, redirects refused, response size capped, and private/loopback/link-local addresses rejected. **Whatever Authorizer fetches must therefore be publicly routable**, which for Kubernetes depends entirely on the cluster's issuer — see [Kubernetes ServiceAccount tokens](#kubernetes-serviceaccount-tokens).
61+
>
62+
> `spiffe_bundle_endpoint` has no implementation and is **rejected at write time** with `key_source_type "spiffe_bundle_endpoint" is not implemented yet` — use `oidc_discovery` or `static_jwks_url` for SPIFFE issuers. `spiffe_refresh_hint_seconds` is stored but not yet honoured at runtime.
6163
6264
## Validation rules
6365

@@ -71,7 +73,7 @@ Every check is fail-closed, and every rejection returns the same generic `invali
7173
| Audience | `aud` must contain the row's `expected_aud` exactly |
7274
| Lifetime | `exp` and `iat` required; declared lifetime (`exp − iat`) must be ≤ 1 hour; `exp`/`nbf`/`iat` checked with 60 s clock skew |
7375
| Subject | `subject_claim` value must exactly match an `allowed_subjects` entry (never prefix/substring); empty list is deny-all |
74-
| Replay | Assertions are **single-use** — keyed by `jti`, or by `(iss, sub, iat, exp)` when `jti` is absent (K8s SA tokens carry none), held until the token's `exp` |
76+
| Replay | Assertions are **single-use** — keyed by `jti`, or by `(iss, sub, iat, exp)` when the issuer omits one ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) permits it), held until the token's `exp`. Kubernetes projected ServiceAccount tokens **do** carry a `jti` and key on it |
7577
| Type match | `jwt-bearer` assertions only match non-SPIFFE rows; `jwt-spiffe` only matches `spiffe_jwt` rows |
7678
| Bound client | Must exist, be active, and be a `service_account` |
7779

@@ -81,9 +83,82 @@ Because assertions are single-use, **mint a fresh platform token per token-endpo
8183

8284
Kubernetes clusters are OIDC issuers: projected ServiceAccount tokens are JWTs signed by the cluster, with `iss` = the cluster's issuer URL and `sub` = `system:serviceaccount:<namespace>:<name>`.
8385

86+
### Does my cluster work out of the box?
87+
88+
Three independent questions decide it, and they have **different answers on the same cluster** — the issuer being private does not make the apiserver private, or vice versa. Check both addresses first:
89+
90+
```bash
91+
kubectl get --raw /.well-known/openid-configuration | jq -r '.issuer, .jwks_uri'
92+
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'; echo
93+
```
94+
95+
Then read off each column independently. Authorizer's SSRF guard refuses private, loopback and link-local addresses, so "reachable" throughout means *publicly routable from Authorizer*.
96+
97+
| | `oidc_discovery` | `static_jwks_url` | TokenReview |
98+
|---|---|---|---|
99+
| **Decided by** | is the **issuer URL** reachable? | is **any copy of the JWKS** reachable? | is the **apiserver** reachable? |
100+
| **EKS / GKE / AKS** (public issuer, e.g. `https://oidc.eks.<region>.amazonaws.com/id/…`) | ✅ | ✅ not needed | ✅ with a public API endpoint |
101+
| **Self-managed, custom public issuer** (`--service-account-issuer=https://…`) | ✅ | ✅ not needed | depends on the endpoint |
102+
| **Default issuer** — `https://kubernetes.default.svc.cluster.local` (kubeadm, kind, k3d) | ❌ discovery lives at a private URL | ✅ point it at a reachable copy — the apiserver itself if that is public, otherwise a [mirror](#clusters-on-the-default-issuer) | depends on the endpoint |
103+
| **Any cluster, private API endpoint only** | per the issuer, above | per the JWKS, above | ❌ and there is no workaround — leave `enable_token_review` off |
104+
105+
Two things worth reading off that table, because they are easy to get backwards:
106+
107+
- **A private issuer does not mean the feature is unavailable.** It rules out `oidc_discovery` only. `issuer_url` is matched against the token's `iss` and never fetched under `static_jwks_url`, so it can stay as the cluster's own unroutable value while the keys come from anywhere reachable.
108+
- **A private issuer does not imply a private apiserver.** A kubeadm cluster on the default issuer can still have a publicly-reachable control-plane endpoint, in which case TokenReview works and `static_jwks_url` can point straight at `<apiserver>/openid/v1/jwks` with no mirror at all.
109+
110+
If your issuer is an `https://` URL on a public domain, the first two rows apply and registration is a two-field job.
111+
112+
### Clusters on the default issuer
113+
114+
A cluster left on the upstream default publishes `https://kubernetes.default.svc.cluster.local` as its issuer. That name is a **cluster-internal DNS record**, served by CoreDNS to pods only — `.cluster.local` is not a public zone, and nothing outside the cluster resolves it. So `oidc_discovery` is out, because the discovery document lives under that URL.
115+
116+
It fails for a different reason depending on where Authorizer runs, which is worth knowing because the error text differs:
117+
118+
| Authorizer runs | What happens | Error you see |
119+
|---|---|---|
120+
| Outside the cluster | The name does not resolve at all (`NXDOMAIN`) | `failed to resolve host` |
121+
| Inside the cluster | CoreDNS resolves it to the `kubernetes` Service ClusterIP — `10.96.0.1` on a default `--service-cluster-ip-range` — which is RFC 1918 | `requests to private/internal networks are not allowed` |
122+
123+
That leaves `static_jwks_url`, and the only question is whether Authorizer can reach a copy of the cluster's keys. **Check the apiserver first: if your control-plane endpoint is publicly reachable, point `jwks_url` straight at `https://<apiserver>/openid/v1/jwks` and skip the rest of this section.** A mirror is only needed when it is not — a kind or k3d cluster, or any control plane on a private network.
124+
125+
The fix needs no code and no exception: **publish the cluster's public keys somewhere reachable and point `jwks_url` at that.** It works because `issuer_url` is only matched against the token's `iss` and is never dialed, so it can stay as the cluster's own unroutable issuer. This is the same shape AWS IRSA uses — the JWKS in public object storage, the apiserver never exposed.
126+
127+
```bash
128+
# 1. Export the cluster's PUBLIC keys. Nothing secret is in this document.
129+
kubectl get --raw /openid/v1/jwks > jwks.json
130+
131+
# 2. Host it anywhere publicly reachable — object storage, your CDN, a static host.
132+
aws s3 cp jwks.json s3://my-bucket/clusters/prod/jwks.json --acl public-read
133+
```
134+
135+
```graphql
136+
mutation {
137+
_add_trusted_issuer(
138+
params: {
139+
service_account_id: "CLIENT_UUID"
140+
name: "prod-cluster payments-worker"
141+
# The cluster's own issuer — matched against `iss`, never fetched.
142+
issuer_url: "https://kubernetes.default.svc.cluster.local"
143+
key_source_type: "static_jwks_url"
144+
jwks_url: "https://my-bucket.s3.amazonaws.com/clusters/prod/jwks.json"
145+
expected_aud: "https://your-authorizer.example"
146+
allowed_subjects: "system:serviceaccount:payments:worker"
147+
issuer_type: "kubernetes_sa"
148+
}
149+
) { id }
150+
}
151+
```
152+
153+
:::warning Refresh the mirror when the cluster rotates its keys
154+
A mirror is only as current as whatever refreshes it. Kubernetes rotates ServiceAccount signing keys, and a stale mirror fails in both directions: **tokens signed with a new key stop validating** (an outage), and **a retired key that is still published keeps validating tokens it should not** (a security gap).
155+
156+
Authorizer caches a fetched JWKS for 10 minutes, so its own staleness is bounded — the mirror's is not. Refresh it as part of whatever rotates the cluster keys, or on a schedule shorter than your rotation period. If you cannot commit to that, prefer a cluster with a public issuer.
157+
:::
158+
84159
### 1. Register the trusted issuer
85160

86-
Find the cluster issuer with `kubectl get --raw /.well-known/openid-configuration | jq -r .issuer` (on managed clusters — EKS/GKE/AKS — this is a public URL and `oidc_discovery` works; for a private cluster expose the JWKS and use `static_jwks_url`):
161+
For a cluster with a public issuer (the common case), `oidc_discovery` needs no JWKS handling at all:
87162

88163
```graphql
89164
mutation {
@@ -153,7 +228,8 @@ mutation {
153228
```
154229

155230
- Authorizer authenticates the TokenReview call with its **own** in-cluster ServiceAccount token, which needs the `system:auth-delegator` ClusterRole.
156-
- The apiserver URL goes through the same SSRF-hardened client, so only a **publicly-routable apiserver endpoint** (e.g. a managed cluster's public API endpoint) works today — `https://kubernetes.default.svc` (a private ClusterIP) is rejected by design.
231+
- The apiserver URL goes through the same SSRF-hardened client, so only a **publicly-routable apiserver endpoint** works — `https://kubernetes.default.svc` (a private ClusterIP) is rejected by design. Unlike key fetch, there is no mirror equivalent here: TokenReview is a live call to your apiserver. A cluster without a reachable API endpoint cannot use it, and should leave `enable_token_review` off — offline JWKS validation still authenticates the workload.
232+
- `kubernetes_api_server_url` is **security-sensitive**: Authorizer authenticates that call with its own in-cluster ServiceAccount token, so whatever host you configure receives that credential. Treat it as a trusted-host field, and keep Authorizer's ClusterRole to `system:auth-delegator` (TokenReview only) so the credential grants nothing else.
157233

158234
## SPIFFE JWT-SVIDs (preview)
159235

0 commit comments

Comments
 (0)