diff --git a/.sops.nix b/.sops.nix index f14adc9..08f5eff 100644 --- a/.sops.nix +++ b/.sops.nix @@ -64,8 +64,15 @@ let "modules/nextcloud/secrets.yaml" = [ "tau" ]; "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" = [ ]; + "terraform/headscale/secrets.yaml" = [ ]; "terraform/vultr/secrets.yaml" = [ ]; } // { diff --git a/.sops.yaml b/.sops.yaml index f3fca93..29d88de 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -119,6 +119,20 @@ 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 + path_regex: terraform/authentik/secrets.yaml + - key_groups: + - age: + - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s + path_regex: terraform/authentik/users.yaml - key_groups: - age: - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s @@ -127,6 +141,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 3522331..4be942f 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`로 변경합니다. + +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 343738d..250f3d7 100644 --- a/docs/admin/terraform.md +++ b/docs/admin/terraform.md @@ -1,6 +1,6 @@ # Terraform -외부 리소스(Cloudflare DNS, GitHub)를 코드로 관리합니다. +외부 리소스(Cloudflare DNS, GitHub), Authentik 애플리케이션 정책, Headscale ACL policy를 코드로 관리합니다. ## 백엔드 @@ -18,20 +18,44 @@ 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` | + +### 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 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..eeaa253 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,39 +96,20 @@ 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"; }; }; 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" ]; }; - # 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/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/postgresql/default.nix b/modules/postgresql/default.nix index c8d7b5f..f908d52 100644 --- a/modules/postgresql/default.nix +++ b/modules/postgresql/default.nix @@ -109,8 +109,10 @@ in let psql = "${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"; terraformModules = [ + "authentik" "cloudflare" "github" + "headscale" "vultr" ]; in 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 new file mode 100644 index 0000000..73875d8 --- /dev/null +++ b/terraform/authentik/data.tf @@ -0,0 +1,36 @@ +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" +} + +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/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..68d5db6 --- /dev/null +++ b/terraform/authentik/import-existing.sh @@ -0,0 +1,178 @@ +#!/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 +} + +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 + 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 + +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/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..6ec0756 --- /dev/null +++ b/terraform/authentik/secrets.tf @@ -0,0 +1,11 @@ +data "sops_file" "secrets" { + source_file = "./secrets.yaml" +} + +data "sops_file" "users" { + source_file = "./users.yaml" +} + +data "sops_file" "oidc_secrets" { + source_file = "./oidc-secrets.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 diff --git a/terraform/flake-module.nix b/terraform/flake-module.nix index 1166cb6..c535ea1 100644 --- a/terraform/flake-module.nix +++ b/terraform/flake-module.nix @@ -6,13 +6,38 @@ 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"; + }; + + 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 ]; @@ -30,6 +55,8 @@ p.hashicorp_local 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 +} 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"]