# 0058. OIDC login and Kubernetes impersonation for ichor-ui

Status: Accepted · Date: 2026-10-26 · Design section: Trust; CLI and UI
Decided: with `ichorUI.oidc.enabled` (default off) every ichor-ui RPC needs an OIDC ID token that `github.com/coreos/go-oidc/v3` verifies (signature, issuer, audience = client ID, expiry), the backend calls the Kubernetes API as that user with `Impersonate-User`/`Impersonate-Group` so the API server alone decides what they may do, and `AuditRecord.actor` and the PR's `Requested by:` are that Kubernetes user name; the UI is a public client doing authorization code + PKCE in the browser (no client secret exists); the `impersonate` grant is on `users` and `groups` only, rendered only when OIDC is on and narrowable by name; with OIDC off nothing changes except a startup warning and a UI banner; the CLI does not forward credentials

## Context

architecture.md "Trust" says the UI backend holds a read-only Talos identity and the browser holds nothing, and
that "OIDC login with Kubernetes impersonation comes later so that RBAC and audit apply per user". The design
decides nothing more: not the flow, the library, the impersonation scope, the off-state, or the CLI. Until now
`AuditRecord.actor` was the fixed string `ichor-ui`/`ichor-cli`, and anyone who could reach ichor-ui wrote with
its ServiceAccount's permissions (ADR 0030's port-forward is the boundary).

## Decisions

**1. Design gives:** OIDC login, Kubernetes impersonation, RBAC and audit per user, browser holds no kube/Talos
credential. Everything below is what the design left open.

**2. Impersonation.** `uiapi.Server` shadows `Get/List/Create/Update/Delete` so a call made under a context that
carries an `Identity` goes through a client with `Impersonate-User` and one `Impersonate-Group` per group; no
handler changed. Kubernetes then enforces RBAC, and the API server's own audit log names the user. Ichor writes
no authorization logic. Reads that are the backend's own business and whose result is not returned to the
user (secrets bundle, `argocd-cm`, the repository token, the Application that `planWrite` inspects) stay on the
ServiceAccount via `s.Client`: users are not meant to read the secrets bundle. A read whose result reaches the
user (the tracking Application's status, the Kubernetes Node's status, `TalosStorage`) is made as the user;
ADR 0062's amendment lists every `s.Client` read and which side it is on. Without an identity (point 4) the
ServiceAccount is used for all of them. The Talos read-only identity is unchanged; RPCs that reach a node first read the
`TalosMachine` as the user, so a user who cannot `get` it cannot reach its node data.

**Scope of `impersonate`.** One rule: verb `impersonate` on `users` and `groups` in the core group, cluster
scoped (impersonation is a cluster-scoped verb). Not `serviceaccounts`, not `userextras`, not `uids`. It is
rendered only when `ichorUI.oidc.enabled`, so the default install gains nothing. Names cannot be enumerated for
a real IdP, so by default it allows any name; `ichorUI.oidc.impersonate.users`/`.groups` turn into
`resourceNames` for sites that can list them (a user or group outside the list gets a 403). Compensating
controls in the backend: names get a configurable prefix (default `oidc:`) so an IdP value cannot be a built-in,
and a name that still starts with `system:` is refused. The ServiceAccount token remains the thing to protect;
this grant is the reason it matters more once OIDC is on.

**3. Token verification.** `github.com/coreos/go-oidc/v3` v3.21.0 is added. `go mod graph` showed only
`coreos/go-oidc v2.5.0+incompatible` (an old indirect dep of `k8s.io/apiserver`, unusable for a v3 import path)
and no JWKS-verifying code we could reuse without pulling in the apiserver authenticator machinery. It brings
`go-jose/v4`, already in the module graph. The verifier does discovery, JWKS rotation, and checks signature,
issuer, audience and expiry; we add: username claim present (default `email`, which must be `email_verified`),
groups claim optional. Errors say "invalid ID token" and never include the token or library detail.
Discovery failing at startup is fatal, never a silent fall back to open.

**4. OIDC not configured.** Behaviour is exactly as before: writes use the ServiceAccount, the actor is
`ichor-ui`. It is not made stricter, so nobody is locked out on upgrade. What changes is that it is no longer
silent: ichor-ui logs a WARNING at start and `GET /auth/config` reports `enabled:false`, which the UI shows as a
banner. `/auth/config` (issuer and client ID only), the SPA assets and `/metrics` are open; every Connect RPC
needs a token when OIDC is on.

**5. CLI.** The CLI runs on the user's kubeconfig (ADR 0049) and does not forward its credentials to ichor-ui:
sending a kubeconfig token to a server that then acts with it is a credential-forwarding path we do not want, it
would need `tokenreviews` to verify, and a client-certificate kubeconfig has no token to send. With OIDC on the
CLI therefore uses its in-process fallback (the same `uiapi.Server` on the user's own RBAC; actor stays
`ichor-cli` for now). Known consequence, not done in this change: the CLI does not yet detect an OIDC-enabled
backend, so its calls through the port-forward get `Unauthenticated` instead of falling back; `ichor ... --local`
works, and commands that need the backend (`node`) will need an ID token flag later. A follow-up should make the
CLI read `/auth/config`, fall back automatically, and take its actor name from a `SelfSubjectReview`.

**5a. Amendment: the CLI detects OIDC (point 5's follow-up, done).** Right after the port-forward is up and
the probe answers, the CLI reads `GET /auth/config`. `enabled:true` makes it drop the tunnel and use the
in-process `uiapi.Server` on the user's own kubeconfig and RBAC, printing one line to stderr saying so. Any other
answer (non-200, unparseable body, an older backend) counts as not enabled, so the call goes to the backend, which
rejects it itself if it does require a token: detection never opens anything up. `node get` / `node logs` need the
backend's read-only Talos identity, so with OIDC on they fail with a reason (the backend requires OIDC, the CLI
cannot present a token and never forwards kubeconfig credentials) instead of `Unauthenticated`; the flag that
would carry an ID token is still later. The in-process actor (`AuditRecord.actor`, `Requested by:`, the approval
decision) is the username from a `SelfSubjectReview` made with the user's kubeconfig; if that call fails the
actor stays `ichor-cli`. Only the username is read; no credential is logged or stored.

**6. Login in the UI.** Authorization code + PKCE (S256), state and nonce, in the browser, as a public client:
there is no client secret to store, mount or leak, and the backend stays stateless (no sessions or cookies; it
verifies the bearer ID token on each call). The ID token lives in `sessionStorage` and is sent as
`Authorization: Bearer`; a rejected token restarts login. It is a credential for Ichor only, not for Kubernetes
or Talos. Cost: the IdP must allow CORS for browser discovery and token requests and have the UI's origin as a
redirect URI, and there is no refresh token, so the user logs in again when the ID token expires. A
backend-held session was rejected as more code (cookie signing, CSRF, secret handling) for the same result.

**7. Audit.** `AuditRecord.actor` is `Identity.User`, the exact string given to `Impersonate-User`, so it matches
Kubernetes' audit log; `prmode.SubmitPRRequest.Requester` receives the same value for `Requested by:`.

**8. Secrets.** The ID token and (nonexistent) client secret are never logged, put in errors or written to
audit; tests assert the verifier's errors do not echo the token. Nothing logs a request header.

## Amendment: what an impersonated user needs for the effective config

`GetEffectiveConfig` lists `TalosConfigPatch` and `TalosStorage` as the user (ADR 0062), so a user needs `get`/`list`
on `talosclusters`, `talosmachines`, `talosconfigpatches` and `talosstorages` in the cluster's namespace; the
chart's per-CRD `*-viewer-role` ClusterRoles grant exactly that. Without `talosstorages` list the view fails with
Forbidden for that user. The ServiceAccount's own `list` on `talosstorages` (ADR 0018 amendment) is separate: it
serves `ResolveSystemInputs` and the OIDC-off case.

## Amendment: Ichor's own users and issuer (ADR 0094, Proposed)

ADR 0094 adds `IchorUser` accounts and makes ichor-ui an OIDC issuer of its own that runs beside the external IdP
described here. Nothing in this ADR changes while `ichorUI.users.enabled` stays false. When it is on, the
authenticator accepts tokens from both sources, the kube-apiserver trusts both through Talos's
`KubeAuthenticationConfig`, and the CLI can present an Ichor-issued token to the backend (point 5a's "later").
