Manage the agent API service account and token in Terraform
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful

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.
This commit is contained in:
2026-08-29 22:58:40 +10:00
parent 09f7c9042e
commit 8ec8bbda8e
6 changed files with 124 additions and 1 deletions
+71
View File
@@ -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 })
}
+25
View File
@@ -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
# `<app_label>.<codename>` 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 = {}
}