Compare commits

...

34 Commits

Author SHA1 Message Date
benvin 0c6145e057 Merge pull request 'Read the vlogs client secret from the vlogs namespace path' (#41) from benvin/vlogs-namespace-move into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #41
2026-09-28 22:55:50 +10:00
unkin-agent 89a5caf353 Read the vlogs client secret from the vlogs namespace path
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
2026-09-28 22:53:01 +10:00
benvin e90e6d027b Merge pull request 'Add vlogs OIDC application and provider' (#40) from benvin/vlogs-oidc into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #40
2026-09-27 11:20:35 +10:00
unkin-agent b3c08369fd Add vlogs OIDC application and provider
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
Onboard the VictoriaLogs query UI at https://vlogs.unkin.net behind
oauth2-proxy, gated on akP-vlogs-admin.

- add config/providers_oauth2/vlogs.yaml reading its client_secret from
  kv/kubernetes/namespace/logging/default/vlogs-oauth-credentials
- add permission group akP-vlogs-admin bound to the vlogs application
- nest akP-vlogs-admin under akR-global-admin
2026-09-27 10:13:18 +10:00
benvin 9508c151c4 Merge pull request 'Make the ArgoCD OAuth2 client public and register the mobile callback' (#39) from benvin/argocd-public-client into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #39
2026-09-19 15:40:18 +10:00
unkin-agent 405dede578 Make the ArgoCD OAuth2 client public and register the mobile callback
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
The ArgoCD mobile app and CLI are native clients that cannot hold a
secret, and Authentik derives the iss claim from the application slug,
so they cannot have a client of their own either. Serve all three
clients from the one provider.

- switch client_type to public
- add argocd://auth/callback as a strict redirect URI
2026-09-19 15:26:18 +10:00
benvin 4fceaeeee6 Merge pull request 'Manage human role membership from config/users/' (#37) from benvin/media-adult-membership into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #37
2026-09-19 14:55:02 +10:00
benvin 1e42fdfc2c Merge pull request 'Nest media groups into akR-global-admin' (#36) from benvin/global-admin-media-access into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #36
2026-09-19 14:53:56 +10:00
benvin e2d7de4148 Merge branch 'main' into benvin/media-adult-membership
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
2026-09-19 13:48:16 +10:00
benvin 331330872d Merge branch 'main' into benvin/global-admin-media-access
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
2026-09-19 13:48:05 +10:00
benvin 3f6a046dff Merge pull request 'Pin the authentik provider to 2026.5.1' (#38) from benvin/pin-authentik-provider into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #38
2026-09-19 12:59:13 +10:00
unkin-agent 3662ff15ca Pin the authentik provider to 2026.5.1
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
Provider 2026.8.0 requires pbm_uuid on the applications API, which the
deployed authentik 2026.5.3 server does not return, so every
authentik_application read fails and no plan completes.

- Pin goauthentik/authentik to 2026.5.1, the newest release built on the
  2026.5 API client
- Record 2026.5.1 in the lock file
- Drop -upgrade from make init so the lock is authoritative
2026-09-19 12:51:26 +10:00
unkin-agent d848d9ae86 Manage human role membership from config/users/
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Add a users/ config kind mapping a human to the akR-* roles they hold.
Look accounts up with data.authentik_user; never declare them. Set
authentik_group.role users only for roles a user file names, leaving
every other role's membership untouched. No assignments yet.
2026-09-19 12:46:58 +10:00
unkin-agent fedbb2fa42 Nest media groups into akR-global-admin
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Global admins get akP-jellyfin-admin but no path to a jellyfin-bound
group, so Authentik denies them at the application gate. Add
akP-media-fafflix and akP-media-cheeztv to reach it.
2026-09-19 12:44:05 +10:00
benvin e5dbcb56a6 Merge pull request 'Import the stranded artifactapi access binding' (#35) from benvin/fix-artifactapi-policy-binding into main
ci/woodpecker/push/apply Pipeline failed
Reviewed-on: #35
2026-09-08 00:11:37 +10:00
unkin-agent cd31cb6fa1 Import the artifactapi access binding stranded outside state
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
The apply that created authentik_policy_binding.app_access["akP-artifactapi-admin"]
read it back off the Authentik Postgres read replica before the row had
replicated, got a 404, and dropped it, leaving main red.

Import the existing binding c9f22628-d48c-477b-b9ac-a952c7d081ce into state.
2026-09-07 23:49:48 +10:00
benvin 4e164014d7 Merge pull request 'Onboard the artifactapi web UI to Authentik OIDC' (#34) from benvin/artifactapi-ui-oidc into main
ci/woodpecker/push/apply Pipeline failed
Reviewed-on: #34
2026-09-07 19:59:42 +10:00
unkin-agent 0d17dc2cf1 Retrigger plan after credential seed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
2026-09-07 17:44:02 +10:00
unkin-agent a6d6681bc9 Onboard the artifactapi web UI to Authentik OIDC
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Add the OAuth2/OIDC provider + application and the akP-artifactapi-admin
permission group gating it, nested under akR-global-admin.
2026-09-07 13:59:45 +10:00
benvin 93c634e4c4 Merge pull request 'Onboard repospawner UI to Authentik OIDC' (#32) from benvin/repospawner-oidc into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #32
2026-08-31 22:21:09 +10:00
unkin-agent 3977f6f86b Merge remote-tracking branch 'origin/main' into benvin/repospawner-oidc
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
# Conflicts:
#	config/roles/akR-global-admin.yaml
2026-08-31 22:07:36 +10:00
benvin 6d014ce913 Merge pull request 'Onboard OpenBao as an Authentik OIDC client' (#33) from benvin/vault-oidc-provider into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #33
2026-08-30 22:17:39 +10:00
unkin-agent 230db5ad7e Onboard OpenBao as an Authentik OIDC client
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
Adds config/providers_oauth2/vault.yaml so human logins to OpenBao go
through Authentik SSO (bao CLI and the web UI). Machine auth (approle,
kubernetes, CI) and break-glass are unchanged and stay on the OpenBao side.

Extends the oauth2 provider module so a config may generate its own client
secret instead of reading a pre-seeded one: client_secret_vault.generate
creates a random_password and writes {client_id, client_secret} to the given
kv-v2 path. Providers without the flag keep the existing read-only data source
behaviour. This is what lets the provider land with no manual Vault seed.

Gates the new application with akP-vault-admin and nests it in
akR-global-admin, matching how every other app in this repo is bound.
2026-08-30 21:25:39 +10:00
unkin-agent 2f80c4a536 Onboard repospawner UI to Authentik OIDC
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Add an OAuth2/OIDC provider + application for the repospawner operator tool,
fronted by oauth2-proxy, and gate it on a new akP-repospawner-admin permission
group nested under akR-global-admin (mirrors the watchstate precedent).
2026-08-30 15:09:47 +10:00
benvin 5ecd03cdd5 Merge pull request 'Namespace service account token map keys by account' (#31) from benvin/token-foreach-namespacing into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #31
2026-08-30 00:03:11 +10:00
unkin-agent 0c6a9104c0 Namespace service account token map keys by account
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
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.
2026-08-29 23:10:38 +10:00
benvin 3884a5b21f Merge pull request 'Manage the agent API service account and token in Terraform' (#30) from benvin/agent-api-token-iac into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #30
2026-08-29 23:04:04 +10:00
unkin-agent 8ec8bbda8e Manage the agent API service account and token in Terraform
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
The Authentik API token used by estate automation was created by hand in the
UI and pasted into Vault, so it was undocumented, unauditable and impossible
to rotate reproducibly. Model it as config instead.

Add a service_accounts config kind, discovered from config/service_accounts/
like the other kinds. Each entry creates a service_account user, an RBAC role
carrying its global permissions, its API tokens, and (optionally) a kv-v2
write publishing each token key.

Add sa-agent-api granting view_outpost, view_token and view_token_key, with a
non-expiring api token agent-api-token published to kv/service/authentik/agent-api-token.
2026-08-29 22:58:40 +10:00
benvin 09f7c9042e Merge pull request 'Onboard mediamark to Authentik OIDC' (#29) from benvin/mediamark-oidc into main
ci/woodpecker/push/apply Pipeline was successful
Reviewed-on: #29
2026-08-29 21:55:34 +10:00
unkin-agent 1485962cf5 Fix mediamark Vault secret path and permission group name
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
The terraform-authentik runner's Vault policy only grants read on
kv/data/kubernetes/namespace/+/default/oauth-credentials (literal trailing
filename), so the arrstack/default/mediamark-oauth-credentials path 403s at
plan time and reddens CI. mediamark deploys in its own `mediamark` namespace
(watchstate model), so point the data source at
kubernetes/namespace/mediamark/default/oauth-credentials, which the policy
covers. Hostnames are unchanged.

Rename the permission group to akP-mediamark-user to match the peer tier-suffix
convention (akP-watchstate-admin, akP-arrstack-user). The group name is derived
from the filename in config/config.hcl, so update the akR-media-adult
reference too.
2026-08-29 21:13:41 +10:00
unkin-agent 72e65d7810 Onboard mediamark to Authentik OIDC
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
Add an oauth2 provider/application for the mediamark kids-content marking UI,
a permission group gating it, and nest that permission in akR-media-adult.
2026-08-29 20:57:54 +10:00
benvin e397fd909f Merge pull request 'Jellyfin SSO: cheeztv OIDC hosts, media-group gating, LDAP outpost' (#28) from benvin/jellyfin-sso into main
ci/woodpecker/push/apply Pipeline failed
Reviewed-on: #28
2026-08-29 11:51:19 +10:00
unkin-agent 9760c2eb3f Fix KV path comment in jellyfin-ldap provider configuration.
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
Reference the actual Vault KV seed path for outpost token instead of deferring to PR body.
2026-08-26 22:23:32 +10:00
unkin-agent 3396b399ce Jellyfin SSO: cheeztv OIDC hosts, media-group gating, LDAP outpost
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
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
29 changed files with 594 additions and 75 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ endef
init:
@$(call vault_env) && \
terragrunt run --all --non-interactive init -- -upgrade
terragrunt run --all --non-interactive init
plan: init
@$(call vault_env) && \
+6 -2
View File
@@ -4,11 +4,13 @@ Terraform configuration for managing the Authentik identity provider at identity
## Managed Resources
- **Groups** — roles and group hierarchy (users are invited manually)
- **Groups** — roles and group hierarchy (accounts themselves are created elsewhere)
- **User role membership** — which `akR-*` roles a human holds (see `config/users/`)
- **SAML providers** — SAML application integrations
- **OAuth2/OIDC providers** — OAuth2 and OpenID Connect integrations
- **LDAP providers** — LDAP provider and outpost configuration
- **Applications** — application definitions linked to providers
- **Service accounts** — machine identities with RBAC roles and API tokens (keys published to Vault kv)
## Configuration
@@ -19,7 +21,9 @@ config/
├── groups/ # Group definitions
├── providers_saml/ # SAML provider definitions
├── providers_oauth2/ # OAuth2/OIDC provider definitions
└── providers_ldap/ # LDAP provider definitions
├── providers_ldap/ # LDAP provider definitions
├── service_accounts/ # Automation service accounts + API tokens
└── users/ # Human role membership (authoritative per named role)
```
## Usage
+10
View File
@@ -37,5 +37,15 @@ locals {
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "providers_ldap/")
}
service_accounts = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "service_accounts/")
}
users = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "users/")
}
}
}
@@ -0,0 +1,3 @@
# Permission group akP-artifactapi-admin (name = filename). Grants admin
# access to artifactapi: bound to the artifactapi application, gating the web UI.
application: artifactapi
+6 -3
View File
@@ -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: {}
+6 -3
View File
@@ -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: {}
+5 -5
View File
@@ -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
+5 -5
View File
@@ -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
@@ -0,0 +1,4 @@
# Permission group akP-mediamark-user (name = filename). Grants user access to
# mediamark: bound to the mediamark application, gating the kids-content
# marking UI.
application: mediamark
@@ -0,0 +1,3 @@
# Permission group akP-repospawner-admin (name = filename). Grants admin
# access to repospawner: bound to the repospawner application, gating the UI.
application: repospawner
+5
View File
@@ -0,0 +1,5 @@
# Permission group akP-vault-admin (name = filename). Gates the OpenBao
# application: without a binding every Authentik user could complete an OIDC
# login, so access is restricted to this group. OpenBao's own policy mapping
# keys off the same group name in the ak_groups claim.
application: vault
+3
View File
@@ -0,0 +1,3 @@
# Permission group akP-vlogs-admin (name = filename). Grants admin
# access to vlogs: bound to the vlogs application, gating the VictoriaLogs UI.
application: vlogs
+27
View File
@@ -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
+14 -2
View File
@@ -1,9 +1,18 @@
# OAuth2/OIDC provider + application for the in-cluster ArgoCD
# (argocd.k8s.syd1.au.unkin.net). client_secret is read from Vault, not committed.
# (argocd.k8s.syd1.au.unkin.net), serving the web UI, the `argocd` CLI and the
# ArgoCD mobile app.
#
# public, not confidential: the native clients cannot hold a secret, and they
# cannot have their own client either -- Authentik derives the `iss` claim from
# the application slug, while ArgoCD validates every token against the single
# issuer in oidc.config, so a second application would issue tokens ArgoCD
# rejects. One client for all three; the strict redirect URIs below are the
# control. Authentik ignores the secret for public clients, but the Vault read
# stays so argocd-cm's `$argocd-oidc:client_secret` keeps resolving.
name: ArgoCD
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_type: public
client_id: argocd
client_secret_vault:
mount: kv
@@ -19,3 +28,6 @@ redirect_uris:
# `argocd login --sso` CLI callback (local listener).
- matching_mode: strict
url: http://localhost:8085/auth/callback
# Mobile app callback (custom URL scheme, PKCE).
- matching_mode: strict
url: argocd://auth/callback
+23
View File
@@ -0,0 +1,23 @@
# OAuth2/OIDC provider + application for the artifactapi web UI
# (https://artifactapi.k8s.syd1.au.unkin.net/ui in the artifactapi namespace).
# An oauth2-proxy fronts ONLY /ui and /oauth2; access is gated on the user's
# hierarchical ak_groups claim (akP-artifactapi-admin). The package-manager
# surfaces (/api/v1, /api/v2, /v2, /terraform, /.well-known) are NOT behind this
# provider -- yum, containerd, docker, terraform and CI cannot do a browser flow.
# client_secret is read from Vault (seeded out of band), never committed.
name: artifactapi
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_id: artifactapi
launch_url: https://artifactapi.k8s.syd1.au.unkin.net/ui/
client_secret_vault:
mount: kv
path: kubernetes/namespace/artifactapi/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:
- matching_mode: strict
url: https://artifactapi.k8s.syd1.au.unkin.net/oauth2/callback
+29 -13
View File
@@ -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://<host>/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
+26
View File
@@ -0,0 +1,26 @@
# OAuth2/OIDC provider + application for mediamark (the Go web UI for marking
# media as kids content, served at https://mediamark.unkin.net (external
# hostname) and https://mediamark.k8s.syd1.au.unkin.net (cluster hostname) in
# its own mediamark namespace). An oauth2-proxy in front of the UI performs the
# OIDC login with a relative redirect, so both hostnames must be registered;
# access is gated on the user's hierarchical ak_groups claim
# (akP-mediamark-user).
# client_secret is read from Vault (seeded out of band), never committed.
name: mediamark
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_id: mediamark
launch_url: https://mediamark.unkin.net/
client_secret_vault:
mount: kv
path: kubernetes/namespace/mediamark/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:
- matching_mode: strict
url: https://mediamark.k8s.syd1.au.unkin.net/oauth2/callback
- matching_mode: strict
url: https://mediamark.unkin.net/oauth2/callback
+25
View File
@@ -0,0 +1,25 @@
# OAuth2/OIDC provider + application for repospawner (the internal repository
# provisioning admin tool, served at
# https://repospawner.k8s.syd1.au.unkin.net (cluster hostname) and
# https://repospawner.unkin.net (external hostname) in the repospawner namespace).
# An oauth2-proxy in front of the UI performs the OIDC login; access is gated on
# the user's hierarchical ak_groups claim (akP-repospawner-admin).
# client_secret is read from Vault (seeded out of band), never committed.
name: repospawner
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_id: repospawner
launch_url: https://repospawner.unkin.net/
client_secret_vault:
mount: kv
path: kubernetes/namespace/repospawner/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:
- matching_mode: strict
url: https://repospawner.k8s.syd1.au.unkin.net/oauth2/callback
- matching_mode: strict
url: https://repospawner.unkin.net/oauth2/callback
+32
View File
@@ -0,0 +1,32 @@
# OAuth2/OIDC provider + application for OpenBao (the estate's Vault), making
# Authentik the default *human* login. Machine auth (approle, k8s, CI) and the
# break-glass paths are untouched and stay on the OpenBao side.
#
# client_secret is generated here and written to kv/service/authentik/oidc-vault
# ({client_id, client_secret}); the companion terraform-vault change reads it to
# configure the OIDC auth mount. Nothing is seeded by hand.
name: OpenBao
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_id: vault
client_secret_vault:
mount: kv
path: service/authentik/oidc-vault
generate: true
scope_mappings:
- goauthentik.io/providers/oauth2/scope-openid
- goauthentik.io/providers/oauth2/scope-email
- goauthentik.io/providers/oauth2/scope-profile
redirect_uris:
# `bao login -method=oidc` CLI callback (local listener, fixed port 8250).
- matching_mode: strict
url: http://localhost:8250/oidc/callback
# UI SSO callback, gateway host (traefik-internal -> vault svc :8200).
- matching_mode: strict
url: https://vault.k8s.syd1.au.unkin.net/ui/vault/auth/oidc/oidc/callback
# UI SSO callback, direct Consul service address (the address the estate
# documents for Vault access; any node forwards to the active replica).
- matching_mode: strict
url: https://vault.service.consul:8200/ui/vault/auth/oidc/oidc/callback
launch_url: https://vault.k8s.syd1.au.unkin.net/ui/
+21
View File
@@ -0,0 +1,21 @@
# OAuth2/OIDC provider + application for vlogs (the VictoriaLogs query UI,
# served at https://vlogs.unkin.net in the vlogs namespace). An oauth2-proxy
# in front of the UI performs the OIDC login; access is gated on the user's
# hierarchical ak_groups claim (akP-vlogs-admin).
# client_secret is read from Vault (seeded out of band), never committed.
name: vlogs
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_id: vlogs
launch_url: https://vlogs.unkin.net/
client_secret_vault:
mount: kv
path: kubernetes/namespace/vlogs/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:
- matching_mode: strict
url: https://vlogs.unkin.net/oauth2/callback
+8
View File
@@ -1,4 +1,6 @@
# Role akR-global-admin (name = filename): full admin across all onboarded apps.
# Nests akP-media-fafflix / akP-media-cheeztv so admins pass the jellyfin
# application gate and can reach the akP-jellyfin-admin rights nested below.
permissions:
- akP-grafana-admin
- akP-argocd-admin
@@ -7,10 +9,16 @@ permissions:
- akP-jellyfin-admin
- akP-traefik-admin
- akP-logviewer-admin
- akP-vlogs-admin
- akP-watchstate-admin
- akP-repospawner-admin
- akP-vault-admin
- akP-artifactapi-admin
# arrstack has no admin tier (it is a proxy front door); grant global admins
# the front door plus every per-app entitlement so they reach all media apps.
- akP-arrstack-user
- akP-arrstack-sonarr
- akP-arrstack-radarr
- akP-arrstack-prowlarr
- akP-media-fafflix
- akP-media-cheeztv
+4 -1
View File
@@ -2,7 +2,10 @@
# adult ("fafflix") and kids ("cheeztv") services, so this role nests both
# per-service entitlements. Membership propagates child -> parent, so a member
# appears in both permission groups in the hierarchical `ak_groups` claim and the
# media proxy routes/authorizes them for fafflix and cheeztv.
# media proxy routes/authorizes them for fafflix and cheeztv. Adults also curate
# which titles are kids content, so the role nests akP-mediamark-user for access
# to the mediamark UI.
permissions:
- akP-media-fafflix
- akP-media-cheeztv
- akP-mediamark-user
+19
View File
@@ -0,0 +1,19 @@
# Service account sa-agent-api (username = filename). Machine identity for
# estate automation that needs to read Authentik outpost tokens; replaces the
# hand-created token an operator used to paste into Vault.
name: Agent API
permissions:
# List outposts and read their bootstrap token keys.
- authentik_outposts.view_outpost
- authentik_core.view_token
- authentik_core.view_token_key
tokens:
agent-api-token:
description: >-
Used by agentvault seed-outpost to look up Authentik outpost tokens.
Managed by terraform-authentik; key published to Vault kv.
expiring: false
vault:
mount: kv
path: service/authentik/agent-api-token
key: token
+17
View File
@@ -0,0 +1,17 @@
# users
One file per human, `<username>.yaml`, listing the `akR-*` roles they hold:
```yaml
# Human user jane (username = filename). The account itself is not managed here
# (humans come from LDAP sync / invite); only its role membership is.
roles:
- akR-media-adult
```
The account is looked up by username and must already exist — nothing here
creates users. A role that has no `config/roles/<name>.yaml` fails the plan.
**Naming a role here makes Terraform authoritative over that role's entire
member list**: members added by hand in the Authentik UI for that role are
removed on the next apply. Roles no user file names are left untouched.
@@ -23,4 +23,6 @@ inputs = {
providers_saml = local.config.providers_saml
providers_oauth2 = local.config.providers_oauth2
providers_ldap = local.config.providers_ldap
service_accounts = local.config.service_accounts
users = local.config.users
}
+56 -33
View File
@@ -2,45 +2,68 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/goauthentik/authentik" {
version = "2026.5.0"
constraints = ">= 2026.5.0"
version = "2026.5.1"
constraints = "2026.5.1"
hashes = [
"h1:SeznjPKBzSrgo8WasRnuxiGMDSeQHEKsv3U/xw8bhQE=",
"zh:0dc1706f6fbff866f4a96de56a4934b9a277954bcdd0713549a29a9b8ec85153",
"zh:218417ec4e864f2d7e585d6c08d39bccb96d8f3bca16c6f762be15365e434234",
"zh:24f9afa7a1174316da3478811848cd76ef348d8a983310b8d75ed6f45abe1a92",
"zh:560092e47cb8a72b890b3eeafe1803202cd25cf27f5f5a6e2c370f645f5d86ae",
"zh:5bc69d8de198007ad1587e146f98cffacf0d1a571800da549b308ff5f4541474",
"zh:65248dce941472ad2a30d0754d2f3c2db6bb6fe5080946316fb097d6ba7cc79f",
"zh:79c9a59a8d3c60280e27a064668889594da44c60f940b046b7c8e63be01067d0",
"zh:87f26cadcd842d6e6d0af94ef0e56860557f5d07f487b10d69d38b63af68bea5",
"zh:8e42c9d0e77d61cc2e5f8c8b761f6e484774d93771927b4cb5fbdae41209dd33",
"zh:94ff632b9b4841527c6b652d51a850a8a47c84c0308a3efc189e0ff7e2558f87",
"zh:b8d32d9f17a905b63c87a23306c02c295b7c8b70f72950071aa3086396932816",
"zh:c91982af99474fc2e4e69be36ed3a68847f261963ed79f6a546fc75703992f99",
"zh:eb9c1fd3020cf61e9b7a6a38d2965f4b521495a9928705e963459a4af857f97d",
"h1:L9q3pjCoeKQdn0/OApv4O1HiC/PDLDqcnM7Ff5XFB+0=",
"zh:05f252734db99792e5d3f52b582a4e0027348ac2614d8c9621bf7e623cff8036",
"zh:0e1cd8041650aed9bb8d4af23301e786223ed5d1a00c2a5150b7ad874ae03917",
"zh:26b1daebd30650df411f903248e061cf963712a063b38e731782fb13a5755ddf",
"zh:362b536ff6bf8866c7c1094c3e2322c22b8c36d8d4caa4db0397cebbcad08561",
"zh:3d94367b853960f5e88efe3ae69d995d02a11dc8d60ec4d7413a37aabf78b379",
"zh:3e840e204677eabe562d17c2ad12afdfa250098725bebac4e91fb8934e233c22",
"zh:4db66139d7a9ec6f7852538573261010d48f0a939e2fe8eeb5c55b01149e3629",
"zh:7b88a830fb26c697f9d240f498f604485454b12e56b7604eda4ca35d10660ecd",
"zh:851bc8bd2d4a16dc71f30da17b6285aa7d85f4bc4175fde7b8319a6a8987ec65",
"zh:8bdfc2c70271d48126eef3b1a30d0d132e868d2118ae7277323ed4c905636e4a",
"zh:e85c03eac43f23f25ad5443a2984cc67977c3abdcb4c4c426d37342c2f25c37e",
"zh:efc296826a700a4e90ee06f8bd3b137617486ce774772aabed3053d4ef8dd906",
"zh:f249033dc242c51600cd3968535efa0780352d5ae7435e8ab1482a7ccc9c2f5e",
]
}
provider "registry.opentofu.org/hashicorp/random" {
version = "3.9.1"
constraints = ">= 3.6.0"
hashes = [
"h1:CEQeHfnUDB3uqAkKoEWfWgbj+kpoQHgcuPbAjPzbh+U=",
"zh:09aaf19b0d22726d2378e0e89fbbefc183494d7bd585759d6c4e69ba50951a2f",
"zh:31575ca9bc0db20337096d178ea73bce3ebca343ed071c67f78cf39f800c9ec6",
"zh:624fb6ed552abc34a5aaac41e76a373da65ac08e524b09b672f29c60e6ac896a",
"zh:6a4760d55132b9750ac1a04f6fc32e247034daa999f71452dba9cbca225a529a",
"zh:768a6047cfb8958e7b0b120c580aa3de6624a7fbb2c56ad6df85cd559ed26ec7",
"zh:8983c788ba660bcb587e64ff9c3e4323515caf78facbe0abe6432e7aff8df893",
"zh:8d570eb026a4f00b58a1d36be0ce3c13adf4d973efcd4162b05cb295bbc14257",
"zh:a2259540854d5f699c36b89244fb202ebb2c219b64669a51072687d04fb47152",
"zh:aaa51d905b0e80a28e02f9bee2cf6c91ffade7389d77ab9198aa12809ed04955",
"zh:afb60995e98573facddfb47baedf7e288408680eb00b5d3df570611758947c72",
"zh:b9a46d852ce53fa037f47537a7de53f37b759ccf211600b7ba44c66ba4b616b7",
"zh:bafcfeeefcd0dfefeff120b655b45edb0497c4717534ffe5201b3cb556d1ffe6",
"zh:c3ac24d397eae054aca2290e20943e0c767592cc661c890850c25ac01829308d",
"zh:eafba4127ebadcc5ed0e427935c66fb5e2da7cfdaae39a66d52f4a50d51faf1e",
"zh:f39d4bce213ed9bba3474bad468136af08ff6c4c33adaafcc10c1f78067adfe3",
]
}
provider "registry.opentofu.org/hashicorp/vault" {
version = "5.10.1"
version = "5.12.0"
constraints = ">= 4.0.0"
hashes = [
"h1:wo5cTkl/1nlxMfdn1yEDIHNoRLMczuK6COH2Id4/zeY=",
"zh:0abf976c01f0c0732d0ccc6481e52008be5ee9c8e3d9b5eba0573c640fcf7019",
"zh:2aff4d7ee7ba9eb3de2cd5cda16ba92b4ec7a2b43232aec180984241a323b216",
"zh:2cc186fd0bfc44e100a22b0b40ae8ddcd0ec210a53c1da65d310ee758b1d2b08",
"zh:3f8fb8594736b34af4b26437dd4df4dd4042ad4905223995cfebb8a1f10682ec",
"zh:47fb41b18b74073f557dbcd6aad2183e416293405ccd70c0691a279cfe97f8cd",
"zh:517e2f2764d671c22d22def0384fdfc521b456458189189c0363375495d114dc",
"zh:5a49a2003636f2b8a547d494a6c06d43d62a68299775305408c52eff22b1c11f",
"zh:66d4e716920ada84b0c768f4aca4c8948388995462923349a01bd3818d82b618",
"zh:7599f652e89a3f18fa4b76a59d115cc63255cc36ce6b273850509ba25031abca",
"zh:9c3e38ae7e670de973b6255d7050f526cd2b3ca7c383d7ba7226fc204d97c507",
"zh:d04b046023fa9fd69def678f27e001c298ea34fc99ba51f835cda82e496fdb57",
"zh:d9acd8810f6660cd51bb4c25596632984ae18e93340c82a102d074c6eac95151",
"zh:e161bcb9a22607270b980eeff2ba693335fb62d6978516dff93dd4c91cda99b3",
"zh:ef47502f08cfcb5311b7b16a7905e0052bf28359e07cc00ed080ef454e0946cf",
"zh:f0640ddb52e7e90c5006ff571f6ad0554e593665320c764c57a3d8b7ec31b490",
"h1:HVdhw1ShP/LlYuDOdVKmLO/kLVfngw9VHM2YfDl879Y=",
"zh:070709539eeff2dbf6af13269e6a3c14cea0ee4e5a5fff237d574805df2787e9",
"zh:0e017e993252c37008dcf1848deb32f15a14b303335c3e06885d7efea07fef1d",
"zh:1717ba0a0f8d906ced0eb5a3ed1e2a1808d824868da5095ea1da12d60904777d",
"zh:218f879c5f3f97564f4867bcbb419197c4aa4e41ce5bc3eed50c71926f0519f9",
"zh:2437d8f76d6220883c96a073801af973db957c8b1c79187256c204437f4ed08e",
"zh:9dce3198d2f03ce05c3fb4cb90433de5863f0d1818fc4abc3e3576f562409c85",
"zh:a51d00c192e6cf86d588501d8c4a2a37a258679784651909d55f64d28011b28d",
"zh:c99586f88b0166d5522dedfbec9d246a51efae346bbdfcaeb296ff72b5c80f94",
"zh:cee10cca2a295e4fd9ce2779239f7f79ab97328d9e30833924cceb349e509051",
"zh:d9b5cde1c02db0b211ec3f77b519122f93b442594ce9f20d386ab5d0d05ead93",
"zh:deafe53fa413e71dac120177a58b678e81a64279a692ba77eca09a9bce0bff1a",
"zh:f372b83c70ee5dabd892bd9ec1eb9127fe6331d88178b7b84fa5f52f616b59df",
"zh:f47e4fd82b72e62a5e1eeaaa1b72cc2709545a8431964123b24ed5c03a03e492",
"zh:f629a86c98be3fd2aacb58336d26ce268a76fe754b048f58a3faa0712bb4a837",
"zh:feaef85debc4a4a72d7a23988b18fff0b4dbc47f37e31a0b51022df071ac7b85",
]
}
+182 -4
View File
@@ -16,6 +16,31 @@ resource "authentik_group" "permission" {
attributes = jsonencode(each.value.attributes)
}
# Humans are created outside this module (LDAP sync / invite), so they are
# looked up rather than declared: resolve the username to the numeric pk that
# group membership is keyed on. A missing account fails the plan; nothing here
# can create a user.
data "authentik_user" "human" {
for_each = var.users
username = each.key
lifecycle {
precondition {
condition = length(setsubtract(each.value.roles, keys(var.role_groups))) == 0
error_message = "config/users/${each.key}.yaml names a role with no config/roles/<name>.yaml."
}
}
}
locals {
# Invert user -> roles into role -> member pks. Only roles some user file
# names appear here; every other role falls through to a null `users` below.
role_members = {
for role in distinct(flatten([for u, v in var.users : v.roles])) :
role => [for u, v in var.users : data.authentik_user.human[u].pk if contains(v.roles, role)]
}
}
# 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
@@ -27,6 +52,10 @@ resource "authentik_group" "role" {
is_superuser = each.value.is_superuser
parents = [for p in each.value.permissions : authentik_group.permission[p].id]
attributes = jsonencode(each.value.attributes)
# Authoritative: a role claimed by config/users/ has exactly these members, so
# one dropped from a user file is removed. null (every unclaimed role) leaves
# the attribute computed, i.e. membership stays whatever Authentik holds.
users = lookup(local.role_members, each.key, null)
}
# Emit an `ak_groups` claim containing the user's groups AND all inherited
@@ -136,12 +165,53 @@ data "authentik_property_mapping_provider_scope" "oauth2" {
managed_list = each.value.scope_mappings
}
locals {
# Providers whose client secret is pre-seeded in Vault and only read here.
oauth2_secret_read = {
for k, v in var.providers_oauth2 : k => v
if v.client_secret_vault != null && !v.client_secret_vault.generate
}
# Providers whose client secret is generated here and published to Vault, so
# onboarding needs no operator seeding the path first.
oauth2_secret_generate = {
for k, v in var.providers_oauth2 : k => v
if v.client_secret_vault != null && v.client_secret_vault.generate
}
oauth2_client_secret = merge(
{ for k, v in local.oauth2_secret_read : k => data.vault_kv_secret_v2.oauth2[k].data["client_secret"] },
{ for k, v in local.oauth2_secret_generate : k => random_password.oauth2_client_secret[k].result },
)
}
data "vault_kv_secret_v2" "oauth2" {
for_each = { for k, v in var.providers_oauth2 : k => v if v.client_secret_vault != null }
for_each = local.oauth2_secret_read
mount = each.value.client_secret_vault.mount
name = each.value.client_secret_vault.path
}
# Alphanumeric only: the secret is pasted into consumer configs and CLI flags,
# where punctuation is an easy way to hit shell/URL escaping bugs.
resource "random_password" "oauth2_client_secret" {
for_each = local.oauth2_secret_generate
length = 64
special = false
}
# Publish the generated credential so consumers (terraform-vault, app configs)
# read it from Vault instead of an operator copying it out of Authentik.
resource "vault_kv_secret_v2" "oauth2_client_secret" {
for_each = local.oauth2_secret_generate
mount = each.value.client_secret_vault.mount
name = each.value.client_secret_vault.path
data_json = jsonencode({
client_id = each.value.client_id
client_secret = random_password.oauth2_client_secret[each.key].result
})
}
# 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.
@@ -157,7 +227,7 @@ resource "authentik_provider_oauth2" "this" {
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
client_secret = lookup(local.oauth2_client_secret, each.key, null)
property_mappings = concat(
try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, []),
[authentik_property_mapping_provider_scope.groups_hierarchical.id],
@@ -169,12 +239,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
@@ -230,3 +312,99 @@ resource "authentik_policy_binding" "app_access" {
group = authentik_group.permission[each.key].id
order = 0
}
# Created server-side, but its post-create read-back hit the Authentik Postgres
# read replica before the row replicated and 404'd, so it never reached state.
import {
to = authentik_policy_binding.app_access["akP-artifactapi-admin"]
id = "c9f22628-d48c-477b-b9ac-a952c7d081ce"
}
# 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"]
}
+46 -2
View File
@@ -60,9 +60,13 @@ variable "providers_oauth2" {
client_id = string
# client_secret is never committed. Point at a Vault kv-v2 secret whose
# `client_secret` key holds the value (seeded out of band); TF reads it.
# generate = true flips that around: the secret is created here and written
# to that path as {client_id, client_secret}, so onboarding needs no manual
# seed. Requires write access to the kv path.
client_secret_vault = optional(object({
mount = string
path = string
mount = string
path = string
generate = optional(bool, false)
}), null)
# Managed identifiers of scope property mappings (e.g.
# goauthentik.io/providers/oauth2/scope-openid). Resolved to ids.
@@ -127,3 +131,43 @@ variable "providers_ldap" {
}))
default = {}
}
# Machine identities for automation (agents, CI). Each entry creates a service
# account user, an RBAC role carrying its global permissions, and any API tokens
# it needs. The username is the map key (the config filename).
variable "service_accounts" {
type = map(object({
name = optional(string, null) # display name; defaults to the key
description = optional(string, "")
# Global RBAC permissions granted via a dedicated role, in
# `<app_label>.<codename>` form (e.g. authentik_outposts.view_outpost).
permissions = optional(list(string), [])
# API tokens keyed by identifier. Set `vault` to publish the generated key
# into kv-v2 so consumers read it from Vault instead of an operator pasting it.
tokens = optional(map(object({
description = optional(string, "")
expiring = optional(bool, false)
vault = optional(object({
mount = string
path = string
key = optional(string, "token")
}), null)
})), {})
}))
default = {}
}
# Human role membership. The username is the map key (the config filename). The
# account itself is never managed here — humans are created by LDAP sync/invite
# and only looked up — so this grants and revokes roles, it does not make users.
#
# OWNERSHIP: naming a role in any user file makes Terraform authoritative over
# that role's entire member list, so members added by hand in the UI for that
# role are removed on the next apply. Roles no user file names are left alone.
variable "users" {
type = map(object({
# keys into var.role_groups (akR-*) this user is a member of.
roles = optional(list(string), [])
}))
default = {}
}
+6 -1
View File
@@ -1,13 +1,18 @@
terraform {
required_version = ">= 1.10"
required_providers {
# 2026.8.0 requires pbm_uuid on applications; the deployed 2026.5.3 server does not return it.
authentik = {
source = "goauthentik/authentik"
version = ">= 2026.5.0"
version = "2026.5.1"
}
vault = {
source = "hashicorp/vault"
version = ">= 4.0.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.6.0"
}
}
}