From 4042760a16b05149193b48c7427d311800447c7b Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 28 Jun 2026 11:55:26 +1000 Subject: [PATCH 01/10] Initial scaffold - Terraform module for groups, SAML/OAuth2/LDAP providers, applications, and LDAP outposts - Data-driven YAML config with Terragrunt config loader - Environment: identity.unkin.net with Consul backend - Provider: goauthentik/authentik 2026.5.0 - Woodpecker CI pipelines (pre-commit, plan, apply) - Makefile with Vault AppRole and K8s auth support --- .gitignore | 6 ++ .pre-commit-config.yaml | 24 ++++++ .woodpecker/apply.yaml | 23 +++++ .woodpecker/plan.yaml | 21 +++++ .woodpecker/pre-commit.yaml | 18 ++++ Makefile | 34 ++++++++ README.md | 34 +++++++- config/config.hcl | 31 +++++++ .../identity.unkin.net/terragrunt.hcl | 24 ++++++ environments/root.hcl | 32 +++++++ modules/authentik/main.tf | 85 +++++++++++++++++++ modules/authentik/variables.tf | 56 ++++++++++++ 12 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/apply.yaml create mode 100644 .woodpecker/plan.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 Makefile create mode 100644 config/config.hcl create mode 100644 environments/identity.unkin.net/terragrunt.hcl create mode 100644 environments/root.hcl create mode 100644 modules/authentik/main.tf create mode 100644 modules/authentik/variables.tf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13275b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.terraform/ +*.tfstate +*.tfstate.backup +*.tfplan +backend.tf +.terragrunt-cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..646cd65 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: end-of-file-fixer + types: [yaml] + - id: trailing-whitespace + types: [yaml] + - repo: https://github.com/gruntwork-io/pre-commit + rev: v0.1.30 + hooks: + - id: tofu-fmt + - id: tofu-validate + - id: tflint + - id: terragrunt-hcl-fmt + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.37.1 + hooks: + - id: yamllint + args: + [ + "-d {extends: relaxed, rules: {line-length: disable}, ignore: chart}", + "-s", + ] diff --git a/.woodpecker/apply.yaml b/.woodpecker/apply.yaml new file mode 100644 index 0000000..8c3e60f --- /dev/null +++ b/.woodpecker/apply.yaml @@ -0,0 +1,23 @@ +when: + - event: push + branch: main + +steps: + - name: apply + image: git.unkin.net/unkin/almalinux9-opentofu:20260606 + environment: + VAULT_AUTH_METHOD: kubernetes + commands: + - dnf install vault -y + - make plan + - make apply + backend_options: + kubernetes: + serviceAccountName: terraform-authentik + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/plan.yaml b/.woodpecker/plan.yaml new file mode 100644 index 0000000..9f7952c --- /dev/null +++ b/.woodpecker/plan.yaml @@ -0,0 +1,21 @@ +when: + - event: pull_request + +steps: + - name: plan + image: git.unkin.net/unkin/almalinux9-opentofu:20260606 + environment: + VAULT_AUTH_METHOD: kubernetes + commands: + - dnf install vault -y + - make plan + backend_options: + kubernetes: + serviceAccountName: terraform-authentik + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..5c5738f --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-opentofu:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe3ec71 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: init plan apply format + +VAULT_AUTH_METHOD ?= approle +VAULT_K8S_ROLE ?= woodpecker_terraform_authentik +VAULT_K8S_MOUNT ?= auth/k8s/au/syd1 +VAULT_K8S_JWT_PATH ?= /var/run/secrets/kubernetes.io/serviceaccount/token + +define vault_env + @export VAULT_ADDR="https://vault.service.consul:8200" && \ + if [ "$(VAULT_AUTH_METHOD)" = "kubernetes" ]; then \ + export VAULT_TOKEN=$$(vault write -field=token $(VAULT_K8S_MOUNT)/login role=$(VAULT_K8S_ROLE) jwt=$$(cat $(VAULT_K8S_JWT_PATH))); \ + else \ + export VAULT_TOKEN=$$(vault write -field=token auth/approle/login role_id=$$VAULT_ROLEID); \ + fi && \ + export CONSUL_HTTP_TOKEN=$$(vault read -field=token consul_root/au/syd1/creds/terraform-authentik) +endef + +init: + @$(call vault_env) && \ + terragrunt run --all --non-interactive init -- -upgrade + +plan: init + @$(call vault_env) && \ + terragrunt run --all --parallelism 4 --non-interactive plan + +apply: init + @$(call vault_env) && \ + terragrunt run --all --parallelism 2 --non-interactive apply + +format: + @echo "Formatting OpenTofu files..." + @tofu fmt -recursive . + @echo "Formatting Terragrunt files..." + @terragrunt hcl fmt diff --git a/README.md b/README.md index 5a88def..12f5c53 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ # terraform-authentik -Terraform configuration for managing Authentik identity provider \ No newline at end of file +Terraform configuration for managing the Authentik identity provider at identity.unkin.net. + +## Managed Resources + +- **Groups** — roles and group hierarchy (users are invited manually) +- **SAML providers** — SAML application integrations +- **OAuth2/OIDC providers** — OAuth2 and OpenID Connect integrations +- **LDAP providers** — LDAP provider and outpost configuration +- **Applications** — application definitions linked to providers + +## Configuration + +Resources are defined as YAML files under `config/`: + +``` +config/ +├── groups/ # Group definitions +├── providers_saml/ # SAML provider definitions +├── providers_oauth2/ # OAuth2/OIDC provider definitions +└── providers_ldap/ # LDAP provider definitions +``` + +## Usage + +```sh +make plan # init + plan +make apply # init + plan + apply +make format # format all .tf and .hcl files +``` + +### Authentication + +Set `VAULT_ROLEID` for local AppRole auth, or `VAULT_AUTH_METHOD=kubernetes` for CI. diff --git a/config/config.hcl b/config/config.hcl new file mode 100644 index 0000000..97eeed2 --- /dev/null +++ b/config/config.hcl @@ -0,0 +1,31 @@ +locals { + config_files = fileset(".", "**/*.yaml") + + all_configs = { + for file_path in local.config_files : + file_path => yamldecode(file(file_path)) + } + + config = { + groups = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "groups/") + } + providers_saml = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "providers_saml/") + } + providers_oauth2 = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "providers_oauth2/") + } + providers_ldap = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "providers_ldap/") + } + } +} diff --git a/environments/identity.unkin.net/terragrunt.hcl b/environments/identity.unkin.net/terragrunt.hcl new file mode 100644 index 0000000..e5d25b3 --- /dev/null +++ b/environments/identity.unkin.net/terragrunt.hcl @@ -0,0 +1,24 @@ +include "root" { + path = find_in_parent_folders("root.hcl") + expose = true +} + +include "config" { + path = "${get_repo_root()}/config/config.hcl" + expose = true +} + +locals { + config = include.config.locals.config +} + +terraform { + source = "../../modules/authentik" +} + +inputs = { + groups = local.config.groups + providers_saml = local.config.providers_saml + providers_oauth2 = local.config.providers_oauth2 + providers_ldap = local.config.providers_ldap +} diff --git a/environments/root.hcl b/environments/root.hcl new file mode 100644 index 0000000..8574f82 --- /dev/null +++ b/environments/root.hcl @@ -0,0 +1,32 @@ +generate "backend" { + path = "backend.tf" + if_exists = "overwrite" + contents = < Date: Sun, 28 Jun 2026 12:04:19 +1000 Subject: [PATCH 02/10] Fix provider schema for goauthentik/authentik 2026.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - group: parent → parents (list) - saml/oauth2: add required invalidation_flow - oauth2: remove redirect_uris (use allowed_redirect_uris via config) - ldap: replace authorization_flow/search_group with bind_flow/unbind_flow - Add versions.tf with required_providers block - Remove service_connection from outpost (auto-discovered) --- modules/authentik/.terraform.lock.hcl | 23 ++++++++++++ modules/authentik/main.tf | 44 +++++++++++------------ modules/authentik/variables.tf | 51 +++++++++++++-------------- modules/authentik/versions.tf | 9 +++++ 4 files changed, 78 insertions(+), 49 deletions(-) create mode 100644 modules/authentik/.terraform.lock.hcl create mode 100644 modules/authentik/versions.tf diff --git a/modules/authentik/.terraform.lock.hcl b/modules/authentik/.terraform.lock.hcl new file mode 100644 index 0000000..28423cf --- /dev/null +++ b/modules/authentik/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/goauthentik/authentik" { + version = "2026.5.0" + constraints = ">= 2026.5.0" + hashes = [ + "h1:SeznjPKBzSrgo8WasRnuxiGMDSeQHEKsv3U/xw8bhQE=", + "zh:0dc1706f6fbff866f4a96de56a4934b9a277954bcdd0713549a29a9b8ec85153", + "zh:218417ec4e864f2d7e585d6c08d39bccb96d8f3bca16c6f762be15365e434234", + "zh:24f9afa7a1174316da3478811848cd76ef348d8a983310b8d75ed6f45abe1a92", + "zh:560092e47cb8a72b890b3eeafe1803202cd25cf27f5f5a6e2c370f645f5d86ae", + "zh:5bc69d8de198007ad1587e146f98cffacf0d1a571800da549b308ff5f4541474", + "zh:65248dce941472ad2a30d0754d2f3c2db6bb6fe5080946316fb097d6ba7cc79f", + "zh:79c9a59a8d3c60280e27a064668889594da44c60f940b046b7c8e63be01067d0", + "zh:87f26cadcd842d6e6d0af94ef0e56860557f5d07f487b10d69d38b63af68bea5", + "zh:8e42c9d0e77d61cc2e5f8c8b761f6e484774d93771927b4cb5fbdae41209dd33", + "zh:94ff632b9b4841527c6b652d51a850a8a47c84c0308a3efc189e0ff7e2558f87", + "zh:b8d32d9f17a905b63c87a23306c02c295b7c8b70f72950071aa3086396932816", + "zh:c91982af99474fc2e4e69be36ed3a68847f261963ed79f6a546fc75703992f99", + "zh:eb9c1fd3020cf61e9b7a6a38d2965f4b521495a9928705e963459a4af857f97d", + ] +} diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index b8fba79..b5190e0 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -3,7 +3,7 @@ resource "authentik_group" "this" { name = each.value.name is_superuser = each.value.is_superuser - parent = each.value.parent != null ? authentik_group.this[each.value.parent].id : null + parents = each.value.parents != null ? [for p in each.value.parents : authentik_group.this[p].id] : [] attributes = jsonencode(each.value.attributes) } @@ -12,8 +12,8 @@ resource "authentik_provider_saml" "this" { name = each.value.name authorization_flow = each.value.authorization_flow + invalidation_flow = each.value.invalidation_flow acs_url = each.value.acs_url - issuer = each.value.issuer sp_binding = each.value.sp_binding audience = each.value.audience name_id_mapping = each.value.name_id_mapping @@ -23,32 +23,31 @@ resource "authentik_provider_saml" "this" { resource "authentik_provider_oauth2" "this" { for_each = var.providers_oauth2 - name = each.value.name - authorization_flow = each.value.authorization_flow - client_type = each.value.client_type - client_id = each.value.client_id - client_secret = each.value.client_secret - redirect_uris = each.value.redirect_uris - property_mappings = each.value.property_mappings - signing_key = each.value.signing_key + name = each.value.name + authorization_flow = each.value.authorization_flow + invalidation_flow = each.value.invalidation_flow + client_type = each.value.client_type + client_id = each.value.client_id + client_secret = each.value.client_secret + property_mappings = each.value.property_mappings + signing_key = each.value.signing_key access_token_validity = each.value.access_token_validity } resource "authentik_provider_ldap" "this" { for_each = var.providers_ldap - name = each.value.name - authorization_flow = each.value.authorization_flow - base_dn = each.value.base_dn - bind_flow = each.value.bind_flow - search_group = each.value.search_group - certificate = each.value.certificate - tls_server_name = each.value.tls_server_name - uid_start_number = each.value.uid_start_number - gid_start_number = each.value.gid_start_number - search_mode = each.value.search_mode - bind_mode = each.value.bind_mode - mfa_support = each.value.mfa_support + name = each.value.name + bind_flow = each.value.bind_flow + unbind_flow = each.value.unbind_flow + base_dn = each.value.base_dn + certificate = each.value.certificate + tls_server_name = each.value.tls_server_name + uid_start_number = each.value.uid_start_number + gid_start_number = each.value.gid_start_number + search_mode = each.value.search_mode + bind_mode = each.value.bind_mode + mfa_support = each.value.mfa_support } resource "authentik_application" "saml" { @@ -81,5 +80,4 @@ resource "authentik_outpost" "ldap" { name = "${each.key}-outpost" type = "ldap" protocol_providers = [authentik_provider_ldap.this[each.key].id] - service_connection = "local" } diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf index e40db37..9095a19 100644 --- a/modules/authentik/variables.tf +++ b/modules/authentik/variables.tf @@ -1,9 +1,9 @@ variable "groups" { type = map(object({ - name = string + name = string is_superuser = optional(bool, false) - parent = optional(string, null) - attributes = optional(map(string), {}) + parents = optional(list(string), null) + attributes = optional(map(string), {}) })) default = {} } @@ -12,9 +12,9 @@ variable "providers_saml" { type = map(object({ name = string authorization_flow = string + invalidation_flow = string acs_url = string - issuer = optional(string, null) - sp_binding = optional(string, "post") + sp_binding = optional(string, "redirect") audience = optional(string, "") name_id_mapping = optional(string, null) signing_kp = optional(string, null) @@ -24,33 +24,32 @@ variable "providers_saml" { variable "providers_oauth2" { type = map(object({ - name = string - authorization_flow = string - client_type = optional(string, "confidential") - client_id = optional(string, null) - client_secret = optional(string, null) - redirect_uris = optional(list(string), []) - property_mappings = optional(list(string), []) - signing_key = optional(string, null) - access_token_validity = optional(string, "minutes=5") + name = string + authorization_flow = string + invalidation_flow = string + client_type = optional(string, "confidential") + client_id = string + client_secret = optional(string, null) + property_mappings = optional(list(string), []) + signing_key = optional(string, null) + access_token_validity = optional(string, "minutes=10") })) default = {} } variable "providers_ldap" { type = map(object({ - name = string - authorization_flow = string - base_dn = string - bind_flow = optional(string, null) - search_group = optional(string, null) - certificate = optional(string, null) - tls_server_name = optional(string, null) - uid_start_number = optional(number, 2000) - gid_start_number = optional(number, 4000) - search_mode = optional(string, "cached") - bind_mode = optional(string, "cached") - mfa_support = optional(bool, true) + name = string + bind_flow = string + unbind_flow = string + base_dn = string + certificate = optional(string, null) + tls_server_name = optional(string, null) + uid_start_number = optional(number, 2000) + gid_start_number = optional(number, 4000) + search_mode = optional(string, "direct") + bind_mode = optional(string, "direct") + mfa_support = optional(bool, true) })) default = {} } diff --git a/modules/authentik/versions.tf b/modules/authentik/versions.tf new file mode 100644 index 0000000..af40006 --- /dev/null +++ b/modules/authentik/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.10" + required_providers { + authentik = { + source = "goauthentik/authentik" + version = ">= 2026.5.0" + } + } +} From 00a122135ee12a1c42393a4933aa4339c7dee0d1 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 28 Jun 2026 12:11:47 +1000 Subject: [PATCH 03/10] Use identity.k8s.syd1.au.unkin.net as provider endpoint --- .../terragrunt.hcl | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename environments/{identity.unkin.net => identity.k8s.syd1.au.unkin.net}/terragrunt.hcl (100%) diff --git a/environments/identity.unkin.net/terragrunt.hcl b/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl similarity index 100% rename from environments/identity.unkin.net/terragrunt.hcl rename to environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl From 97be93a9ffbb72a68254caec97e9a1fa0c963cb9 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 22:06:48 +1000 Subject: [PATCH 04/10] authentik: add Grafana OAuth2/OIDC provider + application Adds an OIDC provider + application so the in-cluster Grafana (grafana.k8s.syd1.au.unkin.net) can authenticate users against Authentik. Extends the oauth2 module so provider config stays declarative and secret-free: - Resolve authorization/invalidation flows by slug (data.authentik_flow) and scope mappings by managed identifier (data.authentik_property_mapping_provider_scope). - Read client_secret from Vault kv-v2 (data.vault_kv_secret_v2) instead of committing it; adds the hashicorp/vault provider (auth via VAULT_ADDR/ VAULT_TOKEN from the Makefile). - Support allowed_redirect_uris on the oauth2 provider. config/providers_oauth2/grafana.yaml wires client_id `grafana`, the openid/email/profile scopes, the login/generic_oauth redirect URI, and points client_secret at kv/kubernetes/namespace/grafana/default/oauth-credentials. --- config/providers_oauth2/grafana.yaml | 17 ++++++++++++++ environments/root.hcl | 8 +++++++ modules/authentik/.terraform.lock.hcl | 23 +++++++++++++++++++ modules/authentik/main.tf | 32 +++++++++++++++++++++++---- modules/authentik/variables.tf | 25 +++++++++++++++------ modules/authentik/versions.tf | 4 ++++ 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 config/providers_oauth2/grafana.yaml diff --git a/config/providers_oauth2/grafana.yaml b/config/providers_oauth2/grafana.yaml new file mode 100644 index 0000000..307d2f8 --- /dev/null +++ b/config/providers_oauth2/grafana.yaml @@ -0,0 +1,17 @@ +# OAuth2/OIDC provider + application for the in-cluster Grafana +# (grafana.k8s.syd1.au.unkin.net). client_secret is read from Vault, not committed. +name: Grafana +authorization_flow: default-provider-authorization-implicit-consent +invalidation_flow: default-provider-invalidation-flow +client_type: confidential +client_id: grafana +client_secret_vault: + mount: kv + path: kubernetes/namespace/grafana/default/oauth-credentials +scope_mappings: + - goauthentik.io/providers/oauth2/scope-openid + - goauthentik.io/providers/oauth2/scope-email + - goauthentik.io/providers/oauth2/scope-profile +redirect_uris: + - matching_mode: strict + url: https://grafana.k8s.syd1.au.unkin.net/login/generic_oauth diff --git a/environments/root.hcl b/environments/root.hcl index 8574f82..87559a2 100644 --- a/environments/root.hcl +++ b/environments/root.hcl @@ -7,6 +7,10 @@ provider "authentik" { token = var.authentik_token } +# Reads client secrets seeded in Vault (kv-v2). Auth via VAULT_ADDR + VAULT_TOKEN +# from the environment (set by the Makefile vault_env helper). +provider "vault" {} + variable "authentik_token" { type = string sensitive = true @@ -26,6 +30,10 @@ terraform { source = "goauthentik/authentik" version = "2026.5.0" } + vault = { + source = "hashicorp/vault" + version = ">= 4.0.0" + } } } EOF diff --git a/modules/authentik/.terraform.lock.hcl b/modules/authentik/.terraform.lock.hcl index 28423cf..825a7c6 100644 --- a/modules/authentik/.terraform.lock.hcl +++ b/modules/authentik/.terraform.lock.hcl @@ -21,3 +21,26 @@ provider "registry.opentofu.org/goauthentik/authentik" { "zh:eb9c1fd3020cf61e9b7a6a38d2965f4b521495a9928705e963459a4af857f97d", ] } + +provider "registry.opentofu.org/hashicorp/vault" { + version = "5.10.1" + constraints = ">= 4.0.0" + hashes = [ + "h1:wo5cTkl/1nlxMfdn1yEDIHNoRLMczuK6COH2Id4/zeY=", + "zh:0abf976c01f0c0732d0ccc6481e52008be5ee9c8e3d9b5eba0573c640fcf7019", + "zh:2aff4d7ee7ba9eb3de2cd5cda16ba92b4ec7a2b43232aec180984241a323b216", + "zh:2cc186fd0bfc44e100a22b0b40ae8ddcd0ec210a53c1da65d310ee758b1d2b08", + "zh:3f8fb8594736b34af4b26437dd4df4dd4042ad4905223995cfebb8a1f10682ec", + "zh:47fb41b18b74073f557dbcd6aad2183e416293405ccd70c0691a279cfe97f8cd", + "zh:517e2f2764d671c22d22def0384fdfc521b456458189189c0363375495d114dc", + "zh:5a49a2003636f2b8a547d494a6c06d43d62a68299775305408c52eff22b1c11f", + "zh:66d4e716920ada84b0c768f4aca4c8948388995462923349a01bd3818d82b618", + "zh:7599f652e89a3f18fa4b76a59d115cc63255cc36ce6b273850509ba25031abca", + "zh:9c3e38ae7e670de973b6255d7050f526cd2b3ca7c383d7ba7226fc204d97c507", + "zh:d04b046023fa9fd69def678f27e001c298ea34fc99ba51f835cda82e496fdb57", + "zh:d9acd8810f6660cd51bb4c25596632984ae18e93340c82a102d074c6eac95151", + "zh:e161bcb9a22607270b980eeff2ba693335fb62d6978516dff93dd4c91cda99b3", + "zh:ef47502f08cfcb5311b7b16a7905e0052bf28359e07cc00ed080ef454e0946cf", + "zh:f0640ddb52e7e90c5006ff571f6ad0554e593665320c764c57a3d8b7ec31b490", + ] +} diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index b5190e0..f6fc278 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -20,18 +20,42 @@ resource "authentik_provider_saml" "this" { signing_kp = each.value.signing_kp } +# Resolve oauth2 flows by slug and scope mappings by managed identifier, and +# read client secrets from Vault so nothing sensitive is committed. +data "authentik_flow" "oauth2_authorization" { + for_each = var.providers_oauth2 + slug = each.value.authorization_flow +} + +data "authentik_flow" "oauth2_invalidation" { + for_each = var.providers_oauth2 + slug = each.value.invalidation_flow +} + +data "authentik_property_mapping_provider_scope" "oauth2" { + for_each = { for k, v in var.providers_oauth2 : k => v if length(v.scope_mappings) > 0 } + managed_list = each.value.scope_mappings +} + +data "vault_kv_secret_v2" "oauth2" { + for_each = { for k, v in var.providers_oauth2 : k => v if v.client_secret_vault != null } + mount = each.value.client_secret_vault.mount + name = each.value.client_secret_vault.path +} + resource "authentik_provider_oauth2" "this" { for_each = var.providers_oauth2 name = each.value.name - authorization_flow = each.value.authorization_flow - invalidation_flow = each.value.invalidation_flow + authorization_flow = data.authentik_flow.oauth2_authorization[each.key].id + invalidation_flow = data.authentik_flow.oauth2_invalidation[each.key].id client_type = each.value.client_type client_id = each.value.client_id - client_secret = each.value.client_secret - property_mappings = each.value.property_mappings + client_secret = each.value.client_secret_vault != null ? data.vault_kv_secret_v2.oauth2[each.key].data["client_secret"] : null + property_mappings = try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, []) signing_key = each.value.signing_key access_token_validity = each.value.access_token_validity + allowed_redirect_uris = each.value.redirect_uris } resource "authentik_provider_ldap" "this" { diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf index 9095a19..6b497a0 100644 --- a/modules/authentik/variables.tf +++ b/modules/authentik/variables.tf @@ -24,13 +24,24 @@ variable "providers_saml" { variable "providers_oauth2" { type = map(object({ - name = string - authorization_flow = string - invalidation_flow = string - client_type = optional(string, "confidential") - client_id = string - client_secret = optional(string, null) - property_mappings = optional(list(string), []) + name = string + authorization_flow = string # flow slug, resolved to id via data.authentik_flow + invalidation_flow = string # flow slug, resolved to id via data.authentik_flow + client_type = optional(string, "confidential") + client_id = string + # client_secret is never committed. Point at a Vault kv-v2 secret whose + # `client_secret` key holds the value (seeded out of band); TF reads it. + client_secret_vault = optional(object({ + mount = string + path = string + }), null) + # Managed identifiers of scope property mappings (e.g. + # goauthentik.io/providers/oauth2/scope-openid). Resolved to ids. + scope_mappings = optional(list(string), []) + redirect_uris = optional(list(object({ + matching_mode = optional(string, "strict") + url = string + })), []) signing_key = optional(string, null) access_token_validity = optional(string, "minutes=10") })) diff --git a/modules/authentik/versions.tf b/modules/authentik/versions.tf index af40006..4f420de 100644 --- a/modules/authentik/versions.tf +++ b/modules/authentik/versions.tf @@ -5,5 +5,9 @@ terraform { source = "goauthentik/authentik" version = ">= 2026.5.0" } + vault = { + source = "hashicorp/vault" + version = ">= 4.0.0" + } } } From 3d7131032f546876492f045c273c165c79268edf Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 23:08:03 +1000 Subject: [PATCH 05/10] ci: re-run plan after terraform-vault kv policy applied From d1aef45d1da91fa7202acea84fd569650c81ebe9 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 23:12:43 +1000 Subject: [PATCH 06/10] fix: drop duplicate required_providers from generated backend.tf The module's versions.tf and the root.hcl-generated backend.tf both declared required_providers, which OpenTofu rejects ("A module may have only one required providers configuration"), so terragrunt init/plan failed. Keep them in the module's versions.tf (authentik + vault) and generate only the backend + provider blocks. --- environments/root.hcl | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/environments/root.hcl b/environments/root.hcl index 87559a2..955ce83 100644 --- a/environments/root.hcl +++ b/environments/root.hcl @@ -25,16 +25,8 @@ terraform { ca_file = "/etc/pki/tls/certs/ca-bundle.crt" } required_version = ">= 1.10" - required_providers { - authentik = { - source = "goauthentik/authentik" - version = "2026.5.0" - } - vault = { - source = "hashicorp/vault" - version = ">= 4.0.0" - } - } + # required_providers are declared in the module's versions.tf; declaring them + # here as well is a duplicate configuration (OpenTofu permits only one). } EOF } From 41cc0fce8bc5da959d260177211b0da7c1fea3a6 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 23:17:37 +1000 Subject: [PATCH 07/10] ci: re-run plan on fixed head (duplicate required_providers resolved) From d96d36a0795d28a63e72817757ab11271e3d2caa Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 23:32:21 +1000 Subject: [PATCH 08/10] makefile: source TF_VAR_authentik_token from vault (kv/service/terraform/authentik) The authentik provider needs a token but nothing supplied it, so plan failed with 'No value for required variable authentik_token'. Source it from Vault in vault_env like the consul creds, from the dedicated terraform-service path kv/service/terraform/authentik (field: token), overridable via AUTHENTIK_TOKEN_KV_* vars. --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fe3ec71..7ba8788 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ VAULT_AUTH_METHOD ?= approle VAULT_K8S_ROLE ?= woodpecker_terraform_authentik VAULT_K8S_MOUNT ?= auth/k8s/au/syd1 VAULT_K8S_JWT_PATH ?= /var/run/secrets/kubernetes.io/serviceaccount/token +# kv-v2 location of the Authentik API token used by the provider. +AUTHENTIK_TOKEN_KV_MOUNT ?= kv +AUTHENTIK_TOKEN_KV_PATH ?= service/terraform/authentik +AUTHENTIK_TOKEN_KV_FIELD ?= token define vault_env @export VAULT_ADDR="https://vault.service.consul:8200" && \ @@ -12,7 +16,8 @@ define vault_env else \ export VAULT_TOKEN=$$(vault write -field=token auth/approle/login role_id=$$VAULT_ROLEID); \ fi && \ - export CONSUL_HTTP_TOKEN=$$(vault read -field=token consul_root/au/syd1/creds/terraform-authentik) + export CONSUL_HTTP_TOKEN=$$(vault read -field=token consul_root/au/syd1/creds/terraform-authentik) && \ + export TF_VAR_authentik_token=$$(vault kv get -mount=$(AUTHENTIK_TOKEN_KV_MOUNT) -field=$(AUTHENTIK_TOKEN_KV_FIELD) $(AUTHENTIK_TOKEN_KV_PATH)) endef init: From 93742883a5c096c9bfaef6396b7d19a7cdf54ccd Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 23:43:16 +1000 Subject: [PATCH 09/10] fix: skip_child_token on vault provider The CI VAULT_TOKEN (short-lived k8s-auth role token) can't create child tokens, so the vault provider failed with 'failed to create limited child token: permission denied'. Use the token directly. --- environments/root.hcl | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/environments/root.hcl b/environments/root.hcl index 955ce83..8667a77 100644 --- a/environments/root.hcl +++ b/environments/root.hcl @@ -8,8 +8,11 @@ provider "authentik" { } # Reads client secrets seeded in Vault (kv-v2). Auth via VAULT_ADDR + VAULT_TOKEN -# from the environment (set by the Makefile vault_env helper). -provider "vault" {} +# from the environment (set by the Makefile vault_env helper). skip_child_token +# is required because the short-lived CI token cannot create child tokens. +provider "vault" { + skip_child_token = true +} variable "authentik_token" { type = string From 0b1b67fbd5ca4a60638cdeab224ee22491249d3a Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 6 Jul 2026 23:44:04 +1000 Subject: [PATCH 10/10] ci: re-run after vault #82 applied (token + oauth read policy live)