Skip to content

feat(monitoring): per-instance OIDC selector for the Grafana instance (Phase 1) - #3176

Merged
IvanHunters merged 39 commits into
mainfrom
feat/monitoring-oidc
Jul 15, 2026
Merged

IvanHunters merged 39 commits into
mainfrom
feat/monitoring-oidc

Conversation

@IvanHunters

@IvanHunters IvanHunters commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

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

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

The Monitoring CR grows a new field:

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

Modes

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

Fail-close guards

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

Break-glass posture

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

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

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

Docs

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

Tests

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

Screenshots

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

Release note

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

Summary by CodeRabbit

  • New Features
    • Added OIDC authentication for Monitoring Grafana with None, platform-managed System, and tenant-controlled CustomConfig modes.
    • System mode provisions platform-backed OIDC for each instance (including persistent client credentials) and can optionally promote matching users to server-level GrafanaAdmin.
    • CustomConfig supports inline OAuth settings or Secret-backed auth.ini (with mutual exclusivity validation).

@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files area/monitoring Issues or PRs related to the monitoring stack (vlogs, vmstack, grafana, workloadmonitor) kind/feature Categorizes issue or PR as related to a new feature labels Jul 1, 2026
@coderabbitai

coderabbitai Bot commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds OIDC authentication support for Monitoring Grafana, including a new spec.oidc.mode contract, System and CustomConfig rendering paths, Keycloak provisioning, platform wiring, tests, and documentation.

Changes

Grafana OIDC Feature

Layer / File(s) Summary
OIDC values and schema
packages/extra/monitoring/values.schema.json, packages/extra/monitoring/values.yaml, packages/system/monitoring/values.yaml, packages/system/monitoring-rd/cozyrds/monitoring.yaml, packages/extra/monitoring/README.md
Adds the oidc configuration object, defaults, schema metadata, and parameter documentation for mode, customConfig, and grafanaAdmin.
OIDC behavior documentation
docs/oidc-grafana.md
Describes System and CustomConfig behavior, role mapping, login flow, failure modes, and Phase 1 exclusions.
OIDC helper templates
packages/system/monitoring/templates/_helpers.tpl
Adds helpers for OIDC client identifiers, host and issuer derivation, role mapping, admin assignment, and validation.
Grafana OIDC wiring
packages/system/monitoring/templates/grafana/grafana.yaml
Conditionally renders auth.generic_oauth and injects the OIDC Secret, custom INI path, and volume wiring.
Keycloak resources and client secret
packages/system/monitoring/templates/grafana/oidc-client-secret.yaml, packages/system/monitoring/templates/grafana/oidc-keycloak.yaml
Generates the System-mode client-secret Secret and the Keycloak client, scope, and realm groups.
Helm OIDC rendering tests
packages/system/monitoring/tests/oidc_test.yaml
Covers None/System/CustomConfig rendering, guard failures, secret persistence, and grafanaAdmin behavior.
Bats OIDC integration tests
hack/e2e-apps/monitoring-oidc-system.bats, hack/e2e-apps/monitoring-oidc-customconfig.bats
Adds live-cluster checks for System-mode provisioning and CustomConfig inline/Secret-backed behavior.
Platform wiring and source tests
packages/core/platform/sources/monitoring-application.yaml, packages/core/platform/templates/bundles/system.yaml, packages/core/platform/tests/*monitoring*
Adds the Keycloak operator dependency, enables platform Grafana admin promotion, and updates bundle/source tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HelmValues
  participant MonitoringChart
  participant Keycloak
  participant Grafana
  participant Secret
  HelmValues->>MonitoringChart: set spec.oidc.mode and customConfig
  MonitoringChart->>Secret: render or reuse client-secret
  MonitoringChart->>Keycloak: provision client, scope, and groups
  MonitoringChart->>Grafana: render auth.generic_oauth or custom INI wiring
  Grafana->>Keycloak: OIDC login
  Keycloak-->>Grafana: token with role and audience claims
Loading

Possibly related PRs

  • cozystack/cozystack#3041: Changes the same monitoring application dependency graph and Keycloak-related ordering around dependsOn.

Suggested labels: area/platform, area/testing

Suggested reviewers: kvaps, lllamnyp, sircthulhu, myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding a per-instance OIDC selector for the Grafana instance in Phase 1.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/monitoring-oidc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the Monitoring package by adding support for OIDC authentication for Grafana instances. It provides a structured way for operators to integrate with the platform's central Keycloak realm or bring their own identity provider. The changes include new Helm templates for managing OIDC-related resources, schema updates for the Monitoring CR, and automated end-to-end tests to ensure reliable configuration rendering.

Highlights

  • OIDC Authentication Support: Introduced a new OIDC selector in the Monitoring CR, allowing Grafana instances to authenticate via 'System' (platform Keycloak) or 'CustomConfig' (BYO IdP) modes.
  • System Mode Integration: Automated the provisioning of Keycloak clients, audience-scoped client scopes, and role-mapping groups to enable secure, per-instance OIDC authentication.
  • CustomConfig Flexibility: Added support for tenant-supplied OIDC configurations, either via inline map definitions or by mounting operator-managed Secrets containing auth.ini fragments.
  • Safety and Documentation: Implemented fail-close guards for invalid configurations and added comprehensive operator documentation in docs/oidc-grafana.md.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@dosubot dosubot Bot added the kind/api-change Categorizes issue or PR as related to adding, removing, or otherwise changing an API label Jul 1, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements Phase 1 of OIDC integration for the Grafana instance, introducing three identity modes: None, System (using the platform's Keycloak), and CustomConfig (for tenant-supplied OIDC issuers). The changes include updates to the Helm chart templates, values schema, documentation, and new E2E and unit tests. The review feedback constructively points out opportunities to apply defensive programming across several templates (grafana.yaml, _helpers.tpl, oidc-client-secret.yaml, and oidc-keycloak.yaml) by safely navigating .Values.oidc to prevent potential nil pointer evaluation errors if the OIDC configuration is omitted or null.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +21 to +28
{{- $oidcMode := .Values.oidc.mode | default "None" }}
{{- if eq $oidcMode "CustomConfig" }}
{{- include "monitoring.oidc.assertCustomConfigXor" . }}
{{- end }}
{{- $customInline := (.Values.oidc.customConfig.config | default dict) }}
{{- $customSecretRefName := dig "secretRef" "name" "" (.Values.oidc.customConfig | default dict) }}
{{- $useCustomInline := and (eq $oidcMode "CustomConfig") (gt (len $customInline) 0) }}
{{- $useCustomSecretRef := and (eq $oidcMode "CustomConfig") (ne $customSecretRefName "") }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent potential nil pointer evaluation errors if .Values.oidc is omitted or set to null in the user's custom values, use defensive programming to safely navigate the nested maps.

{{- $oidc := .Values.oidc | default dict }}
{{- $oidcMode := $oidc.mode | default "None" }}
{{- if eq $oidcMode "CustomConfig" }}
{{-   include "monitoring.oidc.assertCustomConfigXor" . }}
{{- 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 "") }}

Comment on lines +88 to +99
{{- define "monitoring.oidc.assertCustomConfigXor" -}}
{{- $inline := (.Values.oidc.customConfig.config | default dict) -}}
{{- $secretName := dig "secretRef" "name" "" (.Values.oidc.customConfig | default dict) -}}
{{- $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 -}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Safely navigate .Values.oidc to prevent nil pointer evaluation errors if oidc is omitted or set to null in the values.

{{- 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 -}}

Comment on lines +21 to +29
{{- if eq .Values.oidc.mode "System" }}
{{- $secretName := include "monitoring.oidc.clientSecretName" . }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
{{- $clientSecret := "" }}
{{- if $existing }}
{{- $clientSecret = index $existing.data "client-secret" | b64dec }}
{{- else }}
{{- $clientSecret = randAlphaNum 32 }}
{{- end }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Use safe navigation to check if .Values.oidc is defined, and use dig to safely retrieve the existing client secret. This prevents nil pointer evaluation errors if oidc is omitted or if the existing Secret is found but lacks the data field or the client-secret key.

{{- $oidc := .Values.oidc | default dict }}
{{- if eq $oidc.mode "System" }}
{{-   $secretName := include "monitoring.oidc.clientSecretName" . }}
{{-   $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
{{-   $clientSecret := "" }}
{{-   if $existing }}
{{-     $clientSecret = dig "data" "client-secret" "" $existing | b64dec }}
{{-   end }}
{{-   if eq $clientSecret "" }}
{{-     $clientSecret = randAlphaNum 32 }}
{{-   end }}

`role_attribute_path` (see grafana.yaml) evaluates group membership
on login and assigns Admin / Editor / Viewer accordingly.
*/}}
{{- if eq .Values.oidc.mode "System" }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Safely navigate .Values.oidc to prevent nil pointer evaluation errors if oidc is omitted or set to null in the values.

{{- $oidc := .Values.oidc | default dict }}
{{- if eq $oidc.mode "System" }}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/system/monitoring/tests/oidc_test.yaml (1)

131-148: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Regex doesn't actually verify the claimed 32-char length.

The test is titled "persists a random 32-char client-secret" but matchRegex only enforces character set (^[A-Za-z0-9]+={0,2}$), not length. A regression that shortens the random secret (e.g., to 8 chars) would still pass this assertion.

💚 Tighten the regex to assert the expected base64 length
       - matchRegex:
           path: data["client-secret"]
-          pattern: "^[A-Za-z0-9]+={0,2}$"
+          pattern: "^[A-Za-z0-9]{43}=$"

Adjust the exact length/padding to match whatever encoding scheme the template actually uses (base64 of 32 random bytes vs. 32 random alphanumeric chars pre-encoding differ in output length).

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

In `@packages/system/monitoring/tests/oidc_test.yaml` around lines 131 - 148, The
test assertion for the OIDC client secret only checks allowed characters, not
the expected length, so it can miss regressions in the generated secret size.
Update the `it: mode=System persists a random 32-char client-secret at install`
case in `templates/grafana/oidc-client-secret.yaml`/`matchRegex` to validate the
actual encoded length and padding produced by the secret generation scheme,
using the existing `data["client-secret"]` assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hack/e2e-apps/monitoring-oidc-system.bats`:
- Around line 89-98: The OIDC client Secret name is being built from the wrong
release name in the e2e test, so the lookup never matches the rendered Secret.
Update the Secret reference in monitoring-oidc-system.bats to use the
HelmRelease name form expected by monitoring.oidc.clientSecretName, and apply
the same name to the matching secretKeyRef assertion so both checks target the
actual created Secret. Use the existing test variables and the
monitoring-${TEST_NAME} release naming convention to locate the affected
assertions.

In `@packages/system/monitoring/templates/_helpers.tpl`:
- Around line 56-70: The allowAssignGrafanaAdmin guard in
monitoring.oidc.allowAssignGrafanaAdmin only checks .Release.Name, so it can
grant GrafanaAdmin to any release named monitoring-system. Update this helper to
require both the expected release name and the cozy-monitoring namespace before
returning true, and keep tenant instances on false. Use the existing
monitoring.oidc.allowAssignGrafanaAdmin symbol as the entry point for the fix.

In `@packages/system/monitoring/templates/grafana/oidc-client-secret.yaml`:
- Around line 25-29: The OIDC secret template’s existing-secret branch in
oidc-client-secret.yaml does not handle a missing client-secret entry, so it can
reuse an empty value instead of generating one. Update the $existing handling to
check that the data map actually contains client-secret before calling
index/b64dec, and fall back to randAlphaNum 32 when the key is absent or empty.
Keep the fix in the template logic that sets $clientSecret so the rendered
secret always gets a valid value.

---

Nitpick comments:
In `@packages/system/monitoring/tests/oidc_test.yaml`:
- Around line 131-148: The test assertion for the OIDC client secret only checks
allowed characters, not the expected length, so it can miss regressions in the
generated secret size. Update the `it: mode=System persists a random 32-char
client-secret at install` case in
`templates/grafana/oidc-client-secret.yaml`/`matchRegex` to validate the actual
encoded length and padding produced by the secret generation scheme, using the
existing `data["client-secret"]` assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 866218e9-e57c-4fd3-bada-4eb6414577b9

📥 Commits

Reviewing files that changed from the base of the PR and between 4783add and 1970364.

📒 Files selected for processing (13)
  • docs/oidc-grafana.md
  • hack/e2e-apps/monitoring-oidc-customconfig.bats
  • hack/e2e-apps/monitoring-oidc-system.bats
  • packages/extra/monitoring/README.md
  • packages/extra/monitoring/values.schema.json
  • packages/extra/monitoring/values.yaml
  • packages/system/monitoring-rd/cozyrds/monitoring.yaml
  • packages/system/monitoring/templates/_helpers.tpl
  • packages/system/monitoring/templates/grafana/grafana.yaml
  • packages/system/monitoring/templates/grafana/oidc-client-secret.yaml
  • packages/system/monitoring/templates/grafana/oidc-keycloak.yaml
  • packages/system/monitoring/tests/oidc_test.yaml
  • packages/system/monitoring/values.yaml

Comment thread hack/e2e-apps/monitoring-oidc-system.bats
Comment on lines +56 to +70
{{- /*
Platform Grafana (release name `monitoring-system` in the
`cozy-monitoring` namespace) is operated by the platform admin and
should be able to receive `GrafanaAdmin` (server-level) — currently
we upgrade `Admin` role assignments to `GrafanaAdmin` there via the
`allow_assign_grafana_admin` flag. Tenant Grafana instances stay at
org-level Admin.
*/}}
{{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}}
{{- if eq .Release.Name "monitoring-system" -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Admin-escalation guard doesn't check namespace, only release name.

The doc comment scopes the platform Grafana as release monitoring-system in cozy-monitoring, but the guard only compares .Release.Name. Since release/CR names appear caller-controlled elsewhere in this PR (e.g. e2e tests parametrize the CR name), a tenant naming their own release monitoring-system in their own namespace would satisfy this check and get allow_assign_grafana_admin: true.

🔒 Proposed fix: pin both namespace and release name
 {{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}}
-{{- if eq .Release.Name "monitoring-system" -}}
+{{- if and (eq .Release.Namespace "cozy-monitoring") (eq .Release.Name "monitoring-system") -}}
 true
 {{- else -}}
 false
 {{- end -}}
 {{- end -}}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{{- /*
Platform Grafana (release name `monitoring-system` in the
`cozy-monitoring` namespace) is operated by the platform admin and
should be able to receive `GrafanaAdmin` (server-level) — currently
we upgrade `Admin` role assignments to `GrafanaAdmin` there via the
`allow_assign_grafana_admin` flag. Tenant Grafana instances stay at
org-level Admin.
*/}}
{{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}}
{{- if eq .Release.Name "monitoring-system" -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{- /*
Platform Grafana (release name `monitoring-system` in the
`cozy-monitoring` namespace) is operated by the platform admin and
should be able to receive `GrafanaAdmin` (server-level) — currently
we upgrade `Admin` role assignments to `GrafanaAdmin` there via the
`allow_assign_grafana_admin` flag. Tenant Grafana instances stay at
org-level Admin.
*/}}
{{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}}
{{- if and (eq .Release.Namespace "cozy-monitoring") (eq .Release.Name "monitoring-system") -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/system/monitoring/templates/_helpers.tpl` around lines 56 - 70, The
allowAssignGrafanaAdmin guard in monitoring.oidc.allowAssignGrafanaAdmin only
checks .Release.Name, so it can grant GrafanaAdmin to any release named
monitoring-system. Update this helper to require both the expected release name
and the cozy-monitoring namespace before returning true, and keep tenant
instances on false. Use the existing monitoring.oidc.allowAssignGrafanaAdmin
symbol as the entry point for the fix.

@IvanHunters
IvanHunters force-pushed the feat/monitoring-oidc branch from 1970364 to c458dd7 Compare July 4, 2026 20:02

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@packages/core/platform/sources/monitoring-application.yaml`:
- Around line 21-29: Move the cozystack.keycloak-operator entry out of the
shared default buildDependsOn for cozystack.monitoring-application and make it
conditional on platform OIDC being enabled. Update the
monitoring-application.yaml dependency definition so the Keycloak requirement is
only applied when authentication.oidc.enabled is true, while keeping the
non-OIDC modes (None/CustomConfig) dependency-free and allowing the package to
become ready.

In `@packages/system/monitoring/templates/_helpers.tpl`:
- Around line 57-66: The `monitoring.oidc.allowAssignGrafanaAdmin` helper still
reads a tenant-controlled `grafanaAdmin` value from `.Values.oidc`, so tenants
can self-enable server-level GrafanaAdmin. Remove `spec.oidc.grafanaAdmin` from
the `Monitoring` CR schema in `monitoring.yaml`, or add validation to reject it,
while keeping the helper usable only from platform-provided values so the flag
remains platform-only.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a5841ec0-a4b9-49ce-918b-a9fa59ea4a0e

📥 Commits

Reviewing files that changed from the base of the PR and between c458dd7 and 1bd3da9.

📒 Files selected for processing (12)
  • docs/oidc-grafana.md
  • packages/core/platform/sources/monitoring-application.yaml
  • packages/core/platform/templates/bundles/system.yaml
  • packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml
  • packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml
  • packages/extra/monitoring/README.md
  • packages/extra/monitoring/values.schema.json
  • packages/extra/monitoring/values.yaml
  • packages/system/monitoring-rd/cozyrds/monitoring.yaml
  • packages/system/monitoring/templates/_helpers.tpl
  • packages/system/monitoring/tests/oidc_test.yaml
  • packages/system/monitoring/values.yaml
✅ Files skipped from review due to trivial changes (2)
  • packages/extra/monitoring/README.md
  • docs/oidc-grafana.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/system/monitoring/values.yaml
  • packages/extra/monitoring/values.yaml
  • packages/system/monitoring-rd/cozyrds/monitoring.yaml
  • packages/extra/monitoring/values.schema.json

Comment thread packages/core/platform/sources/monitoring-application.yaml Outdated
Comment thread packages/system/monitoring/templates/_helpers.tpl Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/core/platform/sources/monitoring-application.yaml (1)

21-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider YAML anchors to prevent libraries/components drift between variants.

The libraries (Lines 21-23, 56-58) and components (Lines 24-35, 59-70) blocks are identical between default and oidc. This is the same pattern of duplication that led to the prior dependency-gating bug (one variant edited, the other left stale). Anchoring the shared blocks removes that risk going forward (the dependsOn lists differ by one entry, so an anchor/alias doesn't cleanly apply there).

♻️ Proposed refactor using YAML anchors
   variants:
     - name: default
       dependsOn:
         - cozystack.networking
         - cozystack.postgres-operator
         - cozystack.grafana-operator
         - cozystack.victoria-metrics-operator
         # cozystack-engine ships the ApplicationDefinition CRD rendered by the *-rd component below.
         - cozystack.cozystack-engine
-      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
+      libraries: &monitoringApplicationLibraries
+        - name: cozy-lib
+          path: library/cozy-lib
+      components: &monitoringApplicationComponents
+        - 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
     ...
     - 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
+      libraries: *monitoringApplicationLibraries
+      components: *monitoringApplicationComponents
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/platform/sources/monitoring-application.yaml` around lines 21 -
70, The default and oidc variants duplicate the same libraries/components
definitions, which risks drift when one changes and the other is forgotten.
Refactor the shared `libraries` and `components` blocks in
`monitoring-application.yaml` to use YAML anchors/aliases so both variants reuse
the same `cozy-lib`, `monitoring-system`, `monitoring`, and `monitoring-rd`
definitions. Keep the `dependsOn` section separate because the `oidc` variant
uniquely includes `cozystack.keycloak-operator`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/platform/sources/monitoring-application.yaml`:
- Around line 21-70: The default and oidc variants duplicate the same
libraries/components definitions, which risks drift when one changes and the
other is forgotten. Refactor the shared `libraries` and `components` blocks in
`monitoring-application.yaml` to use YAML anchors/aliases so both variants reuse
the same `cozy-lib`, `monitoring-system`, `monitoring`, and `monitoring-rd`
definitions. Keep the `dependsOn` section separate because the `oidc` variant
uniquely includes `cozystack.keycloak-operator`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 313c0ff4-bfa4-4dac-9da4-eae08fc3401b

📥 Commits

Reviewing files that changed from the base of the PR and between 1bd3da9 and 3e8de1e.

📒 Files selected for processing (5)
  • docs/oidc-grafana.md
  • packages/core/platform/sources/monitoring-application.yaml
  • packages/core/platform/templates/bundles/system.yaml
  • packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml
  • packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/core/platform/templates/bundles/system.yaml
  • packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml

@IvanHunters
IvanHunters force-pushed the feat/monitoring-oidc branch from 3e8de1e to 4e29fed Compare July 5, 2026 05:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@packages/system/monitoring/templates/grafana/oidc-keycloak.yaml`:
- Around line 28-30: The Grafana OIDC template currently enters the System mode
path in oidc-keycloak.yaml but silently skips Keycloak resources when the
v1.edp.epam.com/v1 CRD is missing, which can leave Grafana configured without a
working login flow. Update the oidc-keycloak rendering logic around the existing
oidc.assertSystemEnabled and Capabilities.APIVersions.Has check so it either
fails explicitly when the Keycloak CRD is absent or gates the Grafana
auth.generic_oauth config on the same condition; use the oidc-keycloak template
block and the related monitoring.oidc.assertSystemEnabled helper to keep the
behavior consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1411cb23-adba-45dd-ac7e-1c545446b175

📥 Commits

Reviewing files that changed from the base of the PR and between 3e8de1e and 4e29fed.

📒 Files selected for processing (17)
  • docs/oidc-grafana.md
  • hack/e2e-apps/monitoring-oidc-customconfig.bats
  • hack/e2e-apps/monitoring-oidc-system.bats
  • packages/core/platform/sources/monitoring-application.yaml
  • packages/core/platform/templates/bundles/system.yaml
  • packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml
  • packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml
  • packages/extra/monitoring/README.md
  • packages/extra/monitoring/values.schema.json
  • packages/extra/monitoring/values.yaml
  • packages/system/monitoring-rd/cozyrds/monitoring.yaml
  • packages/system/monitoring/templates/_helpers.tpl
  • packages/system/monitoring/templates/grafana/grafana.yaml
  • packages/system/monitoring/templates/grafana/oidc-client-secret.yaml
  • packages/system/monitoring/templates/grafana/oidc-keycloak.yaml
  • packages/system/monitoring/tests/oidc_test.yaml
  • packages/system/monitoring/values.yaml
✅ Files skipped from review due to trivial changes (1)
  • packages/extra/monitoring/README.md
🚧 Files skipped from review as they are similar to previous changes (13)
  • packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml
  • packages/core/platform/sources/monitoring-application.yaml
  • packages/core/platform/templates/bundles/system.yaml
  • packages/extra/monitoring/values.schema.json
  • packages/system/monitoring/templates/grafana/grafana.yaml
  • packages/system/monitoring-rd/cozyrds/monitoring.yaml
  • packages/extra/monitoring/values.yaml
  • packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml
  • packages/system/monitoring/values.yaml
  • packages/system/monitoring/tests/oidc_test.yaml
  • hack/e2e-apps/monitoring-oidc-system.bats
  • packages/system/monitoring/templates/_helpers.tpl
  • hack/e2e-apps/monitoring-oidc-customconfig.bats

Comment thread packages/system/monitoring/templates/grafana/oidc-keycloak.yaml Outdated
@IvanHunters
IvanHunters force-pushed the feat/monitoring-oidc branch from 660a02f to f685300 Compare July 5, 2026 10:47

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Phase-1 core here is faithful to the shape settled in cozystack/community#24 and #3044: the None | System | CustomConfig selector, the per-instance confidential client with the audience mapper, direct-trust CustomConfig with cozy out of the path, the XOR and platform-flag fail-close guards, and the deferred GrafanaAdmin lever are all right, and the tests around them are solid. I'm requesting changes on one architectural block — the RBAC surface of System mode, i.e. the three chart-owned KeycloakRealmGroups and the role_attribute_path that consumes them — for three reasons that stack.

1. A Helm release must not own directory objects

Phase 1 of cozystack/community#24 landed on "couple at provisioning, decouple at ownership": a service may request identity wiring, but IdP-object lifecycle must not be tied to an app-level flag. That's why #3044 ships with zero auto-provisioned Keycloak groups — authentication comes from cozy, and authorization is a users: map reconciled into ClusterRoleBindings inside the thing being authorized.

Here, setting spec.oidc.mode: System → None (or deleting the Monitoring release) Helm-deletes three realm groups, and with them the membership someone curated out-of-band — directory state destroyed by toggling a monitoring value. Per-service groups in the platform realm are exactly the question left open at the end of the community#24 thread (the per-instance-groups lean was never resolved there). If chart-owned groups are to become a pattern, that needs to be settled in the design proposal first, not established as precedent by a monitoring PR.

2. Dash-separated group names are ambiguous by construction

#2019 moved Cozystack's RBAC resource names to the k8s-style colon scheme (cozy:tenant:admin) precisely because dash-concatenation of segments that themselves contain dashes is not injective. These groups are <namespace>-<release>-<role>, where the namespace is itself a dash-joined tenant path and the release is always monitoring-system — so collisions are constructible, not hypothetical:

  • Tenant acme opts in → its Grafana admin group is tenant-acme-monitoring-system-admin.
  • Someone creates subtenant monitoring under acme, and subtenant system under that → the tenant chart provisions realm group tenant-acme-monitoring-system-admin as that namespace's tenant-admin group.

Same flat-realm group name, two unrelated owners (two KeycloakRealmGroup CRs in different namespaces reconciling one group) and two unrelated grants (tenant-admin of tenant-acme-monitoring-system; org-Admin on acme's Grafana). Membership in one is membership in both. If groups survive point 1 at all, the naming must be collision-free by construction — these groups feed only Grafana's JMESPath, not Kubernetes RBAC, so a separator that cannot appear in a segment (the #2019 colon scheme) is available here.

3. Grafana already has the app-side authz surface — the equivalent of the CRBs in #3044

Grafana org roles are first-class, persistent, API-manageable state. The design that actually mirrors #3044 is:

  • spec.oidc.users: [{email, role: Admin | Editor | Viewer}] on the Monitoring CR;
  • a chart-owned post-install/post-upgrade hook Job reconciling that map into org membership/roles via Grafana's admin API (/api/orgs/:orgId/users), authenticated with the admin_user/admin_password Secret the chart already owns — the same vehicle and roughly the same code shape as #3044's bootstrap Job (grafana-operator has no user CRD, so the operator can't do this declaratively);
  • skip_org_role_sync = true in the generic_oauth block, so a login never overwrites app-side assignments;
  • no KeycloakRealmGroups, no role_attribute_path.

That gives: no directory objects owned by the release lifecycle, no naming collisions, the same operator UX as the Kubernetes sibling — a genuine one-mental-model story across both PRs, which is this PR's stated goal.

Related, and fixed for free by point 3: the default-Viewer fallback

monitoring.oidc.roleAttributePath ends in || 'Viewer', and role_attribute_strict is not set. The audience mapper prevents token replay, but it does not gate who can complete the login flow: by default every user of the flat cozy realm can log in against this client, and the fallback then hands each of them org-Viewer — cross-tenant read access to metrics for the entire platform directory, in every opted-in Grafana. Under the users-map approach, unmapped users simply get nothing. If the group mapping is kept, this needs role_attribute_strict = true and no fallback, at minimum.

Smaller items (only relevant if the group design stays)

  • docs/oidc-grafana.md's KeycloakRealmUser example uses group tenant-acme-monitoring-admin, but the identity unit is the inner release (always monitoring-system), so the rendered group is tenant-acme-monitoring-system-admin — an operator following the doc verbatim creates a group that maps to no role. The _helpers.tpl header example (tenant-foo/monitoring) is stale the same way; the bats were already fixed for this.
  • oidc-client-secret.yaml: index $existing.data "client-secret" | b64dec has no fallback when an existing Secret lacks the key (already flagged by CodeRabbit).

Everything outside the RBAC block — client + audience scope, secret persistence and lookup preservation, CustomConfig XOR and mount wiring, the CRD/platform-flag guards, the default/oidc variant split in the platform bundle — looks ready.

@IvanHunters
IvanHunters force-pushed the feat/monitoring-oidc branch from a48b17d to 0fd5751 Compare July 6, 2026 12:20
IvanHunters added a commit that referenced this pull request Jul 6, 2026
…p + reconcile Job

Replace the group-based authorization model on the Phase-1 Grafana
OIDC selector with an app-side users map, mirroring the tenant
kube-apiserver sibling in #3044.

What is removed
- The three chart-owned KeycloakRealmGroup objects in the cozy realm
  (`<clientId>-{admin,editor,viewer}`). A Helm release must not own
  directory objects — toggling `spec.oidc.mode: System -> None` or
  deleting the Monitoring release would Helm-delete the groups, and
  with them any membership curated out-of-band. Per-instance groups
  in the platform realm is exactly the design question left open at
  the end of cozystack/community#24; establishing it as precedent
  from a monitoring PR is out of scope.
- Dash-separated group naming (`<namespace>-<release>-<role>`) was
  also collision-prone against tenant/subtenant paths, since both
  the namespace and release names can themselves contain dashes.
- `role_attribute_path` in the Grafana `generic_oauth` block and the
  default-Viewer fallback, which handed org-Viewer to every cozy
  realm identity that logged in — cross-tenant metrics read across
  every opted-in Grafana. Also removed the `groups` client scope
  from the KeycloakClient defaults.
- `monitoring.oidc.allowAssignGrafanaAdmin` helper and the internal
  `oidc.grafanaAdmin` values key: the feature was already deferred
  and the helper's release-name-only guard was tenant-spoofable.

What replaces it
- `spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` on the
  Monitoring chart values.
- A chart-owned post-install/post-upgrade Job
  (`templates/grafana/oidc-users-job.yaml`) that reconciles that
  list into Grafana's Main Org. via the admin API: pre-provisions
  a local Grafana account per listed email so the reconcile can
  set the org role before the operator's first OIDC login, adds
  the account to the org, PATCHes the role to converge across
  re-runs, and prunes every non-admin Main-Org member whose email
  is not in the list (handles both entry removal and `mode: None`).
- `skip_org_role_sync = true` + `oauth_allow_insecure_email_lookup
  = true` in the generic_oauth block. The former stops a login from
  overwriting the Job's assignments; the latter binds the OIDC
  identity to the pre-provisioned local account.
- Job talks to Grafana over the in-cluster Service and reads
  admin creds from the chart-managed `grafana-admin-password`
  Secret via `envFrom`: no ServiceAccount / Role / RoleBinding.
  `activeDeadlineSeconds: 900` caps a stuck Job so a broken Grafana
  cannot hold the release in `pending-upgrade`.

Defensive rendering
- `.Values.oidc | default dict` in the four templates that dereference
  the `oidc` block so `oidc: ~` or an omitted block no longer nil-derefs.
- `oidc-client-secret.yaml` falls back to a fresh random when the
  existing Secret lookup succeeds but `data.client-secret` is empty.

Rationale: #3176 review from @lllamnyp
(architectural CHANGES_REQUESTED points 1, 2, 3 + the default-Viewer
leak). The three points stack: dropping the groups incidentally
fixes the naming-collision and the default-Viewer leak, and the
users-Job gives operators the same UX story as the tenant
kube-apiserver PR across both sides of the platform.

Signed-off-by: IvanHunters <[email protected]>
IvanHunters added a commit that referenced this pull request Jul 6, 2026
…p + reconcile Job

Replace the group-based authorization model on the Phase-1 Grafana
OIDC selector with an app-side users map, mirroring the tenant
kube-apiserver sibling in #3044.

What is removed
- The three chart-owned KeycloakRealmGroup objects in the cozy realm
  (`<clientId>-{admin,editor,viewer}`). A Helm release must not own
  directory objects — toggling `spec.oidc.mode: System -> None` or
  deleting the Monitoring release would Helm-delete the groups, and
  with them any membership curated out-of-band. Per-instance groups
  in the platform realm is exactly the design question left open at
  the end of cozystack/community#24; establishing it as precedent
  from a monitoring PR is out of scope.
- Dash-separated group naming (`<namespace>-<release>-<role>`) was
  also collision-prone against tenant/subtenant paths, since both
  the namespace and release names can themselves contain dashes.
- `role_attribute_path` in the Grafana `generic_oauth` block and the
  default-Viewer fallback, which handed org-Viewer to every cozy
  realm identity that logged in — cross-tenant metrics read across
  every opted-in Grafana. Also removed the `groups` client scope
  from the KeycloakClient defaults.
- `monitoring.oidc.allowAssignGrafanaAdmin` helper and the internal
  `oidc.grafanaAdmin` values key: the feature was already deferred
  and the helper's release-name-only guard was tenant-spoofable.

What replaces it
- `spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` on the
  Monitoring chart values.
- A chart-owned post-install/post-upgrade Job
  (`templates/grafana/oidc-users-job.yaml`) that reconciles that
  list into Grafana's Main Org. via the admin API: pre-provisions
  a local Grafana account per listed email so the reconcile can
  set the org role before the operator's first OIDC login, adds
  the account to the org, PATCHes the role to converge across
  re-runs, and prunes every non-admin Main-Org member whose email
  is not in the list (handles both entry removal and `mode: None`).
- `skip_org_role_sync = true` + `oauth_allow_insecure_email_lookup
  = true` in the generic_oauth block. The former stops a login from
  overwriting the Job's assignments; the latter binds the OIDC
  identity to the pre-provisioned local account.
- Job talks to Grafana over the in-cluster Service and reads
  admin creds from the chart-managed `grafana-admin-password`
  Secret via `envFrom`: no ServiceAccount / Role / RoleBinding.
  `activeDeadlineSeconds: 900` caps a stuck Job so a broken Grafana
  cannot hold the release in `pending-upgrade`.

Defensive rendering
- `.Values.oidc | default dict` in the four templates that dereference
  the `oidc` block so `oidc: ~` or an omitted block no longer nil-derefs.
- `oidc-client-secret.yaml` falls back to a fresh random when the
  existing Secret lookup succeeds but `data.client-secret` is empty.

Rationale: #3176 review from @lllamnyp
(architectural CHANGES_REQUESTED points 1, 2, 3 + the default-Viewer
leak). The three points stack: dropping the groups incidentally
fixes the naming-collision and the default-Viewer leak, and the
users-Job gives operators the same UX story as the tenant
kube-apiserver PR across both sides of the platform.

Signed-off-by: IvanHunters <[email protected]>
@IvanHunters
IvanHunters force-pushed the feat/monitoring-oidc branch from c703e5f to 83fcbca Compare July 6, 2026 17:29

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The users-map rework lands the design cleanly: the KeycloakRealmGroups and role_attribute_path are gone, System mode provisions only the client + audience scope + persisted secret, and the hook Job (pre-provision by email → add to Main Org → PATCH role → prune) is the right shape, with skip_org_role_sync and oauth_allow_insecure_email_lookup correctly chart-forced so the contract survives CustomConfig-inline overrides. The mode: None carve-out for the prune pass — protecting manually-added users on pre-OIDC installs — is exactly the right instinct. Two gaps remain, both small fixes; requesting changes because the first is the isolation property this design exists to provide.

1. allow_sign_up is missing from the chart-forced dict — unmapped users still get Viewer

docs/oidc-grafana.md now states: "Users not listed in spec.oidc.users who log in through OIDC get nothing — no default Viewer role, no cross-tenant read access." The chart doesn't render that behavior. allow_sign_up is set nowhere, and Grafana's [auth.generic_oauth] default is allow_sign_up = true; with skip_org_role_sync = true a fresh OAuth signup receives auto_assign_org_role, whose default is Viewer. So any cozy-realm identity can still complete the login flow between upgrades, get a new account with org-Viewer, and keep it until the next helm upgrade runs the prune hook.

The machinery that makes the fix safe is already in place: the Job pre-provisions a local account for every mapped user, and oauth_allow_insecure_email_lookup = true binds the OIDC identity to that account at login. That combination exists precisely so sign-up can be closed. Add allow_sign_up: "false" to $chartForcedOidc in templates/grafana/grafana.yaml, and the docs sentence becomes true: unmapped users are rejected at the door instead of admitted as Viewer and reaped later.

2. The prune pass with an empty users list reaps the whole org — including in secretRef mode, where the chart promises to be hands-off

oidc-users-job.yaml renders whenever mode != None, and the prune removes every non-admin Main-Org member whose email isn't in the desired list. But assertCustomSecretRefUsersEmpty forbids users under customConfig.secretRef — so in that mode the desired list is always empty, and every upgrade deletes every non-admin org member. That contradicts both "the operator's mounted auth.ini is authoritative" and the assert's own error message, which tells the operator to "unset users and manage authorization inside the ini fragment yourself" — the Job then deletes the members they manage that way. The same wipe happens in System or CustomConfig-inline with users simply unset.

The mode: None guard shows this failure class was seen — it just needs to cover the other empty-list doors too. Suggestion: don't render the Job when users is empty (this covers secretRef automatically, since users can't be set there), and state in the docs that a non-empty users map means the chart owns Main-Org membership — anything added by hand gets pruned on the next reconcile. The trade-off (removing the last users entry no longer prunes it) is worth a sentence in the docs; going users: [...] → users: [] → cleanup-by-hand matches the already-documented System → None posture.

Minor

  • With fix 2 in place, consider forcing oauth_allow_insecure_email_lookup (and allow_sign_up: "false" from fix 1) only when users is non-empty. A CustomConfig-inline operator who doesn't use the users map at all currently gets insecure email lookup forced on with nothing to bind — combined with a BYO IdP that doesn't verify emails, that's a needless account-linking surface in their instance.
  • The E2E failure on the current head is the etcd test (etcd-operator learner-promotion flake inherited from the rebase), not this PR's suites — both monitoring-oidc-* bats passed.

Everything else from the previous round is closed: the client-secret lookup is key-guarded, the nil-safe $oidc navigation landed, and the docs/test counts moved with the design.

IvanHunters added a commit that referenced this pull request Jul 8, 2026
…p + reconcile Job

Replace the group-based authorization model on the Phase-1 Grafana
OIDC selector with an app-side users map, mirroring the tenant
kube-apiserver sibling in #3044.

What is removed
- The three chart-owned KeycloakRealmGroup objects in the cozy realm
  (`<clientId>-{admin,editor,viewer}`). A Helm release must not own
  directory objects — toggling `spec.oidc.mode: System -> None` or
  deleting the Monitoring release would Helm-delete the groups, and
  with them any membership curated out-of-band. Per-instance groups
  in the platform realm is exactly the design question left open at
  the end of cozystack/community#24; establishing it as precedent
  from a monitoring PR is out of scope.
- Dash-separated group naming (`<namespace>-<release>-<role>`) was
  also collision-prone against tenant/subtenant paths, since both
  the namespace and release names can themselves contain dashes.
- `role_attribute_path` in the Grafana `generic_oauth` block and the
  default-Viewer fallback, which handed org-Viewer to every cozy
  realm identity that logged in — cross-tenant metrics read across
  every opted-in Grafana. Also removed the `groups` client scope
  from the KeycloakClient defaults.
- `monitoring.oidc.allowAssignGrafanaAdmin` helper and the internal
  `oidc.grafanaAdmin` values key: the feature was already deferred
  and the helper's release-name-only guard was tenant-spoofable.

What replaces it
- `spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` on the
  Monitoring chart values.
- A chart-owned post-install/post-upgrade Job
  (`templates/grafana/oidc-users-job.yaml`) that reconciles that
  list into Grafana's Main Org. via the admin API: pre-provisions
  a local Grafana account per listed email so the reconcile can
  set the org role before the operator's first OIDC login, adds
  the account to the org, PATCHes the role to converge across
  re-runs, and prunes every non-admin Main-Org member whose email
  is not in the list (handles both entry removal and `mode: None`).
- `skip_org_role_sync = true` + `oauth_allow_insecure_email_lookup
  = true` in the generic_oauth block. The former stops a login from
  overwriting the Job's assignments; the latter binds the OIDC
  identity to the pre-provisioned local account.
- Job talks to Grafana over the in-cluster Service and reads
  admin creds from the chart-managed `grafana-admin-password`
  Secret via `envFrom`: no ServiceAccount / Role / RoleBinding.
  `activeDeadlineSeconds: 900` caps a stuck Job so a broken Grafana
  cannot hold the release in `pending-upgrade`.

Defensive rendering
- `.Values.oidc | default dict` in the four templates that dereference
  the `oidc` block so `oidc: ~` or an omitted block no longer nil-derefs.
- `oidc-client-secret.yaml` falls back to a fresh random when the
  existing Secret lookup succeeds but `data.client-secret` is empty.

Rationale: #3176 review from @lllamnyp
(architectural CHANGES_REQUESTED points 1, 2, 3 + the default-Viewer
leak). The three points stack: dropping the groups incidentally
fixes the naming-collision and the default-Viewer leak, and the
users-Job gives operators the same UX story as the tenant
kube-apiserver PR across both sides of the platform.

Signed-off-by: IvanHunters <[email protected]>
Extends the tenant-facing `Monitoring` CR with the new
`spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` field
consumed by the users-Job introduced in the previous commit. Works
for both `System` and `CustomConfig` modes; ignored when
`mode: None`.

Regenerated `packages/extra/monitoring/values.schema.json`,
`README.md`, and the mirrored `openAPISchema` on the
`monitoring-rd` ApplicationDefinition through the standard
`make generate` (cozyvalues-gen + hack/update-crd.sh) pipeline.
Also refreshed the stale annotation comment on the CR that still
claimed the chart provisions three KeycloakRealmGroups.

Signed-off-by: IvanHunters <[email protected]>
…roups

Update the oidc-variant comment on
`packages/core/platform/sources/monitoring-application.yaml` so it
no longer claims `spec.oidc.mode: System` renders three
KeycloakRealmGroups. The dependsOn edge and the fail-close guard
in `templates/grafana/oidc-keycloak.yaml` stay unchanged: the
KeycloakClient + KeycloakClientScope path still needs the
`v1.edp.epam.com` CRDs from `cozystack.keycloak-operator` to be
registered before the monitoring chart reconciles.

Signed-off-by: IvanHunters <[email protected]>
Rewrites the helm-unittest coverage for the Phase-1 OIDC selector
against the new chart contract:

- Drop assertions for the three KeycloakRealmGroups and for
  `role_attribute_path` / `allow_assign_grafana_admin` / the
  `oidc.grafanaAdmin` values lever — none of those render anymore.
- Assert the System-mode Grafana config carries
  `skip_org_role_sync = "true"` and
  `oauth_allow_insecure_email_lookup = "true"` and NO
  `role_attribute_path`.
- Cover the new users-Job template: helm-hook annotations, absence
  of a ServiceAccount, `activeDeadlineSeconds`, admin-credentials
  wiring from the chart-managed `grafana-admin-password` Secret,
  and correct serialisation of `spec.oidc.users` into the
  `DESIRED_USERS_JSON` env.
- Assert the users-Job also renders under `mode: None` so a
  System -> None flip still gets a prune pass.
- Cover the defensive `.Values.oidc | default dict` paths — omitted
  block and explicit `oidc: ~` — across the three OIDC templates.
- Tighten the client-secret regex to the actual base64-encoded
  length (`^[A-Za-z0-9+/]{43}=$`) so shortened random secrets are
  caught.

32 tests, all green under helm-unittest v1.0.3.

Signed-off-by: IvanHunters <[email protected]>
Aligns the render-side e2e bats with the users-map contract:

- Feed a `spec.oidc.users:` entry to each mode's Monitoring CR so
  the reconcile Job has real input to act on.
- Assert the users-Job appears in the release namespace with the
  desired `DESIRED_USERS_JSON` env and the expected `OIDC_MODE`.
- Assert the Grafana CR renders with `skip_org_role_sync = "true"`
  and `oauth_allow_insecure_email_lookup = "true"` and NO
  `role_attribute_path`.
- Flip the three KeycloakRealmGroup existence assertions to
  negative checks: chart-owned realm groups must NOT be present
  in either System or CustomConfig mode.

Does not add a full OIDC login flow through Keycloak — that stays
deferred to a follow-up integration suite, same posture as the
tenant kube-apiserver's kubernetes-oidc-system.bats.

Signed-off-by: IvanHunters <[email protected]>
…te-path design

Rewrite `docs/oidc-grafana.md` for the new authorization model:

- Selector example carries a `users:` map.
- System-mode "what the chart provisions" list drops the three
  KeycloakRealmGroups + role_attribute_path, adds the audience
  scope-only KeycloakClient defaults, and adds the users-Job step
  with `skip_org_role_sync` + `oauth_allow_insecure_email_lookup`
  wiring notes.
- Fresh "Users and RBAC" section describes the users-Job reconcile
  contract: pre-provision -> add-to-org -> PATCH role -> prune.
  Explains the "user not in list -> gets nothing" semantics
  (replaces the default-Viewer fallback).
- CustomConfig section explicitly notes that `role_attribute_path`
  in the operator's payload is now dead config: the chart forces
  `skip_org_role_sync = true` regardless of mode.
- "Failure modes" gains "user logs in but sees no dashboards"
  (missing from `users:`) and a users-Job troubleshooting entry.
- Drop the `GrafanaAdmin` deferral notes and the "Mode toggle
  destroys chart-owned KeycloakRealmGroup membership" failure
  mode — both moot now that the groups are gone.
- Removed the stale `KeycloakRealmUser` example that used
  `tenant-acme-monitoring-admin` (the chart never rendered a
  group by that name — the inner release is `monitoring-system`
  and the example was a live footgun).

Signed-off-by: IvanHunters <[email protected]>
…ry OIDC mode

The chart previously injected the two settings the users-Job's
contract depends on — `skip_org_role_sync = true` and
`oauth_allow_insecure_email_lookup = true` — only inside the
`mode: System` branch of `grafana.yaml`. `values.yaml` and
`docs/oidc-grafana.md` both promised the settings would be forced
regardless of mode, but under `mode: CustomConfig` the operator's
inline `auth.generic_oauth` map was written verbatim, both
settings were absent, and the users-Job's role assignments were
silently stranded (no lookup by email → new Grafana account minted
at login) or transiently overwritten (no skip_org_role_sync → any
`role_attribute_path` or Grafana's `auto_assign_org_role: Viewer`
overwrote the Job's role on each login).

Fix: merge a `$chartForcedOidc` dict on top of both the generated
System block and the operator's inline map (`merge` wins for
overlapping keys, so the operator can NOT override the two
settings). CustomConfig secretRef mode cannot receive the merge
because the ini fragment is authoritative — the chart now hard-fails
render when both `customConfig.secretRef.name` and non-empty
`spec.oidc.users` are set, forcing the operator to either switch to
inline config or manage authorization themselves inside the ini.

Also:
- Align the inner HR `timeout` on `packages/extra/monitoring/templates/helmrelease.yaml`
  from 10m to 15m so it matches the outer cozyrds
  `release.cozystack.io/helm-install-timeout: "15m"` and does not
  fail while the users-Job is still inside its
  `activeDeadlineSeconds: 900`.
- Route the users-Job image through `cozy-lib.image` so
  mirrored / air-gapped installations resolve it (matches the
  pattern in `packages/apps/mariadb/templates/hooks/cleanup-pvc.yaml`).
- Add `spec.ttlSecondsAfterFinished: 3600` on the users-Job so a
  succeeded Job + Pod are garbage-collected on the next reconcile
  interval rather than lingering until the next hook activation
  (which never fires on release uninstall).
- Harden the users-Job shell:
    * emails read NUL-terminated (`jq --raw-output0`) so an address
      containing whitespace cannot corrupt the `for email in ...`
      loop's field splitting;
    * `/api/users/lookup?loginOrEmail=<email>` URL-encoded via
      `jq @uri` so `&`/`=`/`?` in the local part parse correctly on
      the Grafana side;
    * prune loop switched from pipe-delimited fields to TSV inside
      NUL-terminated records, with `cut -f` for extraction.
- Add a template-level assertion (`monitoring.oidc.assertUsersEmailShape`)
  rejecting emails that do not match a conservative regex —
  cheaper failure mode than a 400 from Grafana.

Signed-off-by: IvanHunters <[email protected]>
…unittest

Two blockers on the previous review cycle were "code says X but no
test enforces X" — so future edits to `grafana.yaml` could silently
break the contract again. Add three assertions that fail loudly if
the merge behavior regresses:

- `mode=CustomConfig inline preserves the operator's map + chart-forced
  settings` — asserts `skip_org_role_sync=true` and
  `oauth_allow_insecure_email_lookup=true` are both present alongside
  the operator's `client_id` / `client_secret` / `auth_url`.
- `mode=CustomConfig inline — operator cannot override chart-forced
  OIDC settings` — sets both settings to "false" in the operator's
  map; asserts they render as "true" (proves `merge` argument order
  is correct).
- `mode=CustomConfig secretRef + non-empty users fails the render
  (contract incompat)` — asserts the new
  `monitoring.oidc.assertCustomSecretRefUsersEmpty` helper's error
  message fires so operators cannot silently strand their users-Job
  assignments by combining incompatible fields.
- `mode=CustomConfig secretRef + empty users renders successfully`
  — the compatible path.

Signed-off-by: IvanHunters <[email protected]>
Previous docs claimed "the chart forces skip_org_role_sync = true
regardless of mode" — a promise the code did not keep in
CustomConfig mode. The chart is now fixed; sync the docs so the
guarantee reads truthfully and the incompatibility surface is
explicit:

- `docs/oidc-grafana.md`: rewrite the CustomConfig section so both
  chart-forced settings are named explicitly, the `merge` semantics
  are documented (operator can't override), and the
  `secretRef` + `users` incompatibility is called out with an
  explicit failure path.
- `system/monitoring/values.yaml`: expand the `config`/`secretRef`
  comments so operators reading the values doc see the merge behavior
  and the secretRef limitation before they hit the render-time fail.
  Add `enabled: "true"` to the "typical keys" list — Grafana defaults
  `enabled: false` and an inline map without it is inert.
- `extra/monitoring`: regenerated values.schema.json + README.md via
  `make generate` (cozyvalues-gen + hack/update-crd.sh) to pick up
  the `oidc.users[].email` doc-string touch; mirrored openAPISchema
  on the ApplicationDefinition follows.

Signed-off-by: IvanHunters <[email protected]>
…chema

The `OIDCUser` typedef in `packages/extra/monitoring/values.yaml`
declared only `@field {string} email`; `cozyvalues-gen` regenerated
`values.schema.json`, `README.md`, and the mirrored `openAPISchema`
on the `Monitoring` ApplicationDefinition WITHOUT a `role` field.
Consequences:

- The `@enum OIDCRole` declaration was orphan — referenced from
  nowhere, so the schema's items object had `required: ["email"]`
  and one property.
- A dashboard-driven CR would offer a form without any role picker;
  a schema-conformant `{email}` submission would render into the
  users-Job with `role: null`, `jq --raw-output` yields the string
  `"null"`, the `POST /api/orgs/1/users` body carries
  `"role":"null"`, Grafana returns HTTP 400, and the post-install
  hook exhausts `backoffLimit: 6` — HelmRelease never becomes Ready.
- Unit + e2e tests all hardcoded `role: Admin` / `role: Viewer`, so
  the contract mismatch never surfaced in CI.

Fix:

- Add `@field {OIDCRole} role - ...` to the `OIDCUser` typedef in
  `packages/extra/monitoring/values.yaml` so cozyvalues-gen materialises
  the field with enum + required.
- Regenerate `values.schema.json`, `README.md`, and mirrored
  `openAPISchema` via `make generate`.
- Add `monitoring.oidc.assertUsersRoleShape` template-level guard
  (same shape as `assertUsersEmailShape`) so a stale-schema CR or a
  direct-helm invocation fails at render time with a legible error
  instead of at hook execution with a Grafana 400.
- Consolidate all `spec.oidc.users` guards into a single
  `monitoring.oidc.assertUsersPrologue` include and call it once from
  `oidc-users-job.yaml`, ordering the incompat check
  (`assertCustomSecretRefUsersEmpty`) before the per-entry role /
  email regexes so operators see the more actionable message first.
  Drop the duplicate include from `grafana.yaml` (Job template renders
  in every mode anyway).
- Two new helm-unittest cases lock the regression:
  `oidc.users entry missing role fails the render` and
  `oidc.users entry with unknown role fails the render`.
- Point the previous `secretRef + users` incompat unit test at the
  users-Job template since that's where the prologue now fires.
- `docs/oidc-grafana.md`: correct the failed-hook post-mortem window
  claim — with `ttlSecondsAfterFinished: 3600` the Kubernetes TTL
  controller reaps the Job + Pod 1h after the terminal condition,
  not "until the next helm-upgrade".

Signed-off-by: IvanHunters <[email protected]>
The failed-hook post-mortem paragraph in the users-Job troubleshooting
section still said the Job "stays around until the next helm-upgrade
for post-mortem", but the shipped Job spec carries
`ttlSecondsAfterFinished: 3600` — the Kubernetes TTL controller reaps
the Job + Pod one hour after the terminal Failed condition regardless
of when the next `helm upgrade` lands. Reword to name the 1h window
explicitly and instruct the operator to re-trigger the hook if they
missed the window.

The edit was authored alongside commit 1f66456 but slipped out of
that commit's file list; this is the cleanup.

Signed-off-by: IvanHunters <[email protected]>
…CR existence

CI run 28794778739 caught two race conditions in the render-side
Monitoring OIDC bats:

- `Grafana CR carries auth.generic_oauth pointing at the cozy realm`
  (monitoring-oidc-system.bats:86) failed with `enabled=` empty after
  a 0-second wait. The prior `until kubectl get grafana grafana`
  only proved the CR EXISTS — it did not prove grafana-operator had
  reconciled the mode=System values onto it. When the CR from the
  previous `Monitoring CR accepts spec.oidc.mode=System` test was
  already there but the operator hadn't yet flipped `.spec.config.auth
  .generic_oauth.enabled` to "true", the jsonpath returned empty and
  `[ "" = "true" ]` short-circuited the whole test.
- `secretRef variant mounts operator Secret under /etc/grafana/oidc`
  (monitoring-oidc-customconfig.bats:119) timed out at 180s waiting
  for the volumeMount swap from inline → ini. On a warm sandbox the
  full inner-HR upgrade + grafana-operator reconcile cycle can
  legitimately take >3m — the timeout was too tight.

Fix: replace `until kubectl get grafana` with `until [ "$(kubectl
... jsonpath=<target-field>)" = <expected> ]` on every subsequent
assertion, and bump the secretRef-swap window to 600s (well inside
the inner HR's 15m timeout). Same pattern used for the "no
role_attribute_path" and "operator payload verbatim" assertions so
the whole suite is race-free.

Signed-off-by: IvanHunters <[email protected]>
Caught by dev3 end-to-end test on the PR working tree
(cozystack-pr-test skill): the users-Job was rendered in every mode
including `None` on the rationale that a `mode: System → None`
switch should garbage-collect OIDC-provisioned local accounts. That
rationale ignored the upgrade path from the pre-PR chart: an
existing tenant that never had `spec.oidc` on their Monitoring CR
would receive `mode: None` on first render under the PR, the Job
would fire on every Flux reconcile of the outer HR, and its prune
pass would DELETE every non-admin Main-Org member — including users
the tenant added by hand through the Grafana UI on the pre-PR chart.
Silent breaking change with data loss.

Wrap the whole Job resource in `{{- if ne $mode "None" }} … {{- end }}`
so `mode: None` = "chart owns nothing in Grafana orgs, we don't
touch what's there". Operators who need to prune OIDC-provisioned
accounts after switching System | CustomConfig → None do it
themselves through the Grafana UI or admin API — same posture as
"we don't own the Keycloak directory objects" from the earlier
architectural change.

The `assertUsersPrologue` include stays outside the `if` so an
operator listing users in `mode: None` still gets the schema and
email-shape validation at render time — the Job body just doesn't
emit.

Test coverage:
- Flip the previous "mode=None still renders the users-Job so a
  mode switch can prune stale members" case to
  `mode=None does NOT render the users-Job (upgrade safety guard)`
  as a regression lock.
- Add `mode=None with non-empty users still fails render` covering
  the prologue-only-fires-on-schema-issue path.

Docs: rewrite the "Users and RBAC" section step 3 to name the
upgrade-safety motivation and the manual-cleanup handoff.

Signed-off-by: IvanHunters <[email protected]>
…pat)

CI E2E caught the Job container crash-looping within 20s of the
first Start event. Root cause: the `alpine/k8s:1.33.4` image is
Alpine-based (BusyBox coreutils + BusyBox grep), and the Job shell
used four GNU-specific constructs that BusyBox does not implement:

- `head --bytes=N` (BusyBox `head` only accepts `-c N`).
- `tr --delete` (BusyBox `tr` only accepts `-d`).
- `jq --raw-output0` (NUL-terminated output — jq 1.7+ feature; also
  paired with `read -r -d ''` which is a bash-ism, not POSIX `sh`
  and not honoured by BusyBox `ash`).
- `grep --null-data --fixed-strings --line-regexp` (BusyBox `grep`
  supports `-F` and `-x` short forms but NOT `-z`/`--null-data`).

Rewrite:

- `head -c 24 /dev/urandom | base64 | tr -d '=+/' | head -c 32` —
  same random-32-char password, portable to BusyBox.
- Emit desired emails and org membership one record per line (jq
  `--raw-output`) instead of NUL-terminated. Safe because the
  template-level `monitoring.oidc.assertUsersEmailShape` guard
  already rejects any email containing whitespace / control
  characters, so line-oriented iteration cannot get confused.
- Read TSV membership records with `IFS="$(printf '\t')"` (POSIX-
  portable TAB) + plain `read -r uid ulogin uemail` — no need for
  `-d ''` once records are newline-terminated.
- Match desired list with `grep -Fxq -e "$uemail" -e "$ulogin"` —
  short-form flags supported by both GNU and BusyBox `grep`.

Syntax validated with both `sh -n` and `bash -n` on the extracted
inline script. helm-unittest 34/34 still green (render shape is
unchanged, only the shell literal is different).

Signed-off-by: IvanHunters <[email protected]>
…st Job existence

CI E2E showed the users-Job assertion returning `DESIRED_USERS_JSON:
[]` — but the CR body had `users: [{email: [email protected],
role: Admin}]`. Root cause: `hook-delete-policy: before-hook-creation`
keeps the previous hook Job around across helm upgrades, so `until
kubectl get job` matches a stale Job from the previous customconfig-
mode test where users may have been unset (the secretRef swap CR had
no `users:` field, so the schema default `[]` propagated to the
prior Job's DESIRED_USERS_JSON). The System test then grabbed that
stale Job before the helm upgrade for the new CR completed and swapped
in the fresh Job.

Fix: poll on the target env value itself — the new Job with the
correct users list is the first Job whose DESIRED_USERS_JSON string
contains `[email protected]`. 600s window matches the inner HR's
15m timeout with room for a slow reconcile.

Signed-off-by: IvanHunters <[email protected]>
… users-map

Address PR#3176 review round-2 gap-1: the docs promise "users not in
spec.oidc.users get nothing", but Grafana's default allow_sign_up=true
combined with the chart-forced skip_org_role_sync=true was minting a
Viewer account for every cozy-realm identity that completed the OAuth
flow, then reaping it on the next helm-upgrade prune pass.

The chart-forced isolation dict (now three keys: skip_org_role_sync,
oauth_allow_insecure_email_lookup, allow_sign_up=false) is applied only
when spec.oidc.users is non-empty. A BYO-IdP operator who does not use
the users-map gets none of the three forced on top of their inline
config, matching the review's minor: forcing insecure_email_lookup
against an issuer that may not verify emails is a needless account-
linking surface, and allow_sign_up=false with no pre-provisioned
accounts would lock everyone out.

Tests split the two branches: users-active asserts all three keys are
present and the operator cannot override them via CustomConfig-inline;
users-empty asserts the auth.generic_oauth block is emitted without
any of the isolation keys.

Signed-off-by: IvanHunters <[email protected]>
Address PR#3176 review round-2 gap-2: the users-Job's prune pass deletes
every non-admin Main-Org member whose email is not in the desired list.
When the desired list is empty every helm-upgrade wiped the whole org.

Three empty-users states used to fall into this trap:

* customConfig.secretRef: users[] is forbidden by
  assertCustomSecretRefUsersEmpty, so the desired list was
  structurally always empty and the chart wiped an org whose auth.ini
  fragment the operator advertised as authoritative.
* System with users[] unset: the operator opted into OIDC but not
  into the chart-managed users-map, yet the Job still pruned every
  Main-Org member on their behalf.
* CustomConfig-inline with users[] unset: same shape as System.

The mode=None guard (from an earlier round) already had the same
shape; extend the render gate to cover the other empty-list doors —
now the Job renders only when `mode != None AND users non-empty`.
The trade-off: taking `users:` back to `[]` no longer prunes the last
entry — matching the already-documented `System → None` posture —
called out explicitly in the docs.

Signed-off-by: IvanHunters <[email protected]>
…p gate

Round-3 review from lllamnyp: any user in the shared `cozy` Keycloak
realm could sign into any tenant's Grafana. The audience mapper stops
replay of a minted token across releases, but does not gate who may
mint one — tenant-alice could complete the OAuth flow against
tenant-bob's Grafana. With `spec.oidc.users` non-empty the earlier
`allow_sign_up=false` fix mostly closed the door (though tenant-alice
still hit `error 401` after landing in Grafana, revealing the release
exists); with empty users under mode: System — a config the schema
permits and the docs describe as hands-off — the login succeeded and
minted a Viewer account for every cozy-realm identity.

The fix uses the KeycloakRealmGroups the tenant chart already
provisions (`<namespace>-{view,use,admin,super-admin}` in
packages/apps/tenant/templates/keycloakgroups.yaml) — no new
directory objects owned by the Monitoring release. Two chart edits:

1. oidc-keycloak.yaml: add the platform-managed realm-default `groups`
   scope to `defaultClientScopes` explicitly. EDP's KeycloakClient
   does NOT merge realm-level defaults into `defaultClientScopes`, so
   the scope must be listed here or the `groups` claim would be
   missing from the token.

2. grafana.yaml: in System mode, unconditionally emit
   `allowed_groups: <ns>-view <ns>-use <ns>-admin <ns>-super-admin`
   in `auth.generic_oauth` and add `groups` to `scopes`. Grafana
   rejects any login whose token does not carry at least one of the
   groups.

The gate is chart-owned and does not depend on the users-map — with
`users: []` tenant members still get a self-service Viewer through
`auto_assign_org_role`, cross-tenant users get 401 in Keycloak's OAuth
callback. Docs updated to reflect the two-layered contract
(tenant-membership gate + optional users-map).

Signed-off-by: IvanHunters <[email protected]>
… CEL claim gate

Symmetric with the Grafana-side allowed_groups gate — round-3 review
from lllamnyp on PR#3176 covered both surfaces, and #3044's original
System-mode wiring shipped without this guard. The rationale is
identical: audience binding stops REPLAY of a minted token across
tenant kube-apiservers, but nothing stopped Keycloak from ISSUING a
usable token to a caller outside the tenant.

Absent a claim-side gate, any authenticated cozy-realm user could
`kubectl oidc-login` against another tenant's cluster and land at
`system:authenticated`. RBAC default-denies named-resource access, but
`system:authenticated` still leaks discovery (kubectl api-resources,
OpenAPI schemata) — tenant-alice could enumerate tenant-bob's cluster's
CRD shape and built-in resource surface.

Fix: add a CEL `claimValidationRules` entry to the rendered
AuthenticationConfiguration (System mode only) that requires at least
one of the release's four tenant-scoped Keycloak groups
(`<namespace>-{view,use,admin,super-admin}`) to be present in the
token's `groups` claim. The `has(claims.groups) &&` guard is required
— CEL evaluation on a missing claim surfaces as HTTP 500 from the
authenticator instead of a clean 401.

CustomConfig mode is not touched — the tenant's own
AuthenticationConfiguration is authoritative and the tenant is
responsible for claim-side guards on their own issuer.

Signed-off-by: IvanHunters <[email protected]>
…ctually works on Grafana v11.x

Verified end-to-end on dev3 with Grafana v11.6.15: OAuth flow reaches
Grafana, id_token and userinfo response both carry the correct
`groups` claim (`[cozystack-cluster-admin, tenant-root-super-admin]`
for kvaps), `allowed_groups` in grafana.ini lists
`tenant-root-super-admin`, and login still fails with
`user not a member of one of the required groups`.

Grafana debug log shows `Attributes: map[]` — the extracted user's
Groups slice is empty. On v11.x+ Grafana does not auto-map the
`groups` claim onto user.Groups unless `groups_attribute_path` is
set explicitly (the field is documented as optional but the gate is
a silent no-op without it — a claim-name-string is not enough).

Fix: hard-wire `groups_attribute_path=groups` in the chart-forced
System-mode block alongside `allowed_groups`. "groups" points at
the top-level `groups` array from userinfo, matching the shape
Keycloak's oidc-group-membership-mapper emits (verified on the same
dev3 run).

Two helm-unittest cases updated to assert `groups_attribute_path`
lives next to `allowed_groups` in both the users-map-active and
empty-users branches (rejecting the silent no-op regression). Docs
also updated to name the requirement.

Signed-off-by: Ivan Okhotnikov <[email protected]>
… tenant apiserver boots

The System-mode AuthenticationConfiguration rendered a claimValidationRule
ranging over claims.groups, which is statically typed `any` in the apiserver
CEL environment. The .exists() comprehension rejects a range of type any, so
the tenant kube-apiserver failed config compilation at startup and CrashLooped
— the control plane never booted and cluster teardown then hung the e2e
cleanup budget. Wrap the range in dyn() so CEL treats it as dynamic; the has()
guard still short-circuits when groups is absent and the gate semantics are
unchanged. Strengthen the oidc unit test to pin dyn() (mutation-verified).
Verified on a live tenant apiserver (v1.32).

Signed-off-by: Ivan Okhotnikov <[email protected]>
@IvanHunters
IvanHunters force-pushed the feat/monitoring-oidc branch from 0680946 to 5d13c15 Compare July 15, 2026 12:31
@IvanHunters
IvanHunters merged commit 3fb8ec5 into main Jul 15, 2026
18 checks passed
@IvanHunters
IvanHunters deleted the feat/monitoring-oidc branch July 15, 2026 15:17
IvanHunters added a commit to cozystack/website that referenced this pull request Jul 16, 2026
Reconcile the guide with cozystack/cozystack#3176 as merged:

- Drop the spec.oidc.grafanaAdmin paragraph: the field does not exist
  (oidc struct is mode/customConfig/users only) and server-level
  GrafanaAdmin promotion is out of scope for Phase 1; list it under
  What's out of scope instead.
- Fix the mode-toggle note: the users-Job renders only when mode is not
  None and users[] is non-empty, so flipping to None prunes nothing and
  the provisioned accounts survive.
- Clarify that removing a user is an org-membership removal, not deletion
  of the Grafana account.
- Clarify that OIDC identifiers derive from the inner <CR-name>-system
  HelmRelease, so the example clientId tenant-acme-monitoring-system is
  correct.
- Add ini language to the two config fenced blocks and replace Unicode
  ellipsis with ASCII.

Signed-off-by: IvanHunters <[email protected]>
IvanHunters added a commit to cozystack/website that referenced this pull request Jul 17, 2026
Reconcile the guide with cozystack/cozystack#3176 as merged:

- Drop the spec.oidc.grafanaAdmin paragraph: the field does not exist
  (oidc struct is mode/customConfig/users only) and server-level
  GrafanaAdmin promotion is out of scope for Phase 1; list it under
  What's out of scope instead.
- Fix the mode-toggle note: the users-Job renders only when mode is not
  None and users[] is non-empty, so flipping to None prunes nothing and
  the provisioned accounts survive.
- Clarify that removing a user is an org-membership removal, not deletion
  of the Grafana account.
- Clarify that OIDC identifiers derive from the inner <CR-name>-system
  HelmRelease, so the example clientId tenant-acme-monitoring-system is
  correct.
- Add ini language to the two config fenced blocks and replace Unicode
  ellipsis with ASCII.

Signed-off-by: IvanHunters <[email protected]>
IvanHunters added a commit to cozystack/website that referenced this pull request Jul 17, 2026
Reconcile the guide with the merged chart (cozystack/cozystack#3176):

- KeycloakRealmUser example: use spec.realmRef {name: keycloakrealm-cozy,
  kind: ClusterKeycloakRealm} instead of the non-existent spec.realm, and
  passwordSecret instead of the deprecated inline password.
- CustomConfig inline: add enabled: "true" to the example and state that
  System wires it automatically while CustomConfig does not.
- Correct the inline-merge direction: chart-forced settings win over the
  operator's map (Helm merge keeps the chart's values).
- Fix the release-naming model: the OIDC-carrying release is monitoring-system
  in every namespace; instances differ by namespace, not release name.
- Drop CEL claimValidationRules mentions: Grafana generic_oauth has no such
  option; enforce email_verified on the Keycloak side instead.
- Note tls_client_ca is required for a self-signed BYO CA, and that
  spec.config["auth.generic_oauth"] is a literal ini-section key.

Signed-off-by: IvanHunters <[email protected]>
IvanHunters added a commit to cozystack/website that referenced this pull request Jul 22, 2026
## Summary

Adds a new guide under
`content/en/docs/next/operations/services/monitoring/`: **OIDC
authentication for Grafana**.

Documents how to enable per-user OIDC authentication on the Grafana
instance shipped by every `Monitoring` release, using the new
`spec.oidc` selector. Ships alongside
[cozystack/cozystack#3176](cozystack/cozystack#3176),
the code PR that adds the selector to the chart. The identity model is
per-instance audience isolation on the flat `cozy` realm — same shape as
the tenant kube-apiserver's Phase 1
([cozystack/cozystack#3044](cozystack/cozystack#3044));
architectural rationale in
[cozystack/community#24](cozystack/community#24).

## What

- New page
`content/en/docs/next/operations/services/monitoring/oidc-authentication.md`
(`weight: 5`, positioned between `setup.md` (2) and `dashboards.md`
(10)).
- Covers all three modes (`None`, `System`, `CustomConfig`), the three
role-mapping `KeycloakRealmGroup`s the chart owns, how a user is added
via `KeycloakRealmUser`, browser login flow, and gotchas:
`emailVerified` prescriptive requirement, self-signed CA under
`CustomConfig.secretRef`, `admin_user` break-glass posture.
- Explicit "out of scope" section (per-tenant realms,
backend-logout-url, CEL `claimValidationRules`) so readers know where
the boundary sits.
- Only adds to `docs/next/`; version-specific pages (`v1.5`, ...) get
the guide when the code PR lands in a release.

## Why

- The `spec.oidc` selector on the `Monitoring` CR is a new user-facing
surface — needs a user-facing guide before v1.6.
- Uses the same tone and section shape as the tenant Kubernetes OIDC
page (`content/en/docs/next/kubernetes/oidc-authentication.md`,
#596), so operators land on a familiar layout.
- Site builds cleanly in dev mode (Hugo, 1686+ pages, zero warnings);
the page is reachable at
`/docs/next/operations/services/monitoring/oidc-authentication/`.


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

* **Documentation**
* Added a comprehensive OIDC authentication guide for Monitoring
Grafana, including per-instance audience isolation to prevent token
reuse across instances.
* Documented OIDC modes (`None`, `System`, `CustomConfig`), required
Grafana `auth.generic_oauth` settings, and group-based access control
via namespace-scoped authorization with `groups_attribute_path`.
* Explained `System` Keycloak artifact provisioning and automated
user/role syncing, plus the forced Grafana behaviors when OIDC users are
configured.
* Detailed `CustomConfig` validation/precedence, key gotchas (e.g.,
`emailVerified`), CA/tls guidance, and how switching modes affects user
reconciliation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
myasnikovdaniil added a commit that referenced this pull request Aug 15, 2026
hack/e2e-apps/monitoring-oidc-system.bats and its customconfig twin
have never executed. #2826 deleted the `test-apps-%:` rule that ran
hack/e2e-apps/$*.bats along with every other file in that directory;
#3176 landed three days later and added these two into it. Git raises
no conflict when one branch empties a directory and another adds files
to it, so the only signal was a directory name that still looked live.

They could not pass as written either. hack/cozytest.sh defines no
skip(), so the `skip` guarding the Keycloak assertions is a
command-not-found and exit 127, and `kubectl api-resources
--api-group=v1.edp.epam.com` exits 0 whether or not the group is
served, so the guard was dead code in both directions. cac07db found
and dropped both when it ported the kubernetes-oidc twins out of the
same race.

The render-side coverage is superseded, strictly, by
packages/system/monitoring/tests/oidc_test.yaml -- 32 helm-unittest
cases over the same Phase-1 selector, several of them asserting more
than a live test can. `spec.public: false` is the example: the EDP CRD
strips the field off the applied object, which the deleted bats said
so itself.

Four comments described hack/e2e-apps/ as a live directory; they now
describe the shape rather than the instance, and two of them say what
an e2e- prefix does not mean, since arming the cluster captures was
never a claim that a runner exists.

Signed-off-by: Myasnikov Daniil <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/monitoring Issues or PRs related to the monitoring stack (vlogs, vmstack, grafana, workloadmonitor) kind/api-change Categorizes issue or PR as related to adding, removing, or otherwise changing an API kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants