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 <stitch14@yandex.ru>
This commit is contained in:
Kirill Ilin committed 2026-06-25 15:29:46 +05:00
1 parent caeb2c466f
commit 1c8f0243cf
4 files changed
+243 -56

No files matched your search

@@ -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 }}
+52 -10
View File
@@ -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 }}
@@ -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
+13 -5
View File
@@ -6,11 +6,19 @@ ingress:
# 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"), 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: