Files
cozystack/hack
IvanHunters 3fb8ec5b0e feat(monitoring): per-instance OIDC selector for the Grafana instance (Phase 1) (#3176)
<!-- Follow-up to cozystack/cozystack#3044 — same design family,
different subject. -->

## What this PR does

Adds a Phase-1 OIDC selector to the Grafana instance shipped by
`packages/system/monitoring/`, mirroring the shape of the tenant
kube-apiserver PR
([cozystack/cozystack#3044](https://github.com/cozystack/cozystack/pull/3044))
so that operators can use one mental model for both. Rationale in
[cozystack/community#24](https://github.com/cozystack/community/pull/24)
— per-cluster (here: per-Monitoring-release) audience isolation on the
flat `cozy` realm, no per-tenant realm.

Authorization is **app-side** via a chart-owned `users:` map reconciled
by a post-install/post-upgrade Job into Grafana org membership. The
chart does NOT own Keycloak directory objects (no `KeycloakRealmGroup`s)
and does NOT rely on Grafana's `role_attribute_path` — same design shape
as #3044's ClusterRoleBinding reconcile, adapted for Grafana orgs. See
`docs/oidc-grafana.md` for the full rationale.

The `Monitoring` CR grows a new field:

```yaml
spec:
  oidc:
    mode: None | System | CustomConfig     # default None
    customConfig:                          # only for CustomConfig
      config: {}                           # inline grafana.ini keys, or...
      secretRef:
        name: ""                           # ...an operator-managed Secret
    users:                                 # optional; ignored in mode=None
      - email: alice@example.com
        role: Admin | Editor | Viewer
```

### Modes

- **`None`** (default) — no OIDC; `admin_user` / `admin_password` Secret
is the only auth path. Existing releases render byte-identical.
- **`System`** — chart provisions, in the release namespace of the
Monitoring CR:
- `KeycloakClient` (`public: false`, confidential, `secret` sourced from
a chart-owned Secret via the EDP `$<secret>:<key>` reference;
`redirectUris` locked to `https://grafana.<host>/login/generic_oauth`).
- `KeycloakClientScope` with an `oidc-audience-mapper` pinning
`id_token.aud` to the per-release clientId — the isolation primitive.
- Persistent Kubernetes Secret carrying the confidential `client-secret`
(random on first install, preserved via `lookup`+fallback, same pattern
as `packages/system/dashboard`).
- Grafana CR's `spec.config.auth.generic_oauth` section wired to the
`cozy` realm issuer + per-release audience scope, with two chart-forced
settings the users-Job depends on: `skip_org_role_sync: "true"` (login
never overwrites the Job's org-role assignments) and
`oauth_allow_insecure_email_lookup: "true"` (OIDC identity binds to the
pre-provisioned local account by email).
- `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana Deployment
sourced from the Secret.
- Post-install / post-upgrade **users-Job** reconciling
`spec.oidc.users` into Grafana's Main Org.: pre-provisions a local
account per listed email, `POST /api/orgs/1/users` + `PATCH
/api/orgs/1/users/{userId}` to converge role changes, prunes every
non-admin Main-Org member whose email is not in the list.
- Server-level `GrafanaAdmin` promotion (`allow_assign_grafana_admin`)
is out of scope for Phase 1. Every Grafana instance (platform and
tenant) caps at org-level `Admin`.
- **`CustomConfig`** — tenant supplies the whole `[auth.generic_oauth]`
payload; `cozy` is not in the path. Two mutually exclusive paths:
- `customConfig.config: {...}` — inline map of `grafana.ini` keys. The
chart **merges** the two chart-forced settings (`skip_org_role_sync`,
`oauth_allow_insecure_email_lookup`) on top so the users-Job contract
holds. The operator can NOT override those two (merge wins).
- `customConfig.secretRef.name: ...` — operator-owned Secret with an
`auth.ini` fragment; the chart mounts it under `/etc/grafana/oidc` and
wires `GF_PATHS_CUSTOM_INI`, and does NOT emit `auth.generic_oauth`
under `spec.config` (the ini fragment is authoritative). Because the
chart cannot inject the two settings into a mounted ini,
`spec.oidc.users` is NOT supported in this branch — the chart hard-fails
render on that combination.

### Fail-close guards

- `mode: System` without `_cluster.oidc-enabled == "true"` (the
platform-level `authentication.oidc.enabled` flag) → hard-fail with an
actionable message pointing at the platform values.
- `mode: System` on a cluster where the EDP Keycloak operator CRDs
(`v1.edp.epam.com/v1`) are not registered → hard-fail (symmetric on
`oidc-keycloak.yaml` and `grafana.yaml`).
- `mode: CustomConfig` with both or neither of `config` /
`secretRef.name` → hard-fail.
- `mode: CustomConfig` with `secretRef.name` AND non-empty `users` →
hard-fail (chart cannot inject the two forced settings into an
operator-mounted ini; forcing an explicit choice avoids silent contract
loss).
- `spec.oidc.users[]` entry with missing / unknown `role` or malformed
`email` → hard-fail at render time so the users-Job never fires with a
body that would 400 on Grafana's admin API.

### Break-glass posture

`admin_user` / `admin_password` and `disable_login_form: false` are
unchanged in every mode. Locking the form off under `mode: System` is a
documented follow-up hardening (see `docs/oidc-grafana.md`).

### What's NOT in this PR (documented as out of scope)

- Per-tenant Keycloak realms (Phase 2 territory, tracked in
community#24).
- Full-logout through Keycloak's end-session endpoint
(`backend-logout-url` and matching Keycloak client attribute).
- CEL `claimValidationRules` gate on `email_verified` — layered
guarantees are described in `docs/oidc-grafana.md`.
- Multi-issuer composition on one Monitoring release (mutually exclusive
modes).
- Server-level `GrafanaAdmin` promotion.

### Docs

- New `docs/oidc-grafana.md` — full operator guide (parallel to
`docs/oidc-tenant.md`).
- Website page follows in a paired PR against cozystack/website
(`docs/monitoring-oidc-authentication` branch, under
`content/en/docs/next/operations/services/monitoring/oidc-authentication.md`).

### Tests

- 33 `helm-unittest` cases in `tests/oidc_test.yaml` +
`tests/oidc_crd_missing_test.yaml` cover: all three modes; defensive nil
paths (`oidc: ~`); System-mode Keycloak client + audience scope shape;
CustomConfig chart-forced merge (positive assertion) AND
`mode=CustomConfig inline — operator cannot override chart-forced OIDC
settings` (regression guard); CustomConfig secretRef + `users` incompat
fail-fast; `spec.oidc.users[].role` missing/unknown fail-fast; users-Job
shape (hook annotations, RBAC-free, activeDeadlineSeconds,
`ttlSecondsAfterFinished: 3600`, admin credential wiring); no
chart-owned `KeycloakRealmGroup`s in any mode; no `role_attribute_path`
in Grafana config.
- Render-side bats in
`hack/e2e-apps/monitoring-oidc-{system,customconfig}.bats` — mirror the
tenant kube-apiserver's e2e shape (no live browser flow — deferred to a
follow-up integration suite, same as #3044). Asserts on live cluster:
users-Job appears with `DESIRED_USERS_JSON` populated, Grafana config
carries `skip_org_role_sync=true` and no `role_attribute_path`, no
chart-owned `KeycloakRealmGroup`s land in `cozy`.

### Screenshots

N/A — no UI change on default values; the visual delta is Grafana's own
"Sign in with Keycloak" button appearing under the login form when a
release opts in.

### Release note

```release-note
feat(monitoring): Each `Monitoring` CR can now opt its Grafana instance into OIDC via a flat selector — `spec.oidc.mode: System | CustomConfig | None` (default `None`). `System` trusts the platform `cozy` realm via a per-instance confidential Keycloak client + audience binding. Authorization is app-side: `spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` is reconciled into Grafana's Main Org. by a chart-owned post-install/post-upgrade Job (pre-provision → org add → PATCH role → prune stale). `CustomConfig` accepts a tenant-supplied `[auth.generic_oauth]` payload — inline as a map (chart merges the two contract-critical settings `skip_org_role_sync` + `oauth_allow_insecure_email_lookup` on top) or as a Secret with an `auth.ini` key (in which case `spec.oidc.users` is not supported and the chart fails render on that combination). Server-level `GrafanaAdmin` promotion is out of scope for Phase 1. The `admin_user`/`admin_password` Secret stays a documented break-glass path in every mode.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added OIDC authentication for Monitoring Grafana with `None`,
platform-managed `System`, and tenant-controlled `CustomConfig` modes.
* System mode provisions platform-backed OIDC for each instance
(including persistent client credentials) and can optionally promote
matching users to server-level `GrafanaAdmin`.
* CustomConfig supports inline OAuth settings or Secret-backed
`auth.ini` (with mutual exclusivity validation).
2026-07-15 18:17:56 +03:00
..
2026-02-17 22:54:12 +01:00