You are viewing documentation for Cozystack next, which is currently in beta. For the latest stable version, see the v1.6 documentation.
OIDC authentication for Grafana
Cozystack Grafana instances can authenticate users through OIDC instead of the shared admin_user / admin_password Secret. Each user then has their own identity, per-user audit, and a role that can be revoked by removing them from spec.oidc.users on the Monitoring CR.
The identity model is deliberately per-instance rather than per-tenant: every Monitoring instance (one inner monitoring-system release per namespace — each tenant’s own, plus the platform’s in tenant-root) gets its own OIDC audience, and a token minted for instance A is rejected by instance B’s Grafana. Cross-tenant sign-in is additionally blocked by a allowed_groups gate on the release’s namespace-scoped <ns>-{view,use,admin,super-admin} groups (chart-owned in the tenant chart; the platform-managed groups scope in the cozy realm makes them visible on every token). The full rationale is in the
design proposal. The tenant kube-apiserver’s Phase 1 (
cozystack/cozystack#3044) uses the same shape; this Grafana integration is the Phase-1 follow-up called out in that PR’s body.
grafana-admin-password Secret in the release namespace stays available as a break-glass path regardless of whether OIDC is enabled. disable_login_form is not flipped by the selector.Modes
spec.oidc.mode picks the identity source on the Monitoring CR:
None— the default. No OIDC; only theadmin_user/admin_passwordSecret works. Existing instances render identically to before.System— trust the platformcozyKeycloak realm via a per-instance confidential client and audience binding. Users are the ones a Cozystack platform admin already provisioned incozy; the tenant does not manage a directory of its own.CustomConfig— trust a tenant-supplied issuer directly (BYO IdP: Okta, Auth0, a customer’s own Keycloak).cozyis not in the path.
Enable OIDC — System mode
apiVersion: apps.cozystack.io/v1alpha1
kind: Monitoring
metadata:
name: monitoring
namespace: tenant-acme
spec:
oidc:
mode: System
users:
- email: alice@acme.example
role: Admin
- email: bob@acme.example
role: Editor
- email: carol@acme.example
role: Viewer
# ...
Cozystack provisions the following. Every derived identifier below uses <release>, the name of the inner HelmRelease that actually carries the OIDC templates. A Monitoring CR’s release name is forced to monitoring (check-release-name.yaml rejects any other name), and its inner release is always <release-name>-system, so <release> is literally monitoring-system in every namespace — tenant-acme-monitoring-system for the CR above. Instances are told apart by namespace, not by release name:
A per-instance
KeycloakClientin thecozyrealm withclientIdset to<namespace>-<release>(for the CR above:tenant-acme-monitoring-system). Confidential (clientAuthenticatorType: client-secret),secretsourced from a chart-owned Kubernetes Secret.redirectUrislocked tohttps://grafana.<host>/login/generic_oauth.A per-instance
KeycloakClientScopewhose audience mapper pins the token’saudclaim to that sameclientId— the isolation primitive.A persistent Kubernetes Secret carrying the confidential
client-secret(random on first install, preserved on upgrades).A chart-owned users-reconcile Job (
<release>-oidc-users) that syncsspec.oidc.users[]into the Grafana instance on every reconcile: creates missing Grafana accounts, patches roles, and prunes stale org members. Runs as a post-install / post-upgrade hook whenusers[]is non-empty; omitted otherwise (BYO-IdP-friendly).An
auth.generic_oauthentry under the Grafana CR’sspec.config(a single ini-section key literally namedauth.generic_oauth, so it is addressed asspec.config["auth.generic_oauth"], not a two-levelauth/generic_oauthnesting), wired to the cozy realm issuer, per-instance audience scope, and the tenant-membership gate:allowed_groups = <namespace>-view <namespace>-use <namespace>-admin <namespace>-super-admin groups_attribute_path = groupsgroups_attribute_pathis REQUIRED alongsideallowed_groupson Grafana v11.x+: without it Grafana leaves the extracteduser.Groupsslice empty regardless of what the userinfo endpoint returns, and the gate rejects every login with “user not a member of one of the required groups” even when the token carries the correct claim. The JMESPath expressiongroupsreads the top-levelgroupsarray from userinfo — the shape Keycloak’soidc-group-membership-mapperemits.When
spec.oidc.users[]is non-empty the chart additionally forces:skip_org_role_sync = true oauth_allow_insecure_email_lookup = true allow_sign_up = falseskip_org_role_sync=truekeeps a login from overwriting the users-Job’s role assignments;oauth_allow_insecure_email_lookup=truelets Grafana attach the OIDC identity to the pre-provisioned local account by email;allow_sign_up=falseis the isolation lever — without it, Grafana’s defaultallow_sign_up=truecombined withskip_org_role_sync=truewould mint a Viewer account for everycozy-realm identity that hits the login flow.
Prerequisite
System mode requires the platform-level OIDC feature (authentication.oidc.enabled at the Cozystack platform values). If the flag is off, the chart hard-fails the render with a clear message. Ask a Cozystack platform admin to enable it, or use CustomConfig.
Enable OIDC — CustomConfig mode
Bring your own issuer. Two supply paths, mutually exclusive:
spec:
oidc:
mode: CustomConfig
customConfig:
config:
enabled: "true"
client_id: my-grafana
client_secret: xxxxxxxx
auth_url: https://idp.acme.example/protocol/openid-connect/auth
token_url: https://idp.acme.example/protocol/openid-connect/token
api_url: https://idp.acme.example/protocol/openid-connect/userinfo
scopes: openid email profile groups
users:
- email: alice@acme.example
role: Admin
…or via a pre-existing Secret in the tenant namespace holding a ready-made [auth.generic_oauth] ini fragment in the auth.ini key:
spec:
oidc:
mode: CustomConfig
customConfig:
secretRef:
name: acme-byo-grafana-auth
Setting both config and secretRef.name (or neither) fails the render. In CustomConfig mode no Keycloak objects are provisioned in cozy; the Grafana instance trusts the operator-supplied issuer directly.
The secretRef path is authoritative — the chart does not overlay any keys onto the operator-supplied ini fragment. If you use secretRef, spec.oidc.users[] MUST be empty (the chart fails the render otherwise) because the users-Job cannot reason about a config it cannot see.
On the inline config path, when users[] is non-empty the chart-forced users-map contract (skip_org_role_sync=true, oauth_allow_insecure_email_lookup=true, allow_sign_up=false) is merged over your map and wins every conflict: the chart renders merge $chartForced $yourConfig, and Helm’s merge keeps the left-hand (chart) values and only fills in keys your map is missing — so you cannot override those three. Unlike System mode, CustomConfig does not set enabled for you: your config MUST include enabled: "true" or Grafana leaves auth.generic_oauth disabled and no sign-in button appears. If your BYO IdP does not emit groups in the shape Grafana expects, add groups_attribute_path to your inline map to point at the right JMESPath.
Assigning roles
Roles are driven by spec.oidc.users[], not by group membership: each entry has an email (matched against the OIDC email claim on login) and a role (Grafana org-level: Admin, Editor, or Viewer). The users-Job creates or updates the local Grafana account on every reconcile and prunes stale members. Removing a user from the list revokes their access on the next chart apply — the users-Job removes them from the Grafana org (DELETE /api/orgs/1/users/<id>), an org-membership change rather than deletion of the underlying Grafana account; adding a user re-provisions them.
Login authorization is separate: the token owner MUST be a member of one of the release’s tenant-scoped cozy-realm groups (<namespace>-view, <namespace>-use, <namespace>-admin, <namespace>-super-admin) for allowed_groups to accept the login. That gate is unconditional in System mode, independent of users[].
Add a user to the appropriate tenant-scoped group in cozy — via the Keycloak UI or with a KeycloakRealmUser CR:
apiVersion: v1.edp.epam.com/v1
kind: KeycloakRealmUser
metadata:
name: alice-acme
namespace: cozy-keycloak
spec:
realmRef:
name: keycloakrealm-cozy
kind: ClusterKeycloakRealm
username: alice@acme.example
email: alice@acme.example
emailVerified: true
passwordSecret:
name: alice-acme-password
key: password
groups:
- tenant-acme-admin
The cozy realm is a cluster-scoped ClusterKeycloakRealm named keycloakrealm-cozy, so spec.realmRef (not spec.realm) is required, with kind: ClusterKeycloakRealm. spec.passwordSecret points at a Secret in the same namespace holding the initial password (kubectl create secret generic alice-acme-password --namespace cozy-keycloak --from-literal=password=...); the CRD also accepts an inline spec.password, but it is deprecated in favour of passwordSecret.
Sign in
Open https://grafana.<host> and press “Sign in with Keycloak” under the login form. Grafana runs the OAuth Authorization Code flow against cozy, receives a token whose aud matches this Monitoring instance’s clientId, checks the groups claim against allowed_groups, then looks up the pre-provisioned Grafana account by email and grants the users-Job-assigned role.
The admin_user / admin_password field on the form stays wired to grafana-admin-password and continues to work.
Prerequisites and gotchas
emailVerified: trueon Keycloak users.email_verifiedis not enforced by Grafana: itsauth.generic_oauthprovider has no claim-validation option (claim handling is JMESPath only, androle_attribute_pathis unused here because the users-Job drives roles). Enforce it on the Keycloak side instead: setemailVerified: trueon theKeycloakRealmUser(or add theVERIFY_EMAILrequired action / complete the email-verify flow in the Keycloak UI) so the identity holding a given email is guaranteed authentic. Thecozyrealm’s defaultduplicateEmails: falseadditionally prevents a second account from claiming an already-registered address.groups_attribute_pathis not optional on Grafana v11.x+. The chart wires it automatically forSystemmode; inCustomConfiginline the operator must add it explicitly (groups_attribute_path: groups) if their IdP emits a top-levelgroupsarray. Otherwiseallowed_groupsbecomes a silent no-op and every login fails.- BYO issuer with a self-signed CA. In
CustomConfigmode thesecretRefpath is the way to ship a CA bundle alongside the[auth.generic_oauth]block — you packageauth.iniand anyca-certfiles into the Secret and mount both under/etc/grafana/oidc. Placing the file is not enough on its own: pointtls_client_caat it from inside yourauth.iniso Grafana actually trusts the issuer’s certificate. admin_userstays a break-glass path. Even undermode: Systemthe login form and thegrafana-admin-passwordSecret remain wired. Locking the form off is a follow-up hardening.- Mode toggle is non-destructive. The users-Job renders only when
spec.oidc.modeis notNoneandspec.oidc.users[]is non-empty. Flippingspec.oidc.modefromSystemtoNonetherefore runs no reconcile and no prune pass: the Grafana accounts the Job provisioned survive untouched, and OIDC login is simply no longer offered on the form. Flipping back toSystemresumes reconciliation ofspec.oidc.users[]against those existing accounts.
What’s out of scope for this feature
- Per-tenant Keycloak realms. Managed multi-tenant identity is a separate proposal, evaluated against Keycloak Organizations. Track it in the community proposal.
- Federating an external IdP into the platform
cozyrealm. BYO-for-Cozystack-itself is a distinct problem — this feature is BYO-for-a-managed-service. - Full-logout through Keycloak’s end-session endpoint. Native
auth.generic_oauthcovers the OAuth part;backend-logout-urlwiring is a follow-up. - Server-level
GrafanaAdminpromotion. All Grafana instances — platform and tenant — cap at org-levelAdmin;allow_assign_grafana_adminis not wired and theMonitoringCR exposes no field to opt in. - Role granularity beyond Admin/Editor/Viewer. Grafana org roles are the assignment surface; team memberships / dashboard-level permissions stay out-of-band.