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.
140 lines
5.4 KiB
Terraform
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
|
|
}
|