Files
helm-charts/keycloak/templates/keycloak.ingress.yaml
T
jklein f784f40fd5
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 9s
Validate Helm Charts / Build keycloak (pull_request) Successful in 4s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 11s
Install-test / Install-test keycloak (pull_request) Successful in 2m23s
Validate Helm Charts / Install-test (pull_request) Successful in 2m23s
feat(keycloak): new chart for Keycloak (1.0.0+up26.8.0)
Two replicas with cache=ispn, clustered through Keycloak's default jdbc-ping
stack: discovery runs over the PostgreSQL that is there anyway, so no headless
Service and no JGroups wiring. Deployment rather than StatefulSet - Keycloak 26
keeps everything in the database, user sessions included.

The chart's defining property is that it has TWO INIT CONTAINERS and needs
both. db, health-enabled and metrics-enabled are Keycloak BUILD options
(verified in the 26.8.0 option table), the published image is augmented with
their defaults, and supplying different ones at runtime makes Keycloak rewrite
/opt/keycloak/lib/quarkus at start - which readOnlyRootFilesystem: true forbids
and which upstream describes as a start that "creates an image that is
mutable". Not setting db is no way out: the probes alone force it, because
health-enabled is a build option too. An empty emptyDir over that path fails as
well, because QuarkusEntryPoint loads quarkus/quarkus-application.dat before it
dispatches any command, `build` included. So one init container seeds the
directory into a volume, the next runs `kc.sh build` into it, and the serving
container runs `start --optimized` - every container keeping the full hardening
baseline, with no documented deviation to carry.

Build options therefore exist in exactly one place, and the asymmetry is
enforced rather than trusted: the serving container passes no build option at
all, and a build option's variable name in keycloak.env fails the render with a
message naming it.

Hostname handling is the security-relevant part. KC_HOSTNAME is derived from
ingress.domain as a full URL (which upstream requires alongside hostname-admin),
hostname-strict stays at its default true, and proxy-headers is set rather than
optional. The admin hostname is NOT presented as an access control: upstream
states that hostname-admin "does not prevent accessing the Administration REST
API endpoints via the frontend URL specified by the hostname option", so the
chart renders no /admin deny rule and values.yaml, NOTES.txt and the wiki all
say in the same words that restricting that API is reverse-proxy work.

The management port 9000 is a containerPort and a ClusterIP Service for
in-cluster scraping, and is in no Ingress: it serves /health/* and optionally
/metrics without authentication. Liveness uses /health/live and deliberately
not /health or /health/ready - those include the database, and a liveness probe
on them would restart the whole cluster for as long as the database is away.

Also in this change:
- First PodDisruptionBudget in this repository, which makes it a convention
  (README section 11): minAvailable as a value, and rendered only while the
  computed replica count is >= 2, because minAvailable: 1 with one replica
  blocks every node drain forever with a message that names the PDB rather
  than the replica count.
- First quay.io image, so quay.io joins the trusted registries in
  ci/trivy/data/ksv0125.yaml - configured rather than suppressed, so
  AVD-KSV-0125 keeps being able to say something.
- Renovate block with NO version constraint, which is the counter-case to
  garage and stalwart and is measured rather than assumed: docker versioning
  already excludes the -N rebuild tags, the floating minors and latest/nightly.
  Three dry-run fixtures are recorded at the block, and against the committed
  config 26.7.4 offers exactly 26.7.5 (patch) and 26.8.0 (minor).
- No automerge exception, by decision (Gitea #83): the global automerge applies,
  and the minor-upgrade procedure plus `kc.sh update-compatibility` is
  documented in NOTES.txt instead.
- Two upstream defaults deviated from, each with its reason at the value:
  db-pool-max-size 20 instead of 100 (two replicas at 100 ask a default
  PostgreSQL for twice its max_connections), and uid 1000 / gid 0 instead of
  this repo's 20000/1000, because the image declares keycloak:x:1000:0 and
  chowns its install tree to that pair.

Part of #83
2026-10-04 17:02:47 +00:00

109 lines
5.0 KiB
YAML

{{- /*
TWO INGRESS OBJECTS, AND THE SECOND ONE IS NOT A SECURITY BOUNDARY.
The public one publishes the login endpoints, the token endpoints and the
account console. The admin one publishes the SAME backend under a second
hostname, so that the administration console - whose URLs Keycloak generates
from hostname-admin - can live on an internal ingress class or in a different
DNS zone.
WHAT IT DOES NOT DO, because the value's name invites the opposite assumption.
Upstream, verbatim: "Using the hostname-admin option does not prevent accessing
the Administration REST API endpoints via the frontend URL specified by the
hostname option. If you want to restrict access to the Administration REST API,
you need to do it on the reverse proxy level." So /admin/realms/... stays
reachable through ingress.domain, and an IP allow-list on the admin host
restricts nothing by itself.
This chart deliberately renders NO deny rule for /admin on the public Ingress.
Two reasons, and the second is the one that decided it: the administration
console loads assets and performs redirects across more paths than /admin, so a
rule written from the path name breaks the console in ways that only show up in
a browser; and a rule nobody has tested against a running console is a measure
that looks like one, which is worse than an absent one. Whoever needs the
restriction puts it on the proxy in front of the public host, where it can be
tested.
GUIDELINE 12.1, CHECKED IN BOTH DIRECTIONS: ingress.domain is read a second
time outside this template - as KC_HOSTNAME on the Deployment - so it stays
required even with ingress.enabled: false, and its required() lives in the
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
this file, so switching both Ingresses off frees it.
*/ -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
http:
paths:
- backend:
service:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
{{- if $adminEnabled }}
{{- if .Values.ingress.enabled }}
---
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
# Its own certificate, for its own host. A separate TLS secret
# (tls-<release>-keycloak-admin-ingress) rather than a shared one, because
# these are two different hostnames that may well sit on two different
# ingress classes - the shared-secret form in the style guide is for
# several objects on ONE host.
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
{{- with ((.Values.ingress.admin | default dict).annotations) }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
# Falls back to ingress.className when not set. An internal ingress class
# here is the useful half of the admin hostname - and still not the access
# control the comment at the top of this file is about.
ingressClassName: {{ (.Values.ingress.admin | default dict).className | default .Values.ingress.className }}
rules:
- host: {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
http:
paths:
# The SAME backend and the same path as the public Ingress. The
# administration console is not a separate application and not a
# separate path - only the hostname differs, and Keycloak generates
# its admin URLs for that hostname because of KC_HOSTNAME_ADMIN.
- backend:
service:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
{{- end }}