From 1c8f0243cfabdb246604fa55f6b79501d7609d73 Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Fri, 19 Jun 2026 17:35:55 +0500 Subject: [PATCH] feat(keycloak): isolate admin endpoints to a dedicated route When ingress.adminHost is set, narrow the public (login) route to the OIDC paths only (/realms, /resources, /.well-known) so the admin console and Administration REST API are no longer served on the login hostname, and serve them from a dedicated admin route instead. The admin route attaches to the Gateway/ingressClass selected by _cluster.expose-ingress-admin (defaults to expose-ingress), so it can be moved onto a separate, e.g. private, Gateway. KC_HOSTNAME_ADMIN alone is not a security boundary; the path and network separation here is what restricts access. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- .../system/keycloak/templates/httproute.yaml | 46 ++++- .../system/keycloak/templates/ingress.yaml | 62 ++++++- .../keycloak/tests/admin_hostname_test.yaml | 173 ++++++++++++++---- packages/system/keycloak/values.yaml | 18 +- 4 files changed, 243 insertions(+), 56 deletions(-) diff --git a/packages/system/keycloak/templates/httproute.yaml b/packages/system/keycloak/templates/httproute.yaml index 0cd5c1b57..8f17a51e8 100644 --- a/packages/system/keycloak/templates/httproute.yaml +++ b/packages/system/keycloak/templates/httproute.yaml @@ -1,7 +1,9 @@ {{- $host := index .Values._cluster "root-host" }} {{- $ingressHost := .Values.ingress.host | default (printf "keycloak.%s" $host) }} {{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }} +{{- $adminExposeIngress := (index .Values._cluster "expose-ingress-admin") | default $exposeIngress }} {{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }} +{{- $adminHost := .Values.ingress.adminHost }} {{- if eq $gatewayEnabled "true" }} apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -15,11 +17,51 @@ spec: namespace: {{ $exposeIngress }} hostnames: - {{ $ingressHost | quote }} - {{- with .Values.ingress.adminHost }} - - {{ . | quote }} + rules: + {{- if $adminHost }} + # adminHost is set: the public (login) hostname exposes only the + # OIDC/login paths. The admin console and Administration REST API are + # served exclusively on the dedicated admin route below, so /admin is + # not reachable here. See the Keycloak reverse proxy guide, + # "Exposed path recommendations". + - matches: + - path: + type: PathPrefix + value: /realms + - path: + type: PathPrefix + value: /resources + - path: + type: PathPrefix + value: /.well-known + backendRefs: + - name: keycloak-http + port: 80 + {{- else }} + - backendRefs: + - name: keycloak-http + port: 80 {{- end }} +{{- if $adminHost }} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: keycloak-admin +spec: + # Attaches to the Gateway named cozystack in the admin namespace. When + # expose-ingress-admin points at a separate (e.g. private-IP) Gateway, + # admin traffic leaves the public Gateway entirely. + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: cozystack + namespace: {{ $adminExposeIngress }} + hostnames: + - {{ $adminHost | quote }} rules: - backendRefs: - name: keycloak-http port: 80 {{- end }} +{{- end }} diff --git a/packages/system/keycloak/templates/ingress.yaml b/packages/system/keycloak/templates/ingress.yaml index 73069eda3..a2f0e848c 100644 --- a/packages/system/keycloak/templates/ingress.yaml +++ b/packages/system/keycloak/templates/ingress.yaml @@ -3,8 +3,10 @@ {{- $solver := (index .Values._cluster "solver") | default "http01" }} {{- $clusterIssuer := (index .Values._cluster "issuer-name") | default "letsencrypt-prod" }} {{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }} +{{- $adminExposeIngress := (index .Values._cluster "expose-ingress-admin") | default $exposeIngress }} {{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }} {{- $wildcardSecret := (index .Values._cluster "wildcard-secret-name") | default "" }} +{{- $adminHost := .Values.ingress.adminHost }} {{- if ne $gatewayEnabled "true" }} apiVersion: networking.k8s.io/v1 @@ -33,17 +35,25 @@ spec: {{- if not $wildcardSecret }} secretName: web-tls {{- end }} - {{- with .Values.ingress.adminHost }} - - hosts: - - {{ . }} - {{- if not $wildcardSecret }} - secretName: keycloak-admin-tls - {{- end }} - {{- end }} rules: - host: {{ $ingressHost }} http: paths: + {{- if $adminHost }} + # adminHost is set: expose only the OIDC/login paths here. The admin + # console and Administration REST API live on the admin Ingress below, + # so /admin is not reachable on the login hostname. See the Keycloak + # reverse proxy guide, "Exposed path recommendations". + {{- range $path := list "/realms" "/resources" "/.well-known" }} + - path: {{ $path }} + pathType: Prefix + backend: + service: + name: keycloak-http + port: + name: http + {{- end }} + {{- else }} - path: / pathType: Prefix backend: @@ -51,8 +61,40 @@ spec: name: keycloak-http port: name: http - {{- with .Values.ingress.adminHost }} - - host: {{ . }} + {{- end }} +{{- if $adminHost }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-admin-ingress + {{- with .Values.ingress.annotations }} + annotations: + {{- /* Operator wildcard mode: nginx serves the default SSL cert, so skip per-host ACME. */}} + {{- if not $wildcardSecret }} + {{- if eq $solver "http01" }} + acme.cert-manager.io/http01-ingress-ingressclassname: {{ $adminExposeIngress }} + {{- end }} + cert-manager.io/cluster-issuer: {{ $clusterIssuer }} + {{- end }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + # Uses the admin ingressClass. When expose-ingress-admin points at a + # separate (e.g. private) ingress controller, admin traffic leaves the + # public ingress entirely. + ingressClassName: {{ $adminExposeIngress }} + tls: + - hosts: + - {{ $adminHost }} + {{- /* Operator wildcard mode: no per-host secret — ingress-nginx falls + back to the default SSL certificate while the tls section keeps + the HTTP-to-HTTPS redirect enforced. */}} + {{- if not $wildcardSecret }} + secretName: keycloak-admin-tls + {{- end }} + rules: + - host: {{ $adminHost }} http: paths: - path: / @@ -62,5 +104,5 @@ spec: name: keycloak-http port: name: http - {{- end }} +{{- end }} {{- end }} diff --git a/packages/system/keycloak/tests/admin_hostname_test.yaml b/packages/system/keycloak/tests/admin_hostname_test.yaml index 9e8e5b34e..5bda09d54 100644 --- a/packages/system/keycloak/tests/admin_hostname_test.yaml +++ b/packages/system/keycloak/tests/admin_hostname_test.yaml @@ -4,11 +4,12 @@ release: name: keycloak namespace: cozy-keycloak -# ingress.adminHost is an opt-in option that serves the Keycloak admin -# console / admin REST API on a dedicated hostname (KC_HOSTNAME_ADMIN) and -# routes it through an additional Ingress rule / HTTPRoute hostname to the -# same keycloak-http service. Empty (default) keeps the admin console on the -# main hostname and emits no admin-specific wiring. +# ingress.adminHost is an opt-in option. When set, Keycloak's admin console +# and Administration REST API are moved onto a dedicated hostname/route, and +# the public (login) route is narrowed to the OIDC paths only so /admin is no +# longer served there. The admin route attaches to the Gateway/ingressClass +# selected by _cluster.expose-ingress-admin (defaults to expose-ingress), +# allowing it to be pointed at a separate, e.g. private, Gateway. tests: # --- StatefulSet env: KC_HOSTNAME_ADMIN --- - it: does not set KC_HOSTNAME_ADMIN when adminHost is empty (default) @@ -40,13 +41,6 @@ tests: ingress: adminHost: keycloak-admin.example.com asserts: - - isKind: - of: StatefulSet - - contains: - path: spec.template.spec.containers[0].env - content: - name: KC_HOSTNAME - value: https://keycloak.example.com - contains: path: spec.template.spec.containers[0].env content: @@ -54,25 +48,30 @@ tests: value: https://keycloak-admin.example.com # --- Ingress (gateway disabled) --- - - it: renders a single rule and single TLS entry when adminHost is empty + - it: renders one Ingress serving all paths when adminHost is empty template: templates/ingress.yaml set: _cluster: root-host: example.com asserts: + - hasDocuments: + count: 1 - isKind: of: Ingress - - lengthEqual: - path: spec.rules - count: 1 - equal: path: spec.rules[0].host value: keycloak.example.com + - lengthEqual: + path: spec.rules[0].http.paths + count: 1 + - equal: + path: spec.rules[0].http.paths[0].path + value: / - lengthEqual: path: spec.tls count: 1 - - it: renders a second rule and TLS entry for the admin hostname + - it: narrows the login Ingress to OIDC paths and adds an admin Ingress template: templates/ingress.yaml set: _cluster: @@ -80,33 +79,82 @@ tests: ingress: adminHost: keycloak-admin.example.com asserts: - - lengthEqual: - path: spec.rules + - hasDocuments: count: 2 - - equal: - path: spec.rules[1].host + # doc 0: public login Ingress — only OIDC paths, never /admin or / + - documentIndex: 0 + equal: + path: metadata.name + value: keycloak-ingress + - documentIndex: 0 + equal: + path: spec.rules[0].host + value: keycloak.example.com + - documentIndex: 0 + lengthEqual: + path: spec.rules[0].http.paths + count: 3 + - documentIndex: 0 + equal: + path: spec.rules[0].http.paths[0].path + value: /realms + - documentIndex: 0 + equal: + path: spec.rules[0].http.paths[1].path + value: /resources + - documentIndex: 0 + equal: + path: spec.rules[0].http.paths[2].path + value: /.well-known + # doc 1: admin Ingress — full path, dedicated host/secret/class + - documentIndex: 1 + equal: + path: metadata.name + value: keycloak-admin-ingress + - documentIndex: 1 + equal: + path: spec.rules[0].host value: keycloak-admin.example.com - - equal: - path: spec.rules[1].http.paths[0].backend.service.name - value: keycloak-http - - lengthEqual: - path: spec.tls - count: 2 - - equal: - path: spec.tls[1].hosts[0] - value: keycloak-admin.example.com - - equal: - path: spec.tls[1].secretName + - documentIndex: 1 + equal: + path: spec.rules[0].http.paths[0].path + value: / + - documentIndex: 1 + equal: + path: spec.tls[0].secretName value: keycloak-admin-tls + - documentIndex: 1 + equal: + path: spec.ingressClassName + value: tenant-root + + - it: admin Ingress uses expose-ingress-admin ingressClass when set + template: templates/ingress.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + expose-ingress-admin: cozy-private + ingress: + adminHost: keycloak-admin.example.com + asserts: + - equal: + path: metadata.name + value: keycloak-admin-ingress + - equal: + path: spec.ingressClassName + value: cozy-private # --- HTTPRoute (gateway enabled) --- - - it: renders a single hostname when adminHost is empty + - it: renders one HTTPRoute serving all paths when adminHost is empty template: templates/httproute.yaml set: _cluster: root-host: example.com gateway-enabled: "true" asserts: + - hasDocuments: + count: 1 - isKind: of: HTTPRoute - lengthEqual: @@ -115,8 +163,11 @@ tests: - equal: path: spec.hostnames[0] value: keycloak.example.com + # no path matches -> all paths routed + - isNull: + path: spec.rules[0].matches - - it: appends the admin hostname when adminHost is set + - it: narrows the login HTTPRoute to OIDC paths and adds an admin HTTPRoute template: templates/httproute.yaml set: _cluster: @@ -125,9 +176,53 @@ tests: ingress: adminHost: keycloak-admin.example.com asserts: - - lengthEqual: - path: spec.hostnames + - hasDocuments: count: 2 - - contains: - path: spec.hostnames - content: keycloak-admin.example.com + # doc 0: public login route — only OIDC path prefixes + - documentIndex: 0 + equal: + path: metadata.name + value: keycloak + - documentIndex: 0 + equal: + path: spec.hostnames[0] + value: keycloak.example.com + - documentIndex: 0 + lengthEqual: + path: spec.rules[0].matches + count: 3 + - documentIndex: 0 + equal: + path: spec.rules[0].matches[0].path.value + value: /realms + # doc 1: admin route — dedicated hostname, default admin namespace + - documentIndex: 1 + equal: + path: metadata.name + value: keycloak-admin + - documentIndex: 1 + equal: + path: spec.hostnames[0] + value: keycloak-admin.example.com + - documentIndex: 1 + equal: + path: spec.parentRefs[0].namespace + value: tenant-root + + - it: admin HTTPRoute attaches to expose-ingress-admin namespace when set + template: templates/httproute.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + gateway-enabled: "true" + expose-ingress-admin: cozy-private + ingress: + adminHost: keycloak-admin.example.com + asserts: + - equal: + path: metadata.name + value: keycloak-admin + - equal: + path: spec.parentRefs[0].namespace + value: cozy-private diff --git a/packages/system/keycloak/values.yaml b/packages/system/keycloak/values.yaml index 12b83febd..0ba5f029b 100644 --- a/packages/system/keycloak/values.yaml +++ b/packages/system/keycloak/values.yaml @@ -6,11 +6,19 @@ ingress: # If left empty, defaults to "keycloak." based on the cluster root-host setting. host: "" # Optional separate hostname for the Keycloak admin console (opt-in). - # When set (e.g., "keycloak-admin.example.com"), the admin console and admin - # REST API are served only on this hostname (Keycloak KC_HOSTNAME_ADMIN), and - # the admin endpoints become unavailable on the main `host` (login/realms - # only). An additional Ingress rule / HTTPRoute hostname is created so the - # admin hostname routes to the same Keycloak service. + # When set (e.g., "keycloak-admin.example.com"): + # - KC_HOSTNAME_ADMIN is configured so the admin console generates its + # URLs on this hostname; + # - the main `host` (login) route is narrowed to the public OIDC paths + # (/realms, /resources, /.well-known) only, so the admin console and + # Administration REST API (/admin) are NOT served on the login hostname; + # - a dedicated Ingress/HTTPRoute for this hostname routes / to Keycloak. + # The admin route attaches to the Gateway/ingressClass selected by the + # cluster-level expose-ingress-admin (defaults to expose-ingress). Point + # that at a separate, e.g. private-IP, Gateway to keep the admin console off + # the public internet. Note: KC_HOSTNAME_ADMIN alone is NOT a security + # boundary (admin REST stays reachable on any hostname routed to Keycloak); + # the network/path separation here is what restricts access. # If left empty, the admin console stays on the main hostname (default). adminHost: "" annotations: