Skip to content

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:

  1. 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.
  2. 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.
  3. 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.

Creating the provider does not enable it. The IdP template and the login policy are separate objects. With the template present and the policy empty, ZITADEL renders no Google button and resolves a typed email as a local username — producing 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

ServiceHow it authenticatesWhat decides authorisation
Grafanadirect OIDCrole_attribute_path matching roles[*] → Admin / Editor / Viewer
Flux UIdirect OIDCimpersonates claims.email with claims.groups, so Kubernetes RBAC decides
Harbordirect OIDCoidc_auth mode, auto-onboard on first login
Headlampdiffers by cloudsee 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.

Two properties hold that design up, and it fails open without either: the HTTPRoute strips every inbound 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