From 8ec8bbda8e22f7a24db3da412940940f0b44b067 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 29 Aug 2026 22:58:40 +1000 Subject: [PATCH] Manage the agent API service account and token in Terraform The Authentik API token used by estate automation was created by hand in the UI and pasted into Vault, so it was undocumented, unauditable and impossible to rotate reproducibly. Model it as config instead. Add a service_accounts config kind, discovered from config/service_accounts/ like the other kinds. Each entry creates a service_account user, an RBAC role carrying its global permissions, its API tokens, and (optionally) a kv-v2 write publishing each token key. Add sa-agent-api granting view_outpost, view_token and view_token_key, with a non-expiring api token agent-api-token published to kv/service/authentik/agent-api-token. --- README.md | 4 +- config/config.hcl | 5 ++ config/service_accounts/sa-agent-api.yaml | 19 +++++ .../terragrunt.hcl | 1 + modules/authentik/main.tf | 71 +++++++++++++++++++ modules/authentik/variables.tf | 25 +++++++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 config/service_accounts/sa-agent-api.yaml diff --git a/README.md b/README.md index 12f5c53..3e39dd6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Terraform configuration for managing the Authentik identity provider at identity - **OAuth2/OIDC providers** — OAuth2 and OpenID Connect integrations - **LDAP providers** — LDAP provider and outpost configuration - **Applications** — application definitions linked to providers +- **Service accounts** — machine identities with RBAC roles and API tokens (keys published to Vault kv) ## Configuration @@ -19,7 +20,8 @@ config/ ├── groups/ # Group definitions ├── providers_saml/ # SAML provider definitions ├── providers_oauth2/ # OAuth2/OIDC provider definitions -└── providers_ldap/ # LDAP provider definitions +├── providers_ldap/ # LDAP provider definitions +└── service_accounts/ # Automation service accounts + API tokens ``` ## Usage diff --git a/config/config.hcl b/config/config.hcl index 7462c05..eae42dc 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -37,5 +37,10 @@ locals { trimsuffix(basename(file_path), ".yaml") => content if startswith(file_path, "providers_ldap/") } + service_accounts = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "service_accounts/") + } } } diff --git a/config/service_accounts/sa-agent-api.yaml b/config/service_accounts/sa-agent-api.yaml new file mode 100644 index 0000000..16e23ba --- /dev/null +++ b/config/service_accounts/sa-agent-api.yaml @@ -0,0 +1,19 @@ +# Service account sa-agent-api (username = filename). Machine identity for +# estate automation that needs to read Authentik outpost tokens; replaces the +# hand-created token an operator used to paste into Vault. +name: Agent API +permissions: + # List outposts and read their bootstrap token keys. + - authentik_outposts.view_outpost + - authentik_core.view_token + - authentik_core.view_token_key +tokens: + agent-api-token: + description: >- + Used by agentvault seed-outpost to look up Authentik outpost tokens. + Managed by terraform-authentik; key published to Vault kv. + expiring: false + vault: + mount: kv + path: service/authentik/agent-api-token + key: token diff --git a/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl b/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl index c85bbe7..f884c18 100644 --- a/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl +++ b/environments/identity.k8s.syd1.au.unkin.net/terragrunt.hcl @@ -23,4 +23,5 @@ inputs = { providers_saml = local.config.providers_saml providers_oauth2 = local.config.providers_oauth2 providers_ldap = local.config.providers_ldap + service_accounts = local.config.service_accounts } diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index 475399b..450c068 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -242,3 +242,74 @@ resource "authentik_policy_binding" "app_access" { group = authentik_group.permission[each.key].id order = 0 } + +# Service accounts: non-human identities for automation. Kept out of the group +# hierarchy above (which models human app access) and given capabilities through +# RBAC roles instead. +resource "authentik_user" "service_account" { + for_each = var.service_accounts + + username = each.key + name = coalesce(each.value.name, each.key) + type = "service_account" + # roles is only populated for accounts that declare permissions; try() keeps + # the reference lazy so accounts without a role still plan. + roles = try([authentik_rbac_role.service_account[each.key].id], []) +} + +# One role per service account carrying its global permissions. +# authentik_rbac_permission_user is deprecated in favour of the role form, so +# permissions are attached to a role and the role to the account. +resource "authentik_rbac_role" "service_account" { + for_each = { for k, v in var.service_accounts : k => v if length(v.permissions) > 0 } + + name = each.key +} + +locals { + service_account_permissions = merge([ + for k, v in var.service_accounts : { + for perm in v.permissions : "${k}/${perm}" => { + service_account = k + permission = perm + } + } + ]...) + + service_account_tokens = merge([ + for k, v in var.service_accounts : { + for identifier, t in v.tokens : identifier => merge(t, { service_account = k }) + } + ]...) +} + +resource "authentik_rbac_permission_role" "service_account" { + for_each = local.service_account_permissions + + role = authentik_rbac_role.service_account[each.value.service_account].id + permission = each.value.permission +} + +# retrieve_key is required for `key` to be populated; without it the attribute +# stays empty and nothing can be published to Vault. +resource "authentik_token" "service_account" { + for_each = local.service_account_tokens + + identifier = each.key + user = authentik_user.service_account[each.value.service_account].id + description = each.value.description + intent = "api" + expiring = each.value.expiring + retrieve_key = true +} + +# Publish token keys to kv-v2 so consumers (agentvault, CI) read them from Vault. +# The key also lands in Terraform state, same as the oauth2 client secrets this +# module already reads. +resource "vault_kv_secret_v2" "service_account_token" { + for_each = { for k, v in local.service_account_tokens : k => v if v.vault != null } + + mount = each.value.vault.mount + name = each.value.vault.path + data_json = jsonencode({ (each.value.vault.key) = authentik_token.service_account[each.key].key }) +} diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf index 54ac455..fb4ff0d 100644 --- a/modules/authentik/variables.tf +++ b/modules/authentik/variables.tf @@ -127,3 +127,28 @@ variable "providers_ldap" { })) default = {} } + +# Machine identities for automation (agents, CI). Each entry creates a service +# account user, an RBAC role carrying its global permissions, and any API tokens +# it needs. The username is the map key (the config filename). +variable "service_accounts" { + type = map(object({ + name = optional(string, null) # display name; defaults to the key + description = optional(string, "") + # Global RBAC permissions granted via a dedicated role, in + # `.` form (e.g. authentik_outposts.view_outpost). + permissions = optional(list(string), []) + # API tokens keyed by identifier. Set `vault` to publish the generated key + # into kv-v2 so consumers read it from Vault instead of an operator pasting it. + tokens = optional(map(object({ + description = optional(string, "") + expiring = optional(bool, false) + vault = optional(object({ + mount = string + path = string + key = optional(string, "token") + }), null) + })), {}) + })) + default = {} +}