Files
terraform-authentik/modules/authentik/main.tf
T
unkin-agent 3396b399ce
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Jellyfin SSO: cheeztv OIDC hosts, media-group gating, LDAP outpost
Completes the Authentik-side plumbing for Jellyfin SSO across both media
instances -- fafflix (adults, jellyfin.k8s.syd1.au.unkin.net) and cheeztv
(kids, cheeztv.unkin.net + cheeztv.k8s.syd1.au.unkin.net) -- and adds an
LDAP outpost so native clients can authenticate with app passwords.

Why: the previously-merged jellyfin OIDC provider only covered the fafflix
host and gated on the generic jellyfin permission groups. cheeztv needs SSO
too, access should be limited to media users, and native (non-browser)
clients need a password-based path.

How:
- providers_oauth2/jellyfin.yaml: one shared confidential client now lists
  strict redirect URIs for all three hosts using the verified
  jellyfin-plugin-sso callback path /sso/OID/redirect/authentik. Client
  secret moved to kv kubernetes/namespace/fafflix/default/oauth-credentials.
- Access is gated to media users only: akP-media-fafflix and akP-media-cheeztv
  now carry `application: jellyfin` and bind to the app; akP-jellyfin-admin /
  akP-jellyfin-user are demoted to pure role-claim groups (no app binding),
  still mapped by the plugin for admin/user rights. Per-instance authz
  (adults -> both, kids -> cheeztv only) stays with the media proxy.
- providers_ldap/jellyfin-ldap.yaml: new LDAP provider (base_dn
  DC=ldap,DC=goauthentik,DC=io, direct bind/search) + application
  (jellyfin-ldap) + outpost (jellyfin-ldap-outpost) via the existing module.
- modules/authentik/main.tf: resolve LDAP bind/unbind flow slugs to ids via
  data.authentik_flow, matching the oauth2/saml convention.
2026-08-26 22:15:28 +10:00

245 lines
9.1 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
}