From 2d728b41763aaf7d6904263185cb9ef3e967eaae Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Thu, 21 May 2026 18:03:09 -0400 Subject: [PATCH 1/4] feat: add rh-keycloak wrapper chart with PostSync cleanup Add a wrapper chart (charts/rh-keycloak) that consumes the rhbk chart as a dependency and adds a PostSync cleanup job for one-shot ExternalSecret provisioning. The PostSync job: 1. Waits for labeled ExternalSecrets to sync 2. Deletes them with --cascade=orphan (Secrets survive) 3. Cleans up ephemeral Secrets labeled for deletion (keycloak-users) Switch values-hub.yaml from the remote rhbk chart to the local rh-keycloak wrapper chart path. Requires rhbk-chart >= 0.0.9 with externalSecrets.oneShot support. Commented-out overrides updated with rhbk. prefix to match the wrapper chart structure. Signed-off-by: Min Zhang --- charts/rh-keycloak/Chart.yaml | 17 + .../templates/cleanup-externalsecrets.yaml | 94 ++++++ charts/rh-keycloak/values.yaml | 15 + overrides/values-keycloak-network-policy.yaml | 299 +++++++++--------- values-hub.yaml | 10 +- 5 files changed, 281 insertions(+), 154 deletions(-) create mode 100644 charts/rh-keycloak/Chart.yaml create mode 100644 charts/rh-keycloak/templates/cleanup-externalsecrets.yaml create mode 100644 charts/rh-keycloak/values.yaml diff --git a/charts/rh-keycloak/Chart.yaml b/charts/rh-keycloak/Chart.yaml new file mode 100644 index 00000000..e274f18f --- /dev/null +++ b/charts/rh-keycloak/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: rh-keycloak +description: ZTVP Keycloak deployment — wraps the rhbk chart and adds PostSync cleanup for one-shot ExternalSecrets +type: application +version: 0.1.0 +dependencies: + - name: rhbk + version: ">=0.0.9" + repository: "oci://quay.io/validatedpatterns" +maintainers: + - name: Zero Trust Validated Patterns Team + email: ztvp-arch-group@redhat.com +keywords: + - keycloak + - rhbk + - zero-trust + - pattern diff --git a/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml b/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml new file mode 100644 index 00000000..fd8dac90 --- /dev/null +++ b/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml @@ -0,0 +1,94 @@ +{{- if .Values.cleanup.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cleanup-ephemeral-secrets + namespace: {{ .Release.Namespace }} + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: cleanup-ephemeral-secrets + namespace: {{ .Release.Namespace }} + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +rules: +- apiGroups: ["external-secrets.io"] + resources: ["externalsecrets"] + verbs: ["get", "list", "delete"] +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: cleanup-ephemeral-secrets + namespace: {{ .Release.Namespace }} + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: cleanup-ephemeral-secrets +subjects: +- kind: ServiceAccount + name: cleanup-ephemeral-secrets + namespace: {{ .Release.Namespace }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: cleanup-ephemeral-secrets + namespace: {{ .Release.Namespace }} + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 2 + activeDeadlineSeconds: {{ .Values.cleanup.activeDeadlineSeconds }} + template: + spec: + serviceAccountName: cleanup-ephemeral-secrets + restartPolicy: Never + containers: + - name: cleanup + image: {{ .Values.cleanup.image }} + command: + - /bin/bash + - -ce + - | + LABEL="{{ .Values.cleanup.label }}" + NS="{{ .Release.Namespace }}" + + ES_COUNT=$(oc get externalsecret -l "${LABEL}=one-shot" -n "${NS}" --no-headers 2>/dev/null | wc -l) + if [ "${ES_COUNT}" -eq 0 ]; then + echo "No one-shot ExternalSecrets found. Nothing to do." + else + echo "Found ${ES_COUNT} one-shot ExternalSecret(s)." + echo "Waiting for ExternalSecrets to sync..." + oc wait externalsecret -l "${LABEL}=one-shot" -n "${NS}" \ + --for=condition=Ready --timeout=90s 2>/dev/null || \ + echo "WARNING: Timed out waiting for Ready, proceeding." + + echo "Deleting ExternalSecrets (orphaning dependent Secrets)..." + oc delete externalsecret -l "${LABEL}=one-shot" -n "${NS}" \ + --cascade=orphan --ignore-not-found + fi + + SEC_COUNT=$(oc get secret -l "${LABEL}=delete" -n "${NS}" --no-headers 2>/dev/null | wc -l) + if [ "${SEC_COUNT}" -eq 0 ]; then + echo "No ephemeral Secrets to clean up." + else + echo "Deleting ${SEC_COUNT} ephemeral Secret(s)..." + oc delete secret -l "${LABEL}=delete" -n "${NS}" --ignore-not-found + fi + + echo "Cleanup complete." +{{- end }} diff --git a/charts/rh-keycloak/values.yaml b/charts/rh-keycloak/values.yaml new file mode 100644 index 00000000..e1b564d0 --- /dev/null +++ b/charts/rh-keycloak/values.yaml @@ -0,0 +1,15 @@ +# PostSync cleanup for ephemeral Secrets. +# When enabled, a PostSync Job deletes the keycloak-users ExternalSecret +# with --cascade=orphan (so the Secret survives), then removes Secrets +# labeled for deletion (keycloak-users). +cleanup: + enabled: true + image: registry.redhat.io/openshift4/ose-cli-rhel9:latest + label: "ztvp.io/cleanup" + activeDeadlineSeconds: 120 + +# Values passed through to the rhbk subchart. +rhbk: + externalSecrets: + oneShot: true + secretCleanupLabel: "ztvp.io/cleanup" diff --git a/overrides/values-keycloak-network-policy.yaml b/overrides/values-keycloak-network-policy.yaml index 7b2e9f94..f6822269 100644 --- a/overrides/values-keycloak-network-policy.yaml +++ b/overrides/values-keycloak-network-policy.yaml @@ -1,153 +1,154 @@ -defaultDenyNetworkPolicy: - enabled: true - -networkPolicy: - keycloak: +rhbk: + defaultDenyNetworkPolicy: enabled: true - egress: - # DNS resolution via CoreDNS — OCP uses port 5353 - - ports: - - protocol: UDP - port: 5353 - - protocol: TCP - port: 5353 - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: openshift-dns - # PostgreSQL backend database - - ports: - - protocol: TCP - port: 5432 - to: - - podSelector: - matchLabels: - app: postgresql-db - # JGroups cluster discovery and failure detection (multi-instance clustering) - - ports: - - protocol: TCP - port: 7800 - - protocol: TCP - port: 57800 - to: - - podSelector: - matchLabels: - app: keycloak - app.kubernetes.io/instance: keycloak - app.kubernetes.io/managed-by: keycloak-operator - # Kubernetes API server — JDBC_PING discovery reads endpoints - # Endpoints are node IPs after DNAT, port-only rule required - - ports: - - protocol: TCP - port: 6443 - # SPIRE OIDC discovery provider — Keycloak fetches JWKS for federated - # client auth (spiffe feature). Traffic goes via the OCP router external - # IP, port-only rule required - - ports: - - protocol: TCP - port: 443 - realmImport: - enabled: true - podSelector: - app: keycloak-realm-import - egress: - # DNS resolution via CoreDNS - - ports: - - protocol: UDP - port: 5353 - - protocol: TCP - port: 5353 - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: openshift-dns - # PostgreSQL — realm import writes realm data to the database - - ports: - - protocol: TCP - port: 5432 - to: - - podSelector: - matchLabels: - app: postgresql-db - # Kubernetes API server — reads secrets referenced in KeycloakRealmImport CR - - ports: - - protocol: TCP - port: 6443 - # Keycloak HTTPS API — admin API calls during realm import - - ports: - - protocol: TCP - port: 8443 - to: - - podSelector: - matchLabels: - app: keycloak - app.kubernetes.io/instance: keycloak - app.kubernetes.io/managed-by: keycloak-operator + networkPolicy: + keycloak: + enabled: true + egress: + # DNS resolution via CoreDNS — OCP uses port 5353 + - ports: + - protocol: UDP + port: 5353 + - protocol: TCP + port: 5353 + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: openshift-dns + # PostgreSQL backend database + - ports: + - protocol: TCP + port: 5432 + to: + - podSelector: + matchLabels: + app: postgresql-db + # JGroups cluster discovery and failure detection (multi-instance clustering) + - ports: + - protocol: TCP + port: 7800 + - protocol: TCP + port: 57800 + to: + - podSelector: + matchLabels: + app: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + # Kubernetes API server — JDBC_PING discovery reads endpoints + # Endpoints are node IPs after DNAT, port-only rule required + - ports: + - protocol: TCP + port: 6443 + # SPIRE OIDC discovery provider — Keycloak fetches JWKS for federated + # client auth (spiffe feature). Traffic goes via the OCP router external + # IP, port-only rule required + - ports: + - protocol: TCP + port: 443 - postgresql: - enabled: true - ingress: - # Accept connections from Keycloak pods and realm import jobs - - ports: - - protocol: TCP - port: 5432 - from: - - podSelector: - matchLabels: - app: keycloak - app.kubernetes.io/instance: keycloak - app.kubernetes.io/managed-by: keycloak-operator - - podSelector: - matchLabels: - app: keycloak-realm-import - egress: - # DNS resolution via CoreDNS - - ports: - - protocol: UDP - port: 5353 - - protocol: TCP - port: 5353 - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: openshift-dns + realmImport: + enabled: true + podSelector: + app: keycloak-realm-import + egress: + # DNS resolution via CoreDNS + - ports: + - protocol: UDP + port: 5353 + - protocol: TCP + port: 5353 + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: openshift-dns + # PostgreSQL — realm import writes realm data to the database + - ports: + - protocol: TCP + port: 5432 + to: + - podSelector: + matchLabels: + app: postgresql-db + # Kubernetes API server — reads secrets referenced in KeycloakRealmImport CR + - ports: + - protocol: TCP + port: 6443 + # Keycloak HTTPS API — admin API calls during realm import + - ports: + - protocol: TCP + port: 8443 + to: + - podSelector: + matchLabels: + app: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator - operator: - enabled: true - # No ingress rules — operator only initiates outbound connections - egress: - # DNS resolution via CoreDNS - - ports: - - protocol: UDP - port: 5353 - - protocol: TCP - port: 5353 - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: openshift-dns - # Kubernetes API server — operator watches CRs and manages resources - - ports: - - protocol: TCP - port: 6443 - # Keycloak management endpoint — health checks and reconciliation - - ports: - - protocol: TCP - port: 9000 - to: - - podSelector: - matchLabels: - app: keycloak - app.kubernetes.io/instance: keycloak - app.kubernetes.io/managed-by: keycloak-operator - # Keycloak HTTPS API — admin API calls during realm/client reconciliation - - ports: - - protocol: TCP - port: 8443 - to: - - podSelector: - matchLabels: - app: keycloak - app.kubernetes.io/instance: keycloak - app.kubernetes.io/managed-by: keycloak-operator + postgresql: + enabled: true + ingress: + # Accept connections from Keycloak pods and realm import jobs + - ports: + - protocol: TCP + port: 5432 + from: + - podSelector: + matchLabels: + app: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + - podSelector: + matchLabels: + app: keycloak-realm-import + egress: + # DNS resolution via CoreDNS + - ports: + - protocol: UDP + port: 5353 + - protocol: TCP + port: 5353 + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: openshift-dns + + operator: + enabled: true + # No ingress rules — operator only initiates outbound connections + egress: + # DNS resolution via CoreDNS + - ports: + - protocol: UDP + port: 5353 + - protocol: TCP + port: 5353 + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: openshift-dns + # Kubernetes API server — operator watches CRs and manages resources + - ports: + - protocol: TCP + port: 6443 + # Keycloak management endpoint — health checks and reconciliation + - ports: + - protocol: TCP + port: 9000 + to: + - podSelector: + matchLabels: + app: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + # Keycloak HTTPS API — admin API calls during realm/client reconciliation + - ports: + - protocol: TCP + port: 8443 + to: + - podSelector: + matchLabels: + app: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator diff --git a/values-hub.yaml b/values-hub.yaml index cae32f1e..0a061d50 100644 --- a/values-hub.yaml +++ b/values-hub.yaml @@ -494,18 +494,18 @@ clusterGroup: name: rh-keycloak namespace: keycloak-system project: hub - chart: rhbk - chartVersion: 0.0.* + path: charts/rh-keycloak extraValueFiles: - /overrides/values-keycloak-network-policy.yaml annotations: argocd.argoproj.io/sync-wave: "35" - # SPIFFE Identity Provider is enabled by default in the chart. + # SPIFFE Identity Provider is enabled by default in the rhbk subchart. # Override issuer/jwksUrl only if auto-generated values from cluster domain are not suitable. + # Note: overrides must use the rhbk. prefix to reach the subchart. # overrides: - # - name: keycloak.spiffeIdentityProvider.config.config.issuer + # - name: rhbk.keycloak.spiffeIdentityProvider.config.config.issuer # value: "spiffe://apps.example.com" - # - name: keycloak.spiffeIdentityProvider.config.config.jwksUrl + # - name: rhbk.keycloak.spiffeIdentityProvider.config.config.jwksUrl # value: "https://spire-spiffe-oidc-discovery-provider.apps.example.com/keys" rh-cert-manager: name: rh-cert-manager From 48e6d90905bbf53446690c38c60848be2b1ae6ae Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Mon, 25 May 2026 19:34:58 -0400 Subject: [PATCH 2/4] fix: rename cleanup label to validatedpatterns.io/cleanup Bump rhbk dependency to >=0.0.10 which carries the renamed label. Signed-off-by: Min Zhang --- charts/rh-keycloak/Chart.yaml | 2 +- charts/rh-keycloak/values.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/rh-keycloak/Chart.yaml b/charts/rh-keycloak/Chart.yaml index e274f18f..d9daf98a 100644 --- a/charts/rh-keycloak/Chart.yaml +++ b/charts/rh-keycloak/Chart.yaml @@ -5,7 +5,7 @@ type: application version: 0.1.0 dependencies: - name: rhbk - version: ">=0.0.9" + version: ">=0.0.10" repository: "oci://quay.io/validatedpatterns" maintainers: - name: Zero Trust Validated Patterns Team diff --git a/charts/rh-keycloak/values.yaml b/charts/rh-keycloak/values.yaml index e1b564d0..5eeaf24d 100644 --- a/charts/rh-keycloak/values.yaml +++ b/charts/rh-keycloak/values.yaml @@ -5,11 +5,11 @@ cleanup: enabled: true image: registry.redhat.io/openshift4/ose-cli-rhel9:latest - label: "ztvp.io/cleanup" + label: "validatedpatterns.io/cleanup" activeDeadlineSeconds: 120 # Values passed through to the rhbk subchart. rhbk: externalSecrets: oneShot: true - secretCleanupLabel: "ztvp.io/cleanup" + secretCleanupLabel: "validatedpatterns.io/cleanup" From 59c2a68fa38e4c865d35b45d372d0d65ae5f23be Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Tue, 26 May 2026 13:25:29 -0400 Subject: [PATCH 3/4] refactor: simplify cleanup Job to only delete Secrets ArgoCD's HookSucceeded policy already removes the ExternalSecret, so the PostSync Job no longer needs ExternalSecret RBAC or the one-shot label lookup. Signed-off-by: Min Zhang --- .../templates/cleanup-externalsecrets.yaml | 18 ------------------ charts/rh-keycloak/values.yaml | 6 +++--- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml b/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml index fd8dac90..964a05cd 100644 --- a/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml +++ b/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml @@ -18,9 +18,6 @@ metadata: argocd.argoproj.io/hook: PostSync argocd.argoproj.io/hook-delete-policy: BeforeHookCreation rules: -- apiGroups: ["external-secrets.io"] - resources: ["externalsecrets"] - verbs: ["get", "list", "delete"] - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list", "delete"] @@ -67,21 +64,6 @@ spec: LABEL="{{ .Values.cleanup.label }}" NS="{{ .Release.Namespace }}" - ES_COUNT=$(oc get externalsecret -l "${LABEL}=one-shot" -n "${NS}" --no-headers 2>/dev/null | wc -l) - if [ "${ES_COUNT}" -eq 0 ]; then - echo "No one-shot ExternalSecrets found. Nothing to do." - else - echo "Found ${ES_COUNT} one-shot ExternalSecret(s)." - echo "Waiting for ExternalSecrets to sync..." - oc wait externalsecret -l "${LABEL}=one-shot" -n "${NS}" \ - --for=condition=Ready --timeout=90s 2>/dev/null || \ - echo "WARNING: Timed out waiting for Ready, proceeding." - - echo "Deleting ExternalSecrets (orphaning dependent Secrets)..." - oc delete externalsecret -l "${LABEL}=one-shot" -n "${NS}" \ - --cascade=orphan --ignore-not-found - fi - SEC_COUNT=$(oc get secret -l "${LABEL}=delete" -n "${NS}" --no-headers 2>/dev/null | wc -l) if [ "${SEC_COUNT}" -eq 0 ]; then echo "No ephemeral Secrets to clean up." diff --git a/charts/rh-keycloak/values.yaml b/charts/rh-keycloak/values.yaml index 5eeaf24d..87e47d15 100644 --- a/charts/rh-keycloak/values.yaml +++ b/charts/rh-keycloak/values.yaml @@ -1,7 +1,7 @@ # PostSync cleanup for ephemeral Secrets. -# When enabled, a PostSync Job deletes the keycloak-users ExternalSecret -# with --cascade=orphan (so the Secret survives), then removes Secrets -# labeled for deletion (keycloak-users). +# When enabled, a PostSync Job deletes Secrets labeled for cleanup +# (e.g. keycloak-users) after the realm import completes. +# The ExternalSecret itself is removed by ArgoCD's HookSucceeded policy. cleanup: enabled: true image: registry.redhat.io/openshift4/ose-cli-rhel9:latest From 0859799d8b71af60453ebc57426ea144b15416dd Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Tue, 26 May 2026 15:08:09 -0400 Subject: [PATCH 4/4] fix: add NetworkPolicy for cleanup Job pod When default-deny NetworkPolicies are enabled in keycloak-system, the cleanup Job cannot reach the API server. Add a PostSync NetworkPolicy allowing DNS and port 6443 egress, and label the Job pod so it matches. Signed-off-by: Min Zhang --- .../templates/cleanup-externalsecrets.yaml | 3 ++ .../templates/cleanup-network-policy.yaml | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 charts/rh-keycloak/templates/cleanup-network-policy.yaml diff --git a/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml b/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml index 964a05cd..4be4efa7 100644 --- a/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml +++ b/charts/rh-keycloak/templates/cleanup-externalsecrets.yaml @@ -51,6 +51,9 @@ spec: backoffLimit: 2 activeDeadlineSeconds: {{ .Values.cleanup.activeDeadlineSeconds }} template: + metadata: + labels: + app: cleanup-ephemeral-secrets spec: serviceAccountName: cleanup-ephemeral-secrets restartPolicy: Never diff --git a/charts/rh-keycloak/templates/cleanup-network-policy.yaml b/charts/rh-keycloak/templates/cleanup-network-policy.yaml new file mode 100644 index 00000000..b54471a2 --- /dev/null +++ b/charts/rh-keycloak/templates/cleanup-network-policy.yaml @@ -0,0 +1,31 @@ +{{- if and .Values.cleanup.enabled (eq (.Values.rhbk.defaultDenyNetworkPolicy.enabled | toString) "true") }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: cleanup-ephemeral-secrets-network-policy + namespace: {{ .Release.Namespace }} + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + podSelector: + matchLabels: + app: cleanup-ephemeral-secrets + policyTypes: + - Egress + egress: + # DNS resolution via CoreDNS + - ports: + - protocol: UDP + port: 5353 + - protocol: TCP + port: 5353 + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: openshift-dns + # Kubernetes API server — oc get/delete secret + - ports: + - protocol: TCP + port: 6443 +{{- end }}