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