resource "authentik_group" "this" { for_each = var.groups name = each.value.name is_superuser = each.value.is_superuser parents = each.value.parents attributes = jsonencode(each.value.attributes) } # Permission groups (akP-*): atomic, leaf groups. Mapped to app roles via the # groups claim and bound to applications for access. resource "authentik_group" "permission" { for_each = var.permission_groups name = each.key attributes = jsonencode(each.value.attributes) } # 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 # self-reference (authentik_group cannot refer to itself). resource "authentik_group" "role" { for_each = var.role_groups name = each.key is_superuser = each.value.is_superuser parents = [for p in each.value.permissions : authentik_group.permission[p].id] attributes = jsonencode(each.value.attributes) } # Emit an `ak_groups` claim containing the user's groups AND all inherited # (ancestor) groups, so role -> permission nesting reaches apps. The default # profile mapping only emits *direct* groups under `groups` # (goauthentik/authentik#15579); we use a distinct claim key so there is no # collision with that (Authentik dict-overrides same-key claims in an # unpredictable order). Apps request the `ak_groups` scope and read the # `ak_groups` claim. Walks each direct group up through `.parents`. resource "authentik_property_mapping_provider_scope" "groups_hierarchical" { name = "unkin: ak_groups (hierarchical)" scope_name = "ak_groups" expression = <<-EOT groups = {} pending = list(user.ak_groups.all()) while pending: grp = pending.pop() if grp.pk in groups: continue groups[grp.pk] = grp.name pending += list(grp.parents.all()) return {"ak_groups": sorted(groups.values())} EOT } # Resolve SAML flows by slug and the signing keypair by name, so configs use # human-readable names instead of Authentik UUIDs (mirrors the oauth2 handling). data "authentik_flow" "saml_authorization" { for_each = var.providers_saml slug = each.value.authorization_flow } data "authentik_flow" "saml_invalidation" { for_each = var.providers_saml slug = each.value.invalidation_flow } data "authentik_certificate_key_pair" "saml_signing" { for_each = { for k, v in var.providers_saml : k => v if v.signing_kp != null } name = each.value.signing_kp } resource "authentik_provider_saml" "this" { for_each = var.providers_saml name = each.value.name authorization_flow = data.authentik_flow.saml_authorization[each.key].id invalidation_flow = data.authentik_flow.saml_invalidation[each.key].id acs_url = each.value.acs_url sp_binding = each.value.sp_binding audience = each.value.audience name_id_mapping = each.value.name_id_mapping signing_kp = each.value.signing_kp != null ? data.authentik_certificate_key_pair.saml_signing[each.key].id : null } # Build a Python expression per app that emits a role claim from the user's # effective (hierarchical) group membership. Assembled from the config rules so # the generated code has predictable indentation (no template-directive quirks). locals { role_mapping_expr = { for k, v in var.providers_oauth2 : k => join("\n", concat( [ "groups = {}", "pending = list(user.ak_groups.all())", "while pending:", " grp = pending.pop()", " if grp.pk in groups:", " continue", " groups[grp.pk] = grp.name", " pending += list(grp.parents.all())", "names = set(groups.values())", ], flatten([ for rule in coalesce(try(v.role_mappings.rules, null), []) : [ "if ${jsonencode(rule.group)} in names:", " return {${jsonencode(try(v.role_mappings.claim, ""))}: ${jsonencode(rule.role)}}", ] ]), ["return {${jsonencode(try(v.role_mappings.claim, ""))}: ${jsonencode(try(v.role_mappings.default, ""))}}"], )) if v.role_mappings != null } } resource "authentik_property_mapping_provider_scope" "role" { for_each = { for k, v in var.providers_oauth2 : k => v if v.role_mappings != null } name = "unkin: ${each.key} role" scope_name = each.value.role_mappings.claim expression = local.role_mapping_expr[each.key] } # Resolve oauth2 flows by slug and scope mappings by managed identifier, and # read client secrets from Vault so nothing sensitive is committed. data "authentik_flow" "oauth2_authorization" { for_each = var.providers_oauth2 slug = each.value.authorization_flow } data "authentik_flow" "oauth2_invalidation" { for_each = var.providers_oauth2 slug = each.value.invalidation_flow } data "authentik_property_mapping_provider_scope" "oauth2" { for_each = { for k, v in var.providers_oauth2 : k => v if length(v.scope_mappings) > 0 } managed_list = each.value.scope_mappings } locals { # Providers whose client secret is pre-seeded in Vault and only read here. oauth2_secret_read = { for k, v in var.providers_oauth2 : k => v if v.client_secret_vault != null && !v.client_secret_vault.generate } # Providers whose client secret is generated here and published to Vault, so # onboarding needs no operator seeding the path first. oauth2_secret_generate = { for k, v in var.providers_oauth2 : k => v if v.client_secret_vault != null && v.client_secret_vault.generate } oauth2_client_secret = merge( { for k, v in local.oauth2_secret_read : k => data.vault_kv_secret_v2.oauth2[k].data["client_secret"] }, { for k, v in local.oauth2_secret_generate : k => random_password.oauth2_client_secret[k].result }, ) } data "vault_kv_secret_v2" "oauth2" { for_each = local.oauth2_secret_read mount = each.value.client_secret_vault.mount name = each.value.client_secret_vault.path } # Alphanumeric only: the secret is pasted into consumer configs and CLI flags, # where punctuation is an easy way to hit shell/URL escaping bugs. resource "random_password" "oauth2_client_secret" { for_each = local.oauth2_secret_generate length = 64 special = false } # Publish the generated credential so consumers (terraform-vault, app configs) # read it from Vault instead of an operator copying it out of Authentik. resource "vault_kv_secret_v2" "oauth2_client_secret" { for_each = local.oauth2_secret_generate mount = each.value.client_secret_vault.mount name = each.value.client_secret_vault.path data_json = jsonencode({ client_id = each.value.client_id client_secret = random_password.oauth2_client_secret[each.key].result }) } # Default JWT signing key for OAuth2 providers. Without a signing_key Authentik # signs ID tokens with HS256, which RS256-only RP clients (argocd, grafana, ...) # reject. Look up the estate's RSA keypair by name so providers default to RS256. data "authentik_certificate_key_pair" "signing" { name = var.oauth2_signing_key_name } resource "authentik_provider_oauth2" "this" { for_each = var.providers_oauth2 name = each.value.name authorization_flow = data.authentik_flow.oauth2_authorization[each.key].id invalidation_flow = data.authentik_flow.oauth2_invalidation[each.key].id client_type = each.value.client_type client_id = each.value.client_id client_secret = lookup(local.oauth2_client_secret, each.key, null) property_mappings = concat( try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, []), [authentik_property_mapping_provider_scope.groups_hierarchical.id], try([authentik_property_mapping_provider_scope.role[each.key].id], []), ) signing_key = coalesce(each.value.signing_key, data.authentik_certificate_key_pair.signing.id) access_token_validity = each.value.access_token_validity allowed_redirect_uris = each.value.redirect_uris grant_types = each.value.grant_types } # Resolve LDAP bind/unbind flows by slug (mirrors the oauth2/saml handling) so # configs reference human-readable flow slugs instead of Authentik UUIDs. data "authentik_flow" "ldap_bind" { for_each = var.providers_ldap slug = each.value.bind_flow } data "authentik_flow" "ldap_unbind" { for_each = var.providers_ldap slug = each.value.unbind_flow } resource "authentik_provider_ldap" "this" { for_each = var.providers_ldap name = each.value.name bind_flow = data.authentik_flow.ldap_bind[each.key].id unbind_flow = data.authentik_flow.ldap_unbind[each.key].id base_dn = each.value.base_dn certificate = each.value.certificate tls_server_name = each.value.tls_server_name uid_start_number = each.value.uid_start_number gid_start_number = each.value.gid_start_number search_mode = each.value.search_mode bind_mode = each.value.bind_mode mfa_support = each.value.mfa_support } resource "authentik_application" "saml" { for_each = var.providers_saml name = each.value.name slug = each.key protocol_provider = authentik_provider_saml.this[each.key].id } resource "authentik_application" "oauth2" { for_each = var.providers_oauth2 name = each.value.name slug = each.key protocol_provider = authentik_provider_oauth2.this[each.key].id # Null keeps Authentik's derived launch URL (from the first redirect_uri). meta_launch_url = each.value.launch_url } resource "authentik_application" "ldap" { for_each = var.providers_ldap name = each.value.name slug = each.key protocol_provider = authentik_provider_ldap.this[each.key].id } resource "authentik_outpost" "ldap" { for_each = var.providers_ldap name = "${each.key}-outpost" type = "ldap" protocol_providers = [authentik_provider_ldap.this[each.key].id] } # Gate application access: bind each permission group that names an `application` # to that app. Authentik ORs bindings, and membership propagates from child # groups, so a member of any role that nests the permission is also covered. # With any binding present, only these groups (and their children) can authorize. resource "authentik_policy_binding" "app_access" { for_each = { for k, v in var.permission_groups : k => v if v.application != null } target = authentik_application.oauth2[each.value.application].uuid group = authentik_group.permission[each.key].id order = 0 } # Service accounts: non-human identities for automation. Kept out of the group # hierarchy above (which models human app access) and given capabilities through # RBAC roles instead. resource "authentik_user" "service_account" { for_each = var.service_accounts username = each.key name = coalesce(each.value.name, each.key) type = "service_account" # roles is only populated for accounts that declare permissions; try() keeps # the reference lazy so accounts without a role still plan. roles = try([authentik_rbac_role.service_account[each.key].id], []) } # One role per service account carrying its global permissions. # authentik_rbac_permission_user is deprecated in favour of the role form, so # permissions are attached to a role and the role to the account. resource "authentik_rbac_role" "service_account" { for_each = { for k, v in var.service_accounts : k => v if length(v.permissions) > 0 } name = each.key } locals { service_account_permissions = merge([ for k, v in var.service_accounts : { for perm in v.permissions : "${k}/${perm}" => { service_account = k permission = perm } } ]...) # Keyed by account/identifier so two accounts reusing an identifier do not # collapse into one entry under merge(). service_account_tokens = merge([ for k, v in var.service_accounts : { for identifier, t in v.tokens : "${k}/${identifier}" => merge(t, { service_account = k identifier = identifier }) } ]...) } resource "authentik_rbac_permission_role" "service_account" { for_each = local.service_account_permissions role = authentik_rbac_role.service_account[each.value.service_account].id permission = each.value.permission } # retrieve_key is required for `key` to be populated; without it the attribute # stays empty and nothing can be published to Vault. resource "authentik_token" "service_account" { for_each = local.service_account_tokens identifier = each.value.identifier user = authentik_user.service_account[each.value.service_account].id description = each.value.description intent = "api" expiring = each.value.expiring retrieve_key = true } # Publish token keys to kv-v2 so consumers (agentvault, CI) read them from Vault. # The key also lands in Terraform state, same as the oauth2 client secrets this # module already reads. resource "vault_kv_secret_v2" "service_account_token" { for_each = { for k, v in local.service_account_tokens : k => v if v.vault != null } mount = each.value.vault.mount name = each.value.vault.path data_json = jsonencode({ (each.value.vault.key) = authentik_token.service_account[each.key].key }) } # One-off re-address for the tokens that existed before the map was namespaced # by service account. Without these the rekey reads as destroy+create and the # published token key rotates. Safe to drop once applied. moved { from = authentik_token.service_account["agent-api-token"] to = authentik_token.service_account["sa-agent-api/agent-api-token"] } moved { from = vault_kv_secret_v2.service_account_token["agent-api-token"] to = vault_kv_secret_v2.service_account_token["sa-agent-api/agent-api-token"] }