Authentication
Every human-facing service on this platform is behind the same identity. You log in with a Google Workspace account and never see a per-application password.
This page is the chain that makes that true, in the order a login travels it.
The intent
Three properties, in priority order:
- One identity, one place to revoke it. Removing someone from Google Workspace removes their access to every service — there is no second user directory to remember.
- Authorisation from group membership, not per-app configuration. A single ZITADEL role decides what you can do in Grafana, in the Flux UI, in Harbor and in Kubernetes itself.
- No shared credentials. No service accounts handed round, no application passwords in a vault that people copy out of.
Everything below is machinery in service of those three.
The chain
Google Workspace the source of truth for who you are
│ OIDC
▼
ZITADEL the broker: user, project roles, claims
│ OIDC + a `groups` / `roles` claim
├──────────────► Grafana (direct OIDC)
├──────────────► Flux UI (direct OIDC + impersonation)
├──────────────► Harbor (direct OIDC, DB-configured)
└──────────────► Headlamp (differs by cloud — see below)Google Workspace → ZITADEL
ZITADEL holds a Google identity provider at instance level, so every
organisation inherits it. It is created by scripts/zitadel-idp.sh from
credentials in the secret store, never by hand in a console.
isAutoCreation is on: a Workspace user logging in for the first time gets a
ZITADEL user built from their Google profile. This is the only way a human user
ever comes into existence — which is why no bootstrap script can seed one, and
why restoring the database
matters more than it first appears.
User not found on a correct configuration. zitadel-idp.sh
adds it to the login policy for exactly this reason.ZITADEL → a groups claim
ZITADEL has no groups. It has project roles, emitted as a nested object keyed by role and then by organisation. Every consumer here wants a flat array of strings instead.
scripts/zitadel-actions/groups-from-roles.js bridges that: an Action on the
token flow flattens the user’s role grants and sets two claims — groups
(Headlamp, Flux UI) and roles (Grafana). Two names, one list, because the
consumers disagree and both are already deployed.
The roles themselves live on the platform project: admin, backend,
frontend, data. They are created by zitadel-oidc-clients.sh; granting one
to a user is deliberately manual, since a user exists only after a first login.
projectRoleAssertion must be true on the project, and ZITADEL defaults it to
false. With it off, no token carries roles and ctx.v1.user.grants is empty
inside the Action — so it returns early and sets no claim at all, while still
logging action run succeeded. One flag produces three unrelated-looking
failures: no such key: groups in the Flux UI, unauthorized in Headlamp, and
every Grafana user silently landing on Viewer.What each consumer does with it
| Service | How it authenticates | What decides authorisation |
|---|---|---|
| Grafana | direct OIDC | role_attribute_path matching roles[*] → Admin / Editor / Viewer |
| Flux UI | direct OIDC | impersonates claims.email with claims.groups, so Kubernetes RBAC decides |
| Harbor | direct OIDC | oidc_auth mode, auto-onboard on first login |
| Headlamp | differs by cloud | see below |
Harbor is worth one note: its authentication is not chart configuration.
auth_mode, the endpoint, the client and the scopes live in Harbor’s database
and are written through its API at runtime, so no manifest can express them —
scripts/harbor-oidc.sh applies them.
Reaching the Kubernetes API
This is where the two clouds genuinely diverge, and it is the most important thing on this page to understand before changing anything.
aws-0 — the API server trusts ZITADEL
EKS accepts an OIDC identity provider directly:
identity_providers = {
zitadel = {
issuer_url = "https://auth.cloud.ogenki.io"
username_claim = "email"
groups_claim = "groups"
}
}So a token issued by ZITADEL is a Kubernetes identity. The groups claim
becomes real Kubernetes groups, and security/base/rbac/admin.yaml binds the
admin group to cluster-admin. Headlamp simply forwards the user’s token.
gcp-0 — it does not, and cannot
GKE’s managed control plane accepts no equivalent flag, and Identity Service for
GKE — which used to provide one — is deprecated as of 2026-07-01 and unsupported
in GKE 1.37+. Workforce Identity Federation, its replacement, authenticates a
client to Google through a token exchange and cannot take a ZITADEL id_token.
So on GCP, Headlamp sits behind oauth2-proxy: the proxy authenticates the
human against ZITADEL and Headlamp talks to the API server as its own
ServiceAccount. The trade is explicit — per-user Kubernetes RBAC is lost, and
authorisation moves entirely to --allowed-group=admin on the proxy. See
ADR-0026
for the alternatives weighed, including Pinniped.
X-Forwarded-* header before oauth2-proxy sets
its own, and a CiliumNetworkPolicy allows ingress to Headlamp only from the
proxy. Without the second, any pod in the cluster could call Headlamp claiming
to be anyone.Note also that oauth2-proxy emits X-Forwarded-Groups (plural) while
Headlamp defaults to the singular X-Forwarded-Group. Left at defaults the login
succeeds, the user has no groups, and nothing logs an error.
Setting it up
The five ordered bootstrap steps — and why the order is load-bearing — are in Verify the cluster.
Related
- ADR-0024 — why each cloud runs its own ZITADEL
- ADR-0026 — why Headlamp is proxied on GKE
- Restore a database — why an empty ZITADEL costs more than it looks