feat(iam): declare Zitadel identity objects in Terraform

Adds terraform/zitadel: the provisioning half the ArgoCD wiring left open.
It declares 1 project, 19 project roles, 12 OIDC applications, 5 machine users
plus keys, one API application for the gateway, project grants, per-agent claim
metadata, and a disabled groups-claim action.

Facts established while writing it:

- Client IDs cannot be preserved. ZITADEL v4.19.2 CreateOIDCApplicationRequest
  has no client_id field, and provider 3.8.7 rejects a settable application id
  (terraform validate reports "Invalid or unknown key"). Plan §3.2 assumed a
  client_id-preserving re-point; every consumer now also changes client ID.
- Role keys equal the existing group names, so the groups claim can be
  byte-identical and no RBAC file changes. v4.19.2 allows role keys of 1-200
  characters with no character-set restriction.
- Machine users get JWT profile keys, not passwords. Per-agent claims move to
  user metadata and reach tokens only through the groups-claim action.
- The action stays gated: enable_groups_claim_action defaults to false because
  the action runtime API is unverified. Two validations stop a null script from
  reaching file().

Checks: terraform fmt -check -recursive clean, terraform validate 0 errors,
plan reaches only the expected missing-credentials error, structural gate
0 error / 0 warning, and one reviewer round: BLOCK on a plan-time
fileexists(null) and on the missing api-gw machine path, both fixed, re-reviewed
to SIGN-OFF.

State is local and git-ignored; it carries generated client secrets and machine
keys. Move it to MinIO before a second operator applies.

Not applied: no Zitadel instance is running yet.
This commit is contained in:
2026-10-24 03:38:21 -07:00
parent cb1219bf96
commit f81ff0e2fc
9 changed files with 620 additions and 0 deletions
+5
View File
@@ -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
+58
View File
@@ -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:<group>), 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=<t> \
# -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]
}
+18
View File
@@ -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"
}
+136
View File
@@ -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/<project_id>/apps
# {"id":"<old-client-id>","name":"<old-client-id>","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
}
}
+139
View File
@@ -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
}
+46
View File
@@ -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
}
+62
View File
@@ -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"
}
+53
View File
@@ -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
}
+103
View File
@@ -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."
}
}