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..7ba8788 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.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 +# 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" && \ + 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) && \ + export TF_VAR_authentik_token=$$(vault kv get -mount=$(AUTHENTIK_TOKEN_KV_MOUNT) -field=$(AUTHENTIK_TOKEN_KV_FIELD) $(AUTHENTIK_TOKEN_KV_PATH)) +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/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/identity.k8s.syd1.au.unkin.net/terragrunt.hcl b/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl new file mode 100644 index 0000000..e5d25b3 --- /dev/null +++ b/environments/identity.k8s.syd1.au.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..8667a77 --- /dev/null +++ b/environments/root.hcl @@ -0,0 +1,35 @@ +generate "backend" { + path = "backend.tf" + if_exists = "overwrite" + contents = < 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 = 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_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" { + for_each = var.providers_ldap + + 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" { + for_each = var.providers_saml + + name = each.value.name + slug = each.key + protocol_provider = authentik_provider_saml.this[each.key].id +} + +resource "authentik_application" "oauth2" { + for_each = var.providers_oauth2 + + name = each.value.name + slug = each.key + protocol_provider = authentik_provider_oauth2.this[each.key].id +} + +resource "authentik_application" "ldap" { + for_each = var.providers_ldap + + name = each.value.name + slug = each.key + protocol_provider = authentik_provider_ldap.this[each.key].id +} + +resource "authentik_outpost" "ldap" { + for_each = var.providers_ldap + + name = "${each.key}-outpost" + type = "ldap" + protocol_providers = [authentik_provider_ldap.this[each.key].id] +} diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf new file mode 100644 index 0000000..6b497a0 --- /dev/null +++ b/modules/authentik/variables.tf @@ -0,0 +1,66 @@ +variable "groups" { + type = map(object({ + name = string + is_superuser = optional(bool, false) + parents = optional(list(string), null) + attributes = optional(map(string), {}) + })) + default = {} +} + +variable "providers_saml" { + type = map(object({ + name = string + authorization_flow = string + invalidation_flow = string + acs_url = string + sp_binding = optional(string, "redirect") + audience = optional(string, "") + name_id_mapping = optional(string, null) + signing_kp = optional(string, null) + })) + default = {} +} + +variable "providers_oauth2" { + type = map(object({ + 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") + })) + default = {} +} + +variable "providers_ldap" { + type = map(object({ + 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..4f420de --- /dev/null +++ b/modules/authentik/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.10" + required_providers { + authentik = { + source = "goauthentik/authentik" + version = ">= 2026.5.0" + } + vault = { + source = "hashicorp/vault" + version = ">= 4.0.0" + } + } +}