From d848d9ae86cb066264a15059a121a81b43067e00 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 19 Sep 2026 12:46:58 +1000 Subject: [PATCH] Manage human role membership from config/users/ Add a users/ config kind mapping a human to the akR-* roles they hold. Look accounts up with data.authentik_user; never declare them. Set authentik_group.role users only for roles a user file names, leaving every other role's membership untouched. No assignments yet. --- README.md | 6 ++-- config/config.hcl | 5 ++++ config/users/README.md | 17 +++++++++++ .../terragrunt.hcl | 1 + modules/authentik/main.tf | 29 +++++++++++++++++++ modules/authentik/variables.tf | 15 ++++++++++ 6 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 config/users/README.md diff --git a/README.md b/README.md index 3e39dd6..d012d2c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Terraform configuration for managing the Authentik identity provider at identity ## Managed Resources -- **Groups** — roles and group hierarchy (users are invited manually) +- **Groups** — roles and group hierarchy (accounts themselves are created elsewhere) +- **User role membership** — which `akR-*` roles a human holds (see `config/users/`) - **SAML providers** — SAML application integrations - **OAuth2/OIDC providers** — OAuth2 and OpenID Connect integrations - **LDAP providers** — LDAP provider and outpost configuration @@ -21,7 +22,8 @@ config/ ├── providers_saml/ # SAML provider definitions ├── providers_oauth2/ # OAuth2/OIDC provider definitions ├── providers_ldap/ # LDAP provider definitions -└── service_accounts/ # Automation service accounts + API tokens +├── service_accounts/ # Automation service accounts + API tokens +└── users/ # Human role membership (authoritative per named role) ``` ## Usage diff --git a/config/config.hcl b/config/config.hcl index eae42dc..43a8d95 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -42,5 +42,10 @@ locals { trimsuffix(basename(file_path), ".yaml") => content if startswith(file_path, "service_accounts/") } + users = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "users/") + } } } diff --git a/config/users/README.md b/config/users/README.md new file mode 100644 index 0000000..667a0bc --- /dev/null +++ b/config/users/README.md @@ -0,0 +1,17 @@ +# users + +One file per human, `.yaml`, listing the `akR-*` roles they hold: + +```yaml +# Human user jane (username = filename). The account itself is not managed here +# (humans come from LDAP sync / invite); only its role membership is. +roles: + - akR-media-adult +``` + +The account is looked up by username and must already exist — nothing here +creates users. A role that has no `config/roles/.yaml` fails the plan. + +**Naming a role here makes Terraform authoritative over that role's entire +member list**: members added by hand in the Authentik UI for that role are +removed on the next apply. Roles no user file names are left untouched. diff --git a/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl b/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl index f884c18..ca7d76f 100644 --- a/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl +++ b/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl @@ -24,4 +24,5 @@ inputs = { providers_oauth2 = local.config.providers_oauth2 providers_ldap = local.config.providers_ldap service_accounts = local.config.service_accounts + users = local.config.users } diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index d928702..fdddc3b 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -16,6 +16,31 @@ resource "authentik_group" "permission" { attributes = jsonencode(each.value.attributes) } +# Humans are created outside this module (LDAP sync / invite), so they are +# looked up rather than declared: resolve the username to the numeric pk that +# group membership is keyed on. A missing account fails the plan; nothing here +# can create a user. +data "authentik_user" "human" { + for_each = var.users + username = each.key + + lifecycle { + precondition { + condition = length(setsubtract(each.value.roles, keys(var.role_groups))) == 0 + error_message = "config/users/${each.key}.yaml names a role with no config/roles/.yaml." + } + } +} + +locals { + # Invert user -> roles into role -> member pks. Only roles some user file + # names appear here; every other role falls through to a null `users` below. + role_members = { + for role in distinct(flatten([for u, v in var.users : v.roles])) : + role => [for u, v in var.users : data.authentik_user.human[u].pk if contains(v.roles, role)] + } +} + # Role groups (akR-*): what users are assigned to. Each nests permission groups # as parents, so a role member is an effective member of every permission it # grants. Separate resource from permissions so this reference is not a @@ -27,6 +52,10 @@ resource "authentik_group" "role" { is_superuser = each.value.is_superuser parents = [for p in each.value.permissions : authentik_group.permission[p].id] attributes = jsonencode(each.value.attributes) + # Authoritative: a role claimed by config/users/ has exactly these members, so + # one dropped from a user file is removed. null (every unclaimed role) leaves + # the attribute computed, i.e. membership stays whatever Authentik holds. + users = lookup(local.role_members, each.key, null) } # Emit an `ak_groups` claim containing the user's groups AND all inherited diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf index 1ea354f..77842b0 100644 --- a/modules/authentik/variables.tf +++ b/modules/authentik/variables.tf @@ -156,3 +156,18 @@ variable "service_accounts" { })) default = {} } + +# Human role membership. The username is the map key (the config filename). The +# account itself is never managed here — humans are created by LDAP sync/invite +# and only looked up — so this grants and revokes roles, it does not make users. +# +# OWNERSHIP: naming a role in any user file makes Terraform authoritative over +# that role's entire member list, so members added by hand in the UI for that +# role are removed on the next apply. Roles no user file names are left alone. +variable "users" { + type = map(object({ + # keys into var.role_groups (akR-*) this user is a member of. + roles = optional(list(string), []) + })) + default = {} +} -- 2.47.3