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 } data "vault_kv_secret_v2" "oauth2" { for_each = { for k, v in var.providers_oauth2 : k => v if v.client_secret_vault != null } mount = each.value.client_secret_vault.mount name = each.value.client_secret_vault.path } # 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 = each.value.client_secret_vault != null ? data.vault_kv_secret_v2.oauth2[each.key].data["client_secret"] : 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 } resource "authentik_provider_ldap" "this" { for_each = var.providers_ldap name = each.value.name bind_flow = each.value.bind_flow unbind_flow = each.value.unbind_flow 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 }