0c6a9104c0
Two service_accounts declaring the same token identifier collapsed into a single entry under merge(), silently dropping one token. Key the map by account/identifier the way service_account_permissions already does, and carry the bare identifier as a field for the authentik_token attribute.
334 lines
12 KiB
Terraform
334 lines
12 KiB
Terraform
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
|
|
}
|
|
|
|
# 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"]
|
|
}
|