Design: Harden Keycloak DCR to cluster-internal (A1+trust, restore ADR hl-xktj posture)
- Bead: hl-thsf.2 (follow-up to hl-thsf.1, child of hl-thsf)
- Date: 2026-07-02
- Status: Draft (pending design-reviewer gate)
- Author: Sean (with Claude)
- Supersedes/amends: ADR
hl-amad(which itself deferred this work; hl-amad supersededhl-xktj) - Predecessor:
hl-thsf.1/ spec2026-06-29-keycloak-dcr-traefik-deny-fix-design.md(“G now, A1+trust later”) - Depends on (already merged, unrelated bead):
hl-rs8m/ PR #1538 — added theid.fzymgc.houseCoreDNS split-horizon rewrite this design originally proposed to add itself. See “Revision note” below.
Grounding note — read deployed state, not the local working copy. All manifest and cluster claims below are grounded against
origin/main(ArgoCDtargetRevision: HEAD) and the live cluster (kubernetes-mcp-server, read-only), not a possibly-stale local checkout. This mattered concretely during this design’s own review — see below.Revision note (round 1 → round 2). The first draft of this spec proposed adding a CoreDNS rewrite
id.fzymgc.house→keycloak-service.keycloak.svc.cluster.local. While that draft was being reviewed, an unrelated bead (hl-rs8m, fixing HCP Terraform Cloud’s access to Keycloak’s master-realm token endpoint) merged the same kind of rewrite for the same hostname — but pointed at a newkeycloak-internalService (443→8443 shim), notkeycloak-servicedirectly, becausekeycloak-service(operator-managed) only exposes 8443/9000 and a barehttps://id.fzymgc.house(no explicit port, exactly agentgateway’s DCR call shape) defaults to port 443 and would time out against it. The design-reviewer caught the resulting staleness (this session’s local jj workspace still showed the pre-hl-rs8mfile) before implementation started. This revision removes the now-redundant/wrong CoreDNS step entirely and reuseshl-rs8m’s already-merged rewrite as a precondition.
Problem
Section titled “Problem”hl-thsf.1 (Approach G) removed the Traefik deny-client-registration Middleware on
id.fzymgc.house to unblock agentgateway’s own DCR proxy, which hairpins through the
public issuer URL (https://id.fzymgc.house/realms/fzymgc). That fixed the Phase 1
gate but left anonymous DCR governed only by Keycloak’s loopback Trusted-Hosts
policy (host-sending-registration-request-must-match=false) — no network-level
control. ADR hl-amad accepted this as an explicit, temporary trade-off and deferred
restoring the cluster-internal-only posture to this bead.
Goal: make agentgateway’s DCR proxy call Keycloak directly, in-cluster (A1) and
trust its ICA1-issued cert for that call (trust), so the Traefik network deny can
be re-added without reintroducing the hl-thsf.1 403. As of hl-rs8m (merged,
unrelated bead), the routing half (A1) already exists — this bead’s actual scope is
narrower than originally scoped: just the ICA1 trust injection into agentgateway,
plus re-adding the Traefik deny.
Grounding (source-verified, not assumed)
Section titled “Grounding (source-verified, not assumed)”- agentgateway v1.3.1 (
crates/agentgateway/src/mcp/auth.rs::client_registration,crates/agentgateway/src/http/backendtls.rs, source fetched at the deployed tag): DCR’s proxy call uses.simple_call(ureq), whose TLS trust isSYSTEM_TRUST— a process-lifetimeLazy<BackendTLS>built once fromrustls_native_certs::load_native_certs()with no arguments. It does not consult agentgateway’s per-Service backend-TLS policy mechanism (the one already used for the JWKS fetch viakeycloak-service-backend-tls). - rustls-native-certs 0.8.4 (exact version pinned in agentgateway v1.3.1’s
Cargo.lock; source read at tagv/0.8.4):load_native_certs()honorsSSL_CERT_FILE/SSL_CERT_DIR, but “if either (or both) are set, certificates are only loaded from the locations specified via environment variables and not the platform-native certificate store.” This is a replace, not append. A bundle containing the full public root set and ICA1 is therefore mandatory — setting either env var to an ICA1-only file would silently break trust for every other public-HTTPS call agentgateway’s data plane makes (LLM backends, etc.). - rustls-native-certs 0.8.4
load_pem_certs_from_dir()(read directly from the pinned-tag source, not context7/deepwiki — this crate isn’t indexed there at this function-body level):SSL_CERT_DIRscans every plain file in the directory (fs::read_dir+metadata.is_file()) and parses all PEM blocks in each — no OpenSSLc_rehashhash-symlink naming required. A directory containing two ordinary ConfigMap-mounted files works. (Mitigation if this reading is somehow wrong: the PR1 live-validation gate below fails fast and loudly rather than silently.) AgentgatewayParametersCRD (agentgateway-crdschart 1.3.1, live schema read viaresources_get):spec.deployment.specis an opaque Strategic-Merge-Patch overlay onto the generated Deployment PodSpec (containers/volumesmerge byname) — not a narrowly-namedextraVolumesfield. Arbitrary volumes/volumeMounts are supported through this overlay; the top-levelenv:field (already used forTZ/ADMIN_ADDR/OTLP vars inparameters.yaml) covers the env-var side.- agentgateway
Dockerfile(v1.3.1): the runtime stage isFROM cgr.dev/chainguard/glibc-dynamic,ENTRYPOINT ["/app/agentgateway"]— no shell, no package manager, no explicitca-certificatesinstall. Confirmed live: the running pod (agentgatewayns) hasreadOnlyRootFilesystem: true,runAsNonRoot: true, all capabilities dropped. This rules out an init-container copy-cat-from-the-runtime-image approach (nothing to execcat/cpwith, and no confirmed on-disk bundle to copy from) and motivates vendoring a maintained public CA bundle as a new artifact instead of trying to extract one at runtime. - Already-merged CoreDNS rewrite (
hl-rs8m/ PR #1538,origin/main, current):argocd/app-configs/coredns-custom/coredns-custom.yamlalready containsrewrite name exact id.fzymgc.house keycloak-internal.keycloak.svc.cluster.local, in the samefzymgc.houseserver block as the pre-existingllm-gw/mcp-gwrewrites.keycloak-internal(argocd/app-configs/keycloak/internal-service.yaml) is a small, non-operator-managed Service forwarding443 → 8443on the same pod selector askeycloak-service— added specifically because a barehttps://id.fzymgc.house(no explicit port, exactly how agentgateway’sauth.issuer-derived DCR/AS-metadata URLs are shaped) defaults to port 443, whichkeycloak-servicedoesn’t serve. This bead adds no CoreDNS change — the routing precondition already exists and already targets the right port-443 shim. - Already-merged analogous CA-trust pattern (
hl-rs8m/ PR #1538): the HCP Terraform Cloud agent pod (argocd/app-configs/hcp-terraform-operator/agent-pool.yaml) solves the identical “trust ICA1 for a call toid.fzymgc.house” problem via an ExternalSecret (ca-trust-secret.yaml, sourcing the same Vault path as this design’s ICA1 needs) plus a same-image initContainer (hashicorp/tfc-agent:1.28.12, which has a shell andupdate-ca-certificates) that rebuilds/etc/ssl/certsinto a sharedemptyDir. Not directly reusable here: agentgateway’s runtime image has no shell (see Dockerfile finding above), so there is no in-image tooling to run such an initContainer against. This design instead reuses the already-replicatedfzymgc-ica1-caConfigMap directly (see next bullet) rather than the TFC pattern’s fresh ExternalSecret —fzymgc-ica1-cais already proven correct for validating this exact same Keycloak cert from agentgateway specifically (the JWKS backend-TLS path), so no new secret-sourcing is needed. - Existing ICA1 artifact:
fzymgc-ica1-ca(ConfigMap, keyca.crt) is already replicated into every namespace by the mittwald replicator (argocd/app-configs/shared-resources/fzymgc-certificates.yaml) and already consumed by Traefik’skeycloak-internal-tlsServersTransport and agentgateway’skeycloak-service-backend-tlspolicy. Reused as-is here — no changes to that ConfigMap or its replication.
Approaches considered
Section titled “Approaches considered”| Approach | Verdict | |
|---|---|---|
Init-container copy-cat (same-image, à la hl-rs8m’s TFC pattern) | Rebuild /etc/ssl/certs in a same-image initContainer with ICA1 added | Rejected. Proven pattern elsewhere in this repo, but agentgateway’s runtime image (chainguard/glibc-dynamic) has no shell/package manager — there is no in-image tooling to run update-ca-certificates or even cat against. Would need a second, different image, which then can’t see the first image’s actual filesystem without extracting it at build time — a build-time concern, not a runtime one. |
SSL_CERT_FILE, single authored bundle | Concatenate a vendored public bundle + ICA1 into one file, commit as a ConfigMap | Workable, but adds a manual “concatenate and keep in sync” step every time either input changes. |
SSL_CERT_DIR, two-file directory (chosen) | Mount the existing fzymgc-ica1-ca ConfigMap and a new vendored public-root ConfigMap into the same directory via a projected volume | Chosen. No new maintenance on the ICA1 side (reused unchanged); the public bundle is vendored independently and refreshed on its own cadence; confirmed workable directly from the crate’s directory-scan source (no hash-symlink requirement). |
| Re-enable Keycloak host-match Trusted-Hosts | Additionally tighten dcr-trusted-hosts-job.yaml so Keycloak itself checks the source host | Out of scope for this bead (explicit scoping decision — see below). Traefik’s re-added network deny is the sole cluster-internal control; Keycloak’s loopback client-URI policy is unchanged. |
| Single PR (trust bundle + deny together) | Ship both remaining changes in one PR, validate before merge | Rejected. Repeats the exact shape of mistake hl-thsf.1 fixed — the deny must not go back in until the trust bundle is proven live-working post-deploy, not just pre-merge. |
| Two-PR sequence (chosen) | PR1: ICA1 trust bundle for agentgateway, merge, live-validate. PR2: re-add Traefik deny, merge, re-validate. | Chosen. Matches this repo’s existing live-gate pattern (hl-thsf/hl-thsf.1) and gives a real post-deploy checkpoint between the two changes. |
Design
Section titled “Design”PR1 — ICA1 trust bundle for agentgateway (CoreDNS precondition already met)
Section titled “PR1 — ICA1 trust bundle for agentgateway (CoreDNS precondition already met)”No CoreDNS change. The id.fzymgc.house → keycloak-internal rewrite already exists
(hl-rs8m, see Grounding) and already resolves agentgateway’s issuer-derived,
no-explicit-port DCR/AS-metadata calls to the right port-443 shim in-cluster.
New file argocd/app-configs/agentgateway/public-ca-bundle.yaml — a ConfigMap
named agentgateway-public-ca-bundle holding curl’s vendored cacert.pem (the
actively-maintained PEM export of Mozilla’s trusted root list), checked into the repo.
argocd/app-configs/agentgateway/parameters.yaml (AgentgatewayParameters CR) —
via the spec.deployment.spec Strategic-Merge-Patch overlay:
- A
projectedvolume namedca-trustcombining the newagentgateway-public-ca-bundleConfigMap and the existingfzymgc-ica1-caConfigMap into one directory. - A matching
volumeMountsentry on theagentgatewaycontainer:ca-trust→/etc/agentgateway/ca-trust. env: SSL_CERT_DIR=/etc/agentgateway/ca-trust(appended to the existingenv:list alongsideTZ/ADMIN_ADDR/OTLP vars).
Data flow after PR1
Section titled “Data flow after PR1”agentgateway (DCR proxy, SYSTEM_TRUST reads SSL_CERT_DIR) → id.fzymgc.house resolves via the existing hl-rs8m CoreDNS rewrite → keycloak-internal ClusterIP:443 (shim, hl-rs8m) → pod:8443 → TLS validated against {public roots + ICA1} → Keycloak clients-registrations → 201 + client_idNo Traefik hop. Traefik’s keycloak-internal-tls path (used by browser/LAN traffic to
id.fzymgc.house) is untouched by this change. The keycloak-internal Service targets
the same pods as keycloak-service, so it presents the identical ICA1-issued cert
(SANs already include id.fzymgc.house, per hl-thsf.1’s own grounding) — only the
trust anchor is missing on agentgateway’s side, which PR1 supplies.
PR2 — re-add the Traefik deny
Section titled “PR2 — re-add the Traefik deny”argocd/app-configs/keycloak/ingress-route.yaml — restore the exact rule shape
hl-thsf.1 removed (recorded in its own spec’s root-cause section): a route rule
Host(id.fzymgc.house) && PathPrefix(/realms/fzymgc/clients-registrations) at
priority 1000, middleware deny-client-registration with
ipAllowList: { sourceRange: [127.0.0.1/32] } (allow-by-enumeration of loopback only
→ deny-all in practice). Remove the current inline comment (lines ~32-38) documenting
why it’s absent — replace with a comment explaining the restored posture and pointing
at this spec / the new ADR.
Trust model after PR2 (the control that’s restored)
Section titled “Trust model after PR2 (the control that’s restored)”Cluster-internal-only DCR is enforced by the Traefik network deny alone — LAN and
external callers can no longer reach id.fzymgc.house/realms/fzymgc/clients-registrations
at the network level, full stop. Keycloak’s app-level Trusted-Hosts policy is
unchanged from its current loopback model
(host-sending-registration-request-must-match=false, client-uris-must-match=true,
trusted-hosts=[localhost,127.0.0.1]) — it remains a secondary, redirect-URI-scoped
control, not the primary one. Re-enabling Keycloak-side host-match was considered and
explicitly scoped out of this bead (see Approaches table) to keep the change focused;
it can be revisited separately if defense-in-depth beyond the network deny is wanted
later.
Security trade-off (explicit)
Section titled “Security trade-off (explicit)”This restores the network-level control ADR hl-xktj originally wanted, closing the
gap ADR hl-amad explicitly accepted as temporary. Residual exposure after this bead:
none new — DCR is cluster-internal at the network layer again, same as the original
hl-xktj intent, achieved via a different mechanism (the hl-rs8m CoreDNS rewrite +
ICA1 pod trust instead of a Keycloak-side ClusterIP-allowlist).
Validation plan
Section titled “Validation plan”After PR1 merge + ArgoCD sync (gate before PR2 may proceed):
- Sanity-check the (already-existing,
hl-rs8m) CoreDNS rewrite is still in place and healthy (CoreDNS logs/metrics — no new debug pods, no behavior change expected here). - AS-metadata discovery (
GET https://mcp-gw.fzymgc.house/.well-known/oauth-authorization-server) still returns 200 with the correctly rewrittenregistration_endpoint. - Primary gate: an actual anonymous
POST /registerthroughmcp-gwsucceeds end-to-end — proves the DCR proxy reaches Keycloak directly viakeycloak-internaland TLS validates against the newSSL_CERT_DIRbundle, without any Traefik hop. - LLM-backend / other public-HTTPS agentgateway routes still work (proves the
SSL_CERT_DIRbundle didn’t regress public-root trust for unrelated backends).
After PR2 merge + ArgoCD sync:
- Re-run gate 3 above — proves the re-added Traefik deny does not regress the now-internal DCR path (it shouldn’t be on that path at all).
- Direct LAN
POSTtoid.fzymgc.house/realms/fzymgc/clients-registrationsis now network-blocked (403 from the Traefik deny, before reaching Keycloak).
Out of scope / follow-ups
Section titled “Out of scope / follow-ups”- The CoreDNS rewrite and
keycloak-internalService — already delivered byhl-rs8m, not authored by this bead. - Re-enabling Keycloak-side Trusted-Hosts host-match (
dcr-trusted-hosts-job.yaml) — explicitly scoped out; the Traefik deny is the sole restored control. hl-g3xy(pre-existing follow-up, from the hl-thsf.1 spec) — upstream agentgateway DCR/metadatabackendRefoverride, the cleanest long-term fix, unaffected by this bead.- New ADR superseding
hl-amad, capturing the final A1+trust posture — deferred to the standardcapture-adrsstep after plan-review, not authored in this spec.