From e12bb1a3ba51f168bbe9412cf658f45714abf895 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Wed, 1 Jul 2026 23:55:57 +0300 Subject: [PATCH 01/39] feat(monitoring): scaffold OIDC selector on the Grafana instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the plumbing needed for a Phase-1 OIDC integration on Grafana, matching the shape of cozystack/cozystack#3044 for the tenant kube-apiserver: - values.yaml grows a top-level oidc block with a three-value mode enum (None/System/CustomConfig) and a customConfig payload with the XOR-inline/secretRef surface. - monitoring-rd advertises the new selector via its openAPISchema and keysOrder so operators see it on the Monitoring CR. - templates/_helpers.tpl introduces the naming helpers reused by every downstream OIDC template: clientId, audienceScopeName, clientSecretName, grafanaHost, redirectUri, systemIssuerURL, roleAttributePath, allowAssignGrafanaAdmin (platform-only), assertSystemEnabled, assertCustomConfigXor. No behaviour change on default values — mode: None short-circuits and no OIDC objects are rendered. Signed-off-by: Ivan Okhotnikov --- .../monitoring-rd/cozyrds/monitoring.yaml | 4 +- .../system/monitoring/templates/_helpers.tpl | 99 +++++++++++++++++++ packages/system/monitoring/values.yaml | 38 +++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 packages/system/monitoring/templates/_helpers.tpl diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index ce4d75c5d..8e6b442a2 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -8,7 +8,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md.","type":"object","default":{},"properties":{"mode":{"description":"Identity mode: None (no OIDC), System (platform cozy realm via a per-instance confidential client), or CustomConfig (BYO issuer, tenant-supplied).","type":"string","enum":["None","System","CustomConfig"],"default":"None"},"customConfig":{"description":"Tenant-supplied auth.generic_oauth payload; consumed only when mode is CustomConfig. Exactly one of `config` or `secretRef.name` must be set.","type":"object","default":{},"properties":{"config":{"description":"Inline map of grafana.ini [auth.generic_oauth] keys. The chart writes it verbatim into the Grafana CR spec.config. Mutually exclusive with `secretRef.name`.","type":"object","default":{},"additionalProperties":{"type":"string"}},"secretRef":{"description":"Reference to a Secret carrying a ready-made [auth.generic_oauth] ini fragment in the `auth.ini` key. Mutually exclusive with `config`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace.","type":"string","default":""}}}}}}}}} release: prefix: "" labels: @@ -26,7 +26,7 @@ spec: description: Monitoring and observability stack module: true icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzI2OCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zMjY4KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMzg0NTRGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNNTQuMTcyMiAzOC43NzU3SDMzLjEwMzJDMzIuMTMwNyAzOC43NzU3IDMxLjQ4MjQgMzguMTI3NCAzMS40ODI0IDM3LjE1NDlDMzEuNDgyNCAzNi4xODI0IDMyLjEzMDcgMzUuNTM0MiAzMy4xMDMyIDM1LjUzNDJINTQuMTcyMkM1NS4xNDQ3IDM1LjUzNDIgNTUuNzkzIDM2LjE4MjQgNTUuNzkzIDM3LjE1NDlDNTUuNzkyOCAzOC4xMjc0IDU1LjE0NDUgMzguNzc1NyA1NC4xNzIyIDM4Ljc3NTdaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik02My44OTYzIDQ1LjI1OTFINDEuMjA2N0M0MC4yMzQyIDQ1LjI1OTEgMzkuNTg1OSA0NC42MTA4IDM5LjU4NTkgNDMuNjM4M0MzOS41ODU5IDQyLjY2NTggNDAuMjM0MiA0Mi4wMTc2IDQxLjIwNjcgNDIuMDE3Nkg2My44OTYzQzY0Ljg2ODggNDIuMDE3NiA2NS41MTcxIDQyLjY2NTggNjUuNTE3MSA0My42MzgzQzY1LjUxNzEgNDQuNjEwOCA2NC44Njg4IDQ1LjI1OTEgNjMuODk2MyA0NS4yNTkxWiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMzQuNzI0IDQ1LjI1OTFIMzMuMTAzMkMzMi4xMzA3IDQ1LjI1OTEgMzEuNDgyNCA0NC42MTA4IDMxLjQ4MjQgNDMuNjM4M0MzMS40ODI0IDQyLjY2NTggMzIuMTMwNyA0Mi4wMTc2IDMzLjEwMzIgNDIuMDE3NkgzNC43MjRDMzUuNjk2NCA0Mi4wMTc2IDM2LjM0NDcgNDIuNjY1OCAzNi4zNDQ3IDQzLjYzODNDMzYuMzQ0NyA0NC42MTA4IDM1LjY5NjMgNDUuMjU5MSAzNC43MjQgNDUuMjU5MVoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgMzguNzc1N0g2MC42NTQ5QzU5LjY4MjQgMzguNzc1NyA1OS4wMzQyIDM4LjEyNzQgNTkuMDM0MiAzNy4xNTQ5QzU5LjAzNDIgMzYuMTgyNCA1OS42ODI0IDM1LjUzNDIgNjAuNjU0OSAzNS41MzQySDYzLjg5NjNDNjQuODY4OCAzNS41MzQyIDY1LjUxNzEgMzYuMTgyNCA2NS41MTcxIDM3LjE1NDlDNjUuNTE3MSAzOC4xMjc0IDY0Ljg2ODggMzguNzc1NyA2My44OTYzIDM4Ljc3NTdaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik00Ny42ODkzIDUxLjc0MTNIMzMuMTAzMkMzMi4xMzA3IDUxLjc0MTMgMzEuNDgyNCA1MS4wOTMxIDMxLjQ4MjQgNTAuMTIwNkMzMS40ODI0IDQ5LjE0ODEgMzIuMTMwNyA0OC41IDMzLjEwMzIgNDguNUg0Ny42ODkzQzQ4LjY2MTggNDguNSA0OS4zMTAxIDQ5LjE0ODMgNDkuMzEwMSA1MC4xMjA4QzQ5LjMxMDEgNTEuMDkzMyA0OC42NjE4IDUxLjc0MTMgNDcuNjg5MyA1MS43NDEzWiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNjMuODk2OCA1MS43NDEzSDU0LjE3MjVDNTMuMiA1MS43NDEzIDUyLjU1MTggNTEuMDkzMSA1Mi41NTE4IDUwLjEyMDZDNTIuNTUxOCA0OS4xNDgxIDUzLjIwMDIgNDguNSA1NC4xNzI3IDQ4LjVINjMuODk2OUM2NC44Njk0IDQ4LjUgNjUuNTE3NyA0OS4xNDgzIDY1LjUxNzcgNTAuMTIwOEM2NS41MTc3IDUxLjA5MzMgNjQuODY5MiA1MS43NDEzIDYzLjg5NjggNTEuNzQxM1oiIGZpbGw9IiNFQ0JBMTYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNTguMjI0SDMzLjEwMzJDMzIuMTMwNyA1OC4yMjQgMzEuNDgyNCA1Ny41NzU3IDMxLjQ4MjQgNTYuNjAzMkMzMS40ODI0IDU1LjYzMDcgMzIuMTMwNyA1NC45ODI0IDMzLjEwMzIgNTQuOTgyNEg1NC4xNzIyQzU1LjE0NDcgNTQuOTgyNCA1NS43OTMgNTUuNjMwNyA1NS43OTMgNTYuNjAzMkM1NS43OTMgNTcuNTc1NyA1NS4xNDQ1IDU4LjIyNCA1NC4xNzIyIDU4LjIyNFoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgNjQuNzA3NEg0MS4yMDY3QzQwLjIzNDIgNjQuNzA3NCAzOS41ODU5IDY0LjA1OTEgMzkuNTg1OSA2My4wODY2QzM5LjU4NTkgNjIuMTE0MSA0MC4yMzQyIDYxLjQ2NTggNDEuMjA2NyA2MS40NjU4SDYzLjg5NjNDNjQuODY4OCA2MS40NjU4IDY1LjUxNzEgNjIuMTE0MSA2NS41MTcxIDYzLjA4NjZDNjUuNTE3MSA2NC4wNTkxIDY0Ljg2ODggNjQuNzA3NCA2My44OTYzIDY0LjcwNzRaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik0zNC43MjQgNjQuNzA3NEgzMy4xMDMyQzMyLjEzMDcgNjQuNzA3NCAzMS40ODI0IDY0LjA1OTEgMzEuNDgyNCA2My4wODY2QzMxLjQ4MjQgNjIuMTE0MSAzMi4xMzA3IDYxLjQ2NTggMzMuMTAzMiA2MS40NjU4SDM0LjcyNEMzNS42OTY0IDYxLjQ2NTggMzYuMzQ0NyA2Mi4xMTQxIDM2LjM0NDcgNjMuMDg2NkMzNi4zNDQ3IDY0LjA1OTEgMzUuNjk2MyA2NC43MDc0IDM0LjcyNCA2NC43MDc0WiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNDcuNjg5MyA3MS4xODk4SDMzLjEwMzJDMzIuMTMwNyA3MS4xODk4IDMxLjQ4MjQgNzAuNTQxNSAzMS40ODI0IDY5LjU2OUMzMS40ODI0IDY4LjU5NjUgMzIuMTMwNyA2Ny45NDgyIDMzLjEwMzIgNjcuOTQ4Mkg0Ny42ODkzQzQ4LjY2MTggNjcuOTQ4MiA0OS4zMTAxIDY4LjU5NjUgNDkuMzEwMSA2OS41NjlDNDkuMzEwMSA3MC41NDE1IDQ4LjY2MTggNzEuMTg5OCA0Ny42ODkzIDcxLjE4OThaIiBmaWxsPSIjNDJCMDVDIi8+CjxwYXRoIGQ9Ik02My44OTY4IDcxLjE4OThINTQuMTcyNUM1My4yIDcxLjE4OTggNTIuNTUxOCA3MC41NDE1IDUyLjU1MTggNjkuNTY5QzUyLjU1MTggNjguNTk2NSA1My4yIDY3Ljk0ODIgNTQuMTcyNSA2Ny45NDgySDYzLjg5NjhDNjQuODY5MiA2Ny45NDgyIDY1LjUxNzUgNjguNTk2NSA2NS41MTc1IDY5LjU2OUM2NS41MTc1IDcwLjU0MTUgNjQuODY5MiA3MS4xODk4IDYzLjg5NjggNzEuMTg5OFoiIGZpbGw9IiM3MzgzQkYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNzcuNjcyMkgzMy4xMDMyQzMyLjEzMDcgNzcuNjcyMiAzMS40ODI0IDc3LjAyMzkgMzEuNDgyNCA3Ni4wNTE0QzMxLjQ4MjQgNzUuMDc4OSAzMi4xMzA3IDc0LjQzMDcgMzMuMTAzMiA3NC40MzA3SDU0LjE3MjJDNTUuMTQ0NyA3NC40MzA3IDU1Ljc5MyA3NS4wNzg5IDU1Ljc5MyA3Ni4wNTE0QzU1Ljc5MjggNzcuMDIzOSA1NS4xNDQ1IDc3LjY3MjIgNTQuMTcyMiA3Ny42NzIyWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNNjMuODk2MyA3Ny42NzIySDYwLjY1NDlDNTkuNjgyNCA3Ny42NzIyIDU5LjAzNDIgNzcuMDIzOSA1OS4wMzQyIDc2LjA1MTRDNTkuMDM0MiA3NS4wNzg5IDU5LjY4MjQgNzQuNDMwNyA2MC42NTQ5IDc0LjQzMDdINjMuODk2M0M2NC44Njg4IDc0LjQzMDcgNjUuNTE3MSA3NS4wNzg5IDY1LjUxNzEgNzYuMDUxNEM2NS41MTcxIDc3LjAyMzkgNjQuODY4OCA3Ny42NzIyIDYzLjg5NjMgNzcuNjcyMloiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTEwMS4xNzIgNzcuNjcyMkg4MC4xMDMyQzc5LjEzMDcgNzcuNjcyMiA3OC40ODI0IDc3LjAyMzkgNzguNDgyNCA3Ni4wNTE0Qzc4LjQ4MjQgNzUuMDc4OSA3OS4xMzA3IDc0LjQzMDcgODAuMTAzMiA3NC40MzA3SDEwMS4xNzJDMTAyLjE0NSA3NC40MzA3IDEwMi43OTMgNzUuMDc4OSAxMDIuNzkzIDc2LjA1MTRDMTAyLjc5MyA3Ny4wMjM5IDEwMi4xNDUgNzcuNjcyMiAxMDEuMTcyIDc3LjY3MjJaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik0xMTAuODk2IDc3LjY3MjJIMTA3LjY1NUMxMDYuNjgyIDc3LjY3MjIgMTA2LjAzNCA3Ny4wMjM5IDEwNi4wMzQgNzYuMDUxNEMxMDYuMDM0IDc1LjA3ODkgMTA2LjY4MiA3NC40MzA3IDEwNy42NTUgNzQuNDMwN0gxMTAuODk2QzExMS44NjkgNzQuNDMwNyAxMTIuNTE3IDc1LjA3ODkgMTEyLjUxNyA3Ni4wNTE0QzExMi41MTcgNzcuMDIzOSAxMTEuODY5IDc3LjY3MjIgMTEwLjg5NiA3Ny42NzIyWiIgZmlsbD0iIzQyQjA1QyIvPgo8cGF0aCBkPSJNNjMuODk2MyA1OC4yMjRINjAuNjU0OUM1OS42ODI0IDU4LjIyNCA1OS4wMzQyIDU3LjU3NTcgNTkuMDM0MiA1Ni42MDMyQzU5LjAzNDIgNTUuNjMwNyA1OS42ODI0IDU0Ljk4MjQgNjAuNjU0OSA1NC45ODI0SDYzLjg5NjNDNjQuODY4OCA1NC45ODI0IDY1LjUxNzEgNTUuNjMwNyA2NS41MTcxIDU2LjYwMzJDNjUuNTE3MSA1Ny41NzU3IDY0Ljg2ODggNTguMjI0IDYzLjg5NjMgNTguMjI0WiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMTEyLjUxNyA1MS43NDExQzExMi41MTcgNjAuNjU0OSAxMDUuMjI0IDY3Ljk0OCA5Ni4zMTA0IDY3Ljk0OEM4Ny4zOTY2IDY3Ljk0OCA4MC4xMDM1IDYwLjY1NDkgODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNDIuODI3MyA4Ny4zOTY2IDM1LjUzNDIgOTYuMzEwNCAzNS41MzQyQzEwNS4yMjQgMzUuNTM0MiAxMTIuNTE3IDQyLjgyNzMgMTEyLjUxNyA1MS43NDExWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNTIuMjI3MyA4MC4xMDM1IDUyLjg3NTUgODAuMTAzNSA1My4zNjE5SDk2LjMxMDRWMzUuNTM0MkM4Ny4zOTY2IDM1LjUzNDIgODAuMTAzNSA0Mi44MjczIDgwLjEwMzUgNTEuNzQxMVoiIGZpbGw9IiM0MkIwNUMiLz4KPC9nPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4N18zMjY4IiB4MT0iMS4yMzIzOWUtMDYiIHkxPSItOS41MDAwMSIgeDI9IjE2OCIgeTI9IjE2MiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjOEZEREZGIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzAwNzVGRiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzY4N18zMjY4Ij4KPHJlY3Qgd2lkdGg9Ijk0IiBoZWlnaHQ9Ijk0IiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUgMjUpIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg== - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"]] secrets: exclude: [] include: diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl new file mode 100644 index 000000000..ba9a208b1 --- /dev/null +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -0,0 +1,99 @@ +{{- /* + Helpers for the Grafana OIDC integration. + + Naming — the identity unit is the Monitoring release, so the clientId + and the associated groups are `-` prefixed. That + gives: + tenant-foo/monitoring (per-tenant Grafana in tenant-foo ns) + cozy-monitoring/monitoring-system (platform Grafana in cozy-monitoring ns) + A token minted for one instance is rejected by the other's Grafana + because the per-cluster audience scope binds `id_token.aud` to the + clientId — same isolation primitive as the tenant kube-apiserver PR + (cozystack/cozystack#3044). +*/}} + +{{- define "monitoring.oidc.clientId" -}} +{{- printf "%s-%s" .Release.Namespace .Release.Name -}} +{{- end -}} + +{{- define "monitoring.oidc.audienceScopeName" -}} +{{- printf "%s-%s-audience" .Release.Namespace .Release.Name -}} +{{- end -}} + +{{- define "monitoring.oidc.clientSecretName" -}} +{{- printf "%s-oidc-client" .Release.Name -}} +{{- end -}} + +{{- define "monitoring.oidc.grafanaHost" -}} +{{- $namespaceHost := .Values._namespace.host -}} +{{- printf "grafana.%s" (.Values.host | default $namespaceHost) -}} +{{- end -}} + +{{- define "monitoring.oidc.redirectUri" -}} +{{- printf "https://%s/login/generic_oauth" (include "monitoring.oidc.grafanaHost" .) -}} +{{- end -}} + +{{- define "monitoring.oidc.systemIssuerURL" -}} +{{- $host := index .Values._cluster "root-host" -}} +{{- printf "https://keycloak.%s/realms/cozy" $host -}} +{{- end -}} + +{{- /* + Grafana `role_attribute_path` is a JMESPath expression evaluated on + the `groups` claim. We emit a chain that maps membership in the + per-instance -admin / -editor / -viewer groups to Grafana's built-in + Admin / Editor / Viewer roles, defaulting to Viewer for authenticated + identities with none of the three groups. Group naming mirrors + clientId so a Keycloak operator provisions three groups per release. +*/}} +{{- define "monitoring.oidc.roleAttributePath" -}} +{{- $admin := printf "%s-%s-admin" .Release.Namespace .Release.Name -}} +{{- $editor := printf "%s-%s-editor" .Release.Namespace .Release.Name -}} +{{- $viewer := printf "%s-%s-viewer" .Release.Namespace .Release.Name -}} +{{- printf "contains(groups[*], '%s') && 'Admin' || contains(groups[*], '%s') && 'Editor' || contains(groups[*], '%s') && 'Viewer' || 'Viewer'" $admin $editor $viewer -}} +{{- 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 eq .Release.Name "monitoring-system" -}} +true +{{- else -}} +false +{{- end -}} +{{- end -}} + +{{- /* + Fail-fast when `mode: System` is requested without the platform-level + OIDC feature being on. Mirrors the identical guard from the tenant + kube-apiserver chart — see the note there for the reasoning. +*/}} +{{- define "monitoring.oidc.assertSystemEnabled" -}} +{{- $oidcEnabled := dig "oidc-enabled" "" (.Values._cluster | default dict) -}} +{{- if ne ($oidcEnabled | toString) "true" -}} +{{- fail "spec.oidc.mode: System requires the platform-level OIDC feature (authentication.oidc.enabled) — enable it in cozystack-values, or use mode: CustomConfig for a tenant-supplied issuer." -}} +{{- end -}} +{{- end -}} + +{{- /* + Fail-fast when the CustomConfig branch has neither or both payloads + set — mutually exclusive. +*/}} +{{- 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 -}} diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index 3b632cbe2..ca3c7464a 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -87,3 +87,41 @@ vmagent: ## - job_name: "custom" ## static_configs: ## - targets: ["my-service:9090"] + +## OIDC authentication for the Grafana instance. +## +## Identity mode picks the auth source for `[auth.generic_oauth]`: +## None no OIDC; login form + admin_user/admin_password Secret +## stay the only auth path (default). +## System trust the platform `cozy` Keycloak realm via a +## per-instance confidential client and audience binding. +## Requires the platform-level authentication.oidc.enabled +## flag; the chart hard-fails the render otherwise. +## CustomConfig trust a tenant-supplied issuer directly (BYO IdP). +## Exactly one of `customConfig.config` (inline) or +## `customConfig.secretRef.name` (existing Secret) must be +## set — the chart fails the render otherwise. `cozy` is +## not in the path in this mode. +## +## The admin_user / admin_password Secret stays a documented break-glass +## path regardless of mode. +oidc: + mode: "None" + ## CustomConfig payload — consumed only when `mode: CustomConfig`. + ## Exactly one of `config` or `secretRef.name` MUST be set. + customConfig: + ## Inline map of grafana.ini `[auth.generic_oauth]` keys. Any key + ## accepted by Grafana's generic_oauth provider is valid; typical + ## keys are `client_id`, `client_secret`, `auth_url`, `token_url`, + ## `api_url`, `scopes`, `role_attribute_path`, + ## `allow_assign_grafana_admin`. The chart writes the map verbatim + ## into `spec.config.auth.generic_oauth` on the Grafana CR. + ## Mutually exclusive with `customConfig.secretRef.name`. + config: {} + ## Reference to an operator-managed Secret carrying a ready-made + ## `[auth.generic_oauth]` ini fragment in the `auth.ini` key. The + ## chart mounts it and Grafana loads it via + ## `GF_PATHS_CUSTOM_INI`. Mutually exclusive with + ## `customConfig.config`. + secretRef: + name: "" From 9dd9f1d4dde2c0eba19373070f6e493ce7e3a724 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Wed, 1 Jul 2026 23:57:50 +0300 Subject: [PATCH 02/39] feat(monitoring): render Keycloak client + scope + groups for System mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New oidc-keycloak.yaml template mirrors the tenant kube-apiserver Phase-1 shape (cozystack/cozystack#3044) with three Grafana-specific adjustments: - Client is confidential (public: false, directAccess: false); Grafana is server-side so a client_secret is required. The value is wired via the EDP-Keycloak `$:` reference; the backing Secret is created by the next commit. - redirectUri is https://grafana./login/generic_oauth — the Grafana generic_oauth callback path. - Three KeycloakRealmGroups (`-{admin,editor,viewer}`) are chart-owned. Membership is set by an operator through the Keycloak UI or a KeycloakRealmUser CR; grafana.yaml will map group membership to Admin / Editor / Viewer via role_attribute_path. Guarded by mode == System AND _cluster.oidc-enabled == true (via the assertSystemEnabled helper). Renders nothing for None or CustomConfig. Signed-off-by: Ivan Okhotnikov --- .../templates/grafana/oidc-keycloak.yaml | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/system/monitoring/templates/grafana/oidc-keycloak.yaml diff --git a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml new file mode 100644 index 000000000..d0f3fc36d --- /dev/null +++ b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml @@ -0,0 +1,119 @@ +{{- /* + Phase 1 — System mode: render one per-instance Keycloak client, a + per-instance audience client scope, and the three role-mapping + groups (`-admin` / `-editor` / `-viewer`) inside the platform `cozy` + realm. + + The clientId is namespace-prefixed so it is globally unique across + the realm; the audience scope binds `id_token.aud` to that clientId + so a token minted for one Monitoring instance is rejected by + another's Grafana — the per-instance isolation primitive in the + absence of per-tenant realms. + + Nothing is rendered for `mode: CustomConfig` or `mode: None`: in + those modes `cozy` is not in the path. + + The `_cluster.oidc-enabled` guard catches the case where an + operator toggles `mode: System` without the platform-level OIDC + feature (authentication.oidc.enabled) being on — the Keycloak + operator CRDs would not be present and the chart would silently + no-op without the fail-fast. + + The three KeycloakRealmGroup objects are chart-owned but their + membership is not — a Keycloak operator adds users to them via the + Keycloak UI or via a KeycloakRealmUser CR. Grafana's + `role_attribute_path` (see grafana.yaml) evaluates group membership + on login and assigns Admin / Editor / Viewer accordingly. +*/}} +{{- if eq .Values.oidc.mode "System" }} +{{- include "monitoring.oidc.assertSystemEnabled" . }} +{{- if .Capabilities.APIVersions.Has "v1.edp.epam.com/v1" }} +{{- $clientId := include "monitoring.oidc.clientId" . }} +{{- $scopeName := include "monitoring.oidc.audienceScopeName" . }} +{{- $clientSecretName := include "monitoring.oidc.clientSecretName" . }} +{{- $grafanaHost := include "monitoring.oidc.grafanaHost" . }} +--- +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakClientScope +metadata: + name: {{ $scopeName }} + namespace: {{ .Release.Namespace }} +spec: + name: {{ $scopeName }} + realmRef: + name: keycloakrealm-cozy + kind: ClusterKeycloakRealm + description: "Per-instance audience for Grafana {{ .Release.Name }}" + protocol: openid-connect + default: false + attributes: + "include.in.token.scope": "true" + protocolMappers: + - name: audience + protocol: openid-connect + protocolMapper: "oidc-audience-mapper" + config: + "included.client.audience": {{ $clientId | quote }} + "id.token.claim": "true" + "access.token.claim": "true" + "introspection.token.claim": "true" + "lightweight.claim": "false" +--- +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakClient +metadata: + name: {{ $clientId }} + namespace: {{ .Release.Namespace }} +spec: + clientId: {{ $clientId }} + name: {{ $clientId }} + realmRef: + name: keycloakrealm-cozy + kind: ClusterKeycloakRealm + public: false + directAccess: false + advancedProtocolMappers: true + secret: "${{ $clientSecretName }}:client-secret" + webUrl: "https://{{ $grafanaHost }}" + redirectUris: + - "https://{{ $grafanaHost }}/login/generic_oauth" + defaultClientScopes: + - groups + - {{ $scopeName }} + attributes: + "post.logout.redirect.uris": "+" +--- +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakRealmGroup +metadata: + name: {{ printf "%s-admin" $clientId }} + namespace: {{ .Release.Namespace }} +spec: + name: {{ printf "%s-admin" $clientId }} + realmRef: + name: keycloakrealm-cozy + kind: ClusterKeycloakRealm +--- +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakRealmGroup +metadata: + name: {{ printf "%s-editor" $clientId }} + namespace: {{ .Release.Namespace }} +spec: + name: {{ printf "%s-editor" $clientId }} + realmRef: + name: keycloakrealm-cozy + kind: ClusterKeycloakRealm +--- +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakRealmGroup +metadata: + name: {{ printf "%s-viewer" $clientId }} + namespace: {{ .Release.Namespace }} +spec: + name: {{ printf "%s-viewer" $clientId }} + realmRef: + name: keycloakrealm-cozy + kind: ClusterKeycloakRealm +{{- end }} +{{- end }} From 473b112f660d5aeb6a3718d07e58a287782ea257 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Wed, 1 Jul 2026 23:58:34 +0300 Subject: [PATCH 03/39] feat(monitoring): persist confidential OIDC client secret for System mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New oidc-client-secret.yaml renders a Kubernetes Secret with the confidential client_secret used by both the EDP-Keycloak operator (via \$:client-secret from oidc-keycloak.yaml) and the Grafana pod (via GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET, wired by the next commit). Value is generated at first install (randAlphaNum 32) and preserved across upgrades through the same \`lookup\` pattern used by packages/system/dashboard. secret-hash annotation surfaces the value change to whichever controller is watching, ensuring KeycloakClient + Grafana Deployment re-reconcile if it ever rotates. Rendered only for mode: System — CustomConfig operators bring their own credentials (inline plaintext or a Secret they manage out-of-band). Signed-off-by: Ivan Okhotnikov --- .../templates/grafana/oidc-client-secret.yaml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/system/monitoring/templates/grafana/oidc-client-secret.yaml diff --git a/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml new file mode 100644 index 000000000..29f764133 --- /dev/null +++ b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml @@ -0,0 +1,43 @@ +{{- /* + Persistent Secret carrying the confidential OIDC client secret. + + Consumed by two parties: + 1. The EDP-Keycloak operator, referenced from oidc-keycloak.yaml + via the `$:client-secret` syntax on `spec.secret`. + 2. The Grafana pod, referenced from grafana.yaml as + `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env — Grafana's `.ini` + supports `${VAR}` interpolation. + + Random on first install, preserved on upgrades via `lookup` (same + pattern as packages/system/dashboard/templates/keycloakclient.yaml). + Rotating the value requires deleting the Secret and re-running the + release. + + Rendered only for System mode. In CustomConfig mode the operator + supplies their own credentials — either inline in + spec.oidc.customConfig.config (plaintext) or through + spec.oidc.customConfig.secretRef pointing at their own Secret. +*/}} +{{- 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 }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + namespace: {{ .Release.Namespace }} + annotations: + # Rotates the KeycloakClient CR — and therefore the Grafana + # Deployment env — whenever the client-secret changes. + secret-hash: {{ $clientSecret | sha256sum }} +type: Opaque +data: + client-secret: {{ $clientSecret | b64enc }} +{{- end }} From 8eff03b323da5006946d7b7d4da136c07f638b27 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Thu, 2 Jul 2026 00:01:08 +0300 Subject: [PATCH 04/39] feat(monitoring): wire auth.generic_oauth into the Grafana CR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch the Grafana CR so that the oidc.mode selector drives the actual Grafana runtime config: - mode: System — emit a full auth.generic_oauth section under spec.config with client_id / client_secret / auth_url / token_url / api_url / scopes / role_attribute_path / allow_assign_grafana_admin built from the helpers, and inject GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET as an env from the persistent Secret introduced in the previous commit. role_attribute_path maps membership in the per-instance -admin / -editor / -viewer groups to Grafana's Admin / Editor / Viewer roles, defaulting to Viewer on authenticated identities with none of them. allow_assign_grafana_admin is set to true only for the platform release (Release.Name == 'monitoring-system'); tenant Grafana stays at org-level Admin. - mode: CustomConfig (inline) — pass the operator-supplied config map verbatim to spec.config.auth.generic_oauth. `cozy` is not in the path. - mode: CustomConfig (secretRef) — mount the operator-managed Secret under /etc/grafana/oidc and load it via GF_PATHS_CUSTOM_INI. The ini fragment is authoritative; the chart does not emit auth.generic_oauth under spec.config in this branch. - mode: None (default) — no change to the rendered manifests. CustomConfig branch fails the render at the XOR helper if both or neither of `config` / `secretRef.name` are set. `disable_login_form` stays false so the admin_user/admin_password Secret remains a documented break-glass path in every mode. Signed-off-by: Ivan Okhotnikov --- .../monitoring/templates/grafana/grafana.yaml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 2f5a85e07..64f6f5819 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -4,6 +4,28 @@ {{- $host := .Values._namespace.host }} {{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }} {{- $wildcardSecret := (index .Values._cluster "wildcard-secret-name") | default "" }} + +{{- /* + OIDC (see values.yaml — oidc.mode). + System → chart-owned Keycloak client + client-secret + role + mapping via role_attribute_path. + CustomConfig → operator-supplied auth.generic_oauth payload — either + inline as a map (customConfig.config), or as a Secret + with a ready-made ini fragment + (customConfig.secretRef.name). XOR-guarded via the + assertCustomConfigXor helper. + None → no auth.generic_oauth section, no OIDC env — the + admin_user/admin_password Secret remains the only auth + path. +*/}} +{{- $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 "") }} --- apiVersion: grafana.integreatly.org/v1beta1 kind: Grafana @@ -31,6 +53,24 @@ spec: admin_password: ${GF_PASSWORD} plugins: allow_loading_unsigned_plugins: "victoriametrics-logs-datasource" + {{- if eq $oidcMode "System" }} + auth.generic_oauth: + enabled: "true" + name: "Keycloak" + client_id: {{ include "monitoring.oidc.clientId" . | quote }} + client_secret: "${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}" + auth_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/auth" + token_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/token" + api_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/userinfo" + scopes: "openid email profile groups {{ include "monitoring.oidc.audienceScopeName" . }}" + role_attribute_path: {{ include "monitoring.oidc.roleAttributePath" . | quote }} + allow_assign_grafana_admin: {{ include "monitoring.oidc.allowAssignGrafanaAdmin" . | quote }} + email_attribute_path: "email" + auto_login: "false" + {{- else if $useCustomInline }} + auth.generic_oauth: + {{- toYaml $customInline | nindent 6 }} + {{- end }} deployment: spec: replicas: 2 @@ -72,6 +112,29 @@ spec: secretKeyRef: key: password name: grafana-admin-password + {{- if eq $oidcMode "System" }} + - name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "monitoring.oidc.clientSecretName" . }} + key: client-secret + {{- end }} + {{- if $useCustomSecretRef }} + - name: GF_PATHS_CUSTOM_INI + value: /etc/grafana/oidc/auth.ini + {{- end }} + {{- if $useCustomSecretRef }} + volumeMounts: + - name: oidc-custom-ini + mountPath: /etc/grafana/oidc + readOnly: true + {{- end }} + {{- if $useCustomSecretRef }} + volumes: + - name: oidc-custom-ini + secret: + secretName: {{ $customSecretRefName }} + {{- end }} {{- if ne $gatewayEnabled "true" }} ingress: {{- /* Operator wildcard mode: nginx serves the default SSL cert, so skip per-host ACME. */}} From 1f44bc06cf6a7e88009328d7f7a34a0f22cc597c Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Thu, 2 Jul 2026 00:07:50 +0300 Subject: [PATCH 05/39] test(monitoring): helm-unittest coverage for the OIDC selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 cases across the three modes plus the two fail-close guards: - mode: None emits nothing OIDC-related — no cozy-realm objects, no client-secret Secret, no auth.generic_oauth key on the Grafana CR, no GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET / GF_PATHS_CUSTOM_INI env. - mode: System renders KeycloakClient + KeycloakClientScope + three KeycloakRealmGroups + persistent client-secret Secret, and pins the Grafana spec.config to the cozy realm with a role_attribute_path that maps the -admin/-editor/-viewer groups to Grafana roles. - mode: System fails the render when the platform-level OIDC feature is off, via the assertSystemEnabled helper. - mode: CustomConfig inline writes the operator-supplied map verbatim into spec.config.auth.generic_oauth without touching cozy. - mode: CustomConfig secretRef mounts the operator Secret under /etc/grafana/oidc + wires GF_PATHS_CUSTOM_INI, and does NOT emit auth.generic_oauth under spec.config (the ini fragment is authoritative). - CustomConfig branch fails-close on both/neither of the two payloads via the assertCustomConfigXor helper. - Platform release (Release.Name == monitoring-system) upgrades the Admin role to server-level GrafanaAdmin through the allow_assign_grafana_admin flag; tenant Grafana stays at org-level Admin. Signed-off-by: Ivan Okhotnikov --- .../system/monitoring/tests/oidc_test.yaml | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 packages/system/monitoring/tests/oidc_test.yaml diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml new file mode 100644 index 000000000..97ef38110 --- /dev/null +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -0,0 +1,334 @@ +suite: OIDC selector on the Grafana instance (Phase 1) +templates: + - templates/grafana/oidc-keycloak.yaml + - templates/grafana/oidc-client-secret.yaml + - templates/grafana/grafana.yaml + +release: + name: monitoring + namespace: tenant-root + +# Baseline `_cluster` / `_namespace` values wired by cozystack-basics +# at install time. Individual tests override `oidc.*` and re-set the +# oidc-enabled gate as needed. +set: + _cluster: + root-host: example.org + _namespace: + host: tenant-root.example.org + ingress: tenant-root + +capabilities: + apiVersions: + - v1.edp.epam.com/v1 + +tests: + ########################################################## + # mode: None — nothing OIDC-related is emitted + ########################################################## + + - it: mode=None emits no KeycloakClient / KeycloakClientScope / KeycloakRealmGroup + template: templates/grafana/oidc-keycloak.yaml + asserts: + - hasDocuments: + count: 0 + + - it: mode=None emits no client-secret Secret + template: templates/grafana/oidc-client-secret.yaml + asserts: + - hasDocuments: + count: 0 + + - it: mode=None grafana.yaml carries no auth.generic_oauth and no OIDC env + template: templates/grafana/grafana.yaml + asserts: + - notExists: + path: spec.config["auth.generic_oauth"] + - notContains: + path: spec.deployment.spec.template.spec.containers[0].env + content: + name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + - notContains: + path: spec.deployment.spec.template.spec.containers[0].env + content: + name: GF_PATHS_CUSTOM_INI + + ########################################################## + # mode: System — happy path + ########################################################## + + - it: mode=System renders KeycloakClient + KeycloakClientScope + 3 KeycloakRealmGroups + template: templates/grafana/oidc-keycloak.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - hasDocuments: + count: 5 + - equal: + path: kind + value: KeycloakClientScope + documentIndex: 0 + - equal: + path: kind + value: KeycloakClient + documentIndex: 1 + - equal: + path: kind + value: KeycloakRealmGroup + documentIndex: 2 + - equal: + path: metadata.name + value: tenant-root-monitoring-admin + documentIndex: 2 + - equal: + path: metadata.name + value: tenant-root-monitoring-editor + documentIndex: 3 + - equal: + path: metadata.name + value: tenant-root-monitoring-viewer + documentIndex: 4 + + - it: mode=System KeycloakClient is confidential and points to the persistent Secret + template: templates/grafana/oidc-keycloak.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: spec.public + value: false + documentIndex: 1 + - equal: + path: spec.secret + value: "$monitoring-oidc-client:client-secret" + documentIndex: 1 + - equal: + path: spec.redirectUris[0] + value: "https://grafana.tenant-root.example.org/login/generic_oauth" + documentIndex: 1 + - contains: + path: spec.defaultClientScopes + content: tenant-root-monitoring-audience + documentIndex: 1 + + - it: mode=System audience scope pins id_token.aud to the per-instance clientId + template: templates/grafana/oidc-keycloak.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: metadata.name + value: tenant-root-monitoring-audience + documentIndex: 0 + - equal: + path: spec.protocolMappers[0].config["included.client.audience"] + value: tenant-root-monitoring + documentIndex: 0 + + - it: mode=System persists a random 32-char client-secret at install + template: templates/grafana/oidc-client-secret.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - hasDocuments: + count: 1 + - equal: + path: kind + value: Secret + - equal: + path: metadata.name + value: monitoring-oidc-client + - matchRegex: + path: data["client-secret"] + pattern: "^[A-Za-z0-9]+={0,2}$" + + - it: mode=System grafana.yaml emits auth.generic_oauth pointing at the cozy realm + template: templates/grafana/grafana.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: spec.config["auth.generic_oauth"].enabled + value: "true" + - equal: + path: spec.config["auth.generic_oauth"].client_id + value: tenant-root-monitoring + - equal: + path: spec.config["auth.generic_oauth"].client_secret + value: "${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}" + - equal: + path: spec.config["auth.generic_oauth"].auth_url + value: "https://keycloak.example.org/realms/cozy/protocol/openid-connect/auth" + - matchRegex: + path: spec.config["auth.generic_oauth"].role_attribute_path + pattern: "tenant-root-monitoring-admin.*tenant-root-monitoring-editor.*tenant-root-monitoring-viewer" + # tenant Grafana stays at org-level Admin, no server-level upgrade. + - equal: + path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin + value: "false" + + - it: mode=System grafana.yaml wires GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret + template: templates/grafana/grafana.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - contains: + path: spec.deployment.spec.template.spec.containers[0].env + content: + name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: monitoring-oidc-client + key: client-secret + + - it: mode=System keeps disable_login_form false so admin creds stay a break-glass path + template: templates/grafana/grafana.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: spec.config.auth.disable_login_form + value: "false" + + ########################################################## + # mode: System — fail-close guard + ########################################################## + + - it: mode=System hard-fails the render when the platform OIDC feature is off + template: templates/grafana/oidc-keycloak.yaml + set: + oidc.mode: System + # _cluster.oidc-enabled deliberately unset + asserts: + - failedTemplate: + errorMessage: "spec.oidc.mode: System requires the platform-level OIDC feature (authentication.oidc.enabled) — enable it in cozystack-values, or use mode: CustomConfig for a tenant-supplied issuer." + + ########################################################## + # mode: CustomConfig — inline + ########################################################## + + - it: mode=CustomConfig with inline config emits no cozy realm objects + template: templates/grafana/oidc-keycloak.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: my-client + client_secret: xyz + auth_url: https://idp.example/auth + asserts: + - hasDocuments: + count: 0 + + - it: mode=CustomConfig inline emits no client-secret Secret (operator brings their own) + template: templates/grafana/oidc-client-secret.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: my-client + client_secret: xyz + asserts: + - hasDocuments: + count: 0 + + - it: mode=CustomConfig inline writes the operator's map verbatim into spec.config + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: my-client + client_secret: xyz + auth_url: https://idp.example/auth + role_attribute_path: "'Viewer'" + asserts: + - equal: + path: spec.config["auth.generic_oauth"].client_id + value: my-client + - equal: + path: spec.config["auth.generic_oauth"].client_secret + value: xyz + - equal: + path: spec.config["auth.generic_oauth"].auth_url + value: https://idp.example/auth + - equal: + path: spec.config["auth.generic_oauth"].role_attribute_path + value: "'Viewer'" + + ########################################################## + # mode: CustomConfig — secretRef + ########################################################## + + - it: mode=CustomConfig with secretRef mounts the operator Secret + GF_PATHS_CUSTOM_INI + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.secretRef.name: my-oauth-secret + asserts: + # spec.config carries no auth.generic_oauth — the ini fragment is + # authoritative in this branch. + - notExists: + path: spec.config["auth.generic_oauth"] + - contains: + path: spec.deployment.spec.template.spec.containers[0].env + content: + name: GF_PATHS_CUSTOM_INI + value: /etc/grafana/oidc/auth.ini + - contains: + path: spec.deployment.spec.template.spec.containers[0].volumeMounts + content: + name: oidc-custom-ini + mountPath: /etc/grafana/oidc + readOnly: true + - contains: + path: spec.deployment.spec.template.spec.volumes + content: + name: oidc-custom-ini + secret: + secretName: my-oauth-secret + + ########################################################## + # mode: CustomConfig — XOR guards + ########################################################## + + - it: mode=CustomConfig with BOTH inline and secretRef fails the render + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: a + oidc.customConfig.secretRef.name: b + asserts: + - failedTemplate: + errorMessage: "spec.oidc.customConfig: set exactly one of `config` (inline) or `secretRef.name` — they are mutually exclusive." + + - it: mode=CustomConfig with NEITHER inline nor secretRef fails the render + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + asserts: + - failedTemplate: + errorMessage: "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." + + ########################################################## + # Platform release — allow_assign_grafana_admin gets flipped + ########################################################## + + - it: platform release (Release.Name == monitoring-system) upgrades Admin to GrafanaAdmin + template: templates/grafana/grafana.yaml + release: + name: monitoring-system + namespace: cozy-monitoring + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + _namespace.host: cozy-monitoring.example.org + asserts: + - equal: + path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin + value: "true" From fe2c606f7991f17fc3f009ccd20cf676c0e94e83 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Thu, 2 Jul 2026 00:10:07 +0300 Subject: [PATCH 06/39] test(e2e): add Phase-1 OIDC render-side bats for Monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bats files mirroring the shape of the tenant kube-apiserver PR's kubernetes-oidc-{system,customconfig}.bats: - monitoring-oidc-system.bats — asserts the Monitoring CR admits the new spec.oidc.mode: System field; the chart renders the Grafana CR with an auth.generic_oauth section pointing at the cozy realm; a 32-char client-secret Secret is persisted; the KeycloakClient (with public: false), KeycloakClientScope (audience mapper), and three KeycloakRealmGroups (-admin, -editor, -viewer) land in cozy; and the Grafana Deployment injects GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret. - monitoring-oidc-customconfig.bats — asserts the CR admits inline CustomConfig; the operator's payload lands on spec.config verbatim with no cozy realm in the auth_url; NO KeycloakClient / Scope / Groups are created in cozy; and the secretRef variant mounts the operator Secret under /etc/grafana/oidc without emitting auth.generic_oauth under spec.config. Neither drives a real browser login through Keycloak — that is deferred to the follow-up integration suite, same posture as the tenant kube-apiserver's Phase-1 tests. Signed-off-by: Ivan Okhotnikov --- .../monitoring-oidc-customconfig.bats | 165 ++++++++++++++++++ hack/e2e-apps/monitoring-oidc-system.bats | 141 +++++++++++++++ 2 files changed, 306 insertions(+) create mode 100755 hack/e2e-apps/monitoring-oidc-customconfig.bats create mode 100755 hack/e2e-apps/monitoring-oidc-system.bats diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats new file mode 100755 index 000000000..50419da39 --- /dev/null +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -0,0 +1,165 @@ +#!/usr/bin/env bats + +# Phase-1 OIDC CustomConfig selector e2e for the Grafana instance — +# render-side only. +# +# What is exercised on a live cluster: that the new +# `spec.oidc.mode: CustomConfig` field is admitted; that cozystack-api +# accepts the updated schema; that the HelmRelease renders; that the +# operator-supplied auth.generic_oauth payload lands on the Grafana +# CR; and — crucially — that NO Keycloak objects are created in the +# `cozy` realm when the tenant brings their own issuer. + +setup() { + TEST_NAME="mon-oidc-byo-$$" + cleanup_monitoring +} + +teardown() { + cleanup_monitoring +} + +cleanup_monitoring() { + kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" \ + --ignore-not-found --wait=false 2>/dev/null || true + kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ + --for=delete --timeout=2m 2>/dev/null || true + kubectl -n tenant-test delete secret "${TEST_NAME}-byo-oauth" \ + --ignore-not-found 2>/dev/null || true +} + +@test "Monitoring CR accepts spec.oidc.mode=CustomConfig with inline config" { + kubectl apply -f - </dev/null 2>&1; do sleep 2; done' + timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' +} + +@test "Grafana CR carries the operator's auth.generic_oauth payload verbatim" { + timeout 60 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 2; done' + + client_id=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.client_id}') + echo "client_id: ${client_id}" + [ "${client_id}" = "byo-monitoring" ] + + auth_url=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.auth_url}') + echo "auth_url: ${auth_url}" + [ "${auth_url}" = "https://idp.example.test/auth" ] + + # No cozy realm in the picture. + echo "${auth_url}" | grep -vq "realms/cozy" || { + echo "auth_url unexpectedly references the cozy realm" + return 1 + } +} + +@test "No KeycloakClient / Scope / Groups are created in the cozy realm" { + # Only meaningful when the EDP CRDs exist on the cluster. + if ! kubectl api-resources --api-group=v1.edp.epam.com >/dev/null 2>&1; then + skip "EDP Keycloak operator CRDs not present on this cluster" + fi + + CID="tenant-test-monitoring-${TEST_NAME}" + + # Give any reconciler a moment; then assert none of the four objects + # were created. + sleep 5 + ! kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" 2>/dev/null + ! kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" 2>/dev/null + ! kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-admin" 2>/dev/null + ! kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-editor" 2>/dev/null + ! kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-viewer" 2>/dev/null +} + +@test "secretRef variant mounts operator Secret under /etc/grafana/oidc" { + # Operator-owned Secret carrying a ready-made ini fragment. + kubectl -n tenant-test create secret generic "${TEST_NAME}-byo-oauth" \ + --from-literal=auth.ini='[auth.generic_oauth] +enabled = true +name = ByoIdp +client_id = byo-monitoring +client_secret = byo-secret +' --dry-run=client -o yaml | kubectl apply -f - + + # Recreate the Monitoring CR pointing at the Secret. + kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" --ignore-not-found + kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ + --for=delete --timeout=2m 2>/dev/null || true + + kubectl apply -f - </dev/null 2>&1; do sleep 2; done' + timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' + + # spec.config carries no auth.generic_oauth section in this branch. + section=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth}') + echo "spec.config.auth.generic_oauth: ${section:-}" + [ -z "${section}" ] + + # The volume + mount + env are on the Grafana CR's deployment override. + mount=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.deployment.spec.template.spec.containers[?(@.name=="grafana")].volumeMounts[?(@.name=="oidc-custom-ini")].mountPath}') + echo "mount: ${mount}" + [ "${mount}" = "/etc/grafana/oidc" ] + + vol=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.deployment.spec.template.spec.volumes[?(@.name=="oidc-custom-ini")].secret.secretName}') + echo "secret volume: ${vol}" + [ "${vol}" = "${TEST_NAME}-byo-oauth" ] +} diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats new file mode 100755 index 000000000..3676df102 --- /dev/null +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats + +# Phase-1 OIDC System-mode e2e for the Grafana instance — render-side +# only. +# +# Pairs with monitoring-oidc-customconfig.bats (BYO issuer path). +# +# Does NOT drive a full browser login through Keycloak: that path is +# deferred to a follow-up integration suite (same posture as the +# tenant kube-apiserver's kubernetes-oidc-system.bats). +# +# What is exercised here on a live cluster: that the new +# `spec.oidc.*` fields are admitted by the Monitoring CR; that +# cozystack-api accepts the updated schema; that the HelmRelease +# renders the OIDC templates; and that the resulting KeycloakClient / +# KeycloakClientScope / KeycloakRealmGroups / persistent +# client-secret Secret / Grafana CR carry the expected shape. + +setup() { + TEST_NAME="mon-oidc-sys-$$" + cleanup_monitoring +} + +teardown() { + cleanup_monitoring +} + +cleanup_monitoring() { + kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" \ + --ignore-not-found --wait=false 2>/dev/null || true + kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ + --for=delete --timeout=2m 2>/dev/null || true +} + +@test "Monitoring CR accepts spec.oidc.mode=System" { + kubectl apply -f - </dev/null 2>&1; do sleep 2; done' + + # Wait for the chart to render its Grafana CR. We do not wait on + # HR Ready — that requires a full VictoriaMetrics + Postgres bringup + # which the render-side test deliberately avoids. + timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' +} + +@test "Grafana CR carries auth.generic_oauth pointing at the cozy realm" { + timeout 60 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 2; done' + + # auth.generic_oauth is a top-level key under spec.config; the key + # name literally contains a dot, so jsonpath needs a bracketed lookup. + enabled=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.enabled}') + echo "generic_oauth.enabled: ${enabled}" + [ "${enabled}" = "true" ] + + client_id=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.client_id}') + echo "client_id: ${client_id}" + [ "${client_id}" = "tenant-test-monitoring-${TEST_NAME}" ] + + auth_url=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.auth_url}') + echo "auth_url: ${auth_url}" + echo "${auth_url}" | grep -qE '/realms/cozy/protocol/openid-connect/auth$' +} + +@test "Persistent client-secret Secret is created and 32-char random" { + SEC="${TEST_NAME}-oidc-client" + timeout 60 sh -ec 'until kubectl -n tenant-test get secret "'"${SEC}"'" >/dev/null 2>&1; do sleep 2; done' + value=$(kubectl -n tenant-test get secret "${SEC}" \ + -o jsonpath='{.data.client-secret}' | base64 -d) + echo "client-secret length: ${#value}" + [ "${#value}" = "32" ] + # No unexpected characters. + echo "${value}" | grep -qE '^[A-Za-z0-9]+$' +} + +@test "Per-instance KeycloakClient + KeycloakClientScope + 3 KeycloakRealmGroups land in cozy" { + # The EDP Keycloak operator API group is only present when the + # platform-level OIDC feature is on. Skip gracefully otherwise so the + # test suite works on runners without Keycloak installed. + if ! kubectl api-resources --api-group=v1.edp.epam.com >/dev/null 2>&1; then + skip "EDP Keycloak operator CRDs not present on this cluster" + fi + + CID="tenant-test-monitoring-${TEST_NAME}" + + timeout 60 sh -ec 'until kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "'"${CID}"'" >/dev/null 2>&1; do sleep 2; done' + public=$(kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" \ + -o jsonpath='{.spec.public}') + echo "client public: ${public}" + [ "${public}" = "false" ] + + timeout 30 sh -ec 'until kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "'"${CID}"'-audience" >/dev/null 2>&1; do sleep 2; done' + mapper=$(kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" \ + -o jsonpath='{.spec.protocolMappers[0].protocolMapper}') + echo "audience mapper: ${mapper}" + [ "${mapper}" = "oidc-audience-mapper" ] + + for role in admin editor viewer; do + timeout 30 sh -ec 'until kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "'"${CID}-${role}"'" >/dev/null 2>&1; do sleep 2; done' + echo "group ${CID}-${role} present" + done +} + +@test "Grafana Deployment injects GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret" { + # Grafana operator materialises a Deployment named `grafana-deployment` + # from the Grafana CR. Poll for it before asserting on the env. + timeout 120 sh -ec 'until kubectl -n tenant-test get deploy grafana-deployment >/dev/null 2>&1; do sleep 5; done' + envs=$(kubectl -n tenant-test get deploy grafana-deployment \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="grafana")].env[*].name}') + echo "grafana env: ${envs}" + echo "${envs}" | grep -qw "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET" + + ref=$(kubectl -n tenant-test get deploy grafana-deployment \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="grafana")].env[?(@.name=="GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET")].valueFrom.secretKeyRef.name}') + echo "client-secret env source: ${ref}" + [ "${ref}" = "${TEST_NAME}-oidc-client" ] +} From ed4aa841c86d94255ba5d07f3f5c6ae7ca7f85ba Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Thu, 2 Jul 2026 00:11:24 +0300 Subject: [PATCH 07/39] docs(oidc-grafana): operator guide for Phase-1 OIDC on the Grafana instance New docs/oidc-grafana.md parallel to docs/oidc-tenant.md from cozystack/cozystack#3044. Covers: - The three modes on the Monitoring CR (None / System / CustomConfig) and how the selector maps to what the chart provisions. - What System mode creates on the cozy realm: a per-instance confidential KeycloakClient, a KeycloakClientScope with the audience mapper, three KeycloakRealmGroups, and the persistent client-secret Secret; plus the Grafana CR side (auth.generic_oauth section, GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET env). - Both CustomConfig payload paths (inline vs secretRef) and the XOR guard. - User-role mapping via role_attribute_path and how an operator adds users to the three groups through a KeycloakRealmUser. - Failure modes (assertSystemEnabled, XOR, wrong claims, emailVerified prescriptive requirement) and what is out of scope for Phase 1 (per-tenant realms, backend-logout-url, disabled login form, CEL claimValidationRules). Signed-off-by: Ivan Okhotnikov --- docs/oidc-grafana.md | 224 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/oidc-grafana.md diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md new file mode 100644 index 000000000..be1160e61 --- /dev/null +++ b/docs/oidc-grafana.md @@ -0,0 +1,224 @@ +# OIDC for the Grafana instance (Phase 1) + +Cozystack ships one Grafana codebase deployed twice: as +`monitoring-system` in the platform's `cozy-monitoring` namespace and as +`monitoring` in every tenant namespace. Both instances opt in to OIDC +authentication through the same flat selector on the `Monitoring` CR. +This document covers the operator-facing surface: what the modes mean, +what the chart provisions, and how a user signs in. + +The architectural rationale lives in +[cozystack/community#24](https://github.com/cozystack/community/pull/24) +— in particular why per-tenant Keycloak realms are deliberately +deferred to Phase 2. This Grafana integration follows the same shape +as the tenant kube-apiserver's Phase 1 +([cozystack/cozystack#3044](https://github.com/cozystack/cozystack/pull/3044)), +adapted for a server-side (confidential) OAuth client instead of a +public kubectl one. + +## Selector + +```yaml +apiVersion: apps.cozystack.io/v1alpha1 +kind: Monitoring +metadata: + name: monitoring + namespace: tenant-acme +spec: + oidc: + mode: System # System | CustomConfig | None (default) +``` + +Three modes: + +- **None** — the only user-facing path is the `grafana-admin-password` + Secret, exposed to the tenant through + `packages/system/monitoring-rd`. This is the default; existing + instances render byte-identical to before. +- **System** — the Grafana instance trusts the platform `cozy` Keycloak + realm via a per-instance confidential client. Users are the ones a + platform admin already provisioned in `cozy`; the tenant does not + manage a directory of its own. +- **CustomConfig** — Grafana trusts a tenant-supplied OIDC issuer + directly. `cozy` is not in the path. Use for BYO IdPs (Okta, Auth0, + a customer's own Keycloak). + +The `grafana-admin-password` Secret stays a documented break-glass +path in every mode; `disable_login_form` is not flipped by this +selector. Hardening that further (e.g. locking the form off in +`System` mode) is a follow-up. + +## System mode + +What the chart provisions, in the release namespace of the Monitoring +CR: + +1. **KeycloakClient** named `-` in the `cozy` + realm — `public: false`, `directAccess: false`. The `secret` field + points at a chart-owned Kubernetes Secret via the EDP-Keycloak + `$:` syntax so the operator provisions the same + confidential secret to Keycloak that Grafana reads at runtime. + `redirectUris` is locked to + `https://grafana./login/generic_oauth`. +2. **KeycloakClientScope** named `--audience` + carrying an `oidc-audience-mapper` that pins `id_token.aud` to the + per-instance clientId. This is the per-instance isolation + primitive: a token minted for one Monitoring release is rejected + by another's Grafana. +3. **Three `KeycloakRealmGroup`s** named + `--{admin,editor,viewer}`. The groups + themselves are chart-owned; their membership is not — a platform + operator adds users to them through the Keycloak UI or a + `KeycloakRealmUser` CR. See the "Users and RBAC" section. +4. A **Secret** `-oidc-client` carrying a random 32-char + `client-secret`. Generated on first install (`lookup` + random + fallback, same pattern as `packages/system/dashboard`), preserved + across upgrades. +5. The Grafana CR's `spec.config.auth.generic_oauth` section wired to + the cozy realm issuer + the per-instance audience scope + + `role_attribute_path` mapping the three groups above to Grafana's + `Admin` / `Editor` / `Viewer` roles. +6. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana + Deployment sourced from the Secret in step 4. + +For the platform release (release name `monitoring-system`), the chart +sets `allow_assign_grafana_admin: true` so an `Admin`-group member is +auto-promoted to server-level `GrafanaAdmin` (necessary because the +platform Grafana serves as the meta-admin dashboard). Tenant Grafana +instances keep it `false` — organisation-level Admin is enough. + +## CustomConfig mode + +The tenant supplies the entire `[auth.generic_oauth]` payload. Two +paths: + +```yaml +spec: + oidc: + mode: CustomConfig + customConfig: + config: + client_id: my-grafana + client_secret: xxxxxxxx + auth_url: https://idp.acme.example/protocol/openid-connect/auth + token_url: https://idp.acme.example/protocol/openid-connect/token + api_url: https://idp.acme.example/protocol/openid-connect/userinfo + scopes: openid email profile groups + role_attribute_path: "contains(groups[*], 'grafana-admins') && 'Admin' || 'Viewer'" +``` + +…or via an existing Secret in the tenant namespace whose `auth.ini` +key holds a ready-made `[auth.generic_oauth]` fragment: + +```yaml +spec: + oidc: + mode: CustomConfig + customConfig: + secretRef: + name: acme-byo-grafana-auth +``` + +Setting both `config` and `secretRef.name` (or neither) fails the +render. In `CustomConfig` mode no Keycloak objects are provisioned in +`cozy` and no chart-owned client-secret Secret is created — the +operator manages their own credentials end-to-end. + +## Users and RBAC + +Grafana's built-in identity model exposes three org-level roles: +`Admin` / `Editor` / `Viewer`. The chart maps them to Keycloak groups +via `role_attribute_path`, evaluated on the `groups` claim at login: + +```text +contains(groups[*], '--admin') && 'Admin' || +contains(groups[*], '--editor') && 'Editor' || +contains(groups[*], '--viewer') && 'Viewer' || +'Viewer' +``` + +Authenticated identities with none of the three groups default to +`Viewer`. To give a user a role, add them to the corresponding +`KeycloakRealmGroup` in `cozy`: + +```yaml +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakRealmUser +metadata: + name: alice-acme + namespace: cozy-keycloak +spec: + realm: cozy + username: alice@acme.example + email: alice@acme.example + emailVerified: true + password: "…" + groups: + - tenant-acme-monitoring-admin +``` + +Removing a user from the group demotes them (Grafana re-evaluates +`role_attribute_path` on every login). Deleting them from `cozy` +revokes access outright. + +## How a user logs in + +The user opens `https://grafana.` in a browser and picks the +"Sign in with Keycloak" button under the login form. Grafana runs the +Authorization Code + PKCE flow against the cozy realm, receives a +token whose `aud` claim matches this Monitoring instance's clientId, +and creates or updates the local Grafana user on the first +successful login with the role from `role_attribute_path`. + +The break-glass `admin_user` / `admin_password` field on the form +stays wired to the `grafana-admin-password` Secret and continues to +work — useful when Keycloak is down or misconfigured. + +## Failure modes + +- **`mode: System` without the platform-level OIDC feature** — chart + render hard-fails: + `spec.oidc.mode: System requires the platform-level OIDC feature`. + Flip `authentication.oidc.enabled: true` in the platform values, or + switch to `CustomConfig`. +- **`CustomConfig` with an unreachable issuer or wrong claim + mappings** — Grafana rejects the callback and the login screen + shows an error; the `admin_user`/`admin_password` Secret keeps + working as break-glass. +- **`emailVerified` on Keycloak users is a prescriptive requirement, + not a chart-enforced one.** The chart does not emit any + `claimValidationRules` — the layered guarantees you rely on + instead are: + 1. Provision users with `emailVerified: true` (via + `KeycloakRealmUser` or the Keycloak UI's email-verify flow) so + no unverified identity ever holds a given email. + 2. The `cozy` realm keeps Keycloak's default + `duplicateEmails: false`, so a second account cannot claim an + already-registered address to impersonate an existing operator. + 3. Grafana's own login flow rejects tokens the apiserver rejects, + so on the wire the same guarantee still holds through the JWT + signature and audience checks. + Adding a CEL `claimValidationRules` entry is a reasonable + follow-up hardening; it is out of scope for Phase 1. +- **`CustomConfig` with both or neither payload set** — chart render + hard-fails: `set exactly one of 'config' (inline) or + 'secretRef.name' — they are mutually exclusive`, or `CustomConfig + requires either spec.oidc.customConfig.config … or …secretRef.name`. + +## What is NOT in Phase 1 + +- **Per-tenant Keycloak realms.** Phase 2 candidate; tracked in + cozystack/community#24. +- **Full-logout through Keycloak's end-session endpoint.** + `auth.generic_oauth` native to Grafana does the OAuth part; wiring + `--backend-logout-url` and the corresponding Keycloak client + attribute is a subsequent hardening. +- **`disable_login_form: true` under `mode: System`.** Kept off so + the `admin_user`/`admin_password` Secret remains a documented + break-glass path; hardening it is a follow-up. +- **CEL `claimValidationRules` enforcing `email_verified`.** See the + failure-modes note above. +- **Multi-issuer / BYO alongside `cozy`.** `mode: System` and + `mode: CustomConfig` are mutually exclusive on a single Monitoring + release — no composition. Follow the tenant kube-apiserver PR's + structured-config path if you need this later. From 4fe85d039c52f76f234d7962ddcfb77359584b2a Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Thu, 2 Jul 2026 00:19:48 +0300 Subject: [PATCH 08/39] chore(monitoring): move OIDC schema source to packages/extra + regenerate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Monitoring public API surface (openAPISchema in monitoring-rd cozyrds) is generated from packages/extra/monitoring/values.yaml by cozyvalues-gen + hack/update-crd.sh — the foundation commit (186239044) instead wrote it by hand in packages/system/monitoring-rd/cozyrds/monitoring.yaml, which the pre-commit hook then reverts against every push. Move the oidc annotations into packages/extra/monitoring/values.yaml (matching the pattern used by the rest of the top-level fields) and run `make generate` in that directory. That regenerates: - packages/extra/monitoring/values.schema.json - packages/extra/monitoring/README.md - packages/system/monitoring-rd/cozyrds/monitoring.yaml (openAPISchema + keysOrder, both including the new oidc keys) No behaviour change — the values.yaml under packages/system/monitoring still carries the same oidc defaults so `helm template` works stand-alone, and helm-unittest keeps passing (22/22). Signed-off-by: Ivan Okhotnikov --- packages/extra/monitoring/README.md | 12 +++++ packages/extra/monitoring/values.schema.json | 44 +++++++++++++++++++ packages/extra/monitoring/values.yaml | 31 +++++++++++++ .../monitoring-rd/cozyrds/monitoring.yaml | 2 +- 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index ed7df4f64..1bc107019 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -101,3 +101,15 @@ | `vmagent.externalLabels` | External labels applied to all metrics. | `object` | `{}` | | `vmagent.remoteWrite` | Remote write configuration. | `object` | `{}` | + +### OIDC authentication + +| Name | Description | Type | Value | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------ | +| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | +| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | +| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | +| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | +| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | +| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | + diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 4bc6906e2..25b125492 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -595,6 +595,50 @@ } } } + }, + "oidc": { + "description": "OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.", + "type": "object", + "default": {}, + "required": [ + "mode" + ], + "properties": { + "customConfig": { + "description": "Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.", + "type": "object", + "default": {}, + "properties": { + "config": { + "description": "Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.", + "type": "object", + "default": {} + }, + "secretRef": { + "description": "Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.", + "type": "object", + "default": {}, + "properties": { + "name": { + "description": "Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.", + "type": "string", + "default": "" + } + } + } + } + }, + "mode": { + "description": "Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.", + "type": "string", + "default": "None", + "enum": [ + "None", + "System", + "CustomConfig" + ] + } + } } } } diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index c521f3894..b67c5ae99 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -182,3 +182,34 @@ vmagent: ## - job_name: "custom" ## static_configs: ## - targets: ["my-service:9090"] + +## +## @section OIDC authentication +## + +## @enum {string} OIDCMode - Identity mode for the Grafana instance. +## @value None +## @value System +## @value CustomConfig + +## @typedef {struct} OIDCSecretRef - Reference to a Secret carrying an `[auth.generic_oauth]` ini fragment. +## @field {string} [name] - Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. + +## @typedef {struct} OIDCCustomConfigInline - Inline map of grafana.ini `[auth.generic_oauth]` keys. +## @field {string} [] - Value for the generic_oauth key (e.g. `client_id`, `client_secret`, `auth_url`, `token_url`, `api_url`, `scopes`, `role_attribute_path`). + +## @typedef {struct} OIDCCustomConfig - Tenant-supplied AuthN payload; consumed only when `mode: CustomConfig`. Exactly one of `config` or `secretRef.name` must be set. +## @field {OIDCCustomConfigInline} [config] - Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. +## @field {OIDCSecretRef} [secretRef] - Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. + +## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm). +## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. +## @field {OIDCCustomConfig} [customConfig] - Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. + +## @param {OIDC} oidc - OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. +oidc: + mode: "None" + customConfig: + config: {} + secretRef: + name: "" diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index 8e6b442a2..a0317a7ad 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -8,7 +8,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md.","type":"object","default":{},"properties":{"mode":{"description":"Identity mode: None (no OIDC), System (platform cozy realm via a per-instance confidential client), or CustomConfig (BYO issuer, tenant-supplied).","type":"string","enum":["None","System","CustomConfig"],"default":"None"},"customConfig":{"description":"Tenant-supplied auth.generic_oauth payload; consumed only when mode is CustomConfig. Exactly one of `config` or `secretRef.name` must be set.","type":"object","default":{},"properties":{"config":{"description":"Inline map of grafana.ini [auth.generic_oauth] keys. The chart writes it verbatim into the Grafana CR spec.config. Mutually exclusive with `secretRef.name`.","type":"object","default":{},"additionalProperties":{"type":"string"}},"secretRef":{"description":"Reference to a Secret carrying a ready-made [auth.generic_oauth] ini fragment in the `auth.ini` key. Mutually exclusive with `config`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace.","type":"string","default":""}}}}}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]}}}}} release: prefix: "" labels: From ffeb8e5e4f43d287c6539cc1cea26e055ccad973 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sat, 4 Jul 2026 23:16:56 +0300 Subject: [PATCH 09/39] fix(monitoring): close self-review blockers for the OIDC PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two blocking-class findings from the cozy-review pass on this PR: (1) Missing PackageSource dependency on cozystack.keycloak-operator (MAJOR cold-install race). Without this edge, the monitoring chart can reconcile before the v1.edp.epam.com CRDs are registered on a fresh cluster — the `.Capabilities.APIVersions.Has` guard in templates/grafana/oidc-keycloak.yaml then silently drops all Keycloak-side objects for mode: System, and Grafana's auth.generic_oauth block points at a client Keycloak never provisions. Add the ordering edge, plus a sources_monitoring_application_dependson_test.yaml regression guard so a future edit cannot drop it silently. (2) allow_assign_grafana_admin driven by `.Release.Name == monitoring-system` (MINOR fragile coupling). Replace the release-name sniff with an explicit `oidc.grafanaAdmin: false` values field, and flip it on for the platform Grafana release via Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin in packages/core/platform/templates/bundles/system.yaml — mirroring the pattern monitoring-agents already uses for per-component overrides. Tenant Grafana releases inherit the chart default false and stay at org-level Admin. Two new helm-unittest cases in packages/system/monitoring/tests/ lock the values-driven behaviour on both sides (grafanaAdmin=true → Admin uplift, default → tenant stays at org Admin), plus bundles_monitoring_grafana_admin_test.yaml in packages/core/platform/tests/ regression-guards the Package's component override so the platform release keeps its uplift on future edits. Also document the mode-toggle group-membership loss in docs/oidc-grafana.md — mode: System → CustomConfig/None deletes the three chart-owned KeycloakRealmGroups; membership is not restored on toggle-back. Not a bug, but the operator UX cost of the toggle deserves a bullet in Failure modes. Regenerated artifacts (cozyvalues-gen + hack/update-crd.sh): packages/extra/monitoring/values.schema.json packages/extra/monitoring/README.md packages/system/monitoring-rd/cozyrds/monitoring.yaml Test suites still green: packages/system/monitoring 23/23, packages/core/platform 81/81. Signed-off-by: Ivan Okhotnikov --- docs/oidc-grafana.md | 25 +++++++++--- .../sources/monitoring-application.yaml | 9 +++++ .../platform/templates/bundles/system.yaml | 8 +++- ...bundles_monitoring_grafana_admin_test.yaml | 33 ++++++++++++++++ ...monitoring_application_dependson_test.yaml | 39 +++++++++++++++++++ packages/extra/monitoring/README.md | 17 ++++---- packages/extra/monitoring/values.schema.json | 5 +++ packages/extra/monitoring/values.yaml | 2 + .../monitoring-rd/cozyrds/monitoring.yaml | 4 +- .../system/monitoring/templates/_helpers.tpl | 15 +++---- .../system/monitoring/tests/oidc_test.yaml | 19 ++++++--- packages/system/monitoring/values.yaml | 7 ++++ 12 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml create mode 100644 packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index be1160e61..00e855397 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -81,11 +81,16 @@ CR: 6. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana Deployment sourced from the Secret in step 4. -For the platform release (release name `monitoring-system`), the chart -sets `allow_assign_grafana_admin: true` so an `Admin`-group member is -auto-promoted to server-level `GrafanaAdmin` (necessary because the -platform Grafana serves as the meta-admin dashboard). Tenant Grafana -instances keep it `false` — organisation-level Admin is enough. +Server-level `GrafanaAdmin` promotion is opt-in via +`spec.oidc.grafanaAdmin: true`. When set, an `-admin` group +member is auto-promoted to `GrafanaAdmin` (via +`allow_assign_grafana_admin: true` in the rendered `[auth.generic_oauth]` +block). The platform bundle +(`packages/core/platform/templates/bundles/system.yaml`) flips it on for +the platform Grafana release via +`Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin: true`; +tenant Grafana releases inherit the chart default `false` and stay at +org-level Admin. ## CustomConfig mode @@ -185,6 +190,16 @@ work — useful when Keycloak is down or misconfigured. mappings** — Grafana rejects the callback and the login screen shows an error; the `admin_user`/`admin_password` Secret keeps working as break-glass. +- **Mode toggle destroys chart-owned `KeycloakRealmGroup` + membership.** The three groups (`-{admin,editor,viewer}`) + are release-owned. Flipping `spec.oidc.mode` from `System` to + `CustomConfig` or `None` runs a `helm upgrade` that deletes the + three `KeycloakRealmGroup` objects — and, with them, every user's + membership. Flipping back to `System` recreates the groups empty; + the platform operator has to re-populate them by hand (via the + Keycloak UI or new `KeycloakRealmUser` CRs). Not a bug, but a + one-way UX cost on the toggle; treat it like rotating an admin + Secret. - **`emailVerified` on Keycloak users is a prescriptive requirement, not a chart-enforced one.** The chart does not emit any `claimValidationRules` — the layered guarantees you rely on diff --git a/packages/core/platform/sources/monitoring-application.yaml b/packages/core/platform/sources/monitoring-application.yaml index 6c6360398..7621b4825 100644 --- a/packages/core/platform/sources/monitoring-application.yaml +++ b/packages/core/platform/sources/monitoring-application.yaml @@ -18,6 +18,15 @@ spec: - cozystack.victoria-metrics-operator # cozystack-engine ships the ApplicationDefinition CRD rendered by the *-rd component below. - cozystack.cozystack-engine + # keycloak-operator ships the v1.edp.epam.com CRDs consumed by + # the OIDC selector (spec.oidc.mode: System renders KeycloakClient, + # KeycloakClientScope and three KeycloakRealmGroups in the cozy + # realm). Without this dep the monitoring chart can reconcile + # before the CRDs are registered on cold install — the + # .Capabilities.APIVersions.Has guard then silently drops all + # Keycloak-side objects and Grafana's auth.generic_oauth block + # points at a client that never gets provisioned. + - cozystack.keycloak-operator libraries: - name: cozy-lib path: library/cozy-lib diff --git a/packages/core/platform/templates/bundles/system.yaml b/packages/core/platform/templates/bundles/system.yaml index 84aff47ac..d4ddfc4c1 100644 --- a/packages/core/platform/templates/bundles/system.yaml +++ b/packages/core/platform/templates/bundles/system.yaml @@ -192,7 +192,13 @@ {{include "cozystack.platform.package.default" (list "cozystack.gateway-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.seaweedfs-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.info-application" $) }} -{{include "cozystack.platform.package.default" (list "cozystack.monitoring-application" $) }} +{{- /* Platform Grafana (`monitoring-system`) — flip GrafanaAdmin uplift on + so `-admin` group members become server-level Grafana + admins. Tenant Grafana releases (component `monitoring`) inherit + the chart default `oidc.grafanaAdmin: false` and stay at + org-level Admin. See docs/oidc-grafana.md and cozystack#3176. */}} +{{- $monitoringApplicationComponents := dict "monitoring-system" (dict "values" (dict "oidc" (dict "grafanaAdmin" true))) -}} +{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "default" $ $monitoringApplicationComponents) }} {{include "cozystack.platform.package.default" (list "cozystack.etcd-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.cozystack-basics" $) }} {{include "cozystack.platform.package.default" (list "cozystack.backupstrategy-controller" $) }} diff --git a/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml b/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml new file mode 100644 index 000000000..c45e8f8aa --- /dev/null +++ b/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml @@ -0,0 +1,33 @@ +suite: bundles.system monitoring-application component wiring (Grafana OIDC uplift) +templates: + - templates/bundles/system.yaml +release: + name: cozystack + namespace: cozy-system +tests: + - it: monitoring-application Package emits the GrafanaAdmin uplift for the platform Grafana release + set: + bundles: + system: + enabled: true + variant: isp-full + documentSelector: + path: metadata.name + value: cozystack.monitoring-application + asserts: + - equal: + path: kind + value: Package + - equal: + path: spec.variant + value: default + # The platform Grafana release (component `monitoring-system`) is + # promoted to server-level GrafanaAdmin via the explicit values + # override. Tenant Grafana (component `monitoring`) is NOT listed + # in .spec.components — inherits the chart default + # oidc.grafanaAdmin: false and stays at org-level Admin. + - equal: + path: spec.components.monitoring-system.values.oidc.grafanaAdmin + value: true + - notExists: + path: spec.components.monitoring.values.oidc.grafanaAdmin diff --git a/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml b/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml new file mode 100644 index 000000000..27fec8a86 --- /dev/null +++ b/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml @@ -0,0 +1,39 @@ +suite: monitoring-application waits for the Keycloak operator that owns the OIDC CRDs +# Regression guard for a fresh-install ordering race introduced by the Phase-1 +# OIDC selector (cozystack/cozystack#3176). Without the keycloak-operator edge, +# the monitoring chart can reconcile before the v1.edp.epam.com CRDs are +# registered on cold install — the `.Capabilities.APIVersions.Has` guard in +# templates/grafana/oidc-keycloak.yaml silently drops the whole Keycloak side +# of System mode (KeycloakClient / KeycloakClientScope / KeycloakRealmGroups), +# Grafana boots with an auth.generic_oauth block pointing at a client that was +# never provisioned, and OIDC login fails at request time with no build-time +# signal. +templates: + - templates/sources.yaml +release: + name: cozystack + namespace: cozy-system +tests: + - it: monitoring-application dependsOn keycloak-operator (OIDC CRDs) + documentSelector: + path: metadata.name + value: cozystack.monitoring-application + asserts: + - contains: + path: spec.variants[0].dependsOn + content: cozystack.keycloak-operator + # The other deps we rely on transitively for the Grafana instance to + # even come up on a fresh cluster; regression-guard them together so + # a future edit does not accidentally drop the ordering edges. + - contains: + path: spec.variants[0].dependsOn + content: cozystack.networking + - contains: + path: spec.variants[0].dependsOn + content: cozystack.grafana-operator + - contains: + path: spec.variants[0].dependsOn + content: cozystack.postgres-operator + - contains: + path: spec.variants[0].dependsOn + content: cozystack.victoria-metrics-operator diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index 1bc107019..a38d03403 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -104,12 +104,13 @@ ### OIDC authentication -| Name | Description | Type | Value | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------ | -| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | -| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | -| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | -| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | -| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | -| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | +| Name | Description | Type | Value | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | +| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | +| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | +| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | +| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | +| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | +| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | +| `oidc.grafanaAdmin` | Promote members of the `-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin. | `bool` | `false` | diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 25b125492..5abff139b 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -628,6 +628,11 @@ } } }, + "grafanaAdmin": { + "description": "Promote members of the `\u003cclientId\u003e-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin.", + "type": "boolean", + "default": false + }, "mode": { "description": "Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.", "type": "string", diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index b67c5ae99..6be333bc0 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -205,6 +205,7 @@ vmagent: ## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm). ## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. ## @field {OIDCCustomConfig} [customConfig] - Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. +## @field {bool} [grafanaAdmin] - Promote members of the `-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin. ## @param {OIDC} oidc - OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. oidc: @@ -213,3 +214,4 @@ oidc: config: {} secretRef: name: "" + grafanaAdmin: false diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index a0317a7ad..f694cf2d1 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -8,7 +8,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"grafanaAdmin":{"description":"Promote members of the `-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin.","type":"boolean","default":false},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]}}}}} release: prefix: "" labels: @@ -26,7 +26,7 @@ spec: description: Monitoring and observability stack module: true icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzI2OCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zMjY4KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMzg0NTRGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNNTQuMTcyMiAzOC43NzU3SDMzLjEwMzJDMzIuMTMwNyAzOC43NzU3IDMxLjQ4MjQgMzguMTI3NCAzMS40ODI0IDM3LjE1NDlDMzEuNDgyNCAzNi4xODI0IDMyLjEzMDcgMzUuNTM0MiAzMy4xMDMyIDM1LjUzNDJINTQuMTcyMkM1NS4xNDQ3IDM1LjUzNDIgNTUuNzkzIDM2LjE4MjQgNTUuNzkzIDM3LjE1NDlDNTUuNzkyOCAzOC4xMjc0IDU1LjE0NDUgMzguNzc1NyA1NC4xNzIyIDM4Ljc3NTdaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik02My44OTYzIDQ1LjI1OTFINDEuMjA2N0M0MC4yMzQyIDQ1LjI1OTEgMzkuNTg1OSA0NC42MTA4IDM5LjU4NTkgNDMuNjM4M0MzOS41ODU5IDQyLjY2NTggNDAuMjM0MiA0Mi4wMTc2IDQxLjIwNjcgNDIuMDE3Nkg2My44OTYzQzY0Ljg2ODggNDIuMDE3NiA2NS41MTcxIDQyLjY2NTggNjUuNTE3MSA0My42MzgzQzY1LjUxNzEgNDQuNjEwOCA2NC44Njg4IDQ1LjI1OTEgNjMuODk2MyA0NS4yNTkxWiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMzQuNzI0IDQ1LjI1OTFIMzMuMTAzMkMzMi4xMzA3IDQ1LjI1OTEgMzEuNDgyNCA0NC42MTA4IDMxLjQ4MjQgNDMuNjM4M0MzMS40ODI0IDQyLjY2NTggMzIuMTMwNyA0Mi4wMTc2IDMzLjEwMzIgNDIuMDE3NkgzNC43MjRDMzUuNjk2NCA0Mi4wMTc2IDM2LjM0NDcgNDIuNjY1OCAzNi4zNDQ3IDQzLjYzODNDMzYuMzQ0NyA0NC42MTA4IDM1LjY5NjMgNDUuMjU5MSAzNC43MjQgNDUuMjU5MVoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgMzguNzc1N0g2MC42NTQ5QzU5LjY4MjQgMzguNzc1NyA1OS4wMzQyIDM4LjEyNzQgNTkuMDM0MiAzNy4xNTQ5QzU5LjAzNDIgMzYuMTgyNCA1OS42ODI0IDM1LjUzNDIgNjAuNjU0OSAzNS41MzQySDYzLjg5NjNDNjQuODY4OCAzNS41MzQyIDY1LjUxNzEgMzYuMTgyNCA2NS41MTcxIDM3LjE1NDlDNjUuNTE3MSAzOC4xMjc0IDY0Ljg2ODggMzguNzc1NyA2My44OTYzIDM4Ljc3NTdaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik00Ny42ODkzIDUxLjc0MTNIMzMuMTAzMkMzMi4xMzA3IDUxLjc0MTMgMzEuNDgyNCA1MS4wOTMxIDMxLjQ4MjQgNTAuMTIwNkMzMS40ODI0IDQ5LjE0ODEgMzIuMTMwNyA0OC41IDMzLjEwMzIgNDguNUg0Ny42ODkzQzQ4LjY2MTggNDguNSA0OS4zMTAxIDQ5LjE0ODMgNDkuMzEwMSA1MC4xMjA4QzQ5LjMxMDEgNTEuMDkzMyA0OC42NjE4IDUxLjc0MTMgNDcuNjg5MyA1MS43NDEzWiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNjMuODk2OCA1MS43NDEzSDU0LjE3MjVDNTMuMiA1MS43NDEzIDUyLjU1MTggNTEuMDkzMSA1Mi41NTE4IDUwLjEyMDZDNTIuNTUxOCA0OS4xNDgxIDUzLjIwMDIgNDguNSA1NC4xNzI3IDQ4LjVINjMuODk2OUM2NC44Njk0IDQ4LjUgNjUuNTE3NyA0OS4xNDgzIDY1LjUxNzcgNTAuMTIwOEM2NS41MTc3IDUxLjA5MzMgNjQuODY5MiA1MS43NDEzIDYzLjg5NjggNTEuNzQxM1oiIGZpbGw9IiNFQ0JBMTYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNTguMjI0SDMzLjEwMzJDMzIuMTMwNyA1OC4yMjQgMzEuNDgyNCA1Ny41NzU3IDMxLjQ4MjQgNTYuNjAzMkMzMS40ODI0IDU1LjYzMDcgMzIuMTMwNyA1NC45ODI0IDMzLjEwMzIgNTQuOTgyNEg1NC4xNzIyQzU1LjE0NDcgNTQuOTgyNCA1NS43OTMgNTUuNjMwNyA1NS43OTMgNTYuNjAzMkM1NS43OTMgNTcuNTc1NyA1NS4xNDQ1IDU4LjIyNCA1NC4xNzIyIDU4LjIyNFoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgNjQuNzA3NEg0MS4yMDY3QzQwLjIzNDIgNjQuNzA3NCAzOS41ODU5IDY0LjA1OTEgMzkuNTg1OSA2My4wODY2QzM5LjU4NTkgNjIuMTE0MSA0MC4yMzQyIDYxLjQ2NTggNDEuMjA2NyA2MS40NjU4SDYzLjg5NjNDNjQuODY4OCA2MS40NjU4IDY1LjUxNzEgNjIuMTE0MSA2NS41MTcxIDYzLjA4NjZDNjUuNTE3MSA2NC4wNTkxIDY0Ljg2ODggNjQuNzA3NCA2My44OTYzIDY0LjcwNzRaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik0zNC43MjQgNjQuNzA3NEgzMy4xMDMyQzMyLjEzMDcgNjQuNzA3NCAzMS40ODI0IDY0LjA1OTEgMzEuNDgyNCA2My4wODY2QzMxLjQ4MjQgNjIuMTE0MSAzMi4xMzA3IDYxLjQ2NTggMzMuMTAzMiA2MS40NjU4SDM0LjcyNEMzNS42OTY0IDYxLjQ2NTggMzYuMzQ0NyA2Mi4xMTQxIDM2LjM0NDcgNjMuMDg2NkMzNi4zNDQ3IDY0LjA1OTEgMzUuNjk2MyA2NC43MDc0IDM0LjcyNCA2NC43MDc0WiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNDcuNjg5MyA3MS4xODk4SDMzLjEwMzJDMzIuMTMwNyA3MS4xODk4IDMxLjQ4MjQgNzAuNTQxNSAzMS40ODI0IDY5LjU2OUMzMS40ODI0IDY4LjU5NjUgMzIuMTMwNyA2Ny45NDgyIDMzLjEwMzIgNjcuOTQ4Mkg0Ny42ODkzQzQ4LjY2MTggNjcuOTQ4MiA0OS4zMTAxIDY4LjU5NjUgNDkuMzEwMSA2OS41NjlDNDkuMzEwMSA3MC41NDE1IDQ4LjY2MTggNzEuMTg5OCA0Ny42ODkzIDcxLjE4OThaIiBmaWxsPSIjNDJCMDVDIi8+CjxwYXRoIGQ9Ik02My44OTY4IDcxLjE4OThINTQuMTcyNUM1My4yIDcxLjE4OTggNTIuNTUxOCA3MC41NDE1IDUyLjU1MTggNjkuNTY5QzUyLjU1MTggNjguNTk2NSA1My4yIDY3Ljk0ODIgNTQuMTcyNSA2Ny45NDgySDYzLjg5NjhDNjQuODY5MiA2Ny45NDgyIDY1LjUxNzUgNjguNTk2NSA2NS41MTc1IDY5LjU2OUM2NS41MTc1IDcwLjU0MTUgNjQuODY5MiA3MS4xODk4IDYzLjg5NjggNzEuMTg5OFoiIGZpbGw9IiM3MzgzQkYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNzcuNjcyMkgzMy4xMDMyQzMyLjEzMDcgNzcuNjcyMiAzMS40ODI0IDc3LjAyMzkgMzEuNDgyNCA3Ni4wNTE0QzMxLjQ4MjQgNzUuMDc4OSAzMi4xMzA3IDc0LjQzMDcgMzMuMTAzMiA3NC40MzA3SDU0LjE3MjJDNTUuMTQ0NyA3NC40MzA3IDU1Ljc5MyA3NS4wNzg5IDU1Ljc5MyA3Ni4wNTE0QzU1Ljc5MjggNzcuMDIzOSA1NS4xNDQ1IDc3LjY3MjIgNTQuMTcyMiA3Ny42NzIyWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNNjMuODk2MyA3Ny42NzIySDYwLjY1NDlDNTkuNjgyNCA3Ny42NzIyIDU5LjAzNDIgNzcuMDIzOSA1OS4wMzQyIDc2LjA1MTRDNTkuMDM0MiA3NS4wNzg5IDU5LjY4MjQgNzQuNDMwNyA2MC42NTQ5IDc0LjQzMDdINjMuODk2M0M2NC44Njg4IDc0LjQzMDcgNjUuNTE3MSA3NS4wNzg5IDY1LjUxNzEgNzYuMDUxNEM2NS41MTcxIDc3LjAyMzkgNjQuODY4OCA3Ny42NzIyIDYzLjg5NjMgNzcuNjcyMloiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTEwMS4xNzIgNzcuNjcyMkg4MC4xMDMyQzc5LjEzMDcgNzcuNjcyMiA3OC40ODI0IDc3LjAyMzkgNzguNDgyNCA3Ni4wNTE0Qzc4LjQ4MjQgNzUuMDc4OSA3OS4xMzA3IDc0LjQzMDcgODAuMTAzMiA3NC40MzA3SDEwMS4xNzJDMTAyLjE0NSA3NC40MzA3IDEwMi43OTMgNzUuMDc4OSAxMDIuNzkzIDc2LjA1MTRDMTAyLjc5MyA3Ny4wMjM5IDEwMi4xNDUgNzcuNjcyMiAxMDEuMTcyIDc3LjY3MjJaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik0xMTAuODk2IDc3LjY3MjJIMTA3LjY1NUMxMDYuNjgyIDc3LjY3MjIgMTA2LjAzNCA3Ny4wMjM5IDEwNi4wMzQgNzYuMDUxNEMxMDYuMDM0IDc1LjA3ODkgMTA2LjY4MiA3NC40MzA3IDEwNy42NTUgNzQuNDMwN0gxMTAuODk2QzExMS44NjkgNzQuNDMwNyAxMTIuNTE3IDc1LjA3ODkgMTEyLjUxNyA3Ni4wNTE0QzExMi41MTcgNzcuMDIzOSAxMTEuODY5IDc3LjY3MjIgMTEwLjg5NiA3Ny42NzIyWiIgZmlsbD0iIzQyQjA1QyIvPgo8cGF0aCBkPSJNNjMuODk2MyA1OC4yMjRINjAuNjU0OUM1OS42ODI0IDU4LjIyNCA1OS4wMzQyIDU3LjU3NTcgNTkuMDM0MiA1Ni42MDMyQzU5LjAzNDIgNTUuNjMwNyA1OS42ODI0IDU0Ljk4MjQgNjAuNjU0OSA1NC45ODI0SDYzLjg5NjNDNjQuODY4OCA1NC45ODI0IDY1LjUxNzEgNTUuNjMwNyA2NS41MTcxIDU2LjYwMzJDNjUuNTE3MSA1Ny41NzU3IDY0Ljg2ODggNTguMjI0IDYzLjg5NjMgNTguMjI0WiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMTEyLjUxNyA1MS43NDExQzExMi41MTcgNjAuNjU0OSAxMDUuMjI0IDY3Ljk0OCA5Ni4zMTA0IDY3Ljk0OEM4Ny4zOTY2IDY3Ljk0OCA4MC4xMDM1IDYwLjY1NDkgODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNDIuODI3MyA4Ny4zOTY2IDM1LjUzNDIgOTYuMzEwNCAzNS41MzQyQzEwNS4yMjQgMzUuNTM0MiAxMTIuNTE3IDQyLjgyNzMgMTEyLjUxNyA1MS43NDExWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNTIuMjI3MyA4MC4xMDM1IDUyLjg3NTUgODAuMTAzNSA1My4zNjE5SDk2LjMxMDRWMzUuNTM0MkM4Ny4zOTY2IDM1LjUzNDIgODAuMTAzNSA0Mi44MjczIDgwLjEwMzUgNTEuNzQxMVoiIGZpbGw9IiM0MkIwNUMiLz4KPC9nPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4N18zMjY4IiB4MT0iMS4yMzIzOWUtMDYiIHkxPSItOS41MDAwMSIgeDI9IjE2OCIgeTI9IjE2MiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjOEZEREZGIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzAwNzVGRiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzY4N18zMjY4Ij4KPHJlY3Qgd2lkdGg9Ijk0IiBoZWlnaHQ9Ijk0IiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUgMjUpIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg== - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"], ["spec", "oidc", "grafanaAdmin"]] secrets: exclude: [] include: diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index ba9a208b1..856296f53 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -54,15 +54,16 @@ {{- 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. + Whether to promote `-admin` group members to server-level + `GrafanaAdmin` (Grafana's `allow_assign_grafana_admin` flag). Driven + by an explicit values field so the platform bundle can flip it on + for the platform release via + `Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin` + without the chart having to sniff `.Release.Name`. */}} {{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}} -{{- if eq .Release.Name "monitoring-system" -}} +{{- $grafanaAdmin := dig "grafanaAdmin" false (.Values.oidc | default dict) -}} +{{- if $grafanaAdmin -}} true {{- else -}} false diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 97ef38110..0f22600dc 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -316,19 +316,26 @@ tests: errorMessage: "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." ########################################################## - # Platform release — allow_assign_grafana_admin gets flipped + # allow_assign_grafana_admin is driven by oidc.grafanaAdmin ########################################################## - - it: platform release (Release.Name == monitoring-system) upgrades Admin to GrafanaAdmin + - it: oidc.grafanaAdmin=true upgrades Admin to server-level GrafanaAdmin template: templates/grafana/grafana.yaml - release: - name: monitoring-system - namespace: cozy-monitoring set: oidc.mode: System + oidc.grafanaAdmin: true _cluster.oidc-enabled: "true" - _namespace.host: cozy-monitoring.example.org asserts: - equal: path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin value: "true" + + - it: oidc.grafanaAdmin default is false (tenant releases stay at org-level Admin) + template: templates/grafana/grafana.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin + value: "false" diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index ca3c7464a..8d28d329a 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -125,3 +125,10 @@ oidc: ## `customConfig.config`. secretRef: name: "" + ## Promote `-admin` group members to server-level + ## `GrafanaAdmin` via `allow_assign_grafana_admin`. Set true for the + ## platform Grafana release only (`monitoring-system` in the + ## `cozy-monitoring` namespace) — the platform bundle passes this + ## flag through Package.spec.components; tenant Grafana releases + ## keep this false and stay at org-level Admin. + grafanaAdmin: false From be52e80289dfd172939695db87ef751056f39429 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sat, 4 Jul 2026 23:22:27 +0300 Subject: [PATCH 10/39] fix(monitoring): split monitoring-application PackageSource into default/oidc variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous self-review-fix commit: the unconditional `- cozystack.keycloak-operator` in dependsOn was itself a CRITICAL cold-install regression on any cluster where `authentication.oidc.enabled: false` (the default). The keycloak-operator Package is only emitted by `packages/core/platform/templates/bundles/system.yaml:112` when that flag is on — a hard dep against it from the default variant would leave `cozystack.monitoring-application` stuck on DependenciesNotReady forever, cascading a `cozystack-platform` HR timeout that hits every OIDC-off customer. Fix by mirroring the cozystack-engine variant split: - `default` variant keeps the baseline reconciliation edges (no keycloak-operator dep). - `oidc` variant additionally waits for `cozystack.keycloak-operator` so the v1.edp.epam.com CRDs are registered before the monitoring chart reconciles and the `.Capabilities.APIVersions.Has` guard in templates/grafana/oidc-keycloak.yaml can safely emit KeycloakClient/Scope/Groups. The bundle template swings between variants on `.Values.authentication.oidc.enabled`, mirroring the cozystack-engine invocation two lines above. The `monitoring-system` component override (oidc.grafanaAdmin: true) is preserved across both branches — the values field is a no-op when mode: None (no auth.generic_oauth block is emitted at all), so keeping it identical avoids two divergent code paths. Also widened the platform helm-unittest coverage to lock: - default variant does NOT depend on keycloak-operator (permanent DependenciesNotReady guard). - oidc variant DOES. - Bundle emits the right variant based on authentication.oidc.enabled AND keeps the GrafanaAdmin uplift on the monitoring-system component in either branch. 83 platform tests + 23 monitoring tests still green. Signed-off-by: Ivan Okhotnikov --- docs/oidc-grafana.md | 7 +- .../sources/monitoring-application.yaml | 42 ++++++++--- .../platform/templates/bundles/system.yaml | 12 +++- ...bundles_monitoring_grafana_admin_test.yaml | 50 ++++++++++++-- ...monitoring_application_dependson_test.yaml | 69 ++++++++++++++----- 5 files changed, 144 insertions(+), 36 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index 00e855397..7a4bdb626 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -185,7 +185,12 @@ work — useful when Keycloak is down or misconfigured. render hard-fails: `spec.oidc.mode: System requires the platform-level OIDC feature`. Flip `authentication.oidc.enabled: true` in the platform values, or - switch to `CustomConfig`. + switch to `CustomConfig`. On the platform side that flag also + swings the `cozystack.monitoring-application` PackageSource from + variant `default` (baseline dependsOn only) to variant `oidc` + (additionally waits for `cozystack.keycloak-operator` so the + `v1.edp.epam.com` CRDs consumed by the Keycloak-side of System + mode are registered before the monitoring chart reconciles). - **`CustomConfig` with an unreachable issuer or wrong claim mappings** — Grafana rejects the callback and the login screen shows an error; the `admin_user`/`admin_password` Secret keeps diff --git a/packages/core/platform/sources/monitoring-application.yaml b/packages/core/platform/sources/monitoring-application.yaml index 7621b4825..c08bf8ced 100644 --- a/packages/core/platform/sources/monitoring-application.yaml +++ b/packages/core/platform/sources/monitoring-application.yaml @@ -18,14 +18,40 @@ spec: - cozystack.victoria-metrics-operator # cozystack-engine ships the ApplicationDefinition CRD rendered by the *-rd component below. - cozystack.cozystack-engine - # keycloak-operator ships the v1.edp.epam.com CRDs consumed by - # the OIDC selector (spec.oidc.mode: System renders KeycloakClient, - # KeycloakClientScope and three KeycloakRealmGroups in the cozy - # realm). Without this dep the monitoring chart can reconcile - # before the CRDs are registered on cold install — the - # .Capabilities.APIVersions.Has guard then silently drops all - # Keycloak-side objects and Grafana's auth.generic_oauth block - # points at a client that never gets provisioned. + 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 + # `oidc` variant — additionally waits for cozystack.keycloak-operator so + # the v1.edp.epam.com CRDs consumed by the Phase-1 OIDC selector + # (spec.oidc.mode: System renders KeycloakClient, KeycloakClientScope and + # three KeycloakRealmGroups in the cozy realm) are registered before the + # monitoring chart reconciles. Without this edge, the + # `.Capabilities.APIVersions.Has "v1.edp.epam.com/v1"` guard in + # templates/grafana/oidc-keycloak.yaml would silently drop the whole + # Keycloak side and Grafana's auth.generic_oauth block would point at + # a client that never gets provisioned. Selected from the bundle when + # `authentication.oidc.enabled` is true — see + # packages/core/platform/templates/bundles/system.yaml. Mirrors the + # cozystack-engine default/oidc variant split. + - name: oidc + dependsOn: + - cozystack.networking + - cozystack.postgres-operator + - cozystack.grafana-operator + - cozystack.victoria-metrics-operator + - cozystack.cozystack-engine - cozystack.keycloak-operator libraries: - name: cozy-lib diff --git a/packages/core/platform/templates/bundles/system.yaml b/packages/core/platform/templates/bundles/system.yaml index d4ddfc4c1..8ae9702b0 100644 --- a/packages/core/platform/templates/bundles/system.yaml +++ b/packages/core/platform/templates/bundles/system.yaml @@ -196,9 +196,19 @@ so `-admin` group members become server-level Grafana admins. Tenant Grafana releases (component `monitoring`) inherit the chart default `oidc.grafanaAdmin: false` and stay at - org-level Admin. See docs/oidc-grafana.md and cozystack#3176. */}} + org-level Admin. See docs/oidc-grafana.md and cozystack#3176. + + Variant selection mirrors cozystack-engine above: `oidc` when + platform-level OIDC is on (extra dependsOn on + cozystack.keycloak-operator so the v1.edp.epam.com CRDs are + registered before the monitoring chart reconciles), `default` + otherwise (no wait on a Package that would not be emitted). */}} {{- $monitoringApplicationComponents := dict "monitoring-system" (dict "values" (dict "oidc" (dict "grafanaAdmin" true))) -}} +{{- if .Values.authentication.oidc.enabled }} +{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "oidc" $ $monitoringApplicationComponents) }} +{{- else }} {{include "cozystack.platform.package" (list "cozystack.monitoring-application" "default" $ $monitoringApplicationComponents) }} +{{- end }} {{include "cozystack.platform.package.default" (list "cozystack.etcd-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.cozystack-basics" $) }} {{include "cozystack.platform.package.default" (list "cozystack.backupstrategy-controller" $) }} diff --git a/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml b/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml index c45e8f8aa..76c18e1f1 100644 --- a/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml +++ b/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml @@ -1,16 +1,32 @@ -suite: bundles.system monitoring-application component wiring (Grafana OIDC uplift) +suite: bundles.system monitoring-application variant + component wiring +# Two invariants exercised together, because they share the same Package +# emission site: +# 1. `authentication.oidc.enabled` gates the PackageSource variant — +# `oidc` when on (extra dep on cozystack.keycloak-operator so the +# v1.edp.epam.com CRDs are registered before the monitoring chart +# reconciles), `default` when off (no wait on a Package that would +# not be emitted). +# 2. Both variants MUST carry the GrafanaAdmin uplift for the platform +# Grafana release (`monitoring-system`) via +# Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin. +# Tenant Grafana (component `monitoring`) inherits the chart default +# `oidc.grafanaAdmin: false` and stays at org-level Admin — regression +# guard the absence of a `monitoring` override here. templates: - templates/bundles/system.yaml release: name: cozystack namespace: cozy-system tests: - - it: monitoring-application Package emits the GrafanaAdmin uplift for the platform Grafana release + - it: authentication.oidc.enabled=false selects default variant and keeps the platform GrafanaAdmin uplift set: bundles: system: enabled: true variant: isp-full + authentication: + oidc: + enabled: false documentSelector: path: metadata.name value: cozystack.monitoring-application @@ -21,11 +37,31 @@ tests: - equal: path: spec.variant value: default - # The platform Grafana release (component `monitoring-system`) is - # promoted to server-level GrafanaAdmin via the explicit values - # override. Tenant Grafana (component `monitoring`) is NOT listed - # in .spec.components — inherits the chart default - # oidc.grafanaAdmin: false and stays at org-level Admin. + - equal: + path: spec.components.monitoring-system.values.oidc.grafanaAdmin + value: true + - notExists: + path: spec.components.monitoring.values.oidc.grafanaAdmin + + - it: authentication.oidc.enabled=true selects oidc variant and keeps the platform GrafanaAdmin uplift + set: + bundles: + system: + enabled: true + variant: isp-full + authentication: + oidc: + enabled: true + documentSelector: + path: metadata.name + value: cozystack.monitoring-application + asserts: + - equal: + path: kind + value: Package + - equal: + path: spec.variant + value: oidc - equal: path: spec.components.monitoring-system.values.oidc.grafanaAdmin value: true diff --git a/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml b/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml index 27fec8a86..70bc7e59b 100644 --- a/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml +++ b/packages/core/platform/tests/sources_monitoring_application_dependson_test.yaml @@ -1,39 +1,70 @@ -suite: monitoring-application waits for the Keycloak operator that owns the OIDC CRDs +suite: monitoring-application variant ordering edges (default + oidc) # Regression guard for a fresh-install ordering race introduced by the Phase-1 -# OIDC selector (cozystack/cozystack#3176). Without the keycloak-operator edge, -# the monitoring chart can reconcile before the v1.edp.epam.com CRDs are -# registered on cold install — the `.Capabilities.APIVersions.Has` guard in -# templates/grafana/oidc-keycloak.yaml silently drops the whole Keycloak side -# of System mode (KeycloakClient / KeycloakClientScope / KeycloakRealmGroups), -# Grafana boots with an auth.generic_oauth block pointing at a client that was -# never provisioned, and OIDC login fails at request time with no build-time -# signal. +# OIDC selector (cozystack/cozystack#3176). The `oidc` variant additionally +# depends on cozystack.keycloak-operator so the v1.edp.epam.com CRDs are +# registered before the monitoring chart reconciles — without this edge the +# `.Capabilities.APIVersions.Has` guard in +# templates/grafana/oidc-keycloak.yaml would silently drop the whole +# Keycloak side of mode: System and Grafana's auth.generic_oauth block would +# point at a client that never gets provisioned. The `default` variant MUST +# NOT depend on keycloak-operator — that Package is only emitted when +# authentication.oidc.enabled is true, so a hard dep on the default variant +# would leave every OIDC-off cluster stuck on DependenciesNotReady forever. templates: - templates/sources.yaml release: name: cozystack namespace: cozy-system tests: - - it: monitoring-application dependsOn keycloak-operator (OIDC CRDs) + - it: default variant carries the baseline reconciliation edges without keycloak-operator documentSelector: path: metadata.name value: cozystack.monitoring-application asserts: - - contains: - path: spec.variants[0].dependsOn - content: cozystack.keycloak-operator - # The other deps we rely on transitively for the Grafana instance to - # even come up on a fresh cluster; regression-guard them together so - # a future edit does not accidentally drop the ordering edges. - contains: path: spec.variants[0].dependsOn content: cozystack.networking - - contains: - path: spec.variants[0].dependsOn - content: cozystack.grafana-operator - contains: path: spec.variants[0].dependsOn content: cozystack.postgres-operator + - contains: + path: spec.variants[0].dependsOn + content: cozystack.grafana-operator - contains: path: spec.variants[0].dependsOn content: cozystack.victoria-metrics-operator + - contains: + path: spec.variants[0].dependsOn + content: cozystack.cozystack-engine + - notContains: + path: spec.variants[0].dependsOn + content: cozystack.keycloak-operator + + - it: oidc variant additionally waits for keycloak-operator (OIDC CRDs) + documentSelector: + path: metadata.name + value: cozystack.monitoring-application + asserts: + - equal: + path: spec.variants[1].name + value: oidc + - contains: + path: spec.variants[1].dependsOn + content: cozystack.keycloak-operator + # oidc variant inherits the baseline edges (regression guard against + # someone dropping one when refactoring the variant). + - contains: + path: spec.variants[1].dependsOn + content: cozystack.networking + - contains: + path: spec.variants[1].dependsOn + content: cozystack.grafana-operator + - contains: + path: spec.variants[1].dependsOn + content: cozystack.postgres-operator + - contains: + path: spec.variants[1].dependsOn + content: cozystack.victoria-metrics-operator + - contains: + path: spec.variants[1].dependsOn + content: cozystack.cozystack-engine From e05ca91ff07a73719c9b478895c541f8f2aaf4ef Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 08:53:25 +0300 Subject: [PATCH 11/39] fix(monitoring): keep OIDC grafanaAdmin as a platform-only lever The tenant-facing `Monitoring` CR exposed `spec.oidc.grafanaAdmin` via `packages/extra/monitoring/values.schema.json`, which the ApplicationDefinition mirrored into `openAPISchema`. A tenant with create/patch on `Monitoring` in their own namespace could set the flag on their release, promoting members of their `-admin` Keycloak group to server-level `GrafanaAdmin` instead of the intended org-level Admin. Drop the field from the tenant surface. The platform bundle still flips it on for the `monitoring-system` component through `Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin` (the platform Grafana chart continues to read the value verbatim in `packages/system/monitoring/values.yaml`); tenant Grafana releases inherit the chart default `false` and stay at org-level Admin. Signed-off-by: Ivan Okhotnikov --- docs/oidc-grafana.md | 31 +++++++++++++------ packages/extra/monitoring/README.md | 1 - packages/extra/monitoring/values.schema.json | 5 --- packages/extra/monitoring/values.yaml | 2 -- .../monitoring-rd/cozyrds/monitoring.yaml | 4 +-- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index 7a4bdb626..ca6662209 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -81,16 +81,15 @@ CR: 6. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana Deployment sourced from the Secret in step 4. -Server-level `GrafanaAdmin` promotion is opt-in via -`spec.oidc.grafanaAdmin: true`. When set, an `-admin` group -member is auto-promoted to `GrafanaAdmin` (via -`allow_assign_grafana_admin: true` in the rendered `[auth.generic_oauth]` -block). The platform bundle -(`packages/core/platform/templates/bundles/system.yaml`) flips it on for -the platform Grafana release via -`Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin: true`; -tenant Grafana releases inherit the chart default `false` and stay at -org-level Admin. +Server-level `GrafanaAdmin` promotion is a platform-only lever, not +exposed on the tenant `Monitoring` CR — a tenant cannot self-promote +their `-admin` group members to `GrafanaAdmin`. The platform +bundle (`packages/core/platform/templates/bundles/system.yaml`) flips +it on for the platform Grafana release via +`Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin: true`, +which sets `allow_assign_grafana_admin: true` in the rendered +`[auth.generic_oauth]` block. Tenant Grafana releases inherit the +chart default `false` and stay at org-level Admin. ## CustomConfig mode @@ -191,6 +190,18 @@ work — useful when Keycloak is down or misconfigured. (additionally waits for `cozystack.keycloak-operator` so the `v1.edp.epam.com` CRDs consumed by the Keycloak-side of System mode are registered before the monitoring chart reconciles). +- **`mode: System` without the Keycloak operator CRDs registered** — + chart render hard-fails: + `spec.oidc.mode: System requires the Keycloak operator CRDs + (v1.edp.epam.com/v1)`. Symmetric across + `templates/grafana/oidc-keycloak.yaml` and + `templates/grafana/grafana.yaml` so Grafana never comes up with an + `auth.generic_oauth` block pointing at a client that never gets + provisioned. For the platform `monitoring-system` release the + `oidc` variant's `dependsOn: cozystack.keycloak-operator` prevents + the race; for tenant releases (`Monitoring` CR with + `mode: System`) verify the keycloak-operator package is deployed + before creating the CR. - **`CustomConfig` with an unreachable issuer or wrong claim mappings** — Grafana rejects the callback and the login screen shows an error; the `admin_user`/`admin_password` Secret keeps diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index a38d03403..4506f54ca 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -112,5 +112,4 @@ | `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | | `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | | `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | -| `oidc.grafanaAdmin` | Promote members of the `-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin. | `bool` | `false` | diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 5abff139b..25b125492 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -628,11 +628,6 @@ } } }, - "grafanaAdmin": { - "description": "Promote members of the `\u003cclientId\u003e-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin.", - "type": "boolean", - "default": false - }, "mode": { "description": "Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.", "type": "string", diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index 6be333bc0..b67c5ae99 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -205,7 +205,6 @@ vmagent: ## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm). ## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. ## @field {OIDCCustomConfig} [customConfig] - Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. -## @field {bool} [grafanaAdmin] - Promote members of the `-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin. ## @param {OIDC} oidc - OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. oidc: @@ -214,4 +213,3 @@ oidc: config: {} secretRef: name: "" - grafanaAdmin: false diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index f694cf2d1..a0317a7ad 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -8,7 +8,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"grafanaAdmin":{"description":"Promote members of the `-admin` group to server-level `GrafanaAdmin` (sets `allow_assign_grafana_admin: true` in `[auth.generic_oauth]`). Intended for the platform release; tenant Grafana keeps org-level Admin.","type":"boolean","default":false},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]}}}}} release: prefix: "" labels: @@ -26,7 +26,7 @@ spec: description: Monitoring and observability stack module: true icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzI2OCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zMjY4KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMzg0NTRGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNNTQuMTcyMiAzOC43NzU3SDMzLjEwMzJDMzIuMTMwNyAzOC43NzU3IDMxLjQ4MjQgMzguMTI3NCAzMS40ODI0IDM3LjE1NDlDMzEuNDgyNCAzNi4xODI0IDMyLjEzMDcgMzUuNTM0MiAzMy4xMDMyIDM1LjUzNDJINTQuMTcyMkM1NS4xNDQ3IDM1LjUzNDIgNTUuNzkzIDM2LjE4MjQgNTUuNzkzIDM3LjE1NDlDNTUuNzkyOCAzOC4xMjc0IDU1LjE0NDUgMzguNzc1NyA1NC4xNzIyIDM4Ljc3NTdaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik02My44OTYzIDQ1LjI1OTFINDEuMjA2N0M0MC4yMzQyIDQ1LjI1OTEgMzkuNTg1OSA0NC42MTA4IDM5LjU4NTkgNDMuNjM4M0MzOS41ODU5IDQyLjY2NTggNDAuMjM0MiA0Mi4wMTc2IDQxLjIwNjcgNDIuMDE3Nkg2My44OTYzQzY0Ljg2ODggNDIuMDE3NiA2NS41MTcxIDQyLjY2NTggNjUuNTE3MSA0My42MzgzQzY1LjUxNzEgNDQuNjEwOCA2NC44Njg4IDQ1LjI1OTEgNjMuODk2MyA0NS4yNTkxWiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMzQuNzI0IDQ1LjI1OTFIMzMuMTAzMkMzMi4xMzA3IDQ1LjI1OTEgMzEuNDgyNCA0NC42MTA4IDMxLjQ4MjQgNDMuNjM4M0MzMS40ODI0IDQyLjY2NTggMzIuMTMwNyA0Mi4wMTc2IDMzLjEwMzIgNDIuMDE3NkgzNC43MjRDMzUuNjk2NCA0Mi4wMTc2IDM2LjM0NDcgNDIuNjY1OCAzNi4zNDQ3IDQzLjYzODNDMzYuMzQ0NyA0NC42MTA4IDM1LjY5NjMgNDUuMjU5MSAzNC43MjQgNDUuMjU5MVoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgMzguNzc1N0g2MC42NTQ5QzU5LjY4MjQgMzguNzc1NyA1OS4wMzQyIDM4LjEyNzQgNTkuMDM0MiAzNy4xNTQ5QzU5LjAzNDIgMzYuMTgyNCA1OS42ODI0IDM1LjUzNDIgNjAuNjU0OSAzNS41MzQySDYzLjg5NjNDNjQuODY4OCAzNS41MzQyIDY1LjUxNzEgMzYuMTgyNCA2NS41MTcxIDM3LjE1NDlDNjUuNTE3MSAzOC4xMjc0IDY0Ljg2ODggMzguNzc1NyA2My44OTYzIDM4Ljc3NTdaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik00Ny42ODkzIDUxLjc0MTNIMzMuMTAzMkMzMi4xMzA3IDUxLjc0MTMgMzEuNDgyNCA1MS4wOTMxIDMxLjQ4MjQgNTAuMTIwNkMzMS40ODI0IDQ5LjE0ODEgMzIuMTMwNyA0OC41IDMzLjEwMzIgNDguNUg0Ny42ODkzQzQ4LjY2MTggNDguNSA0OS4zMTAxIDQ5LjE0ODMgNDkuMzEwMSA1MC4xMjA4QzQ5LjMxMDEgNTEuMDkzMyA0OC42NjE4IDUxLjc0MTMgNDcuNjg5MyA1MS43NDEzWiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNjMuODk2OCA1MS43NDEzSDU0LjE3MjVDNTMuMiA1MS43NDEzIDUyLjU1MTggNTEuMDkzMSA1Mi41NTE4IDUwLjEyMDZDNTIuNTUxOCA0OS4xNDgxIDUzLjIwMDIgNDguNSA1NC4xNzI3IDQ4LjVINjMuODk2OUM2NC44Njk0IDQ4LjUgNjUuNTE3NyA0OS4xNDgzIDY1LjUxNzcgNTAuMTIwOEM2NS41MTc3IDUxLjA5MzMgNjQuODY5MiA1MS43NDEzIDYzLjg5NjggNTEuNzQxM1oiIGZpbGw9IiNFQ0JBMTYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNTguMjI0SDMzLjEwMzJDMzIuMTMwNyA1OC4yMjQgMzEuNDgyNCA1Ny41NzU3IDMxLjQ4MjQgNTYuNjAzMkMzMS40ODI0IDU1LjYzMDcgMzIuMTMwNyA1NC45ODI0IDMzLjEwMzIgNTQuOTgyNEg1NC4xNzIyQzU1LjE0NDcgNTQuOTgyNCA1NS43OTMgNTUuNjMwNyA1NS43OTMgNTYuNjAzMkM1NS43OTMgNTcuNTc1NyA1NS4xNDQ1IDU4LjIyNCA1NC4xNzIyIDU4LjIyNFoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgNjQuNzA3NEg0MS4yMDY3QzQwLjIzNDIgNjQuNzA3NCAzOS41ODU5IDY0LjA1OTEgMzkuNTg1OSA2My4wODY2QzM5LjU4NTkgNjIuMTE0MSA0MC4yMzQyIDYxLjQ2NTggNDEuMjA2NyA2MS40NjU4SDYzLjg5NjNDNjQuODY4OCA2MS40NjU4IDY1LjUxNzEgNjIuMTE0MSA2NS41MTcxIDYzLjA4NjZDNjUuNTE3MSA2NC4wNTkxIDY0Ljg2ODggNjQuNzA3NCA2My44OTYzIDY0LjcwNzRaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik0zNC43MjQgNjQuNzA3NEgzMy4xMDMyQzMyLjEzMDcgNjQuNzA3NCAzMS40ODI0IDY0LjA1OTEgMzEuNDgyNCA2My4wODY2QzMxLjQ4MjQgNjIuMTE0MSAzMi4xMzA3IDYxLjQ2NTggMzMuMTAzMiA2MS40NjU4SDM0LjcyNEMzNS42OTY0IDYxLjQ2NTggMzYuMzQ0NyA2Mi4xMTQxIDM2LjM0NDcgNjMuMDg2NkMzNi4zNDQ3IDY0LjA1OTEgMzUuNjk2MyA2NC43MDc0IDM0LjcyNCA2NC43MDc0WiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNDcuNjg5MyA3MS4xODk4SDMzLjEwMzJDMzIuMTMwNyA3MS4xODk4IDMxLjQ4MjQgNzAuNTQxNSAzMS40ODI0IDY5LjU2OUMzMS40ODI0IDY4LjU5NjUgMzIuMTMwNyA2Ny45NDgyIDMzLjEwMzIgNjcuOTQ4Mkg0Ny42ODkzQzQ4LjY2MTggNjcuOTQ4MiA0OS4zMTAxIDY4LjU5NjUgNDkuMzEwMSA2OS41NjlDNDkuMzEwMSA3MC41NDE1IDQ4LjY2MTggNzEuMTg5OCA0Ny42ODkzIDcxLjE4OThaIiBmaWxsPSIjNDJCMDVDIi8+CjxwYXRoIGQ9Ik02My44OTY4IDcxLjE4OThINTQuMTcyNUM1My4yIDcxLjE4OTggNTIuNTUxOCA3MC41NDE1IDUyLjU1MTggNjkuNTY5QzUyLjU1MTggNjguNTk2NSA1My4yIDY3Ljk0ODIgNTQuMTcyNSA2Ny45NDgySDYzLjg5NjhDNjQuODY5MiA2Ny45NDgyIDY1LjUxNzUgNjguNTk2NSA2NS41MTc1IDY5LjU2OUM2NS41MTc1IDcwLjU0MTUgNjQuODY5MiA3MS4xODk4IDYzLjg5NjggNzEuMTg5OFoiIGZpbGw9IiM3MzgzQkYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNzcuNjcyMkgzMy4xMDMyQzMyLjEzMDcgNzcuNjcyMiAzMS40ODI0IDc3LjAyMzkgMzEuNDgyNCA3Ni4wNTE0QzMxLjQ4MjQgNzUuMDc4OSAzMi4xMzA3IDc0LjQzMDcgMzMuMTAzMiA3NC40MzA3SDU0LjE3MjJDNTUuMTQ0NyA3NC40MzA3IDU1Ljc5MyA3NS4wNzg5IDU1Ljc5MyA3Ni4wNTE0QzU1Ljc5MjggNzcuMDIzOSA1NS4xNDQ1IDc3LjY3MjIgNTQuMTcyMiA3Ny42NzIyWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNNjMuODk2MyA3Ny42NzIySDYwLjY1NDlDNTkuNjgyNCA3Ny42NzIyIDU5LjAzNDIgNzcuMDIzOSA1OS4wMzQyIDc2LjA1MTRDNTkuMDM0MiA3NS4wNzg5IDU5LjY4MjQgNzQuNDMwNyA2MC42NTQ5IDc0LjQzMDdINjMuODk2M0M2NC44Njg4IDc0LjQzMDcgNjUuNTE3MSA3NS4wNzg5IDY1LjUxNzEgNzYuMDUxNEM2NS41MTcxIDc3LjAyMzkgNjQuODY4OCA3Ny42NzIyIDYzLjg5NjMgNzcuNjcyMloiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTEwMS4xNzIgNzcuNjcyMkg4MC4xMDMyQzc5LjEzMDcgNzcuNjcyMiA3OC40ODI0IDc3LjAyMzkgNzguNDgyNCA3Ni4wNTE0Qzc4LjQ4MjQgNzUuMDc4OSA3OS4xMzA3IDc0LjQzMDcgODAuMTAzMiA3NC40MzA3SDEwMS4xNzJDMTAyLjE0NSA3NC40MzA3IDEwMi43OTMgNzUuMDc4OSAxMDIuNzkzIDc2LjA1MTRDMTAyLjc5MyA3Ny4wMjM5IDEwMi4xNDUgNzcuNjcyMiAxMDEuMTcyIDc3LjY3MjJaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik0xMTAuODk2IDc3LjY3MjJIMTA3LjY1NUMxMDYuNjgyIDc3LjY3MjIgMTA2LjAzNCA3Ny4wMjM5IDEwNi4wMzQgNzYuMDUxNEMxMDYuMDM0IDc1LjA3ODkgMTA2LjY4MiA3NC40MzA3IDEwNy42NTUgNzQuNDMwN0gxMTAuODk2QzExMS44NjkgNzQuNDMwNyAxMTIuNTE3IDc1LjA3ODkgMTEyLjUxNyA3Ni4wNTE0QzExMi41MTcgNzcuMDIzOSAxMTEuODY5IDc3LjY3MjIgMTEwLjg5NiA3Ny42NzIyWiIgZmlsbD0iIzQyQjA1QyIvPgo8cGF0aCBkPSJNNjMuODk2MyA1OC4yMjRINjAuNjU0OUM1OS42ODI0IDU4LjIyNCA1OS4wMzQyIDU3LjU3NTcgNTkuMDM0MiA1Ni42MDMyQzU5LjAzNDIgNTUuNjMwNyA1OS42ODI0IDU0Ljk4MjQgNjAuNjU0OSA1NC45ODI0SDYzLjg5NjNDNjQuODY4OCA1NC45ODI0IDY1LjUxNzEgNTUuNjMwNyA2NS41MTcxIDU2LjYwMzJDNjUuNTE3MSA1Ny41NzU3IDY0Ljg2ODggNTguMjI0IDYzLjg5NjMgNTguMjI0WiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMTEyLjUxNyA1MS43NDExQzExMi41MTcgNjAuNjU0OSAxMDUuMjI0IDY3Ljk0OCA5Ni4zMTA0IDY3Ljk0OEM4Ny4zOTY2IDY3Ljk0OCA4MC4xMDM1IDYwLjY1NDkgODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNDIuODI3MyA4Ny4zOTY2IDM1LjUzNDIgOTYuMzEwNCAzNS41MzQyQzEwNS4yMjQgMzUuNTM0MiAxMTIuNTE3IDQyLjgyNzMgMTEyLjUxNyA1MS43NDExWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNTIuMjI3MyA4MC4xMDM1IDUyLjg3NTUgODAuMTAzNSA1My4zNjE5SDk2LjMxMDRWMzUuNTM0MkM4Ny4zOTY2IDM1LjUzNDIgODAuMTAzNSA0Mi44MjczIDgwLjEwMzUgNTEuNzQxMVoiIGZpbGw9IiM0MkIwNUMiLz4KPC9nPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4N18zMjY4IiB4MT0iMS4yMzIzOWUtMDYiIHkxPSItOS41MDAwMSIgeDI9IjE2OCIgeTI9IjE2MiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjOEZEREZGIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzAwNzVGRiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzY4N18zMjY4Ij4KPHJlY3Qgd2lkdGg9Ijk0IiBoZWlnaHQ9Ijk0IiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUgMjUpIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg== - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"], ["spec", "oidc", "grafanaAdmin"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"]] secrets: exclude: [] include: From 586e77c361e121540f18f712c91efbca452acc53 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 08:53:36 +0300 Subject: [PATCH 12/39] fix(monitoring): hard-fail System OIDC render on missing Keycloak CRD `mode: System` previously wrapped every Keycloak resource in the `Capabilities.APIVersions.Has "v1.edp.epam.com/v1"` guard, so a cluster where the Keycloak operator CRDs were not yet registered silently dropped the whole Keycloak side while Grafana still came up with an `auth.generic_oauth` block pointing at a client that never got provisioned. Login broke with an opaque OIDC error and no chart-side signal that the platform was misconfigured. Turn the missing CRD into an actionable render error. A new `monitoring.oidc.assertSystemKeycloakCRD` helper fails with a message that points at `cozystack.keycloak-operator`, called from both `templates/grafana/oidc-keycloak.yaml` (replacing the silent capability guard) and `templates/grafana/grafana.yaml` (symmetric defense-in-depth so a future refactor of oidc-keycloak.yaml cannot regress the invariant). For the platform `monitoring-system` release the oidc variant's `dependsOn: cozystack.keycloak-operator` already prevents the race; for tenant releases the assertion converts the misconfiguration into an operator-facing error instead of a broken login flow. Signed-off-by: Ivan Okhotnikov --- .../system/monitoring/templates/_helpers.tpl | 23 +++++++++++ .../monitoring/templates/grafana/grafana.yaml | 11 ++++++ .../templates/grafana/oidc-keycloak.yaml | 21 +++++----- .../tests/oidc_crd_missing_test.yaml | 38 +++++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 packages/system/monitoring/tests/oidc_crd_missing_test.yaml diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index 856296f53..1d7513502 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -82,6 +82,29 @@ false {{- end -}} {{- end -}} +{{- /* + Fail-fast when `mode: System` is requested but the Keycloak operator + CRDs (`v1.edp.epam.com/v1`) are not yet registered in the target + cluster. Without this guard the chart would silently drop the whole + Keycloak side (KeycloakClient / KeycloakClientScope / three + KeycloakRealmGroups) while still rendering Grafana's + `auth.generic_oauth` block pointing at a client that never gets + provisioned — a broken login path with no clear error. For the + platform `monitoring-system` release the `oidc` variant of + `cozystack.monitoring-application` waits on + `cozystack.keycloak-operator` (see + packages/core/platform/sources/monitoring-application.yaml) so the + CRDs are registered before this chart reconciles; the assertion + turns any residual race (or a manual `mode: System` toggle on a + cluster where the operator is not deployed) into an actionable + render error instead of a silent misconfiguration. +*/}} +{{- define "monitoring.oidc.assertSystemKeycloakCRD" -}} +{{- if not (.Capabilities.APIVersions.Has "v1.edp.epam.com/v1") -}} +{{- fail "spec.oidc.mode: System requires the Keycloak operator CRDs (v1.edp.epam.com/v1). If cozystack.keycloak-operator is still bootstrapping this will resolve on the next reconcile; otherwise verify the keycloak-operator package is deployed and its CRDs are registered." -}} +{{- end -}} +{{- end -}} + {{- /* Fail-fast when the CustomConfig branch has neither or both payloads set — mutually exclusive. diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 64f6f5819..179e10455 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -22,6 +22,17 @@ {{- if eq $oidcMode "CustomConfig" }} {{- include "monitoring.oidc.assertCustomConfigXor" . }} {{- end }} +{{- /* + Symmetric assertions with templates/grafana/oidc-keycloak.yaml so a + missing platform OIDC feature or missing Keycloak operator CRD + hard-fails the render instead of bringing Grafana up with an + `auth.generic_oauth` block pointing at a client that never gets + provisioned. +*/}} +{{- if eq $oidcMode "System" }} +{{- include "monitoring.oidc.assertSystemEnabled" . }} +{{- include "monitoring.oidc.assertSystemKeycloakCRD" . }} +{{- end }} {{- $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) }} diff --git a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml index d0f3fc36d..5b777e646 100644 --- a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml @@ -15,9 +15,13 @@ The `_cluster.oidc-enabled` guard catches the case where an operator toggles `mode: System` without the platform-level OIDC - feature (authentication.oidc.enabled) being on — the Keycloak - operator CRDs would not be present and the chart would silently - no-op without the fail-fast. + feature (authentication.oidc.enabled) being on. The + `v1.edp.epam.com/v1` capability guard turns a missing Keycloak + operator CRD into an explicit render error rather than a silent + half-rendered state — see monitoring.oidc.assertSystemKeycloakCRD + in _helpers.tpl and grafana.yaml where the same assertion gates + `auth.generic_oauth` so Grafana never comes up pointing at a + client that never gets provisioned. The three KeycloakRealmGroup objects are chart-owned but their membership is not — a Keycloak operator adds users to them via the @@ -27,11 +31,11 @@ */}} {{- if eq .Values.oidc.mode "System" }} {{- include "monitoring.oidc.assertSystemEnabled" . }} -{{- if .Capabilities.APIVersions.Has "v1.edp.epam.com/v1" }} -{{- $clientId := include "monitoring.oidc.clientId" . }} -{{- $scopeName := include "monitoring.oidc.audienceScopeName" . }} -{{- $clientSecretName := include "monitoring.oidc.clientSecretName" . }} -{{- $grafanaHost := include "monitoring.oidc.grafanaHost" . }} +{{- include "monitoring.oidc.assertSystemKeycloakCRD" . }} +{{- $clientId := include "monitoring.oidc.clientId" . }} +{{- $scopeName := include "monitoring.oidc.audienceScopeName" . }} +{{- $clientSecretName := include "monitoring.oidc.clientSecretName" . }} +{{- $grafanaHost := include "monitoring.oidc.grafanaHost" . }} --- apiVersion: v1.edp.epam.com/v1 kind: KeycloakClientScope @@ -115,5 +119,4 @@ spec: realmRef: name: keycloakrealm-cozy kind: ClusterKeycloakRealm -{{- end }} {{- end }} diff --git a/packages/system/monitoring/tests/oidc_crd_missing_test.yaml b/packages/system/monitoring/tests/oidc_crd_missing_test.yaml new file mode 100644 index 000000000..878040c6c --- /dev/null +++ b/packages/system/monitoring/tests/oidc_crd_missing_test.yaml @@ -0,0 +1,38 @@ +suite: OIDC selector — Keycloak operator CRD missing (Phase 1) +# Split from oidc_test.yaml because the suite-level `capabilities` +# override in that file installs `v1.edp.epam.com/v1`, and per-test +# `capabilities` in helm-unittest v1.0.3 merges with the suite-level +# list instead of replacing it — so the CRD-missing fail-close guard +# needs its own suite where the capability list is empty at the top. +templates: + - templates/grafana/oidc-keycloak.yaml + - templates/grafana/grafana.yaml + +release: + name: monitoring + namespace: tenant-root + +set: + _cluster: + root-host: example.org + oidc-enabled: "true" + _namespace: + host: tenant-root.example.org + ingress: tenant-root + +capabilities: + apiVersions: [] + +tests: + - it: mode=System hard-fails the render when the Keycloak operator CRD is missing + # oidc-keycloak.yaml fires the assertion first because helm renders all + # templates in one pass; grafana.yaml carries the same assertion as + # defense-in-depth so a future refactor that removes oidc-keycloak.yaml + # cannot bring Grafana up pointing at a client that never gets + # provisioned. See monitoring.oidc.assertSystemKeycloakCRD in _helpers.tpl. + template: templates/grafana/oidc-keycloak.yaml + set: + oidc.mode: System + asserts: + - failedTemplate: + errorMessage: "spec.oidc.mode: System requires the Keycloak operator CRDs (v1.edp.epam.com/v1). If cozystack.keycloak-operator is still bootstrapping this will resolve on the next reconcile; otherwise verify the keycloak-operator package is deployed and its CRDs are registered." From 1679c84bb00e44842287718baaf1e51fa398b657 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 09:03:24 +0300 Subject: [PATCH 13/39] chore(monitoring): regenerate extra/monitoring README Column widths in the OIDC table contract by one character once the tenant-facing `grafanaAdmin` row is gone. Regenerating the table via `make generate` (cozyvalues-gen) so the pre-commit `make generate` diff check in CI stays clean. Signed-off-by: Ivan Okhotnikov --- packages/extra/monitoring/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index 4506f54ca..1bc107019 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -104,12 +104,12 @@ ### OIDC authentication -| Name | Description | Type | Value | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | -| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | -| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | -| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | -| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | -| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | +| Name | Description | Type | Value | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------ | +| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | +| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | +| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | +| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | +| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | +| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | From 9ff05b2f3260bb05b97533a5bc3dd00e0a8c9063 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 13:47:19 +0300 Subject: [PATCH 14/39] test(e2e): port monitoring OIDC bats to cozytest lifecycle conventions Mirrors the same port applied to kubernetes-oidc-*.bats in the tenant kube-apiserver OIDC series: cozytest.sh (the e2e runner) is not real bats, so setup()/teardown() are never invoked and any state they established (TEST_NAME) is unset by the time an @test body runs, tripping cozytest.sh's `set -eu` at the first "${TEST_NAME}" expansion with `TEST_NAME: parameter not set`. Lift TEST_NAME to file scope as a stable constant, register cleanup via cozy_cleanup() so cozytest.sh runs it at suite exit and on the first failing test, and call cleanup_mon() inline at the top of the first @test for per-run isolation. Fixes the two E2E failures on this PR: `Monitoring CR accepts spec.oidc.mode=CustomConfig with inline config` and `Monitoring CR accepts spec.oidc.mode=System`. Signed-off-by: Ivan Okhotnikov --- .../monitoring-oidc-customconfig.bats | 22 +++++++++---------- hack/e2e-apps/monitoring-oidc-system.bats | 22 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats index 50419da39..4837c1c4f 100755 --- a/hack/e2e-apps/monitoring-oidc-customconfig.bats +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -1,25 +1,22 @@ #!/usr/bin/env bats -# Phase-1 OIDC CustomConfig selector e2e for the Grafana instance — +# Phase-1 OIDC CustomConfig selector e2e for the Grafana instance, # render-side only. # # What is exercised on a live cluster: that the new # `spec.oidc.mode: CustomConfig` field is admitted; that cozystack-api # accepts the updated schema; that the HelmRelease renders; that the # operator-supplied auth.generic_oauth payload lands on the Grafana -# CR; and — crucially — that NO Keycloak objects are created in the +# CR; and, crucially, that NO Keycloak objects are created in the # `cozy` realm when the tenant brings their own issuer. -setup() { - TEST_NAME="mon-oidc-byo-$$" - cleanup_monitoring -} +# cozytest.sh (the e2e runner) is not real bats: it never invokes +# setup()/teardown(). Cleanup belongs in cozy_cleanup(), which runs at +# suite exit and on the first failing test. Per-test isolation is done +# inline at the top of each @test. +TEST_NAME="mon-oidc-byo" -teardown() { - cleanup_monitoring -} - -cleanup_monitoring() { +cleanup_mon() { kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" \ --ignore-not-found --wait=false 2>/dev/null || true kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ @@ -28,7 +25,10 @@ cleanup_monitoring() { --ignore-not-found 2>/dev/null || true } +cozy_cleanup() { cleanup_mon; } + @test "Monitoring CR accepts spec.oidc.mode=CustomConfig with inline config" { + cleanup_mon kubectl apply -f - </dev/null || true kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ --for=delete --timeout=2m 2>/dev/null || true } +cozy_cleanup() { cleanup_mon; } + @test "Monitoring CR accepts spec.oidc.mode=System" { + cleanup_mon kubectl apply -f - </dev/null 2>&1; do sleep 2; done' # Wait for the chart to render its Grafana CR. We do not wait on - # HR Ready — that requires a full VictoriaMetrics + Postgres bringup + # HR Ready: that requires a full VictoriaMetrics + Postgres bringup # which the render-side test deliberately avoids. timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' } From cb04caecee16c00608d7b0a79e0cb214f5c28de1 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 15:35:57 +0300 Subject: [PATCH 15/39] fix(e2e): align monitoring OIDC bats with chart singleton naming The extra/monitoring chart enforces `.Release.Name == "monitoring"` in templates/check-release-name.yaml (the tenant Monitoring is a per-namespace singleton). Naming the Monitoring CR anything else makes the operator materialise an outer HelmRelease with that name and the chart hard-fails at render with execution error at (monitoring/templates/check-release-name.yaml:2:4): The name of the release MUST BE monitoring Rename the CR to `monitoring` in both files. Rework every derived name to match the actual reality of the chart pipeline instead of the invented `monitoring-` prefix the tests polled for: * outer HR = ${CR_NAME} = monitoring * inner HR = ${CR_NAME}-system = monitoring-system (rendered by extra/monitoring/templates/helmrelease.yaml as `metadata.name: {{ .Release.Name }}-system`) * clientId = ${namespace}-${inner Release.Name} = tenant-test-monitoring-system * scope = ${clientId}-audience * groups = ${clientId}-{admin,editor,viewer} * client-secret Secret = ${inner Release.Name}-oidc-client = monitoring-system-oidc-client The BYO Secret name in the customconfig test is chart-independent (the tenant provides it via customConfig.secretRef.name), so it is kept short and no longer tied to CR_NAME. The wait sequence in the first @test of each file is also split into two probes: outer HR appears first (from the operator), then inner HR (from the extra/monitoring chart's helmrelease.yaml), then the Grafana CR. Under the previous incorrect single-probe wait (`hr monitoring-${TEST_NAME}`) the timeout tripped after 60s because that HR never existed under any name. Signed-off-by: Ivan Okhotnikov --- .../monitoring-oidc-customconfig.bats | 43 +++++++++++++------ hack/e2e-apps/monitoring-oidc-system.bats | 33 +++++++++----- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats index 4837c1c4f..021d5af66 100755 --- a/hack/e2e-apps/monitoring-oidc-customconfig.bats +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -14,14 +14,23 @@ # setup()/teardown(). Cleanup belongs in cozy_cleanup(), which runs at # suite exit and on the first failing test. Per-test isolation is done # inline at the top of each @test. -TEST_NAME="mon-oidc-byo" +# +# Naming: the extra/monitoring chart is a singleton per tenant +# namespace and enforces `.Release.Name == "monitoring"` in +# templates/check-release-name.yaml. So the Monitoring CR (and its +# operator-produced outer HelmRelease) MUST be named `monitoring`; +# the inner packages/system/monitoring HelmRelease that carries the +# OIDC templates is then named `${outer}-system`, i.e. `monitoring-system`. +CR_NAME="monitoring" +INNER_REL="${CR_NAME}-system" +BYO_SECRET="mon-oidc-byo-oauth" cleanup_mon() { - kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" \ + kubectl -n tenant-test delete monitoring.apps.cozystack.io "${CR_NAME}" \ --ignore-not-found --wait=false 2>/dev/null || true - kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ + kubectl -n tenant-test wait monitoring.apps.cozystack.io "${CR_NAME}" \ --for=delete --timeout=2m 2>/dev/null || true - kubectl -n tenant-test delete secret "${TEST_NAME}-byo-oauth" \ + kubectl -n tenant-test delete secret "${BYO_SECRET}" \ --ignore-not-found 2>/dev/null || true } @@ -33,7 +42,7 @@ cozy_cleanup() { cleanup_mon; } apiVersion: apps.cozystack.io/v1alpha1 kind: Monitoring metadata: - name: ${TEST_NAME} + name: ${CR_NAME} namespace: tenant-test spec: host: "" @@ -60,7 +69,10 @@ spec: role_attribute_path: "'Viewer'" EOF - timeout 60 sh -ec 'until kubectl -n tenant-test get hr "monitoring-'"${TEST_NAME}"'" >/dev/null 2>&1; do sleep 2; done' + # Outer HR (created by cozystack-operator from the CR) shares the CR name. + timeout 60 sh -ec 'until kubectl -n tenant-test get hr "'"${CR_NAME}"'" >/dev/null 2>&1; do sleep 2; done' + # Inner HR (rendered by extra/monitoring) is ${outer}-system. + timeout 120 sh -ec 'until kubectl -n tenant-test get hr "'"${INNER_REL}"'" >/dev/null 2>&1; do sleep 2; done' timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' } @@ -90,7 +102,9 @@ EOF skip "EDP Keycloak operator CRDs not present on this cluster" fi - CID="tenant-test-monitoring-${TEST_NAME}" + # clientId helper: printf "%s-%s" .Release.Namespace .Release.Name in + # the inner chart -> tenant-test-monitoring-system. + CID="tenant-test-${INNER_REL}" # Give any reconciler a moment; then assert none of the four objects # were created. @@ -104,7 +118,7 @@ EOF @test "secretRef variant mounts operator Secret under /etc/grafana/oidc" { # Operator-owned Secret carrying a ready-made ini fragment. - kubectl -n tenant-test create secret generic "${TEST_NAME}-byo-oauth" \ + kubectl -n tenant-test create secret generic "${BYO_SECRET}" \ --from-literal=auth.ini='[auth.generic_oauth] enabled = true name = ByoIdp @@ -113,15 +127,15 @@ client_secret = byo-secret ' --dry-run=client -o yaml | kubectl apply -f - # Recreate the Monitoring CR pointing at the Secret. - kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" --ignore-not-found - kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ + kubectl -n tenant-test delete monitoring.apps.cozystack.io "${CR_NAME}" --ignore-not-found + kubectl -n tenant-test wait monitoring.apps.cozystack.io "${CR_NAME}" \ --for=delete --timeout=2m 2>/dev/null || true kubectl apply -f - </dev/null 2>&1; do sleep 2; done' + timeout 60 sh -ec 'until kubectl -n tenant-test get hr "'"${CR_NAME}"'" >/dev/null 2>&1; do sleep 2; done' + timeout 120 sh -ec 'until kubectl -n tenant-test get hr "'"${INNER_REL}"'" >/dev/null 2>&1; do sleep 2; done' timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' # spec.config carries no auth.generic_oauth section in this branch. @@ -161,5 +176,5 @@ EOF vol=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.deployment.spec.template.spec.volumes[?(@.name=="oidc-custom-ini")].secret.secretName}') echo "secret volume: ${vol}" - [ "${vol}" = "${TEST_NAME}-byo-oauth" ] + [ "${vol}" = "${BYO_SECRET}" ] } diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats index 887c63043..b86df2087 100755 --- a/hack/e2e-apps/monitoring-oidc-system.bats +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -20,12 +20,23 @@ # setup()/teardown(). Cleanup belongs in cozy_cleanup(), which runs at # suite exit and on the first failing test. Per-test isolation is done # inline at the top of each @test. -TEST_NAME="mon-oidc-sys" +# +# Naming: the extra/monitoring chart is a singleton per tenant +# namespace and enforces `.Release.Name == "monitoring"` in +# templates/check-release-name.yaml. So the Monitoring CR (and its +# operator-produced outer HelmRelease) MUST be named `monitoring`; +# the inner packages/system/monitoring HelmRelease that carries the +# OIDC templates is then named `${outer}-system`, i.e. `monitoring-system`, +# and every OIDC identifier derived by the helpers (clientId, audience +# scope, groups, client-secret Secret) is built off that inner name. +CR_NAME="monitoring" +INNER_REL="${CR_NAME}-system" +CID="tenant-test-${INNER_REL}" cleanup_mon() { - kubectl -n tenant-test delete monitoring.apps.cozystack.io "${TEST_NAME}" \ + kubectl -n tenant-test delete monitoring.apps.cozystack.io "${CR_NAME}" \ --ignore-not-found --wait=false 2>/dev/null || true - kubectl -n tenant-test wait monitoring.apps.cozystack.io "${TEST_NAME}" \ + kubectl -n tenant-test wait monitoring.apps.cozystack.io "${CR_NAME}" \ --for=delete --timeout=2m 2>/dev/null || true } @@ -37,7 +48,7 @@ cozy_cleanup() { cleanup_mon; } apiVersion: apps.cozystack.io/v1alpha1 kind: Monitoring metadata: - name: ${TEST_NAME} + name: ${CR_NAME} namespace: tenant-test spec: host: "" @@ -56,8 +67,9 @@ spec: mode: System EOF - # Wait for cozystack-api to materialise the HelmRelease. - timeout 60 sh -ec 'until kubectl -n tenant-test get hr "monitoring-'"${TEST_NAME}"'" >/dev/null 2>&1; do sleep 2; done' + # Outer HR shares the CR name; inner HR (chart target) is ${outer}-system. + timeout 60 sh -ec 'until kubectl -n tenant-test get hr "'"${CR_NAME}"'" >/dev/null 2>&1; do sleep 2; done' + timeout 120 sh -ec 'until kubectl -n tenant-test get hr "'"${INNER_REL}"'" >/dev/null 2>&1; do sleep 2; done' # Wait for the chart to render its Grafana CR. We do not wait on # HR Ready: that requires a full VictoriaMetrics + Postgres bringup @@ -78,7 +90,7 @@ EOF client_id=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.config.auth\.generic_oauth.client_id}') echo "client_id: ${client_id}" - [ "${client_id}" = "tenant-test-monitoring-${TEST_NAME}" ] + [ "${client_id}" = "${CID}" ] auth_url=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.config.auth\.generic_oauth.auth_url}') @@ -87,7 +99,8 @@ EOF } @test "Persistent client-secret Secret is created and 32-char random" { - SEC="${TEST_NAME}-oidc-client" + # clientSecretName helper: printf "%s-oidc-client" .Release.Name -> ${INNER_REL}-oidc-client. + SEC="${INNER_REL}-oidc-client" timeout 60 sh -ec 'until kubectl -n tenant-test get secret "'"${SEC}"'" >/dev/null 2>&1; do sleep 2; done' value=$(kubectl -n tenant-test get secret "${SEC}" \ -o jsonpath='{.data.client-secret}' | base64 -d) @@ -105,8 +118,6 @@ EOF skip "EDP Keycloak operator CRDs not present on this cluster" fi - CID="tenant-test-monitoring-${TEST_NAME}" - timeout 60 sh -ec 'until kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "'"${CID}"'" >/dev/null 2>&1; do sleep 2; done' public=$(kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" \ -o jsonpath='{.spec.public}') @@ -137,5 +148,5 @@ EOF ref=$(kubectl -n tenant-test get deploy grafana-deployment \ -o jsonpath='{.spec.template.spec.containers[?(@.name=="grafana")].env[?(@.name=="GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET")].valueFrom.secretKeyRef.name}') echo "client-secret env source: ${ref}" - [ "${ref}" = "${TEST_NAME}-oidc-client" ] + [ "${ref}" = "${INNER_REL}-oidc-client" ] } From 5f7ed89799b7b85b7edab3d7d5b7168eb4f6c4d0 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 22:02:53 +0300 Subject: [PATCH 16/39] fix(e2e): work around EDP schema-default strip + kubectl delete/apply race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up bats fixes uncovered by the first live e2e pass on the singleton-aligned monitoring OIDC tests: 1. monitoring-oidc-system, test 4 (KeycloakClient assertion). The EDP Keycloak operator's KeycloakClient CRD strips `spec.public: false` on write because false is the schema default. The chart emits `public: false` explicitly (confidentiality guard), but neither managedFields nor the resulting spec ever carries the field, so the assertion `[ "${public}" = "false" ]` grepped an empty string and tripped. Assert `spec.clientAuthenticatorType == "client-secret"` instead — the operator persists it, and it is the direct signal for "confidential client" (which is what the `public: false` assertion was testing for). 2. monitoring-oidc-customconfig, test 4 (secretRef variant). The original flow was delete-then-recreate the Monitoring CR to swap inline `customConfig.config` for `customConfig.secretRef.name`. `kubectl apply` immediately after delete raced the API server's grace-period cache: apply's GET-then-decide phase saw the CR during grace, chose the patch path, and the patch hit NotFound once the delete propagated. Drop the delete entirely — cozystack- operator reconciles the CR mode change on the same object and Flux re-renders the chart with the new values. Add an explicit wait for the target state (Grafana CR's `oidc-custom-ini` volumeMount present AND `spec.config.auth.generic_oauth` absent) before the assertions, since the CR update triggers an asynchronous chart re-render and the immediate get would still see the inline auth.generic_oauth block from @test 1. Signed-off-by: Ivan Okhotnikov --- .../monitoring-oidc-customconfig.bats | 21 ++++++++++++++----- hack/e2e-apps/monitoring-oidc-system.bats | 12 +++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats index 021d5af66..1c0d8c9b9 100755 --- a/hack/e2e-apps/monitoring-oidc-customconfig.bats +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -126,11 +126,11 @@ client_id = byo-monitoring client_secret = byo-secret ' --dry-run=client -o yaml | kubectl apply -f - - # Recreate the Monitoring CR pointing at the Secret. - kubectl -n tenant-test delete monitoring.apps.cozystack.io "${CR_NAME}" --ignore-not-found - kubectl -n tenant-test wait monitoring.apps.cozystack.io "${CR_NAME}" \ - --for=delete --timeout=2m 2>/dev/null || true - + # Update the Monitoring CR in place: swap customConfig.config (inline) + # for customConfig.secretRef.name. Delete + immediate apply races + # kubectl's create-vs-patch decision against the API server's grace- + # period cache and can hit "NotFound" during patch, so let the operator + # reconcile the mode change on the same CR instead. kubectl apply -f - </dev/null 2>&1; do sleep 2; done' timeout 180 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 5; done' + # The Grafana CR from @test 1 already exists and carries the inline + # auth.generic_oauth block; wait for the chart to reconcile the swap + # to secretRef by polling for the target state (volume mount present + # AND inline block absent) instead of asserting immediately. + timeout 180 sh -ec ' + until [ -n "$(kubectl -n tenant-test get grafana grafana -o jsonpath="{.spec.deployment.spec.template.spec.containers[?(@.name==\"grafana\")].volumeMounts[?(@.name==\"oidc-custom-ini\")].mountPath}")" ]; do sleep 5; done + ' + timeout 60 sh -ec ' + until [ -z "$(kubectl -n tenant-test get grafana grafana -o jsonpath="{.spec.config.auth\.generic_oauth}")" ]; do sleep 5; done + ' + # spec.config carries no auth.generic_oauth section in this branch. section=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.config.auth\.generic_oauth}') diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats index b86df2087..2f846e56f 100755 --- a/hack/e2e-apps/monitoring-oidc-system.bats +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -119,10 +119,14 @@ EOF fi timeout 60 sh -ec 'until kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "'"${CID}"'" >/dev/null 2>&1; do sleep 2; done' - public=$(kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" \ - -o jsonpath='{.spec.public}') - echo "client public: ${public}" - [ "${public}" = "false" ] + # The EDP Keycloak operator's CRD strips `spec.public: false` on write + # (schema default is false, so a false value never materialises in the + # applied object). Assert confidentiality via clientAuthenticatorType, + # which the operator DOES persist. + auth_type=$(kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" \ + -o jsonpath='{.spec.clientAuthenticatorType}') + echo "client authenticator type: ${auth_type}" + [ "${auth_type}" = "client-secret" ] timeout 30 sh -ec 'until kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "'"${CID}"'-audience" >/dev/null 2>&1; do sleep 2; done' mapper=$(kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" \ From b46543e5f23d4c1e559e6af4afc0f1849a8c3d69 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 22:27:29 +0300 Subject: [PATCH 17/39] fix(monitoring): defer platform GrafanaAdmin lever to a future phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MAJOR finding from the cozy-review agent: the platform-side `oidc.grafanaAdmin: true` documented in the PR body and in docs/oidc-grafana.md never actually reached the platform Grafana's HelmRelease. `Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin` was set in packages/core/platform/templates/bundles/system.yaml, but `monitoring-system` (and `monitoring`) have no `install:` block in packages/core/platform/sources/monitoring-application.yaml — only `monitoring-rd` does. PackageReconciler skips components without an `install:` block before ever reaching the assignment that copies `pkg.Spec.Components[].Values` onto `hr.Spec.Values` (internal/operator/package_reconciler.go around lines 197-201 and 292-293). The values-override dictionary was silently dropped, no HR carried `oidc.grafanaAdmin: true`, and the platform Grafana rendered with the chart default (`false`). The `bundles_monitoring_grafana_admin_test.yaml` unit test only asserted against the emitted Package CR spec — exactly the layer where the value still existed but was about to be discarded — so the broken propagation slipped past. Trim the broken promise from Phase 1. The chart-side helper (`monitoring.oidc.allowAssignGrafanaAdmin`) stays as-is so a future phase that routes the override through the tenant-root chart's monitoring HR values (or adds an `install:` block on the `monitoring-system` component) lands on a green chart-side contract. The helm-unittest that verifies chart-side emission stays too, but with a docstring calling out that end-to-end wiring is deferred. Everything else that promised the lever works — bundle line, docs, platform bundle test — is either removed or rewritten to match reality. Also picks up two MINOR findings from the same review: * Drop the misleading `secret-hash` annotation on the OIDC client-secret Secret. The comment claimed it "rotates the KeycloakClient CR and therefore the Grafana Deployment env when the client-secret changes", but nothing downstream watches an annotation on the Secret itself — no rotation is triggered. The `lookup`-based fallback already keeps the value stable across reconciles, so no rotation is even needed. Removing the dead annotation avoids planting a broken assumption for future readers. * Add `release.cozystack.io/helm-install-timeout: "15m"` on the monitoring cozyrds ApplicationDefinition. Under `mode: System` the chart now emits, on top of the existing Grafana + Alerta + VMCluster + CNPG stack, four additional Keycloak CRs whose reconciliation is asynchronous against an operator in `cozy-keycloak` (not the tenant namespace). Flux's default 5m install window was already tight for the pre-OIDC stack; 15m matches the precedent set by tenant-rd/kubernetes-rd cozyrds. Signed-off-by: Ivan Okhotnikov --- docs/oidc-grafana.md | 29 +++++--- .../platform/templates/bundles/system.yaml | 27 +++++--- ...bundles_monitoring_grafana_admin_test.yaml | 69 ------------------- .../monitoring-rd/cozyrds/monitoring.yaml | 11 +++ .../system/monitoring/templates/_helpers.tpl | 20 ++++-- .../templates/grafana/oidc-client-secret.yaml | 4 -- .../system/monitoring/tests/oidc_test.yaml | 7 ++ 7 files changed, 69 insertions(+), 98 deletions(-) delete mode 100644 packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index ca6662209..fe787e9c0 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -81,15 +81,26 @@ CR: 6. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana Deployment sourced from the Secret in step 4. -Server-level `GrafanaAdmin` promotion is a platform-only lever, not -exposed on the tenant `Monitoring` CR — a tenant cannot self-promote -their `-admin` group members to `GrafanaAdmin`. The platform -bundle (`packages/core/platform/templates/bundles/system.yaml`) flips -it on for the platform Grafana release via -`Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin: true`, -which sets `allow_assign_grafana_admin: true` in the rendered -`[auth.generic_oauth]` block. Tenant Grafana releases inherit the -chart default `false` and stay at org-level Admin. +Server-level `GrafanaAdmin` promotion (`allow_assign_grafana_admin`) +is out of scope for Phase 1. All Grafana instances — platform and +tenant — cap at org-level `Admin` for members of the +`-admin` group. Not exposed on the tenant `Monitoring` CR +either. + +The chart carries an internal lever `oidc.grafanaAdmin` (default +`false`), reserved for a future phase that will route the override +through the tenant-root chart's monitoring HR values. In Phase 1 +nothing sets it to `true` end-to-end: the platform bundle's obvious +path via `Package.spec.components["monitoring-system"].values` is +silently dropped because the `monitoring-system` component in +`packages/core/platform/sources/monitoring-application.yaml` has no +`install:` block, and PackageReconciler skips components without +one before ever copying the override onto a HelmRelease +(`internal/operator/package_reconciler.go` around lines 197-201 and +292-293). Wiring it end-to-end requires either an explicit values +injection from the tenant-root chart's `templates/monitoring.yaml` +or an `install:` block on the `monitoring-system` component that +creates a variant-scoped HR — both defensible, both deferred. ## CustomConfig mode diff --git a/packages/core/platform/templates/bundles/system.yaml b/packages/core/platform/templates/bundles/system.yaml index 8ae9702b0..1dc769631 100644 --- a/packages/core/platform/templates/bundles/system.yaml +++ b/packages/core/platform/templates/bundles/system.yaml @@ -192,22 +192,27 @@ {{include "cozystack.platform.package.default" (list "cozystack.gateway-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.seaweedfs-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.info-application" $) }} -{{- /* Platform Grafana (`monitoring-system`) — flip GrafanaAdmin uplift on - so `-admin` group members become server-level Grafana - admins. Tenant Grafana releases (component `monitoring`) inherit - the chart default `oidc.grafanaAdmin: false` and stay at - org-level Admin. See docs/oidc-grafana.md and cozystack#3176. - - Variant selection mirrors cozystack-engine above: `oidc` when +{{- /* Variant selection mirrors cozystack-engine above: `oidc` when platform-level OIDC is on (extra dependsOn on cozystack.keycloak-operator so the v1.edp.epam.com CRDs are registered before the monitoring chart reconciles), `default` - otherwise (no wait on a Package that would not be emitted). */}} -{{- $monitoringApplicationComponents := dict "monitoring-system" (dict "values" (dict "oidc" (dict "grafanaAdmin" true))) -}} + otherwise (no wait on a Package that would not be emitted). + + No component-values override is passed. The platform Grafana's + HelmRelease is created by the tenant-root chart's + templates/monitoring.yaml (not by PackageReconciler over the + monitoring-system component here — that component has no + `install:` block), so `Package.spec.components[].values` here + would be silently dropped by the reconciler + (internal/operator/package_reconciler.go:197-201 skips + components without `install:` before ever reaching the + hr.Spec.Values assignment). Server-level GrafanaAdmin + promotion is out of scope for Phase 1; see + docs/oidc-grafana.md. */}} {{- if .Values.authentication.oidc.enabled }} -{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "oidc" $ $monitoringApplicationComponents) }} +{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "oidc" $) }} {{- else }} -{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "default" $ $monitoringApplicationComponents) }} +{{include "cozystack.platform.package" (list "cozystack.monitoring-application" "default" $) }} {{- end }} {{include "cozystack.platform.package.default" (list "cozystack.etcd-application" $) }} {{include "cozystack.platform.package.default" (list "cozystack.cozystack-basics" $) }} diff --git a/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml b/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml deleted file mode 100644 index 76c18e1f1..000000000 --- a/packages/core/platform/tests/bundles_monitoring_grafana_admin_test.yaml +++ /dev/null @@ -1,69 +0,0 @@ -suite: bundles.system monitoring-application variant + component wiring -# Two invariants exercised together, because they share the same Package -# emission site: -# 1. `authentication.oidc.enabled` gates the PackageSource variant — -# `oidc` when on (extra dep on cozystack.keycloak-operator so the -# v1.edp.epam.com CRDs are registered before the monitoring chart -# reconciles), `default` when off (no wait on a Package that would -# not be emitted). -# 2. Both variants MUST carry the GrafanaAdmin uplift for the platform -# Grafana release (`monitoring-system`) via -# Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin. -# Tenant Grafana (component `monitoring`) inherits the chart default -# `oidc.grafanaAdmin: false` and stays at org-level Admin — regression -# guard the absence of a `monitoring` override here. -templates: - - templates/bundles/system.yaml -release: - name: cozystack - namespace: cozy-system -tests: - - it: authentication.oidc.enabled=false selects default variant and keeps the platform GrafanaAdmin uplift - set: - bundles: - system: - enabled: true - variant: isp-full - authentication: - oidc: - enabled: false - documentSelector: - path: metadata.name - value: cozystack.monitoring-application - asserts: - - equal: - path: kind - value: Package - - equal: - path: spec.variant - value: default - - equal: - path: spec.components.monitoring-system.values.oidc.grafanaAdmin - value: true - - notExists: - path: spec.components.monitoring.values.oidc.grafanaAdmin - - - it: authentication.oidc.enabled=true selects oidc variant and keeps the platform GrafanaAdmin uplift - set: - bundles: - system: - enabled: true - variant: isp-full - authentication: - oidc: - enabled: true - documentSelector: - path: metadata.name - value: cozystack.monitoring-application - asserts: - - equal: - path: kind - value: Package - - equal: - path: spec.variant - value: oidc - - equal: - path: spec.components.monitoring-system.values.oidc.grafanaAdmin - value: true - - notExists: - path: spec.components.monitoring.values.oidc.grafanaAdmin diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index a0317a7ad..ef1e6ec75 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -2,6 +2,17 @@ apiVersion: cozystack.io/v1alpha1 kind: ApplicationDefinition metadata: name: monitoring + annotations: + # Monitoring's HR bootstraps Grafana + Alerta + VMCluster + CNPG + # (postgres for Grafana state) + optionally four Keycloak CRs + # (KeycloakClient, KeycloakClientScope, three KeycloakRealmGroups) + # under `spec.oidc.mode: System`. The Keycloak-side reconciliation + # is asynchronous against an operator in `cozy-keycloak`, not the + # tenant namespace, so Flux's default 5m install window is tight + # even before adding OIDC. Override to 15m — matches the precedent + # set by tenant-rd/kubernetes-rd cozyrds annotations for + # long-bootstrap stacks. + release.cozystack.io/helm-install-timeout: "15m" spec: application: kind: Monitoring diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index 1d7513502..509f8d3d2 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -55,11 +55,21 @@ {{- /* Whether to promote `-admin` group members to server-level - `GrafanaAdmin` (Grafana's `allow_assign_grafana_admin` flag). Driven - by an explicit values field so the platform bundle can flip it on - for the platform release via - `Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin` - without the chart having to sniff `.Release.Name`. + `GrafanaAdmin` (Grafana's `allow_assign_grafana_admin` flag). + Chart-internal lever driven by `oidc.grafanaAdmin` values input; + defaults to `false`. + + Nothing in the Phase 1 platform bundle sets this to `true` end-to- + end: `Package.spec.components[].values` cannot reach this chart + because the `monitoring-system` component in + packages/core/platform/sources/monitoring-application.yaml has no + `install:` block, and PackageReconciler skips components without + one before values propagation (internal/operator/package_reconciler.go + lines 197-201 and 292-293). A future phase will route the + override through the tenant-root chart's monitoring HR values; + until then this helper always renders `false` in practice, and + server-level GrafanaAdmin promotion stays out of scope. See + docs/oidc-grafana.md. */}} {{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}} {{- $grafanaAdmin := dig "grafanaAdmin" false (.Values.oidc | default dict) -}} diff --git a/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml index 29f764133..bb2454940 100644 --- a/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml @@ -33,10 +33,6 @@ kind: Secret metadata: name: {{ $secretName }} namespace: {{ .Release.Namespace }} - annotations: - # Rotates the KeycloakClient CR — and therefore the Grafana - # Deployment env — whenever the client-secret changes. - secret-hash: {{ $clientSecret | sha256sum }} type: Opaque data: client-secret: {{ $clientSecret | b64enc }} diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 0f22600dc..8732ade29 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -317,6 +317,13 @@ tests: ########################################################## # allow_assign_grafana_admin is driven by oidc.grafanaAdmin + # + # Chart-internal capability only. Phase 1 does not wire this end- + # to-end from the platform bundle: `Package.spec.components[].values` + # is dropped for `monitoring-system` because that component has no + # `install:` block in the PackageSource. Kept as a template unit + # test so a future phase that adds the wiring lands on a green + # chart-side contract. ########################################################## - it: oidc.grafanaAdmin=true upgrades Admin to server-level GrafanaAdmin From aa63786acd37aa1d043607e512f689471db7cc52 Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Sun, 5 Jul 2026 22:34:15 +0300 Subject: [PATCH 18/39] docs(monitoring): sync oidc.grafanaAdmin values docstring with deferred-lever posture Second-iteration cozy-review flagged this as a leftover from the platform-lever removal: `packages/system/monitoring/values.yaml` still described `grafanaAdmin` as "Set true for the platform Grafana release only ... the platform bundle passes this flag through Package.spec.components", which contradicts the deferred-lever reality already documented in the same commit's updates to `_helpers.tpl` and `docs/oidc-grafana.md`. Rewrite the docstring to match: chart-internal lever, defaults `false`, Phase 1 has no end-to-end wiring; kept as a values key so a future phase can add the wiring without touching the chart contract. Signed-off-by: Ivan Okhotnikov --- packages/system/monitoring/values.yaml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index 8d28d329a..cc9463c46 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -125,10 +125,17 @@ oidc: ## `customConfig.config`. secretRef: name: "" - ## Promote `-admin` group members to server-level - ## `GrafanaAdmin` via `allow_assign_grafana_admin`. Set true for the - ## platform Grafana release only (`monitoring-system` in the - ## `cozy-monitoring` namespace) — the platform bundle passes this - ## flag through Package.spec.components; tenant Grafana releases - ## keep this false and stay at org-level Admin. + ## Chart-internal lever for `allow_assign_grafana_admin`: promote + ## `-admin` group members to server-level `GrafanaAdmin`. + ## Defaults to `false` and stays `false` in Phase 1 — no supported + ## path sets it to `true` end-to-end. The obvious platform-bundle + ## path via `Package.spec.components["monitoring-system"].values` + ## is dropped by PackageReconciler because that component has no + ## `install:` block in + ## `packages/core/platform/sources/monitoring-application.yaml`. + ## Server-level `GrafanaAdmin` promotion is out of scope for Phase 1; + ## see docs/oidc-grafana.md. Kept as a values key so a future phase + ## can route the override (via a tenant-root chart values injection + ## or a new `install:` block on `monitoring-system`) without touching + ## the chart contract. grafanaAdmin: false From 01d68904ea9a61738ffee096f27b09b5b058da58 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:09:28 +0300 Subject: [PATCH 19/39] feat(monitoring): drop chart-owned Keycloak groups; app-side users-map + reconcile Job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 cozystack/cozystack#3044. What is removed - The three chart-owned KeycloakRealmGroup objects in the cozy realm (`-{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 (`--`) 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: cozystack/cozystack#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 --- packages/system/monitoring/images/kubectl.tag | 1 + .../system/monitoring/templates/_helpers.tpl | 81 +++--- .../monitoring/templates/grafana/grafana.yaml | 35 ++- .../templates/grafana/oidc-client-secret.yaml | 11 +- .../templates/grafana/oidc-keycloak.yaml | 53 +--- .../templates/grafana/oidc-users-job.yaml | 250 ++++++++++++++++++ packages/system/monitoring/values.yaml | 41 +-- 7 files changed, 347 insertions(+), 125 deletions(-) create mode 100644 packages/system/monitoring/images/kubectl.tag create mode 100644 packages/system/monitoring/templates/grafana/oidc-users-job.yaml diff --git a/packages/system/monitoring/images/kubectl.tag b/packages/system/monitoring/images/kubectl.tag new file mode 100644 index 000000000..80d1e903c --- /dev/null +++ b/packages/system/monitoring/images/kubectl.tag @@ -0,0 +1 @@ +docker.io/alpine/k8s:1.33.4@sha256:b0523f0a244ddc4c8e055aa335c040d3d78b3ead5528f4544395f7f9f69c7b68 diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index 509f8d3d2..5bdd2e73c 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -2,7 +2,7 @@ Helpers for the Grafana OIDC integration. Naming — the identity unit is the Monitoring release, so the clientId - and the associated groups are `-` prefixed. That + and the audience scope are `-` prefixed. That gives: tenant-foo/monitoring (per-tenant Grafana in tenant-foo ns) cozy-monitoring/monitoring-system (platform Grafana in cozy-monitoring ns) @@ -10,6 +10,15 @@ because the per-cluster audience scope binds `id_token.aud` to the clientId — same isolation primitive as the tenant kube-apiserver PR (cozystack/cozystack#3044). + + Authorization is NOT modelled on Keycloak realm groups: the chart + does not own directory objects and Grafana's built-in group→role + mapping (`role_attribute_path`) is not wired up. Instead, `spec.oidc` + carries a `users:` map and a chart-owned post-install/post-upgrade + Job reconciles that map into Grafana org membership/roles via the + admin API. `skip_org_role_sync = true` prevents a login from + overwriting those app-side assignments. Same operator UX as the + tenant kube-apiserver sibling in #3044. */}} {{- define "monitoring.oidc.clientId" -}} @@ -38,48 +47,6 @@ {{- printf "https://keycloak.%s/realms/cozy" $host -}} {{- end -}} -{{- /* - Grafana `role_attribute_path` is a JMESPath expression evaluated on - the `groups` claim. We emit a chain that maps membership in the - per-instance -admin / -editor / -viewer groups to Grafana's built-in - Admin / Editor / Viewer roles, defaulting to Viewer for authenticated - identities with none of the three groups. Group naming mirrors - clientId so a Keycloak operator provisions three groups per release. -*/}} -{{- define "monitoring.oidc.roleAttributePath" -}} -{{- $admin := printf "%s-%s-admin" .Release.Namespace .Release.Name -}} -{{- $editor := printf "%s-%s-editor" .Release.Namespace .Release.Name -}} -{{- $viewer := printf "%s-%s-viewer" .Release.Namespace .Release.Name -}} -{{- printf "contains(groups[*], '%s') && 'Admin' || contains(groups[*], '%s') && 'Editor' || contains(groups[*], '%s') && 'Viewer' || 'Viewer'" $admin $editor $viewer -}} -{{- end -}} - -{{- /* - Whether to promote `-admin` group members to server-level - `GrafanaAdmin` (Grafana's `allow_assign_grafana_admin` flag). - Chart-internal lever driven by `oidc.grafanaAdmin` values input; - defaults to `false`. - - Nothing in the Phase 1 platform bundle sets this to `true` end-to- - end: `Package.spec.components[].values` cannot reach this chart - because the `monitoring-system` component in - packages/core/platform/sources/monitoring-application.yaml has no - `install:` block, and PackageReconciler skips components without - one before values propagation (internal/operator/package_reconciler.go - lines 197-201 and 292-293). A future phase will route the - override through the tenant-root chart's monitoring HR values; - until then this helper always renders `false` in practice, and - server-level GrafanaAdmin promotion stays out of scope. See - docs/oidc-grafana.md. -*/}} -{{- define "monitoring.oidc.allowAssignGrafanaAdmin" -}} -{{- $grafanaAdmin := dig "grafanaAdmin" false (.Values.oidc | default dict) -}} -{{- if $grafanaAdmin -}} -true -{{- else -}} -false -{{- end -}} -{{- end -}} - {{- /* Fail-fast when `mode: System` is requested without the platform-level OIDC feature being on. Mirrors the identical guard from the tenant @@ -96,12 +63,11 @@ false Fail-fast when `mode: System` is requested but the Keycloak operator CRDs (`v1.edp.epam.com/v1`) are not yet registered in the target cluster. Without this guard the chart would silently drop the whole - Keycloak side (KeycloakClient / KeycloakClientScope / three - KeycloakRealmGroups) while still rendering Grafana's - `auth.generic_oauth` block pointing at a client that never gets - provisioned — a broken login path with no clear error. For the - platform `monitoring-system` release the `oidc` variant of - `cozystack.monitoring-application` waits on + Keycloak side (KeycloakClient / KeycloakClientScope) while still + rendering Grafana's `auth.generic_oauth` block pointing at a client + that never gets provisioned — a broken login path with no clear + error. For the platform `monitoring-system` release the `oidc` + variant of `cozystack.monitoring-application` waits on `cozystack.keycloak-operator` (see packages/core/platform/sources/monitoring-application.yaml) so the CRDs are registered before this chart reconciles; the assertion @@ -120,8 +86,10 @@ false set — mutually exclusive. */}} {{- define "monitoring.oidc.assertCustomConfigXor" -}} -{{- $inline := (.Values.oidc.customConfig.config | default dict) -}} -{{- $secretName := dig "secretRef" "name" "" (.Values.oidc.customConfig | default dict) -}} +{{- $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 -}} @@ -131,3 +99,14 @@ false {{- fail "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." -}} {{- end -}} {{- end -}} + +{{- /* + Normalised `spec.oidc.users` list. Yields an empty list when + `spec.oidc` is omitted, `null`, or has no `users:` key. Consumers + should range over this without further nil-checks. +*/}} +{{- define "monitoring.oidc.users" -}} +{{- $oidc := .Values.oidc | default dict -}} +{{- $users := $oidc.users | default (list) -}} +{{- toYaml $users -}} +{{- end -}} diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 179e10455..1b0513d0c 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -7,18 +7,28 @@ {{- /* OIDC (see values.yaml — oidc.mode). - System → chart-owned Keycloak client + client-secret + role - mapping via role_attribute_path. + System → chart-owned Keycloak client + audience client scope + + persisted client-secret. Grafana authorization is + app-side: `spec.oidc.users` is reconciled into + Grafana org membership/roles by the users-Job. + Grafana's group-based `role_attribute_path` is NOT + wired up; `skip_org_role_sync = true` prevents a + subsequent login from overwriting the app-side + assignments. CustomConfig → operator-supplied auth.generic_oauth payload — either inline as a map (customConfig.config), or as a Secret with a ready-made ini fragment (customConfig.secretRef.name). XOR-guarded via the - assertCustomConfigXor helper. + assertCustomConfigXor helper. `spec.oidc.users` is + also honoured in this mode by the users-Job (users + are matched to Grafana identities by the `email` + claim from the tenant-supplied issuer). None → no auth.generic_oauth section, no OIDC env — the admin_user/admin_password Secret remains the only auth path. */}} -{{- $oidcMode := .Values.oidc.mode | default "None" }} +{{- $oidc := .Values.oidc | default dict }} +{{- $oidcMode := $oidc.mode | default "None" }} {{- if eq $oidcMode "CustomConfig" }} {{- include "monitoring.oidc.assertCustomConfigXor" . }} {{- end }} @@ -33,8 +43,9 @@ {{- include "monitoring.oidc.assertSystemEnabled" . }} {{- include "monitoring.oidc.assertSystemKeycloakCRD" . }} {{- end }} -{{- $customInline := (.Values.oidc.customConfig.config | default dict) }} -{{- $customSecretRefName := dig "secretRef" "name" "" (.Values.oidc.customConfig | default dict) }} +{{- $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 "") }} --- @@ -73,10 +84,16 @@ spec: auth_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/auth" token_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/token" api_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/userinfo" - scopes: "openid email profile groups {{ include "monitoring.oidc.audienceScopeName" . }}" - role_attribute_path: {{ include "monitoring.oidc.roleAttributePath" . | quote }} - allow_assign_grafana_admin: {{ include "monitoring.oidc.allowAssignGrafanaAdmin" . | quote }} + scopes: "openid email profile {{ include "monitoring.oidc.audienceScopeName" . }}" email_attribute_path: "email" + # Authorization is reconciled app-side by the users-Job (see + # oidc-users-job.yaml); no group→role mapping. skip_org_role_sync + # keeps a login from overwriting the app-side assignments. + skip_org_role_sync: "true" + # Match by email so the pre-provisioned Grafana user (created by + # the users-Job before the operator's first login) is picked up + # instead of a fresh account. + oauth_allow_insecure_email_lookup: "true" auto_login: "false" {{- else if $useCustomInline }} auth.generic_oauth: diff --git a/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml index bb2454940..b0f1b1e96 100644 --- a/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-client-secret.yaml @@ -18,13 +18,18 @@ spec.oidc.customConfig.config (plaintext) or through spec.oidc.customConfig.secretRef pointing at their own Secret. */}} -{{- if eq .Values.oidc.mode "System" }} +{{- $oidc := .Values.oidc | default dict }} +{{- if eq ($oidc.mode | default "None") "System" }} {{- $secretName := include "monitoring.oidc.clientSecretName" . }} {{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} {{- $clientSecret := "" }} {{- if $existing }} -{{- $clientSecret = index $existing.data "client-secret" | b64dec }} -{{- else }} +{{- $existingValue := dig "data" "client-secret" "" $existing }} +{{- if $existingValue }} +{{- $clientSecret = $existingValue | b64dec }} +{{- end }} +{{- end }} +{{- if eq $clientSecret "" }} {{- $clientSecret = randAlphaNum 32 }} {{- end }} --- diff --git a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml index 5b777e646..2bad49368 100644 --- a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml @@ -1,8 +1,6 @@ {{- /* - Phase 1 — System mode: render one per-instance Keycloak client, a - per-instance audience client scope, and the three role-mapping - groups (`-admin` / `-editor` / `-viewer`) inside the platform `cozy` - realm. + Phase 1 — System mode: render one per-instance Keycloak client and a + per-instance audience client scope inside the platform `cozy` realm. The clientId is namespace-prefixed so it is globally unique across the realm; the audience scope binds `id_token.aud` to that clientId @@ -10,6 +8,10 @@ another's Grafana — the per-instance isolation primitive in the absence of per-tenant realms. + No realm groups: authorization is app-side, driven by `spec.oidc.users` + and reconciled into Grafana orgs by the users-Job. See + templates/grafana/oidc-users-job.yaml and docs/oidc-grafana.md. + Nothing is rendered for `mode: CustomConfig` or `mode: None`: in those modes `cozy` is not in the path. @@ -22,14 +24,9 @@ in _helpers.tpl and grafana.yaml where the same assertion gates `auth.generic_oauth` so Grafana never comes up pointing at a client that never gets provisioned. - - The three KeycloakRealmGroup objects are chart-owned but their - membership is not — a Keycloak operator adds users to them via the - Keycloak UI or via a KeycloakRealmUser CR. Grafana's - `role_attribute_path` (see grafana.yaml) evaluates group membership - on login and assigns Admin / Editor / Viewer accordingly. */}} -{{- if eq .Values.oidc.mode "System" }} +{{- $oidc := .Values.oidc | default dict }} +{{- if eq ($oidc.mode | default "None") "System" }} {{- include "monitoring.oidc.assertSystemEnabled" . }} {{- include "monitoring.oidc.assertSystemKeycloakCRD" . }} {{- $clientId := include "monitoring.oidc.clientId" . }} @@ -82,41 +79,7 @@ spec: redirectUris: - "https://{{ $grafanaHost }}/login/generic_oauth" defaultClientScopes: - - groups - {{ $scopeName }} attributes: "post.logout.redirect.uris": "+" ---- -apiVersion: v1.edp.epam.com/v1 -kind: KeycloakRealmGroup -metadata: - name: {{ printf "%s-admin" $clientId }} - namespace: {{ .Release.Namespace }} -spec: - name: {{ printf "%s-admin" $clientId }} - realmRef: - name: keycloakrealm-cozy - kind: ClusterKeycloakRealm ---- -apiVersion: v1.edp.epam.com/v1 -kind: KeycloakRealmGroup -metadata: - name: {{ printf "%s-editor" $clientId }} - namespace: {{ .Release.Namespace }} -spec: - name: {{ printf "%s-editor" $clientId }} - realmRef: - name: keycloakrealm-cozy - kind: ClusterKeycloakRealm ---- -apiVersion: v1.edp.epam.com/v1 -kind: KeycloakRealmGroup -metadata: - name: {{ printf "%s-viewer" $clientId }} - namespace: {{ .Release.Namespace }} -spec: - name: {{ printf "%s-viewer" $clientId }} - realmRef: - name: keycloakrealm-cozy - kind: ClusterKeycloakRealm {{- end }} diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml new file mode 100644 index 000000000..d9888ccf2 --- /dev/null +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -0,0 +1,250 @@ +{{- /* + OIDC user reconcile Job — runs as Helm post-install,post-upgrade + hook and syncs `spec.oidc.users` into the release's Grafana org via + Grafana's admin API. Same operator UX as the tenant kube-apiserver + sibling in cozystack/cozystack#3044 (users-map → ClusterRoleBindings + in the guest cluster); here the target is Grafana org membership + because grafana-operator has no user CRD. + + Contract: + - For each `spec.oidc.users[]` entry with mode != None: + * pre-provision a local Grafana account with the given email + (random password — the account is a shell so the users-Job + can set an org role before the operator's first OIDC login, + and `oauth_allow_insecure_email_lookup = true` glues them + together at login time); + * add the account to Main Org. with the requested role + (`Admin` / `Editor` / `Viewer`), promoting/demoting on + re-run. + - Prune every non-admin Main-Org member whose email is not in the + desired list. Handles both: + * a `users:` entry being removed (should lose access); + * `mode: None` (list is treated as empty — all OIDC-provisioned + accounts are pruned). + - `skip_org_role_sync = true` in the Grafana generic_oauth block + (see grafana.yaml) makes sure a later login never overwrites the + role this Job sets. + + Lifecycle: + - The Job itself is a Helm hook (post-install,post-upgrade) with + `helm.sh/hook-delete-policy: before-hook-creation` so `helm + upgrade` re-runs it on every reconcile. + - No ServiceAccount/Role/RoleBinding: the Job only talks to + Grafana over the in-cluster Service and reads the admin + credentials from the chart-managed `grafana-admin-password` + Secret via `envFrom`. + - `activeDeadlineSeconds` caps a stuck Job so a broken Grafana + does not hold the release in `pending-upgrade`. + + Rendered for all modes so `mode: None` still gets the prune pass. +*/}} +{{- $oidc := .Values.oidc | default dict }} +{{- $mode := $oidc.mode | default "None" }} +{{- $users := $oidc.users | default (list) }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-oidc-users + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "10" + "helm.sh/hook-delete-policy": before-hook-creation +spec: + backoffLimit: 6 + activeDeadlineSeconds: 900 + template: + metadata: + labels: + app.kubernetes.io/name: monitoring-oidc-users + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: reconcile + image: "{{ $.Files.Get "images/kubectl.tag" | trim }}" + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + env: + - name: HOME + value: /tmp + - name: GRAFANA_URL + value: "http://grafana-service.{{ .Release.Namespace }}.svc:3000" + - name: GF_ADMIN_USER + valueFrom: + secretKeyRef: + name: grafana-admin-password + key: user + - name: GF_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-admin-password + key: password + - name: OIDC_MODE + value: {{ $mode | quote }} + - name: DESIRED_USERS_JSON + value: {{ $users | toJson | quote }} + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + command: + - /bin/sh + - -c + - | + set -eu + + log() { printf '%s\n' "$*" >&2; } + + api() { + # api METHOD PATH [json-body] + method=$1 + path=$2 + body=${3:-} + if [ -n "$body" ]; then + curl --silent --show-error --output /tmp/resp --write-out '%{http_code}' \ + --user "$GF_ADMIN_USER:$GF_ADMIN_PASSWORD" \ + --header 'Content-Type: application/json' \ + --request "$method" \ + --data "$body" \ + "$GRAFANA_URL$path" + else + curl --silent --show-error --output /tmp/resp --write-out '%{http_code}' \ + --user "$GF_ADMIN_USER:$GF_ADMIN_PASSWORD" \ + --request "$method" \ + "$GRAFANA_URL$path" + fi + } + + log "Waiting for Grafana at $GRAFANA_URL/api/health..." + deadline=$(( $(date +%s) + 600 )) + while :; do + if curl --silent --fail --output /dev/null --max-time 5 \ + "$GRAFANA_URL/api/health"; then + break + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + log "Grafana did not become ready within 10m; aborting" + exit 1 + fi + sleep 5 + done + + # Main Org. is orgId=1 in Grafana OSS. We only ever + # touch that one org — the chart does not create + # additional orgs. + ORG_ID=1 + + # Desired set — array of {email,role} objects. + # jq -e returns exit 1 on empty; use || true to + # tolerate `users: []`. + desired_emails=$(printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output '.[].email' 2>/dev/null || true) + + if [ "$OIDC_MODE" != "None" ]; then + log "Reconciling ${OIDC_MODE} users into Grafana org $ORG_ID..." + for email in $desired_emails; do + role=$(printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output --arg e "$email" '.[] | select(.email == $e) | .role') + log " ensure user: email=$email role=$role" + + # 1. Pre-provision the local account so the operator + # can be assigned a role before their first login. + # 409 is expected on re-run — already provisioned. + password=$(head --bytes=24 /dev/urandom | base64 | tr --delete '=+/' | head --bytes=32) + create_body=$(jq --null-input --compact-output \ + --arg name "$email" \ + --arg email "$email" \ + --arg login "$email" \ + --arg password "$password" \ + '{name:$name,email:$email,login:$login,password:$password}') + code=$(api POST /api/admin/users "$create_body") + case "$code" in + 200|201) log " created Grafana account" ;; + 412|409|400) log " account already exists (code $code) — proceeding" ;; + *) log " POST /api/admin/users failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; + esac + + # 2. Add to Main Org. — 409 means already a member, + # we'll PATCH the role below either way. + add_body=$(jq --null-input --compact-output \ + --arg loginOrEmail "$email" \ + --arg role "$role" \ + '{loginOrEmail:$loginOrEmail,role:$role}') + code=$(api POST "/api/orgs/$ORG_ID/users" "$add_body") + case "$code" in + 200|201) log " added to org $ORG_ID with role $role" ;; + 409) log " already a member of org $ORG_ID — will PATCH role" ;; + *) log " POST /api/orgs/$ORG_ID/users failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; + esac + + # 3. Look up userId, then PATCH the role to converge + # demotions/promotions across re-runs. + code=$(api GET "/api/users/lookup?loginOrEmail=$email") + if [ "$code" != "200" ]; then + log " GET /api/users/lookup failed with code $code:"; cat /tmp/resp >&2; exit 1 + fi + user_id=$(jq --raw-output '.id' < /tmp/resp) + patch_body=$(jq --null-input --compact-output --arg role "$role" '{role:$role}') + code=$(api PATCH "/api/orgs/$ORG_ID/users/$user_id" "$patch_body") + case "$code" in + 200) log " role set to $role" ;; + *) log " PATCH role failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; + esac + done + else + log "mode=None: skipping user reconcile, running prune only." + fi + + # Prune — remove every non-admin Main-Org member whose + # email is not in the desired list. + log "Pruning stale org members..." + code=$(api GET "/api/orgs/$ORG_ID/users") + if [ "$code" != "200" ]; then + log " GET /api/orgs/$ORG_ID/users failed with code $code:"; cat /tmp/resp >&2; exit 1 + fi + cp /tmp/resp /tmp/members.json + + # The chart-managed admin login name — never pruned. + admin_login="$GF_ADMIN_USER" + + existing=$(jq --raw-output '.[] | "\(.userId)|\(.login)|\(.email)"' < /tmp/members.json) + printf '%s\n' "$existing" | while IFS='|' read -r uid ulogin uemail; do + if [ -z "$uid" ] || [ "$uid" = "null" ]; then continue; fi + if [ "$ulogin" = "$admin_login" ]; then + log " keep admin: login=$ulogin uid=$uid" + continue + fi + if printf '%s\n' "$desired_emails" | grep --fixed-strings --line-regexp --quiet -- "$uemail"; then + continue + fi + if printf '%s\n' "$desired_emails" | grep --fixed-strings --line-regexp --quiet -- "$ulogin"; then + continue + fi + log " removing stale member: login=$ulogin email=$uemail uid=$uid" + code=$(api DELETE "/api/orgs/$ORG_ID/users/$uid") + case "$code" in + 200) : ;; + *) log " DELETE failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; + esac + done + + log "Done." + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index cc9463c46..0521f9600 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -103,6 +103,11 @@ vmagent: ## set — the chart fails the render otherwise. `cozy` is ## not in the path in this mode. ## +## Authorization is app-side: `users` is reconciled into Grafana org +## membership/roles by a chart-owned post-install/post-upgrade Job. +## Users not listed here get nothing when they log in — no default org +## role from group mapping. +## ## The admin_user / admin_password Secret stays a documented break-glass ## path regardless of mode. oidc: @@ -113,9 +118,12 @@ oidc: ## Inline map of grafana.ini `[auth.generic_oauth]` keys. Any key ## accepted by Grafana's generic_oauth provider is valid; typical ## keys are `client_id`, `client_secret`, `auth_url`, `token_url`, - ## `api_url`, `scopes`, `role_attribute_path`, - ## `allow_assign_grafana_admin`. The chart writes the map verbatim - ## into `spec.config.auth.generic_oauth` on the Grafana CR. + ## `api_url`, `scopes`. The chart writes the map verbatim into + ## `spec.config.auth.generic_oauth` on the Grafana CR. + ## Do NOT set `role_attribute_path` here: the chart forces + ## `skip_org_role_sync = true` regardless of mode so a login never + ## overwrites the users-Job assignments; a `role_attribute_path` + ## would only pollute the config. ## Mutually exclusive with `customConfig.secretRef.name`. config: {} ## Reference to an operator-managed Secret carrying a ready-made @@ -125,17 +133,16 @@ oidc: ## `customConfig.config`. secretRef: name: "" - ## Chart-internal lever for `allow_assign_grafana_admin`: promote - ## `-admin` group members to server-level `GrafanaAdmin`. - ## Defaults to `false` and stays `false` in Phase 1 — no supported - ## path sets it to `true` end-to-end. The obvious platform-bundle - ## path via `Package.spec.components["monitoring-system"].values` - ## is dropped by PackageReconciler because that component has no - ## `install:` block in - ## `packages/core/platform/sources/monitoring-application.yaml`. - ## Server-level `GrafanaAdmin` promotion is out of scope for Phase 1; - ## see docs/oidc-grafana.md. Kept as a values key so a future phase - ## can route the override (via a tenant-root chart values injection - ## or a new `install:` block on `monitoring-system`) without touching - ## the chart contract. - grafanaAdmin: false + ## Users granted access to this Grafana instance's org. Each entry + ## produces one pre-provisioned Grafana account (matched to the OIDC + ## login by `email`) and one org membership + role in the release's + ## `Main Org.`. Empty by default — no OIDC user gets any role until + ## an operator lists them here. + ## + ## Example: + ## users: + ## - email: alice@example.com + ## role: Admin + ## - email: bob@example.com + ## role: Viewer + users: [] From 0a65dd04a1bcb62805f87037e4f78166a117e41b Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:09:44 +0300 Subject: [PATCH 20/39] feat(monitoring): expose spec.oidc.users on the Monitoring CR 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 --- packages/extra/monitoring/README.md | 19 +++++++------ packages/extra/monitoring/values.schema.json | 27 +++++++++++++++++++ packages/extra/monitoring/values.yaml | 11 ++++++++ .../monitoring-rd/cozyrds/monitoring.yaml | 21 ++++++++------- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index 1bc107019..c117c7f7b 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -104,12 +104,15 @@ ### OIDC authentication -| Name | Description | Type | Value | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------ | -| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | -| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | -| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | -| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | -| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | -| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | +| Name | Description | Type | Value | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------ | +| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | +| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | +| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | +| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | +| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | +| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | +| `oidc.users` | Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping). | `[]object` | `[]` | +| `oidc.users[i].email` | Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). | `string` | `""` | +| `oidc.users[i].role` | Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. | `string` | `{}` | diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 25b125492..7448a8aee 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -637,6 +637,33 @@ "System", "CustomConfig" ] + }, + "users": { + "description": "Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).", + "type": "array", + "default": [], + "items": { + "type": "object", + "required": [ + "email", + "role" + ], + "properties": { + "email": { + "description": "Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together).", + "type": "string" + }, + "role": { + "description": "Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1.", + "type": "string", + "enum": [ + "Admin", + "Editor", + "Viewer" + ] + } + } + } } } } diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index b67c5ae99..f9a053c54 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -202,9 +202,19 @@ vmagent: ## @field {OIDCCustomConfigInline} [config] - Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. ## @field {OIDCSecretRef} [secretRef] - Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. +## @enum {string} OIDCRole - Grafana org role bound to a listed user. +## @value Admin +## @value Editor +## @value Viewer + +## @typedef {struct} OIDCUser - Grafana user reconciled into the release's org. +## @field {string} email - Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). +## @field {OIDCRole} role - Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. + ## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm). ## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. ## @field {OIDCCustomConfig} [customConfig] - Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. +## @field {[]OIDCUser} [users] - Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping). ## @param {OIDC} oidc - OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. oidc: @@ -213,3 +223,4 @@ oidc: config: {} secretRef: name: "" + users: [] diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index ef1e6ec75..90f406ad8 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -4,14 +4,15 @@ metadata: name: monitoring annotations: # Monitoring's HR bootstraps Grafana + Alerta + VMCluster + CNPG - # (postgres for Grafana state) + optionally four Keycloak CRs - # (KeycloakClient, KeycloakClientScope, three KeycloakRealmGroups) - # under `spec.oidc.mode: System`. The Keycloak-side reconciliation - # is asynchronous against an operator in `cozy-keycloak`, not the - # tenant namespace, so Flux's default 5m install window is tight - # even before adding OIDC. Override to 15m — matches the precedent - # set by tenant-rd/kubernetes-rd cozyrds annotations for - # long-bootstrap stacks. + # (postgres for Grafana state) + optionally two Keycloak CRs + # (KeycloakClient, KeycloakClientScope) under `spec.oidc.mode: + # System`, plus a post-install/post-upgrade Job that reconciles + # `spec.oidc.users` into Grafana org membership. The Keycloak-side + # reconciliation is asynchronous against an operator in + # `cozy-keycloak`, not the tenant namespace, so Flux's default 5m + # install window is tight even before adding OIDC. Override to + # 15m — matches the precedent set by tenant-rd/kubernetes-rd + # cozyrds annotations for long-bootstrap stacks. release.cozystack.io/helm-install-timeout: "15m" spec: application: @@ -19,7 +20,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]},"users":{"description":"Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).","type":"array","default":[],"items":{"type":"object","required":["email","role"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together).","type":"string"},"role":{"description":"Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1.","type":"string","enum":["Admin","Editor","Viewer"]}}}}}}}} release: prefix: "" labels: @@ -37,7 +38,7 @@ spec: description: Monitoring and observability stack module: true icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODdfMzI2OCkiLz4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzY4N18zMjY4KSI+CjxwYXRoIGQ9Ik04OS41MDM5IDExMS43MDdINTQuNDk3QzU0LjE3MjcgMTExLjcwNyA1NC4wMTA4IDExMS4yMjEgNTQuMzM0OSAxMTEuMDU5TDU3LjI1MjIgMTA4Ljk1MkM2MC4zMzE0IDEwNi42ODMgNjEuOTUyMiAxMDIuNjMxIDYwLjk3OTcgOTguNzQxMkg4My4wMjFDODIuMDQ4NSAxMDIuNjMxIDgzLjY2OTMgMTA2LjY4MyA4Ni43NDg1IDEwOC45NTJMODkuNjY1OCAxMTEuMDU5Qzg5Ljk5IDExMS4yMjEgODkuODI3OSAxMTEuNzA3IDg5LjUwMzkgMTExLjcwN1oiIGZpbGw9IiNCMEI2QkIiLz4KPHBhdGggZD0iTTExMy4zMjggOTguNzQxSDMwLjY3MjVDMjcuNTkzMSA5OC43NDEgMjUgOTYuMTQ4IDI1IDkzLjA2ODdWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJWOTMuMDY4N0MxMTkgOTYuMTQ4IDExNi40MDcgOTguNzQxIDExMy4zMjggOTguNzQxWiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNMTE5IDg0LjE1NDlIMjVWMzMuMTAzMkMyNSAzMC4wMjM5IDI3LjU5MzEgMjcuNDMwNyAzMC42NzI1IDI3LjQzMDdIMTEzLjMyOEMxMTYuNDA3IDI3LjQzMDcgMTE5IDMwLjAyMzcgMTE5IDMzLjEwMzJMMTE5IDg0LjE1NDlaIiBmaWxsPSIjMzg0NTRGIi8+CjxwYXRoIGQ9Ik05MC42Mzc0IDExNi41NjlINTMuMzYxNkM1Mi4wNjUxIDExNi41NjkgNTAuOTMwNyAxMTUuNDM1IDUwLjkzMDcgMTE0LjEzOEM1MC45MzA3IDExMi44NDEgNTIuMDY1MSAxMTEuNzA3IDUzLjM2MTYgMTExLjcwN0g5MC42Mzc0QzkxLjkzMzkgMTExLjcwNyA5My4wNjg0IDExMi44NDEgOTMuMDY4NCAxMTQuMTM4QzkzLjA2ODQgMTE1LjQzNSA5MS45MzM5IDExNi41NjkgOTAuNjM3NCAxMTYuNTY5WiIgZmlsbD0iI0U4RURFRSIvPgo8cGF0aCBkPSJNNTQuMTcyMiAzOC43NzU3SDMzLjEwMzJDMzIuMTMwNyAzOC43NzU3IDMxLjQ4MjQgMzguMTI3NCAzMS40ODI0IDM3LjE1NDlDMzEuNDgyNCAzNi4xODI0IDMyLjEzMDcgMzUuNTM0MiAzMy4xMDMyIDM1LjUzNDJINTQuMTcyMkM1NS4xNDQ3IDM1LjUzNDIgNTUuNzkzIDM2LjE4MjQgNTUuNzkzIDM3LjE1NDlDNTUuNzkyOCAzOC4xMjc0IDU1LjE0NDUgMzguNzc1NyA1NC4xNzIyIDM4Ljc3NTdaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik02My44OTYzIDQ1LjI1OTFINDEuMjA2N0M0MC4yMzQyIDQ1LjI1OTEgMzkuNTg1OSA0NC42MTA4IDM5LjU4NTkgNDMuNjM4M0MzOS41ODU5IDQyLjY2NTggNDAuMjM0MiA0Mi4wMTc2IDQxLjIwNjcgNDIuMDE3Nkg2My44OTYzQzY0Ljg2ODggNDIuMDE3NiA2NS41MTcxIDQyLjY2NTggNjUuNTE3MSA0My42MzgzQzY1LjUxNzEgNDQuNjEwOCA2NC44Njg4IDQ1LjI1OTEgNjMuODk2MyA0NS4yNTkxWiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMzQuNzI0IDQ1LjI1OTFIMzMuMTAzMkMzMi4xMzA3IDQ1LjI1OTEgMzEuNDgyNCA0NC42MTA4IDMxLjQ4MjQgNDMuNjM4M0MzMS40ODI0IDQyLjY2NTggMzIuMTMwNyA0Mi4wMTc2IDMzLjEwMzIgNDIuMDE3NkgzNC43MjRDMzUuNjk2NCA0Mi4wMTc2IDM2LjM0NDcgNDIuNjY1OCAzNi4zNDQ3IDQzLjYzODNDMzYuMzQ0NyA0NC42MTA4IDM1LjY5NjMgNDUuMjU5MSAzNC43MjQgNDUuMjU5MVoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgMzguNzc1N0g2MC42NTQ5QzU5LjY4MjQgMzguNzc1NyA1OS4wMzQyIDM4LjEyNzQgNTkuMDM0MiAzNy4xNTQ5QzU5LjAzNDIgMzYuMTgyNCA1OS42ODI0IDM1LjUzNDIgNjAuNjU0OSAzNS41MzQySDYzLjg5NjNDNjQuODY4OCAzNS41MzQyIDY1LjUxNzEgMzYuMTgyNCA2NS41MTcxIDM3LjE1NDlDNjUuNTE3MSAzOC4xMjc0IDY0Ljg2ODggMzguNzc1NyA2My44OTYzIDM4Ljc3NTdaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik00Ny42ODkzIDUxLjc0MTNIMzMuMTAzMkMzMi4xMzA3IDUxLjc0MTMgMzEuNDgyNCA1MS4wOTMxIDMxLjQ4MjQgNTAuMTIwNkMzMS40ODI0IDQ5LjE0ODEgMzIuMTMwNyA0OC41IDMzLjEwMzIgNDguNUg0Ny42ODkzQzQ4LjY2MTggNDguNSA0OS4zMTAxIDQ5LjE0ODMgNDkuMzEwMSA1MC4xMjA4QzQ5LjMxMDEgNTEuMDkzMyA0OC42NjE4IDUxLjc0MTMgNDcuNjg5MyA1MS43NDEzWiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNjMuODk2OCA1MS43NDEzSDU0LjE3MjVDNTMuMiA1MS43NDEzIDUyLjU1MTggNTEuMDkzMSA1Mi41NTE4IDUwLjEyMDZDNTIuNTUxOCA0OS4xNDgxIDUzLjIwMDIgNDguNSA1NC4xNzI3IDQ4LjVINjMuODk2OUM2NC44Njk0IDQ4LjUgNjUuNTE3NyA0OS4xNDgzIDY1LjUxNzcgNTAuMTIwOEM2NS41MTc3IDUxLjA5MzMgNjQuODY5MiA1MS43NDEzIDYzLjg5NjggNTEuNzQxM1oiIGZpbGw9IiNFQ0JBMTYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNTguMjI0SDMzLjEwMzJDMzIuMTMwNyA1OC4yMjQgMzEuNDgyNCA1Ny41NzU3IDMxLjQ4MjQgNTYuNjAzMkMzMS40ODI0IDU1LjYzMDcgMzIuMTMwNyA1NC45ODI0IDMzLjEwMzIgNTQuOTgyNEg1NC4xNzIyQzU1LjE0NDcgNTQuOTgyNCA1NS43OTMgNTUuNjMwNyA1NS43OTMgNTYuNjAzMkM1NS43OTMgNTcuNTc1NyA1NS4xNDQ1IDU4LjIyNCA1NC4xNzIyIDU4LjIyNFoiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTYzLjg5NjMgNjQuNzA3NEg0MS4yMDY3QzQwLjIzNDIgNjQuNzA3NCAzOS41ODU5IDY0LjA1OTEgMzkuNTg1OSA2My4wODY2QzM5LjU4NTkgNjIuMTE0MSA0MC4yMzQyIDYxLjQ2NTggNDEuMjA2NyA2MS40NjU4SDYzLjg5NjNDNjQuODY4OCA2MS40NjU4IDY1LjUxNzEgNjIuMTE0MSA2NS41MTcxIDYzLjA4NjZDNjUuNTE3MSA2NC4wNTkxIDY0Ljg2ODggNjQuNzA3NCA2My44OTYzIDY0LjcwNzRaIiBmaWxsPSIjRUNCQTE2Ii8+CjxwYXRoIGQ9Ik0zNC43MjQgNjQuNzA3NEgzMy4xMDMyQzMyLjEzMDcgNjQuNzA3NCAzMS40ODI0IDY0LjA1OTEgMzEuNDgyNCA2My4wODY2QzMxLjQ4MjQgNjIuMTE0MSAzMi4xMzA3IDYxLjQ2NTggMzMuMTAzMiA2MS40NjU4SDM0LjcyNEMzNS42OTY0IDYxLjQ2NTggMzYuMzQ0NyA2Mi4xMTQxIDM2LjM0NDcgNjMuMDg2NkMzNi4zNDQ3IDY0LjA1OTEgMzUuNjk2MyA2NC43MDc0IDM0LjcyNCA2NC43MDc0WiIgZmlsbD0iI0REMzQyRSIvPgo8cGF0aCBkPSJNNDcuNjg5MyA3MS4xODk4SDMzLjEwMzJDMzIuMTMwNyA3MS4xODk4IDMxLjQ4MjQgNzAuNTQxNSAzMS40ODI0IDY5LjU2OUMzMS40ODI0IDY4LjU5NjUgMzIuMTMwNyA2Ny45NDgyIDMzLjEwMzIgNjcuOTQ4Mkg0Ny42ODkzQzQ4LjY2MTggNjcuOTQ4MiA0OS4zMTAxIDY4LjU5NjUgNDkuMzEwMSA2OS41NjlDNDkuMzEwMSA3MC41NDE1IDQ4LjY2MTggNzEuMTg5OCA0Ny42ODkzIDcxLjE4OThaIiBmaWxsPSIjNDJCMDVDIi8+CjxwYXRoIGQ9Ik02My44OTY4IDcxLjE4OThINTQuMTcyNUM1My4yIDcxLjE4OTggNTIuNTUxOCA3MC41NDE1IDUyLjU1MTggNjkuNTY5QzUyLjU1MTggNjguNTk2NSA1My4yIDY3Ljk0ODIgNTQuMTcyNSA2Ny45NDgySDYzLjg5NjhDNjQuODY5MiA2Ny45NDgyIDY1LjUxNzUgNjguNTk2NSA2NS41MTc1IDY5LjU2OUM2NS41MTc1IDcwLjU0MTUgNjQuODY5MiA3MS4xODk4IDYzLjg5NjggNzEuMTg5OFoiIGZpbGw9IiM3MzgzQkYiLz4KPHBhdGggZD0iTTU0LjE3MjIgNzcuNjcyMkgzMy4xMDMyQzMyLjEzMDcgNzcuNjcyMiAzMS40ODI0IDc3LjAyMzkgMzEuNDgyNCA3Ni4wNTE0QzMxLjQ4MjQgNzUuMDc4OSAzMi4xMzA3IDc0LjQzMDcgMzMuMTAzMiA3NC40MzA3SDU0LjE3MjJDNTUuMTQ0NyA3NC40MzA3IDU1Ljc5MyA3NS4wNzg5IDU1Ljc5MyA3Ni4wNTE0QzU1Ljc5MjggNzcuMDIzOSA1NS4xNDQ1IDc3LjY3MjIgNTQuMTcyMiA3Ny42NzIyWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNNjMuODk2MyA3Ny42NzIySDYwLjY1NDlDNTkuNjgyNCA3Ny42NzIyIDU5LjAzNDIgNzcuMDIzOSA1OS4wMzQyIDc2LjA1MTRDNTkuMDM0MiA3NS4wNzg5IDU5LjY4MjQgNzQuNDMwNyA2MC42NTQ5IDc0LjQzMDdINjMuODk2M0M2NC44Njg4IDc0LjQzMDcgNjUuNTE3MSA3NS4wNzg5IDY1LjUxNzEgNzYuMDUxNEM2NS41MTcxIDc3LjAyMzkgNjQuODY4OCA3Ny42NzIyIDYzLjg5NjMgNzcuNjcyMloiIGZpbGw9IiM0MkIwNUMiLz4KPHBhdGggZD0iTTEwMS4xNzIgNzcuNjcyMkg4MC4xMDMyQzc5LjEzMDcgNzcuNjcyMiA3OC40ODI0IDc3LjAyMzkgNzguNDgyNCA3Ni4wNTE0Qzc4LjQ4MjQgNzUuMDc4OSA3OS4xMzA3IDc0LjQzMDcgODAuMTAzMiA3NC40MzA3SDEwMS4xNzJDMTAyLjE0NSA3NC40MzA3IDEwMi43OTMgNzUuMDc4OSAxMDIuNzkzIDc2LjA1MTRDMTAyLjc5MyA3Ny4wMjM5IDEwMi4xNDUgNzcuNjcyMiAxMDEuMTcyIDc3LjY3MjJaIiBmaWxsPSIjREQzNDJFIi8+CjxwYXRoIGQ9Ik0xMTAuODk2IDc3LjY3MjJIMTA3LjY1NUMxMDYuNjgyIDc3LjY3MjIgMTA2LjAzNCA3Ny4wMjM5IDEwNi4wMzQgNzYuMDUxNEMxMDYuMDM0IDc1LjA3ODkgMTA2LjY4MiA3NC40MzA3IDEwNy42NTUgNzQuNDMwN0gxMTAuODk2QzExMS44NjkgNzQuNDMwNyAxMTIuNTE3IDc1LjA3ODkgMTEyLjUxNyA3Ni4wNTE0QzExMi41MTcgNzcuMDIzOSAxMTEuODY5IDc3LjY3MjIgMTEwLjg5NiA3Ny42NzIyWiIgZmlsbD0iIzQyQjA1QyIvPgo8cGF0aCBkPSJNNjMuODk2MyA1OC4yMjRINjAuNjU0OUM1OS42ODI0IDU4LjIyNCA1OS4wMzQyIDU3LjU3NTcgNTkuMDM0MiA1Ni42MDMyQzU5LjAzNDIgNTUuNjMwNyA1OS42ODI0IDU0Ljk4MjQgNjAuNjU0OSA1NC45ODI0SDYzLjg5NjNDNjQuODY4OCA1NC45ODI0IDY1LjUxNzEgNTUuNjMwNyA2NS41MTcxIDU2LjYwMzJDNjUuNTE3MSA1Ny41NzU3IDY0Ljg2ODggNTguMjI0IDYzLjg5NjMgNTguMjI0WiIgZmlsbD0iIzczODNCRiIvPgo8cGF0aCBkPSJNMTEyLjUxNyA1MS43NDExQzExMi41MTcgNjAuNjU0OSAxMDUuMjI0IDY3Ljk0OCA5Ni4zMTA0IDY3Ljk0OEM4Ny4zOTY2IDY3Ljk0OCA4MC4xMDM1IDYwLjY1NDkgODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNDIuODI3MyA4Ny4zOTY2IDM1LjUzNDIgOTYuMzEwNCAzNS41MzQyQzEwNS4yMjQgMzUuNTM0MiAxMTIuNTE3IDQyLjgyNzMgMTEyLjUxNyA1MS43NDExWiIgZmlsbD0iI0VDQkExNiIvPgo8cGF0aCBkPSJNODAuMTAzNSA1MS43NDExQzgwLjEwMzUgNTIuMjI3MyA4MC4xMDM1IDUyLjg3NTUgODAuMTAzNSA1My4zNjE5SDk2LjMxMDRWMzUuNTM0MkM4Ny4zOTY2IDM1LjUzNDIgODAuMTAzNSA0Mi44MjczIDgwLjEwMzUgNTEuNzQxMVoiIGZpbGw9IiM0MkIwNUMiLz4KPC9nPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4N18zMjY4IiB4MT0iMS4yMzIzOWUtMDYiIHkxPSItOS41MDAwMSIgeDI9IjE2OCIgeTI9IjE2MiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjOEZEREZGIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzAwNzVGRiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzY4N18zMjY4Ij4KPHJlY3Qgd2lkdGg9Ijk0IiBoZWlnaHQ9Ijk0IiBmaWxsPSJ3aGl0ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjUgMjUpIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg== - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "host"], ["spec", "metricsStorages"], ["spec", "logsStorages"], ["spec", "alerta"], ["spec", "alerta", "storage"], ["spec", "alerta", "storageClassName"], ["spec", "alerta", "resources"], ["spec", "alerta", "resources", "limits"], ["spec", "alerta", "resources", "limits", "cpu"], ["spec", "alerta", "resources", "limits", "memory"], ["spec", "alerta", "resources", "requests"], ["spec", "alerta", "resources", "requests", "cpu"], ["spec", "alerta", "resources", "requests", "memory"], ["spec", "alerta", "alerts"], ["spec", "alerta", "alerts", "telegram"], ["spec", "alerta", "alerts", "telegram", "token"], ["spec", "alerta", "alerts", "telegram", "chatID"], ["spec", "alerta", "alerts", "telegram", "disabledSeverity"], ["spec", "alerta", "alerts", "slack"], ["spec", "alerta", "alerts", "slack", "url"], ["spec", "alerta", "alerts", "slack", "disabledSeverity"], ["spec", "grafana"], ["spec", "grafana", "db"], ["spec", "grafana", "db", "size"], ["spec", "grafana", "resources"], ["spec", "grafana", "resources", "limits"], ["spec", "grafana", "resources", "limits", "cpu"], ["spec", "grafana", "resources", "limits", "memory"], ["spec", "grafana", "resources", "requests"], ["spec", "grafana", "resources", "requests", "cpu"], ["spec", "grafana", "resources", "requests", "memory"], ["spec", "vmagent"], ["spec", "vmagent", "externalLabels"], ["spec", "vmagent", "externalLabels", "cluster"], ["spec", "vmagent", "remoteWrite"], ["spec", "vmagent", "remoteWrite", "urls"], ["spec", "oidc"], ["spec", "oidc", "mode"], ["spec", "oidc", "customConfig"], ["spec", "oidc", "customConfig", "config"], ["spec", "oidc", "customConfig", "secretRef"], ["spec", "oidc", "customConfig", "secretRef", "name"], ["spec", "oidc", "users"]] secrets: exclude: [] include: From d4f59af624ea2471261bf021af6a1e2f123e9f2e Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:09:51 +0300 Subject: [PATCH 21/39] chore(monitoring): sync PackageSource comment with removed Keycloak groups 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 --- .../platform/sources/monitoring-application.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/platform/sources/monitoring-application.yaml b/packages/core/platform/sources/monitoring-application.yaml index c08bf8ced..188bcba7a 100644 --- a/packages/core/platform/sources/monitoring-application.yaml +++ b/packages/core/platform/sources/monitoring-application.yaml @@ -35,16 +35,16 @@ spec: releaseName: monitoring-rd # `oidc` variant — additionally waits for cozystack.keycloak-operator so # the v1.edp.epam.com CRDs consumed by the Phase-1 OIDC selector - # (spec.oidc.mode: System renders KeycloakClient, KeycloakClientScope and - # three KeycloakRealmGroups in the cozy realm) are registered before the + # (spec.oidc.mode: System renders a per-instance KeycloakClient and + # KeycloakClientScope in the cozy realm) are registered before the # monitoring chart reconciles. Without this edge, the # `.Capabilities.APIVersions.Has "v1.edp.epam.com/v1"` guard in - # templates/grafana/oidc-keycloak.yaml would silently drop the whole - # Keycloak side and Grafana's auth.generic_oauth block would point at - # a client that never gets provisioned. Selected from the bundle when - # `authentication.oidc.enabled` is true — see - # packages/core/platform/templates/bundles/system.yaml. Mirrors the - # cozystack-engine default/oidc variant split. + # templates/grafana/oidc-keycloak.yaml would fail the render (or, worse, + # if the guard were removed, silently drop the Keycloak side and leave + # Grafana's auth.generic_oauth pointing at a client that never gets + # provisioned). Selected from the bundle when `authentication.oidc.enabled` + # is true — see packages/core/platform/templates/bundles/system.yaml. + # Mirrors the cozystack-engine default/oidc variant split. - name: oidc dependsOn: - cozystack.networking From c493f2cb9615aa8b2562c812abfc590c70bd58a4 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:10:04 +0300 Subject: [PATCH 22/39] test(monitoring): rewrite OIDC unit tests around the users-map contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../system/monitoring/tests/oidc_test.yaml | 256 +++++++++++++----- 1 file changed, 193 insertions(+), 63 deletions(-) diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 8732ade29..256796f1e 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -2,6 +2,7 @@ suite: OIDC selector on the Grafana instance (Phase 1) templates: - templates/grafana/oidc-keycloak.yaml - templates/grafana/oidc-client-secret.yaml + - templates/grafana/oidc-users-job.yaml - templates/grafana/grafana.yaml release: @@ -24,10 +25,44 @@ capabilities: tests: ########################################################## - # mode: None — nothing OIDC-related is emitted + # Defensive rendering — omitted / null oidc block ########################################################## - - it: mode=None emits no KeycloakClient / KeycloakClientScope / KeycloakRealmGroup + - it: oidc omitted entirely renders as if mode=None + template: templates/grafana/grafana.yaml + asserts: + - notExists: + path: spec.config["auth.generic_oauth"] + + - it: oidc=null does not blow up nil-deref on grafana.yaml + template: templates/grafana/grafana.yaml + set: + oidc: ~ + asserts: + - notExists: + path: spec.config["auth.generic_oauth"] + + - it: oidc=null does not blow up nil-deref on oidc-keycloak.yaml + template: templates/grafana/oidc-keycloak.yaml + set: + oidc: ~ + asserts: + - hasDocuments: + count: 0 + + - it: oidc=null does not blow up nil-deref on oidc-client-secret.yaml + template: templates/grafana/oidc-client-secret.yaml + set: + oidc: ~ + asserts: + - hasDocuments: + count: 0 + + ########################################################## + # mode: None + ########################################################## + + - it: mode=None emits no KeycloakClient / KeycloakClientScope template: templates/grafana/oidc-keycloak.yaml asserts: - hasDocuments: @@ -53,18 +88,40 @@ tests: content: name: GF_PATHS_CUSTOM_INI + - it: mode=None still renders the users-Job so a mode switch can prune stale members + template: templates/grafana/oidc-users-job.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: kind + value: Job + - equal: + path: metadata.annotations["helm.sh/hook"] + value: post-install,post-upgrade + - contains: + path: spec.template.spec.containers[0].env + content: + name: OIDC_MODE + value: "None" + - contains: + path: spec.template.spec.containers[0].env + content: + name: DESIRED_USERS_JSON + value: "[]" + ########################################################## # mode: System — happy path ########################################################## - - it: mode=System renders KeycloakClient + KeycloakClientScope + 3 KeycloakRealmGroups + - it: mode=System renders exactly one KeycloakClient and one KeycloakClientScope (no groups) template: templates/grafana/oidc-keycloak.yaml set: oidc.mode: System _cluster.oidc-enabled: "true" asserts: - hasDocuments: - count: 5 + count: 2 - equal: path: kind value: KeycloakClientScope @@ -73,22 +130,6 @@ tests: path: kind value: KeycloakClient documentIndex: 1 - - equal: - path: kind - value: KeycloakRealmGroup - documentIndex: 2 - - equal: - path: metadata.name - value: tenant-root-monitoring-admin - documentIndex: 2 - - equal: - path: metadata.name - value: tenant-root-monitoring-editor - documentIndex: 3 - - equal: - path: metadata.name - value: tenant-root-monitoring-viewer - documentIndex: 4 - it: mode=System KeycloakClient is confidential and points to the persistent Secret template: templates/grafana/oidc-keycloak.yaml @@ -112,6 +153,12 @@ tests: path: spec.defaultClientScopes content: tenant-root-monitoring-audience documentIndex: 1 + # Groups scope removed together with the KeycloakRealmGroups — the + # generic_oauth block no longer consumes group membership. + - notContains: + path: spec.defaultClientScopes + content: groups + documentIndex: 1 - it: mode=System audience scope pins id_token.aud to the per-instance clientId template: templates/grafana/oidc-keycloak.yaml @@ -128,7 +175,7 @@ tests: value: tenant-root-monitoring documentIndex: 0 - - it: mode=System persists a random 32-char client-secret at install + - it: mode=System persists a random client-secret at install template: templates/grafana/oidc-client-secret.yaml set: oidc.mode: System @@ -142,9 +189,10 @@ tests: - equal: path: metadata.name value: monitoring-oidc-client + # 32 random alphanumeric chars base64-encoded → 44 chars (43 + '=' padding). - matchRegex: path: data["client-secret"] - pattern: "^[A-Za-z0-9]+={0,2}$" + pattern: "^[A-Za-z0-9+/]{43}=$" - it: mode=System grafana.yaml emits auth.generic_oauth pointing at the cozy realm template: templates/grafana/grafana.yaml @@ -164,13 +212,24 @@ tests: - equal: path: spec.config["auth.generic_oauth"].auth_url value: "https://keycloak.example.org/realms/cozy/protocol/openid-connect/auth" - - matchRegex: - path: spec.config["auth.generic_oauth"].role_attribute_path - pattern: "tenant-root-monitoring-admin.*tenant-root-monitoring-editor.*tenant-root-monitoring-viewer" - # tenant Grafana stays at org-level Admin, no server-level upgrade. - equal: + path: spec.config["auth.generic_oauth"].scopes + value: "openid email profile tenant-root-monitoring-audience" + # No group-based mapping; the users-Job drives roles instead. + - notExists: + path: spec.config["auth.generic_oauth"].role_attribute_path + - notExists: path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin - value: "false" + # skip_org_role_sync=true keeps a login from overwriting the + # users-Job's role assignments. + - equal: + path: spec.config["auth.generic_oauth"].skip_org_role_sync + value: "true" + # Insecure-email-lookup is required to attach the OIDC identity to + # the pre-provisioned local account. + - equal: + path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup + value: "true" - it: mode=System grafana.yaml wires GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret template: templates/grafana/grafana.yaml @@ -197,6 +256,92 @@ tests: path: spec.config.auth.disable_login_form value: "false" + ########################################################## + # users-Job — post-install/post-upgrade hook, RBAC-free + ########################################################## + + - it: users-Job is a helm hook, has a finite deadline, and needs no ServiceAccount + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: kind + value: Job + - equal: + path: metadata.name + value: monitoring-oidc-users + - equal: + path: metadata.annotations["helm.sh/hook"] + value: post-install,post-upgrade + - equal: + path: metadata.annotations["helm.sh/hook-delete-policy"] + value: before-hook-creation + - equal: + path: spec.activeDeadlineSeconds + value: 900 + # No SA name → falls back to `default` — the Job never talks to + # the kube API, only to Grafana over the in-cluster Service. + - notExists: + path: spec.template.spec.serviceAccountName + + - it: users-Job mounts admin credentials from the chart-managed Secret + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: GF_ADMIN_USER + valueFrom: + secretKeyRef: + name: grafana-admin-password + key: user + - contains: + path: spec.template.spec.containers[0].env + content: + name: GF_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-admin-password + key: password + + - it: users-Job serialises spec.oidc.users into DESIRED_USERS_JSON + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + oidc.users: + - email: alice@example.com + role: Admin + - email: bob@example.com + role: Viewer + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DESIRED_USERS_JSON + value: '[{"email":"alice@example.com","role":"Admin"},{"email":"bob@example.com","role":"Viewer"}]' + + - it: users-Job runs a non-root, read-only-rootfs container + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: spec.template.spec.securityContext.runAsNonRoot + value: true + - equal: + path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem + value: true + - equal: + path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation + value: false + ########################################################## # mode: System — fail-close guard ########################################################## @@ -245,7 +390,6 @@ tests: client_id: my-client client_secret: xyz auth_url: https://idp.example/auth - role_attribute_path: "'Viewer'" asserts: - equal: path: spec.config["auth.generic_oauth"].client_id @@ -256,9 +400,27 @@ tests: - equal: path: spec.config["auth.generic_oauth"].auth_url value: https://idp.example/auth - - equal: - path: spec.config["auth.generic_oauth"].role_attribute_path - value: "'Viewer'" + + - it: mode=CustomConfig users-Job runs and honours the desired list + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: my-client + oidc.users: + - email: carol@example.com + role: Editor + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: OIDC_MODE + value: CustomConfig + - contains: + path: spec.template.spec.containers[0].env + content: + name: DESIRED_USERS_JSON + value: '[{"email":"carol@example.com","role":"Editor"}]' ########################################################## # mode: CustomConfig — secretRef @@ -314,35 +476,3 @@ tests: asserts: - failedTemplate: errorMessage: "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." - - ########################################################## - # allow_assign_grafana_admin is driven by oidc.grafanaAdmin - # - # Chart-internal capability only. Phase 1 does not wire this end- - # to-end from the platform bundle: `Package.spec.components[].values` - # is dropped for `monitoring-system` because that component has no - # `install:` block in the PackageSource. Kept as a template unit - # test so a future phase that adds the wiring lands on a green - # chart-side contract. - ########################################################## - - - it: oidc.grafanaAdmin=true upgrades Admin to server-level GrafanaAdmin - template: templates/grafana/grafana.yaml - set: - oidc.mode: System - oidc.grafanaAdmin: true - _cluster.oidc-enabled: "true" - asserts: - - equal: - path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin - value: "true" - - - it: oidc.grafanaAdmin default is false (tenant releases stay at org-level Admin) - template: templates/grafana/grafana.yaml - set: - oidc.mode: System - _cluster.oidc-enabled: "true" - asserts: - - equal: - path: spec.config["auth.generic_oauth"].allow_assign_grafana_admin - value: "false" From 123616a1d00d71ed65beb93f4f8f1bdab60836fa Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:10:13 +0300 Subject: [PATCH 23/39] test(e2e): swap KeycloakRealmGroup assertions for the users-Job in bats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../monitoring-oidc-customconfig.bats | 14 ++--- hack/e2e-apps/monitoring-oidc-system.bats | 59 +++++++++++++++++-- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats index 1c0d8c9b9..f2257a106 100755 --- a/hack/e2e-apps/monitoring-oidc-customconfig.bats +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -66,7 +66,9 @@ spec: auth_url: https://idp.example.test/auth token_url: https://idp.example.test/token api_url: https://idp.example.test/userinfo - role_attribute_path: "'Viewer'" + users: + - email: e2e-byo@example.com + role: Viewer EOF # Outer HR (created by cozystack-operator from the CR) shares the CR name. @@ -96,7 +98,7 @@ EOF } } -@test "No KeycloakClient / Scope / Groups are created in the cozy realm" { +@test "No KeycloakClient / Scope are created in the cozy realm" { # Only meaningful when the EDP CRDs exist on the cluster. if ! kubectl api-resources --api-group=v1.edp.epam.com >/dev/null 2>&1; then skip "EDP Keycloak operator CRDs not present on this cluster" @@ -106,14 +108,12 @@ EOF # the inner chart -> tenant-test-monitoring-system. CID="tenant-test-${INNER_REL}" - # Give any reconciler a moment; then assert none of the four objects - # were created. + # Give any reconciler a moment; then assert neither cozy-realm object + # exists. Chart-owned KeycloakRealmGroups were removed entirely (see + # design note in docs/oidc-grafana.md) so no group assertions here. sleep 5 ! kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" 2>/dev/null ! kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" 2>/dev/null - ! kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-admin" 2>/dev/null - ! kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-editor" 2>/dev/null - ! kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-viewer" 2>/dev/null } @test "secretRef variant mounts operator Secret under /etc/grafana/oidc" { diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats index 2f846e56f..582ba3782 100755 --- a/hack/e2e-apps/monitoring-oidc-system.bats +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -13,8 +13,11 @@ # `spec.oidc.*` fields are admitted by the Monitoring CR; that # cozystack-api accepts the updated schema; that the HelmRelease # renders the OIDC templates; and that the resulting KeycloakClient / -# KeycloakClientScope / KeycloakRealmGroups / persistent -# client-secret Secret / Grafana CR carry the expected shape. +# KeycloakClientScope / persistent client-secret Secret / Grafana CR / +# users-reconcile Job carry the expected shape. Chart-owned +# KeycloakRealmGroups + role_attribute_path are gone (see the +# design note in docs/oidc-grafana.md); authorization is driven by +# spec.oidc.users reconciled into Grafana orgs by the users-Job. # cozytest.sh (the e2e runner) is not real bats: it never invokes # setup()/teardown(). Cleanup belongs in cozy_cleanup(), which runs at @@ -28,7 +31,7 @@ # the inner packages/system/monitoring HelmRelease that carries the # OIDC templates is then named `${outer}-system`, i.e. `monitoring-system`, # and every OIDC identifier derived by the helpers (clientId, audience -# scope, groups, client-secret Secret) is built off that inner name. +# scope, client-secret Secret) is built off that inner name. CR_NAME="monitoring" INNER_REL="${CR_NAME}-system" CID="tenant-test-${INNER_REL}" @@ -65,6 +68,9 @@ spec: storageClassName: replicated oidc: mode: System + users: + - email: e2e-admin@example.com + role: Admin EOF # Outer HR shares the CR name; inner HR (chart target) is ${outer}-system. @@ -110,7 +116,7 @@ EOF echo "${value}" | grep -qE '^[A-Za-z0-9]+$' } -@test "Per-instance KeycloakClient + KeycloakClientScope + 3 KeycloakRealmGroups land in cozy" { +@test "Per-instance KeycloakClient + KeycloakClientScope land in cozy (no groups)" { # The EDP Keycloak operator API group is only present when the # platform-level OIDC feature is on. Skip gracefully otherwise so the # test suite works on runners without Keycloak installed. @@ -134,12 +140,53 @@ EOF echo "audience mapper: ${mapper}" [ "${mapper}" = "oidc-audience-mapper" ] + # No chart-owned KeycloakRealmGroups — directory objects stay owned + # by whoever runs the cozy realm; authorization is app-side (see the + # users-Job assertion below). for role in admin editor viewer; do - timeout 30 sh -ec 'until kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "'"${CID}-${role}"'" >/dev/null 2>&1; do sleep 2; done' - echo "group ${CID}-${role} present" + if kubectl -n tenant-test get keycloakrealmgroup.v1.edp.epam.com "${CID}-${role}" >/dev/null 2>&1; then + echo "unexpected chart-owned realm group ${CID}-${role} present" >&2 + false + fi done } +@test "Grafana config has no role_attribute_path and enables skip_org_role_sync" { + timeout 60 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 2; done' + role_attr=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.role_attribute_path}') + echo "role_attribute_path: ${role_attr}" + [ -z "${role_attr}" ] + + skip_sync=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.skip_org_role_sync}') + echo "skip_org_role_sync: ${skip_sync}" + [ "${skip_sync}" = "true" ] + + email_lookup=$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath='{.spec.config.auth\.generic_oauth.oauth_allow_insecure_email_lookup}') + echo "oauth_allow_insecure_email_lookup: ${email_lookup}" + [ "${email_lookup}" = "true" ] +} + +@test "users-Job runs and carries the desired list from spec.oidc.users" { + # helm.sh/hook: post-install,post-upgrade — the Job appears after the + # inner HR reconciles. `hook-delete-policy: before-hook-creation` keeps + # it around after completion so we can assert on its shape. + timeout 180 sh -ec 'until kubectl -n tenant-test get job "'"${INNER_REL}"'-oidc-users" >/dev/null 2>&1; do sleep 5; done' + + desired=$(kubectl -n tenant-test get job "${INNER_REL}-oidc-users" \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="reconcile")].env[?(@.name=="DESIRED_USERS_JSON")].value}') + echo "DESIRED_USERS_JSON: ${desired}" + echo "${desired}" | grep -q 'e2e-admin@example.com' + echo "${desired}" | grep -q '"role":"Admin"' + + mode=$(kubectl -n tenant-test get job "${INNER_REL}-oidc-users" \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="reconcile")].env[?(@.name=="OIDC_MODE")].value}') + echo "OIDC_MODE: ${mode}" + [ "${mode}" = "System" ] +} + @test "Grafana Deployment injects GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret" { # Grafana operator materialises a Deployment named `grafana-deployment` # from the Grafana CR. Poll for it before asserting on the env. From affe9beca5e8494876d489bb9a2edb8b24f9349e Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:10:25 +0300 Subject: [PATCH 24/39] docs(oidc-grafana): reflect users-map replacement of the role-attribute-path design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/oidc-grafana.md | 168 ++++++++++++++++++++++++------------------- 1 file changed, 95 insertions(+), 73 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index fe787e9c0..b2895f0e9 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -14,7 +14,10 @@ deferred to Phase 2. This Grafana integration follows the same shape as the tenant kube-apiserver's Phase 1 ([cozystack/cozystack#3044](https://github.com/cozystack/cozystack/pull/3044)), adapted for a server-side (confidential) OAuth client instead of a -public kubectl one. +public kubectl one: authentication is wired up by the chart, but +authorization is an operator-supplied `users:` map reconciled into +the target (Grafana orgs here, ClusterRoleBindings in #3044) by a +chart-owned Job. The chart does not own any directory objects. ## Selector @@ -27,6 +30,11 @@ metadata: spec: oidc: mode: System # System | CustomConfig | None (default) + users: + - email: alice@acme.example + role: Admin + - email: bob@acme.example + role: Viewer ``` Three modes: @@ -65,42 +73,30 @@ CR: per-instance clientId. This is the per-instance isolation primitive: a token minted for one Monitoring release is rejected by another's Grafana. -3. **Three `KeycloakRealmGroup`s** named - `--{admin,editor,viewer}`. The groups - themselves are chart-owned; their membership is not — a platform - operator adds users to them through the Keycloak UI or a - `KeycloakRealmUser` CR. See the "Users and RBAC" section. -4. A **Secret** `-oidc-client` carrying a random 32-char +3. A **Secret** `-oidc-client` carrying a random 32-char `client-secret`. Generated on first install (`lookup` + random fallback, same pattern as `packages/system/dashboard`), preserved across upgrades. -5. The Grafana CR's `spec.config.auth.generic_oauth` section wired to - the cozy realm issuer + the per-instance audience scope + - `role_attribute_path` mapping the three groups above to Grafana's - `Admin` / `Editor` / `Viewer` roles. -6. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana - Deployment sourced from the Secret in step 4. +4. The Grafana CR's `spec.config.auth.generic_oauth` section wired to + the cozy realm issuer + the per-instance audience scope, with + `skip_org_role_sync = true` so a login never overwrites the + app-side role assignments made by the users-Job, and + `oauth_allow_insecure_email_lookup = true` so the OIDC identity + binds to the pre-provisioned local account by email. +5. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana + Deployment sourced from the Secret in step 3. +6. A post-install/post-upgrade **users-Job** — see the "Users and + RBAC" section — that reconciles `spec.oidc.users` into Grafana's + Main Org. + +No `KeycloakRealmGroup`s and no `role_attribute_path`. Directory +objects (users, groups, group memberships) stay owned by whoever +operates the `cozy` realm; the Monitoring release only requests +authentication wiring, it does not act as a user directory. Server-level `GrafanaAdmin` promotion (`allow_assign_grafana_admin`) is out of scope for Phase 1. All Grafana instances — platform and -tenant — cap at org-level `Admin` for members of the -`-admin` group. Not exposed on the tenant `Monitoring` CR -either. - -The chart carries an internal lever `oidc.grafanaAdmin` (default -`false`), reserved for a future phase that will route the override -through the tenant-root chart's monitoring HR values. In Phase 1 -nothing sets it to `true` end-to-end: the platform bundle's obvious -path via `Package.spec.components["monitoring-system"].values` is -silently dropped because the `monitoring-system` component in -`packages/core/platform/sources/monitoring-application.yaml` has no -`install:` block, and PackageReconciler skips components without -one before ever copying the override onto a HelmRelease -(`internal/operator/package_reconciler.go` around lines 197-201 and -292-293). Wiring it end-to-end requires either an explicit values -injection from the tenant-root chart's `templates/monitoring.yaml` -or an `install:` block on the `monitoring-system` component that -creates a variant-scoped HR — both defensible, both deferred. +tenant — cap at org-level `Admin`. ## CustomConfig mode @@ -118,8 +114,7 @@ spec: auth_url: https://idp.acme.example/protocol/openid-connect/auth token_url: https://idp.acme.example/protocol/openid-connect/token api_url: https://idp.acme.example/protocol/openid-connect/userinfo - scopes: openid email profile groups - role_attribute_path: "contains(groups[*], 'grafana-admins') && 'Admin' || 'Viewer'" + scopes: openid email profile ``` …or via an existing Secret in the tenant namespace whose `auth.ini` @@ -134,6 +129,12 @@ spec: name: acme-byo-grafana-auth ``` +Do not set `role_attribute_path` in the payload: the chart forces +`skip_org_role_sync = true` regardless of mode so a login never +overwrites the users-Job assignments, and a `role_attribute_path` +would just be dead config. Roles are reconciled by the same +`spec.oidc.users` map as in `System` mode — see below. + Setting both `config` and `secretRef.name` (or neither) fails the render. In `CustomConfig` mode no Keycloak objects are provisioned in `cozy` and no chart-owned client-secret Secret is created — the @@ -142,39 +143,51 @@ operator manages their own credentials end-to-end. ## Users and RBAC Grafana's built-in identity model exposes three org-level roles: -`Admin` / `Editor` / `Viewer`. The chart maps them to Keycloak groups -via `role_attribute_path`, evaluated on the `groups` claim at login: - -```text -contains(groups[*], '--admin') && 'Admin' || -contains(groups[*], '--editor') && 'Editor' || -contains(groups[*], '--viewer') && 'Viewer' || -'Viewer' -``` - -Authenticated identities with none of the three groups default to -`Viewer`. To give a user a role, add them to the corresponding -`KeycloakRealmGroup` in `cozy`: +`Admin` / `Editor` / `Viewer`. The chart drives them from an +operator-supplied map on the CR: ```yaml -apiVersion: v1.edp.epam.com/v1 -kind: KeycloakRealmUser -metadata: - name: alice-acme - namespace: cozy-keycloak spec: - realm: cozy - username: alice@acme.example - email: alice@acme.example - emailVerified: true - password: "…" - groups: - - tenant-acme-monitoring-admin + oidc: + users: + - email: alice@acme.example + role: Admin + - email: bob@acme.example + role: Editor + - email: carol@acme.example + role: Viewer ``` -Removing a user from the group demotes them (Grafana re-evaluates -`role_attribute_path` on every login). Deleting them from `cozy` -revokes access outright. +A chart-owned Job runs on every `helm install` / `helm upgrade` and +reconciles that list into Main Org. membership via Grafana's admin +API: + +1. Each listed email gets a pre-provisioned local Grafana account + with a random password. The account is a shell; the operator + never uses that password. When they sign in with the OIDC flow + Grafana looks up the pre-provisioned account by email + (`oauth_allow_insecure_email_lookup = true`) and attaches the + OIDC identity to it. +2. Each listed email is added to Main Org. with the requested role. + Re-runs of the Job converge role changes (`Editor` → `Admin`, + demotions, etc.) via `PATCH /api/orgs/{orgId}/users/{userId}`. +3. Every Main-Org member whose email is neither in the list nor the + break-glass `grafana-admin-password` login is removed. Removing + an entry from `users:` and re-reconciling revokes access; + flipping `mode: None` treats the list as empty and prunes every + OIDC-provisioned account. + +Users not listed in `spec.oidc.users` who log in through OIDC get +nothing — no default `Viewer` role, no cross-tenant read access. +`skip_org_role_sync = true` in the Grafana config makes sure the +Job's assignments outlive the next login. + +For `System` mode, the operator provisions the corresponding +Keycloak user in `cozy` in whatever way they already do (Keycloak +UI, a `KeycloakRealmUser` CR, an identity broker). The Monitoring +release does not create Keycloak users and does not manage +`KeycloakRealmGroup`s — group membership curated out-of-band is not +affected by anything this chart does. ## How a user logs in @@ -182,8 +195,14 @@ The user opens `https://grafana.` in a browser and picks the "Sign in with Keycloak" button under the login form. Grafana runs the Authorization Code + PKCE flow against the cozy realm, receives a token whose `aud` claim matches this Monitoring instance's clientId, -and creates or updates the local Grafana user on the first -successful login with the role from `role_attribute_path`. +and — if the user's email is in `spec.oidc.users` — binds the OIDC +identity to the pre-provisioned local account with the role the Job +already set. + +If the user's email is not in `spec.oidc.users` the login succeeds +authentication-wise but they land with no org membership and no role. +Add them to `spec.oidc.users` and re-apply the CR; the Job will run +on the next helm-upgrade and pull them in. The break-glass `admin_user` / `admin_password` field on the form stays wired to the `grafana-admin-password` Secret and continues to @@ -217,16 +236,17 @@ work — useful when Keycloak is down or misconfigured. mappings** — Grafana rejects the callback and the login screen shows an error; the `admin_user`/`admin_password` Secret keeps working as break-glass. -- **Mode toggle destroys chart-owned `KeycloakRealmGroup` - membership.** The three groups (`-{admin,editor,viewer}`) - are release-owned. Flipping `spec.oidc.mode` from `System` to - `CustomConfig` or `None` runs a `helm upgrade` that deletes the - three `KeycloakRealmGroup` objects — and, with them, every user's - membership. Flipping back to `System` recreates the groups empty; - the platform operator has to re-populate them by hand (via the - Keycloak UI or new `KeycloakRealmUser` CRs). Not a bug, but a - one-way UX cost on the toggle; treat it like rotating an admin - Secret. +- **User successfully logs in but sees no dashboards.** Their email + is missing from `spec.oidc.users` — no org role was assigned. + Add the entry and re-apply the CR; the users-Job runs on the + next helm-upgrade and grants access. +- **users-Job fails.** The Job caps at `activeDeadlineSeconds: 900` + and `backoffLimit: 6`. Common causes: Grafana never becomes + ready (check `kubectl -n get pods -l app=grafana`), or the + `grafana-admin-password` Secret is missing its `user`/`password` + keys. The failed hook Job stays around until the next + helm-upgrade for post-mortem; check its logs with + `kubectl -n logs job/-oidc-users`. - **`emailVerified` on Keycloak users is a prescriptive requirement, not a chart-enforced one.** The chart does not emit any `claimValidationRules` — the layered guarantees you rely on @@ -258,6 +278,8 @@ work — useful when Keycloak is down or misconfigured. - **`disable_login_form: true` under `mode: System`.** Kept off so the `admin_user`/`admin_password` Secret remains a documented break-glass path; hardening it is a follow-up. +- **Server-level `GrafanaAdmin` promotion.** All Grafana instances + cap at org-level `Admin`. Not exposed on the CR. - **CEL `claimValidationRules` enforcing `email_verified`.** See the failure-modes note above. - **Multi-issuer / BYO alongside `cozy`.** `mode: System` and From 26e5f06d27deecda5f5879b1deef5917db58dbe8 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:54:06 +0300 Subject: [PATCH 25/39] fix(monitoring): chart-force skip_org_role_sync + email lookup in every OIDC mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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 --- .../monitoring/templates/helmrelease.yaml | 6 +- .../system/monitoring/templates/_helpers.tpl | 42 +++++++++++++ .../monitoring/templates/grafana/grafana.yaml | 53 +++++++++++------ .../templates/grafana/oidc-users-job.yaml | 59 ++++++++++++++----- packages/system/monitoring/values.yaml | 33 ++++++++--- 5 files changed, 150 insertions(+), 43 deletions(-) diff --git a/packages/extra/monitoring/templates/helmrelease.yaml b/packages/extra/monitoring/templates/helmrelease.yaml index e7290cb5d..7bc613e33 100644 --- a/packages/extra/monitoring/templates/helmrelease.yaml +++ b/packages/extra/monitoring/templates/helmrelease.yaml @@ -10,7 +10,11 @@ spec: name: cozystack-monitoring-application-default-monitoring-system namespace: cozy-system interval: 5m - timeout: 10m + # Aligned with the outer cozyrds `release.cozystack.io/helm-install-timeout: "15m"` + # (packages/system/monitoring-rd/cozyrds/monitoring.yaml) so the inner HR does + # not fail at 10m while the OIDC users-Job is still inside its + # activeDeadlineSeconds: 900 (10m Grafana wait + reconcile budget). + timeout: 15m install: remediation: retries: -1 diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index 5bdd2e73c..150920d43 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -110,3 +110,45 @@ {{- $users := $oidc.users | default (list) -}} {{- toYaml $users -}} {{- end -}} + +{{- /* + Reject malformed emails in `spec.oidc.users`. Grafana passes the + string verbatim into a query string (/api/users/lookup?loginOrEmail=) + and into JSON bodies (create_body / add_body); the users-Job's shell + reader is NUL-safe and URL-encodes, but rejecting at render time is + cheaper than debugging a 400 from Grafana. Pattern is intentionally + conservative — no whitespace, no quoted literals, no bracketed IP + domains, no unicode; RFC 5322 in its full generality is out of scope. +*/}} +{{- define "monitoring.oidc.assertUsersEmailShape" -}} +{{- $oidc := .Values.oidc | default dict -}} +{{- $users := $oidc.users | default (list) -}} +{{- range $i, $u := $users -}} +{{- $email := $u.email | default "" -}} +{{- if not (regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" $email) -}} +{{- fail (printf "spec.oidc.users[%d].email: %q does not match the conservative email pattern ^[A-Za-z0-9._%%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$. Whitespace, quoted literals, and non-ASCII characters are rejected on template level so the reconcile-Job's shell handling never has to defend against malformed input." $i $email) -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- /* + Reject `spec.oidc.users` in CustomConfig+secretRef mode: the + operator's `auth.ini` fragment is authoritative and the chart cannot + inject `skip_org_role_sync=true` / `oauth_allow_insecure_email_lookup=true` + into it. Without those two settings the users-Job's role assignments + are overwritten on the operator's next login (skip_org_role_sync) and + the pre-provisioned local account is orphaned (email lookup) — the + Job's contract silently breaks. Force the operator into an explicit + choice: either use `customConfig.config` (inline) and let the chart + merge the two settings, or omit `spec.oidc.users` and manage + authorization themselves inside the mounted ini. +*/}} +{{- define "monitoring.oidc.assertCustomSecretRefUsersEmpty" -}} +{{- $oidc := .Values.oidc | default dict -}} +{{- $customConfig := $oidc.customConfig | default dict -}} +{{- $secretName := dig "secretRef" "name" "" $customConfig -}} +{{- $users := $oidc.users | default (list) -}} +{{- if and (ne ($secretName | toString) "") (gt (len $users) 0) -}} +{{- fail "spec.oidc: `users` is not honoured under `customConfig.secretRef` — the operator's mounted auth.ini is authoritative and the chart cannot inject `skip_org_role_sync=true` / `oauth_allow_insecure_email_lookup=true`, so the users-Job's role assignments would be overwritten on the operator's next login. Either switch to `customConfig.config` (inline map, merged with the chart-forced settings) or unset `users` and manage authorization inside the ini fragment yourself." -}} +{{- end -}} +{{- end -}} diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 1b0513d0c..439a29d30 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -31,6 +31,7 @@ {{- $oidcMode := $oidc.mode | default "None" }} {{- if eq $oidcMode "CustomConfig" }} {{- include "monitoring.oidc.assertCustomConfigXor" . }} +{{- include "monitoring.oidc.assertCustomSecretRefUsersEmpty" . }} {{- end }} {{- /* Symmetric assertions with templates/grafana/oidc-keycloak.yaml so a @@ -75,29 +76,43 @@ spec: admin_password: ${GF_PASSWORD} plugins: allow_loading_unsigned_plugins: "victoriametrics-logs-datasource" + {{- /* + The chart-forced settings — `skip_org_role_sync` and + `oauth_allow_insecure_email_lookup` — are the two runtime keys the + users-Job contract depends on: + * skip_org_role_sync: "true" stops a login from overwriting the + Job's org-role assignments. + * oauth_allow_insecure_email_lookup: "true" binds the OIDC + identity to the local account the Job pre-provisions. + They are merged on top of System's generated block AND on top of + the operator's inline map in CustomConfig, so the contract + holds regardless of mode. In CustomConfig secretRef mode the + operator's ini fragment is authoritative and the chart cannot + inject anything — see _helpers.tpl assertCustomSecretRefUsersEmpty. + */}} + {{- $chartForcedOidc := dict "skip_org_role_sync" "true" "oauth_allow_insecure_email_lookup" "true" }} {{- if eq $oidcMode "System" }} + {{- $systemOidc := dict + "enabled" "true" + "name" "Keycloak" + "client_id" (include "monitoring.oidc.clientId" .) + "client_secret" "${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}" + "auth_url" (printf "%s/protocol/openid-connect/auth" (include "monitoring.oidc.systemIssuerURL" .)) + "token_url" (printf "%s/protocol/openid-connect/token" (include "monitoring.oidc.systemIssuerURL" .)) + "api_url" (printf "%s/protocol/openid-connect/userinfo" (include "monitoring.oidc.systemIssuerURL" .)) + "scopes" (printf "openid email profile %s" (include "monitoring.oidc.audienceScopeName" .)) + "email_attribute_path" "email" + "auto_login" "false" + }} auth.generic_oauth: - enabled: "true" - name: "Keycloak" - client_id: {{ include "monitoring.oidc.clientId" . | quote }} - client_secret: "${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}" - auth_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/auth" - token_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/token" - api_url: "{{ include "monitoring.oidc.systemIssuerURL" . }}/protocol/openid-connect/userinfo" - scopes: "openid email profile {{ include "monitoring.oidc.audienceScopeName" . }}" - email_attribute_path: "email" - # Authorization is reconciled app-side by the users-Job (see - # oidc-users-job.yaml); no group→role mapping. skip_org_role_sync - # keeps a login from overwriting the app-side assignments. - skip_org_role_sync: "true" - # Match by email so the pre-provisioned Grafana user (created by - # the users-Job before the operator's first login) is picked up - # instead of a fresh account. - oauth_allow_insecure_email_lookup: "true" - auto_login: "false" + {{- toYaml (merge $chartForcedOidc $systemOidc) | nindent 6 }} {{- else if $useCustomInline }} auth.generic_oauth: - {{- toYaml $customInline | nindent 6 }} + {{- /* $chartForcedOidc wins over $customInline via `merge` semantics: + merge lhs rhs = lhs values kept, rhs fills only missing keys. + So the operator can NOT override skip_org_role_sync=true or + oauth_allow_insecure_email_lookup=true — those are contract. */}} + {{- toYaml (merge $chartForcedOidc $customInline) | nindent 6 }} {{- end }} deployment: spec: diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml index d9888ccf2..5d5d9e9c7 100644 --- a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -41,6 +41,7 @@ {{- $oidc := .Values.oidc | default dict }} {{- $mode := $oidc.mode | default "None" }} {{- $users := $oidc.users | default (list) }} +{{- include "monitoring.oidc.assertUsersEmailShape" . }} --- apiVersion: batch/v1 kind: Job @@ -54,6 +55,11 @@ metadata: spec: backoffLimit: 6 activeDeadlineSeconds: 900 + # Non-hook cleanup: `hook-delete-policy: before-hook-creation` only fires + # on the NEXT hook activation, never on release uninstall — a succeeded + # Job + Pod would otherwise linger in the namespace forever. Cozystack + # convention: 3600s. + ttlSecondsAfterFinished: 3600 template: metadata: labels: @@ -69,7 +75,11 @@ spec: type: RuntimeDefault containers: - name: reconcile - image: "{{ $.Files.Get "images/kubectl.tag" | trim }}" + # Route through cozy-lib.image so mirrored / air-gapped + # installations (`_cluster.images-registry`) resolve the image; + # matches the pattern in + # packages/apps/mariadb/templates/hooks/cleanup-pvc.yaml. + image: {{ include "cozy-lib.image" (list ($.Files.Get "images/kubectl.tag" | trim) $) }} imagePullPolicy: IfNotPresent securityContext: allowPrivilegeEscalation: false @@ -149,14 +159,20 @@ spec: # additional orgs. ORG_ID=1 - # Desired set — array of {email,role} objects. - # jq -e returns exit 1 on empty; use || true to - # tolerate `users: []`. - desired_emails=$(printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output '.[].email' 2>/dev/null || true) + # Emit desired emails as NUL-terminated so `while read -d ''` + # tolerates any RFC 5322-legal local part (whitespace, quoted + # literals, symbols). `for email in $var` was word-split on + # default IFS which broke on any non-trivial email. + printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output0 '.[].email' > /tmp/desired-emails 2>/dev/null || : > /tmp/desired-emails + + # URL-encode a string via jq (@uri) — used for query-string + # values like /api/users/lookup?loginOrEmail=. + urlencode() { printf '%s' "$1" | jq --slurp --raw-input --raw-output '@uri'; } if [ "$OIDC_MODE" != "None" ]; then log "Reconciling ${OIDC_MODE} users into Grafana org $ORG_ID..." - for email in $desired_emails; do + while IFS= read -r -d '' email; do + [ -n "$email" ] || continue role=$(printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output --arg e "$email" '.[] | select(.email == $e) | .role') log " ensure user: email=$email role=$role" @@ -191,8 +207,11 @@ spec: esac # 3. Look up userId, then PATCH the role to converge - # demotions/promotions across re-runs. - code=$(api GET "/api/users/lookup?loginOrEmail=$email") + # demotions/promotions across re-runs. Query-string + # value URL-encoded so & = ? in the local part parse + # correctly on the Grafana side. + email_enc=$(urlencode "$email") + code=$(api GET "/api/users/lookup?loginOrEmail=$email_enc") if [ "$code" != "200" ]; then log " GET /api/users/lookup failed with code $code:"; cat /tmp/resp >&2; exit 1 fi @@ -203,7 +222,7 @@ spec: 200) log " role set to $role" ;; *) log " PATCH role failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; esac - done + done < /tmp/desired-emails else log "mode=None: skipping user reconcile, running prune only." fi @@ -220,17 +239,25 @@ spec: # The chart-managed admin login name — never pruned. admin_login="$GF_ADMIN_USER" - existing=$(jq --raw-output '.[] | "\(.userId)|\(.login)|\(.email)"' < /tmp/members.json) - printf '%s\n' "$existing" | while IFS='|' read -r uid ulogin uemail; do + # Membership stream is emitted NUL-separated so an email + # containing whitespace / pipe characters cannot corrupt the + # field boundaries. + jq --raw-output0 '.[] | [.userId, .login, .email] | @tsv' < /tmp/members.json > /tmp/members.tsv0 + + while IFS= read -r -d '' triple; do + # Each triple is TAB-separated: userId \t login \t email + uid=$(printf '%s' "$triple" | cut -f 1) + ulogin=$(printf '%s' "$triple" | cut -f 2) + uemail=$(printf '%s' "$triple" | cut -f 3) if [ -z "$uid" ] || [ "$uid" = "null" ]; then continue; fi if [ "$ulogin" = "$admin_login" ]; then log " keep admin: login=$ulogin uid=$uid" continue fi - if printf '%s\n' "$desired_emails" | grep --fixed-strings --line-regexp --quiet -- "$uemail"; then - continue - fi - if printf '%s\n' "$desired_emails" | grep --fixed-strings --line-regexp --quiet -- "$ulogin"; then + # Match uemail / ulogin against desired list via NUL-safe + # grep. `-z` treats input+patterns as NUL-terminated; `-F` + # disables regex; `-x` anchors on the full record. + if grep --null-data --fixed-strings --line-regexp --quiet -e "$uemail" -e "$ulogin" /tmp/desired-emails; then continue fi log " removing stale member: login=$ulogin email=$uemail uid=$uid" @@ -239,7 +266,7 @@ spec: 200) : ;; *) log " DELETE failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; esac - done + done < /tmp/members.tsv0 log "Done." volumeMounts: diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index 0521f9600..6011b0f38 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -117,13 +117,21 @@ oidc: customConfig: ## Inline map of grafana.ini `[auth.generic_oauth]` keys. Any key ## accepted by Grafana's generic_oauth provider is valid; typical - ## keys are `client_id`, `client_secret`, `auth_url`, `token_url`, - ## `api_url`, `scopes`. The chart writes the map verbatim into - ## `spec.config.auth.generic_oauth` on the Grafana CR. - ## Do NOT set `role_attribute_path` here: the chart forces - ## `skip_org_role_sync = true` regardless of mode so a login never - ## overwrites the users-Job assignments; a `role_attribute_path` - ## would only pollute the config. + ## keys are `enabled: "true"`, `client_id`, `client_secret`, + ## `auth_url`, `token_url`, `api_url`, `scopes`. Grafana defaults + ## `enabled: false`, so an inline map without an explicit + ## `enabled: "true"` is inert — remember to set it. + ## + ## The chart merges TWO chart-forced settings on top of this map so + ## the users-Job contract holds: + ## * `skip_org_role_sync: "true"` (login never overwrites the + ## Job's org-role assignments); + ## * `oauth_allow_insecure_email_lookup: "true"` (OIDC identity + ## binds to the pre-provisioned local account by email). + ## These two keys cannot be overridden — set them explicitly here + ## and the chart still wins. `role_attribute_path` would be dead + ## config; the Job manages roles. + ## ## Mutually exclusive with `customConfig.secretRef.name`. config: {} ## Reference to an operator-managed Secret carrying a ready-made @@ -131,6 +139,17 @@ oidc: ## chart mounts it and Grafana loads it via ## `GF_PATHS_CUSTOM_INI`. Mutually exclusive with ## `customConfig.config`. + ## + ## IMPORTANT — users-map is NOT supported in this branch: the + ## chart cannot inject `skip_org_role_sync=true` / + ## `oauth_allow_insecure_email_lookup=true` into a Secret-mounted + ## ini fragment, so the users-Job's role assignments would be + ## silently overwritten on the next login. Either use + ## `customConfig.config` (inline) and let the chart merge the two + ## settings, or leave `spec.oidc.users` empty and manage + ## authorization inside the mounted ini fragment yourself. The + ## chart hard-fails render when both `secretRef.name` and non-empty + ## `users` are set. secretRef: name: "" ## Users granted access to this Grafana instance's org. Each entry From ad67dee95bc6fcc20caf411a3c4f217bf718711a Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:54:18 +0300 Subject: [PATCH 26/39] test(monitoring): lock the CustomConfig chart-force contract in helm-unittest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../system/monitoring/tests/oidc_test.yaml | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 256796f1e..01e1bb76c 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -382,7 +382,7 @@ tests: - hasDocuments: count: 0 - - it: mode=CustomConfig inline writes the operator's map verbatim into spec.config + - it: mode=CustomConfig inline preserves the operator's map + chart-forced settings template: templates/grafana/grafana.yaml set: oidc.mode: CustomConfig @@ -400,6 +400,31 @@ tests: - equal: path: spec.config["auth.generic_oauth"].auth_url value: https://idp.example/auth + # Contract: chart-forced settings are merged on top of CustomConfig + # inline, so the users-Job's role assignments survive login. + - equal: + path: spec.config["auth.generic_oauth"].skip_org_role_sync + value: "true" + - equal: + path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup + value: "true" + + - it: mode=CustomConfig inline — operator cannot override chart-forced OIDC settings + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: my-client + # Operator tries to disable both — chart still wins. + skip_org_role_sync: "false" + oauth_allow_insecure_email_lookup: "false" + asserts: + - equal: + path: spec.config["auth.generic_oauth"].skip_org_role_sync + value: "true" + - equal: + path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup + value: "true" - it: mode=CustomConfig users-Job runs and honours the desired list template: templates/grafana/oidc-users-job.yaml @@ -476,3 +501,26 @@ tests: asserts: - failedTemplate: errorMessage: "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." + + - it: mode=CustomConfig secretRef + non-empty users fails the render (contract incompat) + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.secretRef.name: my-oauth-secret + oidc.users: + - email: alice@example.com + role: Admin + asserts: + - failedTemplate: + errorMessage: "spec.oidc: `users` is not honoured under `customConfig.secretRef` — the operator's mounted auth.ini is authoritative and the chart cannot inject `skip_org_role_sync=true` / `oauth_allow_insecure_email_lookup=true`, so the users-Job's role assignments would be overwritten on the operator's next login. Either switch to `customConfig.config` (inline map, merged with the chart-forced settings) or unset `users` and manage authorization inside the ini fragment yourself." + + - it: mode=CustomConfig secretRef + empty users renders successfully + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.secretRef.name: my-oauth-secret + asserts: + # spec.config still carries no auth.generic_oauth in this branch, + # matching the earlier secretRef test — this is the compatible path. + - notExists: + path: spec.config["auth.generic_oauth"] From 86e54f1296aff742992cc5300aaaa81077d0157a Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 15:54:32 +0300 Subject: [PATCH 27/39] docs(monitoring): align OIDC docs with the actual mode-agnostic contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/oidc-grafana.md | 26 +++++++++++++++---- packages/extra/monitoring/README.md | 21 +++++++-------- packages/extra/monitoring/values.schema.json | 14 ++-------- packages/extra/monitoring/values.yaml | 3 +-- .../monitoring-rd/cozyrds/monitoring.yaml | 2 +- 5 files changed, 35 insertions(+), 31 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index b2895f0e9..615483461 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -129,11 +129,27 @@ spec: name: acme-byo-grafana-auth ``` -Do not set `role_attribute_path` in the payload: the chart forces -`skip_org_role_sync = true` regardless of mode so a login never -overwrites the users-Job assignments, and a `role_attribute_path` -would just be dead config. Roles are reconciled by the same -`spec.oidc.users` map as in `System` mode — see below. +The chart merges two settings on top of the operator's inline map so +the users-Job contract holds in `CustomConfig` mode too: + +- `skip_org_role_sync = true` — a login never overwrites the Job's + org-role assignments; +- `oauth_allow_insecure_email_lookup = true` — the OIDC identity + binds to the pre-provisioned local account by email. + +Both keys are chart-forced (`merge` semantics, chart wins) — setting +them in the operator's map is a no-op, and `role_attribute_path` would +just be dead config since the Job manages roles. + +**`secretRef.name` mode disables the users-map.** The chart mounts the +operator's `auth.ini` fragment verbatim via `GF_PATHS_CUSTOM_INI` and +cannot inject either of the two chart-forced settings. Setting +`spec.oidc.users` alongside `customConfig.secretRef.name` fails the +render with an explicit message — the alternatives are: (a) use +`customConfig.config` (inline) and let the chart merge, or (b) leave +`spec.oidc.users` empty and include both `skip_org_role_sync = true` +and `oauth_allow_insecure_email_lookup = true` in your ini fragment +plus manage the OIDC → Grafana user mapping yourself. Setting both `config` and `secretRef.name` (or neither) fails the render. In `CustomConfig` mode no Keycloak objects are provisioned in diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index c117c7f7b..50a72d764 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -104,15 +104,14 @@ ### OIDC authentication -| Name | Description | Type | Value | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------ | -| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | -| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | -| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | -| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | -| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | -| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | -| `oidc.users` | Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping). | `[]object` | `[]` | -| `oidc.users[i].email` | Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). | `string` | `""` | -| `oidc.users[i].role` | Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. | `string` | `{}` | +| Name | Description | Type | Value | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------ | +| `oidc` | OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide. | `object` | `{}` | +| `oidc.mode` | Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | +| `oidc.customConfig` | Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`. | `object` | `{}` | +| `oidc.customConfig.config` | Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`. | `object` | `{}` | +| `oidc.customConfig.secretRef` | Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`. | `object` | `{}` | +| `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | +| `oidc.users` | Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping). | `[]object` | `[]` | +| `oidc.users[i].email` | Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input. | `string` | `""` | diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 7448a8aee..81165c797 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -645,22 +645,12 @@ "items": { "type": "object", "required": [ - "email", - "role" + "email" ], "properties": { "email": { - "description": "Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together).", + "description": "Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.", "type": "string" - }, - "role": { - "description": "Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1.", - "type": "string", - "enum": [ - "Admin", - "Editor", - "Viewer" - ] } } } diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index f9a053c54..afc87e627 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -208,8 +208,7 @@ vmagent: ## @value Viewer ## @typedef {struct} OIDCUser - Grafana user reconciled into the release's org. -## @field {string} email - Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). -## @field {OIDCRole} role - Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. +## @field {string} email - Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input. ## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm). ## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index 90f406ad8..a97a0fe42 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -20,7 +20,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]},"users":{"description":"Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).","type":"array","default":[],"items":{"type":"object","required":["email","role"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together).","type":"string"},"role":{"description":"Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1.","type":"string","enum":["Admin","Editor","Viewer"]}}}}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]},"users":{"description":"Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).","type":"array","default":[],"items":{"type":"object","required":["email"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.","type":"string"}}}}}}}} release: prefix: "" labels: From c31a9f8ec335954b45991a518248ebf8a414883b Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 16:08:06 +0300 Subject: [PATCH 28/39] fix(monitoring): promote oidc.users[].role to required + regenerate schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/extra/monitoring/README.md | 1 + packages/extra/monitoring/values.schema.json | 12 +++++- packages/extra/monitoring/values.yaml | 1 + .../monitoring-rd/cozyrds/monitoring.yaml | 2 +- .../system/monitoring/templates/_helpers.tpl | 39 +++++++++++++++++++ .../monitoring/templates/grafana/grafana.yaml | 7 +++- .../templates/grafana/oidc-users-job.yaml | 6 ++- .../system/monitoring/tests/oidc_test.yaml | 36 ++++++++++++++++- 8 files changed, 99 insertions(+), 5 deletions(-) diff --git a/packages/extra/monitoring/README.md b/packages/extra/monitoring/README.md index 50a72d764..446774295 100644 --- a/packages/extra/monitoring/README.md +++ b/packages/extra/monitoring/README.md @@ -114,4 +114,5 @@ | `oidc.customConfig.secretRef.name` | Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`. | `string` | `""` | | `oidc.users` | Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping). | `[]object` | `[]` | | `oidc.users[i].email` | Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input. | `string` | `""` | +| `oidc.users[i].role` | Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: "null"` into the body and fail with HTTP 400. | `string` | `{}` | diff --git a/packages/extra/monitoring/values.schema.json b/packages/extra/monitoring/values.schema.json index 81165c797..94af2e467 100644 --- a/packages/extra/monitoring/values.schema.json +++ b/packages/extra/monitoring/values.schema.json @@ -645,12 +645,22 @@ "items": { "type": "object", "required": [ - "email" + "email", + "role" ], "properties": { "email": { "description": "Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.", "type": "string" + }, + "role": { + "description": "Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: \"null\"` into the body and fail with HTTP 400.", + "type": "string", + "enum": [ + "Admin", + "Editor", + "Viewer" + ] } } } diff --git a/packages/extra/monitoring/values.yaml b/packages/extra/monitoring/values.yaml index afc87e627..3edae2af2 100644 --- a/packages/extra/monitoring/values.yaml +++ b/packages/extra/monitoring/values.yaml @@ -209,6 +209,7 @@ vmagent: ## @typedef {struct} OIDCUser - Grafana user reconciled into the release's org. ## @field {string} email - Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input. +## @field {OIDCRole} role - Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: "null"` into the body and fail with HTTP 400. ## @typedef {struct} OIDC - OIDC selector for the Grafana instance (Phase 1 — per-instance audience binding; no per-tenant Keycloak realm). ## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. diff --git a/packages/system/monitoring-rd/cozyrds/monitoring.yaml b/packages/system/monitoring-rd/cozyrds/monitoring.yaml index a97a0fe42..1908bc80f 100644 --- a/packages/system/monitoring-rd/cozyrds/monitoring.yaml +++ b/packages/system/monitoring-rd/cozyrds/monitoring.yaml @@ -20,7 +20,7 @@ spec: singular: monitoring plural: monitorings openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]},"users":{"description":"Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).","type":"array","default":[],"items":{"type":"object","required":["email"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.","type":"string"}}}}}}}} + {"title":"Chart Values","type":"object","properties":{"host":{"description":"The hostname used to access Grafana externally (defaults to 'grafana' subdomain for the tenant host).","type":"string","default":""},"metricsStorages":{"description":"Configuration of metrics storage instances.","type":"array","default":[{"deduplicationInterval":"15s","name":"shortterm","retentionPeriod":"3d","storage":"10Gi","storageClassName":""},{"deduplicationInterval":"5m","name":"longterm","retentionPeriod":"14d","storage":"10Gi","storageClassName":""}],"items":{"type":"object","required":["deduplicationInterval","name","retentionPeriod","storage"],"properties":{"deduplicationInterval":{"description":"Deduplication interval for metrics.","type":"string"},"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for metrics.","type":"string"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the data.","type":"string"},"vminsert":{"description":"Configuration for vminsert.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmselect":{"description":"Configuration for vmselect.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"vmstorage":{"description":"Configuration for vmstorage.","type":"object","properties":{"maxAllowed":{"description":"Maximum allowed resources.","type":"object","properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"minAllowed":{"description":"Minimum guaranteed resources.","type":"object","properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}}},"logsStorages":{"description":"Configuration of logs storage instances.","type":"array","default":[{"name":"generic","retentionPeriod":"1","storage":"10Gi","storageClassName":"replicated"}],"items":{"type":"object","required":["name","retentionPeriod","storage","storageClassName"],"properties":{"name":{"description":"Name of the storage instance.","type":"string"},"retentionPeriod":{"description":"Retention period for logs.","type":"string","default":"1"},"storage":{"description":"Persistent volume size.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"}}}},"alerta":{"description":"Configuration for the Alerta service.","type":"object","default":{},"properties":{"alerts":{"description":"Alert routing configuration.","type":"object","default":{},"properties":{"slack":{"description":"Configuration for Slack alerts.","type":"object","default":{},"required":["url"],"properties":{"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"url":{"description":"Configuration uri for Slack alerts.","type":"string","default":""}}},"telegram":{"description":"Configuration for Telegram alerts.","type":"object","default":{},"required":["chatID","token"],"properties":{"chatID":{"description":"Telegram chat ID(s), separated by commas.","type":"string","default":""},"disabledSeverity":{"description":"List of severities without alerts (e.g. [\"informational\",\"warning\"]).","type":"array","default":[],"items":{"type":"string"}},"token":{"description":"Telegram bot token.","type":"string","default":""}}}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}},"storage":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"},"storageClassName":{"description":"StorageClass used for the database.","type":"string","default":""}}},"grafana":{"description":"Configuration for Grafana.","type":"object","default":{},"properties":{"db":{"description":"Database configuration.","type":"object","default":{},"properties":{"size":{"description":"Persistent volume size for the database.","type":"string","default":"10Gi"}}},"resources":{"description":"Resource configuration.","type":"object","default":{},"properties":{"limits":{"description":"Resource limits.","type":"object","default":{},"properties":{"cpu":{"description":"CPU limit.","default":1,"pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory limit.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"requests":{"description":"Resource requests.","type":"object","default":{},"properties":{"cpu":{"description":"CPU request.","default":"100m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory request.","default":"256Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}}}},"vmagent":{"description":"Configuration for VictoriaMetrics Agent.","type":"object","default":{},"properties":{"externalLabels":{"description":"External labels applied to all metrics.","type":"object","default":{"cluster":"cozystack"}},"remoteWrite":{"description":"Remote write configuration.","type":"object","default":{"urls":["http://vminsert-shortterm:8480/insert/0/prometheus","http://vminsert-longterm:8480/insert/0/prometheus"]}}}},"oidc":{"description":"OIDC authentication for the Grafana instance. See docs/oidc-grafana.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied `[auth.generic_oauth]` payload; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline map of `[auth.generic_oauth]` keys. Written verbatim into the Grafana CR `spec.config`. Mutually exclusive with `secretRef.name`.","type":"object","default":{}},"secretRef":{"description":"Reference to a Secret holding an `auth.ini` fragment; mounted under `/etc/grafana/oidc` and loaded via `GF_PATHS_CUSTOM_INI`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the release namespace whose `auth.ini` key holds a ready-made `[auth.generic_oauth]` block. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the `grafana-admin-password` Secret works. `System`: trust the platform `cozy` realm via a per-instance confidential client and audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.","type":"string","default":"None","enum":["None","System","CustomConfig"]},"users":{"description":"Users granted access to this Grafana instance's org. Each entry is reconciled into Grafana org membership + role by a chart-owned post-install/post-upgrade Job. Works for both `System` and `CustomConfig` modes; ignored when `mode: None`. Users not listed here get nothing (no default org role from group mapping).","type":"array","default":[],"items":{"type":"object","required":["email","role"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. The chart pre-provisions a Grafana account with this email so the users-Job can set the org role before the operator's first login (`oauth_allow_insecure_email_lookup = true` glues the two together). Must match a conservative pattern: local part is `[A-Za-z0-9._%+-]+`, domain is `[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`. Whitespace, quoted literals, and unusual URL-reserved characters are rejected on schema level so the reconcile-Job's shell handling never has to defend against malformed input.","type":"string"},"role":{"description":"Grafana org role to bind: `Admin`, `Editor`, or `Viewer`. Applied to the release's Grafana org (`Main Org.`); server-level `GrafanaAdmin` promotion is out of scope for Phase 1. Required — the users-Job POSTs `{loginOrEmail, role}` to `/api/orgs/1/users` and PATCHes `{role}` to converge across re-runs; omitting the field would render `role: \"null\"` into the body and fail with HTTP 400.","type":"string","enum":["Admin","Editor","Viewer"]}}}}}}}} release: prefix: "" labels: diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index 150920d43..df40917af 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -111,6 +111,45 @@ {{- toYaml $users -}} {{- end -}} +{{- /* + Prologue that all templates touching spec.oidc.users should include + ONCE, near the top. Runs the incompatibility check (secretRef + users) + before the per-entry regexes so operators see the most actionable + error first — combining incompatible top-level fields is a + configuration bug the operator can fix immediately; a per-user + malformed email is a data-entry issue in the CR body. +*/}} +{{- define "monitoring.oidc.assertUsersPrologue" -}} +{{- include "monitoring.oidc.assertCustomSecretRefUsersEmpty" . }} +{{- include "monitoring.oidc.assertUsersRoleShape" . }} +{{- include "monitoring.oidc.assertUsersEmailShape" . }} +{{- end -}} + +{{- /* + Reject `spec.oidc.users[]` entries missing `role` or carrying an + unknown role. openAPISchema also marks `role` required, but a stale + schema (dashboard-emitted CR generated before the field was added, or + a direct-helm invocation that bypasses the API validation) would + otherwise render `role: "null"` into the users-Job's Grafana admin API + body and fail with HTTP 400 at hook execution. Failing at render + time surfaces the root cause instead of a mysterious backoffLimit + exhaustion. +*/}} +{{- define "monitoring.oidc.assertUsersRoleShape" -}} +{{- $oidc := .Values.oidc | default dict -}} +{{- $users := $oidc.users | default (list) -}} +{{- $allowed := list "Admin" "Editor" "Viewer" -}} +{{- range $i, $u := $users -}} +{{- $role := $u.role | default "" -}} +{{- if eq $role "" -}} +{{- fail (printf "spec.oidc.users[%d].role: is required and must be one of Admin, Editor, Viewer. Omitting the field would render null into the Grafana admin API body and fail the post-install users-Job with HTTP 400." $i) -}} +{{- end -}} +{{- if not (has $role $allowed) -}} +{{- fail (printf "spec.oidc.users[%d].role: %q is not one of the allowed values Admin, Editor, Viewer." $i $role) -}} +{{- end -}} +{{- end -}} +{{- end -}} + {{- /* Reject malformed emails in `spec.oidc.users`. Grafana passes the string verbatim into a query string (/api/users/lookup?loginOrEmail=) diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 439a29d30..6dd83fdc3 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -29,9 +29,14 @@ */}} {{- $oidc := .Values.oidc | default dict }} {{- $oidcMode := $oidc.mode | default "None" }} +{{- /* + Users-map prologue is included from oidc-users-job.yaml (rendered in + every mode), so the secretRef+users incompat is caught even when + oidc-users-job.yaml is disabled downstream. Only the CustomConfig-XOR + check is grafana.yaml-scoped. +*/}} {{- if eq $oidcMode "CustomConfig" }} {{- include "monitoring.oidc.assertCustomConfigXor" . }} -{{- include "monitoring.oidc.assertCustomSecretRefUsersEmpty" . }} {{- end }} {{- /* Symmetric assertions with templates/grafana/oidc-keycloak.yaml so a diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml index 5d5d9e9c7..9dcbd47ab 100644 --- a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -41,7 +41,11 @@ {{- $oidc := .Values.oidc | default dict }} {{- $mode := $oidc.mode | default "None" }} {{- $users := $oidc.users | default (list) }} -{{- include "monitoring.oidc.assertUsersEmailShape" . }} +{{- /* + Prologue: incompat (secretRef+users) first, then per-entry role + + email validation. See monitoring.oidc.assertUsersPrologue. +*/}} +{{- include "monitoring.oidc.assertUsersPrologue" . }} --- apiVersion: batch/v1 kind: Job diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 01e1bb76c..c2830f5df 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -503,7 +503,9 @@ tests: errorMessage: "spec.oidc.mode: CustomConfig requires either spec.oidc.customConfig.config (inline generic_oauth map) or spec.oidc.customConfig.secretRef.name (Secret with an `auth.ini` key)." - it: mode=CustomConfig secretRef + non-empty users fails the render (contract incompat) - template: templates/grafana/grafana.yaml + # Prologue fires from the users-Job template — that's where all + # spec.oidc.users assertions live, so the fail surfaces there. + template: templates/grafana/oidc-users-job.yaml set: oidc.mode: CustomConfig oidc.customConfig.secretRef.name: my-oauth-secret @@ -524,3 +526,35 @@ tests: # matching the earlier secretRef test — this is the compatible path. - notExists: path: spec.config["auth.generic_oauth"] + + ########################################################## + # spec.oidc.users[].role — schema contract regression guard + ########################################################## + + # Even though the openAPISchema now marks `role` as required, downstream + # clients (Flux HelmRelease valuesFrom, direct helm CLI, dashboard- + # emitted CRs during a stale schema window) may still pass a + # role-less entry. The template must reject that at render time so the + # users-Job never fires with `role: "null"` on Grafana's admin API. + - it: oidc.users entry missing role fails the render (contract regression guard) + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + oidc.users: + - email: someone@example.com + asserts: + - failedTemplate: + errorMessage: "spec.oidc.users[0].role: is required and must be one of Admin, Editor, Viewer. Omitting the field would render null into the Grafana admin API body and fail the post-install users-Job with HTTP 400." + + - it: oidc.users entry with unknown role fails the render + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + oidc.users: + - email: someone@example.com + role: Superuser + asserts: + - failedTemplate: + errorMessage: "spec.oidc.users[0].role: \"Superuser\" is not one of the allowed values Admin, Editor, Viewer." From 02305eae59d1ffb328a2950a99a285464c235fc8 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 16:22:47 +0300 Subject: [PATCH 29/39] docs(oidc-grafana): correct post-mortem window claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 1f664566 but slipped out of that commit's file list; this is the cleanup. Signed-off-by: IvanHunters --- docs/oidc-grafana.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index 615483461..ccd7acb60 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -260,9 +260,14 @@ work — useful when Keycloak is down or misconfigured. and `backoffLimit: 6`. Common causes: Grafana never becomes ready (check `kubectl -n get pods -l app=grafana`), or the `grafana-admin-password` Secret is missing its `user`/`password` - keys. The failed hook Job stays around until the next - helm-upgrade for post-mortem; check its logs with - `kubectl -n logs job/-oidc-users`. + keys. `ttlSecondsAfterFinished: 3600` keeps the failed hook Job + (and its Pod's logs) around for **one hour** after the terminal + Failed condition — check its logs with + `kubectl -n logs job/-oidc-users` inside that window. + After 1h the Kubernetes TTL controller garbage-collects the Job + and the Pod together, so grab the logs quickly; if you missed + the window, re-trigger the hook with `helm upgrade` on the + release and reproduce. - **`emailVerified` on Keycloak users is a prescriptive requirement, not a chart-enforced one.** The chart does not emit any `claimValidationRules` — the layered guarantees you rely on From 7f9e1a8bf6353aea981dba054ad6f51c337ca1a4 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 18:56:17 +0300 Subject: [PATCH 30/39] test(e2e): poll the reconciled Grafana CR field instead of racing on CR existence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=)" = ]` 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 --- .../monitoring-oidc-customconfig.bats | 19 +++++++++++---- hack/e2e-apps/monitoring-oidc-system.bats | 24 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/hack/e2e-apps/monitoring-oidc-customconfig.bats b/hack/e2e-apps/monitoring-oidc-customconfig.bats index f2257a106..20e290fc2 100755 --- a/hack/e2e-apps/monitoring-oidc-customconfig.bats +++ b/hack/e2e-apps/monitoring-oidc-customconfig.bats @@ -79,7 +79,15 @@ EOF } @test "Grafana CR carries the operator's auth.generic_oauth payload verbatim" { - timeout 60 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 2; done' + # Poll the specific field, not just CR existence — otherwise we may + # grab a stale Grafana CR that hasn't yet been reconciled with the + # inner HR's CustomConfig values. + timeout 180 sh -ec ' + until [ "$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath="{.spec.config.auth\.generic_oauth.client_id}" 2>/dev/null)" = "byo-monitoring" ]; do + sleep 5 + done + ' client_id=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.config.auth\.generic_oauth.client_id}') @@ -164,11 +172,14 @@ EOF # The Grafana CR from @test 1 already exists and carries the inline # auth.generic_oauth block; wait for the chart to reconcile the swap # to secretRef by polling for the target state (volume mount present - # AND inline block absent) instead of asserting immediately. - timeout 180 sh -ec ' + # AND inline block absent) instead of asserting immediately. 600s + # window is sized for a full inner-HR upgrade cycle + grafana- + # operator reconcile — 180s is too tight when the sandbox is warm- + # loaded, cf. the intermittent CI failure. + timeout 600 sh -ec ' until [ -n "$(kubectl -n tenant-test get grafana grafana -o jsonpath="{.spec.deployment.spec.template.spec.containers[?(@.name==\"grafana\")].volumeMounts[?(@.name==\"oidc-custom-ini\")].mountPath}")" ]; do sleep 5; done ' - timeout 60 sh -ec ' + timeout 120 sh -ec ' until [ -z "$(kubectl -n tenant-test get grafana grafana -o jsonpath="{.spec.config.auth\.generic_oauth}")" ]; do sleep 5; done ' diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats index 582ba3782..31926664e 100755 --- a/hack/e2e-apps/monitoring-oidc-system.bats +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -84,10 +84,18 @@ EOF } @test "Grafana CR carries auth.generic_oauth pointing at the cozy realm" { - timeout 60 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 2; done' + # Wait for the Grafana CR to actually reflect mode=System values — + # `until kubectl get grafana` only proves the CR EXISTS (it may still + # be a stale copy from a previous test or from before the inner HR + # picked up the new values). Poll the specific field the test is + # about to assert on so the race window closes. + timeout 180 sh -ec ' + until [ "$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath="{.spec.config.auth\.generic_oauth.enabled}" 2>/dev/null)" = "true" ]; do + sleep 5 + done + ' - # auth.generic_oauth is a top-level key under spec.config; the key - # name literally contains a dot, so jsonpath needs a bracketed lookup. enabled=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.config.auth\.generic_oauth.enabled}') echo "generic_oauth.enabled: ${enabled}" @@ -152,7 +160,15 @@ EOF } @test "Grafana config has no role_attribute_path and enables skip_org_role_sync" { - timeout 60 sh -ec 'until kubectl -n tenant-test get grafana grafana >/dev/null 2>&1; do sleep 2; done' + # Same wait as the first Grafana-CR test — poll the specific field + # rather than merely the CR's existence so we don't hit a stale copy. + timeout 180 sh -ec ' + until [ "$(kubectl -n tenant-test get grafana grafana \ + -o jsonpath="{.spec.config.auth\.generic_oauth.skip_org_role_sync}" 2>/dev/null)" = "true" ]; do + sleep 5 + done + ' + role_attr=$(kubectl -n tenant-test get grafana grafana \ -o jsonpath='{.spec.config.auth\.generic_oauth.role_attribute_path}') echo "role_attribute_path: ${role_attr}" From 1422495b8823e99e0281012b62cb7631a699e16d Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Mon, 6 Jul 2026 23:07:05 +0300 Subject: [PATCH 31/39] fix(monitoring): do not render users-Job when mode=None (upgrade safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/oidc-grafana.md | 15 +++++-- .../templates/grafana/oidc-users-job.yaml | 23 +++++++---- .../system/monitoring/tests/oidc_test.yaml | 40 ++++++++++--------- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index ccd7acb60..e2e60b17e 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -189,9 +189,18 @@ API: demotions, etc.) via `PATCH /api/orgs/{orgId}/users/{userId}`. 3. Every Main-Org member whose email is neither in the list nor the break-glass `grafana-admin-password` login is removed. Removing - an entry from `users:` and re-reconciling revokes access; - flipping `mode: None` treats the list as empty and prunes every - OIDC-provisioned account. + an entry from `users:` and re-reconciling revokes access. + +The Job is only rendered when `mode: System` or `mode: CustomConfig` +— **in `mode: None` the chart deliberately owns nothing in Grafana +orgs**. This matters on upgrade: existing tenants who ran the +pre-PR Monitoring chart may have added users to Grafana manually +through the UI; if the Job ran in `mode: None` its prune pass would +silently delete those accounts on the next Flux reconcile. Operators +who want to clean up OIDC-provisioned accounts after switching +`System | CustomConfig → None` do it themselves through the Grafana +UI or admin API. The `activeDeadlineSeconds` and `ttlSecondsAfterFinished` +notes below only apply when the Job actually renders. Users not listed in `spec.oidc.users` who log in through OIDC get nothing — no default `Viewer` role, no cross-tenant read access. diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml index 9dcbd47ab..1f4945d4e 100644 --- a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -7,7 +7,7 @@ because grafana-operator has no user CRD. Contract: - - For each `spec.oidc.users[]` entry with mode != None: + - For each `spec.oidc.users[]` entry: * pre-provision a local Grafana account with the given email (random password — the account is a shell so the users-Job can set an org role before the operator's first OIDC login, @@ -17,14 +17,19 @@ (`Admin` / `Editor` / `Viewer`), promoting/demoting on re-run. - Prune every non-admin Main-Org member whose email is not in the - desired list. Handles both: - * a `users:` entry being removed (should lose access); - * `mode: None` (list is treated as empty — all OIDC-provisioned - accounts are pruned). + desired list — handles removal of a `users:` entry. - `skip_org_role_sync = true` in the Grafana generic_oauth block (see grafana.yaml) makes sure a later login never overwrites the role this Job sets. + NOT rendered when `mode: None`. The chart deliberately treats + `mode: None` as "OIDC is off, chart owns nothing in Grafana orgs"; + a prune pass in `None` would silently delete every non-admin + Main-Org member on any monitoring upgrade — clobbering users added + manually through the Grafana UI on an installation that predates + this PR. Operators who need to clean up OIDC-provisioned accounts + after switching System/CustomConfig → None do it themselves. + Lifecycle: - The Job itself is a Helm hook (post-install,post-upgrade) with `helm.sh/hook-delete-policy: before-hook-creation` so `helm @@ -35,17 +40,18 @@ Secret via `envFrom`. - `activeDeadlineSeconds` caps a stuck Job so a broken Grafana does not hold the release in `pending-upgrade`. - - Rendered for all modes so `mode: None` still gets the prune pass. */}} {{- $oidc := .Values.oidc | default dict }} {{- $mode := $oidc.mode | default "None" }} {{- $users := $oidc.users | default (list) }} {{- /* Prologue: incompat (secretRef+users) first, then per-entry role + - email validation. See monitoring.oidc.assertUsersPrologue. + email validation. See monitoring.oidc.assertUsersPrologue. Runs in + every mode so a malformed users[] entry fails render even when the + Job itself is not emitted (mode=None). */}} {{- include "monitoring.oidc.assertUsersPrologue" . }} +{{- if ne $mode "None" }} --- apiVersion: batch/v1 kind: Job @@ -279,3 +285,4 @@ spec: volumes: - name: tmp emptyDir: {} +{{- end }} diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index c2830f5df..7582bfe8c 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -88,27 +88,31 @@ tests: content: name: GF_PATHS_CUSTOM_INI - - it: mode=None still renders the users-Job so a mode switch can prune stale members + - it: mode=None does NOT render the users-Job (upgrade safety guard) + # Regression guard against the dev3 test finding: if the Job were + # rendered in mode=None, the post-upgrade prune pass would silently + # delete every non-admin Main-Org member on any monitoring HR + # reconcile — clobbering users added manually through the Grafana + # UI on installations that predate this PR (where `spec.oidc` did + # not exist and Grafana orgs were operator-curated by hand). template: templates/grafana/oidc-users-job.yaml asserts: - hasDocuments: - count: 1 - - equal: - path: kind - value: Job - - equal: - path: metadata.annotations["helm.sh/hook"] - value: post-install,post-upgrade - - contains: - path: spec.template.spec.containers[0].env - content: - name: OIDC_MODE - value: "None" - - contains: - path: spec.template.spec.containers[0].env - content: - name: DESIRED_USERS_JSON - value: "[]" + count: 0 + + - it: mode=None with non-empty users still fails render (users have no effect in None) + template: templates/grafana/oidc-users-job.yaml + set: + oidc.users: + - email: alice@example.com + role: Admin + asserts: + # The Job is not rendered, but the prologue still validates users[] + # entries — an operator who lists users in mode=None almost + # certainly meant to also flip mode; the prologue-level shape + # checks (email/role) still fire so a typo doesn't slip through. + - hasDocuments: + count: 0 ########################################################## # mode: System — happy path From b2fd32385e5aeae1e0f7d99bb7542a376723b3c2 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Tue, 7 Jul 2026 05:40:56 +0300 Subject: [PATCH 32/39] fix(monitoring): rewrite users-Job shell in POSIX (alpine BusyBox compat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../templates/grafana/oidc-users-job.yaml | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml index 1f4945d4e..41baff302 100644 --- a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -169,11 +169,13 @@ spec: # additional orgs. ORG_ID=1 - # Emit desired emails as NUL-terminated so `while read -d ''` - # tolerates any RFC 5322-legal local part (whitespace, quoted - # literals, symbols). `for email in $var` was word-split on - # default IFS which broke on any non-trivial email. - printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output0 '.[].email' > /tmp/desired-emails 2>/dev/null || : > /tmp/desired-emails + # Emit desired emails one per line. Portable — BusyBox + # coreutils in the alpine base do not support jq + # --raw-output0 (jq 1.7+) nor `read -d ''`. The template- + # level assertUsersEmailShape helper already rejects any + # email containing whitespace / control characters, so + # line-oriented iteration is safe here. + printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output '.[].email' > /tmp/desired-emails 2>/dev/null || : > /tmp/desired-emails # URL-encode a string via jq (@uri) — used for query-string # values like /api/users/lookup?loginOrEmail=. @@ -181,7 +183,7 @@ spec: if [ "$OIDC_MODE" != "None" ]; then log "Reconciling ${OIDC_MODE} users into Grafana org $ORG_ID..." - while IFS= read -r -d '' email; do + while IFS= read -r email; do [ -n "$email" ] || continue role=$(printf '%s' "$DESIRED_USERS_JSON" | jq --raw-output --arg e "$email" '.[] | select(.email == $e) | .role') log " ensure user: email=$email role=$role" @@ -189,7 +191,8 @@ spec: # 1. Pre-provision the local account so the operator # can be assigned a role before their first login. # 409 is expected on re-run — already provisioned. - password=$(head --bytes=24 /dev/urandom | base64 | tr --delete '=+/' | head --bytes=32) + # BusyBox `head` / `tr` support only short flags. + password=$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | head -c 32) create_body=$(jq --null-input --compact-output \ --arg name "$email" \ --arg email "$email" \ @@ -249,25 +252,26 @@ spec: # The chart-managed admin login name — never pruned. admin_login="$GF_ADMIN_USER" - # Membership stream is emitted NUL-separated so an email - # containing whitespace / pipe characters cannot corrupt the - # field boundaries. - jq --raw-output0 '.[] | [.userId, .login, .email] | @tsv' < /tmp/members.json > /tmp/members.tsv0 + # Membership stream: one TAB-separated record per line — + # portable across BusyBox coreutils (no jq --raw-output0, + # no `read -d ''`, no `grep -z`). Safe because the + # template-level email-shape guard already rejects + # whitespace / TAB / newline in `spec.oidc.users[].email`. + jq --raw-output '.[] | [.userId, .login, .email] | @tsv' < /tmp/members.json > /tmp/members.tsv - while IFS= read -r -d '' triple; do - # Each triple is TAB-separated: userId \t login \t email - uid=$(printf '%s' "$triple" | cut -f 1) - ulogin=$(printf '%s' "$triple" | cut -f 2) - uemail=$(printf '%s' "$triple" | cut -f 3) + # IFS holds a literal TAB char (POSIX portable — some + # ash variants don't handle $'\t' escape). + IFS="$(printf '\t')" + while read -r uid ulogin uemail; do if [ -z "$uid" ] || [ "$uid" = "null" ]; then continue; fi if [ "$ulogin" = "$admin_login" ]; then log " keep admin: login=$ulogin uid=$uid" continue fi - # Match uemail / ulogin against desired list via NUL-safe - # grep. `-z` treats input+patterns as NUL-terminated; `-F` - # disables regex; `-x` anchors on the full record. - if grep --null-data --fixed-strings --line-regexp --quiet -e "$uemail" -e "$ulogin" /tmp/desired-emails; then + # Match uemail / ulogin against desired list. -F fixed + # strings, -x line-anchored, -q quiet. Portable across + # BusyBox and GNU grep. + if grep -Fxq -e "$uemail" -e "$ulogin" /tmp/desired-emails; then continue fi log " removing stale member: login=$ulogin email=$uemail uid=$uid" @@ -276,7 +280,7 @@ spec: 200) : ;; *) log " DELETE failed with code $code:"; cat /tmp/resp >&2; exit 1 ;; esac - done < /tmp/members.tsv0 + done < /tmp/members.tsv log "Done." volumeMounts: From 3776c447b98340b97324be682c52cb4ff550892a Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Tue, 7 Jul 2026 08:27:24 +0300 Subject: [PATCH 33/39] test(e2e): poll users-Job DESIRED_USERS_JSON for target email, not just Job existence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI E2E showed the users-Job assertion returning `DESIRED_USERS_JSON: []` — but the CR body had `users: [{email: e2e-admin@example.com, 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 `e2e-admin@example.com`. 600s window matches the inner HR's 15m timeout with room for a slow reconcile. Signed-off-by: IvanHunters --- hack/e2e-apps/monitoring-oidc-system.bats | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/hack/e2e-apps/monitoring-oidc-system.bats b/hack/e2e-apps/monitoring-oidc-system.bats index 31926664e..3ce9521ee 100755 --- a/hack/e2e-apps/monitoring-oidc-system.bats +++ b/hack/e2e-apps/monitoring-oidc-system.bats @@ -187,9 +187,20 @@ EOF @test "users-Job runs and carries the desired list from spec.oidc.users" { # helm.sh/hook: post-install,post-upgrade — the Job appears after the - # inner HR reconciles. `hook-delete-policy: before-hook-creation` keeps - # it around after completion so we can assert on its shape. - timeout 180 sh -ec 'until kubectl -n tenant-test get job "'"${INNER_REL}"'-oidc-users" >/dev/null 2>&1; do sleep 5; done' + # inner HR reconciles. `hook-delete-policy: before-hook-creation` + # keeps the previous Job around across upgrades, so `until kubectl + # get job` may return a stale Job from the previous test (or from + # the customconfig round where users was set differently or absent). + # Poll on the target env value directly — the new Job with our + # desired list is the first Job whose DESIRED_USERS_JSON contains + # `e2e-admin@example.com`. + timeout 600 sh -ec ' + until kubectl -n tenant-test get job "'"${INNER_REL}"'-oidc-users" \ + -o jsonpath="{.spec.template.spec.containers[?(@.name==\"reconcile\")].env[?(@.name==\"DESIRED_USERS_JSON\")].value}" 2>/dev/null \ + | grep -q "e2e-admin@example.com"; do + sleep 5 + done + ' desired=$(kubectl -n tenant-test get job "${INNER_REL}-oidc-users" \ -o jsonpath='{.spec.template.spec.containers[?(@.name=="reconcile")].env[?(@.name=="DESIRED_USERS_JSON")].value}') From ff4ef2c189e26f53e134935c4d2e46b77fa40619 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Tue, 7 Jul 2026 23:54:53 +0300 Subject: [PATCH 34/39] fix(monitoring): force allow_sign_up=false and gate isolation dict on 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 --- .../monitoring/templates/grafana/grafana.yaml | 50 ++++++--- .../system/monitoring/tests/oidc_test.yaml | 104 ++++++++++++++++-- 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 6dd83fdc3..d52b62f58 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -54,6 +54,34 @@ {{- $customSecretRefName := dig "secretRef" "name" "" $customConfig }} {{- $useCustomInline := and (eq $oidcMode "CustomConfig") (gt (len $customInline) 0) }} {{- $useCustomSecretRef := and (eq $oidcMode "CustomConfig") (ne $customSecretRefName "") }} +{{- $users := $oidc.users | default (list) }} +{{- /* + The users-map is the switch that turns the chart's authorization + contract on. When it's non-empty: + * skip_org_role_sync=true keeps a login from overwriting the + users-Job's org-role assignments; + * oauth_allow_insecure_email_lookup=true binds the OIDC identity + to the pre-provisioned local account by email; + * allow_sign_up=false rejects any OIDC identity whose email is + NOT in `users:` — otherwise Grafana's default allow_sign_up=true + combined with skip_org_role_sync=true would silently mint a + Viewer account for every cozy-realm user until the next + helm-upgrade prune pass. + When users[] is empty the chart hands the auth.generic_oauth block + to the operator (System mode → chart-supplied issuer defaults; + CustomConfig-inline → operator's map applied verbatim; secretRef → + operator's ini fragment is authoritative). The users-Job also does + not render in that state so nothing app-side touches org membership. +*/}} +{{- $usersMapActive := gt (len $users) 0 }} +{{- $chartForcedOidc := dict }} +{{- if $usersMapActive }} +{{- $chartForcedOidc = dict + "skip_org_role_sync" "true" + "oauth_allow_insecure_email_lookup" "true" + "allow_sign_up" "false" +}} +{{- end }} --- apiVersion: grafana.integreatly.org/v1beta1 kind: Grafana @@ -82,20 +110,16 @@ spec: plugins: allow_loading_unsigned_plugins: "victoriametrics-logs-datasource" {{- /* - The chart-forced settings — `skip_org_role_sync` and - `oauth_allow_insecure_email_lookup` — are the two runtime keys the - users-Job contract depends on: - * skip_org_role_sync: "true" stops a login from overwriting the - Job's org-role assignments. - * oauth_allow_insecure_email_lookup: "true" binds the OIDC - identity to the local account the Job pre-provisions. - They are merged on top of System's generated block AND on top of - the operator's inline map in CustomConfig, so the contract - holds regardless of mode. In CustomConfig secretRef mode the - operator's ini fragment is authoritative and the chart cannot - inject anything — see _helpers.tpl assertCustomSecretRefUsersEmpty. + $chartForcedOidc is prepared above at file scope — it is a non-empty + dict when the users-map is active (skip_org_role_sync + + oauth_allow_insecure_email_lookup + allow_sign_up=false) and an + empty dict otherwise. Merged on top of System's generated block AND + on top of the operator's inline map in CustomConfig, so the users- + Job contract holds regardless of mode. In CustomConfig secretRef + mode the operator's ini fragment is authoritative and the chart + cannot inject anything — see _helpers.tpl + assertCustomSecretRefUsersEmpty. */}} - {{- $chartForcedOidc := dict "skip_org_role_sync" "true" "oauth_allow_insecure_email_lookup" "true" }} {{- if eq $oidcMode "System" }} {{- $systemOidc := dict "enabled" "true" diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 7582bfe8c..0c0ccc326 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -100,17 +100,16 @@ tests: - hasDocuments: count: 0 - - it: mode=None with non-empty users still fails render (users have no effect in None) + - it: mode=None with non-empty users still does NOT render the users-Job template: templates/grafana/oidc-users-job.yaml set: oidc.users: - email: alice@example.com role: Admin asserts: - # The Job is not rendered, but the prologue still validates users[] - # entries — an operator who lists users in mode=None almost - # certainly meant to also flip mode; the prologue-level shape - # checks (email/role) still fire so a typo doesn't slip through. + # The Job is not rendered because `mode: None` short-circuits the + # gate; the prologue-level shape checks (email/role) still fire + # so a typo in the users[] entries doesn't slip through unnoticed. - hasDocuments: count: 0 @@ -203,6 +202,9 @@ tests: set: oidc.mode: System _cluster.oidc-enabled: "true" + oidc.users: + - email: alice@example.com + role: Admin asserts: - equal: path: spec.config["auth.generic_oauth"].enabled @@ -234,12 +236,53 @@ tests: - equal: path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup value: "true" + # allow_sign_up=false is the isolation lever — without it, + # Grafana's default (allow_sign_up=true) combined with + # skip_org_role_sync=true would mint a Viewer account for every + # cozy-realm identity that hits the login flow, then wait for the + # next helm-upgrade prune pass to clean them up. + - equal: + path: spec.config["auth.generic_oauth"].allow_sign_up + value: "false" + + - it: mode=System with empty users omits the chart-forced isolation dict + # Reviewer's minor from PR#3176 round-2: when users[] is empty the + # chart has nothing to protect; forcing insecure_email_lookup / no- + # sign_up in that state is a needless account-linking surface with + # nothing to bind. The System branch still emits auth.generic_oauth + # (issuer + scope), just without the users-Job contract keys. + template: templates/grafana/grafana.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - equal: + path: spec.config["auth.generic_oauth"].enabled + value: "true" + - notExists: + path: spec.config["auth.generic_oauth"].skip_org_role_sync + - notExists: + path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup + - notExists: + path: spec.config["auth.generic_oauth"].allow_sign_up + + - it: mode=System with empty users does NOT render the users-Job + template: templates/grafana/oidc-users-job.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + asserts: + - hasDocuments: + count: 0 - it: mode=System grafana.yaml wires GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET from the Secret template: templates/grafana/grafana.yaml set: oidc.mode: System _cluster.oidc-enabled: "true" + oidc.users: + - email: alice@example.com + role: Admin asserts: - contains: path: spec.deployment.spec.template.spec.containers[0].env @@ -269,6 +312,9 @@ tests: set: oidc.mode: System _cluster.oidc-enabled: "true" + oidc.users: + - email: alice@example.com + role: Admin asserts: - equal: path: kind @@ -295,6 +341,9 @@ tests: set: oidc.mode: System _cluster.oidc-enabled: "true" + oidc.users: + - email: alice@example.com + role: Admin asserts: - contains: path: spec.template.spec.containers[0].env @@ -335,6 +384,9 @@ tests: set: oidc.mode: System _cluster.oidc-enabled: "true" + oidc.users: + - email: alice@example.com + role: Admin asserts: - equal: path: spec.template.spec.securityContext.runAsNonRoot @@ -386,7 +438,7 @@ tests: - hasDocuments: count: 0 - - it: mode=CustomConfig inline preserves the operator's map + chart-forced settings + - it: mode=CustomConfig inline preserves the operator's map + chart-forced settings (users active) template: templates/grafana/grafana.yaml set: oidc.mode: CustomConfig @@ -394,6 +446,9 @@ tests: client_id: my-client client_secret: xyz auth_url: https://idp.example/auth + oidc.users: + - email: alice@example.com + role: Admin asserts: - equal: path: spec.config["auth.generic_oauth"].client_id @@ -412,6 +467,33 @@ tests: - equal: path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup value: "true" + - equal: + path: spec.config["auth.generic_oauth"].allow_sign_up + value: "false" + + - it: mode=CustomConfig inline with empty users hands off — chart forces nothing + # Reviewer's minor from PR#3176 round-2: a BYO-IdP operator who does + # NOT use the users-map should not get insecure-email-lookup or + # allow_sign_up=false forced on top of their config — the chart has + # nothing app-side to protect and the extra keys are a needless + # account-linking surface against issuers that don't verify emails. + template: templates/grafana/grafana.yaml + set: + oidc.mode: CustomConfig + oidc.customConfig.config: + client_id: my-client + client_secret: xyz + auth_url: https://idp.example/auth + asserts: + - equal: + path: spec.config["auth.generic_oauth"].client_id + value: my-client + - notExists: + path: spec.config["auth.generic_oauth"].skip_org_role_sync + - notExists: + path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup + - notExists: + path: spec.config["auth.generic_oauth"].allow_sign_up - it: mode=CustomConfig inline — operator cannot override chart-forced OIDC settings template: templates/grafana/grafana.yaml @@ -419,9 +501,14 @@ tests: oidc.mode: CustomConfig oidc.customConfig.config: client_id: my-client - # Operator tries to disable both — chart still wins. + # Operator tries to disable all three — chart still wins because + # users-map is active. skip_org_role_sync: "false" oauth_allow_insecure_email_lookup: "false" + allow_sign_up: "true" + oidc.users: + - email: alice@example.com + role: Admin asserts: - equal: path: spec.config["auth.generic_oauth"].skip_org_role_sync @@ -429,6 +516,9 @@ tests: - equal: path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup value: "true" + - equal: + path: spec.config["auth.generic_oauth"].allow_sign_up + value: "false" - it: mode=CustomConfig users-Job runs and honours the desired list template: templates/grafana/oidc-users-job.yaml From f64ba918728aaf958c8c82b2fa15403cf065bb32 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Tue, 7 Jul 2026 23:55:03 +0300 Subject: [PATCH 35/39] fix(monitoring): do not render users-Job when spec.oidc.users is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/oidc-grafana.md | 112 +++++++++++++----- .../templates/grafana/oidc-users-job.yaml | 40 +++++-- 2 files changed, 115 insertions(+), 37 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index e2e60b17e..16f74c0cd 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -78,11 +78,25 @@ CR: fallback, same pattern as `packages/system/dashboard`), preserved across upgrades. 4. The Grafana CR's `spec.config.auth.generic_oauth` section wired to - the cozy realm issuer + the per-instance audience scope, with - `skip_org_role_sync = true` so a login never overwrites the - app-side role assignments made by the users-Job, and - `oauth_allow_insecure_email_lookup = true` so the OIDC identity - binds to the pre-provisioned local account by email. + the cozy realm issuer + the per-instance audience scope. When + `spec.oidc.users` is non-empty the chart also merges three + isolation keys on top so the users-Job's app-side role + assignments survive login: + - `skip_org_role_sync = true` — a login never overwrites the Job's + assignments; + - `oauth_allow_insecure_email_lookup = true` — the OIDC identity + binds to the pre-provisioned local account by email; + - `allow_sign_up = false` — a cozy-realm identity whose email is + NOT in `spec.oidc.users` is rejected at login instead of being + admitted as `auto_assign_org_role` (Viewer by default) and + waiting for the next helm-upgrade prune pass to clean them up. + + When `spec.oidc.users` is empty (or unset) the three keys are NOT + forced — the chart owns nothing app-side, so there is nothing to + protect. Everyone in the `cozy` realm can log in and lands at + Grafana's `auto_assign_org_role` default (`Viewer`). Operators + who want stricter behaviour without using the users-map should + set the three keys themselves through `CustomConfig` mode. 5. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana Deployment sourced from the Secret in step 3. 6. A post-install/post-upgrade **users-Job** — see the "Users and @@ -129,17 +143,32 @@ spec: name: acme-byo-grafana-auth ``` -The chart merges two settings on top of the operator's inline map so -the users-Job contract holds in `CustomConfig` mode too: +When `spec.oidc.users` is non-empty the chart merges three settings +on top of the operator's inline map so the users-Job contract holds +in `CustomConfig` mode too: - `skip_org_role_sync = true` — a login never overwrites the Job's org-role assignments; - `oauth_allow_insecure_email_lookup = true` — the OIDC identity - binds to the pre-provisioned local account by email. + binds to the pre-provisioned local account by email; +- `allow_sign_up = false` — an OIDC identity whose email is not in + the users-map is rejected at login instead of being admitted with + the default `auto_assign_org_role`. -Both keys are chart-forced (`merge` semantics, chart wins) — setting -them in the operator's map is a no-op, and `role_attribute_path` would -just be dead config since the Job manages roles. +All three keys are chart-forced (`merge` semantics, chart wins) — +setting them in the operator's map is a no-op, and +`role_attribute_path` would just be dead config since the Job +manages roles. + +When `spec.oidc.users` is empty (or unset) the chart forces none of +the three keys — a BYO-IdP operator who does not use the users-map +gets their inline `auth.generic_oauth` applied verbatim. This +matters because `oauth_allow_insecure_email_lookup` against a BYO +issuer that does not verify emails is a needless account-linking +surface, and `allow_sign_up = false` with no pre-provisioned +accounts would lock everyone out. If you want the isolation +posture without the users-map, set the keys explicitly in your +inline map. **`secretRef.name` mode disables the users-map.** The chart mounts the operator's `auth.ini` fragment verbatim via `GF_PATHS_CUSTOM_INI` and @@ -191,21 +220,41 @@ API: break-glass `grafana-admin-password` login is removed. Removing an entry from `users:` and re-reconciling revokes access. -The Job is only rendered when `mode: System` or `mode: CustomConfig` -— **in `mode: None` the chart deliberately owns nothing in Grafana -orgs**. This matters on upgrade: existing tenants who ran the -pre-PR Monitoring chart may have added users to Grafana manually -through the UI; if the Job ran in `mode: None` its prune pass would -silently delete those accounts on the next Flux reconcile. Operators -who want to clean up OIDC-provisioned accounts after switching -`System | CustomConfig → None` do it themselves through the Grafana -UI or admin API. The `activeDeadlineSeconds` and `ttlSecondsAfterFinished` -notes below only apply when the Job actually renders. +The Job renders only when **both** `mode != None` and +`spec.oidc.users` is non-empty. Three empty-users states all resolve +to the same hands-off contract — the chart owns nothing in Grafana +orgs: -Users not listed in `spec.oidc.users` who log in through OIDC get -nothing — no default `Viewer` role, no cross-tenant read access. -`skip_org_role_sync = true` in the Grafana config makes sure the -Job's assignments outlive the next login. +- `mode: None` — OIDC is off, no chart-managed org membership at + all. This matters on upgrade: existing tenants who ran the + pre-PR Monitoring chart may have added users to Grafana manually + through the UI; if the Job ran in `mode: None` its prune pass + would silently delete those accounts on the next Flux reconcile. +- `customConfig.secretRef` — the users-map is forbidden by the + render-time assert; the operator's mounted `auth.ini` fragment is + authoritative. +- `mode: System | CustomConfig-inline` with `users:` unset — the + operator opted into OIDC but not into the chart-managed users-map; + they manage org membership themselves. + +Setting `users: [...]` on the CR means the chart owns Main-Org +membership — anything added by hand (Grafana UI, admin API, other +tooling) gets pruned on the next reconcile. The reverse edge is +worth calling out: taking `users:` back to `[]` (or unsetting it) +does NOT prune the last entry — the chart stops managing membership +and any accounts the Job provisioned are left in place. Operators +who want to clean up OIDC-provisioned accounts after switching +`System | CustomConfig → None` (or after emptying `users:`) do it +themselves through the Grafana UI or admin API. The +`activeDeadlineSeconds` and `ttlSecondsAfterFinished` notes below +only apply when the Job actually renders. + +While the users-map is active, users NOT listed in `spec.oidc.users` +who try to log in through OIDC are rejected at the door +(`allow_sign_up = false`) — no account is minted, no default +`Viewer` role is assigned. `skip_org_role_sync = true` in the +Grafana config additionally makes sure the Job's assignments +outlive the next login for users who ARE listed. For `System` mode, the operator provisions the corresponding Keycloak user in `cozy` in whatever way they already do (Keycloak @@ -224,10 +273,15 @@ and — if the user's email is in `spec.oidc.users` — binds the OIDC identity to the pre-provisioned local account with the role the Job already set. -If the user's email is not in `spec.oidc.users` the login succeeds -authentication-wise but they land with no org membership and no role. -Add them to `spec.oidc.users` and re-apply the CR; the Job will run -on the next helm-upgrade and pull them in. +If the user's email is not in `spec.oidc.users` (and the users-map +is active — `mode != None` and the list is non-empty) the login is +refused by Grafana at the OAuth callback — `allow_sign_up = false` +does not mint an account for them. Add them to `spec.oidc.users` +and re-apply the CR; the Job will run on the next helm-upgrade, +pre-provision their local account, and the next login attempt will +succeed. When the users-map is inactive Grafana's default +`allow_sign_up = true` applies and any authenticated cozy-realm +identity lands with `auto_assign_org_role` (Viewer). The break-glass `admin_user` / `admin_password` field on the form stays wired to the `grafana-admin-password` Secret and continues to diff --git a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml index 41baff302..90dfad3be 100644 --- a/packages/system/monitoring/templates/grafana/oidc-users-job.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-users-job.yaml @@ -22,13 +22,27 @@ (see grafana.yaml) makes sure a later login never overwrites the role this Job sets. - NOT rendered when `mode: None`. The chart deliberately treats - `mode: None` as "OIDC is off, chart owns nothing in Grafana orgs"; - a prune pass in `None` would silently delete every non-admin - Main-Org member on any monitoring upgrade — clobbering users added - manually through the Grafana UI on an installation that predates - this PR. Operators who need to clean up OIDC-provisioned accounts - after switching System/CustomConfig → None do it themselves. + Renders only when `spec.oidc.users` is non-empty. `users: []` (or + unset) is the switch that says "the chart owns nothing in Grafana + orgs" — a prune pass with an empty desired list would silently + delete every non-admin Main-Org member on every helm upgrade. That + covers three empty-users states that all resolve to the same + hands-off contract: + * mode=None → OIDC off, no chart-managed + org membership at all. + * customConfig.secretRef mode → users[] is forbidden by + the assertion helper; the + operator's ini fragment is + authoritative. + * System / CustomConfig-inline with + users[] unset → the operator opted into + OIDC but not into the + chart-managed users-map; + they own org membership + themselves. + Removing the last `users:` entry therefore no longer prunes it — + the operator cleans up any leftover OIDC-provisioned accounts by + hand, matching the already-documented `System → None` posture. Lifecycle: - The Job itself is a Helm hook (post-install,post-upgrade) with @@ -51,7 +65,17 @@ Job itself is not emitted (mode=None). */}} {{- include "monitoring.oidc.assertUsersPrologue" . }} -{{- if ne $mode "None" }} +{{- /* + Render gate — two independent guards ANDed together: + * mode != None keeps pre-OIDC installs safe on `monitoring` + HR reconciles (empty users but no OIDC at all). + * users non-empty covers `customConfig.secretRef` (users forbidden + by assertion) and System / CustomConfig-inline + with `users:` unset — both cases where the + operator opted into OIDC but not into the + chart-managed users-map. +*/}} +{{- if and (ne $mode "None") (gt (len $users) 0) }} --- apiVersion: batch/v1 kind: Job From b002c967ac0548566cac24f72558fdbacac17913 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Thu, 9 Jul 2026 13:57:43 +0300 Subject: [PATCH 36/39] fix(monitoring): close cross-tenant Grafana OIDC hole via tenant-group gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`-{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: -view -use -admin -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 --- docs/oidc-grafana.md | 65 ++++++++++++------ .../system/monitoring/templates/_helpers.tpl | 23 +++++++ .../monitoring/templates/grafana/grafana.yaml | 17 ++++- .../templates/grafana/oidc-keycloak.yaml | 31 ++++++++- .../system/monitoring/tests/oidc_test.yaml | 66 +++++++++++++++---- 5 files changed, 168 insertions(+), 34 deletions(-) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index 16f74c0cd..efc2dec46 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -78,25 +78,49 @@ CR: fallback, same pattern as `packages/system/dashboard`), preserved across upgrades. 4. The Grafana CR's `spec.config.auth.generic_oauth` section wired to - the cozy realm issuer + the per-instance audience scope. When - `spec.oidc.users` is non-empty the chart also merges three - isolation keys on top so the users-Job's app-side role - assignments survive login: - - `skip_org_role_sync = true` — a login never overwrites the Job's - assignments; + the cozy realm issuer + the per-instance audience scope. Two + independent gates ride on top of the raw OAuth wiring: + + **Tenant-membership gate — unconditional in System mode.** The chart + requests the `groups` OIDC scope (already provisioned platform-wide + by `packages/system/keycloak-configure` as a realm-default + KeycloakClientScope with an `oidc-group-membership-mapper`) and sets + + ``` + allowed_groups = -view -use -admin -super-admin + ``` + + The four groups match the KeycloakRealmGroups the tenant chart + provisions in the `cozy` realm — see + `packages/apps/tenant/templates/keycloakgroups.yaml`. Grafana + rejects any login whose token does not carry at least one of these + groups. This is the isolation primitive that stops tenant-alice + from signing into tenant-bob's Grafana even though both authenticate + against the same flat `cozy` realm. The audience mapper in point 2 + protects against *replay* of a minted token across releases; this + gate protects against *issuance* of a usable token to a caller + outside the release's tenant. + + **Users-map contract — active only when `spec.oidc.users` is + non-empty.** Three additional keys are merged on top: + - `skip_org_role_sync = true` — a login never overwrites the + users-Job's role assignments; - `oauth_allow_insecure_email_lookup = true` — the OIDC identity binds to the pre-provisioned local account by email; - `allow_sign_up = false` — a cozy-realm identity whose email is - NOT in `spec.oidc.users` is rejected at login instead of being - admitted as `auto_assign_org_role` (Viewer by default) and - waiting for the next helm-upgrade prune pass to clean them up. + NOT in `spec.oidc.users` is rejected at login (even if they are + a tenant member and pass the group gate) instead of being + admitted as `auto_assign_org_role` Viewer and waiting for the + next helm-upgrade prune pass to clean them up. - When `spec.oidc.users` is empty (or unset) the three keys are NOT - forced — the chart owns nothing app-side, so there is nothing to - protect. Everyone in the `cozy` realm can log in and lands at - Grafana's `auto_assign_org_role` default (`Viewer`). Operators - who want stricter behaviour without using the users-map should - set the three keys themselves through `CustomConfig` mode. + When `spec.oidc.users` is empty (or unset) the three users-map + keys are NOT forced. The tenant-membership gate still applies, so + only tenant members can log in — they land at Grafana's + `auto_assign_org_role` default (`Viewer`). This is the intended + hands-off contract: the chart owns nothing app-side, but the + Keycloak-layer gate still isolates the release. Cross-tenant + users are rejected outright; in-tenant users get a self-service + Viewer with no org-role management by the chart. 5. A `GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET` env on the Grafana Deployment sourced from the Secret in step 3. 6. A post-install/post-upgrade **users-Job** — see the "Users and @@ -258,10 +282,13 @@ outlive the next login for users who ARE listed. For `System` mode, the operator provisions the corresponding Keycloak user in `cozy` in whatever way they already do (Keycloak -UI, a `KeycloakRealmUser` CR, an identity broker). The Monitoring -release does not create Keycloak users and does not manage -`KeycloakRealmGroup`s — group membership curated out-of-band is not -affected by anything this chart does. +UI, a `KeycloakRealmUser` CR, an identity broker) and adds them to +one of the tenant's `-{view,use,admin,super-admin}` +groups — Grafana's `allowed_groups` rejects the login otherwise. +The Monitoring release does not create Keycloak users and does not +provision `KeycloakRealmGroup`s — group directory ownership stays +with the tenant chart (`packages/apps/tenant`); group membership is +curated by whoever operates the `cozy` realm. ## How a user logs in diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index df40917af..3ce069dce 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -47,6 +47,29 @@ {{- printf "https://keycloak.%s/realms/cozy" $host -}} {{- end -}} +{{- /* + Space-separated list of the four tenant-scoped Keycloak groups the + Monitoring release's Grafana will accept on login (allowed_groups in + auth.generic_oauth). The tenant chart (packages/apps/tenant) already + provisions `-{view,use,admin,super-admin}` KeycloakRealmGroups + in the `cozy` realm — the namespace name is the tenant identifier for + both root and nested tenants (see tenant.name in the tenant chart + helpers), so `.Release.Namespace` is the correct prefix here. + + Why all four groups instead of just those referenced in + `spec.oidc.users[].role`: the users-map controls Grafana org role + assignment, not authentication. A tenant admin whose email is not in + users[] should still be able to log in and land at the release's + configured hands-off default (auto_assign_org_role=Viewer) — being a + member of the tenant is the credential that says "you belong to this + Grafana", regardless of whether the chart also happens to have + pre-provisioned them an org role. +*/}} +{{- define "monitoring.oidc.allowedGroups" -}} +{{- $ns := .Release.Namespace -}} +{{- printf "%s-view %s-use %s-admin %s-super-admin" $ns $ns $ns $ns -}} +{{- end -}} + {{- /* Fail-fast when `mode: System` is requested without the platform-level OIDC feature being on. Mirrors the identical guard from the tenant diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index d52b62f58..940e86c09 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -121,6 +121,20 @@ spec: assertCustomSecretRefUsersEmpty. */}} {{- if eq $oidcMode "System" }} + {{- /* + Tenant-membership gate — chart-owned and unconditional in System + mode. `groups` is requested so Keycloak emits the caller's realm + groups in the token (via the platform-managed `groups` + KeycloakClientScope + oidc-group-membership-mapper — see + packages/system/keycloak-configure). `allowed_groups` then rejects + any login whose token does not carry at least one of this + tenant's `-{view,use,admin,super-admin}` groups. This + closes the cross-tenant hole where a user in the shared `cozy` + realm could otherwise sign into any tenant's Grafana just by + being authenticated: the audience mapper stops REPLAY of a + minted token, the allowed_groups check stops ISSUANCE of a + usable one to callers outside the tenant. + */}} {{- $systemOidc := dict "enabled" "true" "name" "Keycloak" @@ -129,7 +143,8 @@ spec: "auth_url" (printf "%s/protocol/openid-connect/auth" (include "monitoring.oidc.systemIssuerURL" .)) "token_url" (printf "%s/protocol/openid-connect/token" (include "monitoring.oidc.systemIssuerURL" .)) "api_url" (printf "%s/protocol/openid-connect/userinfo" (include "monitoring.oidc.systemIssuerURL" .)) - "scopes" (printf "openid email profile %s" (include "monitoring.oidc.audienceScopeName" .)) + "scopes" (printf "openid email profile groups %s" (include "monitoring.oidc.audienceScopeName" .)) + "allowed_groups" (include "monitoring.oidc.allowedGroups" .) "email_attribute_path" "email" "auto_login" "false" }} diff --git a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml index 2bad49368..2f10894c0 100644 --- a/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml +++ b/packages/system/monitoring/templates/grafana/oidc-keycloak.yaml @@ -8,9 +8,21 @@ another's Grafana — the per-instance isolation primitive in the absence of per-tenant realms. - No realm groups: authorization is app-side, driven by `spec.oidc.users` - and reconciled into Grafana orgs by the users-Job. See - templates/grafana/oidc-users-job.yaml and docs/oidc-grafana.md. + Two distinct group-based gates are wired: + * `defaultClientScopes: [groups, ]` on the client causes + Keycloak to emit the caller's realm-group membership in the + `groups` claim. Grafana's `allowed_groups` in grafana.yaml then + rejects any login whose token does not carry at least one of the + release's tenant-scoped groups + (`-{view,use,admin,super-admin}`, provisioned by the + tenant chart in packages/apps/tenant). This is the isolation gate + that stops tenant-alice from signing into tenant-bob's Grafana. + * `spec.oidc.users`, reconciled into Grafana org membership/roles + by the users-Job (templates/grafana/oidc-users-job.yaml), is the + per-user authorization layer that runs INSIDE Grafana after the + Keycloak-side gate has already admitted the login. + No KeycloakRealmGroup is provisioned by this chart — group directory + ownership stays with the tenant chart. See docs/oidc-grafana.md. Nothing is rendered for `mode: CustomConfig` or `mode: None`: in those modes `cozy` is not in the path. @@ -79,6 +91,19 @@ spec: redirectUris: - "https://{{ $grafanaHost }}/login/generic_oauth" defaultClientScopes: + # Platform-provisioned realm-default scope carrying an + # oidc-group-membership-mapper that emits the caller's Keycloak + # groups into the `groups` claim of every token. Grafana's + # `allowed_groups` reads that claim to gate login on tenant-scoped + # group membership (see grafana.yaml). EDP's KeycloakClient does NOT + # merge realm-level defaults into `defaultClientScopes` — we must + # list it here explicitly, or the claim would be missing and + # `allowed_groups` would reject every login. + - groups + # Per-instance audience: binds `id_token.aud` to this client's + # `clientId` so a token minted for another Monitoring release + # (tenant-alice's Grafana) is rejected by this Grafana even if the + # caller also happens to be a member of this tenant's groups. - {{ $scopeName }} attributes: "post.logout.redirect.uris": "+" diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 0c0ccc326..fb46a2dcb 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -117,7 +117,7 @@ tests: # mode: System — happy path ########################################################## - - it: mode=System renders exactly one KeycloakClient and one KeycloakClientScope (no groups) + - it: mode=System renders one KeycloakClient + one audience KeycloakClientScope (chart-owned only) template: templates/grafana/oidc-keycloak.yaml set: oidc.mode: System @@ -156,9 +156,12 @@ tests: path: spec.defaultClientScopes content: tenant-root-monitoring-audience documentIndex: 1 - # Groups scope removed together with the KeycloakRealmGroups — the - # generic_oauth block no longer consumes group membership. - - notContains: + # `groups` scope is the platform-managed realm-default that emits + # the caller's Keycloak groups into the token; must be listed + # explicitly here because EDP KeycloakClient overrides (does NOT + # merge with) realm-level defaults. Without it, allowed_groups on + # the Grafana side would reject every login. + - contains: path: spec.defaultClientScopes content: groups documentIndex: 1 @@ -220,7 +223,14 @@ tests: value: "https://keycloak.example.org/realms/cozy/protocol/openid-connect/auth" - equal: path: spec.config["auth.generic_oauth"].scopes - value: "openid email profile tenant-root-monitoring-audience" + value: "openid email profile groups tenant-root-monitoring-audience" + # Tenant-membership gate — unconditionally emitted in System mode + # (chart-owned, not tied to spec.oidc.users). Rejects logins whose + # token does not carry at least one of the release's tenant-scoped + # groups. Space-separated per Grafana generic_oauth semantics. + - equal: + path: spec.config["auth.generic_oauth"].allowed_groups + value: "tenant-root-view tenant-root-use tenant-root-admin tenant-root-super-admin" # No group-based mapping; the users-Job drives roles instead. - notExists: path: spec.config["auth.generic_oauth"].role_attribute_path @@ -245,12 +255,15 @@ tests: path: spec.config["auth.generic_oauth"].allow_sign_up value: "false" - - it: mode=System with empty users omits the chart-forced isolation dict - # Reviewer's minor from PR#3176 round-2: when users[] is empty the - # chart has nothing to protect; forcing insecure_email_lookup / no- - # sign_up in that state is a needless account-linking surface with - # nothing to bind. The System branch still emits auth.generic_oauth - # (issuer + scope), just without the users-Job contract keys. + - it: mode=System with empty users omits users-map isolation dict but keeps the tenant-membership gate + # Reviewer's minor from PR#3176 round-2: with empty users[] the + # users-Job contract keys (skip_org_role_sync, insecure_email_lookup, + # allow_sign_up=false) are dropped so a BYO-IdP-like hands-off flow + # applies inside Grafana. But `allowed_groups` and the `groups` scope + # STAY UNCONDITIONALLY — they are the cross-tenant isolation gate + # (see round-3 review from lllamnyp), NOT part of the users-map + # contract. Without them a tenant-alice user could sign into + # tenant-bob's Grafana and get auto-assigned Viewer. template: templates/grafana/grafana.yaml set: oidc.mode: System @@ -265,6 +278,37 @@ tests: path: spec.config["auth.generic_oauth"].oauth_allow_insecure_email_lookup - notExists: path: spec.config["auth.generic_oauth"].allow_sign_up + # Tenant-membership gate stays regardless of users-map state. + - equal: + path: spec.config["auth.generic_oauth"].allowed_groups + value: "tenant-root-view tenant-root-use tenant-root-admin tenant-root-super-admin" + - equal: + path: spec.config["auth.generic_oauth"].scopes + value: "openid email profile groups tenant-root-monitoring-audience" + + - it: mode=System allowed_groups uses the release namespace as tenant prefix (nested tenant) + # Regression guard against a helper regression that would compute + # groups from something else (e.g. .Release.Name or a hardcoded + # "tenant-root"): the tenant chart provisions the groups under + # `-{view,...}` — see packages/apps/tenant/templates/ + # keycloakgroups.yaml — so a mismatch here would silently lock out + # every user of the release on a nested tenant. + template: templates/grafana/grafana.yaml + release: + name: monitoring + namespace: tenant-foo-bar + set: + _cluster: + root-host: example.org + oidc-enabled: "true" + _namespace: + host: tenant-foo-bar.example.org + ingress: tenant-foo-bar + oidc.mode: System + asserts: + - equal: + path: spec.config["auth.generic_oauth"].allowed_groups + value: "tenant-foo-bar-view tenant-foo-bar-use tenant-foo-bar-admin tenant-foo-bar-super-admin" - it: mode=System with empty users does NOT render the users-Job template: templates/grafana/oidc-users-job.yaml From 874ca9427fa6f21e7ad404338f2feb6f3278d755 Mon Sep 17 00:00:00 2001 From: IvanHunters Date: Thu, 9 Jul 2026 13:57:59 +0300 Subject: [PATCH 37/39] fix(apps/kubernetes): close cross-tenant kube-apiserver OIDC hole via CEL claim gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`-{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 --- docs/oidc-tenant.md | 30 ++++++++++++++++--- .../apps/kubernetes/templates/_helpers.tpl | 29 ++++++++++++++++++ .../templates/oidc-authn-config.yaml | 14 ++++++++- packages/apps/kubernetes/tests/oidc_test.yaml | 15 ++++++++++ 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs/oidc-tenant.md b/docs/oidc-tenant.md index 90f4b0bfe..8ab5af57f 100644 --- a/docs/oidc-tenant.md +++ b/docs/oidc-tenant.md @@ -195,10 +195,10 @@ expires. the apiserver rejects tokens at request time; the admin kubeconfig keeps working as break-glass. - **`emailVerified` on Keycloak users is a prescriptive requirement, - not a chart-enforced one.** Phase 1 does not emit any - `claimValidationRules` in the rendered `AuthenticationConfiguration`, - so the chart itself is not the gate. The layered guarantees you rely - on instead: + not a chart-enforced one.** The chart emits a + `claimValidationRules` entry keyed on `groups` (see next bullet), + but not one keyed on `email_verified`. The layered guarantees you + rely on instead: 1. Provision users with `emailVerified: true` (via `KeycloakRealmUser` or the Keycloak UI's email-verify flow) so no unverified identity ever holds the email you name in `users[].email`. @@ -214,6 +214,28 @@ expires. the rendered System-mode config would elevate item 3 to a hard gate; that is a reasonable follow-up hardening but is out of scope for Phase 1. + +- **Cross-tenant isolation gate.** The rendered + `AuthenticationConfiguration` carries a CEL `claimValidationRules` + entry on the `groups` claim that rejects any token whose caller is + not a member of at least one of this tenant's four Keycloak groups: + + ``` + -view -use -admin -super-admin + ``` + + The tenant chart (`packages/apps/tenant/templates/keycloakgroups.yaml`) + provisions these groups in the shared `cozy` realm. Without this + gate, any authenticated cozy-realm user could point `kubectl + oidc-login` at another tenant's cluster, land at + `system:authenticated`, and enumerate the discovery surface + (`kubectl api-resources`, OpenAPI schemata) — RBAC would default-deny + named-resource access, but discovery still leaks. Symmetric with the + Grafana-side `allowed_groups` gate on the Monitoring chart's + `auth.generic_oauth` block (see `docs/oidc-grafana.md`). CustomConfig + mode does NOT get this gate injected — the tenant's own + AuthenticationConfiguration is authoritative and the tenant is + responsible for their own claim-side guards. - **`kubectl` without the `oidc-login` plugin** — the exec block errors out client-side; install the plugin. diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index 7524f10dd..1cfadb36f 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -187,3 +187,32 @@ cozystack-basics. {{- define "kubernetes.oidc.systemIssuerURL" -}} {{- printf "https://keycloak.%s/realms/cozy" (dig "root-host" "" (.Values._cluster | default dict)) }} {{- end }} + +{{/* +CEL claimValidationRule body — rejects tokens whose `groups` claim does +NOT carry at least one of the tenant's four Keycloak groups. The tenant +chart (packages/apps/tenant) provisions these groups per-tenant in the +`cozy` realm; the namespace name is the tenant identifier for both root +and nested tenants (see tenant.name in the tenant chart helpers), so +`.Release.Namespace` is the correct prefix. + +The `has(claims.groups) &&` guard is required — CEL evaluation of +`claims.groups.exists(...)` on a token missing the claim raises a +runtime error that surfaces as HTTP 500 from the authenticator rather +than the intended 401. `has()` short-circuits that path into a plain +unauthorized outcome with the `message` string in the audit log. + +Why enforce membership at the apiserver even though RBAC default-denies +unmapped identities: `system:authenticated` still leaks the OpenAPI + +discovery surface (kubectl auth can-i --list, kubectl api-resources, +`/apis/*` schemata) to every user in the shared `cozy` realm — a +tenant-alice user could enumerate tenant-bob's cluster's CRDs and +built-in resource shape. Adding a hard cross-tenant gate here matches +the design's stated authorization boundary (per-tenant kube-apiserver += per-tenant identity domain) rather than relying on downstream +RBAC-shaped conservatism. +*/}} +{{- define "kubernetes.oidc.groupsClaimValidationExpr" -}} +{{- $ns := .Release.Namespace -}} +{{- printf "has(claims.groups) && claims.groups.exists(g, g in [\"%s-view\", \"%s-use\", \"%s-admin\", \"%s-super-admin\"])" $ns $ns $ns $ns -}} +{{- end }} diff --git a/packages/apps/kubernetes/templates/oidc-authn-config.yaml b/packages/apps/kubernetes/templates/oidc-authn-config.yaml index 72563eae9..17ee04d92 100644 --- a/packages/apps/kubernetes/templates/oidc-authn-config.yaml +++ b/packages/apps/kubernetes/templates/oidc-authn-config.yaml @@ -30,6 +30,7 @@ {{- if eq .Values.oidc.mode "System" }} {{- $issuer := include "kubernetes.oidc.systemIssuerURL" . | trim }} {{- $audience := include "kubernetes.oidc.clientId" . | trim }} +{{- $groupsExpr := include "kubernetes.oidc.groupsClaimValidationExpr" . | trim }} {{- /* User identity claim: `email`, not `preferred_username`. @@ -46,8 +47,19 @@ Switching the claim to `email` aligns the apiserver-visible identity with the operator's declared `users[].email` value. Requires the issuer to emit `email` — cozy realm (verified) and every conformant OIDC provider do. + +claimValidationRules — cross-tenant isolation gate. Without this rule +any authenticated cozy-realm user (i.e. anyone in the shared platform +realm) would receive `system:authenticated` on this tenant's +apiserver. RBAC default-denies, but discovery (`kubectl api-resources`, +OpenAPI schemata) still leaks. The CEL expression forces at least one +of the four tenant-scoped groups +(`-{view,use,admin,super-admin}`, provisioned by the tenant +chart) to be present in the token's `groups` claim. Symmetric with the +Grafana-side `allowed_groups` gate in the Monitoring chart — see +docs/oidc-tenant.md and docs/oidc-grafana.md. */ -}} -{{- $authnConfig := printf "apiVersion: apiserver.config.k8s.io/v1beta1\nkind: AuthenticationConfiguration\njwt:\n- issuer:\n url: %s\n audiences:\n - %s\n claimMappings:\n username:\n claim: email\n prefix: \"\"\n groups:\n claim: groups\n prefix: \"\"\n" $issuer $audience }} +{{- $authnConfig := printf "apiVersion: apiserver.config.k8s.io/v1beta1\nkind: AuthenticationConfiguration\njwt:\n- issuer:\n url: %s\n audiences:\n - %s\n claimValidationRules:\n - expression: '%s'\n message: \"user is not a member of any %s-{view,use,admin,super-admin} group in the cozy realm — this tenant's apiserver only trusts identities the tenant chart has admitted\"\n claimMappings:\n username:\n claim: email\n prefix: \"\"\n groups:\n claim: groups\n prefix: \"\"\n" $issuer $audience $groupsExpr .Release.Namespace }} --- apiVersion: v1 kind: Secret diff --git a/packages/apps/kubernetes/tests/oidc_test.yaml b/packages/apps/kubernetes/tests/oidc_test.yaml index 59c84769f..4a77bfedb 100644 --- a/packages/apps/kubernetes/tests/oidc_test.yaml +++ b/packages/apps/kubernetes/tests/oidc_test.yaml @@ -262,6 +262,21 @@ tests: - matchRegex: path: stringData["config.yaml"] pattern: "- tenant-foo-kubernetes-app" + # Cross-tenant isolation gate — the AuthenticationConfiguration + # must carry a CEL claimValidationRule keyed on the release's + # tenant-scoped groups. Without this rule, any authenticated + # cozy-realm user would land at `system:authenticated` on this + # tenant's apiserver and get discovery access. See + # kubernetes.oidc.groupsClaimValidationExpr in _helpers.tpl. + - matchRegex: + path: stringData["config.yaml"] + pattern: "claimValidationRules:" + - matchRegex: + path: stringData["config.yaml"] + pattern: 'expression: .+has\(claims\.groups\).+tenant-foo-view.+tenant-foo-use.+tenant-foo-admin.+tenant-foo-super-admin' + - matchRegex: + path: stringData["config.yaml"] + pattern: 'message: "user is not a member of any tenant-foo-\{view,use,admin,super-admin\} group' - it: System mode injects --authentication-config and the chart-owned volume into the KCP release: From 0164a6afa41aacb4986faea84959a6125e1258de Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Tue, 14 Jul 2026 00:45:33 +0300 Subject: [PATCH 38/39] fix(monitoring): set groups_attribute_path=groups so allowed_groups actually works on Grafana v11.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/oidc-grafana.md | 11 +++++++++++ .../monitoring/templates/grafana/grafana.yaml | 15 +++++++++++++++ packages/system/monitoring/tests/oidc_test.yaml | 15 +++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index efc2dec46..224a1b802 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -88,8 +88,19 @@ CR: ``` allowed_groups = -view -use -admin -super-admin + groups_attribute_path = groups ``` + `groups_attribute_path` is REQUIRED alongside `allowed_groups` on + Grafana v11.x+: without it Grafana leaves the extracted + `user.Groups` slice empty regardless of what the userinfo endpoint + returns, and the gate rejects every login with "user not a member + of one of the required groups" — even when the token carries the + correct claim (verified end-to-end on dev3 with Grafana v11.6.15). + The JMESPath expression `groups` reads the top-level `groups` + array from userinfo, matching the shape Keycloak's + `oidc-group-membership-mapper` emits. + The four groups match the KeycloakRealmGroups the tenant chart provisions in the `cozy` realm — see `packages/apps/tenant/templates/keycloakgroups.yaml`. Grafana diff --git a/packages/system/monitoring/templates/grafana/grafana.yaml b/packages/system/monitoring/templates/grafana/grafana.yaml index 940e86c09..5b99b3415 100644 --- a/packages/system/monitoring/templates/grafana/grafana.yaml +++ b/packages/system/monitoring/templates/grafana/grafana.yaml @@ -135,6 +135,20 @@ spec: minted token, the allowed_groups check stops ISSUANCE of a usable one to callers outside the tenant. */}} + {{- /* + `groups_attribute_path` is REQUIRED (not optional) alongside + `allowed_groups` on Grafana v11.x+. Without it Grafana leaves + the extracted user's Groups slice empty regardless of what the + userinfo endpoint returns, and the `allowed_groups` gate rejects + every login with "user not a member of one of the required + groups" — even when the token carries the correct claim. Verified + end-to-end on dev3 with Grafana v11.6.15: id_token AND userinfo + both contained `groups: [-super-admin, ...]`, `allowed_groups` + listed `-super-admin`, but login failed until + `groups_attribute_path=groups` was added. JMESPath expression + "groups" reads the top-level `groups` array from userinfo — the + shape Keycloak's oidc-group-membership-mapper emits. + */}} {{- $systemOidc := dict "enabled" "true" "name" "Keycloak" @@ -144,6 +158,7 @@ spec: "token_url" (printf "%s/protocol/openid-connect/token" (include "monitoring.oidc.systemIssuerURL" .)) "api_url" (printf "%s/protocol/openid-connect/userinfo" (include "monitoring.oidc.systemIssuerURL" .)) "scopes" (printf "openid email profile groups %s" (include "monitoring.oidc.audienceScopeName" .)) + "groups_attribute_path" "groups" "allowed_groups" (include "monitoring.oidc.allowedGroups" .) "email_attribute_path" "email" "auto_login" "false" diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index fb46a2dcb..3f6d25817 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -231,6 +231,15 @@ tests: - equal: path: spec.config["auth.generic_oauth"].allowed_groups value: "tenant-root-view tenant-root-use tenant-root-admin tenant-root-super-admin" + # groups_attribute_path is REQUIRED alongside allowed_groups on + # Grafana v11.x+ — without it Grafana leaves the extracted + # user.Groups slice empty and the allowed_groups gate rejects + # every login (verified end-to-end on dev3 with v11.6.15). "groups" + # points at the top-level `groups` array from userinfo — the + # shape Keycloak's oidc-group-membership-mapper emits. + - equal: + path: spec.config["auth.generic_oauth"].groups_attribute_path + value: "groups" # No group-based mapping; the users-Job drives roles instead. - notExists: path: spec.config["auth.generic_oauth"].role_attribute_path @@ -282,6 +291,12 @@ tests: - equal: path: spec.config["auth.generic_oauth"].allowed_groups value: "tenant-root-view tenant-root-use tenant-root-admin tenant-root-super-admin" + # groups_attribute_path must stay too — without it the gate is a + # silent no-op on Grafana v11.x+ regardless of whether the + # users-map is active. + - equal: + path: spec.config["auth.generic_oauth"].groups_attribute_path + value: "groups" - equal: path: spec.config["auth.generic_oauth"].scopes value: "openid email profile groups tenant-root-monitoring-audience" From 5d13c15d658c914575aed1e96928c90b7ff1979e Mon Sep 17 00:00:00 2001 From: Ivan Okhotnikov Date: Wed, 15 Jul 2026 12:00:21 +0300 Subject: [PATCH 39/39] fix(apps/kubernetes): compile OIDC groups claim rule via dyn() so the tenant apiserver boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/apps/kubernetes/templates/_helpers.tpl | 12 +++++++++++- packages/apps/kubernetes/tests/oidc_test.yaml | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index 1cfadb36f..eefc6ab22 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -212,7 +212,17 @@ the design's stated authorization boundary (per-tenant kube-apiserver = per-tenant identity domain) rather than relying on downstream RBAC-shaped conservatism. */}} +{{- /* +`claims.groups` is statically typed `any` in the apiserver's CEL environment +(claims is map(string, any)), and the `.exists()` comprehension macro rejects a +range of type `any` — the apiserver fails AuthenticationConfiguration compilation +at startup and CrashLoops ("expression of type 'any' cannot be range of a +comprehension (must be list, map, or dynamic)"). Wrap in dyn() so CEL treats the +range as dynamic; the `has()` guard still short-circuits when groups is absent. +Verified on a live tenant apiserver (v1.32) — without dyn() the control plane +never boots. +*/ -}} {{- define "kubernetes.oidc.groupsClaimValidationExpr" -}} {{- $ns := .Release.Namespace -}} -{{- printf "has(claims.groups) && claims.groups.exists(g, g in [\"%s-view\", \"%s-use\", \"%s-admin\", \"%s-super-admin\"])" $ns $ns $ns $ns -}} +{{- printf "has(claims.groups) && dyn(claims.groups).exists(g, g in [\"%s-view\", \"%s-use\", \"%s-admin\", \"%s-super-admin\"])" $ns $ns $ns $ns -}} {{- end }} diff --git a/packages/apps/kubernetes/tests/oidc_test.yaml b/packages/apps/kubernetes/tests/oidc_test.yaml index 4a77bfedb..0f9bdb1b3 100644 --- a/packages/apps/kubernetes/tests/oidc_test.yaml +++ b/packages/apps/kubernetes/tests/oidc_test.yaml @@ -271,9 +271,13 @@ tests: - matchRegex: path: stringData["config.yaml"] pattern: "claimValidationRules:" + # The groups range MUST be wrapped in dyn(): claims.groups is statically + # `any`, and the apiserver rejects a comprehension over `any` at startup + # (CrashLoop). Pinning dyn() here makes a regression fail the unit suite + # instead of only surfacing on a live apiserver. Verified on dev3 v1.32. - matchRegex: path: stringData["config.yaml"] - pattern: 'expression: .+has\(claims\.groups\).+tenant-foo-view.+tenant-foo-use.+tenant-foo-admin.+tenant-foo-super-admin' + pattern: 'expression: .+has\(claims\.groups\).+dyn\(claims\.groups\)\.exists\(g, g in .+tenant-foo-view.+tenant-foo-use.+tenant-foo-admin.+tenant-foo-super-admin' - matchRegex: path: stringData["config.yaml"] pattern: 'message: "user is not a member of any tenant-foo-\{view,use,admin,super-admin\} group'