diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/config-cli-job.yaml b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/config-cli-job.yaml index edf3a9d7..af1ee3ba 100644 --- a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/config-cli-job.yaml +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/config-cli-job.yaml @@ -64,10 +64,27 @@ spec: - { name: IMPORT_MANAGED_CLIENT_AUTHORIZATION_RESOURCES, value: "no-delete" } - { name: IMPORT_MANAGED_CLIENT_AUTHORIZATION_POLICIES, value: "no-delete" } - { name: IMPORT_MANAGED_CLIENT_AUTHORIZATION_SCOPES, value: "no-delete" } - # secret substitution — must cover every $(env:...) placeholder in the seeds + # secret substitution — must cover every $(env:...) placeholder in the seeds. + # + # IMPORT_VARSUBSTITUTION_ENABLED is "true" and IMPORT_FILES_LOCATIONS is + # /config/*.json, so substitution runs across the WHOLE BATCH: a seed + # referencing a variable that is not wired here fails substitution for + # EVERY realm file at once, not just its own. That is why the two + # techgarden vars below are wired NOW, in the same PR that adds the + # realm FILE but withholds it from configMapGenerator.files — the flip + # stays a one-line change that cannot take hausparty and kian-coffee + # down with it. - name: KC_CLIENT_HAUSPARTY_SECRET valueFrom: secretKeyRef: { name: keycloak-realm-secrets, key: hausparty-client-secret } + # techgarden realm — not yet imported (see ../kustomization.yaml). + # Wired ahead of the seed on purpose; an unused env var is inert. + - name: KC_CLIENT_TECHGARDEN_GATEWAY_SECRET + valueFrom: + secretKeyRef: { name: keycloak-realm-secrets, key: techgarden-gateway-client-secret } + - name: KC_SMTP_PASSWORD + valueFrom: + secretKeyRef: { name: keycloak-realm-secrets, key: smtp-password } - name: KC_BROKER_HAUSPARTY_SECRET valueFrom: secretKeyRef: { name: keycloak-realm-secrets, key: broker-hausparty-secret } diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/externalsecret-realm.yaml b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/externalsecret-realm.yaml index 11296054..65cfcf9d 100644 --- a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/externalsecret-realm.yaml +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/externalsecret-realm.yaml @@ -17,6 +17,32 @@ spec: - secretKey: hausparty-client-secret remoteRef: key: "7efc9f22-9adb-4d03-86cb-b4240013d5d9" + # techgarden realm — Envoy Gateway OIDC confidential client (WS-03 auth session). + # + # PASSWORD PARITY, NOT DUPLICATION: at the flip the SAME BWS entry is read a + # second time by `techgarden-gateway-oidc` in the `techgarden-web` namespace. + # One entry read twice — never two entries kept in step by hand. If the pair + # ever drifts, the gateway authenticates with a client secret Keycloak does + # not have. Dev implements the identical pattern with a single UUID + # (58eeaf5d…). + - secretKey: techgarden-gateway-client-secret + remoteRef: + # BWS `prod-keycloak-techgarden-gateway` + key: "452e89e3-7083-4eea-a076-b49e00b4df76" + # techgarden realm — Resend SMTP relay password (= Resend API key). SMTP + # username is the literal string "resend"; sending domain mail.techgarden.gg. + # + # SHARED WITH DEV, DELIBERATELY. Resend's free tier allows exactly ONE verified + # sending domain, and there is exactly one `resend` entry in the whole BWS org + # (no prod-specific SMTP secret exists). So prod and dev send as the same + # domain and SHARE the 3,000/month · 100/day allowance — meaning ADR-0052's + # fail-closed-on-429 behaviour is a property the two environments share, and + # dev traffic can exhaust prod's quota. Splitting them requires a paid Resend + # plan and a second verified domain. + - secretKey: smtp-password + remoteRef: + # BWS `resend` — same entry dev reads + key: "7f9f15f5-b70f-4b5a-b3b4-b4990025593b" # accounts realm — broker client secrets (per-env) - secretKey: broker-hausparty-secret remoteRef: diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/keycloak-cr.yaml b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/keycloak-cr.yaml index f5d0facd..447fc73c 100644 --- a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/keycloak-cr.yaml +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/keycloak-cr.yaml @@ -42,3 +42,77 @@ spec: memory: 1Gi limits: memory: 2Gi + + # ── WS-05: TechGarden login + email theme ─────────────────────────────────── + # KeycloakSpec @26.6.3 exposes no supported volume field, so podTemplate is the + # only way to get a ConfigMap into the pod. It is Tech Preview ("unsupported"), + # accepted here because the operator is version-pinned and vendored in-repo + # (base/operator/), so the merge semantics cannot shift under an unplanned + # upgrade. The named alternative — spec.image with a pre-augmented custom image + # — is rejected: spec.image without startOptimized makes the operator append + # --optimized, which forces db/health/metrics build options into a Containerfile + # and a Keycloak publish pipeline homelab does not have. + # + # Mounting at /opt/keycloak/themes/techgarden/ shadows nothing: the 26.6.3 image + # ships only a README.md there, and the built-in base/keycloak/keycloak.v2 + # themes live inside + # /opt/keycloak/lib/lib/main/org.keycloak.keycloak-themes-26.6.3.jar — so the + # theme's parent=base keeps resolving. + # + # NOTHING REFERENCES THIS THEME YET. The prod techgarden realm is withheld from + # configMapGenerator.files (see ../kustomization.yaml), so the theme is mounted + # but unused until the flip. The three live realms are unaffected. + # + # Two mounts, not one: see the collision note in ../kustomization.yaml. items[] + # rebuilds the subdirectory layout that configMapGenerator flattened away. + unsupported: + podTemplate: + metadata: + annotations: + # Keycloak caches themes and templates in production mode (staticMaxAge + # 30 days) and the operator does not watch these ConfigMaps, so new + # theme bytes are NOT served until the pod restarts. The ConfigMaps have + # stable names (no hash suffix), so nothing else forces that restart. + # BUMP THIS on any change under base/themes/ — it is the pod roll. + # + # PROD'S COUNTER IS INDEPENDENT AND STARTS AT 1. Dev is on 2 (it absorbed + # the WS-05 stage-3 emailSentMessage fix). The two clusters' revisions do + # not relate and must never be "synced up" — each only has to CHANGE to + # roll its own pods. + techgarden.gg/theme-revision: "1" + spec: + containers: + - name: keycloak + volumeMounts: + - name: theme-techgarden-login + mountPath: /opt/keycloak/themes/techgarden/login + readOnly: true + - name: theme-techgarden-email + mountPath: /opt/keycloak/themes/techgarden/email + readOnly: true + volumes: + - name: theme-techgarden-login + configMap: + name: keycloak-theme-techgarden-login + items: + - { key: theme.properties, path: theme.properties } + - { key: messages_en.properties, path: messages/messages_en.properties } + - { key: login.css, path: resources/css/login.css } + - { key: OFL-bricolage-grotesque.txt, path: resources/fonts/OFL-bricolage-grotesque.txt } + - { key: OFL-hanken-grotesk.txt, path: resources/fonts/OFL-hanken-grotesk.txt } + - { key: bricolage-grotesque-latin-ext.woff2, path: resources/fonts/bricolage-grotesque-latin-ext.woff2 } + - { key: bricolage-grotesque-latin.woff2, path: resources/fonts/bricolage-grotesque-latin.woff2 } + - { key: hanken-grotesk-latin-ext.woff2, path: resources/fonts/hanken-grotesk-latin-ext.woff2 } + - { key: hanken-grotesk-latin.woff2, path: resources/fonts/hanken-grotesk-latin.woff2 } + - { key: favicon.ico, path: resources/img/favicon.ico } + - { key: logo.png, path: resources/img/logo.png } + - name: theme-techgarden-email + configMap: + name: keycloak-theme-techgarden-email + items: + - { key: theme.properties, path: theme.properties } + - { key: messages_en.properties, path: messages/messages_en.properties } + - { key: email-verification.ftl, path: html/email-verification.ftl } + - { key: executeActions.ftl, path: html/executeActions.ftl } + - { key: password-reset.ftl, path: html/password-reset.ftl } + - { key: template.ftl, path: html/template.ftl } diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/realms/techgarden-realm.json b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/realms/techgarden-realm.json new file mode 100644 index 00000000..4a60eb4a --- /dev/null +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/realms/techgarden-realm.json @@ -0,0 +1,118 @@ +{ + "realm": "techgarden", + "enabled": true, + "accessTokenLifespan": 600, + "revokeRefreshToken": true, + "refreshTokenMaxReuse": 2, + "ssoSessionIdleTimeout": 2592000, + "ssoSessionMaxLifespan": 5184000, + "rememberMe": true, + "ssoSessionIdleTimeoutRememberMe": 2592000, + "ssoSessionMaxLifespanRememberMe": 5184000, + "offlineSessionMaxLifespanEnabled": true, + "offlineSessionMaxLifespan": 5184000, + "registrationAllowed": false, + "resetPasswordAllowed": true, + "verifyEmail": true, + "actionTokenGeneratedByAdminLifespan": 259200, + "actionTokenGeneratedByUserLifespan": 300, + "attributes": { + "actionTokenGeneratedByUserLifespan.verify-email": "43200", + "actionTokenGeneratedByUserLifespan.reset-credentials": "1800" + }, + "loginTheme": "techgarden", + "emailTheme": "techgarden", + "smtpServer": { + "host": "smtp.resend.com", + "port": "587", + "starttls": "true", + "ssl": "false", + "auth": "true", + "user": "resend", + "password": "$(env:KC_SMTP_PASSWORD)", + "from": "no-reply@mail.techgarden.gg", + "fromDisplayName": "TechGarden" + }, + "roles": { + "realm": [ + { + "name": "admin", + "description": "TechGarden administrator" + }, + { + "name": "ai", + "description": "AI feature entitlement (WS-27) — flat realm role, owner only. iris maps realm_access.roles to ROLE_AI; keep it non-composite and lowercase or the authority never appears." + } + ] + }, + "clients": [ + { + "clientId": "iris-web", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "https://techgarden.gg/*" + ], + "webOrigins": [ + "https://techgarden.gg" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "https://techgarden.gg/*" + }, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ] + }, + { + "clientId": "techgarden-gateway", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "$(env:KC_CLIENT_TECHGARDEN_GATEWAY_SECRET)", + "protocol": "openid-connect", + "publicClient": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "https://techgarden.gg/oauth2/callback" + ], + "webOrigins": [ + "https://techgarden.gg" + ], + "attributes": { + "post.logout.redirect.uris": "https://techgarden.gg/*" + }, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ] + } + ], + "users": [ + { + "username": "kian", + "enabled": true, + "emailVerified": true, + "realmRoles": [ + "admin", + "ai" + ] + } + ] +} diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/README.md b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/README.md new file mode 100644 index 00000000..fa9690bb --- /dev/null +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/README.md @@ -0,0 +1,35 @@ +# TechGarden Keycloak theme (`techgarden`) — prod copy + +The 17 files under `techgarden/` are **byte-identical to the dev copy** at +`kubernetes/clusters/1276-dev/keycloak/keycloak/base/themes/techgarden/`. + +**The dev copy's README is the canonical documentation** — how the theme is built, +which surfaces it themes, how to iterate on it locally against a real 26.6.3 +container, the MessageFormat quoting trap, the shared-message-key trap, and the OFL +font licence obligation. It is deliberately **not duplicated here**: that README +itself warns that a stale second copy nobody deletes is the failure mode WS-05 was +shaped to avoid. Read it there; change the theme in both places in one PR. + +## What is different in prod + +**Nothing in the theme files.** Only two things about how they are wired: + +1. **`techgarden.gg/theme-revision` starts at `1` and counts independently.** + Dev is on `2` (it absorbed the WS-05 stage-3 `emailSentMessage` fix). The two + clusters' revision numbers do not relate and must never be "synced up" — each + one only has to *change* to force its own pod roll. Bump prod's on every change + under `base/themes/`. + +2. **The realm that would use this theme is not loaded yet.** + `base/realms/techgarden-realm.json` exists but is deliberately absent from + `configMapGenerator.files` in `../../kustomization.yaml`. Until that one line is + added at the flip, `https://sso.techgarden.gg/realms/techgarden` returns 404 and + this theme is mounted but unreferenced. That is the intended state. + +## Why the mount is two ConfigMaps, not one + +`configMapGenerator` keys off each file's **base name**, and ConfigMap keys cannot +contain `/`. `theme.properties` and `messages_en.properties` each exist under **both** +`login/` and `email/`, so a single ConfigMap collides on those two keys. One ConfigMap +per theme type removes the collision without renaming anything; the volume's +`items[].path` then rebuilds the subdirectory layout that the generator flattened away. diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/email-verification.ftl b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/email-verification.ftl new file mode 100644 index 00000000..be36131f --- /dev/null +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/email-verification.ftl @@ -0,0 +1,11 @@ +<#-- + Overrides base/email/html/email-verification.ftl. See password-reset.ftl for why + the CTA lives in the template rather than the message. + {0}=link {1}=linkExpiration {2}=realmName {3}=formatted expiration +--> +<#import "template.ftl" as layout> +<@layout.emailLayout> +${kcSanitize(msg("emailVerificationBodyHtml", link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration)))?no_esc} +<@layout.emailButton href=link label=msg("emailVerificationButton") /> +${kcSanitize(msg("emailVerificationFooterHtml", linkExpirationFormatter(linkExpiration)))?no_esc} + diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/executeActions.ftl b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/executeActions.ftl new file mode 100644 index 00000000..9e58f20b --- /dev/null +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/executeActions.ftl @@ -0,0 +1,21 @@ +<#-- + Overrides base/email/html/executeActions.ftl -- the account-creation invite. + + This is the email WS-04 proved as the hand-provisioning trigger (Admin REST + PUT /admin/realms/{realm}/users/{id}/execute-actions-email). Per ADR-0052 there is + no self-service signup, so for a friend joining TechGarden this is the FIRST email + they ever receive from us. It gets the same button treatment as the others. + + {0}=link {1}=linkExpiration {2}=realmName {3}=requiredActionsText + {4}=formatted expiration +--> +<#outputformat "plainText"> +<#assign requiredActionsText><#if requiredActions??><#list requiredActions><#items as reqActionItem>${msg("requiredAction.${reqActionItem}")}<#sep>, + + +<#import "template.ftl" as layout> +<@layout.emailLayout> +${kcSanitize(msg("executeActionsBodyHtml", link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration)))?no_esc} +<@layout.emailButton href=link label=msg("executeActionsButton") /> +${kcSanitize(msg("executeActionsFooterHtml", linkExpirationFormatter(linkExpiration)))?no_esc} + diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/password-reset.ftl b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/password-reset.ftl new file mode 100644 index 00000000..8d2c6c32 --- /dev/null +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/password-reset.ftl @@ -0,0 +1,12 @@ +<#-- + Overrides base/email/html/password-reset.ftl so the reset link renders as a real + button. The prose still comes from the message bundle (passwordResetBodyHtml); + only the CTA moved into the template, because message HTML is sanitized. + {0}=link {1}=linkExpiration {2}=realmName {3}=formatted expiration +--> +<#import "template.ftl" as layout> +<@layout.emailLayout> +${kcSanitize(msg("passwordResetBodyHtml", link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration)))?no_esc} +<@layout.emailButton href=link label=msg("passwordResetButton") /> +${kcSanitize(msg("passwordResetFooterHtml", linkExpirationFormatter(linkExpiration)))?no_esc} + diff --git a/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/template.ftl b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/template.ftl new file mode 100644 index 00000000..2db51edd --- /dev/null +++ b/kubernetes/clusters/1276-prod/keycloak/keycloak/base/themes/techgarden/email/html/template.ftl @@ -0,0 +1,96 @@ +<#-- + TechGarden branded email layout (WS-05). + + Overrides base/email/html/template.ftl. Every html/*.ftl in the base theme imports + this macro, so branding it once covers password-reset, email-verification and + executeActions (the account-creation invite) without touching those files. + + Deliberate constraints, all of them email-client reality rather than preference: + - Table layout and INLINE styles. Gmail strips