diff --git a/.agents/architecture.md b/.agents/architecture.md index 8104d98..92acbb2 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -91,6 +91,22 @@ editing templates or values. `authup.tplvalues.render`, so umbrella charts can inject template expressions (the PrivateAIM lesson: their untemplatable `existingSecret` forced a hardcoded-names table). +17. **`global` must stay open in the schema.** helm copies a parent chart's + ENTIRE `global` map into every subchart before validating that subchart's + schema, so `additionalProperties: false` there makes the chart + uninstallable as a dependency of any umbrella that sets a global this + chart does not declare. `values.yaml` carries the + `# @schema additionalProperties: true` opt-out and `ci/default-values.yaml` + a stray global key as the regression guard. The chart reads only + `imageRegistry` / `imagePullSecrets` / `defaultStorageClass` and ignores + the rest. +18. **An HTTPRoute rule with no `matches` is a catch-all.** The Gateway API + defaults an empty `matches` to PathPrefix `/`, and route hostnames come + from the public URL's ORIGIN (the path is dropped), so a sub-path + deployment would silently take over the whole shared hostname. + `validations.yaml` fails that combination; `route.matches` / `route.filters` + are the raw passthroughs that express it (authup always serves at `/`, so + the prefix must be matched AND rewritten away). ## Values conventions diff --git a/.agents/testing.md b/.agents/testing.md index 5533e4d..dbe2769 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -52,8 +52,21 @@ helm template t charts/authup --set server.theme.enabled=true --set server.theme helm template t charts/authup --set server.theme.enabled=true --set server.theme.logo=assets/logo.svg # asset missing from files helm template t charts/authup --set server.theme.enabled=true --set 'server.theme.tokens.--authup-bg=url(x)' # token value authup rejects helm template t charts/authup --set 'server.trustedOrigins[0]=https://**.x' # globstar host +helm template t charts/authup --set server.route.enabled=true --set server.publicUrl=https://h.x/auth # sub-path route without matches +helm template t charts/authup --set server.route.enabled=true --set server.ingress.enabled=true \ + --set server.ingress.hostname=h.x --set server.ingress.path=/auth # same, via the derived URL ``` +The route guard reads the public URL AFTER derivation, so the ingress-derived +case needs its own line: only the origin reaches the HTTPRoute hostname, and the +dropped path is exactly what turns the rule into a catch-all. Adding +`--set 'server.route.matches[0].path.value=/auth'` must make both RENDER. + +Umbrella use is part of the contract: `global` must stay open. Render a throwaway +parent chart with authup in `charts/` and an unrelated global (`global.myOrgKey`) +whenever the schema generation changes; `ci/default-values.yaml` carries a stray +global key as the cheap in-repo version of that check. + A single `*` host wildcard (`https://*.example.com`) must still RENDER: authup supports it, only `**` is the allow-any-origin trap. diff --git a/charts/authup/Chart.yaml b/charts/authup/Chart.yaml index 20df41a..fe19e57 100644 --- a/charts/authup/Chart.yaml +++ b/charts/authup/Chart.yaml @@ -31,11 +31,9 @@ annotations: - name: Source url: https://github.com/authup/helm artifacthub.io/changes: | - - kind: changed - description: appVersion tracks authup 1.0.0-beta.62 + - kind: fixed + description: A parent chart's own global.* keys no longer fail schema validation - kind: added - description: server.theme manifest values compose theme.json (title, logo, tokens) + description: server.route / adminConsole.route accept matches and filters - kind: added - description: server.features.accountConsole toggles the /account self-service console - - kind: added - description: Trusted origins carrying "**" now fail the render, as authup fails the boot + description: An HTTPRoute for a sub-path public URL without matches now fails the render diff --git a/charts/authup/README.md b/charts/authup/README.md index d695c2b..e61e792 100644 --- a/charts/authup/README.md +++ b/charts/authup/README.md @@ -187,7 +187,7 @@ Kubernetes: `>=1.25.0-0` | adminConsole.ingress.enabled | bool | `false` | Enable ingress for the UI | | adminConsole.ingress.extraHosts | list | `[]` | Extra hosts | | adminConsole.ingress.extraPaths | list | `[]` | Extra paths for the primary host | -| adminConsole.ingress.extraRules | list | `[]` | Full custom rules (tpl-rendered; overrides the generated rule) | +| adminConsole.ingress.extraRules | list | `[]` | Full custom rules (tpl-rendered; appended after the generated rules) | | adminConsole.ingress.extraTls | list | `[]` | Extra TLS entries | | adminConsole.ingress.hostname | string | `""` | Ingress hostname (tpl-rendered); also drives the derived UI public URL | | adminConsole.ingress.ingressClassName | string | `""` | Ingress class name | @@ -231,7 +231,9 @@ Kubernetes: `>=1.25.0-0` | adminConsole.revisionHistoryLimit | int | `3` | Deployment revision history limit | | adminConsole.route.annotations | object | `{}` | HTTPRoute annotations | | adminConsole.route.enabled | bool | `false` | Create a Gateway API HTTPRoute for the UI | -| adminConsole.route.hostnames | list | `[]` | Route hostnames ([] = derived from adminConsole.publicUrl / ingress hostname) | +| adminConsole.route.filters | list | `[]` | Rule filters (tpl-rendered), e.g. a URLRewrite stripping a path prefix | +| adminConsole.route.hostnames | list | `[]` | Route hostnames ([] = derived from adminConsole.publicUrl / ingress hostname; only the host is kept, a public URL path is dropped and needs its own matches entry) | +| adminConsole.route.matches | list | `[]` | Rule matches (tpl-rendered); [] is the Gateway API default, PathPrefix "/" | | adminConsole.route.parentRefs | list | `[]` | Gateway parentRefs | | adminConsole.schedulerName | string | `""` | Scheduler name | | adminConsole.service.annotations | object | `{}` | Service annotations (tpl-rendered) | @@ -369,7 +371,7 @@ Kubernetes: `>=1.25.0-0` | server.ingress.enabled | bool | `false` | Enable ingress for server-core. NOTE: this also exposes the UNAUTHENTICATED /metrics endpoint publicly — block it at the ingress controller or disable it via server.configuration ("middlewarePrometheus: false") when it is not scraped | | server.ingress.extraHosts | list | `[]` | Extra hosts | | server.ingress.extraPaths | list | `[]` | Extra paths for the primary host | -| server.ingress.extraRules | list | `[]` | Full custom rules (tpl-rendered; overrides the generated rule) | +| server.ingress.extraRules | list | `[]` | Full custom rules (tpl-rendered; appended after the generated rules) | | server.ingress.extraTls | list | `[]` | Extra TLS entries | | server.ingress.hostname | string | `""` | Ingress hostname (tpl-rendered); also drives the derived PUBLIC_URL | | server.ingress.ingressClassName | string | `""` | Ingress class name | @@ -433,7 +435,9 @@ Kubernetes: `>=1.25.0-0` | server.revisionHistoryLimit | int | `3` | Deployment revision history limit | | server.route.annotations | object | `{}` | HTTPRoute annotations | | server.route.enabled | bool | `false` | Create a Gateway API HTTPRoute for server-core | -| server.route.hostnames | list | `[]` | Route hostnames ([] = derived from server.publicUrl / ingress hostname) | +| server.route.filters | list | `[]` | Rule filters (tpl-rendered), e.g. a URLRewrite stripping a path prefix | +| server.route.hostnames | list | `[]` | Route hostnames ([] = derived from server.publicUrl / ingress hostname; only the host is kept, a public URL path is dropped and needs its own matches entry) | +| server.route.matches | list | `[]` | Rule matches (tpl-rendered); [] is the Gateway API default, PathPrefix "/" | | server.route.parentRefs | list | `[]` | Gateway parentRefs | | server.schedulerName | string | `""` | Scheduler name | | server.service.annotations | object | `{}` | Service annotations (tpl-rendered) | diff --git a/charts/authup/ci/default-values.yaml b/charts/authup/ci/default-values.yaml index 8a84938..ec449d4 100644 --- a/charts/authup/ci/default-values.yaml +++ b/charts/authup/ci/default-values.yaml @@ -1,4 +1,9 @@ # Baseline: built-in PostgreSQL, both services. +# The stray global key is a regression guard: helm copies a parent chart's whole +# global map into every subchart before schema validation, so the schema must not +# reject globals this chart does not declare. +global: + umbrellaProbe: parent-injected postgresql: persistence: enabled: false diff --git a/charts/authup/templates/NOTES.txt b/charts/authup/templates/NOTES.txt index b6c6d42..19a529e 100644 --- a/charts/authup/templates/NOTES.txt +++ b/charts/authup/templates/NOTES.txt @@ -98,6 +98,22 @@ small deployments. For production, consider an external database (externalDatabase.*) or an operator (e.g. CloudNativePG). {{- end }} +{{- $prefixed := list }} +{{- if and .Values.server.enabled .Values.server.route.enabled (urlParse $serverUrl).path (ne (urlParse $serverUrl).path "/") }} +{{- $prefixed = append $prefixed (printf "server.route %s" (urlParse $serverUrl).path) }} +{{- end }} +{{- if and .Values.adminConsole.enabled .Values.adminConsole.route.enabled (urlParse $uiUrl).path (ne (urlParse $uiUrl).path "/") }} +{{- $prefixed = append $prefixed (printf "adminConsole.route %s" (urlParse $uiUrl).path) }} +{{- end }} +{{- if $prefixed }} + +NOTE: serving under a path prefix ({{ join ", " $prefixed }}). authup itself always +serves at "/", so something in the chain has to strip the prefix: a route.filters +URLRewrite (ReplacePrefixMatch "/"), an implementation-specific filter, or a proxy +in front of the gateway. Without one, every request reaches the pod with the prefix +still attached and 404s. +{{- end }} + {{- if $serverUrl }} Changing PUBLIC_URL later breaks enrolled WebAuthn credentials (the rpId binds diff --git a/charts/authup/templates/_ingress.tpl b/charts/authup/templates/_ingress.tpl index ca08d28..4d75ce7 100644 --- a/charts/authup/templates/_ingress.tpl +++ b/charts/authup/templates/_ingress.tpl @@ -113,4 +113,10 @@ spec: - backendRefs: - name: {{ .serviceName }} port: {{ .servicePort }} + {{- if $route.matches }} + matches: {{- include "authup.tplvalues.render" (dict "value" $route.matches "context" $ctx) | nindent 8 }} + {{- end }} + {{- if $route.filters }} + filters: {{- include "authup.tplvalues.render" (dict "value" $route.filters "context" $ctx) | nindent 8 }} + {{- end }} {{- end -}} diff --git a/charts/authup/templates/validations.yaml b/charts/authup/templates/validations.yaml index dbbbed6..ffac23c 100644 --- a/charts/authup/templates/validations.yaml +++ b/charts/authup/templates/validations.yaml @@ -50,6 +50,26 @@ store, token blocklist and MFA challenges fall back to a per-process memory cach {{- fail "authup: adminConsole.ingress.enabled requires adminConsole.ingress.hostname (or extraHosts / extraRules)." }} {{- end }} +{{/* +A Gateway API HTTPRoute rule without `matches` defaults to PathPrefix "/", i.e. it +matches every request on every hostname the route attaches to. The route hostname is +derived from the public URL's ORIGIN, so a sub-path public URL would silently attach +authup as the catch-all backend of a hostname it is meant to share. authup always +serves at "/" and expects the proxy to strip the prefix, hence match AND rewrite. +*/}} +{{- if and .Values.server.enabled .Values.server.route.enabled (not .Values.server.route.matches) }} +{{- $path := (urlParse (include "authup.server.publicUrl" .)).path }} +{{- if and $path (ne $path "/") }} +{{- fail (printf "authup: the server public URL carries the path %q but server.route.matches is empty, which the Gateway API defaults to PathPrefix \"/\" (a catch-all on every hostname this route attaches to). authup serves at \"/\" and expects the proxy to strip the prefix, so set both:\n server.route.matches: [{path: {type: PathPrefix, value: %s}}]\n server.route.filters: [{type: URLRewrite, urlRewrite: {path: {type: ReplacePrefixMatch, replacePrefixMatch: /}}}]" $path $path) }} +{{- end }} +{{- end }} +{{- if and .Values.adminConsole.enabled .Values.adminConsole.route.enabled (not .Values.adminConsole.route.matches) }} +{{- $path := (urlParse (include "authup.adminConsole.publicUrl" .)).path }} +{{- if and $path (ne $path "/") }} +{{- fail (printf "authup: the admin console public URL carries the path %q but adminConsole.route.matches is empty, which the Gateway API defaults to PathPrefix \"/\" (a catch-all on every hostname this route attaches to). Set both:\n adminConsole.route.matches: [{path: {type: PathPrefix, value: %s}}]\n adminConsole.route.filters: [{type: URLRewrite, urlRewrite: {path: {type: ReplacePrefixMatch, replacePrefixMatch: /}}}]" $path $path) }} +{{- end }} +{{- end }} + {{/* URL values must carry a scheme — a scheme-less value would derive a broken "://" origin into TRUSTED_ORIGINS and crash-loop server-core. */}} {{- range $key, $value := dict "server.publicUrl" .Values.server.publicUrl "adminConsole.publicUrl" .Values.adminConsole.publicUrl "adminConsole.apiUrl" .Values.adminConsole.apiUrl "adminConsole.internalApiUrl" .Values.adminConsole.internalApiUrl }} {{- if and $value (not (contains "{{" $value)) (not (regexMatch "^https?://" $value)) }} diff --git a/charts/authup/values.schema.json b/charts/authup/values.schema.json index a6c28cc..608aa7f 100644 --- a/charts/authup/values.schema.json +++ b/charts/authup/values.schema.json @@ -312,7 +312,7 @@ "type": "array" }, "extraRules": { - "description": "Full custom rules (tpl-rendered; overrides the generated rule)", + "description": "Full custom rules (tpl-rendered; appended after the generated rules)", "items": { "required": [] }, @@ -742,8 +742,17 @@ "title": "enabled", "type": "boolean" }, + "filters": { + "description": "Rule filters (tpl-rendered), e.g. a URLRewrite stripping a path prefix", + "items": { + "required": [] + }, + "required": [], + "title": "filters", + "type": "array" + }, "hostnames": { - "description": "Route hostnames ([] = derived from adminConsole.publicUrl / ingress hostname)", + "description": "Route hostnames ([] = derived from adminConsole.publicUrl / ingress hostname; only the host is kept, a public\nURL path is dropped and needs its own matches entry)", "items": { "required": [] }, @@ -751,6 +760,15 @@ "title": "hostnames", "type": "array" }, + "matches": { + "description": "Rule matches (tpl-rendered); [] is the Gateway API default, PathPrefix \"/\"", + "items": { + "required": [] + }, + "required": [], + "title": "matches", + "type": "array" + }, "parentRefs": { "description": "Gateway parentRefs", "items": { @@ -764,7 +782,9 @@ "required": [ "enabled", "hostnames", - "parentRefs" + "parentRefs", + "matches", + "filters" ], "title": "route", "type": "object" @@ -1359,7 +1379,7 @@ "type": "string" }, "global": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "defaultStorageClass": { "default": "", @@ -2469,7 +2489,7 @@ "type": "array" }, "extraRules": { - "description": "Full custom rules (tpl-rendered; overrides the generated rule)", + "description": "Full custom rules (tpl-rendered; appended after the generated rules)", "items": { "required": [] }, @@ -3095,8 +3115,17 @@ "title": "enabled", "type": "boolean" }, + "filters": { + "description": "Rule filters (tpl-rendered), e.g. a URLRewrite stripping a path prefix", + "items": { + "required": [] + }, + "required": [], + "title": "filters", + "type": "array" + }, "hostnames": { - "description": "Route hostnames ([] = derived from server.publicUrl / ingress hostname)", + "description": "Route hostnames ([] = derived from server.publicUrl / ingress hostname; only the host is kept, a public\nURL path is dropped and needs its own matches entry)", "items": { "required": [] }, @@ -3104,6 +3133,15 @@ "title": "hostnames", "type": "array" }, + "matches": { + "description": "Rule matches (tpl-rendered); [] is the Gateway API default, PathPrefix \"/\"", + "items": { + "required": [] + }, + "required": [], + "title": "matches", + "type": "array" + }, "parentRefs": { "description": "Gateway parentRefs", "items": { @@ -3117,7 +3155,9 @@ "required": [ "enabled", "hostnames", - "parentRefs" + "parentRefs", + "matches", + "filters" ], "title": "route", "type": "object" @@ -3890,7 +3930,6 @@ } }, "required": [ - "global", "nameOverride", "fullnameOverride", "namespaceOverride", diff --git a/charts/authup/values.yaml b/charts/authup/values.yaml index 677b5e4..3fe6741 100644 --- a/charts/authup/values.yaml +++ b/charts/authup/values.yaml @@ -2,6 +2,10 @@ ## @section Global parameters +# @schema +# type: object +# additionalProperties: true +# @schema global: # -- Global container image registry override (takes precedence over image.registry) imageRegistry: "" @@ -739,12 +743,13 @@ server: extraPaths: [] # -- Extra TLS entries extraTls: [] - # -- Full custom rules (tpl-rendered; overrides the generated rule) + # -- Full custom rules (tpl-rendered; appended after the generated rules) extraRules: [] route: # -- Create a Gateway API HTTPRoute for server-core enabled: false - # -- Route hostnames ([] = derived from server.publicUrl / ingress hostname) + # -- Route hostnames ([] = derived from server.publicUrl / ingress hostname; only the host is kept, a public + # URL path is dropped and needs its own matches entry) hostnames: [] # -- Gateway parentRefs parentRefs: [] @@ -753,6 +758,10 @@ server: # @schema # -- HTTPRoute annotations annotations: {} + # -- Rule matches (tpl-rendered); [] is the Gateway API default, PathPrefix "/" + matches: [] + # -- Rule filters (tpl-rendered), e.g. a URLRewrite stripping a path prefix + filters: [] metrics: # -- The /metrics endpoint is UNAUTHENTICATED; keep it off the public ingress serviceMonitor: @@ -1040,12 +1049,13 @@ adminConsole: extraPaths: [] # -- Extra TLS entries extraTls: [] - # -- Full custom rules (tpl-rendered; overrides the generated rule) + # -- Full custom rules (tpl-rendered; appended after the generated rules) extraRules: [] route: # -- Create a Gateway API HTTPRoute for the UI enabled: false - # -- Route hostnames ([] = derived from adminConsole.publicUrl / ingress hostname) + # -- Route hostnames ([] = derived from adminConsole.publicUrl / ingress hostname; only the host is kept, a public + # URL path is dropped and needs its own matches entry) hostnames: [] # -- Gateway parentRefs parentRefs: [] @@ -1054,6 +1064,10 @@ adminConsole: # @schema # -- HTTPRoute annotations annotations: {} + # -- Rule matches (tpl-rendered); [] is the Gateway API default, PathPrefix "/" + matches: [] + # -- Rule filters (tpl-rendered), e.g. a URLRewrite stripping a path prefix + filters: [] pdb: # -- Create a PodDisruptionBudget for the UI create: false