diff --git a/tf/deployment/.env b/tf/deployment/.env index eb18e9cf3..1f30c5bf9 100644 --- a/tf/deployment/.env +++ b/tf/deployment/.env @@ -17,6 +17,7 @@ export TF_VAR_zitadel_github_client_secret="op://tf/GITHUB_OAUTH_APP_IMMICH_ZITA export TF_VAR_futo_internal_zitadel_github_client_id="op://tf/IMMICH_GITHUB_OAUTH_APP_INTERNAL_FUTO_ZITADEL_CLIENT_ID/password" export TF_VAR_futo_internal_zitadel_github_client_secret="op://tf/IMMICH_GITHUB_OAUTH_APP_INTERNAL_FUTO_ZITADEL_CLIENT_SECRET/password" export TF_VAR_futo_zitadel_profile_json="op://tf/FUTO_ZITADEL_PROFILE_JSON/password" +export TF_VAR_futo_zitadel_base_domain="op://tf/FUTO_ZITADEL_BASE_DOMAIN/password" export TF_VAR_zitadel_gitlab_client_id="op://tf/GITLAB_OAUTH_APP_FUTO_ZITADEL_CLIENT_ID/password" export TF_VAR_zitadel_gitlab_client_secret="op://tf/GITLAB_OAUTH_APP_FUTO_ZITADEL_CLIENT_SECRET/password" export TF_VAR_zitadel_gitlab_issuer="op://tf/GITLAB_OAUTH_APP_FUTO_ZITADEL_ISSUER/password" diff --git a/tf/deployment/modules/scoped/zitadel/customer/README.md b/tf/deployment/modules/scoped/zitadel/customer/README.md index 61ce71aad..14baeec5c 100644 --- a/tf/deployment/modules/scoped/zitadel/customer/README.md +++ b/tf/deployment/modules/scoped/zitadel/customer/README.md @@ -32,6 +32,7 @@ before Terraform can run. Do this in the ZITADEL Cloud console and 1Password: seeds stub items in `yucca_tf_${env}_manual` with `REPLACE_ME` passwords for: - `CUSTOMER_ZITADEL_DOMAIN` + - `CUSTOMER_ZITADEL_BASE_DOMAIN` - `CUSTOMER_ZITADEL_PROFILE_JSON` - `CUSTOMER_ZITADEL_SMTP_HOST` - `CUSTOMER_ZITADEL_SMTP_USER` @@ -39,9 +40,17 @@ before Terraform can run. Do this in the ZITADEL Cloud console and 1Password: - `CUSTOMER_ZITADEL_SMTP_SENDER_ADDRESS` Replace each stub password in `yucca_tf_${env}_manual` with the real value - (`CUSTOMER_ZITADEL_DOMAIN` = the instance URL `.zitadel.cloud`, profile - JSON from step 3, SMTP credentials for the chosen provider, and sender - address e.g. `no-reply@futo.cloud`). + (`CUSTOMER_ZITADEL_DOMAIN` = the public-facing auth URL — prod: the + `auth.futo.cloud` vanity domain, dev/staging: the generated + `.zitadel.cloud` URL — consumed by app-side config, not this module; + `CUSTOMER_ZITADEL_BASE_DOMAIN` = the generated `.zitadel.cloud` instance + host as a **bare hostname** (no `https://`, no trailing slash) in every env, + which this module both connects through (provider + hosted login) and targets + with the `auth.futo.cloud` CNAME, so the connection never depends on the + mutable vanity domain. Stored bare to match the internal instance's + `FUTO_ZITADEL_BASE_DOMAIN`. Profile JSON from step 3, SMTP + credentials for the chosen provider, and sender address e.g. + `no-reply@futo.cloud`). Re-apply `shared/1password/futo-account` so the copy-secrets module mirrors each value into the `yucca_tf_${env}` vault that this module reads from via `data "onepassword_item"` lookups at plan/apply time. No diff --git a/tf/deployment/modules/scoped/zitadel/customer/data.tf b/tf/deployment/modules/scoped/zitadel/customer/data.tf index f7a656f84..2e4ef4010 100644 --- a/tf/deployment/modules/scoped/zitadel/customer/data.tf +++ b/tf/deployment/modules/scoped/zitadel/customer/data.tf @@ -2,9 +2,13 @@ data "onepassword_vault" "tf_env" { name = "yucca_tf_${var.env}" } -data "onepassword_item" "customer_zitadel_domain" { +# NOTE: CUSTOMER_ZITADEL_DOMAIN (the public auth.futo.cloud vanity domain) is +# intentionally not read here — this module connects to the instance via the +# stable .zitadel.cloud base domain below. The vanity domain remains the +# public-facing auth URL consumed by app-side config. +data "onepassword_item" "customer_zitadel_base_domain" { vault = data.onepassword_vault.tf_env.uuid - title = "CUSTOMER_ZITADEL_DOMAIN" + title = "CUSTOMER_ZITADEL_BASE_DOMAIN" } data "onepassword_item" "customer_zitadel_profile_json" { diff --git a/tf/deployment/modules/scoped/zitadel/customer/dns.tf b/tf/deployment/modules/scoped/zitadel/customer/dns.tf index 082515a1a..43d6038b2 100644 --- a/tf/deployment/modules/scoped/zitadel/customer/dns.tf +++ b/tf/deployment/modules/scoped/zitadel/customer/dns.tf @@ -13,22 +13,20 @@ data "cloudflare_zone" "futo_cloud" { } } -locals { - # CUSTOMER_ZITADEL_DOMAIN is stored as a URL (https://host/); Cloudflare needs - # a bare hostname as CNAME content. - customer_zitadel_host = trimsuffix(trimprefix(data.onepassword_item.customer_zitadel_domain.password, "https://"), "/") -} - -# Prod only: auth.futo.cloud -> the prod ZITADEL Cloud instance host from -# CUSTOMER_ZITADEL_DOMAIN. dev/staging use the generated .zitadel.cloud URL -# directly with no custom domain. DNS-only so ZITADEL terminates TLS. +# Prod only: auth.futo.cloud -> the prod ZITADEL Cloud instance's base +# .zitadel.cloud host from CUSTOMER_ZITADEL_BASE_DOMAIN, which is stored as +# a bare hostname (no scheme) — exactly what a CNAME wants. This is NOT the +# auth.futo.cloud vanity domain that CUSTOMER_ZITADEL_DOMAIN holds; pointing the +# record at the vanity domain would make the CNAME reference itself. dev/staging +# have no custom domain and use the generated URL directly. DNS-only so ZITADEL +# terminates TLS. resource "cloudflare_dns_record" "customer_auth" { count = var.env == "prod" ? 1 : 0 zone_id = data.cloudflare_zone.futo_cloud.id name = "auth.futo.cloud" type = "CNAME" - content = local.customer_zitadel_host + content = data.onepassword_item.customer_zitadel_base_domain.password ttl = 1 proxied = false } diff --git a/tf/deployment/modules/scoped/zitadel/customer/hosted_login_translations.tf b/tf/deployment/modules/scoped/zitadel/customer/hosted_login_translations.tf index 3ecd3385a..4f54c030e 100644 --- a/tf/deployment/modules/scoped/zitadel/customer/hosted_login_translations.tf +++ b/tf/deployment/modules/scoped/zitadel/customer/hosted_login_translations.tf @@ -12,7 +12,7 @@ resource "null_resource" "hosted_login_translations" { triggers = { translations_hash = filemd5(local.hosted_login_translations_file) script_hash = filemd5(local.hosted_login_translations_script) - domain = data.onepassword_item.customer_zitadel_domain.password + domain = data.onepassword_item.customer_zitadel_base_domain.password locale = local.hosted_login_translations_locale } @@ -20,7 +20,8 @@ resource "null_resource" "hosted_login_translations" { command = local.hosted_login_translations_script environment = { - ZITADEL_DOMAIN = data.onepassword_item.customer_zitadel_domain.password + # Connect to the API via the stable base domain, not the vanity domain. + ZITADEL_DOMAIN = data.onepassword_item.customer_zitadel_base_domain.password ZITADEL_PROFILE_JSON = data.onepassword_item.customer_zitadel_profile_json.password TRANSLATIONS_FILE = local.hosted_login_translations_file LOCALE = local.hosted_login_translations_locale diff --git a/tf/deployment/modules/scoped/zitadel/customer/providers.tf b/tf/deployment/modules/scoped/zitadel/customer/providers.tf index d57926bf7..bd86fb2a0 100644 --- a/tf/deployment/modules/scoped/zitadel/customer/providers.tf +++ b/tf/deployment/modules/scoped/zitadel/customer/providers.tf @@ -1,5 +1,7 @@ +# Connect via the stable .zitadel.cloud base domain, not the auth.futo.cloud +# vanity domain (CUSTOMER_ZITADEL_DOMAIN) which is an alias that could change. provider "zitadel" { - domain = data.onepassword_item.customer_zitadel_domain.password + domain = data.onepassword_item.customer_zitadel_base_domain.password insecure = false jwt_profile_json = data.onepassword_item.customer_zitadel_profile_json.password } diff --git a/tf/deployment/modules/shared/zitadel/cloud/actions-v2.tf b/tf/deployment/modules/shared/zitadel/cloud/actions-v2.tf index 85c70454b..8a56d92f3 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/actions-v2.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/actions-v2.tf @@ -82,7 +82,7 @@ resource "cloudflare_worker_version" "zitadel_actions" { }] bindings = [ - { name = "ZITADEL_DOMAIN", type = "plain_text", text = "auth.internal.futo.org" }, + { name = "ZITADEL_DOMAIN", type = "plain_text", text = var.futo_zitadel_base_domain }, { name = "GITHUB_IDP_ID", type = "plain_text", text = zitadel_idp_github.github.id }, { name = "GITLAB_IDP_ID", type = "plain_text", text = zitadel_idp_gitlab_self_hosted.gitlab.id }, { name = "ZITADEL_TOKEN", type = "secret_text", text = zitadel_personal_access_token.zitadel_actions.token }, diff --git a/tf/deployment/modules/shared/zitadel/cloud/branding.tf b/tf/deployment/modules/shared/zitadel/cloud/branding.tf index 83a9899a4..fdeb8e773 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/branding.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/branding.tf @@ -53,7 +53,7 @@ resource "zitadel_default_privacy_policy" "default" { locals { hosted_login_translations_file = "${path.module}/translations/en.json" hosted_login_translations_script = "${path.module}/scripts/set-hosted-login-translations.sh" - hosted_login_translations_domain = "auth.internal.futo.org" + hosted_login_translations_domain = var.futo_zitadel_base_domain hosted_login_translations_locale = "en" } diff --git a/tf/deployment/modules/shared/zitadel/cloud/idp-links.tf b/tf/deployment/modules/shared/zitadel/cloud/idp-links.tf index 189ca7d29..99d842f96 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/idp-links.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/idp-links.tf @@ -54,7 +54,7 @@ resource "terraform_data" "idp_link" { idp_id = each.value.idp_id external_user_id = each.value.external_user_id user_name = each.value.user_name - domain = "auth.internal.futo.org" + domain = var.futo_zitadel_base_domain token = zitadel_personal_access_token.zitadel_actions.token } diff --git a/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf b/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf index e5f1fa757..a83b5cd50 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf @@ -47,7 +47,7 @@ resource "cloudflare_worker_version" "outline_role_sync" { bindings = [ { name = "OUTLINE_BASE_URL", type = "plain_text", text = "https://outline.immich.cloud" }, - { name = "ZITADEL_BASE_URL", type = "plain_text", text = "https://auth.internal.futo.org" }, + { name = "ZITADEL_BASE_URL", type = "plain_text", text = "https://${var.futo_zitadel_base_domain}" }, { name = "ZITADEL_OUTLINE_PROJECT_ID", type = "plain_text", text = zitadel_project.projects["Outline"].id }, { name = "ZITADEL_OUTLINE_PROJECT_ROLES", type = "plain_text", text = join(",", local.outline_role_keys) }, { name = "ZITADEL_SERVICE_ACCOUNT_TOKEN", type = "secret_text", text = zitadel_personal_access_token.outline_role_sync.token }, diff --git a/tf/deployment/modules/shared/zitadel/cloud/providers.tf b/tf/deployment/modules/shared/zitadel/cloud/providers.tf index 75f34ba3a..e7ca6e779 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/providers.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/providers.tf @@ -1,5 +1,7 @@ +# Connect via the stable .zitadel.cloud base domain, not the +# auth.internal.futo.org custom domain which is a mutable alias. provider "zitadel" { - domain = "auth.internal.futo.org" + domain = var.futo_zitadel_base_domain insecure = false jwt_profile_json = var.futo_zitadel_profile_json } diff --git a/tf/deployment/modules/shared/zitadel/cloud/variables.tf b/tf/deployment/modules/shared/zitadel/cloud/variables.tf index 4758b755f..848341cc9 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/variables.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/variables.tf @@ -8,6 +8,11 @@ variable "op_connect_token" { variable "zitadel_profile_json" {} variable "futo_zitadel_profile_json" {} +# The stable .zitadel.cloud base host for the FUTO internal instance. Used +# for all API connections (provider, hosted-login + idp-link scripts) instead of +# the auth.internal.futo.org custom domain, which is a mutable alias. +variable "futo_zitadel_base_domain" {} + variable "users_data_file_path" { description = "The path to the JSON file containing user data. This path should be resolvable from the Terragrunt execution directory or be an absolute path." type = string