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 = {} +}