Files
homelab/terraform/zitadel/machines.tf
T
rock f81ff0e2fc 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.
2026-10-24 03:38:21 -07:00

140 lines
5.4 KiB
Terraform

# 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
}