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).
This commit is contained in:
IvanHunters authored and GitHub committed 2026-07-15 18:17:56 +03:00
commit 3fb8ec5b0e
24 files changed
+2894 -9

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
# OIDC for the Grafana instance (Phase 1)
Cozystack ships one Grafana codebase deployed twice: as
`monitoring-system` in the platform's `cozy-monitoring` namespace and as
`monitoring` in every tenant namespace. Both instances opt in to OIDC
authentication through the same flat selector on the `Monitoring` CR.
This document covers the operator-facing surface: what the modes mean,
what the chart provisions, and how a user signs in.
The architectural rationale lives in
[cozystack/community#24](https://github.com/cozystack/community/pull/24)
— in particular why per-tenant Keycloak realms are deliberately
deferred to Phase 2. This Grafana integration follows the same shape
as the tenant kube-apiserver's Phase 1
([cozystack/cozystack#3044](https://github.com/cozystack/cozystack/pull/3044)),
adapted for a server-side (confidential) OAuth client instead of a
public kubectl one: authentication is wired up by the chart, but
authorization is an operator-supplied `users:` map reconciled into
the target (Grafana orgs here, ClusterRoleBindings in #3044) by a
chart-owned Job. The chart does not own any directory objects.
## Selector
```yaml
apiVersion: apps.cozystack.io/v1alpha1
kind: Monitoring
metadata:
name: monitoring
namespace: tenant-acme
spec:
oidc:
mode: System # System | CustomConfig | None (default)
users:
- email: alice@acme.example
role: Admin
- email: bob@acme.example
role: Viewer
```
Three modes:
- **None** — the only user-facing path is the `grafana-admin-password`
Secret, exposed to the tenant through
`packages/system/monitoring-rd`. This is the default; existing
instances render byte-identical to before.
- **System** — the Grafana instance trusts the platform `cozy` Keycloak
realm via a per-instance confidential client. Users are the ones a
platform admin already provisioned in `cozy`; the tenant does not
manage a directory of its own.
- **CustomConfig** — Grafana trusts a tenant-supplied OIDC issuer
directly. `cozy` is not in the path. Use for BYO IdPs (Okta, Auth0,
a customer's own Keycloak).
The `grafana-admin-password` Secret stays a documented break-glass
path in every mode; `disable_login_form` is not flipped by this
selector. Hardening that further (e.g. locking the form off in
`System` mode) is a follow-up.
## System mode
What the chart provisions, in the release namespace of the Monitoring
CR:
1. **KeycloakClient** named `<namespace>-<release>` in the `cozy`
realm — `public: false`, `directAccess: false`. The `secret` field
points at a chart-owned Kubernetes Secret via the EDP-Keycloak
`$<secret>:<key>` syntax so the operator provisions the same
confidential secret to Keycloak that Grafana reads at runtime.
`redirectUris` is locked to
`https://grafana.<host>/login/generic_oauth`.
2. **KeycloakClientScope** named `<namespace>-<release>-audience`
carrying an `oidc-audience-mapper` that pins `id_token.aud` to the
per-instance clientId. This is the per-instance isolation
primitive: a token minted for one Monitoring release is rejected
by another's Grafana.
3. A **Secret** `<release>-oidc-client` carrying a random 32-char
`client-secret`. Generated on first install (`lookup` + random
fallback, same pattern as `packages/system/dashboard`), preserved
across upgrades.
4. The Grafana CR's `spec.config.auth.generic_oauth` section wired to
the cozy realm issuer + the per-instance audience scope. Two
independent gates ride on top of the raw OAuth wiring:
**Tenant-membership gate — unconditional in System mode.** The chart
requests the `groups` OIDC scope (already provisioned platform-wide
by `packages/system/keycloak-configure` as a realm-default
KeycloakClientScope with an `oidc-group-membership-mapper`) and sets
```
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 (verified end-to-end on dev3 with Grafana v11.6.15).
The JMESPath expression `groups` reads the top-level `groups`
array from userinfo, matching the shape Keycloak's
`oidc-group-membership-mapper` emits.
The four groups match the KeycloakRealmGroups the tenant chart
provisions in the `cozy` realm — see
`packages/apps/tenant/templates/keycloakgroups.yaml`. Grafana
rejects any login whose token does not carry at least one of these
groups. This is the isolation primitive that stops tenant-alice
from signing into tenant-bob's Grafana even though both authenticate
against the same flat `cozy` realm. The audience mapper in point 2
protects against *replay* of a minted token across releases; this
gate protects against *issuance* of a usable token to a caller
outside the release's tenant.
**Users-map contract — active only when `spec.oidc.users` is
non-empty.** Three additional keys are merged on top:
- `skip_org_role_sync = true` — a login never overwrites the
users-Job's role assignments;
- `oauth_allow_insecure_email_lookup = true` — the OIDC identity
binds to the pre-provisioned local account by email;
- `allow_sign_up = false` — a cozy-realm identity whose email is
NOT in `spec.oidc.users` is rejected at login (even if they are
a tenant member and pass the group gate) instead of being
admitted as `auto_assign_org_role` Viewer and waiting for the
next helm-upgrade prune pass to clean them up.
When `spec.oidc.users` is empty (or unset) the three users-map
keys are NOT forced. The tenant-membership gate still applies, so
only tenant members can log in — they land at Grafana's
`auto_assign_org_role` default (`Viewer`). This is the intended
hands-off contract: the chart owns nothing app-side, but the
Keycloak-layer gate still isolates the release. Cross-tenant
users are rejected outright; in-tenant users get a self-service
Viewer with no org-role management by the chart.
5. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana
Deployment sourced from the Secret in step 3.
6. A post-install/post-upgrade **users-Job** — see the "Users and
RBAC" section — that reconciles `spec.oidc.users` into Grafana's
Main Org.
No `KeycloakRealmGroup`s and no `role_attribute_path`. Directory
objects (users, groups, group memberships) stay owned by whoever
operates the `cozy` realm; the Monitoring release only requests
authentication wiring, it does not act as a user directory.
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`.
## CustomConfig mode
The tenant supplies the entire `[auth.generic_oauth]` payload. Two
paths:
```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
```
…or via an existing Secret in the tenant namespace whose `auth.ini`
key holds a ready-made `[auth.generic_oauth]` fragment:
```yaml
spec:
oidc:
mode: CustomConfig
customConfig:
secretRef:
name: acme-byo-grafana-auth
```
When `spec.oidc.users` is non-empty the chart merges three settings
on top of the operator's inline map so the users-Job contract holds
in `CustomConfig` mode too:
- `skip_org_role_sync = true` — a login never overwrites the Job's
org-role assignments;
- `oauth_allow_insecure_email_lookup = true` — the OIDC identity
binds to the pre-provisioned local account by email;
- `allow_sign_up = false` — an OIDC identity whose email is not in
the users-map is rejected at login instead of being admitted with
the default `auto_assign_org_role`.
All three keys are chart-forced (`merge` semantics, chart wins) —
setting them in the operator's map is a no-op, and
`role_attribute_path` would just be dead config since the Job
manages roles.
When `spec.oidc.users` is empty (or unset) the chart forces none of
the three keys — a BYO-IdP operator who does not use the users-map
gets their inline `auth.generic_oauth` applied verbatim. This
matters because `oauth_allow_insecure_email_lookup` against a BYO
issuer that does not verify emails is a needless account-linking
surface, and `allow_sign_up = false` with no pre-provisioned
accounts would lock everyone out. If you want the isolation
posture without the users-map, set the keys explicitly in your
inline map.
**`secretRef.name` mode disables the users-map.** The chart mounts the
operator's `auth.ini` fragment verbatim via `GF_PATHS_CUSTOM_INI` and
cannot inject either of the two chart-forced settings. Setting
`spec.oidc.users` alongside `customConfig.secretRef.name` fails the
render with an explicit message — the alternatives are: (a) use
`customConfig.config` (inline) and let the chart merge, or (b) leave
`spec.oidc.users` empty and include both `skip_org_role_sync = true`
and `oauth_allow_insecure_email_lookup = true` in your ini fragment
plus manage the OIDC → Grafana user mapping yourself.
Setting both `config` and `secretRef.name` (or neither) fails the
render. In `CustomConfig` mode no Keycloak objects are provisioned in
`cozy` and no chart-owned client-secret Secret is created — the
operator manages their own credentials end-to-end.
## Users and RBAC
Grafana's built-in identity model exposes three org-level roles:
`Admin` / `Editor` / `Viewer`. The chart drives them from an
operator-supplied map on the CR:
```yaml
spec:
oidc:
users:
- email: alice@acme.example
role: Admin
- email: bob@acme.example
role: Editor
- email: carol@acme.example
role: Viewer
```
A chart-owned Job runs on every `helm install` / `helm upgrade` and
reconciles that list into Main Org. membership via Grafana's admin
API:
1. Each listed email gets a pre-provisioned local Grafana account
with a random password. The account is a shell; the operator
never uses that password. When they sign in with the OIDC flow
Grafana looks up the pre-provisioned account by email
(`oauth_allow_insecure_email_lookup = true`) and attaches the
OIDC identity to it.
2. Each listed email is added to Main Org. with the requested role.
Re-runs of the Job converge role changes (`Editor` → `Admin`,
demotions, etc.) via `PATCH /api/orgs/{orgId}/users/{userId}`.
3. Every Main-Org member whose email is neither in the list nor the
break-glass `grafana-admin-password` login is removed. Removing
an entry from `users:` and re-reconciling revokes access.
The Job renders only when **both** `mode != None` and
`spec.oidc.users` is non-empty. Three empty-users states all resolve
to the same hands-off contract — the chart owns nothing in Grafana
orgs:
- `mode: None` — OIDC is off, no chart-managed org membership at
all. This matters on upgrade: existing tenants who ran the
pre-PR Monitoring chart may have added users to Grafana manually
through the UI; if the Job ran in `mode: None` its prune pass
would silently delete those accounts on the next Flux reconcile.
- `customConfig.secretRef` — the users-map is forbidden by the
render-time assert; the operator's mounted `auth.ini` fragment is
authoritative.
- `mode: System | CustomConfig-inline` with `users:` unset — the
operator opted into OIDC but not into the chart-managed users-map;
they manage org membership themselves.
Setting `users: [...]` on the CR means the chart owns Main-Org
membership — anything added by hand (Grafana UI, admin API, other
tooling) gets pruned on the next reconcile. The reverse edge is
worth calling out: taking `users:` back to `[]` (or unsetting it)
does NOT prune the last entry — the chart stops managing membership
and any accounts the Job provisioned are left in place. Operators
who want to clean up OIDC-provisioned accounts after switching
`System | CustomConfig → None` (or after emptying `users:`) do it
themselves through the Grafana UI or admin API. The
`activeDeadlineSeconds` and `ttlSecondsAfterFinished` notes below
only apply when the Job actually renders.
While the users-map is active, users NOT listed in `spec.oidc.users`
who try to log in through OIDC are rejected at the door
(`allow_sign_up = false`) — no account is minted, no default
`Viewer` role is assigned. `skip_org_role_sync = true` in the
Grafana config additionally makes sure the Job's assignments
outlive the next login for users who ARE listed.
For `System` mode, the operator provisions the corresponding
Keycloak user in `cozy` in whatever way they already do (Keycloak
UI, a `KeycloakRealmUser` CR, an identity broker) and adds them to
one of the tenant's `<namespace>-{view,use,admin,super-admin}`
groups — Grafana's `allowed_groups` rejects the login otherwise.
The Monitoring release does not create Keycloak users and does not
provision `KeycloakRealmGroup`s — group directory ownership stays
with the tenant chart (`packages/apps/tenant`); group membership is
curated by whoever operates the `cozy` realm.
## How a user logs in
The user opens `https://grafana.<host>` in a browser and picks the
"Sign in with Keycloak" button under the login form. Grafana runs the
Authorization Code + PKCE flow against the cozy realm, receives a
token whose `aud` claim matches this Monitoring instance's clientId,
and — if the user's email is in `spec.oidc.users` — binds the OIDC
identity to the pre-provisioned local account with the role the Job
already set.
If the user's email is not in `spec.oidc.users` (and the users-map
is active — `mode != None` and the list is non-empty) the login is
refused by Grafana at the OAuth callback — `allow_sign_up = false`
does not mint an account for them. Add them to `spec.oidc.users`
and re-apply the CR; the Job will run on the next helm-upgrade,
pre-provision their local account, and the next login attempt will
succeed. When the users-map is inactive Grafana's default
`allow_sign_up = true` applies and any authenticated cozy-realm
identity lands with `auto_assign_org_role` (Viewer).
The break-glass `admin_user` / `admin_password` field on the form
stays wired to the `grafana-admin-password` Secret and continues to
work — useful when Keycloak is down or misconfigured.
## Failure modes
- **`mode: System` without the platform-level OIDC feature** — chart
render hard-fails:
`spec.oidc.mode: System requires the platform-level OIDC feature`.
Flip `authentication.oidc.enabled: true` in the platform values, or
switch to `CustomConfig`. On the platform side that flag also
swings the `cozystack.monitoring-application` PackageSource from
variant `default` (baseline dependsOn only) to variant `oidc`
(additionally waits for `cozystack.keycloak-operator` so the
`v1.edp.epam.com` CRDs consumed by the Keycloak-side of System
mode are registered before the monitoring chart reconciles).
- **`mode: System` without the Keycloak operator CRDs registered** —
chart render hard-fails:
`spec.oidc.mode: System requires the Keycloak operator CRDs
(v1.edp.epam.com/v1)`. Symmetric across
`templates/grafana/oidc-keycloak.yaml` and
`templates/grafana/grafana.yaml` so Grafana never comes up with an
`auth.generic_oauth` block pointing at a client that never gets
provisioned. For the platform `monitoring-system` release the
`oidc` variant's `dependsOn: cozystack.keycloak-operator` prevents
the race; for tenant releases (`Monitoring` CR with
`mode: System`) verify the keycloak-operator package is deployed
before creating the CR.
- **`CustomConfig` with an unreachable issuer or wrong claim
mappings** — Grafana rejects the callback and the login screen
shows an error; the `admin_user`/`admin_password` Secret keeps
working as break-glass.
- **User successfully logs in but sees no dashboards.** Their email
is missing from `spec.oidc.users` — no org role was assigned.
Add the entry and re-apply the CR; the users-Job runs on the
next helm-upgrade and grants access.
- **users-Job fails.** The Job caps at `activeDeadlineSeconds: 900`
and `backoffLimit: 6`. Common causes: Grafana never becomes
ready (check `kubectl -n <ns> get pods -l app=grafana`), or the
`grafana-admin-password` Secret is missing its `user`/`password`
keys. `ttlSecondsAfterFinished: 3600` keeps the failed hook Job
(and its Pod's logs) around for **one hour** after the terminal
Failed condition — check its logs with
`kubectl -n <ns> logs job/<release>-oidc-users` inside that window.
After 1h the Kubernetes TTL controller garbage-collects the Job
and the Pod together, so grab the logs quickly; if you missed
the window, re-trigger the hook with `helm upgrade` on the
release and reproduce.
- **`emailVerified` on Keycloak users is a prescriptive requirement,
not a chart-enforced one.** The chart does not emit any
`claimValidationRules` — the layered guarantees you rely on
instead are:
1. Provision users with `emailVerified: true` (via
`KeycloakRealmUser` or the Keycloak UI's email-verify flow) so
no unverified identity ever holds a given email.
2. The `cozy` realm keeps Keycloak's default
`duplicateEmails: false`, so a second account cannot claim an
already-registered address to impersonate an existing operator.
3. Grafana's own login flow rejects tokens the apiserver rejects,
so on the wire the same guarantee still holds through the JWT
signature and audience checks.
Adding a CEL `claimValidationRules` entry is a reasonable
follow-up hardening; it is out of scope for Phase 1.
- **`CustomConfig` with both or neither payload set** — chart render
hard-fails: `set exactly one of 'config' (inline) or
'secretRef.name' — they are mutually exclusive`, or `CustomConfig
requires either spec.oidc.customConfig.config … or …secretRef.name`.
## What is NOT in Phase 1
- **Per-tenant Keycloak realms.** Phase 2 candidate; tracked in
cozystack/community#24.
- **Full-logout through Keycloak's end-session endpoint.**
`auth.generic_oauth` native to Grafana does the OAuth part; wiring
`--backend-logout-url` and the corresponding Keycloak client
attribute is a subsequent hardening.
- **`disable_login_form: true` under `mode: System`.** Kept off so
the `admin_user`/`admin_password` Secret remains a documented
break-glass path; hardening it is a follow-up.
- **Server-level `GrafanaAdmin` promotion.** All Grafana instances
cap at org-level `Admin`. Not exposed on the CR.
- **CEL `claimValidationRules` enforcing `email_verified`.** See the
failure-modes note above.
- **Multi-issuer / BYO alongside `cozy`.** `mode: System` and
`mode: CustomConfig` are mutually exclusive on a single Monitoring
release — no composition. Follow the tenant kube-apiserver PR's
structured-config path if you need this later.
+26 -4
View File
@@ -195,10 +195,10 @@ expires.
the apiserver rejects tokens at request time; the admin kubeconfig
keeps working as break-glass.
- **`emailVerified` on Keycloak users is a prescriptive requirement,
not a chart-enforced one.** Phase 1 does not emit any
`claimValidationRules` in the rendered `AuthenticationConfiguration`,
so the chart itself is not the gate. The layered guarantees you rely
on instead:
not a chart-enforced one.** The chart emits a
`claimValidationRules` entry keyed on `groups` (see next bullet),
but not one keyed on `email_verified`. The layered guarantees you
rely on instead:
1. Provision users with `emailVerified: true` (via `KeycloakRealmUser`
or the Keycloak UI's email-verify flow) so no unverified identity
ever holds the email you name in `users[].email`.
@@ -214,6 +214,28 @@ expires.
the rendered System-mode config would elevate item 3 to a hard gate;
that is a reasonable follow-up hardening but is out of scope for
Phase 1.
- **Cross-tenant isolation gate.** The rendered
`AuthenticationConfiguration` carries a CEL `claimValidationRules`
entry on the `groups` claim that rejects any token whose caller is
not a member of at least one of this tenant's four Keycloak groups:
```
<namespace>-view <namespace>-use <namespace>-admin <namespace>-super-admin
```
The tenant chart (`packages/apps/tenant/templates/keycloakgroups.yaml`)
provisions these groups in the shared `cozy` realm. Without this
gate, any authenticated cozy-realm user could point `kubectl
oidc-login` at another tenant's cluster, land at
`system:authenticated`, and enumerate the discovery surface
(`kubectl api-resources`, OpenAPI schemata) — RBAC would default-deny
named-resource access, but discovery still leaks. Symmetric with the
Grafana-side `allowed_groups` gate on the Monitoring chart's
`auth.generic_oauth` block (see `docs/oidc-grafana.md`). CustomConfig
mode does NOT get this gate injected — the tenant's own
AuthenticationConfiguration is authoritative and the tenant is
responsible for their own claim-side guards.
- **`kubectl` without the `oidc-login` plugin** — the exec block errors
out client-side; install the plugin.
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bats
# Phase-1 OIDC CustomConfig selector e2e for the Grafana instance,
# render-side only.
#
# What is exercised on a live cluster: that the new
# `spec.oidc.mode: CustomConfig` field is admitted; that cozystack-api
# accepts the updated schema; that the HelmRelease renders; that the
# operator-supplied auth.generic_oauth payload lands on the Grafana
# CR; and, crucially, that NO Keycloak objects are created in the
# `cozy` realm when the tenant brings their own issuer.
# cozytest.sh (the e2e runner) is not real bats: it never invokes
# setup()/teardown(). Cleanup belongs in cozy_cleanup(), which runs at
# suite exit and on the first failing test. Per-test isolation is done
# inline at the top of each @test.
#
# Naming: the extra/monitoring chart is a singleton per tenant
# namespace and enforces `.Release.Name == "monitoring"` in
# templates/check-release-name.yaml. So the Monitoring CR (and its
# operator-produced outer HelmRelease) MUST be named `monitoring`;
# the inner packages/system/monitoring HelmRelease that carries the
# OIDC templates is then named `${outer}-system`, i.e. `monitoring-system`.
CR_NAME="monitoring"
INNER_REL="${CR_NAME}-system"
BYO_SECRET="mon-oidc-byo-oauth"
cleanup_mon() {
kubectl -n tenant-test delete monitoring.apps.cozystack.io "${CR_NAME}" \
--ignore-not-found --wait=false 2>/dev/null || true
kubectl -n tenant-test wait monitoring.apps.cozystack.io "${CR_NAME}" \
--for=delete --timeout=2m 2>/dev/null || true
kubectl -n tenant-test delete secret "${BYO_SECRET}" \
--ignore-not-found 2>/dev/null || true
}
cozy_cleanup() { cleanup_mon; }
@test "Monitoring CR accepts spec.oidc.mode=CustomConfig with inline config" {
cleanup_mon
kubectl apply -f - <<EOF
apiVersion: apps.cozystack.io/v1alpha1
kind: Monitoring
metadata:
name: ${CR_NAME}
namespace: tenant-test
spec:
host: ""
metricsStorages:
- name: shortterm
retentionPeriod: "3d"
deduplicationInterval: "15s"
storage: 10Gi
storageClassName: replicated
logsStorages:
- name: generic
retentionPeriod: "1"
storage: 10Gi
storageClassName: replicated
oidc:
mode: CustomConfig
customConfig:
config:
client_id: byo-monitoring
client_secret: byo-secret
auth_url: https://idp.example.test/auth
token_url: https://idp.example.test/token
api_url: https://idp.example.test/userinfo
users:
- email: e2e-byo@example.com
role: Viewer
EOF
# Outer HR (created by cozystack-operator from the CR) shares the CR name.
timeout 60 sh -ec 'until kubectl -n tenant-test get hr "'"${CR_NAME}"'" >/dev/null 2>&1; do sleep 2; done'
# Inner HR (rendered by extra/monitoring) is ${outer}-system.
timeout 120 sh -ec 'until kubectl -n tenant-test get hr "'"${INNER_REL}"'" >/dev/null 2>&1; do sleep 2; done'
timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done'
}
@test "Grafana CR carries the operator's auth.generic_oauth payload verbatim" {
# Poll the specific field, not just CR existence — otherwise we may
# grab a stale Grafana CR that hasn't yet been reconciled with the
# inner HR's CustomConfig values.
timeout 180 sh -ec '
until [ "$(kubectl -n tenant-test get grafana grafana \
-o jsonpath="{.spec.config.auth\.generic_oauth.client_id}" 2>/dev/null)" = "byo-monitoring" ]; do
sleep 5
done
'
client_id=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.client_id}')
echo "client_id: ${client_id}"
[ "${client_id}" = "byo-monitoring" ]
auth_url=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.auth_url}')
echo "auth_url: ${auth_url}"
[ "${auth_url}" = "https://idp.example.test/auth" ]
# No cozy realm in the picture.
echo "${auth_url}" | grep -vq "realms/cozy" || {
echo "auth_url unexpectedly references the cozy realm"
return 1
}
}
@test "No KeycloakClient / Scope are created in the cozy realm" {
# Only meaningful when the EDP CRDs exist on the cluster.
if ! kubectl api-resources --api-group=v1.edp.epam.com >/dev/null 2>&1; then
skip "EDP Keycloak operator CRDs not present on this cluster"
fi
# clientId helper: printf "%s-%s" .Release.Namespace .Release.Name in
# the inner chart -> tenant-test-monitoring-system.
CID="tenant-test-${INNER_REL}"
# Give any reconciler a moment; then assert neither cozy-realm object
# exists. Chart-owned KeycloakRealmGroups were removed entirely (see
# design note in docs/oidc-grafana.md) so no group assertions here.
sleep 5
! kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" 2>/dev/null
! kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" 2>/dev/null
}
@test "secretRef variant mounts operator Secret under /etc/grafana/oidc" {
# Operator-owned Secret carrying a ready-made ini fragment.
kubectl -n tenant-test create secret generic "${BYO_SECRET}" \
--from-literal=auth.ini='[auth.generic_oauth]
enabled = true
name = ByoIdp
client_id = byo-monitoring
client_secret = byo-secret
' --dry-run=client -o yaml | kubectl apply -f -
# Update the Monitoring CR in place: swap customConfig.config (inline)
# for customConfig.secretRef.name. Delete + immediate apply races
# kubectl's create-vs-patch decision against the API server's grace-
# period cache and can hit "NotFound" during patch, so let the operator
# reconcile the mode change on the same CR instead.
kubectl apply -f - <<EOF
apiVersion: apps.cozystack.io/v1alpha1
kind: Monitoring
metadata:
name: ${CR_NAME}
namespace: tenant-test
spec:
host: ""
metricsStorages:
- name: shortterm
retentionPeriod: "3d"
deduplicationInterval: "15s"
storage: 10Gi
storageClassName: replicated
logsStorages:
- name: generic
retentionPeriod: "1"
storage: 10Gi
storageClassName: replicated
oidc:
mode: CustomConfig
customConfig:
secretRef:
name: ${BYO_SECRET}
EOF
timeout 60 sh -ec 'until kubectl -n tenant-test get hr "'"${CR_NAME}"'" >/dev/null 2>&1; do sleep 2; done'
timeout 120 sh -ec 'until kubectl -n tenant-test get hr "'"${INNER_REL}"'" >/dev/null 2>&1; do sleep 2; done'
timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done'
# The Grafana CR from @test 1 already exists and carries the inline
# auth.generic_oauth block; wait for the chart to reconcile the swap
# to secretRef by polling for the target state (volume mount present
# AND inline block absent) instead of asserting immediately. 600s
# window is sized for a full inner-HR upgrade cycle + grafana-
# operator reconcile — 180s is too tight when the sandbox is warm-
# loaded, cf. the intermittent CI failure.
timeout 600 sh -ec '
until [ -n "$(kubectl -n tenant-test get grafana grafana -o jsonpath="{.spec.deployment.spec.template.spec.containers[?(@.name==\"grafana\")].volumeMounts[?(@.name==\"oidc-custom-ini\")].mountPath}")" ]; do sleep 5; done
'
timeout 120 sh -ec '
until [ -z "$(kubectl -n tenant-test get grafana grafana -o jsonpath="{.spec.config.auth\.generic_oauth}")" ]; do sleep 5; done
'
# spec.config carries no auth.generic_oauth section in this branch.
section=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth}')
echo "spec.config.auth.generic_oauth: ${section:-<empty>}"
[ -z "${section}" ]
# The volume + mount + env are on the Grafana CR's deployment override.
mount=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.deployment.spec.template.spec.containers[?(@.name=="grafana")].volumeMounts[?(@.name=="oidc-custom-ini")].mountPath}')
echo "mount: ${mount}"
[ "${mount}" = "/etc/grafana/oidc" ]
vol=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.deployment.spec.template.spec.volumes[?(@.name=="oidc-custom-ini")].secret.secretName}')
echo "secret volume: ${vol}"
[ "${vol}" = "${BYO_SECRET}" ]
}
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bats
# Phase-1 OIDC System-mode e2e for the Grafana instance, render-side
# only.
#
# Pairs with monitoring-oidc-customconfig.bats (BYO issuer path).
#
# Does NOT drive a full browser login through Keycloak: that path is
# deferred to a follow-up integration suite (same posture as the
# tenant kube-apiserver's kubernetes-oidc-system.bats).
#
# What is exercised here on a live cluster: that the new
# `spec.oidc.*` fields are admitted by the Monitoring CR; that
# cozystack-api accepts the updated schema; that the HelmRelease
# renders the OIDC templates; and that the resulting KeycloakClient /
# KeycloakClientScope / persistent client-secret Secret / Grafana CR /
# users-reconcile Job carry the expected shape. Chart-owned
# KeycloakRealmGroups + role_attribute_path are gone (see the
# design note in docs/oidc-grafana.md); authorization is driven by
# spec.oidc.users reconciled into Grafana orgs by the users-Job.
# cozytest.sh (the e2e runner) is not real bats: it never invokes
# setup()/teardown(). Cleanup belongs in cozy_cleanup(), which runs at
# suite exit and on the first failing test. Per-test isolation is done
# inline at the top of each @test.
#
# Naming: the extra/monitoring chart is a singleton per tenant
# namespace and enforces `.Release.Name == "monitoring"` in
# templates/check-release-name.yaml. So the Monitoring CR (and its
# operator-produced outer HelmRelease) MUST be named `monitoring`;
# the inner packages/system/monitoring HelmRelease that carries the
# OIDC templates is then named `${outer}-system`, i.e. `monitoring-system`,
# and every OIDC identifier derived by the helpers (clientId, audience
# scope, client-secret Secret) is built off that inner name.
CR_NAME="monitoring"
INNER_REL="${CR_NAME}-system"
CID="tenant-test-${INNER_REL}"
cleanup_mon() {
kubectl -n tenant-test delete monitoring.apps.cozystack.io "${CR_NAME}" \
--ignore-not-found --wait=false 2>/dev/null || true
kubectl -n tenant-test wait monitoring.apps.cozystack.io "${CR_NAME}" \
--for=delete --timeout=2m 2>/dev/null || true
}
cozy_cleanup() { cleanup_mon; }
@test "Monitoring CR accepts spec.oidc.mode=System" {
cleanup_mon
kubectl apply -f - <<EOF
apiVersion: apps.cozystack.io/v1alpha1
kind: Monitoring
metadata:
name: ${CR_NAME}
namespace: tenant-test
spec:
host: ""
metricsStorages:
- name: shortterm
retentionPeriod: "3d"
deduplicationInterval: "15s"
storage: 10Gi
storageClassName: replicated
logsStorages:
- name: generic
retentionPeriod: "1"
storage: 10Gi
storageClassName: replicated
oidc:
mode: System
users:
- email: e2e-admin@example.com
role: Admin
EOF
# Outer HR shares the CR name; inner HR (chart target) is ${outer}-system.
timeout 60 sh -ec 'until kubectl -n tenant-test get hr "'"${CR_NAME}"'" >/dev/null 2>&1; do sleep 2; done'
timeout 120 sh -ec 'until kubectl -n tenant-test get hr "'"${INNER_REL}"'" >/dev/null 2>&1; do sleep 2; done'
# Wait for the chart to render its Grafana CR. We do not wait on
# HR Ready: that requires a full VictoriaMetrics + Postgres bringup
# which the render-side test deliberately avoids.
timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done'
}
@test "Grafana CR carries auth.generic_oauth pointing at the cozy realm" {
# Wait for the Grafana CR to actually reflect mode=System values —
# `until kubectl get grafana` only proves the CR EXISTS (it may still
# be a stale copy from a previous test or from before the inner HR
# picked up the new values). Poll the specific field the test is
# about to assert on so the race window closes.
timeout 180 sh -ec '
until [ "$(kubectl -n tenant-test get grafana grafana \
-o jsonpath="{.spec.config.auth\.generic_oauth.enabled}" 2>/dev/null)" = "true" ]; do
sleep 5
done
'
enabled=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.enabled}')
echo "generic_oauth.enabled: ${enabled}"
[ "${enabled}" = "true" ]
client_id=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.client_id}')
echo "client_id: ${client_id}"
[ "${client_id}" = "${CID}" ]
auth_url=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.auth_url}')
echo "auth_url: ${auth_url}"
echo "${auth_url}" | grep -qE '/realms/cozy/protocol/openid-connect/auth$'
}
@test "Persistent client-secret Secret is created and 32-char random" {
# clientSecretName helper: printf "%s-oidc-client" .Release.Name -> ${INNER_REL}-oidc-client.
SEC="${INNER_REL}-oidc-client"
timeout 60 sh -ec 'until kubectl -n tenant-test get secret "'"${SEC}"'" >/dev/null 2>&1; do sleep 2; done'
value=$(kubectl -n tenant-test get secret "${SEC}" \
-o jsonpath='{.data.client-secret}' | base64 -d)
echo "client-secret length: ${#value}"
[ "${#value}" = "32" ]
# No unexpected characters.
echo "${value}" | grep -qE '^[A-Za-z0-9]+$'
}
@test "Per-instance KeycloakClient + KeycloakClientScope land in cozy (no groups)" {
# The EDP Keycloak operator API group is only present when the
# platform-level OIDC feature is on. Skip gracefully otherwise so the
# test suite works on runners without Keycloak installed.
if ! kubectl api-resources --api-group=v1.edp.epam.com >/dev/null 2>&1; then
skip "EDP Keycloak operator CRDs not present on this cluster"
fi
timeout 60 sh -ec 'until kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "'"${CID}"'" >/dev/null 2>&1; do sleep 2; done'
# The EDP Keycloak operator's CRD strips `spec.public: false` on write
# (schema default is false, so a false value never materialises in the
# applied object). Assert confidentiality via clientAuthenticatorType,
# which the operator DOES persist.
auth_type=$(kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" \
-o jsonpath='{.spec.clientAuthenticatorType}')
echo "client authenticator type: ${auth_type}"
[ "${auth_type}" = "client-secret" ]
timeout 30 sh -ec 'until kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "'"${CID}"'-audience" >/dev/null 2>&1; do sleep 2; done'
mapper=$(kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" \
-o jsonpath='{.spec.protocolMappers[0].protocolMapper}')
echo "audience mapper: ${mapper}"
[ "${mapper}" = "oidc-audience-mapper" ]
# No chart-owned KeycloakRealmGroups — directory objects stay owned
# by whoever runs the cozy realm; authorization is app-side (see the
# users-Job assertion below).
for role in admin editor viewer; do
if kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-${role}" >/dev/null 2>&1; then
echo "unexpected chart-owned realm group ${CID}-${role} present" >&2
false
fi
done
}
@test "Grafana config has no role_attribute_path and enables skip_org_role_sync" {
# Same wait as the first Grafana-CR test — poll the specific field
# rather than merely the CR's existence so we don't hit a stale copy.
timeout 180 sh -ec '
until [ "$(kubectl -n tenant-test get grafana grafana \
-o jsonpath="{.spec.config.auth\.generic_oauth.skip_org_role_sync}" 2>/dev/null)" = "true" ]; do
sleep 5
done
'
role_attr=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.role_attribute_path}')
echo "role_attribute_path: ${role_attr}"
[ -z "${role_attr}" ]
skip_sync=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.skip_org_role_sync}')
echo "skip_org_role_sync: ${skip_sync}"
[ "${skip_sync}" = "true" ]
email_lookup=$(kubectl -n tenant-test get grafana grafana \
-o jsonpath='{.spec.config.auth\.generic_oauth.oauth_allow_insecure_email_lookup}')
echo "oauth_allow_insecure_email_lookup: ${email_lookup}"
[ "${email_lookup}" = "true" ]
}
@test "users-Job runs and carries the desired list from spec.oidc.users" {
# helm.sh/hook: post-install,post-upgrade — the Job appears after the
# inner HR reconciles. `hook-delete-policy: before-hook-creation`
# keeps the previous Job around across upgrades, so `until kubectl
# get job` may return a stale Job from the previous test (or from
# the customconfig round where users was set differently or absent).
# Poll on the target env value directly — the new Job with our
# desired list is the first Job whose DESIRED_USERS_JSON contains
# `e2e-admin@example.com`.
timeout 600 sh -ec '
until kubectl -n tenant-test get job "'"${INNER_REL}"'-oidc-users" \
-o jsonpath="{.spec.template.spec.containers[?(@.name==\"reconcile\")].env[?(@.name==\"DESIRED_USERS_JSON\")].value}" 2>/dev/null \
| grep -q "e2e-admin@example.com"; do
sleep 5
done
'
desired=$(kubectl -n tenant-test get job "${INNER_REL}-oidc-users" \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="reconcile")].env[?(@.name=="DESIRED_USERS_JSON")].value}')
echo "DESIRED_USERS_JSON: ${desired}"
echo "${desired}" | grep -q 'e2e-admin@example.com'
echo "${desired}" | grep -q '"role":"Admin"'
mode=$(kubectl -n tenant-test get job "${INNER_REL}-oidc-users" \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="reconcile")].env[?(@.name=="OIDC_MODE")].value}')
echo "OIDC_MODE: ${mode}"
[ "${mode}" = "System" ]
}
@test "Grafana Deployment injects GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret" {
# Grafana operator materialises a Deployment named `grafana-deployment`
# from the Grafana CR. Poll for it before asserting on the env.
timeout 120 sh -ec 'until kubectl -n tenant-test get deploy grafana-deployment >/dev/null 2>&1; do sleep 5; done'
envs=$(kubectl -n tenant-test get deploy grafana-deployment \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="grafana")].env[*].name}')
echo "grafana env: ${envs}"
echo "${envs}" | grep -qw "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"
ref=$(kubectl -n tenant-test get deploy grafana-deployment \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="grafana")].env[?(@.name=="GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET")].valueFrom.secretKeyRef.name}')
echo "client-secret env source: ${ref}"
[ "${ref}" = "${INNER_REL}-oidc-client" ]
}
@@ -187,3 +187,42 @@ cozystack-basics.
{{- define "kubernetes.oidc.systemIssuerURL" -}}
{{- printf "https://keycloak.%s/realms/cozy" (dig "root-host" "" (.Values._cluster | default dict)) }}
{{- end }}
{{/*
CEL claimValidationRule body — rejects tokens whose `groups` claim does
NOT carry at least one of the tenant's four Keycloak groups. The tenant
chart (packages/apps/tenant) provisions these groups per-tenant in the
`cozy` realm; the namespace name is the tenant identifier for both root
and nested tenants (see tenant.name in the tenant chart helpers), so
`.Release.Namespace` is the correct prefix.
The `has(claims.groups) &&` guard is required — CEL evaluation of
`claims.groups.exists(...)` on a token missing the claim raises a
runtime error that surfaces as HTTP 500 from the authenticator rather
than the intended 401. `has()` short-circuits that path into a plain
unauthorized outcome with the `message` string in the audit log.
Why enforce membership at the apiserver even though RBAC default-denies
unmapped identities: `system:authenticated` still leaks the OpenAPI +
discovery surface (kubectl auth can-i --list, kubectl api-resources,
`/apis/*` schemata) to every user in the shared `cozy` realm — a
tenant-alice user could enumerate tenant-bob's cluster's CRDs and
built-in resource shape. Adding a hard cross-tenant gate here matches
the design's stated authorization boundary (per-tenant kube-apiserver
= per-tenant identity domain) rather than relying on downstream
RBAC-shaped conservatism.
*/}}
{{- /*
`claims.groups` is statically typed `any` in the apiserver's CEL environment
(claims is map(string, any)), and the `.exists()` comprehension macro rejects a
range of type `any` — the apiserver fails AuthenticationConfiguration compilation
at startup and CrashLoops ("expression of type 'any' cannot be range of a
comprehension (must be list, map, or dynamic)"). Wrap in dyn() so CEL treats the
range as dynamic; the `has()` guard still short-circuits when groups is absent.
Verified on a live tenant apiserver (v1.32) — without dyn() the control plane
never boots.
*/ -}}
{{- define "kubernetes.oidc.groupsClaimValidationExpr" -}}
{{- $ns := .Release.Namespace -}}
{{- printf "has(claims.groups) && dyn(claims.groups).exists(g, g in [\"%s-view\", \"%s-use\", \"%s-admin\", \"%s-super-admin\"])" $ns $ns $ns $ns -}}
{{- end }}
@@ -30,6 +30,7 @@
{{- if eq .Values.oidc.mode "System" }}
{{- $issuer := include "kubernetes.oidc.systemIssuerURL" . | trim }}
{{- $audience := include "kubernetes.oidc.clientId" . | trim }}
{{- $groupsExpr := include "kubernetes.oidc.groupsClaimValidationExpr" . | trim }}
{{- /*
User identity claim: `email`, not `preferred_username`.
@@ -46,8 +47,19 @@ Switching the claim to `email` aligns the apiserver-visible identity
with the operator's declared `users[].email` value. Requires the
issuer to emit `email` — cozy realm (verified) and every conformant
OIDC provider do.
claimValidationRules — cross-tenant isolation gate. Without this rule
any authenticated cozy-realm user (i.e. anyone in the shared platform
realm) would receive `system:authenticated` on this tenant's
apiserver. RBAC default-denies, but discovery (`kubectl api-resources`,
OpenAPI schemata) still leaks. The CEL expression forces at least one
of the four tenant-scoped groups
(`<namespace>-{view,use,admin,super-admin}`, provisioned by the tenant
chart) to be present in the token's `groups` claim. Symmetric with the
Grafana-side `allowed_groups` gate in the Monitoring chart — see
docs/oidc-tenant.md and docs/oidc-grafana.md.
*/ -}}
{{- $authnConfig := printf "apiVersion: apiserver.config.k8s.io/v1beta1\nkind: AuthenticationConfiguration\njwt:\n- issuer:\n url: %s\n audiences:\n - %s\n claimMappings:\n username:\n claim: email\n prefix: \"\"\n groups:\n claim: groups\n prefix: \"\"\n" $issuer $audience }}
{{- $authnConfig := printf "apiVersion: apiserver.config.k8s.io/v1beta1\nkind: AuthenticationConfiguration\njwt:\n- issuer:\n url: %s\n audiences:\n - %s\n claimValidationRules:\n - expression: '%s'\n message: \"user is not a member of any %s-{view,use,admin,super-admin} group in the cozy realm — this tenant's apiserver only trusts identities the tenant chart has admitted\"\n claimMappings:\n username:\n claim: email\n prefix: \"\"\n groups:\n claim: groups\n prefix: \"\"\n" $issuer $audience $groupsExpr .Release.Namespace }}
---
apiVersion: v1
kind: Secret
@@ -262,6 +262,25 @@ tests:
- matchRegex:
path: stringData["config.yaml"]
pattern: "- tenant-foo-kubernetes-app"
# Cross-tenant isolation gate — the AuthenticationConfiguration
# must carry a CEL claimValidationRule keyed on the release's
# tenant-scoped groups. Without this rule, any authenticated
# cozy-realm user would land at `system:authenticated` on this
# tenant's apiserver and get discovery access. See
# kubernetes.oidc.groupsClaimValidationExpr in _helpers.tpl.
- matchRegex:
path: stringData["config.yaml"]
pattern: "claimValidationRules:"
# The groups range MUST be wrapped in dyn(): claims.groups is statically
# `any`, and the apiserver rejects a comprehension over `any` at startup
# (CrashLoop). Pinning dyn() here makes a regression fail the unit suite
# instead of only surfacing on a live apiserver. Verified on dev3 v1.32.
- matchRegex:
path: stringData["config.yaml"]
pattern: 'expression: .+has\(claims\.groups\).+dyn\(claims\.groups\)\.exists\(g, g in .+tenant-foo-view.+tenant-foo-use.+tenant-foo-admin.+tenant-foo-super-admin'
- matchRegex:
path: stringData["config.yaml"]
pattern: 'message: "user is not a member of any tenant-foo-\{view,use,admin,super-admin\} group'
- it: System mode injects --authentication-config and the chart-owned volume into the KCP
release:
@@ -33,3 +33,38 @@ spec:
install:
namespace: cozy-system
releaseName: monitoring-rd
# `oidc` variant — additionally waits for cozystack.keycloak-operator so
# the v1.edp.epam.com CRDs consumed by the Phase-1 OIDC selector
# (spec.oidc.mode: System renders a per-instance KeycloakClient and
# KeycloakClientScope in the cozy realm) are registered before the
# monitoring chart reconciles. Without this edge, the
# `.Capabilities.APIVersions.Has "v1.edp.epam.com/v1"` guard in
# templates/grafana/oidc-keycloak.yaml would fail the render (or, worse,
# if the guard were removed, silently drop the Keycloak side and leave
# Grafana's auth.generic_oauth pointing at a client that never gets
# provisioned). Selected from the bundle when `authentication.oidc.enabled`
# is true — see packages/core/platform/templates/bundles/system.yaml.
# Mirrors the cozystack-engine default/oidc variant split.
- name: oidc
dependsOn:
- cozystack.networking
- cozystack.postgres-operator
- cozystack.grafana-operator
- cozystack.victoria-metrics-operator
- cozystack.cozystack-engine
- cozystack.keycloak-operator
libraries:
- name: cozy-lib
path: library/cozy-lib
components:
- name: monitoring-system
path: system/monitoring
libraries: ["cozy-lib"]
- name: monitoring
path: extra/monitoring
libraries: ["cozy-lib"]
- name: monitoring-rd
path: system/monitoring-rd
install:
namespace: cozy-system
releaseName: monitoring-rd
@@ -192,7 +192,28 @@
{{include "cozystack.platform.package.default" (list "cozystack.gateway-application" $) }}
{{include "cozystack.platform.package.default" (list "cozystack.seaweedfs-application" $) }}
{{include "cozystack.platform.package.default" (list "cozystack.info-application" $) }}
{{include "cozystack.platform.package.default" (list "cozystack.monitoring-application" $) }}
{{- /* Variant selection mirrors cozystack-engine above: `oidc` when
platform-level OIDC is on (extra dependsOn on
cozystack.keycloak-operator so the v1.edp.epam.com CRDs are
registered before the monitoring chart reconciles), `default`
otherwise (no wait on a Package that would not be emitted).
No component-values override is passed. The platform Grafana's
HelmRelease is created by the tenant-root chart's
templates/monitoring.yaml (not by PackageReconciler over the
monitoring-system component here — that component has no
`install:` block), so `Package.spec.components[].values` here
would be silently dropped by the reconciler
(internal/operator/package_reconciler.go:197-201 skips
components without `install:` before ever reaching the
hr.Spec.Values assignment). Server-level GrafanaAdmin
promotion is out of scope for Phase 1; see
docs/oidc-grafana.md. */}}
{{- if .Values.authentication.oidc.enabled }}
{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "oidc" $) }}
{{- else }}
{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "default" $) }}
{{- end }}
{{include "cozystack.platform.package.default" (list "cozystack.etcd-application" $) }}
{{include "cozystack.platform.package.default" (list "cozystack.cozystack-basics" $) }}
{{include "cozystack.platform.package.default" (list "cozystack.backupstrategy-controller" $) }}
@@ -0,0 +1,70 @@
suite: monitoring-application variant ordering edges (default + oidc)
# Regression guard for a fresh-install ordering race introduced by the Phase-1
# OIDC selector (cozystack/cozystack#3176). The `oidc` variant additionally
# depends on cozystack.keycloak-operator so the v1.edp.epam.com CRDs are
# registered before the monitoring chart reconciles — without this edge the
# `.Capabilities.APIVersions.Has` guard in
# templates/grafana/oidc-keycloak.yaml would silently drop the whole
# Keycloak side of mode: System and Grafana's auth.generic_oauth block would
# point at a client that never gets provisioned. The `default` variant MUST
# NOT depend on keycloak-operator — that Package is only emitted when
# authentication.oidc.enabled is true, so a hard dep on the default variant
# would leave every OIDC-off cluster stuck on DependenciesNotReady forever.
templates:
- templates/sources.yaml
release:
name: cozystack
namespace: cozy-system
tests:
- it: default variant carries the baseline reconciliation edges without keycloak-operator
documentSelector:
path: metadata.name
value: cozystack.monitoring-application
asserts:
- contains:
path: spec.variants[0].dependsOn
content: cozystack.networking
- contains:
path: spec.variants[0].dependsOn
content: cozystack.postgres-operator
- contains:
path: spec.variants[0].dependsOn
content: cozystack.grafana-operator
- contains:
path: spec.variants[0].dependsOn
content: cozystack.victoria-metrics-operator
- contains:
path: spec.variants[0].dependsOn
content: cozystack.cozystack-engine
- notContains:
path: spec.variants[0].dependsOn
content: cozystack.keycloak-operator
- it: oidc variant additionally waits for keycloak-operator (OIDC CRDs)
documentSelector:
path: metadata.name
value: cozystack.monitoring-application
asserts:
- equal:
path: spec.variants[1].name
value: oidc
- contains:
path: spec.variants[1].dependsOn
content: cozystack.keycloak-operator
# oidc variant inherits the baseline edges (regression guard against
# someone dropping one when refactoring the variant).
- contains:
path: spec.variants[1].dependsOn
content: cozystack.networking
- contains:
path: spec.variants[1].dependsOn
content: cozystack.grafana-operator
- contains:
path: spec.variants[1].dependsOn
content: cozystack.postgres-operator
- contains:
path: spec.variants[1].dependsOn
content: cozystack.victoria-metrics-operator
- contains:
path: spec.variants[1].dependsOn
content: cozystack.cozystack-engine
+15
View File
@@ -101,3 +101,18 @@
| `vmagent.externalLabels` | External labels applied to all metrics. | `object` | `{}` |
| `vmagent.remoteWrite` | Remote write configuration. | `object` | `{}` |
### OIDC authentication
| Name | Description | Type | Value |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------ |
| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` |
| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` |
| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` |
| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` |
| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` |
| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` |
| `oidc.users` | Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping). | `[]object` | `[]` |
| `oidc.users[i].email` | Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input. | `string` | `""` |
| `oidc.users[i].role` | Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: "null"` into the body and fail with HTTP 400. | `string` | `{}` |
@@ -10,7 +10,11 @@ spec:
name: cozystack-monitoring-application-default-monitoring-system
namespace: cozy-system
interval: 5m
timeout: 10m
# Aligned with the outer cozyrds `release.cozystack.io/helm-install-timeout: "15m"`
# (packages/system/monitoring-rd/cozyrds/monitoring.yaml) so the inner HR does
# not fail at 10m while the OIDC users-Job is still inside its
# activeDeadlineSeconds: 900 (10m Grafana wait + reconcile budget).
timeout: 15m
install:
remediation:
retries: -1
@@ -595,6 +595,77 @@
}
}
}
},
"oidc": {
"description": "OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.",
"type": "object",
"default": {},
"required": [
"mode"
],
"properties": {
"customConfig": {
"description": "Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.",
"type": "object",
"default": {},
"properties": {
"config": {
"description": "Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.",
"type": "object",
"default": {}
},
"secretRef": {
"description": "Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.",
"type": "object",
"default": {},
"properties": {
"name": {
"description": "Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.",
"type": "string",
"default": ""
}
}
}
}
},
"mode": {
"description": "Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.",
"type": "string",
"default": "None",
"enum": [
"None",
"System",
"CustomConfig"
]
},
"users": {
"description": "Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).",
"type": "array",
"default": [],
"items": {
"type": "object",
"required": [
"email",
"role"
],
"properties": {
"email": {
"description": "Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.",
"type": "string"
},
"role": {
"description": "Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: \"null\"` into the body and fail with HTTP 400.",
"type": "string",
"enum": [
"Admin",
"Editor",
"Viewer"
]
}
}
}
}
}
}
}
}
+42
View File
@@ -182,3 +182,45 @@ vmagent:
## - job_name: "custom"
## static_configs:
## - targets: ["my-service:9090"]
##
## @section OIDC authentication
##
## @enum {string} OIDCMode - Identity mode for the Grafana instance.
## @value None
## @value System
## @value CustomConfig
## @typedef {struct} OIDCSecretRef - Reference to a Secret carrying an `[auth.generic_oauth]` ini fragment.
## @field {string} [name] - Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.
## @typedef {struct} OIDCCustomConfigInline - Inline map of grafana.ini `[auth.generic_oauth]` keys.
## @field {string} [<key>] - Value for the generic_oauth key (e.g. `client_id`, `client_secret`, `auth_url`, `token_url`, `api_url`, `scopes`, `role_attribute_path`).
## @typedef {struct} OIDCCustomConfig - Tenant-supplied AuthN payload; consumed only when `mode: CustomConfig`. Exactly one of `config` or `secretRef.name` must be set.
## @field {OIDCCustomConfigInline} [config] - Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.
## @field {OIDCSecretRef} [secretRef] - Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.
## @enum {string} OIDCRole - Grafana org role bound to a listed user.
## @value Admin
## @value Editor
## @value Viewer
## @typedef {struct} OIDCUser - Grafana user reconciled into the release's org.
## @field {string} email - Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.
## @field {OIDCRole} role - Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: "null"` into the body and fail with HTTP 400.
## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm).
## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.
## @field {OIDCCustomConfig} [customConfig] - Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.
## @field {[]OIDCUser} [users] - Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).
## @param {OIDC} oidc - OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.
oidc:
mode: "None"
customConfig:
config: {}
secretRef:
name: ""
users: []
@@ -2,13 +2,25 @@ apiVersion: cozystack.io/v1alpha1
kind: ApplicationDefinition
metadata:
name: monitoring
annotations:
# Monitoring's HR bootstraps Grafana + Alerta + VMCluster + CNPG
# (postgres for Grafana state) + optionally two Keycloak CRs
# (KeycloakClient, KeycloakClientScope) under `spec.oidc.mode:
# System`, plus a post-install/post-upgrade Job that reconciles
# `spec.oidc.users` into Grafana org membership. The Keycloak-side
# reconciliation is asynchronous against an operator in
# `cozy-keycloak`, not the tenant namespace, so Flux's default 5m
# install window is tight even before adding OIDC. Override to
# 15m — matches the precedent set by tenant-rd/kubernetes-rd
# cozyrds annotations for long-bootstrap stacks.
release.cozystack.io/helm-install-timeout: "15m"
spec:
application:
kind: Monitoring
singular: monitoring
plural: monitorings
openAPISchema: |-
{"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0Line truncated
{"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0Line truncated
release:
prefix: ""
labels:
@@ -26,7 +38,7 @@ spec:
description: Monitoring and observability stack
module: true
icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzI2OCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zMjY4KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMzg0NTRGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNNTQuMTcyMiAzOC43NzU3SDMzLjEwMzJDMzIuMTMwNyAzOC43NzU3IDMxLjQ4MjQgMzguMTI3NCAzMS40ODI0IDM3LjE1NDlDMzEuNDgyNCAzNi4xODI0IDMyLjEzMDcgMzUuNTM0MiAzMy4xMDMyIDM1LjUzNDJINTQuMTcyMkM1NS4xNDQ3IDM1LjUzNDIgNTUuNzkzIDM2LjE4MjQgNTUuNzkzIDM3LjE1NDlDNTUuNzkyOCAzOC4xMjc0IDU1LjE0NDUgMzguNzc1NyA1NC4xNzIyIDM4Ljc3NTdaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik02My44OTYzIDQ1LjI1OTFINDEuMjA2N0M0MC4yMzQyIDQ1LjI1OTEgMzkuNTg1OSA0NC42MTA4IDM5LjU4NTkgNDMuNjM4M0MzOS41ODU5IDQyLjY2NTggNDAuMjM0MiA0Mi4wMTc2IDQxLjIwNjcgNDIuMDE3Nkg2My44OTYzQzY0Ljg2ODggNDIuMDE3NiA2NS41MTcxIDQyLjY2NTggNjUuNTE3MSA0My42MzgzQzY1LjUxNzEgNDQuNjEwOCA2NC44Njg4IDQ1LjI1OTEgNjMuODk2MyA0NS4yNTkxWiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMzQuNzI0IDQ1LjI1OTFIMzMuMTAzMkMzMi4xMzA3IDQ1LjI1OTEgMzEuNDgyNCA0NC42MTA4IDMxLjQ4MjQgNDMuNjM4M0MzMS40ODI0IDQyLjY2NTggMzIuMTMwNyA0Mi4wMTc2IDMzLjEwMzIgNDIuMDE3NkgzNC43MjRDMzUuNjk2NCA0Mi4wMTc2IDM2LjM0NDcgNDIuNjY1OCAzNi4zNDQ3IDQzLjYzODNDMzYuMzQ0NyA0NC42MTA4IDM1LjY5NjMgNDUuMjU5MSAzNC43MjQgNDUuMjU5MVoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgMzguNzc1N0g2MC42NTQ5QzU5LjY4MjQgMzguNzc1NyA1OS4wMzQyIDM4LjEyNzQgNTkuMDM0MiAzNy4xNTQ5QzU5LjAzNDIgMzYuMTgyNCA1OS42ODI0IDM1LjUzNDIgNjAuNjU0OSAzNS41MzQySDYzLjg5NjNDNjQuODY4OCAzNS41MzQyIDY1LjUxNzEgMzYuMTgyNCA2NS41MTcxIDM3LjE1NDlDNjUuNTE3MSAzOC4xMjc0IDY0Ljg2ODggMzguNzc1NyA2My44OTYzIDM4Ljc3NTdaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik00Ny42ODkzIDUxLjc0MTNIMzMuMTAzMkMzMi4xMzA3IDUxLjc0MTMgMzEuNDgyNCA1MS4wOTMxIDMxLjQ4MjQgNTAuMTIwNkMzMS40ODI0IDQ5LjE0ODEgMzIuMTMwNyA0OC41IDMzLjEwMzIgNDguNUg0Ny42ODkzQzQ4LjY2MTggNDguNSA0OS4zMTAxIDQ5LjE0ODMgNDkuMzEwMSA1MC4xMjA4QzQ5LjMxMDEgNTEuMDkzMyA0OC42NjE4IDUxLjc0MTMgNDcuNjg5MyA1MS43NDEzWiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNjMuODk2OCA1MS43NDEzSDU0LjE3MjVDNTMuMiA1MS43NDEzIDUyLjU1MTggNTEuMDkzMSA1Mi41NTE4IDUwLjEyMDZDNTIuNTUxOCA0OS4xNDgxIDUzLjIwMDIgNDguNSA1NC4xNzI3IDQ4LjVINjMuODk2OUM2NC44Njk0IDQ4LjUgNjUuNTE3NyA0OS4xNDgzIDY1LjUxNzcgNTAuMTIwOEM2NS41MTc3IDUxLjA5MzMgNjQuODY5MiA1MS43NDEzIDYzLjg5NjggNTEuNzQxM1oiIGZpbGw9IiNFQ0JBMTYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNTguMjI0SDMzLjEwMzJDMzIuMTMwNyA1OC4yMjQgMzEuNDgyNCA1Ny41NzU3IDMxLjQ4MjQgNTYuNjAzMkMzMS40ODI0IDU1LjYzMDcgMzIuMTMwNyA1NC45ODI0IDMzLjEwMzIgNTQuOTgyNEg1NC4xNzIyQzU1LjE0NDcgNTQuOTgyNCA1NS43OTMgNTUuNjMwNyA1NS43OTMgNTYuNjAzMkM1NS43OTMgNTcuNTc1NyA1NS4xNDQ1IDU4LjIyNCA1NC4xNzIyIDU4LjIyNFoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgNjQuNzA3NEg0MS4yMDY3QzQwLjIzNDIgNjQuNzA3NCAzOS41ODU5IDY0LjA1OTEgMzkuNTg1OSA2My4wODY2QzM5LjU4NTkgNjIuMTE0MSA0MC4yMzQyIDYxLjQ2NTggNDEuMjA2NyA2MS40NjU4SDYzLjg5NjNDNjQuODY4OCA2MS40NjU4IDY1LjUxNzEgNjIuMTE0MSA2NS41MTcxIDYzLjA4NjZDNjUuNTE3MSA2NC4wNTkxIDY0Ljg2ODggNjQuNzA3NCA2My44OTYzIDY0LjcwNzRaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik0zNC43MjQgNjQuNzA3NEgzMy4xMDMyQzMyLjEzMDcgNjQuNzA3NCAzMS40ODI0IDY0LjA1OTEgMzEuNDgyNCA2My4wODY2QzMxLjQ4MjQgNjIuMTE0MSAzMi4xMzA3IDYxLjQ2NTggMzMuMTAzMiA2MS40NjU4SDM0LjcyNEMzNS42OTY0IDYxLjQ2NTggMzYuMzQ0NyA2Mi4xMTQxIDM2LjM0NDcgNjMuMDg2NkMzNi4zNDQ3IDY0LjA1OTEgMzUuNjk2MyA2NC43MDc0IDM0LjcyNCA2NC43MDc0WiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNDcuNjg5MyA3MS4xODk4SDMzLjEwMzJDMzIuMTMwNyA3MS4xODk4IDMxLjQ4MjQgNzAuNTQxNSAzMS40ODI0IDY5LjU2OUMzMS40ODI0IDY4LjU5NjUgMzIuMTMwNyA2Ny45NDgyIDMzLjEwMzIgNjcuOTQ4Mkg0Ny42ODkzQzQ4LjY2MTggNjcuOTQ4MiA0OS4zMTAxIDY4LjU5NjUgNDkuMzEwMSA2OS41NjlDNDkuMzEwMSA3MC41NDE1IDQ4LjY2MTggNzEuMTg5OCA0Ny42ODkzIDcxLjE4OThaIiBmaWxsPSIjNDJCMDVDIi8+CjxwYXRoIGQ9Ik02My44OTY4IDcxLjE4OThINTQuMTcyNUM1My4yIDcxLjE4OTggNTIuNTUxOCA3MC41NDE1IDUyLjU1MTggNjkuNTY5QzUyLjU1MTggNjguNTk2NSA1My4yIDY3Ljk0ODIgNTQuMLine truncated
keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"]]
keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"], ["spec", "oidc", "users"]]
secrets:
exclude: []
include:
@@ -0,0 +1 @@
docker.io/alpine/k8s:1.33.4@sha256:b0523f0a244ddc4c8e055aa335c040d3d78b3ead5528f4544395f7f9f69c7b68
@@ -0,0 +1,216 @@
{{- /*
Helpers for the Grafana OIDC integration.
Naming — the identity unit is the Monitoring release, so the clientId
and the audience scope are `<namespace>-<release>` prefixed. That
gives:
tenant-foo/monitoring (per-tenant Grafana in tenant-foo ns)
cozy-monitoring/monitoring-system (platform Grafana in cozy-monitoring ns)
A token minted for one instance is rejected by the other's Grafana
because the per-cluster audience scope binds `id_token.aud` to the
clientId — same isolation primitive as the tenant kube-apiserver PR
(cozystack/cozystack#3044).
Authorization is NOT modelled on Keycloak realm groups: the chart
does not own directory objects and Grafana's built-in group→role
mapping (`role_attribute_path`) is not wired up. Instead, `spec.oidc`
carries a `users:` map and a chart-owned post-install/post-upgrade
Job reconciles that map into Grafana org membership/roles via the
admin API. `skip_org_role_sync = true` prevents a login from
overwriting those app-side assignments. Same operator UX as the
tenant kube-apiserver sibling in #3044.
*/}}
{{- define "monitoring.oidc.clientId" -}}
{{- printf "%s-%s" .Release.Namespace .Release.Name -}}
{{- end -}}
{{- define "monitoring.oidc.audienceScopeName" -}}
{{- printf "%s-%s-audience" .Release.Namespace .Release.Name -}}
{{- end -}}
{{- define "monitoring.oidc.clientSecretName" -}}
{{- printf "%s-oidc-client" .Release.Name -}}
{{- end -}}
{{- define "monitoring.oidc.grafanaHost" -}}
{{- $namespaceHost := .Values._namespace.host -}}
{{- printf "grafana.%s" (.Values.host | default $namespaceHost) -}}
{{- end -}}
{{- define "monitoring.oidc.redirectUri" -}}
{{- printf "https://%s/login/generic_oauth" (include "monitoring.oidc.grafanaHost" .) -}}
{{- end -}}
{{- define "monitoring.oidc.systemIssuerURL" -}}
{{- $host := index .Values._cluster "root-host" -}}
{{- printf "https://keycloak.%s/realms/cozy" $host -}}
{{- end -}}
{{- /*
Space-separated list of the four tenant-scoped Keycloak groups the
Monitoring release's Grafana will accept on login (allowed_groups in
auth.generic_oauth). The tenant chart (packages/apps/tenant) already
provisions `<namespace>-{view,use,admin,super-admin}` KeycloakRealmGroups
in the `cozy` realm — the namespace name is the tenant identifier for
both root and nested tenants (see tenant.name in the tenant chart
helpers), so `.Release.Namespace` is the correct prefix here.
Why all four groups instead of just those referenced in
`spec.oidc.users[].role`: the users-map controls Grafana org role
assignment, not authentication. A tenant admin whose email is not in
users[] should still be able to log in and land at the release's
configured hands-off default (auto_assign_org_role=Viewer) — being a
member of the tenant is the credential that says "you belong to this
Grafana", regardless of whether the chart also happens to have
pre-provisioned them an org role.
*/}}
{{- define "monitoring.oidc.allowedGroups" -}}
{{- $ns := .Release.Namespace -}}
{{- printf "%s-view %s-use %s-admin %s-super-admin" $ns $ns $ns $ns -}}
{{- end -}}
{{- /*
Fail-fast when `mode: System` is requested without the platform-level
OIDC feature being on. Mirrors the identical guard from the tenant
kube-apiserver chart — see the note there for the reasoning.
*/}}
{{- define "monitoring.oidc.assertSystemEnabled" -}}
{{- $oidcEnabled := dig "oidc-enabled" "" (.Values._cluster | default dict) -}}
{{- if ne ($oidcEnabled | toString) "true" -}}
{{- fail "spec.oidc.mode: System requires the platform-level OIDC feature (authentication.oidc.enabled) — enable it in cozystack-values, or use mode: CustomConfig for a tenant-supplied issuer." -}}
{{- end -}}
{{- end -}}
{{- /*
Fail-fast when `mode: System` is requested but the Keycloak operator
CRDs (`v1.edp.epam.com/v1`) are not yet registered in the target
cluster. Without this guard the chart would silently drop the whole
Keycloak side (KeycloakClient / KeycloakClientScope) while still
rendering Grafana's `auth.generic_oauth` block pointing at a client
that never gets provisioned — a broken login path with no clear
error. For the platform `monitoring-system` release the `oidc`
variant of `cozystack.monitoring-application` waits on
`cozystack.keycloak-operator` (see
packages/core/platform/sources/monitoring-application.yaml) so the
CRDs are registered before this chart reconciles; the assertion
turns any residual race (or a manual `mode: System` toggle on a
cluster where the operator is not deployed) into an actionable
render error instead of a silent misconfiguration.
*/}}
{{- define "monitoring.oidc.assertSystemKeycloakCRD" -}}
{{- if not (.Capabilities.APIVersions.Has "v1.edp.epam.com/v1") -}}
{{- fail "spec.oidc.mode: System requires the Keycloak operator CRDs (v1.edp.epam.com/v1). If cozystack.keycloak-operator is still bootstrapping this will resolve on the next reconcile; otherwise verify the keycloak-operator package is deployed and its CRDs are registered." -}}
{{- end -}}
{{- end -}}
{{- /*
Fail-fast when the CustomConfig branch has neither or both payloads
set — mutually exclusive.
*/}}
{{- define "monitoring.oidc.assertCustomConfigXor" -}}
{{- $oidc := .Values.oidc | default dict -}}
{{- $customConfig := $oidc.customConfig | default dict -}}
{{- $inline := $customConfig.config | default dict -}}
{{- $secretName := dig "secretRef" "name" "" $customConfig -}}
{{- $hasInline := gt (len $inline) 0 -}}
{{- $hasSecretRef := ne ($secretName | toString) "" -}}
{{- if and $hasInline $hasSecretRef -}}
{{- fail "spec.oidc.customConfig: set exactly one of `config` (inline) or `secretRef.name` — they are mutually exclusive." -}}
{{- end -}}
{{- if not (or $hasInline $hasSecretRef) -}}
{{- fail "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." -}}
{{- end -}}
{{- end -}}
{{- /*
Normalised `spec.oidc.users` list. Yields an empty list when
`spec.oidc` is omitted, `null`, or has no `users:` key. Consumers
should range over this without further nil-checks.
*/}}
{{- define "monitoring.oidc.users" -}}
{{- $oidc := .Values.oidc | default dict -}}
{{- $users := $oidc.users | default (list) -}}
{{- toYaml $users -}}
{{- end -}}
{{- /*
Prologue that all templates touching spec.oidc.users should include
ONCE, near the top. Runs the incompatibility check (secretRef + users)
before the per-entry regexes so operators see the most actionable
error first — combining incompatible top-level fields is a
configuration bug the operator can fix immediately; a per-user
malformed email is a data-entry issue in the CR body.
*/}}
{{- define "monitoring.oidc.assertUsersPrologue" -}}
{{- include "monitoring.oidc.assertCustomSecretRefUsersEmpty" . }}
{{- include "monitoring.oidc.assertUsersRoleShape" . }}
{{- include "monitoring.oidc.assertUsersEmailShape" . }}
{{- end -}}
{{- /*
Reject `spec.oidc.users[]` entries missing `role` or carrying an
unknown role. openAPISchema also marks `role` required, but a stale
schema (dashboard-emitted CR generated before the field was added, or
a direct-helm invocation that bypasses the API validation) would
otherwise render `role: "null"` into the users-Job's Grafana admin API
body and fail with HTTP 400 at hook execution. Failing at render
time surfaces the root cause instead of a mysterious backoffLimit
exhaustion.
*/}}
{{- define "monitoring.oidc.assertUsersRoleShape" -}}
{{- $oidc := .Values.oidc | default dict -}}
{{- $users := $oidc.users | default (list) -}}
{{- $allowed := list "Admin" "Editor" "Viewer" -}}
{{- range $i, $u := $users -}}
{{- $role := $u.role | default "" -}}
{{- if eq $role "" -}}
{{- fail (printf "spec.oidc.users[%d].role: is required and must be one of Admin, Editor, Viewer. Omitting the field would render null into the Grafana admin API body and fail the post-install users-Job with HTTP 400." $i) -}}
{{- end -}}
{{- if not (has $role $allowed) -}}
{{- fail (printf "spec.oidc.users[%d].role: %q is not one of the allowed values Admin, Editor, Viewer." $i $role) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /*
Reject malformed emails in `spec.oidc.users`. Grafana passes the
string verbatim into a query string (/api/users/lookup?loginOrEmail=)
and into JSON bodies (create_body / add_body); the users-Job's shell
reader is NUL-safe and URL-encodes, but rejecting at render time is
cheaper than debugging a 400 from Grafana. Pattern is intentionally
conservative — no whitespace, no quoted literals, no bracketed IP
domains, no unicode; RFC 5322 in its full generality is out of scope.
*/}}
{{- define "monitoring.oidc.assertUsersEmailShape" -}}
{{- $oidc := .Values.oidc | default dict -}}
{{- $users := $oidc.users | default (list) -}}
{{- range $i, $u := $users -}}
{{- $email := $u.email | default "" -}}
{{- if not (regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" $email) -}}
{{- fail (printf "spec.oidc.users[%d].email: %q does not match the conservative email pattern ^[A-Za-z0-9._%%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$. Whitespace, quoted literals, and non-ASCII characters are rejected on template level so the reconcile-Job's shell handling never has to defend against malformed input." $i $email) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /*
Reject `spec.oidc.users` in CustomConfig+secretRef mode: the
operator's `auth.ini` fragment is authoritative and the chart cannot
inject `skip_org_role_sync=true` / `oauth_allow_insecure_email_lookup=true`
into it. Without those two settings the users-Job's role assignments
are overwritten on the operator's next login (skip_org_role_sync) and
the pre-provisioned local account is orphaned (email lookup) — the
Job's contract silently breaks. Force the operator into an explicit
choice: either use `customConfig.config` (inline) and let the chart
merge the two settings, or omit `spec.oidc.users` and manage
authorization themselves inside the mounted ini.
*/}}
{{- define "monitoring.oidc.assertCustomSecretRefUsersEmpty" -}}
{{- $oidc := .Values.oidc | default dict -}}
{{- $customConfig := $oidc.customConfig | default dict -}}
{{- $secretName := dig "secretRef" "name" "" $customConfig -}}
{{- $users := $oidc.users | default (list) -}}
{{- if and (ne ($secretName | toString) "") (gt (len $users) 0) -}}
{{- fail "spec.oidc: `users` is not honoured under `customConfig.secretRef` — the operator's mounted auth.ini is authoritative and the chart cannot inject `skip_org_role_sync=true` / `oauth_allow_insecure_email_lookup=true`, so the users-Job's role assignments would be overwritten on the operator's next login. Either switch to `customConfig.config` (inline map, merged with the chart-forced settings) or unset `users` and manage authorization inside the ini fragment yourself." -}}
{{- end -}}
{{- end -}}
@@ -4,6 +4,84 @@
{{- $host := .Values._namespace.host }}
{{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }}
{{- $wildcardSecret := (index .Values._cluster "wildcard-secret-name") | default "" }}
{{- /*
OIDC (see values.yaml — oidc.mode).
System → chart-owned Keycloak client + audience client scope +
persisted client-secret. Grafana authorization is
app-side: `spec.oidc.users` is reconciled into
Grafana org membership/roles by the users-Job.
Grafana's group-based `role_attribute_path` is NOT
wired up; `skip_org_role_sync = true` prevents a
subsequent login from overwriting the app-side
assignments.
CustomConfig → operator-supplied auth.generic_oauth payload — either
inline as a map (customConfig.config), or as a Secret
with a ready-made ini fragment
(customConfig.secretRef.name). XOR-guarded via the
assertCustomConfigXor helper. `spec.oidc.users` is
also honoured in this mode by the users-Job (users
are matched to Grafana identities by the `email`
claim from the tenant-supplied issuer).
None → no auth.generic_oauth section, no OIDC env — the
admin_user/admin_password Secret remains the only auth
path.
*/}}
{{- $oidc := .Values.oidc | default dict }}
{{- $oidcMode := $oidc.mode | default "None" }}
{{- /*
Users-map prologue is included from oidc-users-job.yaml (rendered in
every mode), so the secretRef+users incompat is caught even when
oidc-users-job.yaml is disabled downstream. Only the CustomConfig-XOR
check is grafana.yaml-scoped.
*/}}
{{- if eq $oidcMode "CustomConfig" }}
{{- include "monitoring.oidc.assertCustomConfigXor" . }}
{{- end }}
{{- /*
Symmetric assertions with templates/grafana/oidc-keycloak.yaml so a
missing platform OIDC feature or missing Keycloak operator CRD
hard-fails the render instead of bringing Grafana up with an
`auth.generic_oauth` block pointing at a client that never gets
provisioned.
*/}}
{{- if eq $oidcMode "System" }}
{{- include "monitoring.oidc.assertSystemEnabled" . }}
{{- include "monitoring.oidc.assertSystemKeycloakCRD" . }}
{{- end }}
{{- $customConfig := $oidc.customConfig | default dict }}
{{- $customInline := $customConfig.config | default dict }}
{{- $customSecretRefName := dig "secretRef" "name" "" $customConfig }}
{{- $useCustomInline := and (eq $oidcMode "CustomConfig") (gt (len $customInline) 0) }}
{{- $useCustomSecretRef := and (eq $oidcMode "CustomConfig") (ne $customSecretRefName "") }}
{{- $users := $oidc.users | default (list) }}
{{- /*
The users-map is the switch that turns the chart's authorization
contract on. When it's non-empty:
* skip_org_role_sync=true keeps a login from overwriting the
users-Job's org-role assignments;
* oauth_allow_insecure_email_lookup=true binds the OIDC identity
to the pre-provisioned local account by email;
* allow_sign_up=false rejects any OIDC identity whose email is
NOT in `users:` — otherwise Grafana's default allow_sign_up=true
combined with skip_org_role_sync=true would silently mint a
Viewer account for every cozy-realm user until the next
helm-upgrade prune pass.
When users[] is empty the chart hands the auth.generic_oauth block
to the operator (System mode → chart-supplied issuer defaults;
CustomConfig-inline → operator's map applied verbatim; secretRef →
operator's ini fragment is authoritative). The users-Job also does
not render in that state so nothing app-side touches org membership.
*/}}
{{- $usersMapActive := gt (len $users) 0 }}
{{- $chartForcedOidc := dict }}
{{- if $usersMapActive }}
{{- $chartForcedOidc = dict
"skip_org_role_sync" "true"
"oauth_allow_insecure_email_lookup" "true"
"allow_sign_up" "false"
}}
{{- end }}
---
apiVersion: grafana.integreatly.org/v1beta1
kind: Grafana
@@ -31,6 +109,70 @@ spec:
admin_password: ${GF_PASSWORD}
plugins:
allow_loading_unsigned_plugins: "victoriametrics-logs-datasource"
{{- /*
$chartForcedOidc is prepared above at file scope — it is a non-empty
dict when the users-map is active (skip_org_role_sync +
oauth_allow_insecure_email_lookup + allow_sign_up=false) and an
empty dict otherwise. Merged on top of System's generated block AND
on top of the operator's inline map in CustomConfig, so the users-
Job contract holds regardless of mode. In CustomConfig secretRef
mode the operator's ini fragment is authoritative and the chart
cannot inject anything — see _helpers.tpl
assertCustomSecretRefUsersEmpty.
*/}}
{{- if eq $oidcMode "System" }}
{{- /*
Tenant-membership gate — chart-owned and unconditional in System
mode. `groups` is requested so Keycloak emits the caller's realm
groups in the token (via the platform-managed `groups`
KeycloakClientScope + oidc-group-membership-mapper — see
packages/system/keycloak-configure). `allowed_groups` then rejects
any login whose token does not carry at least one of this
tenant's `<namespace>-{view,use,admin,super-admin}` groups. This
closes the cross-tenant hole where a user in the shared `cozy`
realm could otherwise sign into any tenant's Grafana just by
being authenticated: the audience mapper stops REPLAY of a
minted token, the allowed_groups check stops ISSUANCE of a
usable one to callers outside the tenant.
*/}}
{{- /*
`groups_attribute_path` is REQUIRED (not optional) alongside
`allowed_groups` on Grafana v11.x+. Without it Grafana leaves
the extracted user's Groups slice empty regardless of what the
userinfo endpoint returns, and the `allowed_groups` gate rejects
every login with "user not a member of one of the required
groups" — even when the token carries the correct claim. Verified
end-to-end on dev3 with Grafana v11.6.15: id_token AND userinfo
both contained `groups: [<ns>-super-admin, ...]`, `allowed_groups`
listed `<ns>-super-admin`, but login failed until
`groups_attribute_path=groups` was added. JMESPath expression
"groups" reads the top-level `groups` array from userinfo — the
shape Keycloak's oidc-group-membership-mapper emits.
*/}}
{{- $systemOidc := dict
"enabled" "true"
"name" "Keycloak"
"client_id" (include "monitoring.oidc.clientId" .)
"client_secret" "${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}"
"auth_url" (printf "%s/protocol/openid-connect/auth" (include "monitoring.oidc.systemIssuerURL" .))
"token_url" (printf "%s/protocol/openid-connect/token" (include "monitoring.oidc.systemIssuerURL" .))
"api_url" (printf "%s/protocol/openid-connect/userinfo" (include "monitoring.oidc.systemIssuerURL" .))
"scopes" (printf "openid email profile groups %s" (include "monitoring.oidc.audienceScopeName" .))
"groups_attribute_path" "groups"
"allowed_groups" (include "monitoring.oidc.allowedGroups" .)
"email_attribute_path" "email"
"auto_login" "false"
}}
auth.generic_oauth:
{{- toYaml (merge $chartForcedOidc $systemOidc) | nindent 6 }}
{{- else if $useCustomInline }}
auth.generic_oauth:
{{- /* $chartForcedOidc wins over $customInline via `merge` semantics:
merge lhs rhs = lhs values kept, rhs fills only missing keys.
So the operator can NOT override skip_org_role_sync=true or
oauth_allow_insecure_email_lookup=true — those are contract. */}}
{{- toYaml (merge $chartForcedOidc $customInline) | nindent 6 }}
{{- end }}
deployment:
spec:
replicas: 2
@@ -72,6 +214,29 @@ spec:
secretKeyRef:
key: password
name: grafana-admin-password
{{- if eq $oidcMode "System" }}
- name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "monitoring.oidc.clientSecretName" . }}
key: client-secret
{{- end }}
{{- if $useCustomSecretRef }}
- name: GF_PATHS_CUSTOM_INI
value: /etc/grafana/oidc/auth.ini
{{- end }}
{{- if $useCustomSecretRef }}
volumeMounts:
- name: oidc-custom-ini
mountPath: /etc/grafana/oidc
readOnly: true
{{- end }}
{{- if $useCustomSecretRef }}
volumes:
- name: oidc-custom-ini
secret:
secretName: {{ $customSecretRefName }}
{{- end }}
{{- if ne $gatewayEnabled "true" }}
ingress:
{{- /* Operator wildcard mode: nginx serves the default SSL cert, so skip per-host ACME. */}}
@@ -0,0 +1,44 @@
{{- /*
Persistent Secret carrying the confidential OIDC client secret.
Consumed by two parties:
1. The EDP-Keycloak operator, referenced from oidc-keycloak.yaml
via the `$<secret>:client-secret` syntax on `spec.secret`.
2. The Grafana pod, referenced from grafana.yaml as
`GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env — Grafana's `.ini`
supports `${VAR}` interpolation.
Random on first install, preserved on upgrades via `lookup` (same
pattern as packages/system/dashboard/templates/keycloakclient.yaml).
Rotating the value requires deleting the Secret and re-running the
release.
Rendered only for System mode. In CustomConfig mode the operator
supplies their own credentials — either inline in
spec.oidc.customConfig.config (plaintext) or through
spec.oidc.customConfig.secretRef pointing at their own Secret.
*/}}
{{- $oidc := .Values.oidc | default dict }}
{{- if eq ($oidc.mode | default "None") "System" }}
{{- $secretName := include "monitoring.oidc.clientSecretName" . }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
{{- $clientSecret := "" }}
{{- if $existing }}
{{- $existingValue := dig "data" "client-secret" "" $existing }}
{{- if $existingValue }}
{{- $clientSecret = $existingValue | b64dec }}
{{- end }}
{{- end }}
{{- if eq $clientSecret "" }}
{{- $clientSecret = randAlphaNum 32 }}
{{- end }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
namespace: {{ .Release.Namespace }}
type: Opaque
data:
client-secret: {{ $clientSecret | b64enc }}
{{- end }}
@@ -0,0 +1,110 @@
{{- /*
Phase 1 — System mode: render one per-instance Keycloak client and a
per-instance audience client scope inside the platform `cozy` realm.
The clientId is namespace-prefixed so it is globally unique across
the realm; the audience scope binds `id_token.aud` to that clientId
so a token minted for one Monitoring instance is rejected by
another's Grafana — the per-instance isolation primitive in the
absence of per-tenant realms.
Two distinct group-based gates are wired:
* `defaultClientScopes: [groups, <audience>]` on the client causes
Keycloak to emit the caller's realm-group membership in the
`groups` claim. Grafana's `allowed_groups` in grafana.yaml then
rejects any login whose token does not carry at least one of the
release's tenant-scoped groups
(`<namespace>-{view,use,admin,super-admin}`, provisioned by the
tenant chart in packages/apps/tenant). This is the isolation gate
that stops tenant-alice from signing into tenant-bob's Grafana.
* `spec.oidc.users`, reconciled into Grafana org membership/roles
by the users-Job (templates/grafana/oidc-users-job.yaml), is the
per-user authorization layer that runs INSIDE Grafana after the
Keycloak-side gate has already admitted the login.
No KeycloakRealmGroup is provisioned by this chart — group directory
ownership stays with the tenant chart. See docs/oidc-grafana.md.
Nothing is rendered for `mode: CustomConfig` or `mode: None`: in
those modes `cozy` is not in the path.
The `_cluster.oidc-enabled` guard catches the case where an
operator toggles `mode: System` without the platform-level OIDC
feature (authentication.oidc.enabled) being on. The
`v1.edp.epam.com/v1` capability guard turns a missing Keycloak
operator CRD into an explicit render error rather than a silent
half-rendered state — see monitoring.oidc.assertSystemKeycloakCRD
in _helpers.tpl and grafana.yaml where the same assertion gates
`auth.generic_oauth` so Grafana never comes up pointing at a
client that never gets provisioned.
*/}}
{{- $oidc := .Values.oidc | default dict }}
{{- if eq ($oidc.mode | default "None") "System" }}
{{- include "monitoring.oidc.assertSystemEnabled" . }}
{{- include "monitoring.oidc.assertSystemKeycloakCRD" . }}
{{- $clientId := include "monitoring.oidc.clientId" . }}
{{- $scopeName := include "monitoring.oidc.audienceScopeName" . }}
{{- $clientSecretName := include "monitoring.oidc.clientSecretName" . }}
{{- $grafanaHost := include "monitoring.oidc.grafanaHost" . }}
---
apiVersion: v1.edp.epam.com/v1
kind: KeycloakClientScope
metadata:
name: {{ $scopeName }}
namespace: {{ .Release.Namespace }}
spec:
name: {{ $scopeName }}
realmRef:
name: keycloakrealm-cozy
kind: ClusterKeycloakRealm
description: "Per-instance audience for Grafana {{ .Release.Name }}"
protocol: openid-connect
default: false
attributes:
"include.in.token.scope": "true"
protocolMappers:
- name: audience
protocol: openid-connect
protocolMapper: "oidc-audience-mapper"
config:
"included.client.audience": {{ $clientId | quote }}
"id.token.claim": "true"
"access.token.claim": "true"
"introspection.token.claim": "true"
"lightweight.claim": "false"
---
apiVersion: v1.edp.epam.com/v1
kind: KeycloakClient
metadata:
name: {{ $clientId }}
namespace: {{ .Release.Namespace }}
spec:
clientId: {{ $clientId }}
name: {{ $clientId }}
realmRef:
name: keycloakrealm-cozy
kind: ClusterKeycloakRealm
public: false
directAccess: false
advancedProtocolMappers: true
secret: "${{ $clientSecretName }}:client-secret"
webUrl: "https://{{ $grafanaHost }}"
redirectUris:
- "https://{{ $grafanaHost }}/login/generic_oauth"
defaultClientScopes:
# Platform-provisioned realm-default scope carrying an
# oidc-group-membership-mapper that emits the caller's Keycloak
# groups into the `groups` claim of every token. Grafana's
# `allowed_groups` reads that claim to gate login on tenant-scoped
# group membership (see grafana.yaml). EDP's KeycloakClient does NOT
# merge realm-level defaults into `defaultClientScopes` — we must
# list it here explicitly, or the claim would be missing and
# `allowed_groups` would reject every login.
- groups
# Per-instance audience: binds `id_token.aud` to this client's
# `clientId` so a token minted for another Monitoring release
# (tenant-alice's Grafana) is rejected by this Grafana even if the
# caller also happens to be a member of this tenant's groups.
- {{ $scopeName }}
attributes:
"post.logout.redirect.uris": "+"
{{- end }}
@@ -0,0 +1,316 @@
{{- /*
OIDC user reconcile Job — runs as Helm post-install,post-upgrade
hook and syncs `spec.oidc.users` into the release's Grafana org via
Grafana's admin API. Same operator UX as the tenant kube-apiserver
sibling in cozystack/cozystack#3044 (users-map → ClusterRoleBindings
in the guest cluster); here the target is Grafana org membership
because grafana-operator has no user CRD.
Contract:
- For each `spec.oidc.users[]` entry:
* pre-provision a local Grafana account with the given email
(random password — the account is a shell so the users-Job
can set an org role before the operator's first OIDC login,
and `oauth_allow_insecure_email_lookup = true` glues them
together at login time);
* add the account to Main Org. with the requested role
(`Admin` / `Editor` / `Viewer`), promoting/demoting on
re-run.
- Prune every non-admin Main-Org member whose email is not in the
desired list — handles removal of a `users:` entry.
- `skip_org_role_sync = true` in the Grafana generic_oauth block
(see grafana.yaml) makes sure a later login never overwrites the
role this Job sets.
Renders only when `spec.oidc.users` is non-empty. `users: []` (or
unset) is the switch that says "the chart owns nothing in Grafana
orgs" — a prune pass with an empty desired list would silently
delete every non-admin Main-Org member on every helm upgrade. That
covers three empty-users states that all resolve to the same
hands-off contract:
* mode=None → OIDC off, no chart-managed
org membership at all.
* customConfig.secretRef mode → users[] is forbidden by
the assertion helper; the
operator's ini fragment is
authoritative.
* System / CustomConfig-inline with
users[] unset → the operator opted into
OIDC but not into the
chart-managed users-map;
they own org membership
themselves.
Removing the last `users:` entry therefore no longer prunes it —
the operator cleans up any leftover OIDC-provisioned accounts by
hand, matching the already-documented `System → None` posture.
Lifecycle:
- The Job itself is a Helm hook (post-install,post-upgrade) with
`helm.sh/hook-delete-policy: before-hook-creation` so `helm
upgrade` re-runs it on every reconcile.
- No ServiceAccount/Role/RoleBinding: the Job only talks to
Grafana over the in-cluster Service and reads the admin
credentials from the chart-managed `grafana-admin-password`
Secret via `envFrom`.
- `activeDeadlineSeconds` caps a stuck Job so a broken Grafana
does not hold the release in `pending-upgrade`.
*/}}
{{- $oidc := .Values.oidc | default dict }}
{{- $mode := $oidc.mode | default "None" }}
{{- $users := $oidc.users | default (list) }}
{{- /*
Prologue: incompat (secretRef+users) first, then per-entry role +
email validation. See monitoring.oidc.assertUsersPrologue. Runs in
every mode so a malformed users[] entry fails render even when the
Job itself is not emitted (mode=None).
*/}}
{{- include "monitoring.oidc.assertUsersPrologue" . }}
{{- /*
Render gate — two independent guards ANDed together:
* mode != None keeps pre-OIDC installs safe on `monitoring`
HR reconciles (empty users but no OIDC at all).
* users non-empty covers `customConfig.secretRef` (users forbidden
by assertion) and System / CustomConfig-inline
with `users:` unset — both cases where the
operator opted into OIDC but not into the
chart-managed users-map.
*/}}
{{- if and (ne $mode "None") (gt (len $users) 0) }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-oidc-users
namespace: {{ .Release.Namespace }}
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "10"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: 6
activeDeadlineSeconds: 900
# Non-hook cleanup: `hook-delete-policy: before-hook-creation` only fires
# on the NEXT hook activation, never on release uninstall — a succeeded
# Job + Pod would otherwise linger in the namespace forever. Cozystack
# convention: 3600s.
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app.kubernetes.io/name: monitoring-oidc-users
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: reconcile
# Route through cozy-lib.image so mirrored / air-gapped
# installations (`_cluster.images-registry`) resolve the image;
# matches the pattern in
# packages/apps/mariadb/templates/hooks/cleanup-pvc.yaml.
image: {{ include "cozy-lib.image" (list ($.Files.Get "images/kubectl.tag" | trim) $) }}
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: HOME
value: /tmp
- name: GRAFANA_URL
value: "http://grafana-service.{{ .Release.Namespace }}.svc:3000"
- name: GF_ADMIN_USER
valueFrom:
secretKeyRef:
name: grafana-admin-password
key: user
- name: GF_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: grafana-admin-password
key: password
- name: OIDC_MODE
value: {{ $mode | quote }}
- name: DESIRED_USERS_JSON
value: {{ $users | toJson | quote }}
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
command:
- /bin/sh
- -c
- |
set -eu
log() { printf '%s\n' "$*" >&2; }
api() {
# api METHOD PATH [json-body]
method=$1
path=$2
body=${3:-}
if [ -n "$body" ]; then
curl --silent --show-error --output /tmp/resp --write-out '%{http_code}' \
--user "$GF_ADMIN_USER:$GF_ADMIN_PASSWORD" \
--header 'Content-Type: application/json' \
--request "$method" \
--data "$body" \
"$GRAFANA_URL$path"
else
curl --silent --show-error --output /tmp/resp --write-out '%{http_code}' \
--user "$GF_ADMIN_USER:$GF_ADMIN_PASSWORD" \
--request "$method" \
"$GRAFANA_URL$path"
fi
}
log "Waiting for Grafana at $GRAFANA_URL/api/health..."
deadline=$(( $(date +%s) + 600 ))
while :; do
if curl --silent --fail --output /dev/null --max-time 5 \
"$GRAFANA_URL/api/health"; then
break
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
log "Grafana did not become ready within 10m; aborting"
exit 1
fi
sleep 5
done
# Main Org. is orgId=1 in Grafana OSS. We only ever
# touch that one org — the chart does not create
# additional orgs.
ORG_ID=1
# Emit desired emails one per line. Portable — BusyBox
# coreutils in the alpine base do not support jq
# --raw-output0 (jq 1.7+) nor `read -d ''`. The template-
# level assertUsersEmailShape helper already rejects any
# email containing whitespace / control characters, so
# line-oriented iteration is safe here.
printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output '.[].email' > /tmp/desired-emails 2>/dev/null || : > /tmp/desired-emails
# URL-encode a string via jq (@uri) — used for query-string
# values like /api/users/lookup?loginOrEmail=<encoded>.
urlencode() { printf '%s' "$1" | jq --slurp --raw-input --raw-output '@uri'; }
if [ "$OIDC_MODE" != "None" ]; then
log "Reconciling ${OIDC_MODE} users into Grafana org $ORG_ID..."
while IFS= read -r email; do
[ -n "$email" ] || continue
role=$(printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output --arg e "$email" '.[] | select(.email == $e) | .role')
log " ensure user: email=$email role=$role"
# 1. Pre-provision the local account so the operator
# can be assigned a role before their first login.
# 409 is expected on re-run — already provisioned.
# BusyBox `head` / `tr` support only short flags.
password=$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | head -c 32)
create_body=$(jq --null-input --compact-output \
--arg name "$email" \
--arg email "$email" \
--arg login "$email" \
--arg password "$password" \
'{name:$name,email:$email,login:$login,password:$password}')
code=$(api POST /api/admin/users "$create_body")
case "$code" in
200|201) log " created Grafana account" ;;
412|409|400) log " account already exists (code $code) — proceeding" ;;
*) log " POST /api/admin/users failed with code $code:"; cat /tmp/resp >&2; exit 1 ;;
esac
# 2. Add to Main Org. — 409 means already a member,
# we'll PATCH the role below either way.
add_body=$(jq --null-input --compact-output \
--arg loginOrEmail "$email" \
--arg role "$role" \
'{loginOrEmail:$loginOrEmail,role:$role}')
code=$(api POST "/api/orgs/$ORG_ID/users" "$add_body")
case "$code" in
200|201) log " added to org $ORG_ID with role $role" ;;
409) log " already a member of org $ORG_ID — will PATCH role" ;;
*) log " POST /api/orgs/$ORG_ID/users failed with code $code:"; cat /tmp/resp >&2; exit 1 ;;
esac
# 3. Look up userId, then PATCH the role to converge
# demotions/promotions across re-runs. Query-string
# value URL-encoded so & = ? in the local part parse
# correctly on the Grafana side.
email_enc=$(urlencode "$email")
code=$(api GET "/api/users/lookup?loginOrEmail=$email_enc")
if [ "$code" != "200" ]; then
log " GET /api/users/lookup failed with code $code:"; cat /tmp/resp >&2; exit 1
fi
user_id=$(jq --raw-output '.id' < /tmp/resp)
patch_body=$(jq --null-input --compact-output --arg role "$role" '{role:$role}')
code=$(api PATCH "/api/orgs/$ORG_ID/users/$user_id" "$patch_body")
case "$code" in
200) log " role set to $role" ;;
*) log " PATCH role failed with code $code:"; cat /tmp/resp >&2; exit 1 ;;
esac
done < /tmp/desired-emails
else
log "mode=None: skipping user reconcile, running prune only."
fi
# Prune — remove every non-admin Main-Org member whose
# email is not in the desired list.
log "Pruning stale org members..."
code=$(api GET "/api/orgs/$ORG_ID/users")
if [ "$code" != "200" ]; then
log " GET /api/orgs/$ORG_ID/users failed with code $code:"; cat /tmp/resp >&2; exit 1
fi
cp /tmp/resp /tmp/members.json
# The chart-managed admin login name — never pruned.
admin_login="$GF_ADMIN_USER"
# Membership stream: one TAB-separated record per line —
# portable across BusyBox coreutils (no jq --raw-output0,
# no `read -d ''`, no `grep -z`). Safe because the
# template-level email-shape guard already rejects
# whitespace / TAB / newline in `spec.oidc.users[].email`.
jq --raw-output '.[] | [.userId, .login, .email] | @tsv' < /tmp/members.json > /tmp/members.tsv
# IFS holds a literal TAB char (POSIX portable — some
# ash variants don't handle $'\t' escape).
IFS="$(printf '\t')"
while read -r uid ulogin uemail; do
if [ -z "$uid" ] || [ "$uid" = "null" ]; then continue; fi
if [ "$ulogin" = "$admin_login" ]; then
log " keep admin: login=$ulogin uid=$uid"
continue
fi
# Match uemail / ulogin against desired list. -F fixed
# strings, -x line-anchored, -q quiet. Portable across
# BusyBox and GNU grep.
if grep -Fxq -e "$uemail" -e "$ulogin" /tmp/desired-emails; then
continue
fi
log " removing stale member: login=$ulogin email=$uemail uid=$uid"
code=$(api DELETE "/api/orgs/$ORG_ID/users/$uid")
case "$code" in
200) : ;;
*) log " DELETE failed with code $code:"; cat /tmp/resp >&2; exit 1 ;;
esac
done < /tmp/members.tsv
log "Done."
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
{{- end }}
@@ -0,0 +1,38 @@
suite: OIDC selector — Keycloak operator CRD missing (Phase 1)
# Split from oidc_test.yaml because the suite-level `capabilities`
# override in that file installs `v1.edp.epam.com/v1`, and per-test
# `capabilities` in helm-unittest v1.0.3 merges with the suite-level
# list instead of replacing it — so the CRD-missing fail-close guard
# needs its own suite where the capability list is empty at the top.
templates:
- templates/grafana/oidc-keycloak.yaml
- templates/grafana/grafana.yaml
release:
name: monitoring
namespace: tenant-root
set:
_cluster:
root-host: example.org
oidc-enabled: "true"
_namespace:
host: tenant-root.example.org
ingress: tenant-root
capabilities:
apiVersions: []
tests:
- it: mode=System hard-fails the render when the Keycloak operator CRD is missing
# oidc-keycloak.yaml fires the assertion first because helm renders all
# templates in one pass; grafana.yaml carries the same assertion as
# defense-in-depth so a future refactor that removes oidc-keycloak.yaml
# cannot bring Grafana up pointing at a client that never gets
# provisioned. See monitoring.oidc.assertSystemKeycloakCRD in _helpers.tpl.
template: templates/grafana/oidc-keycloak.yaml
set:
oidc.mode: System
asserts:
- failedTemplate:
errorMessage: "spec.oidc.mode: System requires the Keycloak operator CRDs (v1.edp.epam.com/v1). If cozystack.keycloak-operator is still bootstrapping this will resolve on the next reconcile; otherwise verify the keycloak-operator package is deployed and its CRDs are registered."
@@ -0,0 +1,713 @@
suite: OIDC selector on the Grafana instance (Phase 1)
templates:
- templates/grafana/oidc-keycloak.yaml
- templates/grafana/oidc-client-secret.yaml
- templates/grafana/oidc-users-job.yaml
- templates/grafana/grafana.yaml
release:
name: monitoring
namespace: tenant-root
# Baseline `_cluster` / `_namespace` values wired by cozystack-basics
# at install time. Individual tests override `oidc.*` and re-set the
# oidc-enabled gate as needed.
set:
_cluster:
root-host: example.org
_namespace:
host: tenant-root.example.org
ingress: tenant-root
capabilities:
apiVersions:
- v1.edp.epam.com/v1
tests:
##########################################################
# Defensive rendering — omitted / null oidc block
##########################################################
- it: oidc omitted entirely renders as if mode=None
template: templates/grafana/grafana.yaml
asserts:
- notExists:
path: spec.config["auth.generic_oauth"]
- it: oidc=null does not blow up nil-deref on grafana.yaml
template: templates/grafana/grafana.yaml
set:
oidc: ~
asserts:
- notExists:
path: spec.config["auth.generic_oauth"]
- it: oidc=null does not blow up nil-deref on oidc-keycloak.yaml
template: templates/grafana/oidc-keycloak.yaml
set:
oidc: ~
asserts:
- hasDocuments:
count: 0
- it: oidc=null does not blow up nil-deref on oidc-client-secret.yaml
template: templates/grafana/oidc-client-secret.yaml
set:
oidc: ~
asserts:
- hasDocuments:
count: 0
##########################################################
# mode: None
##########################################################
- it: mode=None emits no KeycloakClient / KeycloakClientScope
template: templates/grafana/oidc-keycloak.yaml
asserts:
- hasDocuments:
count: 0
- it: mode=None emits no client-secret Secret
template: templates/grafana/oidc-client-secret.yaml
asserts:
- hasDocuments:
count: 0
- it: mode=None grafana.yaml carries no auth.generic_oauth and no OIDC env
template: templates/grafana/grafana.yaml
asserts:
- notExists:
path: spec.config["auth.generic_oauth"]
- notContains:
path: spec.deployment.spec.template.spec.containers[0].env
content:
name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
- notContains:
path: spec.deployment.spec.template.spec.containers[0].env
content:
name: GF_PATHS_CUSTOM_INI
- it: mode=None does NOT render the users-Job (upgrade safety guard)
# Regression guard against the dev3 test finding: if the Job were
# rendered in mode=None, the post-upgrade prune pass would silently
# delete every non-admin Main-Org member on any monitoring HR
# reconcile — clobbering users added manually through the Grafana
# UI on installations that predate this PR (where `spec.oidc` did
# not exist and Grafana orgs were operator-curated by hand).
template: templates/grafana/oidc-users-job.yaml
asserts:
- hasDocuments:
count: 0
- it: mode=None with non-empty users still does NOT render the users-Job
template: templates/grafana/oidc-users-job.yaml
set:
oidc.users:
- email: alice@example.com
role: Admin
asserts:
# The Job is not rendered because `mode: None` short-circuits the
# gate; the prologue-level shape checks (email/role) still fire
# so a typo in the users[] entries doesn't slip through unnoticed.
- hasDocuments:
count: 0
##########################################################
# mode: System — happy path
##########################################################
- it: mode=System renders one KeycloakClient + one audience KeycloakClientScope (chart-owned only)
template: templates/grafana/oidc-keycloak.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- hasDocuments:
count: 2
- equal:
path: kind
value: KeycloakClientScope
documentIndex: 0
- equal:
path: kind
value: KeycloakClient
documentIndex: 1
- it: mode=System KeycloakClient is confidential and points to the persistent Secret
template: templates/grafana/oidc-keycloak.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- equal:
path: spec.public
value: false
documentIndex: 1
- equal:
path: spec.secret
value: "$monitoring-oidc-client:client-secret"
documentIndex: 1
- equal:
path: spec.redirectUris[0]
value: "https://grafana.tenant-root.example.org/login/generic_oauth"
documentIndex: 1
- contains:
path: spec.defaultClientScopes
content: tenant-root-monitoring-audience
documentIndex: 1
# `groups` scope is the platform-managed realm-default that emits
# the caller's Keycloak groups into the token; must be listed
# explicitly here because EDP KeycloakClient overrides (does NOT
# merge with) realm-level defaults. Without it, allowed_groups on
# the Grafana side would reject every login.
- contains:
path: spec.defaultClientScopes
content: groups
documentIndex: 1
- it: mode=System audience scope pins id_token.aud to the per-instance clientId
template: templates/grafana/oidc-keycloak.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- equal:
path: metadata.name
value: tenant-root-monitoring-audience
documentIndex: 0
- equal:
path: spec.protocolMappers[0].config["included.client.audience"]
value: tenant-root-monitoring
documentIndex: 0
- it: mode=System persists a random client-secret at install
template: templates/grafana/oidc-client-secret.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- hasDocuments:
count: 1
- equal:
path: kind
value: Secret
- equal:
path: metadata.name
value: monitoring-oidc-client
# 32 random alphanumeric chars base64-encoded → 44 chars (43 + '=' padding).
- matchRegex:
path: data["client-secret"]
pattern: "^[A-Za-z0-9+/]{43}=$"
- it: mode=System grafana.yaml emits auth.generic_oauth pointing at the cozy realm
template: templates/grafana/grafana.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- equal:
path: spec.config["auth.generic_oauth"].enabled
value: "true"
- equal:
path: spec.config["auth.generic_oauth"].client_id
value: tenant-root-monitoring
- equal:
path: spec.config["auth.generic_oauth"].client_secret
value: "${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}"
- equal:
path: spec.config["auth.generic_oauth"].auth_url
value: "https://keycloak.example.org/realms/cozy/protocol/openid-connect/auth"
- equal:
path: spec.config["auth.generic_oauth"].scopes
value: "openid email profile groups tenant-root-monitoring-audience"
# Tenant-membership gate — unconditionally emitted in System mode
# (chart-owned, not tied to spec.oidc.users). Rejects logins whose
# token does not carry at least one of the release's tenant-scoped
# groups. Space-separated per Grafana generic_oauth semantics.
- equal:
path: spec.config["auth.generic_oauth"].allowed_groups
value: "tenant-root-view tenant-root-use tenant-root-admin tenant-root-super-admin"
# groups_attribute_path is REQUIRED alongside allowed_groups on
# Grafana v11.x+ — without it Grafana leaves the extracted
# user.Groups slice empty and the allowed_groups gate rejects
# every login (verified end-to-end on dev3 with v11.6.15). "groups"
# points at the top-level `groups` array from userinfo — the
# shape Keycloak's oidc-group-membership-mapper emits.
- equal:
path: spec.config["auth.generic_oauth"].groups_attribute_path
value: "groups"
# No group-based mapping; the users-Job drives roles instead.
- notExists:
path: spec.config["auth.generic_oauth"].role_attribute_path
- notExists:
path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin
# skip_org_role_sync=true keeps a login from overwriting the
# users-Job's role assignments.
- equal:
path: spec.config["auth.generic_oauth"].skip_org_role_sync
value: "true"
# Insecure-email-lookup is required to attach the OIDC identity to
# the pre-provisioned local account.
- equal:
path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup
value: "true"
# 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, then wait for the
# next helm-upgrade prune pass to clean them up.
- equal:
path: spec.config["auth.generic_oauth"].allow_sign_up
value: "false"
- it: mode=System with empty users omits users-map isolation dict but keeps the tenant-membership gate
# Reviewer's minor from PR#3176 round-2: with empty users[] the
# users-Job contract keys (skip_org_role_sync, insecure_email_lookup,
# allow_sign_up=false) are dropped so a BYO-IdP-like hands-off flow
# applies inside Grafana. But `allowed_groups` and the `groups` scope
# STAY UNCONDITIONALLY — they are the cross-tenant isolation gate
# (see round-3 review from lllamnyp), NOT part of the users-map
# contract. Without them a tenant-alice user could sign into
# tenant-bob's Grafana and get auto-assigned Viewer.
template: templates/grafana/grafana.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- equal:
path: spec.config["auth.generic_oauth"].enabled
value: "true"
- notExists:
path: spec.config["auth.generic_oauth"].skip_org_role_sync
- notExists:
path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup
- notExists:
path: spec.config["auth.generic_oauth"].allow_sign_up
# Tenant-membership gate stays regardless of users-map state.
- equal:
path: spec.config["auth.generic_oauth"].allowed_groups
value: "tenant-root-view tenant-root-use tenant-root-admin tenant-root-super-admin"
# groups_attribute_path must stay too — without it the gate is a
# silent no-op on Grafana v11.x+ regardless of whether the
# users-map is active.
- equal:
path: spec.config["auth.generic_oauth"].groups_attribute_path
value: "groups"
- equal:
path: spec.config["auth.generic_oauth"].scopes
value: "openid email profile groups tenant-root-monitoring-audience"
- it: mode=System allowed_groups uses the release namespace as tenant prefix (nested tenant)
# Regression guard against a helper regression that would compute
# groups from something else (e.g. .Release.Name or a hardcoded
# "tenant-root"): the tenant chart provisions the groups under
# `<namespace>-{view,...}` — see packages/apps/tenant/templates/
# keycloakgroups.yaml — so a mismatch here would silently lock out
# every user of the release on a nested tenant.
template: templates/grafana/grafana.yaml
release:
name: monitoring
namespace: tenant-foo-bar
set:
_cluster:
root-host: example.org
oidc-enabled: "true"
_namespace:
host: tenant-foo-bar.example.org
ingress: tenant-foo-bar
oidc.mode: System
asserts:
- equal:
path: spec.config["auth.generic_oauth"].allowed_groups
value: "tenant-foo-bar-view tenant-foo-bar-use tenant-foo-bar-admin tenant-foo-bar-super-admin"
- it: mode=System with empty users does NOT render the users-Job
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- hasDocuments:
count: 0
- it: mode=System grafana.yaml wires GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret
template: templates/grafana/grafana.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- contains:
path: spec.deployment.spec.template.spec.containers[0].env
content:
name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: monitoring-oidc-client
key: client-secret
- it: mode=System keeps disable_login_form false so admin creds stay a break-glass path
template: templates/grafana/grafana.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
asserts:
- equal:
path: spec.config.auth.disable_login_form
value: "false"
##########################################################
# users-Job — post-install/post-upgrade hook, RBAC-free
##########################################################
- it: users-Job is a helm hook, has a finite deadline, and needs no ServiceAccount
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- equal:
path: kind
value: Job
- equal:
path: metadata.name
value: monitoring-oidc-users
- equal:
path: metadata.annotations["helm.sh/hook"]
value: post-install,post-upgrade
- equal:
path: metadata.annotations["helm.sh/hook-delete-policy"]
value: before-hook-creation
- equal:
path: spec.activeDeadlineSeconds
value: 900
# No SA name → falls back to `default` — the Job never talks to
# the kube API, only to Grafana over the in-cluster Service.
- notExists:
path: spec.template.spec.serviceAccountName
- it: users-Job mounts admin credentials from the chart-managed Secret
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: GF_ADMIN_USER
valueFrom:
secretKeyRef:
name: grafana-admin-password
key: user
- contains:
path: spec.template.spec.containers[0].env
content:
name: GF_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: grafana-admin-password
key: password
- it: users-Job serialises spec.oidc.users into DESIRED_USERS_JSON
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: alice@example.com
role: Admin
- email: bob@example.com
role: Viewer
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: DESIRED_USERS_JSON
value: '[{"email":"alice@example.com","role":"Admin"},{"email":"bob@example.com","role":"Viewer"}]'
- it: users-Job runs a non-root, read-only-rootfs container
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- equal:
path: spec.template.spec.securityContext.runAsNonRoot
value: true
- equal:
path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem
value: true
- equal:
path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation
value: false
##########################################################
# mode: System — fail-close guard
##########################################################
- it: mode=System hard-fails the render when the platform OIDC feature is off
template: templates/grafana/oidc-keycloak.yaml
set:
oidc.mode: System
# _cluster.oidc-enabled deliberately unset
asserts:
- failedTemplate:
errorMessage: "spec.oidc.mode: System requires the platform-level OIDC feature (authentication.oidc.enabled) — enable it in cozystack-values, or use mode: CustomConfig for a tenant-supplied issuer."
##########################################################
# mode: CustomConfig — inline
##########################################################
- it: mode=CustomConfig with inline config emits no cozy realm objects
template: templates/grafana/oidc-keycloak.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: my-client
client_secret: xyz
auth_url: https://idp.example/auth
asserts:
- hasDocuments:
count: 0
- it: mode=CustomConfig inline emits no client-secret Secret (operator brings their own)
template: templates/grafana/oidc-client-secret.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: my-client
client_secret: xyz
asserts:
- hasDocuments:
count: 0
- it: mode=CustomConfig inline preserves the operator's map + chart-forced settings (users active)
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: my-client
client_secret: xyz
auth_url: https://idp.example/auth
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- equal:
path: spec.config["auth.generic_oauth"].client_id
value: my-client
- equal:
path: spec.config["auth.generic_oauth"].client_secret
value: xyz
- equal:
path: spec.config["auth.generic_oauth"].auth_url
value: https://idp.example/auth
# Contract: chart-forced settings are merged on top of CustomConfig
# inline, so the users-Job's role assignments survive login.
- equal:
path: spec.config["auth.generic_oauth"].skip_org_role_sync
value: "true"
- equal:
path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup
value: "true"
- equal:
path: spec.config["auth.generic_oauth"].allow_sign_up
value: "false"
- it: mode=CustomConfig inline with empty users hands off — chart forces nothing
# Reviewer's minor from PR#3176 round-2: a BYO-IdP operator who does
# NOT use the users-map should not get insecure-email-lookup or
# allow_sign_up=false forced on top of their config — the chart has
# nothing app-side to protect and the extra keys are a needless
# account-linking surface against issuers that don't verify emails.
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: my-client
client_secret: xyz
auth_url: https://idp.example/auth
asserts:
- equal:
path: spec.config["auth.generic_oauth"].client_id
value: my-client
- notExists:
path: spec.config["auth.generic_oauth"].skip_org_role_sync
- notExists:
path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup
- notExists:
path: spec.config["auth.generic_oauth"].allow_sign_up
- it: mode=CustomConfig inline — operator cannot override chart-forced OIDC settings
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: my-client
# Operator tries to disable all three — chart still wins because
# users-map is active.
skip_org_role_sync: "false"
oauth_allow_insecure_email_lookup: "false"
allow_sign_up: "true"
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- equal:
path: spec.config["auth.generic_oauth"].skip_org_role_sync
value: "true"
- equal:
path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup
value: "true"
- equal:
path: spec.config["auth.generic_oauth"].allow_sign_up
value: "false"
- it: mode=CustomConfig users-Job runs and honours the desired list
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: my-client
oidc.users:
- email: carol@example.com
role: Editor
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: OIDC_MODE
value: CustomConfig
- contains:
path: spec.template.spec.containers[0].env
content:
name: DESIRED_USERS_JSON
value: '[{"email":"carol@example.com","role":"Editor"}]'
##########################################################
# mode: CustomConfig — secretRef
##########################################################
- it: mode=CustomConfig with secretRef mounts the operator Secret + GF_PATHS_CUSTOM_INI
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.secretRef.name: my-oauth-secret
asserts:
# spec.config carries no auth.generic_oauth — the ini fragment is
# authoritative in this branch.
- notExists:
path: spec.config["auth.generic_oauth"]
- contains:
path: spec.deployment.spec.template.spec.containers[0].env
content:
name: GF_PATHS_CUSTOM_INI
value: /etc/grafana/oidc/auth.ini
- contains:
path: spec.deployment.spec.template.spec.containers[0].volumeMounts
content:
name: oidc-custom-ini
mountPath: /etc/grafana/oidc
readOnly: true
- contains:
path: spec.deployment.spec.template.spec.volumes
content:
name: oidc-custom-ini
secret:
secretName: my-oauth-secret
##########################################################
# mode: CustomConfig — XOR guards
##########################################################
- it: mode=CustomConfig with BOTH inline and secretRef fails the render
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.config:
client_id: a
oidc.customConfig.secretRef.name: b
asserts:
- failedTemplate:
errorMessage: "spec.oidc.customConfig: set exactly one of `config` (inline) or `secretRef.name` — they are mutually exclusive."
- it: mode=CustomConfig with NEITHER inline nor secretRef fails the render
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
asserts:
- failedTemplate:
errorMessage: "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)."
- it: mode=CustomConfig secretRef + non-empty users fails the render (contract incompat)
# Prologue fires from the users-Job template — that's where all
# spec.oidc.users assertions live, so the fail surfaces there.
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.secretRef.name: my-oauth-secret
oidc.users:
- email: alice@example.com
role: Admin
asserts:
- failedTemplate:
errorMessage: "spec.oidc: `users` is not honoured under `customConfig.secretRef` — the operator's mounted auth.ini is authoritative and the chart cannot inject `skip_org_role_sync=true` / `oauth_allow_insecure_email_lookup=true`, so the users-Job's role assignments would be overwritten on the operator's next login. Either switch to `customConfig.config` (inline map, merged with the chart-forced settings) or unset `users` and manage authorization inside the ini fragment yourself."
- it: mode=CustomConfig secretRef + empty users renders successfully
template: templates/grafana/grafana.yaml
set:
oidc.mode: CustomConfig
oidc.customConfig.secretRef.name: my-oauth-secret
asserts:
# spec.config still carries no auth.generic_oauth in this branch,
# matching the earlier secretRef test — this is the compatible path.
- notExists:
path: spec.config["auth.generic_oauth"]
##########################################################
# spec.oidc.users[].role — schema contract regression guard
##########################################################
# Even though the openAPISchema now marks `role` as required, downstream
# clients (Flux HelmRelease valuesFrom, direct helm CLI, dashboard-
# emitted CRs during a stale schema window) may still pass a
# role-less entry. The template must reject that at render time so the
# users-Job never fires with `role: "null"` on Grafana's admin API.
- it: oidc.users entry missing role fails the render (contract regression guard)
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: someone@example.com
asserts:
- failedTemplate:
errorMessage: "spec.oidc.users[0].role: is required and must be one of Admin, Editor, Viewer. Omitting the field would render null into the Grafana admin API body and fail the post-install users-Job with HTTP 400."
- it: oidc.users entry with unknown role fails the render
template: templates/grafana/oidc-users-job.yaml
set:
oidc.mode: System
_cluster.oidc-enabled: "true"
oidc.users:
- email: someone@example.com
role: Superuser
asserts:
- failedTemplate:
errorMessage: "spec.oidc.users[0].role: \"Superuser\" is not one of the allowed values Admin, Editor, Viewer."
+78
View File
@@ -87,3 +87,81 @@ vmagent:
## - job_name: "custom"
## static_configs:
## - targets: ["my-service:9090"]
## OIDC authentication for the Grafana instance.
##
## Identity mode picks the auth source for `[auth.generic_oauth]`:
## None no OIDC; login form + admin_user/admin_password Secret
## stay the only auth path (default).
## System trust the platform `cozy` Keycloak realm via a
## per-instance confidential client and audience binding.
## Requires the platform-level authentication.oidc.enabled
## flag; the chart hard-fails the render otherwise.
## CustomConfig trust a tenant-supplied issuer directly (BYO IdP).
## Exactly one of `customConfig.config` (inline) or
## `customConfig.secretRef.name` (existing Secret) must be
## set — the chart fails the render otherwise. `cozy` is
## not in the path in this mode.
##
## Authorization is app-side: `users` is reconciled into Grafana org
## membership/roles by a chart-owned post-install/post-upgrade Job.
## Users not listed here get nothing when they log in — no default org
## role from group mapping.
##
## The admin_user / admin_password Secret stays a documented break-glass
## path regardless of mode.
oidc:
mode: "None"
## CustomConfig payload — consumed only when `mode: CustomConfig`.
## Exactly one of `config` or `secretRef.name` MUST be set.
customConfig:
## Inline map of grafana.ini `[auth.generic_oauth]` keys. Any key
## accepted by Grafana's generic_oauth provider is valid; typical
## keys are `enabled: "true"`, `client_id`, `client_secret`,
## `auth_url`, `token_url`, `api_url`, `scopes`. Grafana defaults
## `enabled: false`, so an inline map without an explicit
## `enabled: "true"` is inert — remember to set it.
##
## The chart merges TWO chart-forced settings on top of this map so
## the users-Job contract holds:
## * `skip_org_role_sync: "true"` (login never overwrites the
## Job's org-role assignments);
## * `oauth_allow_insecure_email_lookup: "true"` (OIDC identity
## binds to the pre-provisioned local account by email).
## These two keys cannot be overridden — set them explicitly here
## and the chart still wins. `role_attribute_path` would be dead
## config; the Job manages roles.
##
## Mutually exclusive with `customConfig.secretRef.name`.
config: {}
## Reference to an operator-managed Secret carrying a ready-made
## `[auth.generic_oauth]` ini fragment in the `auth.ini` key. The
## chart mounts it and Grafana loads it via
## `GF_PATHS_CUSTOM_INI`. Mutually exclusive with
## `customConfig.config`.
##
## IMPORTANT — users-map is NOT supported in this branch: the
## chart cannot inject `skip_org_role_sync=true` /
## `oauth_allow_insecure_email_lookup=true` into a Secret-mounted
## ini fragment, so the users-Job's role assignments would be
## silently overwritten on the next login. Either use
## `customConfig.config` (inline) and let the chart merge the two
## settings, or leave `spec.oidc.users` empty and manage
## authorization inside the mounted ini fragment yourself. The
## chart hard-fails render when both `secretRef.name` and non-empty
## `users` are set.
secretRef:
name: ""
## Users granted access to this Grafana instance's org. Each entry
## produces one pre-provisioned Grafana account (matched to the OIDC
## login by `email`) and one org membership + role in the release's
## `Main Org.`. Empty by default — no OIDC user gets any role until
## an operator lists them here.
##
## Example:
## users:
## - email: alice@example.com
## role: Admin
## - email: bob@example.com
## role: Viewer
users: []