|
| 1 | +# Customer-facing ZITADEL |
| 2 | + |
| 3 | +B2C ZITADEL Cloud instance used to hold **customer** end-user accounts for FUTO |
| 4 | +products. Separate from the internal staff SSO instances under |
| 5 | +`modules/shared/zitadel/`. |
| 6 | + |
| 7 | +One Cloud instance per environment (same Cloud account as the internal FUTO |
| 8 | +instance), with state and secrets scoped per `TF_VAR_env` (`dev`, `prod`). |
| 9 | + |
| 10 | +## Bootstrap (manual, once per environment) |
| 11 | + |
| 12 | +The ZITADEL provider needs an existing instance + a machine-user JWT profile |
| 13 | +before Terraform can run. Do this in the ZITADEL Cloud console and 1Password: |
| 14 | + |
| 15 | +1. Create a new instance for customer auth in the FUTO ZITADEL Cloud account. |
| 16 | + One per env — prod first, dev later (or vice versa). |
| 17 | +2. Add the custom domain: |
| 18 | + - prod → `auth.futo.tech` |
| 19 | + - dev → `auth.dev.futo.tech` (or chosen equivalent) |
| 20 | + |
| 21 | + DNS is managed via the existing Cloudflare modules. |
| 22 | +3. In each instance, create a machine user with role `IAM_OWNER`, generate a |
| 23 | + JWT key, and download the profile JSON. |
| 24 | +4. Apply `modules/shared/1password/futo-account` — the manual-secrets module |
| 25 | + seeds stub items in `yucca_tf_${env}_manual` with `REPLACE_ME` passwords |
| 26 | + for: |
| 27 | + - `CUSTOMER_ZITADEL_DOMAIN` |
| 28 | + - `CUSTOMER_ZITADEL_PROFILE_JSON` |
| 29 | + - `CUSTOMER_ZITADEL_SMTP_HOST` |
| 30 | + - `CUSTOMER_ZITADEL_SMTP_USER` |
| 31 | + - `CUSTOMER_ZITADEL_SMTP_PASSWORD` |
| 32 | + - `CUSTOMER_ZITADEL_SMTP_SENDER_ADDRESS` |
| 33 | + |
| 34 | + Replace each stub password in `yucca_tf_${env}_manual` with the real value |
| 35 | + (custom domain from step 2, profile JSON from step 3, SMTP credentials |
| 36 | + for the chosen provider, and sender address e.g. `no-reply@futo.tech`). |
| 37 | + Re-apply `shared/1password/futo-account` so the copy-secrets module |
| 38 | + mirrors each value into the `yucca_tf_${env}` vault that this module |
| 39 | + reads from via `data "onepassword_item"` lookups at plan/apply time. No |
| 40 | + `.env` changes required for these, since the `onepassword` provider here |
| 41 | + uses `service_account_token = var.futo_op_service_account_token` which is |
| 42 | + already globally wired in `tf/deployment/.env`. |
| 43 | + |
| 44 | +## Adding a new product OIDC client |
| 45 | + |
| 46 | +Edit `projects.tf` and append to `local.projects_data`: |
| 47 | + |
| 48 | +```hcl |
| 49 | +{ |
| 50 | + name = "Immich Web" |
| 51 | + authMethod = "NONE" # "BASIC" for confidential clients |
| 52 | + appType = "WEB" # or "NATIVE", "USER_AGENT" |
| 53 | + grantTypes = ["AUTHORIZATION_CODE"] |
| 54 | + redirectUris = ["https://app.immich.cloud/auth/callback"] |
| 55 | +}, |
| 56 | +``` |
| 57 | + |
| 58 | +Apply the module. `client_id` and `client_secret` land in 1Password under |
| 59 | +`CUSTOMER_ZITADEL_OAUTH_CLIENT_ID_<NAME>` / `..._CLIENT_SECRET_<NAME>` in the |
| 60 | +`yucca_tf_$ENVIRONMENT` vault (FUTO 1Password account). |
| 61 | + |
| 62 | +## Adding a machine user for a product backend |
| 63 | + |
| 64 | +When a product backend needs to call the ZITADEL API (e.g. user lookup), add |
| 65 | +a `machine_users.tf` following the pattern in |
| 66 | +`modules/shared/zitadel/cloud/outline-role-sync.tf` — machine user + PAT + |
| 67 | +`onepassword_item` + `zitadel_org_member` on `zitadel_org.customers` with the |
| 68 | +least-privilege role that fits the use case (typically `ORG_USER_MANAGER`). |
| 69 | + |
| 70 | +## Adding a social IDP (Google / Apple / GitHub) |
| 71 | + |
| 72 | +1. Store the OAuth app `client_id` / `client_secret` in 1Password and add the |
| 73 | + `TF_VAR_*` lines to `.env`. |
| 74 | +2. Add a new `idps.tf` with the relevant `zitadel_idp_*` resource. |
| 75 | +3. Reference it from `zitadel_default_login_policy.default.idps` in |
| 76 | + `defaults.tf` (replace the current empty list). |
| 77 | + |
| 78 | +## Branding |
| 79 | + |
| 80 | +Colours live in `branding.tf` and track the FUTO palette from futo.tech |
| 81 | +(`#3c9eea` primary, `#02070a` dark bg, `#cfeaff` dark font, `#f2b859` warn). |
| 82 | + |
| 83 | +Logo / icon assets live under `assets/`: |
| 84 | + |
| 85 | +- `assets/logo.svg` — the FUTO wordmark (blue gradient SVG lifted from the |
| 86 | + futo.tech header). Transparent background. Used for both light and dark |
| 87 | + themes via `logo_path` / `logo_dark_path` so it adapts to either. |
| 88 | +- `assets/icon.svg` — square FUTO "O" symbol extracted from the wordmark |
| 89 | + (same blue gradient, centred in a 24×24 viewBox). Transparent by design. |
| 90 | + Used for both themes via `icon_path` / `icon_dark_path`. |
| 91 | + |
| 92 | +To swap an asset, drop the new file into `assets/` keeping the same filename; |
| 93 | +the `filemd5(...)` hash in `branding.tf` will detect the change and re-upload |
| 94 | +on next apply. If you need distinct light/dark variants, add the new file |
| 95 | +(e.g. `logo-dark.svg`), point `logo_dark_path` / `logo_dark_hash` at it, and |
| 96 | +update this README. |
| 97 | + |
| 98 | +`assets/*` is whitelisted via `include_in_copy` in `terragrunt.hcl` so the |
| 99 | +directory makes it into the terragrunt working copy. |
| 100 | + |
| 101 | +## Hosted login (v2) translations |
| 102 | + |
| 103 | +The v2 TypeScript login UI ships its own bundled locale files. Three strings |
| 104 | +in the default `en.json` reference "ZITADEL" by name; the ZITADEL terraform |
| 105 | +provider does not currently expose the `SettingsService.SetHostedLoginTranslation` |
| 106 | +endpoint that the v2 UI reads custom overrides from, so we push them with a |
| 107 | +local-exec script. |
| 108 | + |
| 109 | +- `translations/en.json` — only the keys being overridden (same nested shape |
| 110 | + as [zitadel/zitadel `apps/login/locales/en.json`](https://github.com/zitadel/zitadel/blob/main/apps/login/locales/en.json)); |
| 111 | + the v2 UI deep-merges these on top of its bundled defaults. |
| 112 | +- `scripts/set-hosted-login-translations.sh` — signs a client-assertion JWT |
| 113 | + from the 1Password-stored machine-user profile, exchanges it for an access |
| 114 | + token via `urn:ietf:params:oauth:grant-type:jwt-bearer`, and `PUT`s the JSON |
| 115 | + to `/v2/settings/hosted_login_translation`. |
| 116 | +- `hosted_login_translations.tf` — a `null_resource` whose `triggers` hash the |
| 117 | + JSON + script + domain + locale, so the script re-runs only when something |
| 118 | + real changes. Runtime deps: `curl`, `jq`, `openssl`. |
| 119 | +- `defaults.tf` sets `zitadel_instance_restrictions.allowed_languages = ["en"]`, |
| 120 | + so we don't have to maintain overrides for other locales. |
| 121 | + |
| 122 | +To edit copy: change `translations/en.json`, re-apply the module. Plan output |
| 123 | +only shows the trigger hash changing — use `git diff translations/en.json` to |
| 124 | +see the actual text diff. Verify after apply with: |
| 125 | + |
| 126 | +```sh |
| 127 | +curl -H "Authorization: Bearer $TOKEN" \ |
| 128 | + "https://$ZITADEL_DOMAIN/v2/settings/hosted_login_translation?locale=en&instance=true" |
| 129 | +``` |
| 130 | + |
| 131 | +## Intentionally not present |
| 132 | + |
| 133 | +- `idp.tf` — no social IDPs at launch. |
| 134 | +- `actions.tf` — no custom login hooks needed (no GitHub/GitLab attribute |
| 135 | + mapping, no cross-product role-grant logic). |
| 136 | +- `users.tf` / `permissions.tf` — customers self-register; no user |
| 137 | + provisioning or pre-defined user grants. |
| 138 | +- `login_texts` / per-message text overrides — default ZITADEL copy is fine |
| 139 | + to start. Add `default_login_texts` / `default_*_message_text` resources if |
| 140 | + FUTO-signed copy is needed later. |
0 commit comments