feat(keycloak): opt-in separate hostname and route for the admin console (#2954)

## What this PR does

Adds an opt-in way to serve the Keycloak admin console and
Administration
REST API on a separate hostname and route from the public login
endpoints,
so the admin surface can be kept off the public internet.

- New `ingress.adminHost` in the keycloak chart. When set:
  - `KC_HOSTNAME_ADMIN` is configured so the admin console generates its
    URLs on that hostname;
  - the public (login) route is narrowed 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;
- a dedicated route (Ingress or HTTPRoute, depending on
`gateway-enabled`)
    serves the admin hostname.
- New platform `publishing.ingressNameAdmin`, propagated as
  `_cluster.expose-ingress-admin`. The admin route attaches to the
Gateway/ingressClass it names, defaulting to `expose-ingress`. Pointing
it
at a separate (for example, private-IP) Gateway moves admin traffic off
the public one. The separate Gateway itself is provisioned out of band;
  this only selects where the admin route attaches.

Everything is empty by default, so existing deployments are unchanged.

Note: `KC_HOSTNAME_ADMIN` on its own is not a security boundary — the
Administration REST API stays reachable on any hostname routed to
Keycloak
(per the Keycloak docs). The path and network separation introduced here
is
what restricts access; full isolation requires attaching the admin route
to
a non-public Gateway.

Covered by helm-unittest suites for the keycloak chart and the platform
value wiring.

### Screenshots

N/A — no UI changes.

### Release note

```release-note
feat(keycloak): add opt-in `ingress.adminHost` to serve the admin console and Administration REST API on a separate hostname/route, plus `publishing.ingressNameAdmin` (`_cluster.expose-ingress-admin`) to attach that route to a separate (e.g. private) Gateway/ingressClass. Disabled by default; existing deployments are unchanged.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added optional configuration to serve the Keycloak admin console on a
dedicated hostname, with separate routing and ingress/gateway attachment
so admin UI/API can be isolated from public login/OIDC paths.
* Introduced a cluster-level setting to optionally expose admin routes
via a separate ingress/gateway class/namespace.

* **Tests**
* Added new YAML test coverage to validate defaulting, hostname split
behavior, and correct routing for both gateway-enabled and
gateway-disabled modes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Kirill Ilin authored and GitHub committed 2026-06-26 14:08:23 +05:00
commit 5cb6ef435f
8 files changed
+443

No files matched your search

@@ -108,6 +108,13 @@ stringData:
key. Documented here as a known limitation.
*/}}
expose-ingress: {{ .Values.publishing.ingressName | quote }}
{{- /* Optional namespace of a SEPARATE Gateway/ingressClass that
system components (currently Keycloak) attach their admin
routes to — e.g. a private-IP Gateway unreachable from the
public internet. Empty -> admin shares expose-ingress. The
Gateway itself is provisioned out of band; this only selects
where the admin route attaches. */}}
expose-ingress-admin: {{ .Values.publishing.ingressNameAdmin | default "" | quote }}
expose-external-ips: {{ .Values.publishing.externalIPs | join "," | quote }}
{{- /* Host PROXY-protocol switch. extra/ingress reads this and applies
use-proxy-protocol to the external-facing ingress (the namespace
@@ -0,0 +1,25 @@
suite: apps.yaml admin-ingress platform → tenant value wiring
templates:
- templates/apps.yaml
release:
name: cozy-platform
namespace: cozy-system
# Asserts that publishing.ingressNameAdmin lands in _cluster.expose-ingress-admin,
# the key read by system charts (currently keycloak) to attach admin routes to a
# separate Gateway/ingressClass. Empty by default so admin shares expose-ingress.
tests:
- it: writes an empty expose-ingress-admin by default
asserts:
- matchRegex:
path: stringData["values.yaml"]
pattern: 'expose-ingress-admin:\s*""'
- it: writes the configured admin ingress namespace
set:
publishing.ingressNameAdmin: cozy-private
asserts:
- matchRegex:
path: stringData["values.yaml"]
pattern: 'expose-ingress-admin:\s*"cozy-private"'
+7
View File
@@ -49,6 +49,13 @@ networking:
publishing:
host: "example.org"
ingressName: tenant-root
# Optional namespace of a SEPARATE Gateway / ingressClass that system
# components attach their admin routes to (currently Keycloak) — e.g. one
# bound to a private IP, unreachable from the public internet. Empty means
# admin endpoints share the same ingress as everything else (ingressName).
# The private Gateway itself is NOT created by cozystack; provision it out
# of band, then point this at its namespace to move admin traffic onto it.
ingressNameAdmin: ""
exposedServices:
- api
- dashboard
@@ -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
@@ -16,7 +18,50 @@ spec:
hostnames:
- {{ $ingressHost | 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 }}
@@ -3,8 +3,16 @@
{{- $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 }}
{{- /* Wildcard mode applies to the admin Ingress only when it shares the
public ingressClass: the operator's default SSL cert covers it too, so
skip per-host ACME like the login Ingress. When admin is moved onto a
separate (e.g. private) ingress, that controller has no wildcard default
cert, so keep its own per-host certificate regardless of wildcard mode. */}}
{{- $adminWildcard := and $wildcardSecret (eq $adminExposeIngress $exposeIngress) }}
{{- if ne $gatewayEnabled "true" }}
apiVersion: networking.k8s.io/v1
@@ -35,6 +43,64 @@ spec:
{{- 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:
service:
name: keycloak-http
port:
name: http
{{- end }}
{{- if $adminHost }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak-admin-ingress
{{- with .Values.ingress.annotations }}
annotations:
{{- /* Operator wildcard mode (shared public ingress): nginx serves the
default SSL cert, so skip per-host ACME. */}}
{{- if not $adminWildcard }}
{{- 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 }}
{{- /* Wildcard mode on the shared public ingress: rely on the default SSL
cert, no per-host secret. A separate admin ingress keeps its own. */}}
{{- if not $adminWildcard }}
secretName: keycloak-admin-tls
{{- end }}
rules:
- host: {{ $adminHost }}
http:
paths:
- path: /
@@ -45,3 +111,4 @@ spec:
port:
name: http
{{- end }}
{{- end }}
@@ -182,6 +182,10 @@ spec:
value: "docker"
- name: KC_HOSTNAME
value: https://{{ $ingressHost }}
{{- with .Values.ingress.adminHost }}
- name: KC_HOSTNAME_ADMIN
value: https://{{ . }}
{{- end }}
- name: JAVA_OPTS_APPEND
value: "-Djgroups.dns.query=keycloak-headless.cozy-keycloak.svc.{{ $clusterDomain }}"
{{- with .Values.extraEnv }}
@@ -0,0 +1,272 @@
suite: admin hostname split (ingress.adminHost)
release:
name: keycloak
namespace: cozy-keycloak
# 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)
template: templates/sts.yaml
documentIndex: 1
set:
_cluster:
root-host: example.com
asserts:
- isKind:
of: StatefulSet
- contains:
path: spec.template.spec.containers[0].env
content:
name: KC_HOSTNAME
value: https://keycloak.example.com
- notContains:
path: spec.template.spec.containers[0].env
content:
name: KC_HOSTNAME_ADMIN
value: https://keycloak-admin.example.com
- it: sets KC_HOSTNAME_ADMIN when adminHost is set
template: templates/sts.yaml
documentIndex: 1
set:
_cluster:
root-host: example.com
ingress:
adminHost: keycloak-admin.example.com
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: KC_HOSTNAME_ADMIN
value: https://keycloak-admin.example.com
# --- Ingress (gateway disabled) ---
- 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
- 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: narrows the login Ingress to OIDC paths and adds an admin Ingress
template: templates/ingress.yaml
set:
_cluster:
root-host: example.com
ingress:
adminHost: keycloak-admin.example.com
asserts:
- hasDocuments:
count: 2
# 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
- 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
- it: admin Ingress drops per-host TLS in wildcard mode on the shared ingress
template: templates/ingress.yaml
documentIndex: 1
set:
_cluster:
root-host: example.com
wildcard-secret-name: cozystack-wildcard
ingress:
adminHost: keycloak-admin.example.com
asserts:
- equal:
path: metadata.name
value: keycloak-admin-ingress
# no per-host secret — relies on the operator default SSL cert
- notExists:
path: spec.tls[0].secretName
# no ACME / cluster-issuer annotations in wildcard mode
- notExists:
path: metadata.annotations["cert-manager.io/cluster-issuer"]
- notExists:
path: metadata.annotations["acme.cert-manager.io/http01-ingress-ingressclassname"]
- it: admin Ingress keeps its own cert on a separate ingress even in wildcard mode
template: templates/ingress.yaml
documentIndex: 1
set:
_cluster:
root-host: example.com
wildcard-secret-name: cozystack-wildcard
expose-ingress-admin: cozy-private
ingress:
adminHost: keycloak-admin.example.com
asserts:
- equal:
path: metadata.name
value: keycloak-admin-ingress
# separate ingress has no wildcard default cert -> per-host secret stays
- equal:
path: spec.tls[0].secretName
value: keycloak-admin-tls
- equal:
path: metadata.annotations["cert-manager.io/cluster-issuer"]
value: letsencrypt-prod
# --- HTTPRoute (gateway enabled) ---
- 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:
path: spec.hostnames
count: 1
- equal:
path: spec.hostnames[0]
value: keycloak.example.com
# no path matches -> all paths routed
- isNull:
path: spec.rules[0].matches
- it: narrows the login HTTPRoute to OIDC paths and adds an admin HTTPRoute
template: templates/httproute.yaml
set:
_cluster:
root-host: example.com
gateway-enabled: "true"
ingress:
adminHost: keycloak-admin.example.com
asserts:
- hasDocuments:
count: 2
# 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
+16
View File
@@ -5,6 +5,22 @@ ingress:
# If set, this value will be used as the Ingress hostname (e.g., "auth.example.com").
# If left empty, defaults to "keycloak.<root-host>" 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"):
# - 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:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-expires: "86400"