diff --git a/config/permissions/akP-vault-admin.yaml b/config/permissions/akP-vault-admin.yaml new file mode 100644 index 0000000..9809e22 --- /dev/null +++ b/config/permissions/akP-vault-admin.yaml @@ -0,0 +1,5 @@ +# Permission group akP-vault-admin (name = filename). Gates the OpenBao +# application: without a binding every Authentik user could complete an OIDC +# login, so access is restricted to this group. OpenBao's own policy mapping +# keys off the same group name in the ak_groups claim. +application: vault diff --git a/config/providers_oauth2/vault.yaml b/config/providers_oauth2/vault.yaml new file mode 100644 index 0000000..d2d92d8 --- /dev/null +++ b/config/providers_oauth2/vault.yaml @@ -0,0 +1,32 @@ +# OAuth2/OIDC provider + application for OpenBao (the estate's Vault), making +# Authentik the default *human* login. Machine auth (approle, k8s, CI) and the +# break-glass paths are untouched and stay on the OpenBao side. +# +# client_secret is generated here and written to kv/service/authentik/oidc-vault +# ({client_id, client_secret}); the companion terraform-vault change reads it to +# configure the OIDC auth mount. Nothing is seeded by hand. +name: OpenBao +authorization_flow: default-provider-authorization-implicit-consent +invalidation_flow: default-provider-invalidation-flow +client_type: confidential +client_id: vault +client_secret_vault: + mount: kv + path: service/authentik/oidc-vault + generate: true +scope_mappings: + - goauthentik.io/providers/oauth2/scope-openid + - goauthentik.io/providers/oauth2/scope-email + - goauthentik.io/providers/oauth2/scope-profile +redirect_uris: + # `bao login -method=oidc` CLI callback (local listener, fixed port 8250). + - matching_mode: strict + url: http://localhost:8250/oidc/callback + # UI SSO callback, gateway host (traefik-internal -> vault svc :8200). + - matching_mode: strict + url: https://vault.k8s.syd1.au.unkin.net/ui/vault/auth/oidc/oidc/callback + # UI SSO callback, direct Consul service address (the address the estate + # documents for Vault access; any node forwards to the active replica). + - matching_mode: strict + url: https://vault.service.consul:8200/ui/vault/auth/oidc/oidc/callback +launch_url: https://vault.k8s.syd1.au.unkin.net/ui/ diff --git a/config/roles/akR-global-admin.yaml b/config/roles/akR-global-admin.yaml index 15b9c61..f1401d9 100644 --- a/config/roles/akR-global-admin.yaml +++ b/config/roles/akR-global-admin.yaml @@ -8,6 +8,7 @@ permissions: - akP-traefik-admin - akP-logviewer-admin - akP-watchstate-admin + - akP-vault-admin # arrstack has no admin tier (it is a proxy front door); grant global admins # the front door plus every per-app entitlement so they reach all media apps. - akP-arrstack-user diff --git a/modules/authentik/.terraform.lock.hcl b/modules/authentik/.terraform.lock.hcl index 825a7c6..688f1d7 100644 --- a/modules/authentik/.terraform.lock.hcl +++ b/modules/authentik/.terraform.lock.hcl @@ -22,6 +22,29 @@ provider "registry.opentofu.org/goauthentik/authentik" { ] } +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.6.0" + hashes = [ + "h1:8EQU5KSxezcjo/phRSe69rDOI0lk4pSaggj7FsskYp8=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} + provider "registry.opentofu.org/hashicorp/vault" { version = "5.10.1" constraints = ">= 4.0.0" diff --git a/modules/authentik/main.tf b/modules/authentik/main.tf index cd98e6f..c5864bd 100644 --- a/modules/authentik/main.tf +++ b/modules/authentik/main.tf @@ -136,12 +136,53 @@ data "authentik_property_mapping_provider_scope" "oauth2" { managed_list = each.value.scope_mappings } +locals { + # Providers whose client secret is pre-seeded in Vault and only read here. + oauth2_secret_read = { + for k, v in var.providers_oauth2 : k => v + if v.client_secret_vault != null && !v.client_secret_vault.generate + } + # Providers whose client secret is generated here and published to Vault, so + # onboarding needs no operator seeding the path first. + oauth2_secret_generate = { + for k, v in var.providers_oauth2 : k => v + if v.client_secret_vault != null && v.client_secret_vault.generate + } + + oauth2_client_secret = merge( + { for k, v in local.oauth2_secret_read : k => data.vault_kv_secret_v2.oauth2[k].data["client_secret"] }, + { for k, v in local.oauth2_secret_generate : k => random_password.oauth2_client_secret[k].result }, + ) +} + data "vault_kv_secret_v2" "oauth2" { - for_each = { for k, v in var.providers_oauth2 : k => v if v.client_secret_vault != null } + for_each = local.oauth2_secret_read mount = each.value.client_secret_vault.mount name = each.value.client_secret_vault.path } +# Alphanumeric only: the secret is pasted into consumer configs and CLI flags, +# where punctuation is an easy way to hit shell/URL escaping bugs. +resource "random_password" "oauth2_client_secret" { + for_each = local.oauth2_secret_generate + + length = 64 + special = false +} + +# Publish the generated credential so consumers (terraform-vault, app configs) +# read it from Vault instead of an operator copying it out of Authentik. +resource "vault_kv_secret_v2" "oauth2_client_secret" { + for_each = local.oauth2_secret_generate + + mount = each.value.client_secret_vault.mount + name = each.value.client_secret_vault.path + data_json = jsonencode({ + client_id = each.value.client_id + client_secret = random_password.oauth2_client_secret[each.key].result + }) +} + # Default JWT signing key for OAuth2 providers. Without a signing_key Authentik # signs ID tokens with HS256, which RS256-only RP clients (argocd, grafana, ...) # reject. Look up the estate's RSA keypair by name so providers default to RS256. @@ -157,7 +198,7 @@ resource "authentik_provider_oauth2" "this" { 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 + client_secret = lookup(local.oauth2_client_secret, each.key, null) property_mappings = concat( try(data.authentik_property_mapping_provider_scope.oauth2[each.key].ids, []), [authentik_property_mapping_provider_scope.groups_hierarchical.id], diff --git a/modules/authentik/variables.tf b/modules/authentik/variables.tf index fb4ff0d..1ea354f 100644 --- a/modules/authentik/variables.tf +++ b/modules/authentik/variables.tf @@ -60,9 +60,13 @@ variable "providers_oauth2" { 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. + # generate = true flips that around: the secret is created here and written + # to that path as {client_id, client_secret}, so onboarding needs no manual + # seed. Requires write access to the kv path. client_secret_vault = optional(object({ - mount = string - path = string + mount = string + path = string + generate = optional(bool, false) }), null) # Managed identifiers of scope property mappings (e.g. # goauthentik.io/providers/oauth2/scope-openid). Resolved to ids. diff --git a/modules/authentik/versions.tf b/modules/authentik/versions.tf index 4f420de..67bf4f5 100644 --- a/modules/authentik/versions.tf +++ b/modules/authentik/versions.tf @@ -9,5 +9,9 @@ terraform { source = "hashicorp/vault" version = ">= 4.0.0" } + random = { + source = "hashicorp/random" + version = ">= 3.6.0" + } } }