diff --git a/api/apps/v1alpha1/kubernetes/types.go b/api/apps/v1alpha1/kubernetes/types.go index 9f6f69d47..8799754b2 100644 --- a/api/apps/v1alpha1/kubernetes/types.go +++ b/api/apps/v1alpha1/kubernetes/types.go @@ -226,7 +226,7 @@ type OIDC struct { // Tenant-supplied AuthenticationConfiguration; consumed only when `mode: CustomConfig`. // +kubebuilder:default:={} CustomConfig OIDCCustomConfig `json:"customConfig,omitempty"` - // Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform `cozy` realm via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. + // Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform Keycloak realm (`cozy` by default) via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. // +kubebuilder:default:="None" Mode OIDCMode `json:"mode"` // Users granted access to the tenant cluster; each entry produces one ClusterRoleBinding inside the tenant cluster. Works for both `System` and `CustomConfig` modes. diff --git a/docs/oidc-grafana.md b/docs/oidc-grafana.md index 224a1b802..b65931dd4 100644 --- a/docs/oidc-grafana.md +++ b/docs/oidc-grafana.md @@ -78,7 +78,7 @@ 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. Two + the platform realm issuer (`cozy` by default, `authentication.oidc.realmName`) + 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 diff --git a/docs/oidc-tenant.md b/docs/oidc-tenant.md index 8ab5af57f..bb67780d2 100644 --- a/docs/oidc-tenant.md +++ b/docs/oidc-tenant.md @@ -34,9 +34,7 @@ Three modes: `-admin-kubeconfig` Secret (the Kamaji-minted `super-admin.svc` kubeconfig). This is the default; existing clusters render byte-identical to before. -- **System** — the apiserver trusts the platform `cozy` Keycloak realm via a - per-cluster public client. Authenticates the users already in `cozy` (the - realm cozystack ships with). Zero-config default. +- **System** — the apiserver trusts the platform Keycloak realm (`cozy` by default, set via `authentication.oidc.realmName`) via a per-cluster public client. Authenticates the users already in that realm (the one cozystack provisions). Zero-config default. - **CustomConfig** — the apiserver trusts a tenant-supplied OIDC issuer directly. `cozy` is not in the path. Use for BYO IdPs (Okta, Auth0, a customer's own Keycloak). diff --git a/packages/apps/kubernetes/README.md b/packages/apps/kubernetes/README.md index a0305297a..ab0aba086 100644 --- a/packages/apps/kubernetes/README.md +++ b/packages/apps/kubernetes/README.md @@ -207,7 +207,7 @@ See the reference for components utilized in this service: | Name | Description | Type | Value | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------ | | `oidc` | OIDC authentication and per-user RBAC for the tenant kube-apiserver. See docs/oidc-tenant.md for the operator guide. | `object` | `{}` | -| `oidc.mode` | Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform `cozy` realm via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. | `string` | `None` | +| `oidc.mode` | Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform Keycloak realm (`cozy` by default) via a per-cluster public client with 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 AuthenticationConfiguration; consumed only when `mode: CustomConfig`. | `object` | `{}` | | `oidc.customConfig.config` | Inline AuthenticationConfiguration YAML (`apiserver.config.k8s.io/v1beta1`). The chart writes the value verbatim into a Secret mounted at `/etc/kubernetes/authentication-config/config.yaml` on the kube-apiserver. Mutually exclusive with `secretRef.name`. | `string` | `""` | | `oidc.customConfig.secretRef` | Reference to an existing Secret in the tenant namespace carrying the `AuthenticationConfiguration`. | `object` | `{}` | diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index d61b6bedb..0d4502882 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -146,7 +146,7 @@ Issuer URL for `mode: System`. Resolves to the platform Keycloak realm cozystack-basics. */}} {{- define "kubernetes.oidc.systemIssuerURL" -}} -{{- printf "https://keycloak.%s/realms/cozy" (dig "root-host" "" (.Values._cluster | default dict)) }} +{{- printf "https://keycloak.%s/realms/%s" (dig "root-host" "" (.Values._cluster | default dict)) (dig "oidc-realm-name" "cozy" (.Values._cluster | default dict) | toString) }} {{- end }} {{/* diff --git a/packages/apps/kubernetes/templates/oidc-authn-config.yaml b/packages/apps/kubernetes/templates/oidc-authn-config.yaml index 17ee04d92..91c8bfda0 100644 --- a/packages/apps/kubernetes/templates/oidc-authn-config.yaml +++ b/packages/apps/kubernetes/templates/oidc-authn-config.yaml @@ -31,6 +31,7 @@ {{- $issuer := include "kubernetes.oidc.systemIssuerURL" . | trim }} {{- $audience := include "kubernetes.oidc.clientId" . | trim }} {{- $groupsExpr := include "kubernetes.oidc.groupsClaimValidationExpr" . | trim }} +{{- $realm := dig "oidc-realm-name" "cozy" (.Values._cluster | default dict) | toString }} {{- /* User identity claim: `email`, not `preferred_username`. @@ -59,7 +60,7 @@ 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 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 }} +{{- $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 %s 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 $realm }} --- apiVersion: v1 kind: Secret diff --git a/packages/apps/kubernetes/tests/oidc_test.yaml b/packages/apps/kubernetes/tests/oidc_test.yaml index 0f9bdb1b3..de718fbc4 100644 --- a/packages/apps/kubernetes/tests/oidc_test.yaml +++ b/packages/apps/kubernetes/tests/oidc_test.yaml @@ -570,3 +570,33 @@ tests: asserts: - failedTemplate: errorMessage: "spec.oidc.customConfig: set exactly one of `config` (inline) or `secretRef.name` — they are mutually exclusive." + + # The managed cluster's apiserver trusts the platform Keycloak realm, whose + # name is driven by _cluster.oidc-realm-name (authentication.oidc.realmName in + # the platform). A renamed realm must reach the AuthenticationConfiguration + # issuer, or the guest apiserver rejects every token the platform mints. + - it: System mode threads a custom realm name into the issuer + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-authn-config.yaml + set: + _cluster.oidc-enabled: "true" + _cluster.root-host: example.org + _cluster.oidc-realm-name: acme + oidc.mode: System + asserts: + - matchRegex: + path: stringData["config.yaml"] + pattern: "url: https://keycloak.example.org/realms/acme" + - notMatchRegex: + path: stringData["config.yaml"] + pattern: "/realms/cozy" + # The claimValidationRules denial message names the realm; on a renamed + # realm it must not keep telling the user to look in "cozy". + - matchRegex: + path: stringData["config.yaml"] + pattern: "group in the acme realm" + - notMatchRegex: + path: stringData["config.yaml"] + pattern: "group in the cozy realm" diff --git a/packages/apps/kubernetes/values.schema.json b/packages/apps/kubernetes/values.schema.json index c585fdfd5..9baf38906 100644 --- a/packages/apps/kubernetes/values.schema.json +++ b/packages/apps/kubernetes/values.schema.json @@ -830,7 +830,7 @@ } }, "mode": { - "description": "Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform `cozy` realm via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.", + "description": "Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform Keycloak realm (`cozy` by default) via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path.", "type": "string", "default": "None", "enum": [ diff --git a/packages/apps/kubernetes/values.yaml b/packages/apps/kubernetes/values.yaml index bcdf2e8ff..152305cca 100644 --- a/packages/apps/kubernetes/values.yaml +++ b/packages/apps/kubernetes/values.yaml @@ -284,7 +284,7 @@ talos: ## @field {OIDCSecretRef} [secretRef] - Reference to an existing Secret in the tenant namespace carrying the `AuthenticationConfiguration`. ## @typedef {struct} OIDC - OIDC selector for the tenant cluster (Phase 1 — per-cluster audience binding; no per-tenant Keycloak realm). -## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform `cozy` realm via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. +## @field {OIDCMode} mode - Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform Keycloak realm (`cozy` by default) via a per-cluster public client with audience binding; zero-config default. `CustomConfig`: trust a tenant-supplied issuer directly (BYO); `cozy` is not in the path. ## @field {OIDCCustomConfig} [customConfig] - Tenant-supplied AuthenticationConfiguration; consumed only when `mode: CustomConfig`. ## @field {[]OIDCUser} [users] - Users granted access to the tenant cluster; each entry produces one ClusterRoleBinding inside the tenant cluster. Works for both `System` and `CustomConfig` modes. diff --git a/packages/core/platform/templates/apps.yaml b/packages/core/platform/templates/apps.yaml index 484924f26..15a7b99de 100644 --- a/packages/core/platform/templates/apps.yaml +++ b/packages/core/platform/templates/apps.yaml @@ -106,7 +106,40 @@ stringData: oidc-enabled: {{ .Values.authentication.oidc.enabled | quote }} oidc-insecure-skip-verify: {{ .Values.authentication.oidc.insecureSkipVerify | quote }} extra-keycloak-redirect-uri-for-dashboard: {{ index .Values.authentication.oidc.keycloakExtraRedirectUri | quote }} - keycloak-internal-url: {{ .Values.authentication.oidc.keycloakInternalUrl | quote }} + {{- /* The realm name becomes a Keycloak realm, a URL path segment in every + issuer/redirect/logout URL, and a shell token in the update-email + hook. Reject anything YAML or --set did not resolve to a string (a + bare number, or true/false) — coercing it would rename the realm to + something never typed and inject a "%!s(...)" into the derived URL — + and reject anything outside a safe slug charset, so a stray "/" or + space cannot split a URL path or the shell. Fail at the source + rather than emit a broken issuer later. The hint does not echo the + parsed value back: YAML has already lost the operator's original + token (on -> true), so quoting is the fix, not the reprinted bool. */}} + {{- /* Unset (null) falls back to cozy; an explicit "" is a meaningless + value, not a default request, so let it reach the charset guard + below rather than swallowing it — mirrors the keycloakInternalUrl + tri-state a few lines down (kindIs "invalid" is null, not ""). */}} + {{- $realmName := .Values.authentication.oidc.realmName }} + {{- if kindIs "invalid" $realmName }} + {{- $realmName = "cozy" }} + {{- end }} + {{- if not (kindIs "string" $realmName) }} + {{- fail (printf "authentication.oidc.realmName must be a string (got %s); wrap the value in quotes in the platform values, e.g. realmName: \"my-realm\"" (kindOf $realmName)) }} + {{- end }} + {{- if not (regexMatch "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" $realmName) }} + {{- fail (printf "authentication.oidc.realmName %q is invalid: use only letters, digits, '.', '_' and '-' (it is used verbatim as a URL path segment and a shell token)" $realmName) }} + {{- end }} + oidc-realm-name: {{ $realmName | quote }} + {{- /* null keycloakInternalUrl derives the in-cluster Service URL for the + configured realm; "" is the external-discovery escape hatch and an + explicit value is passed through, so distinguish null from "" with + kindIs rather than `default`, which would also fire on "". */}} + {{- $keycloakInternalUrl := .Values.authentication.oidc.keycloakInternalUrl }} + {{- if kindIs "invalid" $keycloakInternalUrl }} + {{- $keycloakInternalUrl = printf "http://keycloak-http.cozy-keycloak.svc:8080/realms/%s" $realmName }} + {{- end }} + keycloak-internal-url: {{ $keycloakInternalUrl | quote }} expose-services: {{ .Values.publishing.exposedServices | join "," | quote }} {{- /* expose-ingress carries two semantically distinct values that diff --git a/packages/core/platform/tests/apps_oidc_keycloak_internal_url_test.yaml b/packages/core/platform/tests/apps_oidc_keycloak_internal_url_test.yaml index d3c1f229f..35f7db4d8 100644 --- a/packages/core/platform/tests/apps_oidc_keycloak_internal_url_test.yaml +++ b/packages/core/platform/tests/apps_oidc_keycloak_internal_url_test.yaml @@ -51,3 +51,27 @@ tests: - matchRegex: path: stringData["values.yaml"] pattern: 'keycloak-internal-url:\s*""' + + # The default internal URL bakes the realm segment, so it must follow + # authentication.oidc.realmName rather than the literal "cozy" — otherwise a + # renamed realm points every backend call at a realm Keycloak does not serve. + - it: derives the default internal URL from a custom realm name + set: + authentication.oidc.enabled: true + authentication.oidc.realmName: acme + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'keycloak-internal-url:\s*"http://keycloak-http\.cozy-keycloak\.svc:8080/realms/acme"' + + # The escape hatch is an operator decision independent of the realm name, so a + # custom realm must not resurrect a derived URL over an explicit empty string. + - it: keeps the empty-string escape hatch even with a custom realm name + set: + authentication.oidc.enabled: true + authentication.oidc.realmName: acme + authentication.oidc.keycloakInternalUrl: "" + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'keycloak-internal-url:\s*""' diff --git a/packages/core/platform/tests/apps_oidc_realm_name_test.yaml b/packages/core/platform/tests/apps_oidc_realm_name_test.yaml new file mode 100644 index 000000000..ebfa68e21 --- /dev/null +++ b/packages/core/platform/tests/apps_oidc_realm_name_test.yaml @@ -0,0 +1,90 @@ +suite: apps.yaml OIDC realm-name platform → tenant value wiring +templates: + - templates/apps.yaml + +release: + name: cozy-platform + namespace: cozy-system + +# authentication.oidc.realmName is the single source for the Keycloak realm. +# It rides the _cluster channel as oidc-realm-name and is read by +# keycloak-configure (realm/clients/roles), the dashboard and linstor-gui +# gatekeepers (issuer/login/logout URLs) and the info kubeconfig. The apiserver +# OIDC issuer is configured out-of-band via talm oidcIssuerUrl, so a mismatch +# there silently breaks SSO; pin the default and the propagation so a rename in +# one place cannot drift from the value every consumer reads. + +tests: + - it: defaults the realm name to cozy + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'oidc-realm-name:\s*"cozy"' + + # Unset (null) falls back to cozy, but an explicit empty string is a + # meaningless value, not a request for the default — it is rejected by the + # charset guard rather than silently coerced, mirroring the null-vs-"" tristate + # of keycloakInternalUrl below. + - it: rejects an explicitly empty realm name + set: + authentication.oidc.realmName: "" + asserts: + - failedTemplate: + errorMessage: 'authentication.oidc.realmName "" is invalid: use only letters, digits, ''.'', ''_'' and ''-'' (it is used verbatim as a URL path segment and a shell token)' + + - it: propagates a custom realm name onto the _cluster channel + set: + authentication.oidc.realmName: acme + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'oidc-realm-name:\s*"acme"' + + - it: derives the default in-cluster keycloak URL from the realm name + set: + authentication.oidc.realmName: acme + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'keycloak-internal-url:\s*"http://keycloak-http\.cozy-keycloak\.svc:8080/realms/acme"' + + # A realm name YAML resolves to a non-string (a bare integer, or a reserved + # word like on/off that becomes a bool) is rejected, not silently coerced — + # coercion would rename the realm to something never typed and inject a + # "%!s(...)" format error into the derived URL. + - it: rejects a non-string realm name with a quoting hint + set: + authentication.oidc.realmName: 123 + asserts: + - failedTemplate: + errorMessage: 'authentication.oidc.realmName must be a string (got float64); wrap the value in quotes in the platform values, e.g. realmName: "my-realm"' + + # A quoted numeric name is a legitimate string and passes through to both the + # realm key and the derived URL unchanged. + - it: accepts a quoted numeric realm name verbatim + set: + authentication.oidc.realmName: "123" + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'oidc-realm-name:\s*"123"' + - matchRegex: + path: stringData["values.yaml"] + pattern: 'keycloak-internal-url:\s*"http://keycloak-http\.cozy-keycloak\.svc:8080/realms/123"' + + # The realm is spliced verbatim into URL path segments and a shell token, so a + # name with a "/" or a space must be rejected at the source rather than split + # a URL or break the hook script downstream. + - it: rejects a realm name with a path separator + set: + authentication.oidc.realmName: "foo/bar" + asserts: + - failedTemplate: + errorMessage: 'authentication.oidc.realmName "foo/bar" is invalid: use only letters, digits, ''.'', ''_'' and ''-'' (it is used verbatim as a URL path segment and a shell token)' + + - it: rejects a realm name with a space + set: + authentication.oidc.realmName: "foo bar" + asserts: + - failedTemplate: + errorMessage: 'authentication.oidc.realmName "foo bar" is invalid: use only letters, digits, ''.'', ''_'' and ''-'' (it is used verbatim as a URL path segment and a shell token)' diff --git a/packages/core/platform/values.yaml b/packages/core/platform/values.yaml index feab13374..9bc0913d5 100644 --- a/packages/core/platform/values.yaml +++ b/packages/core/platform/values.yaml @@ -456,6 +456,20 @@ authentication: enabled: false insecureSkipVerify: false keycloakExtraRedirectUri: "" + # Technical name of the Keycloak realm that owns the cozystack clients and + # the cozystack-cluster-admin roles. keycloak-configure creates the realm + # under this name, and every SSO consumer (dashboard, linstor-gui, the + # generated kubeconfig, and the derived keycloakInternalUrl below) points at + # /realms/. The apiserver OIDC issuer is set out-of-band via talm + # oidcIssuerUrl and must carry the same realm, or the tokens minted here are + # rejected. Rename it in one place; keep it in step with talm. + # + # Set this at install time. Changing it on a running cluster does not rename + # the realm: the keycloak-operator provisions a fresh, empty realm under the + # new name and abandons the old one, so every account, session and client + # living in the previous realm is orphaned. Treat a rename on a live cluster + # as a from-scratch re-provision of identities, not an in-place migration. + realmName: "cozy" # In-cluster URL of the KeyCloak realm, used for backend-to-backend requests. oauth2-proxy runs # with --skip-oidc-discovery and sends every backend call (token, jwks, userinfo, logout) to this # URL, while browser-facing redirects keep pointing at the external host. @@ -470,7 +484,13 @@ authentication: # # Override only for a KeyCloak that does not live at the standard in-cluster address. Setting it # to an empty string restores full OIDC discovery against the external issuer. - keycloakInternalUrl: "http://keycloak-http.cozy-keycloak.svc:8080/realms/cozy" + # + # Left unset (null), it is derived from realmName as + # http://keycloak-http.cozy-keycloak.svc:8080/realms/, so a realm rename reaches these + # backend calls automatically. The tri-state is: null -> derive from realmName; "" -> external + # OIDC discovery; any other value -> used verbatim. An explicit value is not rewritten, so a + # custom realm paired with an explicit URL must name the same realm. + keycloakInternalUrl: null # Gateway API configuration gateway: # Enable Gateway API support across the platform. When true: diff --git a/packages/extra/info/Makefile b/packages/extra/info/Makefile index 02e04d53b..9d58f7197 100644 --- a/packages/extra/info/Makefile +++ b/packages/extra/info/Makefile @@ -1,7 +1,10 @@ -NAME=etcd +NAME=info include ../../../hack/package.mk +test: + helm unittest . + generate: cozyvalues-gen -v values.yaml -s values.schema.json -r README.md yq -o json -i '.properties = {}' values.schema.json diff --git a/packages/extra/info/templates/kubeconfig.yaml b/packages/extra/info/templates/kubeconfig.yaml index 5e473e1ed..e8fea935d 100644 --- a/packages/extra/info/templates/kubeconfig.yaml +++ b/packages/extra/info/templates/kubeconfig.yaml @@ -1,4 +1,5 @@ {{- $host := index .Values._cluster "root-host" }} +{{- $realm := index .Values._cluster "oidc-realm-name" | default "cozy" }} {{- $oidcEnabled := index .Values._cluster "oidc-enabled" }} {{- if $oidcEnabled }} {{- $apiServerEndpoint := index .Values._cluster "api-server-endpoint" }} @@ -35,7 +36,7 @@ stringData: args: - oidc-login - get-token - - --oidc-issuer-url=https://keycloak.{{ $host }}/realms/cozy + - --oidc-issuer-url=https://keycloak.{{ $host }}/realms/{{ $realm }} - --oidc-client-id=kubernetes - --skip-open-browser command: kubectl diff --git a/packages/extra/info/tests/kubeconfig_test.yaml b/packages/extra/info/tests/kubeconfig_test.yaml new file mode 100644 index 000000000..6486e4055 --- /dev/null +++ b/packages/extra/info/tests/kubeconfig_test.yaml @@ -0,0 +1,43 @@ +suite: info kubeconfig OIDC issuer realm +templates: + - templates/kubeconfig.yaml + +release: + name: info + namespace: tenant-root + +# The generated user kubeconfig runs kubectl oidc-login against +# keycloak./realms/. The realm segment is driven by +# _cluster.oidc-realm-name (authentication.oidc.realmName in the platform), so a +# renamed realm must reach the issuer here too — the token this kubeconfig mints +# is rejected by the apiserver unless its issuer matches the apiserver OIDC +# issuer, which the operator points at the same custom realm via talm. + +tests: + - it: defaults the issuer realm to cozy + set: + _cluster: + root-host: example.org + oidc-enabled: "true" + api-server-endpoint: https://api.example.org:6443 + kube-root-ca: dGVzdA== + asserts: + - matchRegex: + path: stringData.kubeconfig + pattern: '--oidc-issuer-url=https://keycloak\.example\.org/realms/cozy' + + - it: threads a custom realm name into the issuer + set: + _cluster: + root-host: example.org + oidc-enabled: "true" + oidc-realm-name: acme + api-server-endpoint: https://api.example.org:6443 + kube-root-ca: dGVzdA== + asserts: + - matchRegex: + path: stringData.kubeconfig + pattern: '--oidc-issuer-url=https://keycloak\.example\.org/realms/acme' + - notMatchRegex: + path: stringData.kubeconfig + pattern: '/realms/cozy' diff --git a/packages/system/dashboard/templates/gatekeeper.yaml b/packages/system/dashboard/templates/gatekeeper.yaml index 652675300..11e194a39 100644 --- a/packages/system/dashboard/templates/gatekeeper.yaml +++ b/packages/system/dashboard/templates/gatekeeper.yaml @@ -1,4 +1,5 @@ {{- $host := index .Values._cluster "root-host" }} +{{- $realm := index .Values._cluster "oidc-realm-name" | default "cozy" }} {{- $oidcEnabled := index .Values._cluster "oidc-enabled" }} {{- $oidcInsecureSkipVerify := index .Values._cluster "oidc-insecure-skip-verify" }} {{- $keycloakInternalUrl := index .Values._cluster "keycloak-internal-url" | default "" }} @@ -55,16 +56,16 @@ spec: - --upstream=http://cozy-dashboard-console.{{ .Release.Namespace }}.svc:8080 - --http-address=0.0.0.0:8000 - --redirect-url=https://dashboard.{{ $host }}/oauth2/callback - - --oidc-issuer-url=https://keycloak.{{ $host }}/realms/cozy + - --oidc-issuer-url=https://keycloak.{{ $host }}/realms/{{ $realm }} {{- if $keycloakInternalUrl }} - --skip-oidc-discovery - - --login-url=https://keycloak.{{ $host }}/realms/cozy/protocol/openid-connect/auth + - --login-url=https://keycloak.{{ $host }}/realms/{{ $realm }}/protocol/openid-connect/auth - --redeem-url={{ $keycloakInternalUrl }}/protocol/openid-connect/token - --oidc-jwks-url={{ $keycloakInternalUrl }}/protocol/openid-connect/certs - --validate-url={{ $keycloakInternalUrl }}/protocol/openid-connect/userinfo - --backend-logout-url={{ $keycloakInternalUrl }}/protocol/openid-connect/logout?id_token_hint={id_token} {{- else }} - - --backend-logout-url=https://keycloak.{{ $host }}/realms/cozy/protocol/openid-connect/logout?id_token_hint={id_token} + - --backend-logout-url=https://keycloak.{{ $host }}/realms/{{ $realm }}/protocol/openid-connect/logout?id_token_hint={id_token} {{- end }} - --whitelist-domain=keycloak.{{ $host }} - --email-domain=* diff --git a/packages/system/dashboard/tests/gatekeeper_test.yaml b/packages/system/dashboard/tests/gatekeeper_test.yaml index d1b45edc2..3f011128c 100644 --- a/packages/system/dashboard/tests/gatekeeper_test.yaml +++ b/packages/system/dashboard/tests/gatekeeper_test.yaml @@ -205,3 +205,48 @@ tests: - notContains: path: spec.template.spec.containers[0].args content: --backend-logout-url=http://keycloak-http.cozy-keycloak.svc:8080/realms/cozy/protocol/openid-connect/logout?id_token_hint={id_token} + + # The realm segment of the browser-facing issuer/login/logout URLs is driven + # by _cluster.oidc-realm-name (authentication.oidc.realmName in the platform). + # A renamed realm must reach every one of them; a URL left on the literal + # "cozy" would send the browser to a realm the apiserver OIDC issuer no longer + # matches, silently breaking SSO. + - it: threads a custom realm name through the browser-facing OIDC URLs + set: + _cluster: + root-host: example.org + oidc-enabled: "true" + oidc-realm-name: acme + keycloak-internal-url: http://keycloak-http.cozy-keycloak.svc:8080/realms/acme + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --oidc-issuer-url=https://keycloak.example.org/realms/acme + - contains: + path: spec.template.spec.containers[0].args + content: --login-url=https://keycloak.example.org/realms/acme/protocol/openid-connect/auth + - notContains: + path: spec.template.spec.containers[0].args + content: --oidc-issuer-url=https://keycloak.example.org/realms/cozy + + # The external-discovery branch (empty internal URL) builds its own + # backend-logout-url on the browser host, so the realm segment there must + # follow oidc-realm-name too; reverting $realm in that branch alone would + # otherwise survive the suite. + - it: threads a custom realm into the external-discovery logout URL + set: + _cluster: + root-host: example.org + oidc-enabled: "true" + oidc-realm-name: acme + keycloak-internal-url: "" + asserts: + - notContains: + path: spec.template.spec.containers[0].args + content: --skip-oidc-discovery + - contains: + path: spec.template.spec.containers[0].args + content: --backend-logout-url=https://keycloak.example.org/realms/acme/protocol/openid-connect/logout?id_token_hint={id_token} + - notContains: + path: spec.template.spec.containers[0].args + content: --backend-logout-url=https://keycloak.example.org/realms/cozy/protocol/openid-connect/logout?id_token_hint={id_token} diff --git a/packages/system/keycloak-configure/templates/configure-kk.yaml b/packages/system/keycloak-configure/templates/configure-kk.yaml index 308ffff95..56884b224 100644 --- a/packages/system/keycloak-configure/templates/configure-kk.yaml +++ b/packages/system/keycloak-configure/templates/configure-kk.yaml @@ -1,4 +1,5 @@ {{- $host := index .Values._cluster "root-host" }} +{{- $realmName := index .Values._cluster "oidc-realm-name" | default "cozy" }} {{- $extraRedirectUris := splitList "," ((index .Values._cluster "extra-keycloak-redirect-uri-for-dashboard") | default "") }} {{- $k8sCa := index .Values._cluster "kube-root-ca" }} @@ -25,7 +26,7 @@ metadata: name: keycloakrealm-cozy namespace: {{ .Release.Namespace }} spec: - realmName: cozy + realmName: {{ $realmName | quote }} clusterKeycloakRef: keycloak-cozy {{- if $brandingConfig }} {{- if hasKey $brandingConfig "brandName" }} diff --git a/packages/system/keycloak-configure/templates/enable-update-email.yaml b/packages/system/keycloak-configure/templates/enable-update-email.yaml index 69ca2d913..c1bbdae39 100644 --- a/packages/system/keycloak-configure/templates/enable-update-email.yaml +++ b/packages/system/keycloak-configure/templates/enable-update-email.yaml @@ -1,5 +1,7 @@ +{{- $realmName := index .Values._cluster "oidc-realm-name" | default "cozy" | toString }} {{/* - Enable the UPDATE_EMAIL required action in the `cozy` realm. + Enable the UPDATE_EMAIL required action in the platform realm (cozy by + default, authentication.oidc.realmName). The portal user-settings page lets a user change their email via a Keycloak `kc_action=UPDATE_EMAIL` redirect. That flow only works when @@ -73,7 +75,7 @@ spec: set -eu KCADM=/opt/keycloak/bin/kcadm.sh CFG=/tmp/kcadm.config - REALM=cozy + REALM={{ $realmName | squote }} # The realm is reconciled asynchronously by the # keycloak-operator, so wait for it before configuring it. diff --git a/packages/system/keycloak-configure/tests/hook_realm_name_test.yaml b/packages/system/keycloak-configure/tests/hook_realm_name_test.yaml new file mode 100644 index 000000000..a9f2cf7b0 --- /dev/null +++ b/packages/system/keycloak-configure/tests/hook_realm_name_test.yaml @@ -0,0 +1,35 @@ +suite: enable-update-email hook Job targets the configured realm +templates: + - templates/enable-update-email.yaml + +release: + name: keycloak-configure + namespace: cozy-keycloak + +# The enable-update-email hook Job is a consumer of the realm in the same chart +# that creates it: it waits on and configures the realm by name via kcadm. On a +# custom realm it must target the realm keycloak-configure actually provisions, +# not the literal "cozy" — otherwise the Job waits for a realm that never +# appears and the cold install times out, so a non-default realmName is the one +# value nobody could set without breaking the install. + +tests: + # The realm is single-quoted in the shell assignment so a name carrying a + # space or a shell metacharacter cannot break or inject into the Job script. + - it: defaults the hook Job realm to cozy + asserts: + - matchRegex: + path: spec.template.spec.containers[0].command[2] + pattern: "REALM='cozy'(\\s|$)" + + - it: threads a single-quoted custom realm name into the hook Job + set: + _cluster: + oidc-realm-name: acme + asserts: + - matchRegex: + path: spec.template.spec.containers[0].command[2] + pattern: "REALM='acme'(\\s|$)" + - notMatchRegex: + path: spec.template.spec.containers[0].command[2] + pattern: "REALM='cozy'" diff --git a/packages/system/keycloak-configure/tests/realm_name_test.yaml b/packages/system/keycloak-configure/tests/realm_name_test.yaml new file mode 100644 index 000000000..8bc06c5bf --- /dev/null +++ b/packages/system/keycloak-configure/tests/realm_name_test.yaml @@ -0,0 +1,49 @@ +suite: configure-kk realm name is driven by _cluster.oidc-realm-name +templates: + - templates/configure-kk.yaml + +release: + name: keycloak-configure + namespace: cozy-keycloak + +# configure-kk.yaml is the source of truth for the realm: it creates the +# ClusterKeycloakRealm, its clients and the cozystack-cluster-admin roles. The +# realm's technical name must follow authentication.oidc.realmName (arriving as +# _cluster.oidc-realm-name from the platform) so operators can rename it in one +# place. The KeycloakRealm CR object name (keycloakrealm-cozy) and the realmRef +# that clients point at are internal Kubernetes names and stay fixed. + +tests: + - it: defaults the realm name to cozy when _cluster is absent + asserts: + - matchRegex: + path: spec.realmName + pattern: '^cozy$' + documentIndex: 1 + + - it: uses a custom realm name from the _cluster channel + set: + _cluster: + oidc-realm-name: acme + asserts: + - equal: + path: spec.realmName + value: acme + documentIndex: 1 + + # A realm name that is a bare integer or a YAML-reserved word (123, on, no) + # is legal in Keycloak but, rendered unquoted, YAML-coerces to int/bool and + # fails the CRD's string schema on apply — far from values.yaml and hard to + # diagnose. Keep spec.realmName a string regardless of the value's shape. + - it: keeps a numeric realm name a string + set: + _cluster: + oidc-realm-name: 123 + asserts: + - equal: + path: spec.realmName + value: "123" + documentIndex: 1 + - isKind: + of: ClusterKeycloakRealm + documentIndex: 1 diff --git a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml index 1c57158fe..180e5eafb 100644 --- a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml +++ b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml @@ -32,7 +32,7 @@ spec: singular: kubernetes plural: kuberneteses openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"storageClass":{"description":"Default StorageClass inside the tenant cluster. Remote-accessible LINSTOR classes are auto-propagated to the tenant under the same name.","type":"string","default":"replicated","x-kubernetes-validations":[{"rule":"self == oldSelf","message":"storageClass is immutable"}],"x-cozystack-options":{"source":"storageclass"}},"version":{"description":"Kubernetes major.minor version to deploy","type":"string","default":"v1.35","enum":["v1.35","v1.34","v1.33","v1.32","v1.31"]},"host":{"description":"External hostname for Kubernetes cluster. Defaults to `.` if empty.","type":"string","default":""},"addons":{"description":"Cluster addons configuration.","type":"object","default":{},"required":["certManager","cilium","coredns","gatewayAPI","gpuOperator","hami","ingressNginx","monitoringAgents","ouroboros","velero","verticalPodAutoscaler"],"properties":{"certManager":{"description":"Cert-manager addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable cert-manager.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"cilium":{"description":"Cilium CNI plugin.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"coredns":{"description":"CoreDNS addon.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"gatewayAPI":{"description":"Gateway API addon.","type":"object","default":{},"required":["enabled"],"properties":{"enabled":{"description":"Enable Gateway API.","type":"boolean","default":false}}},"gpuOperator":{"description":"NVIDIA GPU Operator.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable GPU Operator.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"hami":{"description":"HAMi GPU virtualization middleware.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable HAMi (requires GPU Operator).","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"ingressNginx":{"description":"Ingress-NGINX controller.","type":"object","default":{},"required":["enabled","exposeMethod","valuesOverride"],"properties":{"enabled":{"description":"Enable the controller (requires nodes labeled `ingress-nginx`).","type":"boolean","default":false},"exposeMethod":{"description":"Method to expose the controller. Allowed values: `Proxied`, `LoadBalancer`.","type":"string","default":"Proxied","enum":["Proxied","LoadBalancer"]},"hosts":{"description":"Domains routed to this tenant cluster when `exposeMethod` is `Proxied`.","type":"array","default":[],"items":{"type":"string"}},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"monitoringAgents":{"description":"Monitoring agents.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable monitoring agents.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"ouroboros":{"description":"Hairpin-NAT fix for ingress-nginx with PROXY-protocol.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable ouroboros. Requires addons.ingressNginx.enabled (chart-render fail otherwise). Only useful when PROXY-protocol is wired on the tenant ingress-nginx via valuesOverride.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides. Operator-key wins over cozystack defaults.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"velero":{"description":"Velero backup/restore addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable Velero.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"verticalPodAutoscaler":{"description":"Vertical Pod Autoscaler.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}}}},"controlPlane":{"description":"Kubernetes control-plane configuration.","type":"object","default":{},"required":["apiServer","controllerManager","konnectivity","replicas","scheduler"],"properties":{"apiServer":{"description":"API Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"extraArgs":{"description":"Extra command-line flags appended to the tenant kube-apiserver, passed through to KamajiControlPlane `spec.apiServer.extraArgs`. For OIDC use `spec.oidc.mode` — this passthrough is the escape hatch for other apiserver flags (`--requestheader-uid-headers=X-Remote-Uid`, feature gates, etc). Do NOT add legacy `--oidc-*` flags here when `spec.oidc.mode` is not `None`; the chart injects `--authentication-config` and the apiserver refuses to boot with both. Empty by default (no change to current behavior).","type":"array","default":[],"items":{"type":"string"}},"extraVolumeMounts":{"description":"Extra volume mounts added to the tenant kube-apiserver container, passed through to KamajiControlPlane `spec.apiServer.extraVolumeMounts`. Each `name` must reference a volume declared in `extraVolumes`; the chart-managed talos secret volumes cannot be mounted. Each item is a core/v1 VolumeMount. Empty by default.","type":"array","default":[],"items":{"type":"object","x-kubernetes-preserve-unknown-fields":true}},"extraVolumes":{"description":"Extra volumes added to the control-plane Deployment, passed through to KamajiControlPlane `spec.deployment.extraVolumes`. Use to mount a ConfigMap or Secret holding an AuthenticationConfiguration file referenced by `extraArgs`. Each item is a core/v1 Volume, but because the control-plane pod runs on the management cluster only `configMap` and `secret` sources are allowed (host-reaching sources like `hostPath`/`csi` and token-projection via `projected` are rejected); each volume must have a unique, non-empty name and exactly one source. The names `talos-ca` and `talos-tls-cert` are reserved by the chart. Empty by default.","type":"array","default":[],"items":{"type":"object","x-kubernetes-preserve-unknown-fields":true}},"resources":{"description":"CPU and memory resources for API Server.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"c1.medium","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}},"controllerManager":{"description":"Controller Manager configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Controller Manager.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}},"konnectivity":{"description":"Konnectivity configuration.","type":"object","default":{},"required":["server"],"properties":{"server":{"description":"Konnectivity Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Konnectivity.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"replicas":{"description":"Number of control-plane replicas.","type":"integer","default":2},"scheduler":{"description":"Scheduler configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Scheduler.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"images":{"description":"Optional image overrides for air-gapped or rate-limited registries.","type":"object","default":{},"properties":{"kubectl":{"description":"Image used by the bootstrap-token tenant Job (kubectl). Empty falls back to images/kubectl.tag.","type":"string","default":""},"talosCsrSigner":{"description":"Image used by the talos-csr-signer sidecar in the Kamaji control plane. Empty falls back to images/talos-csr-signer.tag.","type":"string","default":""},"waitForKubeconfig":{"description":"Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag.","type":"string","default":""}}},"talos":{"description":"Talos worker image configuration.","type":"object","default":{},"required":["imageFactoryURL","installerRepository","registryMirrors","schematicID","version"],"properties":{"imageFactoryURL":{"description":"Base URL of the Talos Image Factory that serves the worker OS disk image (the `openstack-amd64.raw.xz` raw artifact streamed in by CDI over HTTP). Defaults to the public factory. Point at a self-hosted Image Factory, a caching mirror, or an internal HTTP file server for air-gapped, rate-limited, or flaky-egress environments. No trailing slash.","type":"string","default":"https://factory.talos.dev"},"installerRepository":{"description":"OCI repository prefix for the Talos installer image used by the in-guest `talos-reconcile` upgrade Job. Resolved as `/:`. Defaults to the public factory's installer path. Override for air-gapped or mirrored registries. No trailing slash.","type":"string","default":"factory.talos.dev/installer"},"registryMirrors":{"description":"Talos `machine.registries.mirrors` passthrough for worker nodes: a map of upstream registry host to `{ endpoints: [ ... ] }`. Empty by default, so workers pull container images (the Talos `kubelet` image included) directly from the upstream registry. Point a host such as `ghcr.io` at an in-cluster pull-through mirror for air-gapped, rate-limited, or flaky-egress environments so a worker's boot does not depend on live public egress. Talos still falls back to the upstream registry unless a host also sets `skipFallback: true`, so a mirror alone does not enforce air-gap.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true},"schematicID":{"description":"Talos image-factory schematic ID. Defaults to the cozystack-tested vanilla schematic. Operators using custom schematics (system extensions, kernel args) override here.","type":"string","default":"ce4c980550dd2ab1b17bbf2b08801c7eb59418eafe8f279833297925d67c7515"},"version":{"description":"Talos release used for worker OS image and installer. Must satisfy the chart's Talos<->Kubernetes support matrix against the chosen `version`.","type":"string","default":"v1.13.6"}}},"oidc":{"description":"OIDC authentication and per-user RBAC for the tenant kube-apiserver. See docs/oidc-tenant.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied AuthenticationConfiguration; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline AuthenticationConfiguration YAML (`apiserver.config.k8s.io/v1beta1`). The chart writes the value verbatim into a Secret mounted at `/etc/kubernetes/authentication-config/config.yaml` on the kube-apiserver. Mutually exclusive with `secretRef.name`.","type":"string","default":""},"secretRef":{"description":"Reference to an existing Secret in the tenant namespace carrying the `AuthenticationConfiguration`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the tenant (release) namespace whose `config.yaml` key holds an `apiserver.config.k8s.io/v1beta1` `AuthenticationConfiguration`. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform `cozy` realm via a per-cluster public client with 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 the tenant cluster; each entry produces one ClusterRoleBinding inside the tenant cluster. Works for both `System` and `CustomConfig` modes.","type":"array","default":[],"items":{"type":"object","required":["email","role"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. Used verbatim as the `User:` subject in the ClusterRoleBinding inside the tenant cluster. The `email` claim is a built-in OIDC scope requested explicitly by the chart-generated `kubectl oidc-login` kubeconfig; every conformant OIDC provider (cozy realm included) emits it.","type":"string"},"role":{"description":"Role to bind: `admin` maps to `ClusterRole/cluster-admin`, `view` maps to `ClusterRole/view`.","type":"string","enum":["admin","view"]}}}}}}}} + {"title":"Chart Values","type":"object","properties":{"storageClass":{"description":"Default StorageClass inside the tenant cluster. Remote-accessible LINSTOR classes are auto-propagated to the tenant under the same name.","type":"string","default":"replicated","x-kubernetes-validations":[{"rule":"self == oldSelf","message":"storageClass is immutable"}],"x-cozystack-options":{"source":"storageclass"}},"version":{"description":"Kubernetes major.minor version to deploy","type":"string","default":"v1.35","enum":["v1.35","v1.34","v1.33","v1.32","v1.31"]},"host":{"description":"External hostname for Kubernetes cluster. Defaults to `.` if empty.","type":"string","default":""},"addons":{"description":"Cluster addons configuration.","type":"object","default":{},"required":["certManager","cilium","coredns","gatewayAPI","gpuOperator","hami","ingressNginx","monitoringAgents","ouroboros","velero","verticalPodAutoscaler"],"properties":{"certManager":{"description":"Cert-manager addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable cert-manager.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"cilium":{"description":"Cilium CNI plugin.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"coredns":{"description":"CoreDNS addon.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"gatewayAPI":{"description":"Gateway API addon.","type":"object","default":{},"required":["enabled"],"properties":{"enabled":{"description":"Enable Gateway API.","type":"boolean","default":false}}},"gpuOperator":{"description":"NVIDIA GPU Operator.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable GPU Operator.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"hami":{"description":"HAMi GPU virtualization middleware.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable HAMi (requires GPU Operator).","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"ingressNginx":{"description":"Ingress-NGINX controller.","type":"object","default":{},"required":["enabled","exposeMethod","valuesOverride"],"properties":{"enabled":{"description":"Enable the controller (requires nodes labeled `ingress-nginx`).","type":"boolean","default":false},"exposeMethod":{"description":"Method to expose the controller. Allowed values: `Proxied`, `LoadBalancer`.","type":"string","default":"Proxied","enum":["Proxied","LoadBalancer"]},"hosts":{"description":"Domains routed to this tenant cluster when `exposeMethod` is `Proxied`.","type":"array","default":[],"items":{"type":"string"}},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"monitoringAgents":{"description":"Monitoring agents.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable monitoring agents.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"ouroboros":{"description":"Hairpin-NAT fix for ingress-nginx with PROXY-protocol.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable ouroboros. Requires addons.ingressNginx.enabled (chart-render fail otherwise). Only useful when PROXY-protocol is wired on the tenant ingress-nginx via valuesOverride.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides. Operator-key wins over cozystack defaults.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"velero":{"description":"Velero backup/restore addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable Velero.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"verticalPodAutoscaler":{"description":"Vertical Pod Autoscaler.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}}}},"controlPlane":{"description":"Kubernetes control-plane configuration.","type":"object","default":{},"required":["apiServer","controllerManager","konnectivity","replicas","scheduler"],"properties":{"apiServer":{"description":"API Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"extraArgs":{"description":"Extra command-line flags appended to the tenant kube-apiserver, passed through to KamajiControlPlane `spec.apiServer.extraArgs`. For OIDC use `spec.oidc.mode` — this passthrough is the escape hatch for other apiserver flags (`--requestheader-uid-headers=X-Remote-Uid`, feature gates, etc). Do NOT add legacy `--oidc-*` flags here when `spec.oidc.mode` is not `None`; the chart injects `--authentication-config` and the apiserver refuses to boot with both. Empty by default (no change to current behavior).","type":"array","default":[],"items":{"type":"string"}},"extraVolumeMounts":{"description":"Extra volume mounts added to the tenant kube-apiserver container, passed through to KamajiControlPlane `spec.apiServer.extraVolumeMounts`. Each `name` must reference a volume declared in `extraVolumes`; the chart-managed talos secret volumes cannot be mounted. Each item is a core/v1 VolumeMount. Empty by default.","type":"array","default":[],"items":{"type":"object","x-kubernetes-preserve-unknown-fields":true}},"extraVolumes":{"description":"Extra volumes added to the control-plane Deployment, passed through to KamajiControlPlane `spec.deployment.extraVolumes`. Use to mount a ConfigMap or Secret holding an AuthenticationConfiguration file referenced by `extraArgs`. Each item is a core/v1 Volume, but because the control-plane pod runs on the management cluster only `configMap` and `secret` sources are allowed (host-reaching sources like `hostPath`/`csi` and token-projection via `projected` are rejected); each volume must have a unique, non-empty name and exactly one source. The names `talos-ca` and `talos-tls-cert` are reserved by the chart. Empty by default.","type":"array","default":[],"items":{"type":"object","x-kubernetes-preserve-unknown-fields":true}},"resources":{"description":"CPU and memory resources for API Server.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"c1.medium","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}},"controllerManager":{"description":"Controller Manager configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Controller Manager.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}},"konnectivity":{"description":"Konnectivity configuration.","type":"object","default":{},"required":["server"],"properties":{"server":{"description":"Konnectivity Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Konnectivity.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"replicas":{"description":"Number of control-plane replicas.","type":"integer","default":2},"scheduler":{"description":"Scheduler configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Scheduler.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-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 (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"images":{"description":"Optional image overrides for air-gapped or rate-limited registries.","type":"object","default":{},"properties":{"kubectl":{"description":"Image used by the bootstrap-token tenant Job (kubectl). Empty falls back to images/kubectl.tag.","type":"string","default":""},"talosCsrSigner":{"description":"Image used by the talos-csr-signer sidecar in the Kamaji control plane. Empty falls back to images/talos-csr-signer.tag.","type":"string","default":""},"waitForKubeconfig":{"description":"Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag.","type":"string","default":""}}},"talos":{"description":"Talos worker image configuration.","type":"object","default":{},"required":["imageFactoryURL","installerRepository","registryMirrors","schematicID","version"],"properties":{"imageFactoryURL":{"description":"Base URL of the Talos Image Factory that serves the worker OS disk image (the `openstack-amd64.raw.xz` raw artifact streamed in by CDI over HTTP). Defaults to the public factory. Point at a self-hosted Image Factory, a caching mirror, or an internal HTTP file server for air-gapped, rate-limited, or flaky-egress environments. No trailing slash.","type":"string","default":"https://factory.talos.dev"},"installerRepository":{"description":"OCI repository prefix for the Talos installer image used by the in-guest `talos-reconcile` upgrade Job. Resolved as `/:`. Defaults to the public factory's installer path. Override for air-gapped or mirrored registries. No trailing slash.","type":"string","default":"factory.talos.dev/installer"},"registryMirrors":{"description":"Talos `machine.registries.mirrors` passthrough for worker nodes: a map of upstream registry host to `{ endpoints: [ ... ] }`. Empty by default, so workers pull container images (the Talos `kubelet` image included) directly from the upstream registry. Point a host such as `ghcr.io` at an in-cluster pull-through mirror for air-gapped, rate-limited, or flaky-egress environments so a worker's boot does not depend on live public egress. Talos still falls back to the upstream registry unless a host also sets `skipFallback: true`, so a mirror alone does not enforce air-gap.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true},"schematicID":{"description":"Talos image-factory schematic ID. Defaults to the cozystack-tested vanilla schematic. Operators using custom schematics (system extensions, kernel args) override here.","type":"string","default":"ce4c980550dd2ab1b17bbf2b08801c7eb59418eafe8f279833297925d67c7515"},"version":{"description":"Talos release used for worker OS image and installer. Must satisfy the chart's Talos<->Kubernetes support matrix against the chosen `version`.","type":"string","default":"v1.13.6"}}},"oidc":{"description":"OIDC authentication and per-user RBAC for the tenant kube-apiserver. See docs/oidc-tenant.md for the operator guide.","type":"object","default":{},"required":["mode"],"properties":{"customConfig":{"description":"Tenant-supplied AuthenticationConfiguration; consumed only when `mode: CustomConfig`.","type":"object","default":{},"properties":{"config":{"description":"Inline AuthenticationConfiguration YAML (`apiserver.config.k8s.io/v1beta1`). The chart writes the value verbatim into a Secret mounted at `/etc/kubernetes/authentication-config/config.yaml` on the kube-apiserver. Mutually exclusive with `secretRef.name`.","type":"string","default":""},"secretRef":{"description":"Reference to an existing Secret in the tenant namespace carrying the `AuthenticationConfiguration`.","type":"object","default":{},"properties":{"name":{"description":"Name of an existing Secret in the tenant (release) namespace whose `config.yaml` key holds an `apiserver.config.k8s.io/v1beta1` `AuthenticationConfiguration`. Mutually exclusive with `customConfig.config`.","type":"string","default":""}}}}},"mode":{"description":"Identity mode. `None`: no OIDC, only the static admin kubeconfig works. `System`: trust the platform Keycloak realm (`cozy` by default) via a per-cluster public client with 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 the tenant cluster; each entry produces one ClusterRoleBinding inside the tenant cluster. Works for both `System` and `CustomConfig` modes.","type":"array","default":[],"items":{"type":"object","required":["email","role"],"properties":{"email":{"description":"Email address matched against the `email` claim from the issuer. Used verbatim as the `User:` subject in the ClusterRoleBinding inside the tenant cluster. The `email` claim is a built-in OIDC scope requested explicitly by the chart-generated `kubectl oidc-login` kubeconfig; every conformant OIDC provider (cozy realm included) emits it.","type":"string"},"role":{"description":"Role to bind: `admin` maps to `ClusterRole/cluster-admin`, `view` maps to `ClusterRole/view`.","type":"string","enum":["admin","view"]}}}}}}}} release: prefix: kubernetes- labels: diff --git a/packages/system/linstor-gui/templates/gatekeeper.yaml b/packages/system/linstor-gui/templates/gatekeeper.yaml index 8bb172fc8..ef1c2521e 100644 --- a/packages/system/linstor-gui/templates/gatekeeper.yaml +++ b/packages/system/linstor-gui/templates/gatekeeper.yaml @@ -1,4 +1,5 @@ {{- $host := index .Values._cluster "root-host" }} +{{- $realm := index .Values._cluster "oidc-realm-name" | default "cozy" }} {{- $oidcEnabled := index .Values._cluster "oidc-enabled" }} {{- $oidcInsecureSkipVerify := index .Values._cluster "oidc-insecure-skip-verify" }} {{- $keycloakInternalUrl := index .Values._cluster "keycloak-internal-url" | default "" }} @@ -69,16 +70,16 @@ spec: - --upstream=http://linstor-gui.{{ .Release.Namespace }}.svc:80 - --http-address=0.0.0.0:8000 - --redirect-url=https://linstor-gui.{{ $host }}/oauth2/callback - - --oidc-issuer-url=https://keycloak.{{ $host }}/realms/cozy + - --oidc-issuer-url=https://keycloak.{{ $host }}/realms/{{ $realm }} {{- if $keycloakInternalUrl }} - --skip-oidc-discovery - - --login-url=https://keycloak.{{ $host }}/realms/cozy/protocol/openid-connect/auth + - --login-url=https://keycloak.{{ $host }}/realms/{{ $realm }}/protocol/openid-connect/auth - --redeem-url={{ $keycloakInternalUrl }}/protocol/openid-connect/token - --oidc-jwks-url={{ $keycloakInternalUrl }}/protocol/openid-connect/certs - --validate-url={{ $keycloakInternalUrl }}/protocol/openid-connect/userinfo - --backend-logout-url={{ $keycloakInternalUrl }}/protocol/openid-connect/logout?id_token_hint={id_token} {{- else }} - - --backend-logout-url=https://keycloak.{{ $host }}/realms/cozy/protocol/openid-connect/logout?id_token_hint={id_token} + - --backend-logout-url=https://keycloak.{{ $host }}/realms/{{ $realm }}/protocol/openid-connect/logout?id_token_hint={id_token} {{- end }} - --whitelist-domain=keycloak.{{ $host }} - --email-domain=* diff --git a/packages/system/linstor-gui/tests/ingress_auth_test.yaml b/packages/system/linstor-gui/tests/ingress_auth_test.yaml index c17868982..01c1807ec 100644 --- a/packages/system/linstor-gui/tests/ingress_auth_test.yaml +++ b/packages/system/linstor-gui/tests/ingress_auth_test.yaml @@ -298,3 +298,50 @@ tests: - documentIndex: 1 isKind: of: Secret + + # The realm segment of the browser-facing issuer/login/logout URLs is driven + # by _cluster.oidc-realm-name (authentication.oidc.realmName in the platform). + # A renamed realm must reach every one of them so SSO keeps matching the + # apiserver OIDC issuer. + - it: threads a custom realm name through the browser-facing OIDC URLs + template: templates/gatekeeper.yaml + set: + _cluster: + root-host: dev10.example.org + oidc-enabled: "true" + oidc-realm-name: acme + keycloak-internal-url: http://keycloak-http.cozy-keycloak.svc:8080/realms/acme + expose-services: "linstor-gui" + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --oidc-issuer-url=https://keycloak.dev10.example.org/realms/acme + - contains: + path: spec.template.spec.containers[0].args + content: --login-url=https://keycloak.dev10.example.org/realms/acme/protocol/openid-connect/auth + - notContains: + path: spec.template.spec.containers[0].args + content: --oidc-issuer-url=https://keycloak.dev10.example.org/realms/cozy + + # The external-discovery branch (empty internal URL) builds its own + # backend-logout-url on the browser host; its realm segment must follow + # oidc-realm-name too. + - it: threads a custom realm into the external-discovery logout URL + template: templates/gatekeeper.yaml + set: + _cluster: + root-host: dev10.example.org + oidc-enabled: "true" + oidc-realm-name: acme + keycloak-internal-url: "" + expose-services: "linstor-gui" + asserts: + - notContains: + path: spec.template.spec.containers[0].args + content: --skip-oidc-discovery + - contains: + path: spec.template.spec.containers[0].args + content: --backend-logout-url=https://keycloak.dev10.example.org/realms/acme/protocol/openid-connect/logout?id_token_hint={id_token} + - notContains: + path: spec.template.spec.containers[0].args + content: --backend-logout-url=https://keycloak.dev10.example.org/realms/cozy/protocol/openid-connect/logout?id_token_hint={id_token} diff --git a/packages/system/monitoring/templates/_helpers.tpl b/packages/system/monitoring/templates/_helpers.tpl index 3ce069dce..6e2878b07 100644 --- a/packages/system/monitoring/templates/_helpers.tpl +++ b/packages/system/monitoring/templates/_helpers.tpl @@ -44,7 +44,8 @@ {{- define "monitoring.oidc.systemIssuerURL" -}} {{- $host := index .Values._cluster "root-host" -}} -{{- printf "https://keycloak.%s/realms/cozy" $host -}} +{{- $realm := index .Values._cluster "oidc-realm-name" | default "cozy" | toString -}} +{{- printf "https://keycloak.%s/realms/%s" $host $realm -}} {{- end -}} {{- /* diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 7c694d990..32493552b 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -724,3 +724,21 @@ tests: asserts: - failedTemplate: errorMessage: "spec.oidc.users[0].role: \"Superuser\" is not one of the allowed values Admin, Editor, Viewer." + + # The grafana OIDC issuer realm is driven by _cluster.oidc-realm-name + # (authentication.oidc.realmName in the platform). A renamed realm must reach + # grafana's auth/token/userinfo URLs, or Grafana SSO breaks against a realm + # Keycloak no longer serves under the old name. + - it: mode=System grafana.yaml threads a custom realm name into the OIDC URLs + template: templates/grafana/grafana.yaml + set: + oidc.mode: System + _cluster.oidc-enabled: "true" + _cluster.oidc-realm-name: acme + oidc.users: + - email: alice@example.com + role: Admin + asserts: + - equal: + path: spec.config["auth.generic_oauth"].auth_url + value: "https://keycloak.example.org/realms/acme/protocol/openid-connect/auth" diff --git a/packages/system/monitoring/values.yaml b/packages/system/monitoring/values.yaml index 150365042..85156816c 100644 --- a/packages/system/monitoring/values.yaml +++ b/packages/system/monitoring/values.yaml @@ -104,7 +104,7 @@ vmagent: ## 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 +## System trust the platform Keycloak realm (`cozy` by default) via a ## per-instance confidential client and audience binding. ## Requires the platform-level authentication.oidc.enabled ## flag; the chart hard-fails the render otherwise.