Skip to content

Commit 799d9b5

Browse files
authored
feat: futo customer auth zitadel IaC (#1525)
1 parent 1eb354f commit 799d9b5

20 files changed

Lines changed: 745 additions & 1 deletion

File tree

tf/deployment/modules/scoped/zitadel/customer/.terraform.lock.hcl

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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.
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// FUTO brand palette, sourced from futo.tech design tokens:
2+
// primary = accent-blue-400 (#3c9eea)
3+
// warn = accent-yellow-400 (#f2b859)
4+
// bg dark = neutral-blue-950 (#02070a)
5+
// font dk = neutral-blue-200 (#cfeaff)
6+
resource "zitadel_default_label_policy" "default" {
7+
primary_color = "#3c9eea"
8+
warn_color = "#f2b859"
9+
background_color = "#ffffff"
10+
font_color = "#02070a"
11+
12+
primary_color_dark = "#3c9eea"
13+
warn_color_dark = "#f2b859"
14+
background_color_dark = "#02070a"
15+
font_color_dark = "#cfeaff"
16+
17+
hide_login_name_suffix = true
18+
disable_watermark = true
19+
theme_mode = "THEME_MODE_AUTO"
20+
21+
logo_path = "${path.module}/assets/logo.svg"
22+
logo_hash = filemd5("${path.module}/assets/logo.svg")
23+
24+
logo_dark_path = "${path.module}/assets/logo.svg"
25+
logo_dark_hash = filemd5("${path.module}/assets/logo.svg")
26+
27+
icon_path = "${path.module}/assets/icon.svg"
28+
icon_hash = filemd5("${path.module}/assets/icon.svg")
29+
30+
icon_dark_path = "${path.module}/assets/icon.svg"
31+
icon_dark_hash = filemd5("${path.module}/assets/icon.svg")
32+
33+
set_active = true
34+
}
35+
36+
resource "zitadel_default_privacy_policy" "default" {
37+
tos_link = "https://futo.tech/terms"
38+
privacy_link = "https://futo.tech/privacy"
39+
help_link = "https://futo.tech/help"
40+
support_email = "support@futo.tech"
41+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
terraform {
2+
backend "pg" {}
3+
required_version = "~> 1.7"
4+
5+
required_providers {
6+
zitadel = {
7+
source = "zitadel/zitadel"
8+
version = "2.11.0"
9+
}
10+
onepassword = {
11+
source = "1Password/onepassword"
12+
version = "~> 2.1"
13+
}
14+
null = {
15+
source = "hashicorp/null"
16+
version = "~> 3.2"
17+
}
18+
}
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
data "onepassword_vault" "tf_env" {
2+
name = "yucca_tf_${var.env}"
3+
}
4+
5+
data "onepassword_item" "customer_zitadel_domain" {
6+
vault = data.onepassword_vault.tf_env.uuid
7+
title = "CUSTOMER_ZITADEL_DOMAIN"
8+
}
9+
10+
data "onepassword_item" "customer_zitadel_profile_json" {
11+
vault = data.onepassword_vault.tf_env.uuid
12+
title = "CUSTOMER_ZITADEL_PROFILE_JSON"
13+
}
14+
15+
data "onepassword_item" "customer_zitadel_smtp_host" {
16+
vault = data.onepassword_vault.tf_env.uuid
17+
title = "CUSTOMER_ZITADEL_SMTP_HOST"
18+
}
19+
20+
data "onepassword_item" "customer_zitadel_smtp_user" {
21+
vault = data.onepassword_vault.tf_env.uuid
22+
title = "CUSTOMER_ZITADEL_SMTP_USER"
23+
}
24+
25+
data "onepassword_item" "customer_zitadel_smtp_password" {
26+
vault = data.onepassword_vault.tf_env.uuid
27+
title = "CUSTOMER_ZITADEL_SMTP_PASSWORD"
28+
}
29+
30+
data "onepassword_item" "customer_zitadel_smtp_sender_address" {
31+
vault = data.onepassword_vault.tf_env.uuid
32+
title = "CUSTOMER_ZITADEL_SMTP_SENDER_ADDRESS"
33+
}

0 commit comments

Comments
 (0)