mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-29 16:17:20 +09:00
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:
24 files changed
+2894
-9
No files matched your search
@@ -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
@@ -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
@@ -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}" ]
|
||||
}
|
||||
Executable
+230
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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: []
|
||||
Reference in new issue
Block a user