mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-08 21:14:56 +09:00
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:
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 }}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user