diff --git a/charts/authup/README.md b/charts/authup/README.md index 570f2b9..84b75d8 100644 --- a/charts/authup/README.md +++ b/charts/authup/README.md @@ -405,6 +405,11 @@ Kubernetes: `>=1.25.0-0` | server.startupProbe.successThreshold | int | `1` | | | server.startupProbe.timeoutSeconds | int | `5` | | | server.terminationGracePeriodSeconds | int | `30` | Pod termination grace period (server-core tears down within ~10s after signal) | +| server.theme.enabled | bool | `false` | Mount an operator theme for the served consoles (the auth console and the account console). Requires an authup image that supports THEME_DIRECTORY_PATH; older images ignore it | +| server.theme.existingConfigMap | string | `""` | Existing ConfigMap holding the theme (tpl-rendered name). Use for binary assets, which cannot be expressed in files | +| server.theme.existingConfigMapItems | list | `[]` | Key -> path projection for existingConfigMap, so its keys can land in subdirectories (e.g. [{key: theme-css, path: assets/theme.css}]). Empty mounts every key flat at the theme root | +| server.theme.files | object | `{}` | Map of path -> file content, relative to the theme root (tpl-rendered). Keys may carry a "/" ("assets/theme.css") and are projected into subdirectories. Only assets/ is served over HTTP. Text only — use existingConfigMap with binaryData for images | +| server.theme.fragmentsEnabled | bool | `false` | Read fragments/head.html and splice it into the console . Raw, unsanitized markup on the identity provider origin, so it is opt-in | | server.tolerations | list | `[]` | Tolerations | | server.topologySpreadConstraints | list | `[]` | Topology spread constraints (a missing labelSelector is filled with the pod's selector labels) | | server.trustProxy | string | `"1"` | TRUST_PROXY setting. The chart defaults to one trusted hop (the ingress), not authup's spoofable trust-everything default | diff --git a/charts/authup/ci/theme-values.yaml b/charts/authup/ci/theme-values.yaml new file mode 100644 index 0000000..e44d962 --- /dev/null +++ b/charts/authup/ci/theme-values.yaml @@ -0,0 +1,28 @@ +# Operator theme mounted from an inline files map, covering the path-keyed +# ConfigMap projection (a "/" key must land in a subdirectory) and the env +# wiring. THEME_DIRECTORY_PATH is ignored by an authup image that predates +# console theming, so the rollout assertion holds either way. +server: + theme: + enabled: true + fragmentsEnabled: true + files: + theme.json: | + { + "version": 1, + "title": "Sign in to ACME", + "stylesheet": "assets/theme.css", + "tokens": { + "--authup-periwinkle": "#c0392b" + }, + "tokensDark": { + "--authup-auth-accent": "#e06c5a" + } + } + assets/theme.css: | + .a-auth-shell-card { border-radius: 2px; } + fragments/head.html: | + + +adminConsole: + enabled: false diff --git a/charts/authup/templates/_server-env.tpl b/charts/authup/templates/_server-env.tpl index 2d0dadb..d5e71db 100644 --- a/charts/authup/templates/_server-env.tpl +++ b/charts/authup/templates/_server-env.tpl @@ -6,6 +6,7 @@ Strict-boolean variables are always quoted: authup crashes the boot on unparsable boolean strings by design. */}} {{- define "authup.server.configEnv" -}} +{{- include "authup.server.validateTheme" . -}} DB_TYPE: {{ include "authup.database.type" . | quote }} DB_HOST: {{ include "authup.database.host" . | quote }} DB_PORT: {{ include "authup.database.port" . | quote }} @@ -36,7 +37,7 @@ CLIENT_SYSTEM_ENABLED: "true" CLIENT_SYSTEM_SECRET_RESET: "true" {{- end }} {{- end }} -{{- $reserved := list "DB_TYPE" "DB_HOST" "DB_PORT" "DB_USERNAME" "DB_DATABASE" "DB_PASSWORD" "PUBLIC_URL" "TRUSTED_ORIGINS" "TRUST_PROXY" "REGISTRATION_ENABLED" "PASSWORD_RECOVERY_ENABLED" "EMAIL_VERIFICATION_ENABLED" "MFA_ENABLED" "MFA_REQUIRED" "USER_ADMIN_PASSWORD" "USER_ADMIN_PASSWORD_RESET" "CLIENT_SYSTEM_ENABLED" "CLIENT_SYSTEM_SECRET" "CLIENT_SYSTEM_SECRET_RESET" "REDIS" "SMTP" "SECRETS_ENCRYPTION_KEY" }} +{{- $reserved := list "DB_TYPE" "DB_HOST" "DB_PORT" "DB_USERNAME" "DB_DATABASE" "DB_PASSWORD" "PUBLIC_URL" "TRUSTED_ORIGINS" "TRUST_PROXY" "REGISTRATION_ENABLED" "PASSWORD_RECOVERY_ENABLED" "EMAIL_VERIFICATION_ENABLED" "MFA_ENABLED" "MFA_REQUIRED" "THEME_DIRECTORY_PATH" "THEME_FRAGMENTS_ENABLED" "USER_ADMIN_PASSWORD" "USER_ADMIN_PASSWORD_RESET" "CLIENT_SYSTEM_ENABLED" "CLIENT_SYSTEM_SECRET" "CLIENT_SYSTEM_SECRET_RESET" "REDIS" "SMTP" "SECRETS_ENCRYPTION_KEY" }} {{- range $key, $value := .Values.server.config }} {{- if has $key $reserved }} {{- fail (printf "authup: server.config.%s collides with a first-class chart value — set it through the dedicated value instead." $key) }} @@ -138,6 +139,134 @@ provisioning files, config file). {{- end }} {{- end -}} +{{/* +Theme volume / volumeMount, deliberately NOT part of the shared server +helpers: the migration Job is a pre-upgrade HOOK, and hooks precede regular +resources, so on the upgrade that first enables theming it would reference a +ConfigMap that does not exist yet and hang. A migration run has no use for +the theme either way. +*/}} +{{/* +Theme environment, kept OUT of authup.server.configEnv for the same reason +as the volume: the migration Job inlines configEnv, and pointing +THEME_DIRECTORY_PATH at a directory that Job does not mount would describe +a pod that does not exist. Nothing reads it there today (the migration +command boots only config + logger, never the http module), but the env +should not contradict the pod it is in. + +THEME_* stays in configEnv's reserved-key list regardless, so a +`server.config` entry cannot emit a duplicate key into the same ConfigMap. +*/}} +{{- define "authup.server.themeEnv" -}} +{{- if include "authup.server.themeMounted" . }} +THEME_DIRECTORY_PATH: {{ include "authup.server.themeMountPath" . | quote }} +THEME_FRAGMENTS_ENABLED: {{ .Values.server.theme.fragmentsEnabled | toString | quote }} +{{- end }} +{{- end -}} + +{{- define "authup.server.themeVolumeMounts" -}} +{{- if include "authup.server.themeMounted" . }} +- name: theme + mountPath: {{ include "authup.server.themeMountPath" . }} + readOnly: true +{{- end }} +{{- end -}} + +{{- define "authup.server.themeVolumes" -}} +{{- if include "authup.server.themeMounted" . }} +- name: theme + configMap: + name: {{ include "authup.server.themeConfigMapName" . }} + {{- /* Whole-volume projection on purpose: a subPath mount is frozen + until the pod restarts, which would destroy authup's live theme + reload. */}} + {{- if .Values.server.theme.existingConfigMap }} + {{- with .Values.server.theme.existingConfigMapItems }} + items: {{- include "authup.tplvalues.render" (dict "value" . "context" $) | nindent 6 }} + {{- end }} + {{- else }} + items: + {{- range $path, $content := .Values.server.theme.files }} + - key: {{ include "authup.server.themeConfigMapKey" $path }} + path: {{ $path }} + {{- end }} + {{- end }} +{{- end }} +{{- end -}} + +{{/* +Absolute path the theme volume is mounted at, and the value of +THEME_DIRECTORY_PATH. A constant: the chart owns both ends. +*/}} +{{- define "authup.server.themeMountPath" -}} +/etc/authup/theme +{{- end -}} + +{{/* +Flatten a theme path into a valid ConfigMap key ("/" is not allowed in one). +The volume's items list projects it back, so the encoding never reaches the +operator. +*/}} +{{- define "authup.server.themeConfigMapKey" -}} +{{- . | replace "/" "__" -}} +{{- end -}} + +{{- define "authup.server.themeConfigMapName" -}} +{{- if .Values.server.theme.existingConfigMap -}} +{{- include "authup.tplvalues.render" (dict "value" .Values.server.theme.existingConfigMap "context" $) -}} +{{- else -}} +{{- printf "%s-theme" (include "authup.server.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{/* +True when a theme should be mounted at all. +*/}} +{{- define "authup.server.themeMounted" -}} +{{- if and .Values.server.theme.enabled (or .Values.server.theme.files .Values.server.theme.existingConfigMap) -}} +true +{{- end -}} +{{- end -}} + +{{/* +Render-time validation. The chart fails loud rather than shipping a +silently-inert theme: the dominant failure mode of theming is a page that +looks exactly like an un-themed page. +*/}} +{{- define "authup.server.validateTheme" -}} +{{- if .Values.server.theme.enabled }} +{{- if not (or .Values.server.theme.files .Values.server.theme.existingConfigMap) }} +{{- fail "authup: server.theme.enabled requires server.theme.files or server.theme.existingConfigMap — an empty theme directory would render an un-themed page with no error." }} +{{- end }} +{{- if and .Values.server.theme.files .Values.server.theme.existingConfigMap }} +{{- fail "authup: set either server.theme.files or server.theme.existingConfigMap, not both — the existing ConfigMap would win and the inline files would be silently ignored." }} +{{- end }} +{{- range $path, $content := .Values.server.theme.files }} +{{- if hasPrefix "/" $path }} +{{- fail (printf "authup: server.theme.files key %q must be relative to the theme root." $path) }} +{{- end }} +{{- if contains ".." $path }} +{{- fail (printf "authup: server.theme.files key %q must not traverse out of the theme root." $path) }} +{{- end }} +{{- if contains "__" $path }} +{{- fail (printf "authup: server.theme.files key %q must not contain \"__\" — it is reserved for encoding the path separator into a ConfigMap key." $path) }} +{{- end }} +{{- /* A ConfigMap data key must match ^[A-Za-z0-9._-]+$, so a path + carrying a space, a colon or any other character outside this set + would flatten into an INVALID key and fail at apply time with a + Kubernetes validation error instead of here. The set is the one the + server's own asset handler accepts, so the chart now rejects at + render time exactly what the server would 404 at request time. */}} +{{- if not (regexMatch "^[a-zA-Z0-9][a-zA-Z0-9._/-]*$" $path) }} +{{- fail (printf "authup: server.theme.files key %q must start with a letter or digit and contain only letters, digits, \".\", \"_\", \"-\" and \"/\"." $path) }} +{{- end }} +{{- end }} +{{- end }} +{{- if and .Values.server.theme.existingConfigMapItems (not .Values.server.theme.existingConfigMap) }} +{{- fail "authup: server.theme.existingConfigMapItems requires server.theme.existingConfigMap." }} +{{- end }} +{{- end -}} + {{- define "authup.server.provisioningConfigMapName" -}} {{- if .Values.server.provisioning.existingConfigMap -}} {{- include "authup.tplvalues.render" (dict "value" .Values.server.provisioning.existingConfigMap "context" $) -}} diff --git a/charts/authup/templates/server/configmap-env.yaml b/charts/authup/templates/server/configmap-env.yaml index 4607efe..27176cd 100644 --- a/charts/authup/templates/server/configmap-env.yaml +++ b/charts/authup/templates/server/configmap-env.yaml @@ -7,4 +7,10 @@ metadata: labels: {{- include "authup.labels" (dict "context" $ "component" "server") | nindent 4 }} annotations: {{- include "authup.annotations" (dict "context" $) | nindent 4 }} data: {{- include "authup.server.configEnv" . | nindent 2 }} + {{- /* Only the Deployment consumes this ConfigMap (envFrom); the + migration Job inlines configEnv instead, so it never sees the + theme env. See authup.server.themeEnv. */}} + {{- if include "authup.server.themeMounted" . }} + {{- include "authup.server.themeEnv" . | nindent 2 }} + {{- end }} {{- end }} diff --git a/charts/authup/templates/server/configmap-theme.yaml b/charts/authup/templates/server/configmap-theme.yaml new file mode 100644 index 0000000..59d7169 --- /dev/null +++ b/charts/authup/templates/server/configmap-theme.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.server.enabled .Values.server.theme.enabled .Values.server.theme.files (not .Values.server.theme.existingConfigMap) }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ printf "%s-theme" (include "authup.server.fullname" .) }} + namespace: {{ include "authup.namespace" . | quote }} + labels: {{- include "authup.labels" (dict "context" $ "component" "server") | nindent 4 }} + annotations: {{- include "authup.annotations" (dict "context" $) | nindent 4 }} +data: + {{- /* A ConfigMap key cannot contain "/", but the theme layout has + subdirectories. Keys are flattened here and projected back to their + real path by the volume's items list, so the operator writes + "assets/theme.css" and never sees the encoding. */}} + {{- range $path, $content := .Values.server.theme.files }} + {{ include "authup.server.themeConfigMapKey" $path }}: |- + {{- include "authup.tplvalues.render" (dict "value" $content "context" $) | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/authup/templates/server/deployment.yaml b/charts/authup/templates/server/deployment.yaml index 8b60e9d..684b648 100644 --- a/charts/authup/templates/server/deployment.yaml +++ b/charts/authup/templates/server/deployment.yaml @@ -24,7 +24,11 @@ spec: {{- end }} annotations: {{- if not .Values.server.disableRestartOnChanges }} - checksum/env: {{ include "authup.server.configEnv" . | sha256sum }} + {{- /* Both halves of the env ConfigMap: themeEnv lives outside + configEnv (the migration Job must not inherit it), so hashing + configEnv alone would leave a fragmentsEnabled flip invisible + to the running pods. */}} + checksum/env: {{ printf "%s%s" (include "authup.server.configEnv" .) (include "authup.server.themeEnv" .) | sha256sum }} {{- if include "authup.auth.createSecret" . }} checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} {{- end }} @@ -40,6 +44,9 @@ spec: {{- if and .Values.server.provisioning.enabled .Values.server.provisioning.files }} checksum/provisioning: {{ include (print $.Template.BasePath "/server/configmap-provisioning.yaml") . | sha256sum }} {{- end }} + {{- if and .Values.server.theme.enabled .Values.server.theme.files }} + checksum/theme: {{ include (print $.Template.BasePath "/server/configmap-theme.yaml") . | sha256sum }} + {{- end }} {{- if .Values.server.configuration }} checksum/configuration: {{ include (print $.Template.BasePath "/server/configmap-configuration.yaml") . | sha256sum }} {{- end }} @@ -158,6 +165,9 @@ spec: lifecycle: {{- include "authup.tplvalues.render" (dict "value" .Values.server.lifecycleHooks "context" $) | nindent 12 }} {{- end }} volumeMounts: {{- include "authup.server.volumeMounts" . | nindent 12 }} + {{- if include "authup.server.themeMounted" . }} + {{- include "authup.server.themeVolumeMounts" . | nindent 12 }} + {{- end }} {{- if .Values.server.extraVolumeMounts }} {{- include "authup.tplvalues.render" (dict "value" .Values.server.extraVolumeMounts "context" $) | nindent 12 }} {{- end }} @@ -165,6 +175,9 @@ spec: {{- include "authup.tplvalues.render" (dict "value" .Values.server.sidecars "context" $) | nindent 8 }} {{- end }} volumes: {{- include "authup.server.volumes" . | nindent 8 }} + {{- if include "authup.server.themeMounted" . }} + {{- include "authup.server.themeVolumes" . | nindent 8 }} + {{- end }} {{- if .Values.server.extraVolumes }} {{- include "authup.tplvalues.render" (dict "value" .Values.server.extraVolumes "context" $) | nindent 8 }} {{- end }} diff --git a/charts/authup/values.schema.json b/charts/authup/values.schema.json index 1d15b12..2143ba7 100644 --- a/charts/authup/values.schema.json +++ b/charts/authup/values.schema.json @@ -3306,6 +3306,55 @@ "title": "terminationGracePeriodSeconds", "type": "integer" }, + "theme": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": false, + "description": "Mount an operator theme for the served consoles (the auth console and\nthe account console). Requires an authup image that supports\nTHEME_DIRECTORY_PATH; older images ignore it", + "required": [], + "title": "enabled", + "type": "boolean" + }, + "existingConfigMap": { + "default": "", + "description": "Existing ConfigMap holding the theme (tpl-rendered name). Use for\nbinary assets, which cannot be expressed in files", + "required": [], + "title": "existingConfigMap", + "type": "string" + }, + "existingConfigMapItems": { + "description": "Key -\u003e path projection for existingConfigMap, so its keys can land in\nsubdirectories (e.g. [{key: theme-css, path: assets/theme.css}]).\nEmpty mounts every key flat at the theme root", + "items": { + "required": [] + }, + "required": [], + "title": "existingConfigMapItems", + "type": "array" + }, + "files": { + "additionalProperties": true, + "description": "Map of path -\u003e file content, relative to the theme root (tpl-rendered).\nKeys may carry a \"/\" (\"assets/theme.css\") and are projected into\nsubdirectories. Only assets/ is served over HTTP. Text only — use\nexistingConfigMap with binaryData for images", + "required": [], + "title": "files" + }, + "fragmentsEnabled": { + "default": false, + "description": "Read fragments/head.html and splice it into the console \u003chead\u003e. Raw,\nunsanitized markup on the identity provider origin, so it is opt-in", + "required": [], + "title": "fragmentsEnabled", + "type": "boolean" + } + }, + "required": [ + "enabled", + "existingConfigMap", + "existingConfigMapItems", + "fragmentsEnabled" + ], + "title": "theme", + "type": "object" + }, "tolerations": { "description": "Tolerations", "items": { @@ -3376,6 +3425,7 @@ "configuration", "existingConfigmap", "provisioning", + "theme", "migration", "command", "args", diff --git a/charts/authup/values.yaml b/charts/authup/values.yaml index 2cab0e8..5917134 100644 --- a/charts/authup/values.yaml +++ b/charts/authup/values.yaml @@ -451,6 +451,29 @@ server: # -- Existing Secret with provisioning files (tpl-rendered; takes # precedence — use for provisioning content that carries credentials) existingSecret: "" + theme: + # -- Mount an operator theme for the served consoles (the auth console and + # the account console). Requires an authup image that supports + # THEME_DIRECTORY_PATH; older images ignore it + enabled: false + # @schema + # additionalProperties: true + # @schema + # -- Map of path -> file content, relative to the theme root (tpl-rendered). + # Keys may carry a "/" ("assets/theme.css") and are projected into + # subdirectories. Only assets/ is served over HTTP. Text only — use + # existingConfigMap with binaryData for images + files: {} + # -- Existing ConfigMap holding the theme (tpl-rendered name). Use for + # binary assets, which cannot be expressed in files + existingConfigMap: "" + # -- Key -> path projection for existingConfigMap, so its keys can land in + # subdirectories (e.g. [{key: theme-css, path: assets/theme.css}]). + # Empty mounts every key flat at the theme root + existingConfigMapItems: [] + # -- Read fragments/head.html and splice it into the console . Raw, + # unsanitized markup on the identity provider origin, so it is opt-in + fragmentsEnabled: false migration: # -- Run `server/core migration run` as a pre-upgrade hook Job. Recommended # for multi-replica deployments (serializes DDL before pods roll). Fresh