Manage human role membership from config/users/ #37

Merged
benvin merged 2 commits from benvin/media-adult-membership into main 2026-09-19 14:55:02 +10:00
6 changed files with 71 additions and 2 deletions
+4 -2
View File
@@ -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
+5
View File
@@ -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/")
}
}
}
+17
View File
@@ -0,0 +1,17 @@
# users
One file per human, `<username>.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/<name>.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.
@@ -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
}
+29
View File
@@ -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/<name>.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
+15
View File
@@ -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 = {}
}