diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md new file mode 100644 index 000000000..224a1b802 --- /dev/null +++ b/docs/oidc-grafana.md @@ -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 `-` in the `cozy` + realm — `public: false`, `directAccess: false`. The `secret` field + points at a chart-owned Kubernetes Secret via the EDP-Keycloak + `$:` syntax so the operator provisions the same + confidential secret to Keycloak that Grafana reads at runtime. + `redirectUris` is locked to + `https://grafana./login/generic_oauth`. +2. **KeycloakClientScope** named `--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** `-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 = -view -use -admin -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 `-{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.` 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 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 logs job/-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. diff --git a/docs/oidc-tenant.md b/docs/oidc-tenant.md index 90f4b0bfe..8ab5af57f 100644 --- a/docs/oidc-tenant.md +++ b/docs/oidc-tenant.md @@ -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: + + ``` + -view -use -admin -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. diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats new file mode 100755 index 000000000..20e290fc2 --- /dev/null +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -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 - </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 - </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:-}" + [ -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}" ] +} diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats new file mode 100755 index 000000000..3ce9521ee --- /dev/null +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -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 - </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" ] +} diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index 7524f10dd..eefc6ab22 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -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 }} diff --git a/packages/apps/kubernetes/templates/oidc-authn-config.yaml b/packages/apps/kubernetes/templates/oidc-authn-config.yaml index 72563eae9..17ee04d92 100644 --- a/packages/apps/kubernetes/templates/oidc-authn-config.yaml +++ b/packages/apps/kubernetes/templates/oidc-authn-config.yaml @@ -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 +(`-{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 diff --git a/packages/apps/kubernetes/tests/oidc_test.yaml b/packages/apps/kubernetes/tests/oidc_test.yaml index 59c84769f..0f9bdb1b3 100644 --- a/packages/apps/kubernetes/tests/oidc_test.yaml +++ b/packages/apps/kubernetes/tests/oidc_test.yaml @@ -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: diff --git a/packages/core/platform/sources/monitoring-application.yaml b/packages/core/platform/sources/monitoring-application.yaml index 6c6360398..188bcba7a 100644 --- a/packages/core/platform/sources/monitoring-application.yaml +++ b/packages/core/platform/sources/monitoring-application.yaml @@ -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 diff --git a/packages/core/platform/templates/bundles/system.yaml b/packages/core/platform/templates/bundles/system.yaml index 84aff47ac..1dc769631 100644 --- a/packages/core/platform/templates/bundles/system.yaml +++ b/packages/core/platform/templates/bundles/system.yaml @@ -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" $) }} diff --git a/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml b/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml new file mode 100644 index 000000000..70bc7e59b --- /dev/null +++ b/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml @@ -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 diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index ed7df4f64..446774295 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -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` | `{}` | + diff --git a/packages/extra/monitoring/templates/helmrelease.yaml b/packages/extra/monitoring/templates/helmrelease.yaml index e7290cb5d..7bc613e33 100644 --- a/packages/extra/monitoring/templates/helmrelease.yaml +++ b/packages/extra/monitoring/templates/helmrelease.yaml @@ -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 diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 4bc6906e2..94af2e467 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -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" + ] + } + } + } + } + } } } } diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index c521f3894..3edae2af2 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -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} [] - 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: [] diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index ce4d75c5d..1908bc80f 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -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]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"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}}},"requests":{"description":"Resource requests.","type":"object","default":{},"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}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"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}}},"requests":{"description":"Resource requests.","type":"object","default":{},"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}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}}}} + {"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]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"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}}},"requests":{"description":"Resource requests.","type":"object","default":{},"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}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"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}}},"requests":{"description":"Resource requests.","type":"object","default":{},"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}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"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"]}}}}}}}} release: prefix: "" labels: @@ -26,7 +38,7 @@ spec: description: Monitoring and observability stack module: true icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzI2OCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zMjY4KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMzg0NTRGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNNTQuMTcyMiAzOC43NzU3SDMzLjEwMzJDMzIuMTMwNyAzOC43NzU3IDMxLjQ4MjQgMzguMTI3NCAzMS40ODI0IDM3LjE1NDlDMzEuNDgyNCAzNi4xODI0IDMyLjEzMDcgMzUuNTM0MiAzMy4xMDMyIDM1LjUzNDJINTQuMTcyMkM1NS4xNDQ3IDM1LjUzNDIgNTUuNzkzIDM2LjE4MjQgNTUuNzkzIDM3LjE1NDlDNTUuNzkyOCAzOC4xMjc0IDU1LjE0NDUgMzguNzc1NyA1NC4xNzIyIDM4Ljc3NTdaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik02My44OTYzIDQ1LjI1OTFINDEuMjA2N0M0MC4yMzQyIDQ1LjI1OTEgMzkuNTg1OSA0NC42MTA4IDM5LjU4NTkgNDMuNjM4M0MzOS41ODU5IDQyLjY2NTggNDAuMjM0MiA0Mi4wMTc2IDQxLjIwNjcgNDIuMDE3Nkg2My44OTYzQzY0Ljg2ODggNDIuMDE3NiA2NS41MTcxIDQyLjY2NTggNjUuNTE3MSA0My42MzgzQzY1LjUxNzEgNDQuNjEwOCA2NC44Njg4IDQ1LjI1OTEgNjMuODk2MyA0NS4yNTkxWiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMzQuNzI0IDQ1LjI1OTFIMzMuMTAzMkMzMi4xMzA3IDQ1LjI1OTEgMzEuNDgyNCA0NC42MTA4IDMxLjQ4MjQgNDMuNjM4M0MzMS40ODI0IDQyLjY2NTggMzIuMTMwNyA0Mi4wMTc2IDMzLjEwMzIgNDIuMDE3NkgzNC43MjRDMzUuNjk2NCA0Mi4wMTc2IDM2LjM0NDcgNDIuNjY1OCAzNi4zNDQ3IDQzLjYzODNDMzYuMzQ0NyA0NC42MTA4IDM1LjY5NjMgNDUuMjU5MSAzNC43MjQgNDUuMjU5MVoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgMzguNzc1N0g2MC42NTQ5QzU5LjY4MjQgMzguNzc1NyA1OS4wMzQyIDM4LjEyNzQgNTkuMDM0MiAzNy4xNTQ5QzU5LjAzNDIgMzYuMTgyNCA1OS42ODI0IDM1LjUzNDIgNjAuNjU0OSAzNS41MzQySDYzLjg5NjNDNjQuODY4OCAzNS41MzQyIDY1LjUxNzEgMzYuMTgyNCA2NS41MTcxIDM3LjE1NDlDNjUuNTE3MSAzOC4xMjc0IDY0Ljg2ODggMzguNzc1NyA2My44OTYzIDM4Ljc3NTdaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik00Ny42ODkzIDUxLjc0MTNIMzMuMTAzMkMzMi4xMzA3IDUxLjc0MTMgMzEuNDgyNCA1MS4wOTMxIDMxLjQ4MjQgNTAuMTIwNkMzMS40ODI0IDQ5LjE0ODEgMzIuMTMwNyA0OC41IDMzLjEwMzIgNDguNUg0Ny42ODkzQzQ4LjY2MTggNDguNSA0OS4zMTAxIDQ5LjE0ODMgNDkuMzEwMSA1MC4xMjA4QzQ5LjMxMDEgNTEuMDkzMyA0OC42NjE4IDUxLjc0MTMgNDcuNjg5MyA1MS43NDEzWiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNjMuODk2OCA1MS43NDEzSDU0LjE3MjVDNTMuMiA1MS43NDEzIDUyLjU1MTggNTEuMDkzMSA1Mi41NTE4IDUwLjEyMDZDNTIuNTUxOCA0OS4xNDgxIDUzLjIwMDIgNDguNSA1NC4xNzI3IDQ4LjVINjMuODk2OUM2NC44Njk0IDQ4LjUgNjUuNTE3NyA0OS4xNDgzIDY1LjUxNzcgNTAuMTIwOEM2NS41MTc3IDUxLjA5MzMgNjQuODY5MiA1MS43NDEzIDYzLjg5NjggNTEuNzQxM1oiIGZpbGw9IiNFQ0JBMTYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNTguMjI0SDMzLjEwMzJDMzIuMTMwNyA1OC4yMjQgMzEuNDgyNCA1Ny41NzU3IDMxLjQ4MjQgNTYuNjAzMkMzMS40ODI0IDU1LjYzMDcgMzIuMTMwNyA1NC45ODI0IDMzLjEwMzIgNTQuOTgyNEg1NC4xNzIyQzU1LjE0NDcgNTQuOTgyNCA1NS43OTMgNTUuNjMwNyA1NS43OTMgNTYuNjAzMkM1NS43OTMgNTcuNTc1NyA1NS4xNDQ1IDU4LjIyNCA1NC4xNzIyIDU4LjIyNFoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgNjQuNzA3NEg0MS4yMDY3QzQwLjIzNDIgNjQuNzA3NCAzOS41ODU5IDY0LjA1OTEgMzkuNTg1OSA2My4wODY2QzM5LjU4NTkgNjIuMTE0MSA0MC4yMzQyIDYxLjQ2NTggNDEuMjA2NyA2MS40NjU4SDYzLjg5NjNDNjQuODY4OCA2MS40NjU4IDY1LjUxNzEgNjIuMTE0MSA2NS41MTcxIDYzLjA4NjZDNjUuNTE3MSA2NC4wNTkxIDY0Ljg2ODggNjQuNzA3NCA2My44OTYzIDY0LjcwNzRaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik0zNC43MjQgNjQuNzA3NEgzMy4xMDMyQzMyLjEzMDcgNjQuNzA3NCAzMS40ODI0IDY0LjA1OTEgMzEuNDgyNCA2My4wODY2QzMxLjQ4MjQgNjIuMTE0MSAzMi4xMzA3IDYxLjQ2NTggMzMuMTAzMiA2MS40NjU4SDM0LjcyNEMzNS42OTY0IDYxLjQ2NTggMzYuMzQ0NyA2Mi4xMTQxIDM2LjM0NDcgNjMuMDg2NkMzNi4zNDQ3IDY0LjA1OTEgMzUuNjk2MyA2NC43MDc0IDM0LjcyNCA2NC43MDc0WiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNDcuNjg5MyA3MS4xODk4SDMzLjEwMzJDMzIuMTMwNyA3MS4xODk4IDMxLjQ4MjQgNzAuNTQxNSAzMS40ODI0IDY5LjU2OUMzMS40ODI0IDY4LjU5NjUgMzIuMTMwNyA2Ny45NDgyIDMzLjEwMzIgNjcuOTQ4Mkg0Ny42ODkzQzQ4LjY2MTggNjcuOTQ4MiA0OS4zMTAxIDY4LjU5NjUgNDkuMzEwMSA2OS41NjlDNDkuMzEwMSA3MC41NDE1IDQ4LjY2MTggNzEuMTg5OCA0Ny42ODkzIDcxLjE4OThaIiBmaWxsPSIjNDJCMDVDIi8+CjxwYXRoIGQ9Ik02My44OTY4IDcxLjE4OThINTQuMTcyNUM1My4yIDcxLjE4OTggNTIuNTUxOCA3MC41NDE1IDUyLjU1MTggNjkuNTY5QzUyLjU1MTggNjguNTk2NSA1My4yIDY3Ljk0ODIgNTQuMTcyNSA2Ny45NDgySDYzLjg5NjhDNjQuODY5MiA2Ny45NDgyIDY1LjUxNzUgNjguNTk2NSA2NS41MTc1IDY5LjU2OUM2NS41MTc1IDcwLjU0MTUgNjQuODY5MiA3MS4xODk4IDYzLjg5NjggNzEuMTg5OFoiIGZpbGw9IiM3MzgzQkYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNzcuNjcyMkgzMy4xMDMyQzMyLjEzMDcgNzcuNjcyMiAzMS40ODI0IDc3LjAyMzkgMzEuNDgyNCA3Ni4wNTE0QzMxLjQ4MjQgNzUuMDc4OSAzMi4xMzA3IDc0LjQzMDcgMzMuMTAzMiA3NC40MzA3SDU0LjE3MjJDNTUuMTQ0NyA3NC40MzA3IDU1Ljc5MyA3NS4wNzg5IDU1Ljc5MyA3Ni4wNTE0QzU1Ljc5MjggNzcuMDIzOSA1NS4xNDQ1IDc3LjY3MjIgNTQuMTcyMiA3Ny42NzIyWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNNjMuODk2MyA3Ny42NzIySDYwLjY1NDlDNTkuNjgyNCA3Ny42NzIyIDU5LjAzNDIgNzcuMDIzOSA1OS4wMzQyIDc2LjA1MTRDNTkuMDM0MiA3NS4wNzg5IDU5LjY4MjQgNzQuNDMwNyA2MC42NTQ5IDc0LjQzMDdINjMuODk2M0M2NC44Njg4IDc0LjQzMDcgNjUuNTE3MSA3NS4wNzg5IDY1LjUxNzEgNzYuMDUxNEM2NS41MTcxIDc3LjAyMzkgNjQuODY4OCA3Ny42NzIyIDYzLjg5NjMgNzcuNjcyMloiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTEwMS4xNzIgNzcuNjcyMkg4MC4xMDMyQzc5LjEzMDcgNzcuNjcyMiA3OC40ODI0IDc3LjAyMzkgNzguNDgyNCA3Ni4wNTE0Qzc4LjQ4MjQgNzUuMDc4OSA3OS4xMzA3IDc0LjQzMDcgODAuMTAzMiA3NC40MzA3SDEwMS4xNzJDMTAyLjE0NSA3NC40MzA3IDEwMi43OTMgNzUuMDc4OSAxMDIuNzkzIDc2LjA1MTRDMTAyLjc5MyA3Ny4wMjM5IDEwMi4xNDUgNzcuNjcyMiAxMDEuMTcyIDc3LjY3MjJaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik0xMTAuODk2IDc3LjY3MjJIMTA3LjY1NUMxMDYuNjgyIDc3LjY3MjIgMTA2LjAzNCA3Ny4wMjM5IDEwNi4wMzQgNzYuMDUxNEMxMDYuMDM0IDc1LjA3ODkgMTA2LjY4MiA3NC40MzA3IDEwNy42NTUgNzQuNDMwN0gxMTAuODk2QzExMS44NjkgNzQuNDMwNyAxMTIuNTE3IDc1LjA3ODkgMTEyLjUxNyA3Ni4wNTE0QzExMi41MTcgNzcuMDIzOSAxMTEuODY5IDc3LjY3MjIgMTEwLjg5NiA3Ny42NzIyWiIgZmlsbD0iIzQyQjA1QyIvPgo8cGF0aCBkPSJNNjMuODk2MyA1OC4yMjRINjAuNjU0OUM1OS42ODI0IDU4LjIyNCA1OS4wMzQyIDU3LjU3NTcgNTkuMDM0MiA1Ni42MDMyQzU5LjAzNDIgNTUuNjMwNyA1OS42ODI0IDU0Ljk4MjQgNjAuNjU0OSA1NC45ODI0SDYzLjg5NjNDNjQuODY4OCA1NC45ODI0IDY1LjUxNzEgNTUuNjMwNyA2NS41MTcxIDU2LjYwMzJDNjUuNTE3MSA1Ny41NzU3IDY0Ljg2ODggNTguMjI0IDYzLjg5NjMgNTguMjI0WiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMTEyLjUxNyA1MS43NDExQzExMi41MTcgNjAuNjU0OSAxMDUuMjI0IDY3Ljk0OCA5Ni4zMTA0IDY3Ljk0OEM4Ny4zOTY2IDY3Ljk0OCA4MC4xMDM1IDYwLjY1NDkgODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNDIuODI3MyA4Ny4zOTY2IDM1LjUzNDIgOTYuMzEwNCAzNS41MzQyQzEwNS4yMjQgMzUuNTM0MiAxMTIuNTE3IDQyLjgyNzMgMTEyLjUxNyA1MS43NDExWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNTIuMjI3MyA4MC4xMDM1IDUyLjg3NTUgODAuMTAzNSA1My4zNjE5SDk2LjMxMDRWMzUuNTM0MkM4Ny4zOTY2IDM1LjUzNDIgODAuMTAzNSA0Mi44MjczIDgwLjEwMzUgNTEuNzQxMVoiIGZpbGw9IiM0MkIwNUMiLz4KPC9nPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4N18zMjY4IiB4MT0iMS4yMzIzOWUtMDYiIHkxPSItOS41MDAwMSIgeDI9IjE2OCIgeTI9IjE2MiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjOEZEREZGIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzAwNzVGRiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzY4N18zMjY4Ij4KPHJlY3Qgd2lkdGg9Ijk0IiBoZWlnaHQ9Ijk0IiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUgMjUpIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg== - 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: diff --git a/packages/system/monitoring/images/kubectl.tag b/packages/system/monitoring/images/kubectl.tag new file mode 100644 index 000000000..80d1e903c --- /dev/null +++ b/packages/system/monitoring/images/kubectl.tag @@ -0,0 +1 @@ +docker.io/alpine/k8s:1.33.4@sha256:b0523f0a244ddc4c8e055aa335c040d3d78b3ead5528f4544395f7f9f69c7b68 diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl new file mode 100644 index 000000000..3ce069dce --- /dev/null +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -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 `-` 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 `-{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 -}} diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 2f5a85e07..5b99b3415 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -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 `-{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: [-super-admin, ...]`, `allowed_groups` + listed `-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. */}} diff --git a/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml new file mode 100644 index 000000000..b0f1b1e96 --- /dev/null +++ b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml @@ -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 `$: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 }} diff --git a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml new file mode 100644 index 000000000..2f10894c0 --- /dev/null +++ b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml @@ -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, ]` 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 + (`-{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 }} diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml new file mode 100644 index 000000000..90dfad3be --- /dev/null +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -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=. + 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 }} diff --git a/packages/system/monitoring/tests/oidc_crd_missing_test.yaml b/packages/system/monitoring/tests/oidc_crd_missing_test.yaml new file mode 100644 index 000000000..878040c6c --- /dev/null +++ b/packages/system/monitoring/tests/oidc_crd_missing_test.yaml @@ -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." diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml new file mode 100644 index 000000000..3f6d25817 --- /dev/null +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -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 + # `-{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." diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index 3b632cbe2..6011b0f38 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -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: []