From 705bf4d1c5a9e29a337bd9a543c1874ab8cd12bf Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:54:16 +0900 Subject: [PATCH 1/5] terraform: package Authentik provider Manage Authentik objects declaratively without letting OpenTofu download provider binaries outside the Nix-managed toolchain. --- terraform/flake-module.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/terraform/flake-module.nix b/terraform/flake-module.nix index 1166cb6..b89d59a 100644 --- a/terraform/flake-module.nix +++ b/terraform/flake-module.nix @@ -6,6 +6,17 @@ pkgs, ... }: + let + authentikProvider = pkgs.terraform-providers.mkProvider { + owner = "goauthentik"; + repo = "terraform-provider-authentik"; + rev = "v2026.5.0"; + hash = "sha256-S7TbUK68XAGwdjkoRko8cZyA1UsuKTjR9jxh+YsjMyo="; + vendorHash = "sha256-6PjmKg9cpBjx2Pn92Jm7fIp/35erbS/AeQ3NB2VmFlQ="; + spdx = "GPL-3.0-only"; + homepage = "https://registry.terraform.io/providers/goauthentik/authentik"; + }; + in { devShells.terraform = pkgs.mkShellNoCC { packages = [ @@ -30,6 +41,7 @@ p.hashicorp_local p.hashicorp_null p.cloudflare_cloudflare + authentikProvider ]); }; }; From c5928b88d57c2f8fea6256268bf961e9c093d30f Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:02:43 +0900 Subject: [PATCH 2/5] authentik: manage forward auth apps with Terraform Nginx forward auth fails closed when the embedded outpost lacks matching proxy providers, leaving private dashboards with runtime 500s. Declare the protected applications, group bindings, and outpost attachments so Authentik policy stays in sync with the tailnet ingress configuration. --- .sops.nix | 2 + .sops.yaml | 8 ++ docs/admin/authentication.md | 6 +- docs/admin/terraform.md | 40 ++++--- modules/postgresql/default.nix | 1 + terraform/authentik/data.tf | 11 ++ terraform/authentik/forward-auth.tf | 93 +++++++++++++++ terraform/authentik/groups.tf | 27 +++++ terraform/authentik/import-existing.sh | 143 +++++++++++++++++++++++ terraform/authentik/providers.tf | 16 +++ terraform/authentik/secrets.tf | 7 ++ terraform/authentik/secrets.yaml | 16 +++ terraform/authentik/terragrunt.hcl | 3 + terraform/authentik/users.tf | 54 +++++++++ terraform/authentik/users.yaml | 155 +++++++++++++++++++++++++ 15 files changed, 566 insertions(+), 16 deletions(-) create mode 100644 terraform/authentik/data.tf create mode 100644 terraform/authentik/forward-auth.tf create mode 100644 terraform/authentik/groups.tf create mode 100755 terraform/authentik/import-existing.sh create mode 100644 terraform/authentik/providers.tf create mode 100644 terraform/authentik/secrets.tf create mode 100644 terraform/authentik/secrets.yaml create mode 100644 terraform/authentik/terragrunt.hcl create mode 100644 terraform/authentik/users.tf create mode 100644 terraform/authentik/users.yaml diff --git a/.sops.nix b/.sops.nix index f14adc9..cc3266a 100644 --- a/.sops.nix +++ b/.sops.nix @@ -64,6 +64,8 @@ let "modules/nextcloud/secrets.yaml" = [ "tau" ]; "modules/nfs/secrets.yaml" = [ "psi" ]; "modules/users/xrdp-passwords.yaml" = [ "psi" ]; + "terraform/authentik/secrets.yaml" = [ ]; + "terraform/authentik/users.yaml" = [ ]; "terraform/cloudflare/secrets.yaml" = [ ]; "terraform/github/secrets.yaml" = [ ]; "terraform/vultr/secrets.yaml" = [ ]; diff --git a/.sops.yaml b/.sops.yaml index f3fca93..115818c 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -119,6 +119,14 @@ creation_rules: - age1zdhqm6ptcnuu3tf2lzcngqmf6eud7jfah7v8falfy5mdksmnfuzq35sq54 - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s path_regex: modules/vaultwarden/secrets.yaml + - key_groups: + - age: + - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + path_regex: terraform/authentik/secrets.yaml + - key_groups: + - age: + - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + path_regex: terraform/authentik/users.yaml - key_groups: - age: - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s diff --git a/docs/admin/authentication.md b/docs/admin/authentication.md index 3522331..e56b3e0 100644 --- a/docs/admin/authentication.md +++ b/docs/admin/authentication.md @@ -12,6 +12,8 @@ | Nextcloud | OIDC (`user_oidc`) | SSO 로그인 | | Vaultwarden | OIDC (PKCE) | SSO 로그인 | | n8n | Forward Auth | nginx에서 인증 후 이메일 헤더 전달 (Headscale ACL + Forward Auth 이중 보호) | +| Grafana | Forward Auth | Tailnet에서만 접근 가능한 관리자 대시보드 | +| Gatus | 없음 | Tailnet에서만 접근 가능한 공개 상태 페이지 (Authentik dashboard tile만 표시) | | Nixbot | GitHub OAuth | CI/CD 대시보드 접근 | ### RAGFlow UI-only residue cleanup @@ -58,4 +60,6 @@ Authentik outpost는 `wg-admin` 인터페이스(포트 9000)에서만 접근 가 | `sjanglab-researchers` | AI + 앱 접근 | `tag:ai`, `tag:apps` | | `sjanglab-students` | 앱만 접근 | `tag:apps` | -Authentik 그룹은 Headscale ACL과 15분마다 자동 동기화됩니다. 상세 매커니즘은 [네트워크 — ACL 동기화](network.md#acl-sync)를 참조하세요. +Authentik 사용자와 그룹 선언은 `terraform/authentik`에서 관리합니다. 사람 계정 목록은 개인정보 보호를 위해 SOPS로 암호화한 `terraform/authentik/users.yaml`에 둡니다. 학생 계정은 `expires_on`을 반드시 지정하고, 만료 후에는 `active: false`로 변경합니다. 비활성 사용자는 Authentik group membership도 제거되어 Headscale ACL 동기화에서 빠집니다. + +그룹 membership은 Headscale ACL과 15분마다 자동 동기화됩니다. 상세 매커니즘은 [네트워크 — ACL 동기화](network.md#acl-sync)를 참조하세요. diff --git a/docs/admin/terraform.md b/docs/admin/terraform.md index 343738d..7323f21 100644 --- a/docs/admin/terraform.md +++ b/docs/admin/terraform.md @@ -1,6 +1,6 @@ # Terraform -외부 리소스(Cloudflare DNS, GitHub)를 코드로 관리합니다. +외부 리소스(Cloudflare DNS, GitHub)와 Authentik 애플리케이션 정책을 코드로 관리합니다. ## 백엔드 @@ -18,20 +18,30 @@ terraform apply ### Cloudflare DNS (`sjanglab.org`) -| 레코드 | 값 | 용도 | -|--------|-----|------| -| `buildbot.sjanglab.org` | 141.164.53.203 | Nixbot CI/CD edge proxy (eta → psi) | -| `logging.sjanglab.org` | 141.164.53.203 | Grafana | -| `hs.sjanglab.org` | 141.164.53.203 | Headscale | -| `auth.sjanglab.org` | 141.164.53.203 | Authentik | -| `vault.sjanglab.org` | 141.164.53.203 | Vaultwarden | -| `gatus.sjanglab.org` | 141.164.53.203 | 상태 페이지 | -| `ntfy.sjanglab.org` | 141.164.53.203 | 알림 | -| `n8n.sjanglab.org` | 141.164.53.203 | 워크플로우 | -| `cache.sjanglab.org` | 141.164.53.203 | Nix 캐시 | -| `upterm.sjanglab.org` | 141.164.53.203 | Upterm relay | - -대부분의 웹 서비스는 eta(141.164.53.203)의 nginx를 통해 프록시됩니다. Nixbot 서비스 스택은 psi에 있지만 `buildbot.sjanglab.org` 공개 ingress는 eta가 받아 wg-admin으로 psi에 프록시합니다. Upterm relay는 eta의 `2323/tcp`에 직접 노출됩니다. +공개 ingress가 필요한 레코드만 Cloudflare DNS에 둡니다. Tailnet 전용 서비스 이름은 Headscale split DNS로 관리합니다. + +### Authentik + +`terraform/authentik`은 사용자, 그룹, nginx forward auth에 필요한 Authentik proxy provider, application, embedded outpost attachment, access policy binding을 관리합니다. Terraform token은 `terraform/authentik/secrets.yaml`의 `AUTHENTIK_TOKEN`으로 전달합니다. 사람 계정 목록은 SOPS로 암호화한 `terraform/authentik/users.yaml`에 둡니다. + +기존 UI 객체를 Terraform으로 전환할 때는 먼저 import helper를 실행한 뒤 plan을 확인합니다. + +```bash +cd terraform/authentik +terragrunt init +./import-existing.sh +terragrunt plan +``` + +학생 계정은 `users.yaml`에서 `expires_on`을 설정합니다. 만료된 학생 계정은 `active: false`로 바꿔 Authentik 로그인과 Headscale ACL group membership을 함께 제거합니다. + +관리 대상: + +| 애플리케이션 | 그룹 | +|--------------|------| +| `n8n.sjanglab.org` | `sjanglab-admins`, `sjanglab-researchers` | +| `status.sjanglab.org` | 인증 없음 (Authentik dashboard tile만 관리) | +| `logging.sjanglab.org` | `sjanglab-admins` | ### GitHub diff --git a/modules/postgresql/default.nix b/modules/postgresql/default.nix index c8d7b5f..26f020b 100644 --- a/modules/postgresql/default.nix +++ b/modules/postgresql/default.nix @@ -109,6 +109,7 @@ in let psql = "${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"; terraformModules = [ + "authentik" "cloudflare" "github" "vultr" diff --git a/terraform/authentik/data.tf b/terraform/authentik/data.tf new file mode 100644 index 0000000..3b7c7a9 --- /dev/null +++ b/terraform/authentik/data.tf @@ -0,0 +1,11 @@ +data "authentik_flow" "authorization" { + slug = "default-provider-authorization-implicit-consent" +} + +data "authentik_flow" "invalidation" { + slug = "default-provider-invalidation-flow" +} + +data "authentik_outpost" "embedded" { + name = "authentik Embedded Outpost" +} diff --git a/terraform/authentik/forward-auth.tf b/terraform/authentik/forward-auth.tf new file mode 100644 index 0000000..f04691d --- /dev/null +++ b/terraform/authentik/forward-auth.tf @@ -0,0 +1,93 @@ +locals { + access_policies = { + admins = { + name = "sjanglab-forward-auth-admin-access" + allowed_groups = ["sjanglab-admins"] + } + researchers = { + name = "sjanglab-forward-auth-access" + allowed_groups = ["sjanglab-admins", "sjanglab-researchers"] + } + } + + launch_only_apps = { + status = { + name = "Gatus" + slug = "status" + meta_launch_url = "https://status.sjanglab.org" + } + } + + proxy_apps = { + n8n = { + name = "N8N" + provider_name = "n8n-forward-auth" + slug = "n8n" + external_host = "https://n8n.sjanglab.org" + access_policy = "researchers" + } + logging = { + name = "Grafana" + provider_name = "Grafana" + slug = "logging" + external_host = "https://logging.sjanglab.org" + access_policy = "admins" + } + } +} + +resource "authentik_policy_expression" "forward_auth" { + for_each = local.access_policies + + name = each.value.name + expression = <<-EOF + allowed_groups = [${join(", ", [for group in each.value.allowed_groups : jsonencode(group)])}] + user_groups = [g.name for g in request.user.ak_groups.all()] + return any(g in allowed_groups for g in user_groups) + EOF +} + +resource "authentik_application" "launch_only" { + for_each = local.launch_only_apps + + name = each.value.name + slug = each.value.slug + policy_engine_mode = "any" + meta_launch_url = each.value.meta_launch_url + open_in_new_tab = true +} + +resource "authentik_provider_proxy" "app" { + for_each = local.proxy_apps + + name = each.value.provider_name + authorization_flow = data.authentik_flow.authorization.id + invalidation_flow = data.authentik_flow.invalidation.id + external_host = each.value.external_host + mode = "forward_single" + access_token_validity = "hours=24" +} + +resource "authentik_application" "app" { + for_each = local.proxy_apps + + name = each.value.name + slug = each.value.slug + protocol_provider = authentik_provider_proxy.app[each.key].id + policy_engine_mode = "any" +} + +resource "authentik_policy_binding" "app_access" { + for_each = local.proxy_apps + + target = authentik_application.app[each.key].uuid + policy = authentik_policy_expression.forward_auth[each.value.access_policy].id + order = 0 +} + +resource "authentik_outpost_provider_attachment" "embedded" { + for_each = local.proxy_apps + + outpost = data.authentik_outpost.embedded.id + protocol_provider = authentik_provider_proxy.app[each.key].id +} diff --git a/terraform/authentik/groups.tf b/terraform/authentik/groups.tf new file mode 100644 index 0000000..eac6dc4 --- /dev/null +++ b/terraform/authentik/groups.tf @@ -0,0 +1,27 @@ +locals { + groups = { + authentik_admins = { + name = "authentik Admins" + is_superuser = true + } + sjanglab_admins = { + name = "sjanglab-admins" + is_superuser = true + } + sjanglab_researchers = { + name = "sjanglab-researchers" + is_superuser = false + } + sjanglab_students = { + name = "sjanglab-students" + is_superuser = false + } + } +} + +resource "authentik_group" "group" { + for_each = local.groups + + name = each.value.name + is_superuser = each.value.is_superuser +} diff --git a/terraform/authentik/import-existing.sh b/terraform/authentik/import-existing.sh new file mode 100755 index 0000000..b2d47f5 --- /dev/null +++ b/terraform/authentik/import-existing.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +AK=${AK:-https://auth.sjanglab.org} +AK_TOKEN=${AK_TOKEN:-$(sops -d --extract '["AUTHENTIK_TOKEN"]' secrets.yaml)} +TG=${TG:-terragrunt} + +api() { + curl -sSf -H "Authorization: Bearer ${AK_TOKEN}" "$AK/api/v3/$1" +} + +state_has() { + $TG state show "$1" >/dev/null 2>&1 +} + +import_if_missing() { + local address=$1 + local id=$2 + + if [ -z "$id" ] || [ "$id" = "null" ]; then + echo "skip $address: no remote object" + return 0 + fi + + if state_has "$address"; then + echo "ok $address already imported" + return 0 + fi + + echo "import $address <- $id" + $TG import "$address" "$id" +} + +json_quote() { + jq -Rn --arg v "$1" '$v' +} + +group_id_by_name() { + local name=$1 + api "core/groups/?search=$(jq -rn --arg v "$name" '$v|@uri')&page_size=100" | + jq -r --arg name "$name" '.results[] | select(.name == $name) | .pk' | + head -n1 +} + +user_id_by_username() { + local username=$1 + api "core/users/?search=$(jq -rn --arg v "$username" '$v|@uri')&page_size=100" | + jq -r --arg username "$username" '.results[] | select(.username == $username) | .pk' | + head -n1 +} + +proxy_id_by_external_host() { + local external_host=$1 + api "providers/proxy/?page_size=100" | + jq -r --arg external_host "$external_host" '.results[] | select(.external_host == $external_host) | .pk' | + head -n1 +} + +app_uuid_by_slug() { + local slug=$1 + curl -sS -H "Authorization: Bearer ${AK_TOKEN}" "$AK/api/v3/core/applications/${slug}/" 2>/dev/null | + jq -r '.pk // empty' 2>/dev/null || true +} + +policy_id_by_name() { + local name=$1 + api "policies/expression/?search=$(jq -rn --arg v "$name" '$v|@uri')&page_size=100" | + jq -r --arg name "$name" '.results[] | select(.name == $name) | .pk' | + head -n1 +} + +binding_id_by_target_policy() { + local target=$1 + local policy=$2 + api "policies/bindings/?target=${target}&page_size=100" | + jq -r --arg policy "$policy" '.results[] | select(.policy == $policy) | .pk' | + head -n1 +} + +outpost_id=$(api 'outposts/instances/?search=authentik%20Embedded%20Outpost&page_size=100' | + jq -r '.results[] | select(.name == "authentik Embedded Outpost") | .pk' | + head -n1) + +# Keep keys in sync with local.groups in groups.tf. +while IFS='|' read -r key name; do + import_if_missing "authentik_group.group[$(json_quote "$key")]" "$(group_id_by_name "$name")" +done <<'EOF' +authentik_admins|authentik Admins +sjanglab_admins|sjanglab-admins +sjanglab_researchers|sjanglab-researchers +sjanglab_students|sjanglab-students +EOF + +while IFS= read -r username; do + import_if_missing "authentik_user.user[$(json_quote "$username")]" "$(user_id_by_username "$username")" +done < <(sops -d users.yaml | yq -r '.users[].username') + +while IFS='|' read -r key name; do + import_if_missing "authentik_policy_expression.forward_auth[$(json_quote "$key")]" "$(policy_id_by_name "$name")" +done <<'EOF' +admins|sjanglab-forward-auth-admin-access +researchers|sjanglab-forward-auth-access +EOF + +policy_name_for_key() { + case "$1" in + admins) echo 'sjanglab-forward-auth-admin-access' ;; + researchers) echo 'sjanglab-forward-auth-access' ;; + *) + echo "unknown policy key: $1" >&2 + return 1 + ;; + esac +} + +while IFS='|' read -r app external_host policy_key; do + provider_id=$(proxy_id_by_external_host "$external_host") + app_id=$(app_uuid_by_slug "$app") + policy_name=$(policy_name_for_key "$policy_key") + policy_id=$(policy_id_by_name "$policy_name") + + import_if_missing "authentik_provider_proxy.app[$(json_quote "$app")]" "$provider_id" + if [ -n "$app_id" ]; then + import_if_missing "authentik_application.app[$(json_quote "$app")]" "$app" + else + echo "skip application for $app: no remote object" + fi + + if [ -n "$outpost_id" ] && [ -n "$provider_id" ]; then + import_if_missing "authentik_outpost_provider_attachment.embedded[$(json_quote "$app")]" "${outpost_id}:${provider_id}" + else + echo "skip outpost attachment for $app: no outpost/provider" + fi + + if [ -n "$app_id" ] && [ -n "$policy_id" ]; then + import_if_missing "authentik_policy_binding.app_access[$(json_quote "$app")]" "$(binding_id_by_target_policy "$app_id" "$policy_id")" + else + echo "skip policy binding for $app: no app/policy" + fi +done <<'EOF' +n8n|https://n8n.sjanglab.org|researchers +logging|https://logging.sjanglab.org|admins +EOF diff --git a/terraform/authentik/providers.tf b/terraform/authentik/providers.tf new file mode 100644 index 0000000..fd14a9f --- /dev/null +++ b/terraform/authentik/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + authentik = { + source = "goauthentik/authentik" + version = "2026.5.0" + } + sops = { + source = "carlpett/sops" + } + } +} + +provider "authentik" { + url = "https://auth.sjanglab.org" + token = data.sops_file.secrets.data["AUTHENTIK_TOKEN"] +} diff --git a/terraform/authentik/secrets.tf b/terraform/authentik/secrets.tf new file mode 100644 index 0000000..9d92584 --- /dev/null +++ b/terraform/authentik/secrets.tf @@ -0,0 +1,7 @@ +data "sops_file" "secrets" { + source_file = "./secrets.yaml" +} + +data "sops_file" "users" { + source_file = "./users.yaml" +} diff --git a/terraform/authentik/secrets.yaml b/terraform/authentik/secrets.yaml new file mode 100644 index 0000000..97d60ec --- /dev/null +++ b/terraform/authentik/secrets.yaml @@ -0,0 +1,16 @@ +AUTHENTIK_TOKEN: ENC[AES256_GCM,data:hlm8OntXNJJi0HmMMSb3Nk5PZML+q6O7pASN+L8yNQw17gB+54Jp+kfOsFkGOvQ2KsHjott4TvIQC2J5,iv:wK9hiHZn7Flml8rAR9rw+TcBFDZK9aIwuA+Wg40ZEJo=,tag:R4ePfLlunYY+I34+1I8C2A==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4RFRnaTJMMWtFNVNoWmF3 + M2diekZNWVhGUlJ1SWhRQjBLNEozUzJyWkY0CkpRUzU2T1NhSmNYZjFJWHl5b1hl + c2xSZ3VERWNHaURSVlZCRG5na3VIYnMKLS0tIHJqVVBYLzJuR1huVXpwQmtwZW1M + YmQxWnMwQWN4cDhYRGxqL1FlZEh2elkKe4irSFqaajWd8mOAcvBGMDFkxjAJEf+q + UeRChkt+ekwbGuccmLAGVmuzEIRiIDzh+Jw0TWsNh+vk5qmBPmHGlg== + -----END AGE ENCRYPTED FILE----- + recipient: age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + lastmodified: "2026-07-02T16:17:41Z" + mac: ENC[AES256_GCM,data:1wQSm2b9jTThq5h6AvbMN5nQLllm2CKR6B0quAk6V4eYrOq6cjIyxOlV84nQU0aNLSqB+K9pYTvYl4KopFIRR7wiQ6JYulallj03Koagid88HCd0Qvpl4FegfHfnb+QaOh2KShRJB5GQjHm27PUlPmEGYP8fazjZMxDVv+uwLQA=,iv:8TTS49aHAmeIOQA0izD+JaPMJmwqehk7owDBF6E9YZo=,tag:poUkWiSEsj1lYqn/jG2l1g==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.1 diff --git a/terraform/authentik/terragrunt.hcl b/terraform/authentik/terragrunt.hcl new file mode 100644 index 0000000..aef53a2 --- /dev/null +++ b/terraform/authentik/terragrunt.hcl @@ -0,0 +1,3 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} diff --git a/terraform/authentik/users.tf b/terraform/authentik/users.tf new file mode 100644 index 0000000..759c6b0 --- /dev/null +++ b/terraform/authentik/users.tf @@ -0,0 +1,54 @@ +locals { + user_inventory = nonsensitive(yamldecode(data.sops_file.users.raw).users) + users = { + for user in local.user_inventory : user.username => merge( + { + name = "" + email = "" + type = "internal" + path = "users" + groups = [] + active = true + expires_on = null + }, + user, + ) + } + today = formatdate("YYYY-MM-DD", timestamp()) +} + +resource "authentik_user" "user" { + for_each = local.users + + username = each.key + name = each.value.name + email = each.value.email + type = each.value.type + path = each.value.path + is_active = each.value.active + groups = [ + for group_key in(each.value.active ? each.value.groups : []) : authentik_group.group[group_key].id + ] + + lifecycle { + precondition { + condition = !contains(each.value.groups, "sjanglab_students") || each.value.expires_on != null + error_message = "Student users must set expires_on in terraform/authentik/users.yaml." + } + + precondition { + condition = each.value.expires_on == null || can(regex("^\\d{4}-\\d{2}-\\d{2}$", each.value.expires_on)) + error_message = "expires_on must use YYYY-MM-DD format." + } + + precondition { + condition = each.value.expires_on == null || !contains(each.value.groups, "sjanglab_students") || !each.value.active || timecmp("${each.value.expires_on}T00:00:00Z", "${local.today}T00:00:00Z") >= 0 + error_message = "Expired student users must set active = false in terraform/authentik/users.yaml." + } + + ignore_changes = [ + attributes, + roles, + ] + } +} diff --git a/terraform/authentik/users.yaml b/terraform/authentik/users.yaml new file mode 100644 index 0000000..6348d2b --- /dev/null +++ b/terraform/authentik/users.yaml @@ -0,0 +1,155 @@ +users: + - username: ENC[AES256_GCM,data:V9+nV4/45g==,iv:Txq92CrXKWuO0OqCxmuqPOQvs78oRR6wpF0z2diSB7w=,tag:i1wA7TPCOwiQqAWldbzpwQ==,type:str] + email: ENC[AES256_GCM,data:YFZYfoODWudtol6+XsI4YqEm,iv:CZfa3uJ6wUV0BzU8uRhVT/MurReop8s6LE5ws4SfGZc=,tag:lOmkYb09hUuvPaybXQknZA==,type:str] + groups: + - ENC[AES256_GCM,data:UyeSraQzvu2veuSY/Nyg,iv:inw267ZDzpLrYxHJy8p1efKl8TNlVuBXUo8Hgej7qaQ=,tag:ZHlKb+9LpClZ9HvNHuYt3Q==,type:str] + name: ENC[AES256_GCM,data:q6/4krbAPqMPPE2WcxTVtFEjCUxUVmo=,iv:oyWtN9g46NdeQkZl5Pznhk9uN8x5XqHXsESr7+bDG54=,tag:M07xa68vs0pW2/e/jaBSGg==,type:str] + type: ENC[AES256_GCM,data:qfbNMaonBMA=,iv:Rvx6kV0nUMgmqPwhzC9YpJWvL+Sq6ifauC/fjyM1Khw=,tag:HtRtCYi1U9rp5B8G7KArhw==,type:str] + active: ENC[AES256_GCM,data:TRWAbg==,iv:N52/GuOxP++t5M591NbUkWuqk+EIHF2vjtMNFRZOPhc=,tag:H1+qMOcBh18G67DAoybFTg==,type:bool] + - username: ENC[AES256_GCM,data:bBibrQmaQ0p6sf4mCRFTcu0/b9F4,iv:N9h8xbq3aTPIh+wZ4N8fMku51eLtCZ0pxU/Bllg2h10=,tag:K1mgJWqm1OYfgYDiD5XzSg==,type:str] + email: ENC[AES256_GCM,data:0kS1NaJ9Ry4hu2D1LN77SdItD7Ez,iv:qIZendBnnOj1BX9Fc6eYCbMjZcvMmKs5/ejfDnsFBMc=,tag:E9mzYM9RNibzJbF/b8OR+w==,type:str] + groups: + - ENC[AES256_GCM,data:erLn1wugSeXem4s8R0dvqVk=,iv:e21905RcZ4Qg/JZmc+E+JpVHbvX1wJWV4L+dJlfS2L4=,tag:Fd5nR5THEOerjGXXPHARfw==,type:str] + name: "" + type: ENC[AES256_GCM,data:3JbzR2mYNA4=,iv:yxL4mue+4ejYLRoHDyXuVmL1EpFCnIN8zN4nrpNCTXU=,tag:cx+gz3KGIckygPcRa5XvUQ==,type:str] + active: ENC[AES256_GCM,data:fIQCCQ==,iv:ChNaaVIxYcVBFBi7eOG0BgXpFYakm7j/3CKZ2PdnKdI=,tag:CjLPQ/J0KZ6HAwGO2fZU7g==,type:bool] + expires_on: ENC[AES256_GCM,data:bFrwGgg4CDDwGg==,iv:gvoOwxhIqV8X6Qy8IaWEacnXzaCLRVxJkszA0wG67QY=,tag:uULad3m2P0Q3m/uV4bBakw==,type:str] + - username: ENC[AES256_GCM,data:530udwLSfXJLJto0rfrR4xtz,iv:NqND3wQxoJMkzCMAf8ZY6wpTxhFGpCy9wQ5+K1eaZxY=,tag:1s+mWwJIW06AR/okXgDChg==,type:str] + email: ENC[AES256_GCM,data:CFydL9IOq1kCQ5d3rXY9H3xQ,iv:uzG5+kT82OJ5BRuzHNKrD06AWo9Yy5nU34FcuKodn8A=,tag:8JxfM/R3dCFeAWTacFfIJA==,type:str] + groups: + - ENC[AES256_GCM,data:24PS1sNk7/7lkZoIVC8HFbk=,iv:UZVxZ6Ui6YfLv6IJReuTPl88FkkU0DsPEnTJ7f3tElo=,tag:PrR0k7c5CnR6U4J9FDFoVw==,type:str] + name: "" + type: ENC[AES256_GCM,data:u8PuOwq9Idk=,iv:kBa/sqflAyL7dzExbYGbO47ljb9BNrTpJrQuySTPXq0=,tag:sHKmJbkA5/YmYxfEdX1CpQ==,type:str] + active: ENC[AES256_GCM,data:q+FGjQ==,iv:CFmGJILh+auK7zTII218/Cf7zQo9yO9EioAGlqtvHVY=,tag:SONSNDog1Wj0w/4Q0LqbuA==,type:bool] + expires_on: ENC[AES256_GCM,data:pLgZ28rtB30UfA==,iv:BVFMxfJGgZRCVFkTm+9xCA8/DFCDJjWDfrST9SCC1v8=,tag:V6+0lxzlmroyUmjpUO+mlA==,type:str] + - username: ENC[AES256_GCM,data:li1nSqWDHanppy83YYZ7M3WP,iv:aryLOXfdckVqIKBNCDiPPKVb5VEEauSAZIPXl/WaXl0=,tag:T1ygm9Y9BVEY+03V9CfanA==,type:str] + email: "" + groups: [] + name: ENC[AES256_GCM,data:it9XBSS9u1Tohj6LQ5ssy/gc,iv:VkZRTr0xa0/VJC1PwKFh2GeAR3dKQIn4g/HtVaGAycU=,tag:FaOZhNx0e1w0zUKYKojUqw==,type:str] + path: ENC[AES256_GCM,data:+6mnAZojvZUxsix/VC0jq/f6gkkJhzBCxnQ2NolI3A==,iv:93uJYt5a0prPRzaD4L2f5YeECrYDfMvv1GTxWS91SNo=,tag:MLBUnTv6lfJLoPcf1VvOqA==,type:str] + type: ENC[AES256_GCM,data:RAQl8rgqohxUYMbAQQOi,iv:psGDizXv3HGFqZiOq+p196rn+VQoBWYgNpCRxX2mUuY=,tag:AAC499D/JtlBTjH5hl2gyA==,type:str] + active: ENC[AES256_GCM,data:VkV2CQ==,iv:AidUX0abkqHkHV+6B6Y3WIYC65Opbiw/sV8neG5hVAA=,tag:mgbveMAAdcVTYMdlmGdeyA==,type:bool] + - username: ENC[AES256_GCM,data:7Wo9C5oi3ehZ86jxyy4KYKc=,iv:Dcz/Nrxsc/GxYIFofg4LuqYL1xrtkP+ESM3EnCgdwK0=,tag:tWaTOzLTqB189T4pmx/6Pw==,type:str] + email: ENC[AES256_GCM,data:CKg4gVDgZQI+f1XQePm3PLY=,iv:hHAJzWcZHjEsMSqIVuPwjqpDegZ1YhP7R7q/gy0yVww=,tag:/mEXK8jXj1IVWQ1E0pya3Q==,type:str] + groups: + - ENC[AES256_GCM,data:zoXc+Up1WI0HExlakuF72QVaAZE=,iv:1LXeFQdFINHuvzqW+JW7sEBDZwl5RLJlwItWudS9oEg=,tag:e+bQ3LgW0QPlq+PEwFk/uw==,type:str] + name: "" + type: ENC[AES256_GCM,data:kz4bfxt+FrE=,iv:RWo18XPu1NDo1vSBKLY+qA1Cbz3cXcWWpzu4qKS8bAk=,tag:GiPC0x3jji+0lXCxMgp8ZQ==,type:str] + active: ENC[AES256_GCM,data:lEROGQ==,iv:16A/zZGsvBN4deeaDWL0KK16pCuchp/7P09XwuSVxkQ=,tag:y5oCIgD+udNauAxHsuV7MA==,type:bool] + - username: ENC[AES256_GCM,data:8KjToIhQmynO0eqoB12qE4WK2w==,iv:4LtbcF+gTOnDI/wW0nKw+FEIxwO/HdD3R3jbQdGuOfU=,tag:UCeBsWf/Fi9EQEiU+rXgPA==,type:str] + email: ENC[AES256_GCM,data:hmEerBg6JF3t0ZFmLbjKpq9gNg==,iv:0++AkRaf/I0Hh15NrUxxtoZBLLIGpoJtL2WIslhcsdo=,tag:CYAENsg1kgm8lN2hqY1PKg==,type:str] + groups: + - ENC[AES256_GCM,data:9+E49r9xAQnrFyKZw1WmsLbcRws=,iv:Ue13/X2dptH2/eFD2lF8jdgVM7C1GGFjfRZA35Rdrlo=,tag:ULZgmVxvH06Z7Vzd1keapw==,type:str] + name: "" + type: ENC[AES256_GCM,data:vZU/xSJ8YwU=,iv:WfoTb5DaCqdK9zhoVKLJ8nK3JTRLSai+f3mohp3SR94=,tag:KLwIQMQOZ8EHacgzmUC43Q==,type:str] + active: ENC[AES256_GCM,data:MLL8Zg==,iv:gueNE+amZqEyKBtM/jXbbbdNLAw+KlJPksNu0RWbznQ=,tag:WnB9vUx40Wf/S5b94TWETg==,type:bool] + - username: ENC[AES256_GCM,data:vfETsbnxOM3vPSY9423Beu7ltw==,iv:zgrwFfDyZpKvDMv5aJZotsYqdfm5Q8gpBKQzWqM1Z9A=,tag:GyQPI4v/Oi1dR2GtpFeSXw==,type:str] + email: ENC[AES256_GCM,data:GsAR2gEIMA7UyJV0u829w9VAcw==,iv:Zai7vpSTOofHATgaxfuHFqCLqhjIgSsAs54/SXvYKiw=,tag:fUTw1zbeWl+TgKZByhsjwA==,type:str] + groups: + - ENC[AES256_GCM,data:6BV8hORIKfZMaf2KJVBmNwBQoo0=,iv:UCHz/i8Go0IkaT/JcY56y9de9jtdKlzRSuFarAEq3G0=,tag:PxSg5Na1PnXLXvDvMq3Pkw==,type:str] + name: "" + type: ENC[AES256_GCM,data:ZE5ZnXsKVJo=,iv:XkYwMQrPXL746lNTxr3bGfStj3WcYZyw6wx+3dcx4Tk=,tag:lxUWT156+1tdsoSWnTOP5Q==,type:str] + active: ENC[AES256_GCM,data:9viNFw==,iv:ePJ4ecemg0E4bghmVKkyBDyM81UtUOgzMa3AWKHdA4E=,tag:GIG57U6I0iR7cejmPOC2ZA==,type:bool] + - username: ENC[AES256_GCM,data:dhUAv840YeT/9d2tNqNVuLrYXg==,iv:TL6EM4ol/ojTL/QcnsKvNpoIhY2PwRVq6gTWbpGoxnQ=,tag:rMi5mWOQ2MgMYUt5qor9uA==,type:str] + email: ENC[AES256_GCM,data:J2detVnzPK4b2qh02GvHo8VKtA==,iv:MQ2b6v6qJkdv5Iiazbt6kFdWwFSzA5tHSh+uco97ARg=,tag:LUc9Y5UIJwmqUxzU/y5KVw==,type:str] + groups: + - ENC[AES256_GCM,data:TBrfYRQ2ymjKdtP9Z3bf7Q0=,iv:eb+Tg7aVX9vAc6NA77pJS0jhqAK/tTBabx6xu81+pLQ=,tag:Wrg0c334eac+cJ36ZM2Ytg==,type:str] + name: "" + type: ENC[AES256_GCM,data:9fVqG63gKpQ=,iv:8MBejaLO4czeAXI+s9++a9zPgmdQuxXkqUHoijQha+Y=,tag:6y9bIOpC0WEEiHk8uFb9Cg==,type:str] + active: ENC[AES256_GCM,data:5dxIwg==,iv:0uKCEfRmlka4ZVs750A4gT5/uMJQXUTwNfWHsSdADeM=,tag:TrsQhiL3jaFB/3dGYCqn9w==,type:bool] + expires_on: ENC[AES256_GCM,data:uo03FvlkV4Zj2A==,iv:Q6tBZz0+RidIUtHNNXznO2stK0WW5elDPtmctCuStvs=,tag:VxSb29Eq34KtWk3jphSSLw==,type:str] + - username: ENC[AES256_GCM,data:DzTiyFBLwzlJ3WWJfHAiKtsq7jLz02/P,iv:qNgBNTLDe/0zGBwvrNjp8iF2+n001qrQJCSGrUV2FsY=,tag:2dy7FfOwsDgiDNl2FNl7aA==,type:str] + email: ENC[AES256_GCM,data:TybfUTZroK7kJ6/qatkshJC8brXqNOrq,iv:IwloaA7StROR73tw9jiLCo8rzlhe/2RvdFqgVWVoLfk=,tag:zF0bryT7cAuKZ1m2DTE8nw==,type:str] + groups: + - ENC[AES256_GCM,data:6bWbhdG+GTN4c//M1pnHkl7IJEg=,iv:LPRzhqfGaWG4iRg7mZv7tcKsOKjLl0dfeLPpVHi0zl0=,tag:VlSJBgZhl4xDbVzj0rYo1g==,type:str] + name: "" + type: ENC[AES256_GCM,data:EttOfvSzwh8=,iv:PtegukIrZnBfYp4SWOa4+YsI8nAaIZAcCWDJ2TpOo+I=,tag:zgMzL/0YLB5kCz09iJvrsw==,type:str] + active: ENC[AES256_GCM,data:0zKJnA==,iv:hjm+pQjhA1XC2MS93zfSP6GCdj5BTHGENXe4rLgpzNs=,tag:LLQIEBEaFlBgrLvGONRZVg==,type:bool] + - username: ENC[AES256_GCM,data:Qv0BTH42K+VdDSFKn62jWOs=,iv:jws4dq2a80n/VOUpVFdej5ks0C+qCjxjgBudFeT4gY0=,tag:FWFniJkNAgI/4dW077JcLg==,type:str] + email: ENC[AES256_GCM,data:FqvcwPqh1jrQ7X+bZJdw+kI=,iv:NGlZKr1Nq/tYX1wdGOlABPhPyuvpYIwGY0zzP7ddYVI=,tag:sOaQv75Szd2PM4Py49wCjg==,type:str] + groups: + - ENC[AES256_GCM,data:U3NvwF8p5xQjCbre1mx74aD3CN0=,iv:0pH3eoZBkZDATjDrFUOoHEb23jroji0G8dEiW19Xu4o=,tag:i+GAy9d+kinyjawN4wj1/A==,type:str] + name: ENC[AES256_GCM,data:d/YIstbcG1rM,iv:7FSjWmHY76I72w9l98rCML2rrBsQ/8yKC2SpCpynbjQ=,tag:SH9vV7J2q/ilnls/reX29w==,type:str] + type: ENC[AES256_GCM,data:5Vxcfz2KmE8=,iv:0OkmH3rlinOXh6OmaFQBPARoFy7cn734Zg+jjURU3y8=,tag:+JTbUfZiF3Tzxy1EyPSKRA==,type:str] + active: ENC[AES256_GCM,data:ILyfsg==,iv:P0uIEuYbIfU0sanwmpFZ2XmX+4AYa9nKHMzuX4E3G5c=,tag:XHJzg743NA/r3Qb/AANTpw==,type:bool] + - username: ENC[AES256_GCM,data:sdtiG/8BaT9qWcVmKJ2/cNDb,iv:BpFYS8JunO9j4+oQCG/tar6g+78gXijCCE/8Vbnk3Qg=,tag:QweLnopdUn8dGXfm7TS4ig==,type:str] + email: ENC[AES256_GCM,data:7vt2CPU/OlzwsA4yEayOEo4s,iv:vUn5WZfkp3UitH9G04TIRL0IdLkKUCvmpiq9yLifpQM=,tag:rPshI4iNxkkZAlMGSbI4KQ==,type:str] + groups: + - ENC[AES256_GCM,data:HyD0PJuob4FNn+JIlhB2XueFuj4=,iv:xukSt5mm1qszC2rTmka/kBs4HfAqssHU3OU7w5Sm0+k=,tag:cGFMloI6YqCugGsgG0tyrA==,type:str] + name: "" + type: ENC[AES256_GCM,data:pwhSuY7Hq0g=,iv:lnmuhm5/KG/3almXOZulyBeSvn2Cfp79CSgKdFqtSFs=,tag:+MjUo4wmoD+Z/vFM5V4YkQ==,type:str] + active: ENC[AES256_GCM,data:+on3Jg==,iv:l7JDDApboTnZvHG+PEfznDFmhL+s0Ozu6gfXLg4MzBs=,tag:LIRz0ZVKWe1Yk16MJ4VNsA==,type:bool] + - username: ENC[AES256_GCM,data:2PbKjlaLh12W4v33ieeSCDADWA==,iv:F8BUO5fyRTWQeEGMo9KVkEnLSs1zyDds/DQOXYrzhQs=,tag:2GhCKJX1eyUHuO4208DBMQ==,type:str] + email: ENC[AES256_GCM,data:GJvocRLv2E0nPr7APEf06Lqz4Q==,iv:5bKQLpDhcbfnKW1EM0VWpZkDMaHrfe4wu/hqjNjXcdc=,tag:OKIfZgpMVPZCyXZXUvoW8w==,type:str] + groups: + - ENC[AES256_GCM,data:S46X9mALkDKjkIE3u3K6q+1ZjuQ=,iv:/oREwLzk9B5I2PrKieI0G/kPk9sNfh/IUeMeiiJvYik=,tag:tNOPHlOAp1PKzRfphi+FhQ==,type:str] + name: "" + type: ENC[AES256_GCM,data:2ZkjMf15gU8=,iv:jtdUIXjgZyo3nARqP/U0vEB9+RJdvs225p/NnZgSgMU=,tag:bhWhuNL/zj2hqTefW3UPYQ==,type:str] + active: ENC[AES256_GCM,data:qxc5aw==,iv:q4y4qmfcNRh4Lg5KA0FND5/nCFC6lqomWTST1+HluO8=,tag:R++x3pz9c31B8H0ekx/FXg==,type:bool] + - username: ENC[AES256_GCM,data:MzeIX6ogpGoysCCecISZKtwet2myZw==,iv:gT2FBqhDwuoFwL9gbwrM3cE91gyEF8Ts2WHsUzG/iuQ=,tag:9L+xHC1PGXQOGwlrtE8EMg==,type:str] + email: ENC[AES256_GCM,data:JW42Br9ibjRaadxEP1lQmqxpM9+eJA==,iv:G0gp4u/evUu1v0r6eatGt5L0ObHHRQvHxp+O8aj6JLY=,tag:/vv+UiVf2GMkXvOQNTkykQ==,type:str] + groups: + - ENC[AES256_GCM,data:xqijFEinYGa/NpuwxBgxaZ/GwnE=,iv:d8VP8kq5bzYLkNBJuqVnFWFqQjvUslkPJ/YCGQHkG8E=,tag:xXRF8xOLTsARD0Pz0lKH0A==,type:str] + name: "" + type: ENC[AES256_GCM,data:6DWRdBrgrmQ=,iv:ThpnsJ2n7G0rmXJ8R/oReXE5FVnCODzZauMInK00MTo=,tag:n133mj0CorueoVNotNayQQ==,type:str] + active: ENC[AES256_GCM,data:v58lZA==,iv:HwT5VNHqu/NyJhS4Ws4b/VU1NH8Pu0yHg3qiBwOq2iI=,tag:vRB/Tg6aIspuG4Cusj9aDw==,type:bool] + - username: ENC[AES256_GCM,data:JByr/gM/Xa7pAU02IVports99BW5Dw==,iv:jTCTOJgPQOYI+Jf3V6ehcGNBY/t/hZH+VnE0TuoHoDE=,tag:tKwUvuH9CptnetUPeecmbw==,type:str] + email: ENC[AES256_GCM,data:VZ8IM4SCmXZ4bTBM3L6GubpPggSp9w==,iv:xjVXFSkkpd/teKx39KsvaUtagSTLPnWagSBEsJkJuls=,tag:fvmUrdrwZ5sv2EVjwPLGiw==,type:str] + groups: + - ENC[AES256_GCM,data:Gjji2tH5oR7KxFHnkYvBtI4=,iv:iSzqeZh+ZSuh6RPXcd13b8IyTZEznqpYRAUboviTnPc=,tag:EQ51uB/7V4MuSUq9/9wwnA==,type:str] + name: "" + type: ENC[AES256_GCM,data:jMUmg4265pE=,iv:PUkyRiqcd8EoOS67fgV/WcGE+N74ycfRKA0TngNzw9I=,tag:bkjJ7+egiF2QbhkEDi1yZw==,type:str] + active: ENC[AES256_GCM,data:I/nYZA==,iv:BB5FOSVDjftGuc9ZaDySmMvlGGE1G0iJwkFrS+jTptU=,tag:0aJegcMyNQ0WZbhr1qiV2g==,type:bool] + expires_on: ENC[AES256_GCM,data:7Cs1kLDsYEc3iA==,iv:4ph9cZLgFILaCwIwAiwRHboD7fd1jj560cyu0nK2H7o=,tag:PXSYvM0zG8pYJ7B0NFtKFw==,type:str] + - username: ENC[AES256_GCM,data:cQoqFz+M/w+eegyFG/4kuXJnfY0=,iv:u1ZtGKm+zNAwdk1MCq8wvfY0Oswbqkj8Hhazq/bT1bc=,tag:0QPMJjQv5QXoEVou/Mq8NA==,type:str] + email: ENC[AES256_GCM,data:JjaRY3U+H5UhxzI/SdZ/ADnxm6I=,iv:HzcfnvORGTn5SbFCdXy1QK4ANFfcIzHpYB47Nq0T3FU=,tag:C01c0vqTVrLJICvvFzSoCw==,type:str] + groups: + - ENC[AES256_GCM,data:m/wS8XNu9Dr0Az9uU4gs,iv:cU7BrFWMaJE3Q1vfjbouCmfLTuE1vB6imXtMxwmWrnE=,tag:6TtqsBecXPU+Fk2Q3Aybwg==,type:str] + name: "" + type: ENC[AES256_GCM,data:DeuU0JLGe0g=,iv:1wsj3xrizUd7yOeB8/rOtAbMcuCmNqdk45umqLtYOd0=,tag:kJe25huxEtozjdSfngqpSw==,type:str] + active: ENC[AES256_GCM,data:+L26WQ==,iv:v8GXPPZtvVywliY10iaxB8u7dcfBuYpM4nnU2tGXBKY=,tag:W+v9XLBO76G30LtUBePUNA==,type:bool] + - username: ENC[AES256_GCM,data:BbD11yCIiM2K,iv:Jvl+aOIBhx/7UcyDAGrACtvpAxj8w8iz5kAs73H9iBc=,tag:shxbjE4FnSApLyqJji94gA==,type:str] + email: "" + groups: + - ENC[AES256_GCM,data:zo1F071eFZllN2ZiLAjeag==,iv:vDkvhcd2+NhYCaZybf5nu4pZns08jKqOyvhY0XxPYJE=,tag:OtVYoOsBXRwEFh3VpowWeA==,type:str] + name: ENC[AES256_GCM,data:ttgIOd6i38Tz,iv:jV+zcZ8immcr7eCX5tW0YlZKEpWceKMP0lzP74KHRg8=,tag:exGq6MSKXqHAguQ4njHjTg==,type:str] + path: ENC[AES256_GCM,data:xftnNUZJpGDiDYQiWhbEd6Azutx0soyumNrV+uJ25Q==,iv:L+rm1qiDTvOb70WzIuORm+iWg0+hixGgj1EP8A/rySs=,tag:svuL6tUUM8WuBeCtrGJ7PA==,type:str] + type: ENC[AES256_GCM,data:Nsh/AdhbGlRu+HV6w9dE,iv:iXEwyMj4TMXlYk167yhTyOFKJpawWvMRBQkP/neo6Vg=,tag:v1CIQ5PZbfPGmqLqBdIaWQ==,type:str] + active: ENC[AES256_GCM,data:92IKhA==,iv:TlzDYycCp+00KkDm7aIAnkJNN1PfzN6KT8L3qb++f40=,tag:CGQqCwZNJ/2MKfoZCaDz2g==,type:bool] + - username: ENC[AES256_GCM,data:Dqrb9jyraoNZvQecSrzy2hKHWQ==,iv:L+zMQLou+HTZRcLpjutg/Np1YgrLCx3WLDYj5Q9U7fg=,tag:10e7xfdvdr/utXsETlwy/g==,type:str] + email: ENC[AES256_GCM,data:aZsDYaSxDc+AkNF/L36jd3bzHQ==,iv:Hc2ykXoAZkwXP+D+qMwa4UYSgIcJOnmlf2A+lgzU7kE=,tag:fUboVynj6B8CTxEb7/n9iA==,type:str] + groups: + - ENC[AES256_GCM,data:4iIWVIVzO2Uw448F//wV4w8=,iv:oDtjUf0dwzX7LC3KEpQl+3Y7S3nhgSaNxh87WWtLVPY=,tag:09XmNrkseQSPe/KBl5YFaA==,type:str] + name: "" + type: ENC[AES256_GCM,data:QvEv52pNqw0=,iv:XBx/cJ6GnwiTUFoGpjiU8mKsMfPqkYi6c+c1QjWBKmE=,tag:UYBmqHikZOyK9xpL0G3GhQ==,type:str] + active: ENC[AES256_GCM,data:T3ir3g==,iv:/f32K9ZE3CB2ytaIIjbek2MYFC7gOMEYZzO1pXKTBKc=,tag:p9EmFQbftOv5lfpBuRF/kA==,type:bool] + expires_on: ENC[AES256_GCM,data:w9VUZgTYAHG7Bw==,iv:PUKXaD5xKcppDX2H7a7D076GOE3zyGmJ+WlfliOpXEk=,tag:nVnWTPZf1Ck+Dwfmcx4rDg==,type:str] + - username: ENC[AES256_GCM,data:V9gWWaBwuItV3ZlwHrq/n/OZHA+4SQ==,iv:DlpRDsMhpKP/XMlU20mTqbXwD2y15r6IcT2FhR7hL9A=,tag:eI2KHEZwiZoiMvN+Yeauuw==,type:str] + email: ENC[AES256_GCM,data:dTWapHK4QftRw4WxK8gtw/EFZcwnIQ==,iv:lHi4NU+80a2ierS7FONSlmi7Hq1r02hgANhPwWfJ0ps=,tag:wnZT0Nt/K5huySl008z6Fg==,type:str] + groups: + - ENC[AES256_GCM,data:RhAvSlE9CexpeEhIhG5CjDYW58M=,iv:VKox6yp2xWt/YvzRT+6E2KgnDTB7fbkDMHn88H0j3VM=,tag:koIpjyWEmLKKSzs6fAfUig==,type:str] + name: "" + type: ENC[AES256_GCM,data:YqcF5ONEHwo=,iv:ypZ3CZQW5f+DV2VvwkySHFVtR6YWoQNUEsHAcmS1qg4=,tag:+yV+uQV9mncguryG6KcYYA==,type:str] + active: ENC[AES256_GCM,data:mDd1gg==,iv:JuoLbQEKVHlhmOsK8R9+z7TchH02blf+abe3wPya9aE=,tag:sQz5gF0pSQOzwbFHwVmW/A==,type:bool] + - username: ENC[AES256_GCM,data:wG1guCqlkDS6J8F7QhcfqyU=,iv:AwEFjd5AsAKoeSA6/rZuWQJlQkCQNAtW+Trtk7sxs+A=,tag:wKTjPmNLRN7h1LRF0cyXkg==,type:str] + email: ENC[AES256_GCM,data:QQR+7FdII2EJdkKH2rb1qkA=,iv:oY9sdL0118I2HSGWHS5z2VhwDBnFvE3BVgHtgT+i+0w=,tag:T6oqLfBPReCm7X5/8QCtaA==,type:str] + groups: + - ENC[AES256_GCM,data:yjkXhCaXrJ2zEC0l4t9KnvBMKk4=,iv:F0I0UnddBXdIh3f4N1QpvATvYPPbswAXKjHEnkVrPxE=,tag:qEa0zK4BIJS5DK92KlFrjA==,type:str] + name: "" + type: ENC[AES256_GCM,data:i1uWy0b240s=,iv:24u4h+o9OKZ+Uv25Xw1BDBxKONFvGLecE+6ni6ALYzA=,tag:D07YIDKBTO9SvNsI3WRwrg==,type:str] + active: ENC[AES256_GCM,data:aBn0Ew==,iv:+47Wz63h7OdW0WvAEdob2rX1EGjrhxcjypb3i00pvAY=,tag:NRXOr2PF+z/ZUf/SYioZ1g==,type:bool] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOK0lScTFtRlhrVUtTamlu + L3gwUGdIdkhVU2RMeVNCcEhvOUxuNWxpeENVCmNDK0Q5SUsrRm1xVTVydllGcXQz + N0dmbTVlUjF5VWhsNlo1SkZKUXo1ME0KLS0tIG5SbXR3OFRFamREZ1d2VUw1Wktu + aFpqY1JHTGp5L3hRZW1qM2ZRS25jZlUK7wZP5Eq362oz7BEPmM4Xry+VyP4YdT2n + EFiJEgh88CAl0fx89kTD/MYiT28bG+dGkfCbc/i8ttO46GDI8y70pQ== + -----END AGE ENCRYPTED FILE----- + recipient: age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + lastmodified: "2026-07-02T16:46:08Z" + mac: ENC[AES256_GCM,data:ms8+BzCmffdRB1bR2GAHKkdUkGLGCzHpwDv7nF8EoBgVSQI16+uatEbGCPMqEuJdCTy7G+DEAyIe3yZZelFGPCRJgmlYm/pdlAS8xE16edjxfkEOIkFRLe7RXxOg/UBdFdItClM6e5nhJqsyChCJYl8czFugSablomwF5nuduPE=,iv:Qwxm6lHz9KX6zT9VH9/fxPTczu3I8mU/h0k1DAn7fjk=,tag:1of77cLYpa+0fxbgNiUkqA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.1 From f0b0ab2a447fe2dd57a8ad3cb9a1ebc2e3fdbe75 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:10:03 +0900 Subject: [PATCH 3/5] headscale: manage ACL policy with Terraform Keep Authentik and Headscale authorization derived from the same user inventory so group membership changes do not depend on a separate sync service. Terraform owns Headscale users and the database policy, while SOPS keeps API credentials and user PII encrypted. --- .sops.nix | 1 + .sops.yaml | 4 + docs/admin/authentication.md | 4 +- docs/admin/index.md | 4 +- docs/admin/network.md | 28 +++--- docs/admin/terraform.md | 16 +++- docs/admin/user-management.md | 30 +++---- modules/headscale/acl-rules.nix | 59 ------------- modules/headscale/acl-sync.nix | 95 -------------------- modules/headscale/default.nix | 29 +----- modules/postgresql/default.nix | 1 + terraform/flake-module.nix | 15 ++++ terraform/headscale/import-existing.sh | 52 +++++++++++ terraform/headscale/main.tf | 117 +++++++++++++++++++++++++ terraform/headscale/providers.tf | 16 ++++ terraform/headscale/secrets.tf | 7 ++ terraform/headscale/secrets.yaml | 16 ++++ terraform/headscale/terragrunt.hcl | 7 ++ terraform/headscale/variables.tf | 4 + 19 files changed, 288 insertions(+), 217 deletions(-) delete mode 100644 modules/headscale/acl-rules.nix delete mode 100644 modules/headscale/acl-sync.nix create mode 100755 terraform/headscale/import-existing.sh create mode 100644 terraform/headscale/main.tf create mode 100644 terraform/headscale/providers.tf create mode 100644 terraform/headscale/secrets.tf create mode 100644 terraform/headscale/secrets.yaml create mode 100644 terraform/headscale/terragrunt.hcl create mode 100644 terraform/headscale/variables.tf diff --git a/.sops.nix b/.sops.nix index cc3266a..f4583df 100644 --- a/.sops.nix +++ b/.sops.nix @@ -68,6 +68,7 @@ let "terraform/authentik/users.yaml" = [ ]; "terraform/cloudflare/secrets.yaml" = [ ]; "terraform/github/secrets.yaml" = [ ]; + "terraform/headscale/secrets.yaml" = [ ]; "terraform/vultr/secrets.yaml" = [ ]; } // { diff --git a/.sops.yaml b/.sops.yaml index 115818c..f5ffb83 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -135,6 +135,10 @@ creation_rules: - age: - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s path_regex: terraform/github/secrets.yaml + - key_groups: + - age: + - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + path_regex: terraform/headscale/secrets.yaml - key_groups: - age: - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s diff --git a/docs/admin/authentication.md b/docs/admin/authentication.md index e56b3e0..4be942f 100644 --- a/docs/admin/authentication.md +++ b/docs/admin/authentication.md @@ -60,6 +60,6 @@ Authentik outpost는 `wg-admin` 인터페이스(포트 9000)에서만 접근 가 | `sjanglab-researchers` | AI + 앱 접근 | `tag:ai`, `tag:apps` | | `sjanglab-students` | 앱만 접근 | `tag:apps` | -Authentik 사용자와 그룹 선언은 `terraform/authentik`에서 관리합니다. 사람 계정 목록은 개인정보 보호를 위해 SOPS로 암호화한 `terraform/authentik/users.yaml`에 둡니다. 학생 계정은 `expires_on`을 반드시 지정하고, 만료 후에는 `active: false`로 변경합니다. 비활성 사용자는 Authentik group membership도 제거되어 Headscale ACL 동기화에서 빠집니다. +Authentik 사용자와 그룹 선언은 `terraform/authentik`에서 관리합니다. 사람 계정 목록은 개인정보 보호를 위해 SOPS로 암호화한 `terraform/authentik/users.yaml`에 둡니다. 학생 계정은 `expires_on`을 반드시 지정하고, 만료 후에는 `active: false`로 변경합니다. -그룹 membership은 Headscale ACL과 15분마다 자동 동기화됩니다. 상세 매커니즘은 [네트워크 — ACL 동기화](network.md#acl-sync)를 참조하세요. +Headscale ACL policy는 `terraform/headscale`이 같은 `users.yaml`에서 생성합니다. 비활성 사용자는 Authentik group membership과 Headscale ACL group에서 함께 빠집니다. 상세 매커니즘은 [네트워크 — ACL 정책 관리](network.md#acl-policy)를 참조하세요. diff --git a/docs/admin/index.md b/docs/admin/index.md index e6b6b44..9034777 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -9,7 +9,7 @@ SBEE Lab 인프라를 운영하기 위한 관리자 가이드입니다. | `inv` (invoke) | 배포, 운영, 사용자 관리 자동화 | 로컬 `tasks.py` | | Authentik | SSO/사용자/그룹 관리 | `https://auth.sjanglab.org` | | sops | 시크릿 암호화/복호화 | `sops hosts/.yaml` | -| Terraform | DNS, GitHub 리소스 관리 | `terraform/` 디렉토리 | +| Terraform | DNS, GitHub, Authentik, Headscale 정책 관리 | `terraform/` 디렉토리 | ## 인증 흐름 @@ -18,7 +18,7 @@ SBEE Lab 인프라를 운영하기 위한 관리자 가이드입니다. → nginx → Authentik Forward Auth → 서비스 ``` -Authentik 그룹(`sjanglab-admins`, `sjanglab-researchers`, `sjanglab-students`)이 Headscale ACL과 15분마다 자동 동기화되어 네트워크 수준의 접근 제어가 이루어집니다. +Terraform이 Authentik 그룹과 Headscale ACL policy를 같은 사용자 inventory에서 생성하여 네트워크 수준의 접근 제어가 이루어집니다. ## 주요 명령어 diff --git a/docs/admin/network.md b/docs/admin/network.md index 3bd6def..819a0d9 100644 --- a/docs/admin/network.md +++ b/docs/admin/network.md @@ -72,7 +72,7 @@ NAT 뒤 호스트(rho, tau)는 엔드포인트가 없으며, 공인 IP 호스트 ### ACL 그룹 { #acl-groups } -Authentik 그룹과 15분마다 자동 동기화됩니다. +Terraform이 `terraform/authentik/users.yaml`의 그룹 membership으로 생성합니다. | 그룹 | 접근 태그 | |------|----------| @@ -91,27 +91,23 @@ Authentik 그룹과 15분마다 자동 동기화됩니다. > †n8n은 네트워크 수준에서 `tag:apps`로 접근 가능하나, Authentik Forward Auth에서 관리자만 허용합니다. -### ACL 동기화 매커니즘 { #acl-sync } +### ACL 정책 관리 { #acl-policy } + +Headscale ACL policy는 `terraform/headscale`의 `headscale_policy.tailnet` 리소스가 관리합니다. 사용자 membership은 SOPS로 암호화한 `terraform/authentik/users.yaml`을 source of truth로 사용하고, Authentik 사용자/그룹과 Headscale ACL policy가 같은 inventory에서 생성됩니다. ```mermaid sequenceDiagram - participant T as systemd 타이머 (15분) - participant S as acl-sync 스크립트 - participant A as Authentik API + participant U as users.yaml + participant T as Terraform + participant A as Authentik participant H as Headscale - T->>S: 실행 - S->>A: 그룹 멤버십 조회 - A-->>S: 그룹/사용자 목록 - S->>S: 정적 ACL + 동적 그룹 병합 - S->>H: ACL 파일 갱신 - H->>H: inotify 감지 → 리로드 + U->>T: 사용자/그룹 inventory + T->>A: Authentik 사용자/그룹 반영 + T->>H: Headscale ACL policy 반영 ``` -1. systemd 타이머가 15분마다 `acl-sync` 스크립트를 실행 -1. Authentik API에서 그룹 멤버십을 조회 -1. 정적 ACL 규칙과 동적 그룹 정보를 병합하여 새 ACL 파일 생성 -1. Headscale이 inotify로 ACL 파일 변경을 감지하여 자동 리로드 +Headscale은 `policy.mode = "database"`로 동작합니다. ACL 변경 후 즉시 반영하려면 `terraform/headscale`에서 `terragrunt apply`를 실행합니다. ## 방화벽 정책 @@ -131,4 +127,4 @@ sequenceDiagram ## ACME 인증서 -대부분의 TLS 인증서는 eta에서 Cloudflare DNS 챌린지로 발급됩니다. Nixbot(`buildbot.sjanglab.org`) 공개 인증서는 eta에서 발급되고, psi의 Nixbot nginx도 wg-admin upstream용 인증서를 유지합니다. 다른 호스트(psi, tau)의 나머지 인증서는 `acme-sync` 사용자를 통해 rsync로 동기화됩니다. +대부분의 TLS 인증서는 eta에서 Cloudflare DNS 챌린지로 발급됩니다. Nixbot(`buildbot.sjanglab.org`) 공개 인증서는 eta에서 발급되고, psi의 Nixbot nginx도 wg-admin upstream용 인증서를 유지합니다. 다른 호스트(rho, tau, psi)의 나머지 인증서는 `acme-sync` 사용자를 통해 rsync로 동기화됩니다. diff --git a/docs/admin/terraform.md b/docs/admin/terraform.md index 7323f21..250f3d7 100644 --- a/docs/admin/terraform.md +++ b/docs/admin/terraform.md @@ -1,6 +1,6 @@ # Terraform -외부 리소스(Cloudflare DNS, GitHub)와 Authentik 애플리케이션 정책을 코드로 관리합니다. +외부 리소스(Cloudflare DNS, GitHub), Authentik 애플리케이션 정책, Headscale ACL policy를 코드로 관리합니다. ## 백엔드 @@ -43,6 +43,20 @@ terragrunt plan | `status.sjanglab.org` | 인증 없음 (Authentik dashboard tile만 관리) | | `logging.sjanglab.org` | `sjanglab-admins` | +### Headscale + +`terraform/headscale`은 Headscale database ACL policy를 관리합니다. Headscale API key는 `terraform/headscale/secrets.yaml`의 `HEADSCALE_API_KEY`로 전달합니다. 사용자 membership은 `terraform/authentik/users.yaml`을 함께 읽어 Authentik과 같은 source of truth를 사용합니다. + +Headscale module은 `services.headscale.settings.policy.mode = "database"` 배포 후 apply합니다. 기존 Headscale users는 먼저 import합니다. `headscale_policy`는 provider가 import를 지원하지 않아 첫 apply가 singleton database policy를 설정하면서 Terraform state를 만듭니다. + +```bash +cd terraform/headscale +terragrunt init +./import-existing.sh +terragrunt plan +terragrunt apply +``` + ### GitHub - 리포지토리 설정: Pages, 브랜치 보호 규칙 diff --git a/docs/admin/user-management.md b/docs/admin/user-management.md index 5b80d61..7d317eb 100644 --- a/docs/admin/user-management.md +++ b/docs/admin/user-management.md @@ -7,7 +7,7 @@ | **NixOS** (`modules/users/`) | SSH 계정, 로컬 사용자 | 서버 접속, 파일시스템, Docker | | **Authentik** (`auth.sjanglab.org`) | SSO 계정, 그룹 | 웹 서비스 인증, Headscale ACL | -두 시스템은 독립적으로 관리되며, Headscale ACL만 Authentik 그룹에서 15분마다 자동 동기화됩니다. +SSO 계정과 Headscale ACL은 `terraform/authentik/users.yaml`을 source of truth로 삼아 Terraform에서 함께 관리합니다. ______________________________________________________________________ @@ -137,30 +137,30 @@ Authentik은 웹 서비스(Nextcloud, Vaultwarden, n8n)의 SSO 인증과 Headsca ### 사용자 추가 -1. `https://auth.sjanglab.org/if/admin/` 접속 -1. **Directory → Users** 에서 사용자를 생성하거나 초대합니다 -1. **Directory → Groups** 에서 해당 그룹에 사용자를 추가합니다 +1. `terraform/authentik/users.yaml`에 사용자를 추가합니다 +1. 학생 계정이면 `expires_on`을 설정합니다 +1. `terraform/authentik`과 `terraform/headscale` plan을 확인한 뒤 apply합니다 -### ACL 동기화 +### ACL 정책 반영 -Authentik 그룹 변경은 Headscale ACL에 자동 반영됩니다: +Authentik 사용자/그룹과 Headscale ACL은 같은 SOPS inventory에서 생성됩니다: -1. systemd 타이머가 15분마다 `headscale-acl-sync` 서비스를 실행 -1. Authentik API에서 `sjanglab-*` 그룹의 멤버십을 조회 -1. 정적 ACL 규칙(`acl-rules.nix`)과 동적 그룹 정보를 병합하여 `policy.json` 생성 -1. Headscale이 inotify로 파일 변경을 감지하여 자동 리로드 +1. `terraform/authentik/users.yaml`이 사용자와 그룹 membership의 source of truth입니다 +1. `terraform/authentik`은 Authentik 사용자/그룹을 반영합니다 +1. `terraform/headscale`은 같은 membership으로 Headscale database ACL policy를 생성합니다 -즉시 동기화가 필요하면: +즉시 반영하려면: ```bash -ssh -p 10022 root@eta systemctl start headscale-acl-sync.service +cd terraform/headscale +terragrunt apply ``` ### 사용자 비활성화 -1. Authentik 관리 UI에서 사용자를 **비활성화** (삭제가 아닌 비활성화) -1. 그룹에서 제거합니다 -1. 다음 ACL 동기화 시 Headscale 접근이 차단됩니다 +1. `terraform/authentik/users.yaml`에서 `active: false`로 변경합니다 +1. `terraform/authentik`을 apply해 Authentik 로그인을 비활성화합니다 +1. `terraform/headscale`을 apply해 Headscale ACL 그룹에서 제거합니다 ______________________________________________________________________ diff --git a/modules/headscale/acl-rules.nix b/modules/headscale/acl-rules.nix deleted file mode 100644 index f1fb573..0000000 --- a/modules/headscale/acl-rules.nix +++ /dev/null @@ -1,59 +0,0 @@ -# Static ACL rules -# - Groups: populated dynamically by acl-sync.nix (from Authentik) -# - Tags: assigned by tag-sync.nix (declarative) -{ - tagOwners = { - "tag:server" = [ "group:sjanglab-admins" ]; - "tag:ai" = [ "group:sjanglab-admins" ]; - "tag:apps" = [ "group:sjanglab-admins" ]; - "tag:monitoring" = [ "group:sjanglab-admins" ]; - }; - - acls = [ - # Server-to-server: tau (n8n) -> psi (AI APIs) - { - action = "accept"; - src = [ "tag:apps" ]; - dst = [ "tag:ai:443" ]; - } - - # Admins: AI (psi:80,443) + apps (tau:80,443) + monitoring (rho:3000) - { - action = "accept"; - src = [ "group:sjanglab-admins" ]; - dst = [ - "tag:ai:80" - "tag:ai:443" - "tag:apps:80" - "tag:apps:443" - "tag:monitoring:443" - "tag:monitoring:3000" - ]; - } - - # Researchers: AI + apps (nextcloud; n8n gated by Authentik forward auth) - { - action = "accept"; - src = [ "group:sjanglab-researchers" ]; - dst = [ - "tag:ai:80" - "tag:ai:443" - "tag:apps:80" - "tag:apps:443" - ]; - } - - # Students: apps only (nextcloud; n8n gated by Authentik forward auth) - { - action = "accept"; - src = [ "group:sjanglab-students" ]; - dst = [ - "tag:apps:80" - "tag:apps:443" - ]; - } - ]; - - # No SSH via headscale — SSH is wg-admin only (port 10022) - ssh = [ ]; -} diff --git a/modules/headscale/acl-sync.nix b/modules/headscale/acl-sync.nix deleted file mode 100644 index fa70ae7..0000000 --- a/modules/headscale/acl-sync.nix +++ /dev/null @@ -1,95 +0,0 @@ -# Sync Authentik groups → Headscale ACL policy.json -# -# Merges dynamic groups from Authentik API with static ACL rules. -# Headscale auto-reloads policy.json on file change (inotify). -{ config, pkgs, ... }: -let - aclRules = import ./acl-rules.nix; - policyPath = "/var/lib/headscale/policy.json"; - - syncScript = pkgs.writeShellScript "headscale-acl-sync" '' - set -euo pipefail - - AUTHENTIK_URL="https://auth.sjanglab.org" - TOKEN=$(< "$CREDENTIALS_DIRECTORY/authentik-api-token") - - # Fetch groups with members from Authentik - response=$(${pkgs.curl}/bin/curl -sf \ - -H "Authorization: Bearer $TOKEN" \ - "$AUTHENTIK_URL/api/v3/core/groups/?include_users=true&page_size=100") - - # Build groups JSON: map Authentik groups to headscale ACL groups - # Only include sjanglab-* groups; filter users with @ (headscale requirement) - groups=$(echo "$response" | ${pkgs.jq}/bin/jq -c ' - [.results[] | select(.name | startswith("sjanglab-"))] | - map({ - key: ("group:" + .name), - value: [.users_obj[]? | .username | select(contains("@"))] - }) | from_entries - ') - - # Merge dynamic groups with static rules - policy=$(${pkgs.jq}/bin/jq -nc \ - --argjson groups "$groups" \ - --slurpfile rules ${pkgs.writeText "acl-rules.json" (builtins.toJSON aclRules)} \ - '$rules[0] + {groups: $groups}') - - # Atomic write - tmp=$(mktemp -p /var/lib/headscale) - echo "$policy" > "$tmp" - mv "$tmp" "${policyPath}" - - echo "ACL sync complete: $(echo "$groups" | ${pkgs.jq}/bin/jq -r 'to_entries | map(.key + ": " + (.value | length | tostring)) | join(", ")')" - ''; - - # Sanitize policy.json before headscale starts (remove usernames without @) - sanitizeScript = pkgs.writeShellScript "headscale-policy-sanitize" '' - set -euo pipefail - if [ -f "${policyPath}" ]; then - ${pkgs.jq}/bin/jq ' - .groups = (.groups // {} | to_entries | map({ - key: .key, - value: [.value[]? | select(contains("@"))] - }) | from_entries) - ' "${policyPath}" > "${policyPath}.tmp" - mv "${policyPath}.tmp" "${policyPath}" - echo "Policy sanitized: removed usernames without @" - fi - ''; -in -{ - # Sanitize policy.json before headscale starts - systemd.services.headscale.serviceConfig.ExecStartPre = [ - "${sanitizeScript}" - ]; - sops.secrets.authentik-api-token = { - sopsFile = ./secrets.yaml; - owner = "headscale"; - group = "headscale"; - mode = "0400"; - }; - - systemd.services.headscale-acl-sync = { - description = "Sync Authentik groups to Headscale ACL policy"; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - serviceConfig = { - Type = "oneshot"; - User = "headscale"; - Group = "headscale"; - LoadCredential = [ - "authentik-api-token:${config.sops.secrets.authentik-api-token.path}" - ]; - }; - script = "${syncScript}"; - }; - - systemd.timers.headscale-acl-sync = { - wantedBy = [ "timers.target" ]; - timerConfig = { - OnBootSec = "1min"; - OnUnitActiveSec = "15min"; - RandomizedDelaySec = "1min"; - }; - }; -} diff --git a/modules/headscale/default.nix b/modules/headscale/default.nix index 407c296..6ec5d17 100644 --- a/modules/headscale/default.nix +++ b/modules/headscale/default.nix @@ -1,12 +1,8 @@ { config, ... }: -let - policyPath = "/var/lib/headscale/policy.json"; -in { imports = [ ../acme ../gatus/check.nix - ./acl-sync.nix ./tag-sync.nix ]; @@ -100,11 +96,8 @@ in logtail.enabled = false; metrics_listen_addr = "127.0.0.1:9090"; - # ACL policy: static rules + dynamic groups from Authentik (see acl-sync.nix) - policy = { - mode = "file"; - path = policyPath; - }; + # ACL policy is managed by terraform/headscale through the Headscale API. + policy.mode = "database"; }; }; @@ -115,24 +108,6 @@ in mode = "0400"; }; - # Fallback policy for initial boot (before first acl-sync run) - # Uses permissive rules; acl-sync overwrites with group-based ACLs - systemd.tmpfiles.rules = - let - fallback = builtins.toJSON { - groups = { }; - acls = [ - { - action = "accept"; - src = [ "autogroup:member" ]; - dst = [ "*:*" ]; - } - ]; - ssh = [ ]; - }; - in - [ "f ${policyPath} 0640 headscale headscale - ${fallback}" ]; - # ACME certificate security.acme.certs."hs.sjanglab.org" = { dnsProvider = "cloudflare"; diff --git a/modules/postgresql/default.nix b/modules/postgresql/default.nix index 26f020b..f908d52 100644 --- a/modules/postgresql/default.nix +++ b/modules/postgresql/default.nix @@ -112,6 +112,7 @@ in "authentik" "cloudflare" "github" + "headscale" "vultr" ]; in diff --git a/terraform/flake-module.nix b/terraform/flake-module.nix index b89d59a..c535ea1 100644 --- a/terraform/flake-module.nix +++ b/terraform/flake-module.nix @@ -16,14 +16,28 @@ spdx = "GPL-3.0-only"; homepage = "https://registry.terraform.io/providers/goauthentik/authentik"; }; + + headscaleProvider = pkgs.terraform-providers.mkProvider { + owner = "awlsring"; + repo = "terraform-provider-headscale"; + rev = "v0.5.1"; + hash = "sha256-TgDwX5On4nvPU2hAePPimZD2f3y2ev3nKVmkRaXiTxk="; + vendorHash = "sha256-zkV47RZtjjaIy+9sLpCgfcnYqWTSgKqdgHZhJ26oaQQ="; + spdx = "MPL-2.0"; + homepage = "https://registry.terraform.io/providers/awlsring/headscale"; + }; in { devShells.terraform = pkgs.mkShellNoCC { packages = [ + pkgs.curl + pkgs.jq + pkgs.shellcheck pkgs.sops pkgs.terragrunt pkgs.postgresql_17 pkgs.vultr-cli + pkgs.yq-go config.packages.terraform ]; @@ -42,6 +56,7 @@ p.hashicorp_null p.cloudflare_cloudflare authentikProvider + headscaleProvider ]); }; }; diff --git a/terraform/headscale/import-existing.sh b/terraform/headscale/import-existing.sh new file mode 100755 index 0000000..5190da5 --- /dev/null +++ b/terraform/headscale/import-existing.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +HS=${HS:-https://hs.sjanglab.org} +HS_TOKEN=${HS_TOKEN:-$(sops -d --extract '["HEADSCALE_API_KEY"]' secrets.yaml)} +TG=${TG:-terragrunt} + +api() { + curl -sSf -H "Authorization: Bearer ${HS_TOKEN}" "$HS/api/v1/$1" +} + +state_has() { + $TG state show "$1" >/dev/null 2>&1 +} + +json_quote() { + jq -Rn --arg v "$1" '$v' +} + +import_if_missing() { + local address=$1 + local id=$2 + + if [ -z "$id" ] || [ "$id" = "null" ]; then + echo "skip $address: no remote object" + return 0 + fi + + if state_has "$address"; then + echo "ok $address already imported" + return 0 + fi + + echo "import $address <- $id" + $TG import "$address" "$id" +} + +user_id_by_name() { + local name=$1 + api "user" | + jq -r --arg name "$name" '.users[] | select(.name == $name) | .id' | + head -n1 +} + +while IFS= read -r username; do + import_if_missing "headscale_user.user[$(json_quote "$username")]" "$(user_id_by_name "$username")" +done < <(sops -d ../authentik/users.yaml | yq -r '.users[].username | select(test("@"))') + +cat <<'EOF' + +note: headscale_policy.tailnet cannot be imported because awlsring/headscale v0.5.1 does not implement ImportState for headscale_policy. First apply will create Terraform state while setting the singleton database policy. +EOF diff --git a/terraform/headscale/main.tf b/terraform/headscale/main.tf new file mode 100644 index 0000000..4cac7d8 --- /dev/null +++ b/terraform/headscale/main.tf @@ -0,0 +1,117 @@ +locals { + user_inventory = nonsensitive(yamldecode(data.sops_file.users.raw).users) + users = { + for user in local.user_inventory : user.username => merge( + { + groups = [] + active = true + }, + user, + ) + } + + headscale_groups = { + sjanglab_admins = { + name = "sjanglab-admins" + } + sjanglab_researchers = { + name = "sjanglab-researchers" + } + sjanglab_students = { + name = "sjanglab-students" + } + } + + policy_groups = { + for group_key, group in local.headscale_groups : "group:${group.name}" => [ + for username, user in local.users : username + if user.active && contains(user.groups, group_key) && can(regex("@", username)) + ] + } + + headscale_users = { + for username, user in local.users : username => user + if can(regex("@", username)) + } + + policy = { + groups = local.policy_groups + + hosts = { + status = "100.64.0.2" + } + + tagOwners = { + "tag:server" = ["group:sjanglab-admins"] + "tag:ai" = ["group:sjanglab-admins"] + "tag:apps" = ["group:sjanglab-admins"] + "tag:monitoring" = ["group:sjanglab-admins"] + } + + acls = [ + { + action = "accept" + src = ["tag:apps"] + dst = ["tag:ai:443"] + }, + { + action = "accept" + src = ["autogroup:member"] + dst = ["status:443"] + }, + { + action = "accept" + src = ["group:sjanglab-admins"] + dst = [ + "tag:ai:80", + "tag:ai:443", + "tag:apps:80", + "tag:apps:443", + "tag:monitoring:443", + "tag:monitoring:3000", + ] + }, + { + action = "accept" + src = ["group:sjanglab-researchers"] + dst = [ + "tag:ai:80", + "tag:ai:443", + "tag:apps:80", + "tag:apps:443", + ] + }, + { + action = "accept" + src = ["group:sjanglab-students"] + dst = [ + "tag:apps:80", + "tag:apps:443", + ] + }, + ] + + ssh = [] + } +} + +resource "headscale_user" "user" { + for_each = local.headscale_users + + name = each.key + + lifecycle { + prevent_destroy = true + ignore_changes = [ + display_name, + email, + profile_picture_url, + ] + } +} + +resource "headscale_policy" "tailnet" { + depends_on = [headscale_user.user] + + policy = jsonencode(local.policy) +} diff --git a/terraform/headscale/providers.tf b/terraform/headscale/providers.tf new file mode 100644 index 0000000..5bf92f7 --- /dev/null +++ b/terraform/headscale/providers.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + headscale = { + source = "awlsring/headscale" + version = "0.5.1" + } + sops = { + source = "carlpett/sops" + } + } +} + +provider "headscale" { + endpoint = "https://hs.sjanglab.org" + api_key = data.sops_file.secrets.data["HEADSCALE_API_KEY"] +} diff --git a/terraform/headscale/secrets.tf b/terraform/headscale/secrets.tf new file mode 100644 index 0000000..307a785 --- /dev/null +++ b/terraform/headscale/secrets.tf @@ -0,0 +1,7 @@ +data "sops_file" "secrets" { + source_file = "./secrets.yaml" +} + +data "sops_file" "users" { + source_file = var.user_inventory_file +} diff --git a/terraform/headscale/secrets.yaml b/terraform/headscale/secrets.yaml new file mode 100644 index 0000000..e6d311b --- /dev/null +++ b/terraform/headscale/secrets.yaml @@ -0,0 +1,16 @@ +HEADSCALE_API_KEY: ENC[AES256_GCM,data:v9VNyRf19l9vrouNGpMXfCUGxnPIpdNNbYNDg4uFLhsV/G5TbI6IKe0bYrNt9N26ogeinmvftgqme2vYWUuux79NbmWNyJkN8faKFiIxQg+58Ff9XO+M,iv:SnYVOBXD/240N9dO0F+Wq7QLxrmMOSruVYX+nYjfTXc=,tag:qB3ijacHQ5WmSyKSW/+VSw==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDV3hJYzBQVzh4WjNUOUJ6 + SkNpdit0V2ZpY0VJQXBIaXBQL05rM2JLcmxVCll1YjlYOUs0Yk5INUU2c2h6bVpy + Zm9JbFFWYUJoUE9DMFI2OFhqbGJWd3MKLS0tIGNDaVVUcW1IYXA2dHUrSEtCZEk2 + SXFBMVduaCtGRCtldlJOamZ5RzR3T3MKKM7WAf8eCwR0EpwhGZBTrMdqG9ZVizq2 + 1vDH7rSK4AyjOZQFyfQahRMmOA3SuxHf6cDZG1PHi7UOeadgNc3kuw== + -----END AGE ENCRYPTED FILE----- + recipient: age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + lastmodified: "2026-07-02T17:02:55Z" + mac: ENC[AES256_GCM,data:F/2xWWevW5JvsnGSJifcjkjAsF/+8HUfimRCldZJsyRZDmm5IeCsPDUlrcyBVl07EVhprI19FAtmM3cTBN4zXx8a6h5iXT7C5D9/P8B9wSJvgacvEx0bMjcbxr9mkHC4atlxXxTnaitf25YIPMipt1SEOrjTiToR9s0U9rR14hk=,iv:Zjq8bI/NG8j+6WnDYjAvricpGFbtZa7NxRC6gI+lMeY=,tag:uWXWEeMzbDR9CWdGFZtfHw==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.1 diff --git a/terraform/headscale/terragrunt.hcl b/terraform/headscale/terragrunt.hcl new file mode 100644 index 0000000..dce28a4 --- /dev/null +++ b/terraform/headscale/terragrunt.hcl @@ -0,0 +1,7 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + user_inventory_file = "${get_repo_root()}/terraform/authentik/users.yaml" +} diff --git a/terraform/headscale/variables.tf b/terraform/headscale/variables.tf new file mode 100644 index 0000000..e5cf6ab --- /dev/null +++ b/terraform/headscale/variables.tf @@ -0,0 +1,4 @@ +variable "user_inventory_file" { + description = "Path to the SOPS-encrypted Authentik user inventory." + type = string +} From 92df63cce39c1b27933befb0099c820d0e8cb878 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:48:56 +0900 Subject: [PATCH 4/5] terraform: wait for backend locks during stack plans Parallel Terragrunt stack plans can briefly contend for PostgreSQL backend locks. Make OpenTofu wait instead of failing immediately so running plan across all Terraform modules is reliable. --- terraform/root.hcl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/terraform/root.hcl b/terraform/root.hcl index bcb7541..b96ad72 100644 --- a/terraform/root.hcl +++ b/terraform/root.hcl @@ -5,6 +5,11 @@ locals { } terraform { + extra_arguments "wait_for_state_lock" { + commands = ["plan", "apply", "destroy", "import"] + arguments = ["-lock-timeout=30s"] + } + before_hook "reset_old_terraform_state" { commands = ["init"] execute = ["rm", "-f", ".terraform.lock.hcl"] From 0f7eeca13dcfe396646ada84226d395e7118a68b Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:49:32 +0900 Subject: [PATCH 5/5] authentik: manage OIDC clients with Terraform Headscale, Nextcloud, and Vaultwarden depend on manually configured Authentik OIDC clients. Move those clients and their group claim mappings into Terraform, and make the services consume the same SOPS-managed client secrets so Authentik and Nix converge from one source. --- .sops.nix | 4 + .sops.yaml | 6 + modules/headscale/default.nix | 4 +- modules/nextcloud/default.nix | 33 +++++ modules/vaultwarden/default.nix | 21 +++- terraform/authentik/data.tf | 25 ++++ terraform/authentik/import-existing.sh | 35 ++++++ terraform/authentik/oidc-secrets.yaml | 36 ++++++ terraform/authentik/oidc.tf | 165 +++++++++++++++++++++++++ terraform/authentik/secrets.tf | 4 + 10 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 terraform/authentik/oidc-secrets.yaml create mode 100644 terraform/authentik/oidc.tf diff --git a/.sops.nix b/.sops.nix index f4583df..08f5eff 100644 --- a/.sops.nix +++ b/.sops.nix @@ -65,6 +65,10 @@ let "modules/nfs/secrets.yaml" = [ "psi" ]; "modules/users/xrdp-passwords.yaml" = [ "psi" ]; "terraform/authentik/secrets.yaml" = [ ]; + "terraform/authentik/oidc-secrets.yaml" = [ + "eta" + "tau" + ]; "terraform/authentik/users.yaml" = [ ]; "terraform/cloudflare/secrets.yaml" = [ ]; "terraform/github/secrets.yaml" = [ ]; diff --git a/.sops.yaml b/.sops.yaml index f5ffb83..29d88de 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -119,6 +119,12 @@ creation_rules: - age1zdhqm6ptcnuu3tf2lzcngqmf6eud7jfah7v8falfy5mdksmnfuzq35sq54 - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s path_regex: modules/vaultwarden/secrets.yaml + - key_groups: + - age: + - age1zdhqm6ptcnuu3tf2lzcngqmf6eud7jfah7v8falfy5mdksmnfuzq35sq54 + - age13v0djuhkmnd06zvct0zc6sddykqpk3j9k8ev5sfgd6gtj82s0avs68psvj + - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + path_regex: terraform/authentik/oidc-secrets.yaml - key_groups: - age: - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s diff --git a/modules/headscale/default.nix b/modules/headscale/default.nix index 6ec5d17..eeaa253 100644 --- a/modules/headscale/default.nix +++ b/modules/headscale/default.nix @@ -102,10 +102,12 @@ }; sops.secrets.headscale-oidc-secret = { - sopsFile = ./secrets.yaml; + sopsFile = ../../terraform/authentik/oidc-secrets.yaml; + key = "HEADSCALE_CLIENT_SECRET"; owner = "headscale"; group = "headscale"; mode = "0400"; + restartUnits = [ "headscale.service" ]; }; # ACME certificate diff --git a/modules/nextcloud/default.nix b/modules/nextcloud/default.nix index 4315d6a..e182083 100644 --- a/modules/nextcloud/default.nix +++ b/modules/nextcloud/default.nix @@ -1,5 +1,6 @@ { config, + lib, pkgs, ... }: @@ -165,6 +166,38 @@ in sops.secrets.whiteboard-jwt = { sopsFile = ./secrets.yaml; }; + sops.secrets.nextcloud-oidc-client-secret = { + sopsFile = ../../terraform/authentik/oidc-secrets.yaml; + key = "NEXTCLOUD_CLIENT_SECRET"; + owner = "nextcloud"; + group = "nextcloud"; + mode = "0400"; + restartUnits = [ "nextcloud-oidc-authentik.service" ]; + }; + + systemd.services.nextcloud-oidc-authentik = { + description = "Configure Authentik OIDC provider for Nextcloud"; + wantedBy = [ "multi-user.target" ]; + requires = [ "nextcloud-setup.service" ]; + after = [ "nextcloud-setup.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + ${lib.getExe config.services.nextcloud.occ} user_oidc:provider authentik \ + --clientid=4GdFUqIaLHa3Hx5VnMul6RU8iaJG8GqtUHXHjfqo \ + --clientsecret-file=${config.sops.secrets.nextcloud-oidc-client-secret.path} \ + --discoveryuri=https://auth.sjanglab.org/application/o/nextcloud/.well-known/openid-configuration \ + --scope="openid email profile groups" \ + --mapping-uid=email \ + --mapping-groups=groups \ + --mapping-quota=quota \ + --extra-claims=nextcloud-groups \ + --group-provisioning=1 \ + --send-id-token-hint=1 + ''; + }; networking.firewall.allowedTCPPorts = [ 80 diff --git a/modules/vaultwarden/default.nix b/modules/vaultwarden/default.nix index f817f19..8213b6e 100644 --- a/modules/vaultwarden/default.nix +++ b/modules/vaultwarden/default.nix @@ -13,7 +13,7 @@ services.vaultwarden = { enable = true; - environmentFile = config.sops.secrets.vaultwarden-env.path; + environmentFile = config.sops.templates.vaultwarden-env.path; backupDir = "/var/backup/vaultwarden"; config = { @@ -45,6 +45,25 @@ mode = "0400"; }; + sops.secrets.vaultwarden-oidc-client-secret = { + sopsFile = ../../terraform/authentik/oidc-secrets.yaml; + key = "VAULTWARDEN_CLIENT_SECRET"; + owner = "vaultwarden"; + group = "vaultwarden"; + mode = "0400"; + restartUnits = [ "vaultwarden.service" ]; + }; + + sops.templates.vaultwarden-env = { + owner = "vaultwarden"; + group = "vaultwarden"; + mode = "0400"; + content = '' + ${config.sops.placeholder.vaultwarden-env} + SSO_CLIENT_SECRET=${config.sops.placeholder.vaultwarden-oidc-client-secret} + ''; + }; + systemd.tmpfiles.rules = [ "d /var/backup/vaultwarden 0700 vaultwarden vaultwarden -" ]; diff --git a/terraform/authentik/data.tf b/terraform/authentik/data.tf index 3b7c7a9..73875d8 100644 --- a/terraform/authentik/data.tf +++ b/terraform/authentik/data.tf @@ -2,6 +2,10 @@ data "authentik_flow" "authorization" { slug = "default-provider-authorization-implicit-consent" } +data "authentik_flow" "authorization_explicit" { + slug = "default-provider-authorization-explicit-consent" +} + data "authentik_flow" "invalidation" { slug = "default-provider-invalidation-flow" } @@ -9,3 +13,24 @@ data "authentik_flow" "invalidation" { data "authentik_outpost" "embedded" { name = "authentik Embedded Outpost" } + +data "authentik_certificate_key_pair" "self_signed" { + name = "authentik Self-signed Certificate" + fetch_key = false +} + +data "authentik_property_mapping_provider_scope" "openid" { + managed = "goauthentik.io/providers/oauth2/scope-openid" +} + +data "authentik_property_mapping_provider_scope" "email" { + managed = "goauthentik.io/providers/oauth2/scope-email" +} + +data "authentik_property_mapping_provider_scope" "profile" { + managed = "goauthentik.io/providers/oauth2/scope-profile" +} + +data "authentik_property_mapping_provider_scope" "offline_access" { + managed = "goauthentik.io/providers/oauth2/scope-offline_access" +} diff --git a/terraform/authentik/import-existing.sh b/terraform/authentik/import-existing.sh index b2d47f5..68d5db6 100755 --- a/terraform/authentik/import-existing.sh +++ b/terraform/authentik/import-existing.sh @@ -69,6 +69,20 @@ policy_id_by_name() { head -n1 } +oauth2_id_by_name() { + local name=$1 + api "providers/oauth2/?search=$(jq -rn --arg v "$name" '$v|@uri')&page_size=100" | + jq -r --arg name "$name" '.results[] | select(.name == $name) | .pk' | + head -n1 +} + +scope_mapping_id_by_name() { + local name=$1 + api "propertymappings/provider/scope/?search=$(jq -rn --arg v "$name" '$v|@uri')&page_size=100" | + jq -r --arg name "$name" '.results[] | select(.name == $name) | .pk' | + head -n1 +} + binding_id_by_target_policy() { local target=$1 local policy=$2 @@ -141,3 +155,24 @@ done <<'EOF' n8n|https://n8n.sjanglab.org|researchers logging|https://logging.sjanglab.org|admins EOF + +while IFS='|' read -r key name; do + import_if_missing "authentik_property_mapping_provider_scope.oidc_group[$(json_quote "$key")]" "$(scope_mapping_id_by_name "$name")" +done <<'EOF' +headscale|headscale-groups +nextcloud|nextcloud-groups +EOF + +while IFS='|' read -r key name slug; do + import_if_missing "authentik_provider_oauth2.oidc[$(json_quote "$key")]" "$(oauth2_id_by_name "$name")" + app_id=$(app_uuid_by_slug "$slug") + if [ -n "$app_id" ]; then + import_if_missing "authentik_application.oidc[$(json_quote "$key")]" "$slug" + else + echo "skip application for $slug: no remote object" + fi +done <<'EOF' +headscale|Headscale|headscale +nextcloud|Nextcloud|nextcloud +vaultwarden|Vaultwarden|vaultwarden +EOF diff --git a/terraform/authentik/oidc-secrets.yaml b/terraform/authentik/oidc-secrets.yaml new file mode 100644 index 0000000..c6d9cd8 --- /dev/null +++ b/terraform/authentik/oidc-secrets.yaml @@ -0,0 +1,36 @@ +HEADSCALE_CLIENT_SECRET: ENC[AES256_GCM,data:nBjh6nhGKFFxMZeZnUi2vgpRhMlvL7+PKC9yYikucUIPgv/lheyw8/2DoC0P2xXQBCzyasPWPy+mlA4Kp+FqCly2CU2Rv6GDFqfgOVacWjzmUmBq7gVzPiShwT7VP3m8a583vjrYRu1NGnHFj6wRjN2Wqpg03cFpJ9vR4XA43HI=,iv:y9804e/yL1mOM//+Rk7sVT7ZnVCJiZGV/DWfvm6R7as=,tag:/Xsro33rPMEGPx9tuLBY5A==,type:str] +VAULTWARDEN_CLIENT_SECRET: ENC[AES256_GCM,data:fXyrFHVEQXU/OC91eh8ppl4dtyA2XkzmPdpV6joGU0ogRpbOA7EJXQ1OiF+/MTmuCqpA/ld4MOBpDqmQVUiqgpqifvZB+hfztkP4NdUT2hMgci6MLOTgl2rqNl0orzT8ubHobZOeBEBw4d6e6Nsbj0luLwkOW0ASWMIgMtsvnfE=,iv:HzZzbNDBLsJOfiQDrFBeA3TcTDRLLImmvZJSm3dQG4E=,tag:+hgMYN0G1JCQDkvbUQ1oUA==,type:str] +NEXTCLOUD_CLIENT_SECRET: ENC[AES256_GCM,data:A1Nu2k5VWwStV2MdosWZXg0EWxKtOxEbL+E1hOa9juBg9IiwPE7t20nEx/ED0uKKUq2CDTwlcU/n2/60KgZ6tw==,iv:MOmps8oQNQOq1JC0DNO3H/OByi69Kh62KS8dOTWgj+w=,tag:9bRuN8I+ABeM0wcmvVfzxw==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNM3pyeGw2bXdDay96aFpD + R0NFZ2p5dzZORTMxMjVEREZFN1piSjBGUWcwCjFBM0cveXY2ZUxDaElJNUFtbjBl + MjFNZTVKRzRoUkorek1WOGFkMzNla1UKLS0tIHBzMWRRbWlrNDFqNk83SjJQUVJz + MkhDRzY5cnZEQVFxMzlhZU03N2x6RFEKbHa6V0GCD67MUO5L/dPvmQvpIByUjKXu + uKd1BhsziwH+qDIghik4VkRopVm+V1mqbiVIVeaiEfuNtonqWmNUxA== + -----END AGE ENCRYPTED FILE----- + recipient: age1zdhqm6ptcnuu3tf2lzcngqmf6eud7jfah7v8falfy5mdksmnfuzq35sq54 + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVUnMzTDdJOFcraktFMHRO + cmxESEhYOHh0cmo3K2d2eE10a1U4clpkamhnCi8wYTdETXpYVHl4U3ZFSUd3YTkz + TUtHS1dJdWFmZ1lTWjEyZXlBNGkwVjQKLS0tIE16RmtPc0tIU1grQ213VjJSRkJw + UnhqalVRbGFuZ2RQYWZ6V0RRNWJXaVkKhZNJ4DKdG2aIuSAyoiOZsw0Ir3uqwWpm + wuFspTJc5GE3M3LHEZJSle+PFbKCyspzyycZ99a4LjzFucZv+BSF4A== + -----END AGE ENCRYPTED FILE----- + recipient: age13v0djuhkmnd06zvct0zc6sddykqpk3j9k8ev5sfgd6gtj82s0avs68psvj + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXSDQ1WmlYMXhNbmdMWDRQ + bWFVNXBuVkxXdWlzMFM0OHdnbU0wNEhTYzFrCjdpVHE5Yy9uTXk0YVZCYUo4SEJJ + cStIc0FxaHhKRWRpQW5Uc0pmNE1LK1kKLS0tIEQ0YnNsaStseGtWcnhHMldnWXNE + VWxzU0p5Vk5ZWnNIWTBPUDM0ZWFFYTAKBHWo2IL1cfOH5EJV3uGl/yLC+A+FvKz6 + Vcj7497z7ZWx7xD+U9W+03gdZDF50kq6fKIoaV0r9oFB9KK6MH53kA== + -----END AGE ENCRYPTED FILE----- + recipient: age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + lastmodified: "2026-07-03T02:59:58Z" + mac: ENC[AES256_GCM,data:xCuMQqsJrHMHgFNoI5wcjGt01JDUtD3z8p9ofRafjjk6zN4eVd0t08Y+3YkuoZOjZajTX1SvzNrEHYANM+yC5jNgj7wIA7ugbNBmd+Wf05v9dSqLCZxcszVnoGcjywrZJzsuyIzb5km4MEEUJXtU+zTW/q7ObB4VF4YpHZnV0t8=,iv:AU9LyOfcf9K4lk6GOgCglV5Nk6Q8xQUNyUSBX2U+qCw=,tag:rfGb+LNBPxkmHHPQGlwr1A==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.1 diff --git a/terraform/authentik/oidc.tf b/terraform/authentik/oidc.tf new file mode 100644 index 0000000..feea35c --- /dev/null +++ b/terraform/authentik/oidc.tf @@ -0,0 +1,165 @@ +locals { + oidc_group_property_mappings = { + headscale = { + name = "headscale-groups" + scope_name = "groups" + expression = <<-EOF + return { + "groups": [g.name for g in request.user.ak_groups.all()] + } + EOF + } + nextcloud = { + name = "nextcloud-groups" + scope_name = "groups" + expression = <<-EOF + GROUP_MAP = { + "sjanglab-admins": "admin", + "sjanglab-researchers": "researchers", + "sjanglab-students": "students", + } + + QUOTA_MAP = { + "admin": "none", + "researchers": "100 GB", + "students": "15 GB", + } + DEFAULT_QUOTA = "5 GB" + + # Groups claim + mapped_groups = [GROUP_MAP.get(g.name, g.name) for g in request.user.ak_groups.all()] + + # Quota - admin > researchers > students + for group in ["admin", "researchers", "students"]: + if group in mapped_groups: + quota = QUOTA_MAP[group] + break + else: + quota = DEFAULT_QUOTA + + return { + "groups": mapped_groups, + "quota": quota, + } + EOF + } + } + + oidc_default_property_mappings = [ + data.authentik_property_mapping_provider_scope.openid.id, + data.authentik_property_mapping_provider_scope.email.id, + data.authentik_property_mapping_provider_scope.profile.id, + ] + + oidc_apps = { + headscale = { + name = "Headscale" + slug = "headscale" + client_id = "4HgENmoHd0zxoqKYX6FgC2EtVKM1djT5lWEFacER" + client_secret = data.sops_file.oidc_secrets.data["HEADSCALE_CLIENT_SECRET"] + client_type = "confidential" + sub_mode = "hashed_user_id" + meta_hide = true + meta_launch_url = "" + allowed_redirect_uris = [ + { + matching_mode = "strict" + redirect_uri_type = "authorization" + url = "https://hs.sjanglab.org/oidc/callback" + }, + ] + group_property_mapping_keys = ["headscale"] + property_mappings = local.oidc_default_property_mappings + } + nextcloud = { + name = "Nextcloud" + slug = "nextcloud" + client_id = "4GdFUqIaLHa3Hx5VnMul6RU8iaJG8GqtUHXHjfqo" + client_secret = data.sops_file.oidc_secrets.data["NEXTCLOUD_CLIENT_SECRET"] + client_type = "confidential" + sub_mode = "user_email" + meta_launch_url = "" + allowed_redirect_uris = [ + { + matching_mode = "strict" + redirect_uri_type = "authorization" + url = "https://cloud.sjanglab.org/apps/user_oidc/code" + }, + ] + group_property_mapping_keys = ["nextcloud"] + property_mappings = local.oidc_default_property_mappings + } + vaultwarden = { + name = "Vaultwarden" + slug = "vaultwarden" + client_id = "OfBSHOHF0txEZzpJgZAIahUAjfHSQQ18xNWGwyNV" + client_secret = data.sops_file.oidc_secrets.data["VAULTWARDEN_CLIENT_SECRET"] + client_type = "confidential" + sub_mode = "hashed_user_id" + meta_launch_url = "https://vault.sjanglab.org" + allowed_redirect_uris = [ + { + matching_mode = "strict" + redirect_uri_type = "authorization" + url = "https://vault.sjanglab.org/identity/connect/oidc-signin" + }, + ] + property_mappings = concat( + local.oidc_default_property_mappings, + [data.authentik_property_mapping_provider_scope.offline_access.id], + ) + } + } +} + +resource "authentik_property_mapping_provider_scope" "oidc_group" { + for_each = local.oidc_group_property_mappings + + name = each.value.name + scope_name = each.value.scope_name + expression = each.value.expression +} + +resource "authentik_provider_oauth2" "oidc" { + for_each = local.oidc_apps + + name = each.value.name + authorization_flow = data.authentik_flow.authorization_explicit.id + invalidation_flow = data.authentik_flow.invalidation.id + client_id = each.value.client_id + client_secret = each.value.client_secret + client_type = each.value.client_type + sub_mode = each.value.sub_mode + issuer_mode = "per_provider" + include_claims_in_id_token = true + signing_key = data.authentik_certificate_key_pair.self_signed.id + access_code_validity = "minutes=1" + access_token_validity = "minutes=5" + refresh_token_validity = "days=30" + refresh_token_threshold = "hours=1" + allowed_redirect_uris = each.value.allowed_redirect_uris + property_mappings = concat( + compact([ + for key in lookup(each.value, "group_property_mapping_keys", []) : try(authentik_property_mapping_provider_scope.oidc_group[key].id, "") + ]), + each.value.property_mappings, + ) + + lifecycle { + precondition { + condition = length(each.value.client_secret) <= 255 + error_message = "Authentik OAuth2 client_secret must be 255 characters or less." + } + } +} + +resource "authentik_application" "oidc" { + for_each = local.oidc_apps + + name = each.value.name + slug = each.value.slug + protocol_provider = authentik_provider_oauth2.oidc[each.key].id + policy_engine_mode = "any" + meta_hide = lookup(each.value, "meta_hide", false) + meta_launch_url = each.value.meta_launch_url +} diff --git a/terraform/authentik/secrets.tf b/terraform/authentik/secrets.tf index 9d92584..6ec0756 100644 --- a/terraform/authentik/secrets.tf +++ b/terraform/authentik/secrets.tf @@ -5,3 +5,7 @@ data "sops_file" "secrets" { data "sops_file" "users" { source_file = "./users.yaml" } + +data "sops_file" "oidc_secrets" { + source_file = "./oidc-secrets.yaml" +}