diff --git a/config/permissions/akP-jellyfin-admin.yaml b/config/permissions/akP-jellyfin-admin.yaml index ce96197..91be97c 100644 --- a/config/permissions/akP-jellyfin-admin.yaml +++ b/config/permissions/akP-jellyfin-admin.yaml @@ -1,3 +1,6 @@ -# Permission group akP-jellyfin-admin (name = filename). Grants admin -# access to jellyfin: bound to the jellyfin application and mapped to its admin role. -application: jellyfin +# Permission group akP-jellyfin-admin (name = filename). Does NOT gate the +# jellyfin application (SSO access is gated by the media groups akP-media-fafflix +# / akP-media-cheeztv). It exists purely as an admin role-claim group: the +# jellyfin-plugin-sso matches the hierarchical `ak_groups` claim against this +# group name to grant Jellyfin administrator rights. Nested under akR-global-admin. +attributes: {} diff --git a/config/permissions/akP-jellyfin-user.yaml b/config/permissions/akP-jellyfin-user.yaml index 77aa33f..bc1e7e5 100644 --- a/config/permissions/akP-jellyfin-user.yaml +++ b/config/permissions/akP-jellyfin-user.yaml @@ -1,3 +1,6 @@ -# Permission group akP-jellyfin-user (name = filename). Grants user -# access to jellyfin: bound to the jellyfin application and mapped to its user role. -application: jellyfin +# Permission group akP-jellyfin-user (name = filename). Does NOT gate the +# jellyfin application (SSO access is gated by the media groups akP-media-fafflix +# / akP-media-cheeztv). It exists purely as a user role-claim group: the +# jellyfin-plugin-sso matches the hierarchical `ak_groups` claim against this +# group name for regular (non-admin) Jellyfin access. Nested under akR-standard-user. +attributes: {} diff --git a/config/permissions/akP-media-cheeztv.yaml b/config/permissions/akP-media-cheeztv.yaml index 388b63e..1a45c58 100644 --- a/config/permissions/akP-media-cheeztv.yaml +++ b/config/permissions/akP-media-cheeztv.yaml @@ -1,6 +1,6 @@ # Permission group akP-media-cheeztv (name = filename). Per-service entitlement -# for the kids ("cheeztv") media tier: NOT bound to any application (no -# `application` field), so it does not gate OIDC. It exists purely to appear in -# the user's hierarchical `ak_groups` claim, which the media proxy reads to -# decide whether to route/authorize the cheeztv backend. -attributes: {} +# for the kids ("cheeztv") media tier. Bound to the jellyfin application, so it +# gates Jellyfin SSO access: only members (via akR-media-adult or akR-media-kids) +# may authorize. It also appears in the user's hierarchical `ak_groups` claim, +# which the media proxy reads to route/authorize the cheeztv backend. +application: jellyfin diff --git a/config/permissions/akP-media-fafflix.yaml b/config/permissions/akP-media-fafflix.yaml index 366e18f..879a1fa 100644 --- a/config/permissions/akP-media-fafflix.yaml +++ b/config/permissions/akP-media-fafflix.yaml @@ -1,6 +1,6 @@ # Permission group akP-media-fafflix (name = filename). Per-service entitlement -# for the adult ("fafflix") media tier: NOT bound to any application (no -# `application` field), so it does not gate OIDC. It exists purely to appear in -# the user's hierarchical `ak_groups` claim, which the media proxy reads to -# decide whether to route/authorize the fafflix backend. -attributes: {} +# for the adult ("fafflix") media tier. Bound to the jellyfin application, so it +# gates Jellyfin SSO access: only members (via akR-media-adult) may authorize. +# It also appears in the user's hierarchical `ak_groups` claim, which the media +# proxy reads to route/authorize the fafflix backend. +application: jellyfin diff --git a/config/providers_ldap/jellyfin-ldap.yaml b/config/providers_ldap/jellyfin-ldap.yaml new file mode 100644 index 0000000..6d52cdf --- /dev/null +++ b/config/providers_ldap/jellyfin-ldap.yaml @@ -0,0 +1,27 @@ +# LDAP provider + outpost for Jellyfin native-client app-password validation. +# +# Jellyfin's web UI signs in via OIDC (see providers_oauth2/jellyfin.yaml), but +# native clients (mobile/TV apps, DLNA) cannot do a browser OIDC dance. Those +# clients authenticate against this Authentik LDAP outpost using their username +# plus an Authentik "App password" token as the bind password. The Jellyfin LDAP +# plugin binds as the user; a successful bind == valid app-password. +# +# bind_mode "direct": the outpost runs the bind_flow (default-authentication-flow) +# on every bind request, so app-password revocation takes effect immediately. +# search_mode "direct": entries are read live from the Authentik API. Search is +# gated by Authentik's directory permissions -- the bound user is NOT granted the +# "Search full LDAP directory" permission, so it can only read its own entry, +# which is all the Jellyfin plugin needs to resolve the user after bind. +# +# base_dn is the Authentik default LDAP tree. The module creates the matching +# authentik_application (slug jellyfin-ldap) and authentik_outpost +# (name jellyfin-ldap-outpost, type ldap). The outpost's API token is generated +# by Authentik AFTER apply and must be seeded into KV for the k8s outpost +# Deployment to consume: kv/kubernetes/namespace/authentik/default/outpost-token (key: token). +name: Jellyfin LDAP +bind_flow: default-authentication-flow +unbind_flow: default-invalidation-flow +base_dn: DC=ldap,DC=goauthentik,DC=io +bind_mode: direct +search_mode: direct +mfa_support: true diff --git a/config/providers_oauth2/jellyfin.yaml b/config/providers_oauth2/jellyfin.yaml index 6aff889..c288768 100644 --- a/config/providers_oauth2/jellyfin.yaml +++ b/config/providers_oauth2/jellyfin.yaml @@ -1,29 +1,45 @@ -# OAuth2/OIDC provider + application for Jellyfin -# (jellyfin.k8s.syd1.au.unkin.net), consumed by jellyfin-plugin-sso (OIDC) so -# the web UI signs in via Authentik while native clients keep Jellyfin local/API -# auth. client_secret is read from Vault, not committed. The plugin requests the -# `ak_groups` scope and matches the hierarchical groups claim against -# akP-jellyfin-admin / akP-jellyfin-user for its admin/user role mapping, so no -# role_mappings claim is needed here. The path segment "authentik" is the SSO -# provider name configured in the plugin — it must match on the Jellyfin side. +# OAuth2/OIDC provider + application for the Jellyfin web UI SSO, consumed by +# jellyfin-plugin-sso (OIDC) so the browser signs in via Authentik while native +# clients use the LDAP outpost (see providers_ldap/jellyfin-ldap.yaml). +# +# ONE shared confidential client (client_id jellyfin) serves BOTH Jellyfin +# instances -- fafflix (adults, jellyfin.k8s.syd1.au.unkin.net) and cheeztv +# (kids, cheeztv.unkin.net + cheeztv.k8s.syd1.au.unkin.net). Each instance runs +# the SSO plugin with provider name "authentik", so its callback is +# https:///sso/OID/redirect/authentik (verified plugin path shape); all +# three hosts are listed as strict redirect URIs. +# +# Access is gated to media users only: akP-media-fafflix and akP-media-cheeztv +# carry `application: jellyfin` and bind to this app, so only members of the +# media roles (akR-media-adult / akR-media-kids) can authorize. Per-instance +# authorization (adults -> fafflix + cheeztv, kids -> cheeztv only) is enforced +# downstream by the media proxy reading the hierarchical ak_groups claim. +# Admin vs. user role inside Jellyfin is still mapped by the plugin matching the +# ak_groups claim against akP-jellyfin-admin / akP-jellyfin-user. +# +# client_secret is read from Vault (seeded out of band), never committed. Both +# instances share this one secret; terraform reads it from the fafflix namespace +# path, and the cheeztv Deployment reads the same value from its own namespace. name: Jellyfin authorization_flow: default-provider-authorization-implicit-consent invalidation_flow: default-provider-invalidation-flow client_type: confidential client_id: jellyfin -# Explicit dashboard tile launch URL (the Jellyfin web UI). +# Explicit dashboard tile launch URL (the fafflix Jellyfin web UI). launch_url: https://jellyfin.k8s.syd1.au.unkin.net/ client_secret_vault: mount: kv - path: kubernetes/namespace/jellyfin/default/oauth-credentials + path: kubernetes/namespace/fafflix/default/oauth-credentials scope_mappings: - goauthentik.io/providers/oauth2/scope-openid - goauthentik.io/providers/oauth2/scope-email - goauthentik.io/providers/oauth2/scope-profile redirect_uris: - # jellyfin-plugin-sso OIDC callback (plugin >= 3.5.2). + # fafflix (adults) - matching_mode: strict url: https://jellyfin.k8s.syd1.au.unkin.net/sso/OID/redirect/authentik - # Legacy plugin callback path, kept so older plugin builds also work. + # cheeztv (kids) -- external + in-cluster hostnames - matching_mode: strict - url: https://jellyfin.k8s.syd1.au.unkin.net/sso/OID/r/authentik + url: https://cheeztv.unkin.net/sso/OID/redirect/authentik + - matching_mode: strict + url: https://cheeztv.k8s.syd1.au.unkin.net/sso/OID/redirect/authentik diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index 81c0def..475399b 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -169,12 +169,24 @@ resource "authentik_provider_oauth2" "this" { 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 = each.value.bind_flow - unbind_flow = each.value.unbind_flow + 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