diff --git a/.gitignore b/.gitignore index ffc2a33..7afa6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,11 @@ k8s/storage/test/test terraform/.terraform/ terraform/*.tfstate terraform/*.tfstate.* +# The Zitadel root module keeps its own state (see terraform/zitadel/providers.tf). +# That state carries generated client secrets and machine keys. +terraform/zitadel/*.tfstate +terraform/zitadel/*.tfstate.* +terraform/zitadel/*.tfvars terraform.tfvars.local skills-lock.json secrets-plaintext.yaml diff --git a/terraform/zitadel/actions.tf b/terraform/zitadel/actions.tf new file mode 100644 index 0000000..6cc4560 --- /dev/null +++ b/terraform/zitadel/actions.tf @@ -0,0 +1,58 @@ +# The groups claim — the single load-bearing decision of the migration. +# +# ZITADEL does not issue a `groups` claim. Authentik did, and these consumers +# read it: kube-apiserver RBAC (`--oidc-groups-claim=groups` plus 11 RoleBindings +# on oidc:), MinIO (`MINIO_IDENTITY_OPENID_CLAIM_NAME=groups`), Grafana +# role mapping, and every service that filters on a group name. +# +# Plan §1.3 Option A keeps those consumers unchanged by synthesizing the claim. +# Option B refactors all of them. This module takes responsibility only for the +# ZITADEL side of Option A: the action resource and its trigger. +# +# The script body is NOT shipped by default. The go/no-go gate (§1.3, §1.5 task +# 1.3) is: prove on the live instance that the action runtime can read project +# roles plus user metadata and write both `groups` and the per-agent claims, +# for a human token AND a machine-user token. Until that proof exists, enabling +# this resource would move every consumer onto an unverified claim source: +# +# terraform apply -var enable_groups_claim_action=true \ +# -var groups_claim_script=./groups-claim.js +# +# After it applies, verify with a real token exchange before moving a consumer: +# +# curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \ +# -d grant_type=refresh_token -d refresh_token= \ +# -d client_id=kubernetes | jq -R 'split(".")[1] | @base64d | fromjson' +# +# The decoded access token must contain `"groups":["homelab-admins", ...]`. + +resource "zitadel_action" "groups_claim" { + count = var.enable_groups_claim_action ? 1 : 0 + + org_id = data.zitadel_org.default.id + name = "homelab-groups-claim" + script = file(var.groups_claim_script) + timeout = "5s" + allowed_to_fail = false +} + +# Both triggers are needed. The access-token trigger fills the JWT that +# Kubernetes and MinIO read. The userinfo trigger fills the response that +# Grafana, paperless and Forgejo read. +resource "zitadel_trigger_actions" "groups_claim_access_token" { + count = var.enable_groups_claim_action ? 1 : 0 + + org_id = data.zitadel_org.default.id + flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN" + trigger_type = "TRIGGER_TYPE_PRE_ACCESS_TOKEN_CREATION" + action_ids = [zitadel_action.groups_claim[0].id] +} + +resource "zitadel_trigger_actions" "groups_claim_userinfo" { + count = var.enable_groups_claim_action ? 1 : 0 + + org_id = data.zitadel_org.default.id + flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN" + trigger_type = "TRIGGER_TYPE_PRE_USERINFO_CREATION" + action_ids = [zitadel_action.groups_claim[0].id] +} diff --git a/terraform/zitadel/api_apps.tf b/terraform/zitadel/api_apps.tf new file mode 100644 index 0000000..6825663 --- /dev/null +++ b/terraform/zitadel/api_apps.tf @@ -0,0 +1,18 @@ +# API applications — the machine-token surface. +# +# ZITADEL keeps two different objects apart: an OIDC application is for a +# browser flow, and an API application is what a client_credentials / JWT-profile +# caller presents. Authentik modelled both with one provider, so the plan §1.1 +# row 1 ("OIDC app + machine user") needs one of each here. +# +# The gateway signs its token request with the machine key from machines.tf, so +# the auth method is PRIVATE_KEY_JWT. No secret is stored for it. + +resource "zitadel_application_api" "machine" { + for_each = { for name, spec in local.machine_users : name => spec if contains(keys(var.api_app_consumers), name) } + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + name = "${each.key}-machine" + auth_method_type = "API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT" +} diff --git a/terraform/zitadel/clients.tf b/terraform/zitadel/clients.tf new file mode 100644 index 0000000..ce07659 --- /dev/null +++ b/terraform/zitadel/clients.tf @@ -0,0 +1,136 @@ +# OIDC applications — one per consumer that logs a browser in. +# +# Client IDs: the plan §3.2 wants every client_id byte-identical after the move, +# so that a consumer change is only the issuer URL plus the secret. +# +# THAT GOAL IS NOT ACHIEVABLE HERE, verified twice on 2026-10-24: +# - ZITADEL v4.19.2 CreateOIDCApplicationRequest has no client_id field, and +# CreateOIDCApplicationResponse documents the value as "generated" +# (proto/zitadel/app/v2beta/app_service.proto:540-650). +# - zitadel provider 3.8.7 exposes no settable application id: `id` on +# `zitadel_application_v2` is not a valid argument (terraform validate +# rejects it), and `client_id` on `zitadel_application_oidc` is computed. +# Consequence: every consumer takes a NEW client_id as well as a new secret. +# Budget the extra config change per consumer — this is not the one-line +# re-point the plan assumed. +# +# The only way to keep the old value is to create each app through the v2 REST +# API with an explicit body id, then import it: +# POST /v2/projects//apps +# {"id":"","name":"","oidcRequest":{...}} +# Not implemented here: it would put app creation outside Terraform state. +# +# Secrets are generated by ZITADEL, never written here. `terraform output -json +# client_ids` and `client_secrets` feed the SOPS re-point (§3.2, §6.9). Do not +# commit the tfstate: it carries those values. + +locals { + oidc_clients = { + # Gateway. Browser sessions for the console, plus a machine path + # (machines.tf) for callers that need client_credentials. + "api-gw" = { + redirect_uris = ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "argocd" = { + redirect_uris = ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "forgejo" = { + redirect_uris = ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "grafana" = { + redirect_uris = ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "homarr" = { + redirect_uris = ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "immich" = { + redirect_uris = ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"] + app_type = "NATIVE" + auth_method = "BASIC" + dev_mode = false + } + "kubernetes" = { + redirect_uris = ["http://localhost:8000", "http://localhost:18000"] + app_type = "WEB" + auth_method = "NONE" + dev_mode = true + } + "minio" = { + redirect_uris = ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "paperless" = { + redirect_uris = ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "poimen" = { + redirect_uris = ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "queue-sqs" = { + redirect_uris = ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + "vault" = { + redirect_uris = ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"] + app_type = "WEB" + auth_method = "BASIC" + dev_mode = false + } + } +} + +resource "zitadel_application_v2" "oidc" { + for_each = local.oidc_clients + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + name = each.key + + oidc { + redirect_uris = each.value.redirect_uris + response_types = ["OIDC_RESPONSE_TYPE_CODE"] + grant_types = [ + "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + "OIDC_GRANT_TYPE_REFRESH_TOKEN", + ] + app_type = "OIDC_APP_TYPE_${each.value.app_type}" + auth_method_type = "OIDC_AUTH_METHOD_TYPE_${each.value.auth_method}" + version = "OIDC_VERSION_1_0" + + # JWT access tokens: Kubernetes RBAC, MinIO policy mapping and the Go + # gateway all read claims, and an opaque bearer token carries none. + access_token_type = "OIDC_TOKEN_TYPE_JWT" + + # Assert roles without the client having to request the scope. Every + # consumer here maps roles or groups to local permissions. + access_token_role_assertion = true + id_token_role_assertion = true + id_token_userinfo_assertion = true + + dev_mode = each.value.dev_mode + } +} diff --git a/terraform/zitadel/machines.tf b/terraform/zitadel/machines.tf new file mode 100644 index 0000000..e376798 --- /dev/null +++ b/terraform/zitadel/machines.tf @@ -0,0 +1,139 @@ +# Machine users and their project grants (plan §1.4, §5.3). +# +# Authentik gave each agent an app password or a client_credentials grant built +# on a user password. ZITADEL has no user password on a machine user: it issues +# a JWT profile key (`KEY_TYPE_JSON`) that the SDK signs its token request with. +# That removes the password path entirely, which is the point — see §5.3. +# +# Per-agent claim values are copied from `SERVICE_ACCOUNTS` in +# scripts/iam/provision-rbac.py. They live in user metadata as JSON strings, +# because the groups-claim action (actions.tf) is the only thing that turns them +# into token claims. Without that action a consumer sees roles but not +# `minio_buckets`, `authorized_models`, `sqs_queues`, `memory_projects`, +# `memory_visibility` or `paperless_doctypes`. +# +# Keys are generated per machine user and land in `terraform output -json +# machine_keys` (sensitive). Feed each one to the owning workload as a mounted +# file, never as an environment variable dumps-able through `ps`. + +locals { + machine_users = { + "memory-agent" = { + description = "Memory service agent" + project_roles = ["poimen-admins"] + authenticated_by = "poimen memory (Rust)" + metadata = { + roles = jsonencode(["llm:inference", "memory:read", "memory:write", "notification:write", "sessions:read", "sessions:write"]) + memory_projects = jsonencode(["*"]) + memory_visibility = "private" + authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"]) + } + } + "paperless-ai-agent" = { + description = "Paperless AI plugin (auto-tagging, entity extraction)" + project_roles = ["poimen-devs"] + authenticated_by = "paperless-ai" + metadata = { + roles = jsonencode(["llm:inference", "memory:write", "paperless:admin", "notification:write"]) + minio_buckets = jsonencode(["paperless"]) + paperless_doctypes = jsonencode(["*"]) + memory_projects = jsonencode(["*"]) + authorized_models = jsonencode(["reasoning", "qwen2.5:3b"]) + } + } + "portfolio-agent" = { + description = "Portfolio service agent" + project_roles = ["poimen-prod-readonly"] + authenticated_by = "portfolio service" + metadata = { + roles = jsonencode(["llm:inference", "memory:read"]) + memory_projects = jsonencode(["homelab", "portfolio"]) + memory_visibility = "public" + authorized_models = jsonencode(["ornith:35b"]) + minio_buckets = jsonencode(["backups"]) + } + } + "temporal-worker-agent" = { + description = "Temporal workflow worker" + project_roles = ["poimen-admins"] + authenticated_by = "Temporal worker" + metadata = { + roles = jsonencode(["llm:inference", "workflow:execute", "memory:read", "memory:write", "queue:send", "notification:write"]) + memory_projects = jsonencode(["*"]) + sqs_queues = jsonencode(["*"]) + authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"]) + } + } + # The gateway is the one consumer that used client_credentials on a machine + # token, so it needs both the OIDC app (clients.tf) and a machine user + # (here) plus its API application (api_apps.tf). Its capability check reads + # the `roles` claim, which arrives as metadata. + "api-gw" = { + description = "Go LLM gateway machine identity" + project_roles = [] + authenticated_by = "api-gw" + metadata = { + roles = jsonencode(["llm:inference"]) + } + } + # pi-worker has no anchor in git (plan §1.1: "not in git") and no group in + # the Authentik provisioning script. It gets a machine user and no project + # role until the owner names the consumer configuration that holds its old + # credentials. Creating a role grant blind would widen access, not preserve it. + "pi-worker" = { + description = "pi sandbox worker (no git anchor for its consumer config)" + project_roles = [] + authenticated_by = "pi sandbox worker" + metadata = { + roles = jsonencode([]) + } + } + } + + machine_metadata = merge([ + for user, spec in local.machine_users : { + for key, value in spec.metadata : "${user}/${key}" => { + user = user + key = key + value = value + } + } + ]...) +} + +resource "zitadel_machine_user" "agent" { + for_each = local.machine_users + + org_id = data.zitadel_org.default.id + user_name = each.key + name = each.key + description = "${each.value.description} — consumer: ${each.value.authenticated_by}" + with_secret = false +} + +resource "zitadel_machine_key" "agent" { + for_each = local.machine_users + + org_id = data.zitadel_org.default.id + user_id = zitadel_machine_user.agent[each.key].id + key_type = "KEY_TYPE_JSON" + expiration_date = var.machine_key_expiry +} + +resource "zitadel_project_member" "agent" { + for_each = { for user, spec in local.machine_users : user => spec if length(spec.project_roles) > 0 } + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + user_id = zitadel_machine_user.agent[each.key].id + roles = each.value.project_roles +} + +resource "zitadel_user_metadata" "agent_claim" { + for_each = local.machine_metadata + + org_id = data.zitadel_org.default.id + user_id = zitadel_machine_user.agent[each.value.user].id + key = each.value.key + value = each.value.value +} diff --git a/terraform/zitadel/outputs.tf b/terraform/zitadel/outputs.tf new file mode 100644 index 0000000..3ee5ad3 --- /dev/null +++ b/terraform/zitadel/outputs.tf @@ -0,0 +1,46 @@ +# Outputs feed two things: the SOPS re-point of consumer secrets (§3.2, §6.9) +# and the machine-key handoff to each agent workload (§5.3). Every value that can +# authenticate a client is marked sensitive, so `terraform output` prints a +# placeholder until you ask for it explicitly. + +output "org_id" { + description = "Organization that owns the project, roles and applications." + value = data.zitadel_org.default.id +} + +output "project_id" { + description = "Project ID, which ZITADEL puts in the access-token `aud` claim." + value = zitadel_project_v2.homelab.id +} + +output "client_ids" { + description = "client_id per consumer. Compare against the Authentik values: any difference means the consumer config must change too." + value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_id } +} + +output "client_secrets" { + description = "Generated client secret per consumer. Write these into the existing SOPS files; never into git." + value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_secret } + sensitive = true +} + +output "api_app_client_ids" { + description = "client_id of each API application (machine-token surface)." + value = { for name, app in zitadel_application_api.machine : name => app.client_id } +} + +output "machine_user_ids" { + description = "ZITADEL user ID per machine user." + value = { for name, user in zitadel_machine_user.agent : name => user.id } +} + +output "machine_keys" { + description = "JWT profile key JSON per machine user. Mount as a file; do not pass through the environment." + value = { for name, key in zitadel_machine_key.agent : name => key.key_details } + sensitive = true +} + +output "groups_claim_enabled" { + description = "Whether the groups-claim action and its triggers exist. False means Option A is not yet in place and no consumer may move." + value = var.enable_groups_claim_action +} diff --git a/terraform/zitadel/project.tf b/terraform/zitadel/project.tf new file mode 100644 index 0000000..8ec6d67 --- /dev/null +++ b/terraform/zitadel/project.tf @@ -0,0 +1,62 @@ +# Project, roles and default-org lookup. +# +# ZITADEL has no group object. The plan §1.3 maps every Authentik group to a +# project role, and the groups-claim action (actions.tf) reconstructs the +# `groups` claim from those role keys. Role keys therefore EQUAL the existing +# Authentik group names, including the hyphens, so that the resulting claim is +# byte-identical and no consumer or RBAC file changes. The v4.19.2 API allows +# role keys of 1-200 characters with no character-set restriction (proto +# zitadel/project/v2/project_service.proto, AddProjectRole.role_key). + +data "zitadel_org" "default" { + id = var.org_id +} + +locals { + # Single source: the 19 groups of scripts/iam/provision-rbac.py GROUPS. + # Descriptions are carried over so the console reads the same as before. + roles = { + "homelab-admins" = "Cluster administrators with full access" + "minio-admins" = "MinIO administrators" + "minio-photos" = "Photos bucket (Immich) access" + "minio-documents" = "Documents bucket (Paperless) access" + "minio-backups" = "Backups bucket read-only access" + "poimen-admins" = "Poimen memory administrators" + "poimen-devs" = "Dev and staging projects access" + "poimen-prod-readonly" = "Production projects read-only access" + "paperless-admins" = "Paperless administrators" + "paperless-finance" = "Finance documents" + "paperless-legal" = "Legal documents" + "paperless-hr" = "HR documents" + "grafana-admins" = "Grafana administrators" + "grafana-editors" = "Grafana dashboard editors" + "grafana-viewers" = "Grafana dashboard viewers" + "sqs-users" = "SQS/Temporal queue read access" + "sqs-writers" = "SQS/Temporal queue read/write access" + "s3-users" = "S3 read access" + "s3-writers" = "S3 read/write access" + } +} + +resource "zitadel_project_v2" "homelab" { + org_id = data.zitadel_org.default.id + name = var.project_name + project_id = var.project_id + project_role_check = false + + # Roles must reach the tokens. Every consumer authorizes on them: + # kube-apiserver RBAC reads `groups`, MinIO reads bucket policies, Grafana + # reads grafana_org_role. Role assertion on the ID token is what most OIDC + # clients inspect. + project_role_assertion = true +} + +resource "zitadel_project_role" "groups" { + for_each = local.roles + + org_id = data.zitadel_org.default.id + project_id = zitadel_project_v2.homelab.id + role_key = each.key + display_name = each.key + group = "homelab" +} diff --git a/terraform/zitadel/providers.tf b/terraform/zitadel/providers.tf new file mode 100644 index 0000000..59ef519 --- /dev/null +++ b/terraform/zitadel/providers.tf @@ -0,0 +1,53 @@ +# terraform/zitadel — identity configuration for the ZITADEL instance +# +# Scope: this root module declares the objects INSIDE ZITADEL (org, project, +# roles, OIDC applications, machine users, grants). It does NOT deploy ZITADEL +# itself — that is ArgoCD's job (`k8s/argocd/apps/30-security.yaml`, values in +# `k8s/infra/iam/zitadel-values.yaml`). +# +# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git). +# This file implements §1.3 (roles), §1.4 (machine users), §2.4 (grants) and +# §6.2 (Terraform provider route). The claim-injection action of §1.3 lives in +# `actions.tf` and stays disabled until the live go/no-go gate proves it. +# +# Authentication to ZITADEL uses the `iam-admin` machine key that the chart's +# setup job writes to the `iam-admin` Secret in namespace iam: +# +# kubectl -n iam get secret iam-admin -o jsonpath='{.data.iam-admin\.json}' \ +# | base64 -d > ~/.config/zitadel/iam-admin.json +# chmod 600 ~/.config/zitadel/iam-admin.json +# +# export ZITADEL_JWT_PROFILE_FILE=$HOME/.config/zitadel/iam-admin.json +# terraform -chdir=terraform/zitadel init +# terraform -chdir=terraform/zitadel plan +# +# State is LOCAL and git-ignored (`terraform/*.tfstate` in .gitignore). That is +# a deliberate first step, not the final answer: §8 risk 8 of the plan names +# state drift as a blast-radius item. Move the state to MinIO before a second +# operator ever runs `apply`. No secret is written to state except the generated +# client secrets and machine keys, which are marked sensitive — they still land +# in the state file, so treat that file as a secret. +terraform { + required_version = ">= 1.6.0" + + required_providers { + zitadel = { + source = "zitadel/zitadel" + version = "3.8.7" + } + } +} + +provider "zitadel" { + domain = var.zitadel_domain + port = var.zitadel_port + + # JWT profile of the iam-admin machine user. Preferred over a PAT: it is + # short-lived per call and never sits in the environment as a bearer token. + # Set the path through ZITADEL_JWT_PROFILE_FILE. + jwt_profile_file = var.jwt_profile_file + + # The instance serves plain HTTP behind ingress-nginx, which terminates TLS. + # Turn this on only if the provider must call the pod directly. + insecure = var.zitadel_insecure +} diff --git a/terraform/zitadel/variables.tf b/terraform/zitadel/variables.tf new file mode 100644 index 0000000..c5d3834 --- /dev/null +++ b/terraform/zitadel/variables.tf @@ -0,0 +1,103 @@ +variable "zitadel_domain" { + description = "External domain of the ZITADEL instance, without scheme." + type = string + default = "zitadel.riotpiao.com" +} + +variable "zitadel_port" { + description = "Port for the ZITADEL API. 443 behind ingress, 8080 for a direct pod port-forward." + type = string + default = "443" +} + +variable "zitadel_insecure" { + description = "Call the API over plain HTTP (set true only when port-forwarding the pod)." + type = bool + default = false +} + +variable "jwt_profile_file" { + description = "Path to the iam-admin machine key JSON extracted from the iam-admin Secret." + type = string + default = null +} + +variable "org_id" { + description = <<-EOT + ID of the ZITADEL organization that holds the project. Read it from the + console: https://zitadel.riotpiao.com -> Organization settings -> Resource + ID. The chart's setup job creates the default org; this module never creates + one, because creating a second org would split the role and grant space. + EOT + type = string +} + +variable "project_id" { + description = <<-EOT + Stable project ID. ZITADEL puts the project ID in the access token `aud` + claim, so pinning it keeps audience checks readable across a rebuild. + Every consumer that validates `aud` against a client ID must be re-pointed + to this value — see the plan §5.2. + EOT + type = string + default = "homelab" +} + +variable "project_name" { + description = "Display name of the project that carries every role and application." + type = string + default = "homelab" +} + +variable "machine_key_expiry" { + description = "Expiry of every machine key, RFC3339. Matches the chart's default iam-admin key window; rotate by changing this value and re-applying." + type = string + default = "2029-01-01T00:00:00Z" +} + +variable "api_app_consumers" { + description = <<-EOT + Machine users that also need an API application (the client_credentials + replacement). Only the gateway does: every other agent signs a JWT with its + machine key and does not need a client_credentials grant. + EOT + type = map(string) + default = { api-gw = "Go LLM gateway" } +} + +variable "enable_groups_claim_action" { + description = <<-EOT + Create the Actions v1 script and its trigger that synthesizes the `groups` + claim. Keep this false until the live go/no-go gate passes: the claim is + load-bearing for kube-apiserver RBAC, MinIO policies, Grafana role mapping + and every service that reads `groups`. Turning it on with an unverified + script can break every consumer at once. + EOT + type = bool + default = false +} + +variable "groups_claim_script" { + description = <<-EOT + Path to the JavaScript body for the groups-claim action, read only when + enable_groups_claim_action is true. The body is deliberately not shipped + blind: the ZITADEL v1 Actions runtime API must be confirmed against the live + instance before a script is committed, because a wrong call silently emits + no claim instead of failing loudly. + EOT + type = string + default = null + + validation { + # try() is required: HCL does not short-circuit `||`, so fileexists(null) + # would fail the whole plan even with the action disabled. + condition = try(fileexists(var.groups_claim_script), true) + error_message = "groups_claim_script must name an existing file when it is set." + } + + # Enabling the action without a script would reach file(null) inside actions.tf. + validation { + condition = !var.enable_groups_claim_action || var.groups_claim_script != null + error_message = "enable_groups_claim_action requires groups_claim_script." + } +}