Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
title: "OIDC authentication for Grafana"
linkTitle: "OIDC authentication"
description: "Give tenant users per-identity Grafana access with per-instance audience isolation and tenant-membership gating."
weight: 5
---

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: each Monitoring release (per-tenant `monitoring`, plus the platform's `monitoring-system`) 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](https://github.com/cozystack/community/pull/24). The tenant kube-apiserver's Phase 1 ([cozystack/cozystack#3044](https://github.com/cozystack/cozystack/pull/3044)) uses the same shape; this Grafana integration is the Phase-1 follow-up called out in that PR's body.

{{% alert color="info" %}}
The `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.
{{% /alert %}}

## Modes

`spec.oidc.mode` picks the identity source on the `Monitoring` CR:

- **`None`** — the default. No OIDC; only the `admin_user` / `admin_password` Secret works. Existing instances render identically to before.
- **`System`** — trust the platform `cozy` Keycloak realm via a per-instance confidential client and audience binding. Users are the ones a Cozystack platform admin already provisioned in `cozy`; 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). `cozy` is not in the path.

## Enable OIDC — `System` mode

```yaml
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:

- A per-instance **`KeycloakClient`** in the `cozy` realm with `clientId` set to `<namespace>-<release>` (for the CR above: `tenant-acme-monitoring-system`). Confidential (`clientAuthenticatorType: client-secret`), `secret` sourced from a chart-owned Kubernetes Secret. `redirectUris` locked to `https://grafana.<host>/login/generic_oauth`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule (<namespace>-<release>) is right but the worked example is not. monitoring.oidc.clientId is printf "%s-%s" .Release.Namespace .Release.Name, and the Monitoring ApplicationDefinition sets release.prefix: "" — so the CR shown directly above (name: monitoring in tenant-acme) yields the release monitoring and the clientId tenant-acme-monitoring.

monitoring-system is the platform release name, in cozy-monitoring — which this page names correctly two paragraphs earlier. As written, a reader goes looking for a KeycloakClient that does not exist.

- A per-instance **`KeycloakClientScope`** whose audience mapper pins the token's `aud` claim to that same `clientId` — 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 syncs `spec.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 when `users[]` is non-empty; omitted otherwise (BYO-IdP-friendly).
- The Grafana CR's **`spec.config.auth.generic_oauth`** section wired to the cozy realm issuer, per-instance audience scope, and the tenant-membership gate:

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify languages for the fenced configuration blocks.

Add a language identifier, such as ini, to both fences so markdownlint passes and syntax highlighting is available.

Proposed fix
-  ```
+  ```ini
...
-  ```
+  ```ini

Also applies to: 62-62

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 53-53: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@content/en/docs/next/operations/services/monitoring/oidc-authentication.md`
at line 53, Update both fenced configuration blocks in the OIDC authentication
documentation to include an appropriate language identifier, such as ini, on
each opening fence while leaving their configuration content unchanged.

Source: Linters/SAST tools

allowed_groups = <namespace>-view <namespace>-use <namespace>-admin <namespace>-super-admin
groups_attribute_path = groups
```

`groups_attribute_path` is REQUIRED alongside `allowed_groups` on Grafana v11.x+: without it Grafana leaves the extracted `user.Groups` slice 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 expression `groups` reads the top-level `groups` array from userinfo — the shape Keycloak's `oidc-group-membership-mapper` emits.

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 = false
```

`skip_org_role_sync=true` keeps a login from overwriting the users-Job's role assignments; `oauth_allow_insecure_email_lookup=true` lets Grafana attach the OIDC identity to the pre-provisioned local account by email; `allow_sign_up=false` is the isolation lever — without it, Grafana's default `allow_sign_up=true` combined with `skip_org_role_sync=true` would mint a Viewer account for every `cozy`-realm identity that hits the login flow.

Server-level `GrafanaAdmin` promotion is opt-in via `spec.oidc.grafanaAdmin: true` — the chart then sets `allow_assign_grafana_admin: true` on the rendered `[auth.generic_oauth]` block. The platform bundle flips this on for the platform Grafana release (`monitoring-system` in `cozy-monitoring`); tenant Grafana releases inherit the chart default `false` and stay at org-level Admin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec.oidc.grafanaAdmin does not exist in the code PR this page ships alongside. The oidc struct in packages/extra/monitoring/values.yaml has exactly three fields — mode, customConfig, users — and the string grafanaAdmin appears nowhere in cozystack/cozystack#3176.

That PR states the opposite explicitly in its own guide: "Server-level GrafanaAdmin promotion (allow_assign_grafana_admin) is out of scope for Phase 1. All Grafana instances — platform and tenant — cap at org-level Admin." Its unit suite pins the absence: notExists: spec.config["auth.generic_oauth"].allow_assign_grafana_admin.

An operator following this paragraph would set a field the CR schema rejects. Please drop it and move server-level GrafanaAdmin into the "What's out of scope" section instead.


### 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**:

```yaml
spec:
oidc:
mode: CustomConfig
customConfig:
config:
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using standard ASCII characters (like starting the sentence directly with Or) is preferred over the Unicode ellipsis character to ensure consistent rendering and avoid copy-paste issues.

Suggested change
…or via a pre-existing Secret in the tenant namespace holding a ready-made `[auth.generic_oauth]` ini fragment in the `auth.ini` key:
Or via a pre-existing Secret in the tenant namespace holding a ready-made `[auth.generic_oauth]` ini fragment in the `auth.ini` key:


```yaml
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.

The `inline config` path merges the operator's map on top of the chart-forced users-map contract (`skip_org_role_sync=true`, `oauth_allow_insecure_email_lookup=true`, `allow_sign_up=false`) when `users[]` is non-empty. 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 demotes them (users-Job deletes the account) on the next chart apply; 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:

```yaml
apiVersion: v1.edp.epam.com/v1
kind: KeycloakRealmUser
metadata:
name: alice-acme
namespace: cozy-keycloak
spec:
realm: cozy
username: alice@acme.example
email: alice@acme.example
emailVerified: true
password: "…"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use standard three dots ... instead of the Unicode ellipsis inside the code block.

Suggested change
password: ""
password: "..."

groups:
- tenant-acme-admin
```

## 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: true` on Keycloak users.** Phase 1 does not add a `claimValidationRules` entry — so `email_verified` is not chart-enforced. Set `emailVerified: true` on the `KeycloakRealmUser` (or complete the email-verify flow through the Keycloak UI) so the identity holding a given email is guaranteed authentic. The `cozy` realm's default `duplicateEmails: false` prevents a second account from claiming an already-registered address. CEL `claimValidationRules` to make this a hard gate is a follow-up hardening path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The mention of CEL claimValidationRules seems to be a copy-paste leftover from the Kubernetes OIDC guide. Grafana's generic OAuth does not support Kubernetes-style CEL (Common Expression Language) or claimValidationRules. Instead, claim validation or enforcement of email_verified in Grafana is typically achieved using JMESPath expressions within role_attribute_path (potentially combined with role_attribute_strict: true). Let's update this to refer to JMESPath-based enforcement instead of CEL claimValidationRules.

Suggested change
- **`emailVerified: true` on Keycloak users.** Phase 1 does not add a `claimValidationRules` entry — so `email_verified` is not chart-enforced. Set `emailVerified: true` on the `KeycloakRealmUser` (or complete the email-verify flow through the Keycloak UI) so the identity holding a given email is guaranteed authentic. The `cozy` realm's default `duplicateEmails: false` prevents a second account from claiming an already-registered address. CEL `claimValidationRules` to make this a hard gate is a follow-up hardening path.
- **`emailVerified: true` on Keycloak users.** The default configuration does not enforce `email_verified` in the role mapping. Set `emailVerified: true` on the `KeycloakRealmUser` (or complete the email-verify flow through the Keycloak UI) so the identity holding a given email is guaranteed authentic. The `cozy` realm's default `duplicateEmails: false` prevents a second account from claiming an already-registered address. Enforcing this in Grafana's `role_attribute_path` via JMESPath is a follow-up hardening path.

- **`groups_attribute_path` is not optional on Grafana v11.x+.** The chart wires it automatically for `System` mode; in `CustomConfig` inline the operator must add it explicitly (`groups_attribute_path: groups`) if their IdP emits a top-level `groups` array. Otherwise `allowed_groups` becomes a silent no-op and every login fails.
- **BYO issuer with a self-signed CA.** In `CustomConfig` mode the `secretRef` path is the way to ship a CA bundle alongside the `[auth.generic_oauth]` block — you package `auth.ini` and any `ca-cert` files into the Secret and mount both under `/etc/grafana/oidc`.
- **`admin_user` stays a break-glass path.** Even under `mode: System` the login form and the `grafana-admin-password` Secret remain wired. Locking the form off is a follow-up hardening.
- **Mode toggle drops the users-Job's local accounts.** Flipping `spec.oidc.mode` from `System` to `None` prunes every user the users-Job provisioned (the accounts are ephemeral shadows of `spec.oidc.users[]`). Any dashboards those users starred / owned move to their original creators; the local passwords / API keys go away. Flipping back re-provisions them empty. Treat mode toggle like an admin-Secret rotation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inverts the code. oidc-users-job.yaml renders only when spec.oidc.users is non-empty, and its header comment says so deliberately, naming mode=None as one of three empty-users states it guards against: "a prune pass with an empty desired list would silently delete every non-admin Main-Org member on every helm upgrade … Removing the last users: entry therefore no longer prunes it."

So SystemNone runs no Job and prunes nothing — the local Grafana accounts survive. Telling operators to treat the toggle "like an admin-Secret rotation" is the opposite of the actual, deliberately-safe behaviour.

While you are here, please re-check the related claim under "Assigning roles" that removing a user from the list "deletes the account" — the Job's documented action is pruning non-admin Main-Org members, which is an org-membership change, not necessarily account deletion.


## 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](https://github.com/cozystack/community/pull/24).
- **Federating an external IdP into the platform `cozy` realm.** 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_oauth` covers the OAuth part; `backend-logout-url` wiring is a follow-up.
- **CEL `claimValidationRules` for `email_verified`.** Explicit-gate hardening; not required for Phase 1 given the layered guarantees above.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since Grafana does not support CEL or claimValidationRules, we should clarify that this is a limitation of Grafana's OAuth capabilities rather than an out-of-scope feature that could be added later using CEL.

Suggested change
- **CEL `claimValidationRules` for `email_verified`.** Explicit-gate hardening; not required for Phase 1 given the layered guarantees above.
- **CEL `claimValidationRules` for `email_verified`.** Grafana does not support Kubernetes-style CEL validation rules; any such validation must be done via JMESPath in `role_attribute_path`.

- **Role granularity beyond Admin/Editor/Viewer.** Grafana org roles are the assignment surface; team memberships / dashboard-level permissions stay out-of-band.