3396b399ce
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.
245 lines
9.1 KiB
Terraform
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
|
|
}
|