From 28d0a6ed79103a433f32244ff162d9fd354660e7 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 11 Aug 2026 21:52:21 +1000 Subject: [PATCH 1/3] Decouple netbox user management from the rotating engine seed The netbox secrets engine seeds its admin_token from KV once and then rotates it in place (netbox/config/rotate), which mints a fresh admin token and deletes the old one. netbox_user_management re-reads that same KV admin_token on every apply to drive the e-breuninger provider, so after the first rotation it authenticates with a token NetBox has already deleted and the apply fails. A separate failure mode compounds this: a KV admin token carrying a literal "Bearer "/"Token " scheme prefix produces a malformed three-part Authorization header and a 403, because NetBox derives the token version from the value's nbt_ prefix (not the keyword) and expects a bare token. - Read a dedicated, never-rotated user_mgmt_token for user management, falling back to admin_token only as a bootstrap convenience until it is seeded. - Add a check block that warns when only the rotating seed is present, nudging operators to seed user_mgmt_token before enabling engine rotation. - Add data-source postconditions on both modules rejecting a scheme-prefixed admin credential with a clear message instead of a downstream 403. - Document the two KV keys and the bare-token contract in the backend config. --- config/netbox_secret_backend/netbox.yaml | 17 +++++- .../modules/netbox_secret_backend/main.tf | 13 +++++ .../modules/netbox_user_management/main.tf | 53 +++++++++++++++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/config/netbox_secret_backend/netbox.yaml b/config/netbox_secret_backend/netbox.yaml index 4e22f42..cc877e4 100644 --- a/config/netbox_secret_backend/netbox.yaml +++ b/config/netbox_secret_backend/netbox.yaml @@ -1,12 +1,25 @@ # Mounts the netbox token secrets engine at "netbox" and writes its config. # The seeded NetBox admin token is sensitive and read from KV, not stored here: # kv/service/vault/au/syd1/secret_backend/netbox/config -# -> key: admin_token (required) -# Populate that KV path with a purpose-built NetBox service token that has +# -> key: admin_token (required) engine bootstrap seed; rotated by Vault +# -> key: user_mgmt_token (recommended) stable token for netbox_user_management +# +# Both keys must be BARE NetBox tokens with NO scheme prefix: do not prepend +# "Bearer " or "Token ". NetBox infers the version from the value's nbt_ prefix, +# so one bare token authenticates under either scheme; the plugin and provider +# add the keyword themselves. A prefixed value yields a malformed header + 403. +# +# Populate admin_token with a purpose-built NetBox service token that has # add_token + grant_token (or superuser) BEFORE applying, then run # `vault write -f netbox/config/rotate` after the first apply so only Vault # holds the live admin token. # +# Rotation caveat: config/rotate mints a fresh admin_token and DELETES the old +# one, so the KV admin_token becomes a dead token. netbox_user_management reads +# its credential every apply, so seed a SEPARATE, never-rotated user_mgmt_token +# (also add_user + add_token + grant_token, or superuser) BEFORE rotating; +# otherwise user management breaks once admin_token is rotated away. +# # token_version 2 is the NetBox 4.6.5 default and requires API_TOKEN_PEPPERS to # be configured on the NetBox server; set token_version: 1 here if the server # has no peppers. diff --git a/modules/vault_cluster/modules/netbox_secret_backend/main.tf b/modules/vault_cluster/modules/netbox_secret_backend/main.tf index 19c99b0..48dbfb5 100644 --- a/modules/vault_cluster/modules/netbox_secret_backend/main.tf +++ b/modules/vault_cluster/modules/netbox_secret_backend/main.tf @@ -8,6 +8,19 @@ data "vault_kv_secret_v2" "config" { mount = "kv" name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config" + + lifecycle { + # The plugin adds the Authorization scheme itself (Bearer for v2 nbt_ tokens, + # Token for v1), so admin_token must be the BARE token. A literal `Bearer `/ + # `Token ` prefix in the seed yields a malformed header and 403s on mint/rotate. + postcondition { + condition = nonsensitive( + !startswith(self.data["admin_token"], "Bearer ") && + !startswith(self.data["admin_token"], "Token ") + ) + error_message = "KV admin_token for netbox backend '${var.path}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix (v2: nbt_.; v1: the 40-char value)." + } + } } resource "netbox_secret_backend" "this" { diff --git a/modules/vault_cluster/modules/netbox_user_management/main.tf b/modules/vault_cluster/modules/netbox_user_management/main.tf index a5692d0..a0d0d1c 100644 --- a/modules/vault_cluster/modules/netbox_user_management/main.tf +++ b/modules/vault_cluster/modules/netbox_user_management/main.tf @@ -1,11 +1,56 @@ -# Read the seeded NetBox admin token for each backend from KV. This is the same -# token the netbox secrets engine is configured with (key admin_token), and it -# must carry add_token + grant_token (or superuser) to create users/permissions. +# Read the NetBox admin credential for each backend from KV. User management runs +# on EVERY apply (the e-breuninger provider refreshes/reconciles the users and +# permissions), so it needs a STABLE admin token, not the engine's bootstrap seed. +# +# The netbox secrets engine seeds its `admin_token` once and then rotates it in +# place (`vault write -f netbox/config/rotate`), which mints a fresh admin token +# and DELETES the old one - so after the first rotation the KV `admin_token` names +# a token that no longer exists in NetBox. Binding user management to it would make +# every post-rotation apply fail to authenticate. We therefore prefer a dedicated, +# never-rotated key `user_mgmt_token` and fall back to `admin_token` only as a +# bootstrap convenience (valid until the first rotate). Seed `user_mgmt_token` +# (bare token, add_user + add_token + grant_token, or superuser) BEFORE rotating. data "vault_kv_secret_v2" "netbox_backend_configs" { for_each = var.netbox_backends mount = "kv" name = "service/vault/${var.country}/${var.region}/secret_backend/${each.key}/config" + + lifecycle { + # NetBox derives the token version from the value's `nbt_` prefix, not the + # Authorization keyword, so the same BARE token works whether a client sends + # it as `Token ` (e-breuninger provider) or `Bearer ` (the plugin). A + # value carrying a literal `Bearer `/`Token ` scheme prefix produces a + # malformed three-part header and a 403; reject it here with a clear message. + postcondition { + condition = nonsensitive( + !startswith(lookup(self.data, "user_mgmt_token", self.data["admin_token"]), "Bearer ") && + !startswith(lookup(self.data, "user_mgmt_token", self.data["admin_token"]), "Token ") + ) + error_message = "KV admin credential for netbox backend '${each.key}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix (v2: nbt_.; v1: the 40-char value)." + } + } +} + +# Warn (non-fatal) when only the rotating bootstrap seed is present, so operators +# seed a stable user_mgmt_token before enabling engine rotation. +check "netbox_user_mgmt_token_seeded" { + assert { + condition = nonsensitive(alltrue([ + for cfg in data.vault_kv_secret_v2.netbox_backend_configs : + contains(keys(cfg.data), "user_mgmt_token") + ])) + error_message = "A netbox backend has no dedicated user_mgmt_token; user management is falling back to the rotating admin_token seed. Seed a stable user_mgmt_token before running netbox/config/rotate, or user management will fail after the seed is rotated away." + } +} + +locals { + # Prefer the stable, never-rotated user-management token; fall back to the + # bootstrap admin_token only until a user_mgmt_token is seeded. + netbox_admin_tokens = { + for backend, cfg in data.vault_kv_secret_v2.netbox_backend_configs : + backend => lookup(cfg.data, "user_mgmt_token", cfg.data["admin_token"]) + } } # One NetBox provider instance per backend, authenticated with its admin token. @@ -14,7 +59,7 @@ provider "netbox" { for_each = var.netbox_backend_aliases server_url = var.netbox_backends[each.key].netbox_url - api_token = data.vault_kv_secret_v2.netbox_backend_configs[each.key].data["admin_token"] + api_token = local.netbox_admin_tokens[each.key] allow_insecure_https = var.netbox_backends[each.key].tls_skip_verify # NetBox is internal and not always reachable at plan time; the resource CRUD # calls surface any real incompatibility, so skip the startup version probe. -- 2.47.3 From 743ad5ac3343b675ade9068b49f68738babfb453 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 11 Aug 2026 21:56:45 +1000 Subject: [PATCH 2/3] Validate token_version matches the admin token kind; document live-mount repair A live re-test showed the plugin's own NetBox calls (username resolution, cred minting) still 403 after the KV admin_token was re-seeded bare, because the mount uses ignore_changes=[token] and kept the old scheme-prefixed value. The plugin builds its admin Authorization header from the token value's nbt_ prefix, not from token_version, so a stale "Bearer nbt_..." value double-mangles into a malformed header. - Extend the netbox_secret_backend postcondition to also assert token_version matches the admin token kind (nbt_ v2 <-> token_version 2; bare v1 <-> 1), so a version/token mismatch fails at plan time with a clear message. - Document that token_version does not affect the admin header (only minted token version), and give the exact command to push a corrected token into a running mount (vault write netbox/config token=), warning against -replace which would drop the mount's roles. --- config/netbox_secret_backend/netbox.yaml | 11 ++++++++++- .../modules/netbox_secret_backend/main.tf | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/config/netbox_secret_backend/netbox.yaml b/config/netbox_secret_backend/netbox.yaml index cc877e4..993b9c3 100644 --- a/config/netbox_secret_backend/netbox.yaml +++ b/config/netbox_secret_backend/netbox.yaml @@ -22,7 +22,16 @@ # # token_version 2 is the NetBox 4.6.5 default and requires API_TOKEN_PEPPERS to # be configured on the NetBox server; set token_version: 1 here if the server -# has no peppers. +# has no peppers. token_version does NOT change how the plugin authenticates its +# own calls (that scheme comes from the admin_token value's nbt_ prefix); it only +# sets the version of the per-user tokens the engine mints. It must still MATCH +# the admin_token kind: nbt_ v2 token -> token_version 2; bare v1 token -> 1. +# +# The mount uses ignore_changes=[token], so editing KV alone does NOT reach the +# live mount. To push a corrected/rotated admin token into a running mount: +# vault write netbox/config token= +# (netbox_url/token_version are preserved on a partial update). Do NOT -replace +# the mount to force a re-read - that recreates it and drops all roles/config. description: "NetBox ephemeral scoped API token engine" netbox_url: "https://netbox.k8s.syd1.au.unkin.net" token_version: 2 diff --git a/modules/vault_cluster/modules/netbox_secret_backend/main.tf b/modules/vault_cluster/modules/netbox_secret_backend/main.tf index 48dbfb5..585d6d9 100644 --- a/modules/vault_cluster/modules/netbox_secret_backend/main.tf +++ b/modules/vault_cluster/modules/netbox_secret_backend/main.tf @@ -10,15 +10,23 @@ data "vault_kv_secret_v2" "config" { name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config" lifecycle { - # The plugin adds the Authorization scheme itself (Bearer for v2 nbt_ tokens, - # Token for v1), so admin_token must be the BARE token. A literal `Bearer `/ - # `Token ` prefix in the seed yields a malformed header and 403s on mint/rotate. + # The plugin builds its own Authorization header from the token VALUE, not + # token_version: a value starting with the nbt_ prefix is sent as + # "Bearer " (v2), otherwise "Token " (v1). So admin_token must + # be the BARE token - a literal `Bearer `/`Token ` scheme prefix yields a + # malformed three-part header and 403s on the plugin's own NetBox calls. + # + # token_version does NOT change that header; it only selects the version of + # the per-user tokens the engine mints for roles. It must still MATCH the + # admin token's kind so the mount and its minted creds line up: an nbt_ v2 + # admin token pairs with token_version=2, a bare v1 token with token_version=1. postcondition { condition = nonsensitive( !startswith(self.data["admin_token"], "Bearer ") && - !startswith(self.data["admin_token"], "Token ") + !startswith(self.data["admin_token"], "Token ") && + startswith(self.data["admin_token"], "nbt_") == (var.token_version == 2) ) - error_message = "KV admin_token for netbox backend '${var.path}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix (v2: nbt_.; v1: the 40-char value)." + error_message = "KV admin_token for netbox backend '${var.path}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix, AND its version must match token_version: a v2 token (nbt_.) requires token_version=2; a v1 token (bare 40-char value) requires token_version=1." } } } -- 2.47.3 From 483fd21acbb2e6aca5b1b8be863fa6ab0bafa10a Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 11 Aug 2026 22:19:25 +1000 Subject: [PATCH 3/3] Mint the user-management credential dynamically from the single admin token Replace the second static user_mgmt_token with a Vault-minted, user-admin-capable token so only ONE static NetBox admin credential exists. A dedicated engine role (netbox/roles/vault-user-mgmt) mints a short-lived token for a pre-existing NetBox superuser (user_mgmt_username); netbox_user_management authenticates the e-breuninger provider with that minted token to reconcile the service users. This also resolves rotation-divergence structurally: the credential is always derived from the current static admin token, so rotating it never strands user management. Constraints this design works within (documented in the module): - The hashicorp/vault provider ships ephemeral resources for KV only, not dynamic engine creds, so the mint is read via the vault_generic_secret data source; the short-lived token transits state (sensitive, lease-revoked) and is re-minted each plan. Migrate to an ephemeral resource once the provider ships one. - A provider cannot be configured from a role created in the same fresh apply, so a brand-new backend needs a one-time targeted bootstrap of the mount + role. - Add user_mgmt_username to the netbox backend config; when set, mint dynamically, else fall back to the single static admin_token (a check block warns that rotation would then break user management). - Add module.netbox_user_mgmt_role (vault-user-mgmt, write-enabled, short TTL). - Grant the deployer read on netbox/creds/vault-user-mgmt (the one deliberate exception to the admin policy's creds exclusion). - Keep the bare-token + token_version postconditions on the single admin token. --- config/netbox_secret_backend/netbox.yaml | 38 +++++--- modules/vault_cluster/main.tf | 36 +++++++- .../modules/netbox_user_management/main.tf | 89 ++++++++++++------- .../netbox_user_management/variables.tf | 3 + modules/vault_cluster/variables.tf | 6 ++ policies/netbox/creds/vault-user-mgmt.yaml | 23 +++++ 6 files changed, 145 insertions(+), 50 deletions(-) create mode 100644 policies/netbox/creds/vault-user-mgmt.yaml diff --git a/config/netbox_secret_backend/netbox.yaml b/config/netbox_secret_backend/netbox.yaml index 993b9c3..9c2ff49 100644 --- a/config/netbox_secret_backend/netbox.yaml +++ b/config/netbox_secret_backend/netbox.yaml @@ -1,24 +1,30 @@ # Mounts the netbox token secrets engine at "netbox" and writes its config. # The seeded NetBox admin token is sensitive and read from KV, not stored here: # kv/service/vault/au/syd1/secret_backend/netbox/config -# -> key: admin_token (required) engine bootstrap seed; rotated by Vault -# -> key: user_mgmt_token (recommended) stable token for netbox_user_management +# -> key: admin_token (required) the SINGLE static admin credential # -# Both keys must be BARE NetBox tokens with NO scheme prefix: do not prepend +# admin_token must be a BARE NetBox token with NO scheme prefix: do not prepend # "Bearer " or "Token ". NetBox infers the version from the value's nbt_ prefix, -# so one bare token authenticates under either scheme; the plugin and provider -# add the keyword themselves. A prefixed value yields a malformed header + 403. +# so one bare token authenticates under either scheme; the plugin adds the keyword +# itself. A prefixed value yields a malformed header + 403. # -# Populate admin_token with a purpose-built NetBox service token that has -# add_token + grant_token (or superuser) BEFORE applying, then run -# `vault write -f netbox/config/rotate` after the first apply so only Vault -# holds the live admin token. +# Populate admin_token with a purpose-built NetBox superuser token (add_user + +# add_token + grant_token, or superuser) BEFORE applying, then run +# `vault write -f netbox/config/rotate` after the first apply so only Vault holds +# the live admin token. # -# Rotation caveat: config/rotate mints a fresh admin_token and DELETES the old -# one, so the KV admin_token becomes a dead token. netbox_user_management reads -# its credential every apply, so seed a SEPARATE, never-rotated user_mgmt_token -# (also add_user + add_token + grant_token, or superuser) BEFORE rotating; -# otherwise user management breaks once admin_token is rotated away. +# Only ONE static admin token exists. netbox_user_management does NOT re-read this +# token; instead the engine mints it a short-lived user-admin token per apply from +# netbox/roles/vault-user-mgmt (see user_mgmt_username below), so rotating +# admin_token never breaks user management. Set user_mgmt_username to the +# pre-existing NetBox superuser the static admin_token belongs to (or another +# superuser). Leaving it unset falls back to using admin_token directly, which is +# only a bootstrap/degraded path and breaks after rotation. +# +# Bootstrap ordering: the vault-user-mgmt role must exist before the netbox +# provider is configured from its creds, so on a brand-new backend apply the mount +# + role first (e.g. `tofu apply -target=...netbox_secret_backend +# -target=...netbox_user_mgmt_role`) once, then apply normally. # # token_version 2 is the NetBox 4.6.5 default and requires API_TOKEN_PEPPERS to # be configured on the NetBox server; set token_version: 1 here if the server @@ -36,3 +42,7 @@ description: "NetBox ephemeral scoped API token engine" netbox_url: "https://netbox.k8s.syd1.au.unkin.net" token_version: 2 request_timeout_seconds: 30 +# Set to the pre-existing NetBox superuser admin_token belongs to, to mint the +# user-management credential dynamically (recommended). Until set, user management +# uses admin_token directly and a check block warns that rotation will break it. +# user_mgmt_username: "vault-netbox-admin" diff --git a/modules/vault_cluster/main.tf b/modules/vault_cluster/main.tf index bfd7e0d..459a4f5 100644 --- a/modules/vault_cluster/main.tf +++ b/modules/vault_cluster/main.tf @@ -475,10 +475,34 @@ module "netbox_secret_backend" { depends_on = [module.plugin] } +# Dedicated engine role that mints an ephemeral, user-admin-capable token for the +# pre-existing NetBox superuser named on each backend (user_mgmt_username). +# netbox_user_management reads netbox/creds/vault-user-mgmt from it, so it +# authenticates with a short-lived Vault-minted token derived from the single +# static admin token - never a second static credential, and unaffected by +# rotation of the engine's admin seed. Created before user management so the role +# exists when it reads creds. +module "netbox_user_mgmt_role" { + source = "./modules/netbox_secret_backend_role" + + for_each = { for k, v in var.netbox_secret_backend : k => v if v.user_mgmt_username != null } + + backend = each.key + name = "vault-user-mgmt" + netbox_username = each.value.user_mgmt_username + write_enabled = true + description = "Ephemeral user-admin token for netbox_user_management (Vault-minted per apply)" + ttl = 600 + max_ttl = 1200 + + depends_on = [module.netbox_secret_backend] +} + # Declaratively manage the NetBox service users + object permissions the engine -# roles mint tokens for, using the seeded admin token (mirrors consul_acl_management). -# Consumes the SAME role config as netbox_secret_backend_role: one file per identity, -# filename-derived username, inline permissions. +# roles mint tokens for, authenticating with the Vault-minted user-admin token +# above (mirrors consul_acl_management). Consumes the SAME role config as +# netbox_secret_backend_role: one file per identity, filename-derived username, +# inline permissions. module "netbox_user_management" { source = "./modules/netbox_user_management" @@ -487,6 +511,12 @@ module "netbox_user_management" { netbox_backends = var.netbox_secret_backend netbox_roles = var.netbox_secret_backend_role netbox_backend_aliases = var.netbox_backend_aliases + + # This module declares its own netbox provider, so it is a legacy module and + # cannot take depends_on. Ordering vs the vault-user-mgmt role is not needed on + # steady state (the role pre-exists, so reading its creds succeeds regardless); + # on first enablement the role must be created first via the one-time targeted + # bootstrap documented in config/netbox_secret_backend/netbox.yaml. } module "netbox_secret_backend_role" { diff --git a/modules/vault_cluster/modules/netbox_user_management/main.tf b/modules/vault_cluster/modules/netbox_user_management/main.tf index a0d0d1c..c79c813 100644 --- a/modules/vault_cluster/modules/netbox_user_management/main.tf +++ b/modules/vault_cluster/modules/netbox_user_management/main.tf @@ -1,59 +1,82 @@ -# Read the NetBox admin credential for each backend from KV. User management runs -# on EVERY apply (the e-breuninger provider refreshes/reconciles the users and -# permissions), so it needs a STABLE admin token, not the engine's bootstrap seed. +# netbox_user_management reconciles NetBox service users + permissions on every +# apply, so it needs an admin credential each run. That credential is minted +# DYNAMICALLY by the netbox engine from the SINGLE static admin token, so no +# second static credential exists and it survives rotation of the engine seed: # -# The netbox secrets engine seeds its `admin_token` once and then rotates it in -# place (`vault write -f netbox/config/rotate`), which mints a fresh admin token -# and DELETES the old one - so after the first rotation the KV `admin_token` names -# a token that no longer exists in NetBox. Binding user management to it would make -# every post-rotation apply fail to authenticate. We therefore prefer a dedicated, -# never-rotated key `user_mgmt_token` and fall back to `admin_token` only as a -# bootstrap convenience (valid until the first rotate). Seed `user_mgmt_token` -# (bare token, add_user + add_token + grant_token, or superuser) BEFORE rotating. +# 1. module.netbox_user_mgmt_role creates netbox/roles/vault-user-mgmt, a +# write-enabled role for a pre-existing NetBox superuser (user_mgmt_username). +# 2. Reading netbox/creds/vault-user-mgmt mints a short-lived, user-admin-capable +# token for that superuser; the e-breuninger provider uses it to CRUD users. +# +# The hashicorp/vault provider ships ephemeral resources for KV only, not for +# dynamic engine creds, so the mint is read via the vault_generic_secret DATA +# source: the short-lived token transits Terraform state (sensitive, lease-revoked) +# and is re-minted each plan. This is the closest single-static-token shape the +# current providers allow; move to an ephemeral resource once the vault provider +# ships a dynamic-secret one. Ordering note: the vault-user-mgmt role must already +# exist when this reads creds, so on a brand-new backend bootstrap the mount + +# role first (targeted apply) - a fresh single apply cannot configure the netbox +# provider from a role created in the same run. +locals { + # Backends that mint a dynamic user-admin token (a pre-existing superuser named). + netbox_dynamic_backends = { for k, v in var.netbox_backends : k => v if v.user_mgmt_username != null } + # Backends still using the single static admin_token directly (until a superuser + # is named). Bootstrap/degraded path - the same one token, not a second static. + netbox_static_backends = { for k, v in var.netbox_backends : k => v if v.user_mgmt_username == null } +} + +# Dynamic path: the engine mints a user-admin token for the superuser. Requires +# the deployer to read netbox/creds/vault-user-mgmt (policies/netbox/creds). +data "vault_generic_secret" "user_admin" { + for_each = local.netbox_dynamic_backends + + path = "${each.key}/creds/vault-user-mgmt" +} + +# Static fallback: the single admin_token from KV, used only until a superuser is +# named. NetBox derives the token version from the value's `nbt_` prefix, not the +# keyword, so the same BARE token works under either scheme; reject a value that +# carries a literal `Bearer `/`Token ` scheme prefix (a malformed header -> 403). data "vault_kv_secret_v2" "netbox_backend_configs" { - for_each = var.netbox_backends + for_each = local.netbox_static_backends mount = "kv" name = "service/vault/${var.country}/${var.region}/secret_backend/${each.key}/config" lifecycle { - # NetBox derives the token version from the value's `nbt_` prefix, not the - # Authorization keyword, so the same BARE token works whether a client sends - # it as `Token ` (e-breuninger provider) or `Bearer ` (the plugin). A - # value carrying a literal `Bearer `/`Token ` scheme prefix produces a - # malformed three-part header and a 403; reject it here with a clear message. postcondition { condition = nonsensitive( - !startswith(lookup(self.data, "user_mgmt_token", self.data["admin_token"]), "Bearer ") && - !startswith(lookup(self.data, "user_mgmt_token", self.data["admin_token"]), "Token ") + !startswith(self.data["admin_token"], "Bearer ") && + !startswith(self.data["admin_token"], "Token ") ) - error_message = "KV admin credential for netbox backend '${each.key}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix (v2: nbt_.; v1: the 40-char value)." + error_message = "KV admin_token for netbox backend '${each.key}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix (v2: nbt_.; v1: the 40-char value)." } } } -# Warn (non-fatal) when only the rotating bootstrap seed is present, so operators -# seed a stable user_mgmt_token before enabling engine rotation. -check "netbox_user_mgmt_token_seeded" { +# Warn (non-fatal) for any backend still on the static token: rotating the engine +# admin seed would then break user management. Set user_mgmt_username to switch to +# the dynamic, rotation-proof mint. +check "netbox_user_mgmt_dynamic" { assert { - condition = nonsensitive(alltrue([ - for cfg in data.vault_kv_secret_v2.netbox_backend_configs : - contains(keys(cfg.data), "user_mgmt_token") - ])) - error_message = "A netbox backend has no dedicated user_mgmt_token; user management is falling back to the rotating admin_token seed. Seed a stable user_mgmt_token before running netbox/config/rotate, or user management will fail after the seed is rotated away." + condition = length(local.netbox_static_backends) == 0 + error_message = "A netbox backend has no user_mgmt_username, so user management uses the static admin_token directly and will break if that token is rotated (netbox/config/rotate). Set user_mgmt_username to a pre-existing NetBox superuser to mint the credential dynamically." } } locals { - # Prefer the stable, never-rotated user-management token; fall back to the - # bootstrap admin_token only until a user_mgmt_token is seeded. + # Per backend: the dynamically-minted user-admin token, else the static seed. netbox_admin_tokens = { - for backend, cfg in data.vault_kv_secret_v2.netbox_backend_configs : - backend => lookup(cfg.data, "user_mgmt_token", cfg.data["admin_token"]) + for k, v in var.netbox_backends : k => ( + v.user_mgmt_username != null + ? data.vault_generic_secret.user_admin[k].data["token"] + : data.vault_kv_secret_v2.netbox_backend_configs[k].data["admin_token"] + ) } } -# One NetBox provider instance per backend, authenticated with its admin token. +# One NetBox provider instance per backend, authenticated with its (dynamic or +# static) admin token. provider "netbox" { alias = "by_backend" for_each = var.netbox_backend_aliases diff --git a/modules/vault_cluster/modules/netbox_user_management/variables.tf b/modules/vault_cluster/modules/netbox_user_management/variables.tf index 051907c..313d48d 100644 --- a/modules/vault_cluster/modules/netbox_user_management/variables.tf +++ b/modules/vault_cluster/modules/netbox_user_management/variables.tf @@ -3,6 +3,9 @@ variable "netbox_backends" { type = map(object({ netbox_url = string tls_skip_verify = optional(bool, false) + # Pre-existing NetBox superuser the engine mints a dynamic user-admin token + # for; unset means fall back to the static admin_token from KV. + user_mgmt_username = optional(string) })) } diff --git a/modules/vault_cluster/variables.tf b/modules/vault_cluster/variables.tf index be7b04d..01814f8 100644 --- a/modules/vault_cluster/variables.tf +++ b/modules/vault_cluster/variables.tf @@ -426,6 +426,12 @@ variable "netbox_secret_backend" { ca_cert = optional(string) tls_skip_verify = optional(bool, false) request_timeout_seconds = optional(number, 30) + # Pre-existing NetBox superuser (or add_user + add_token + grant_token) the + # engine mints an ephemeral user-admin token for, so netbox_user_management + # authenticates with a Vault-minted credential derived from the single static + # admin token instead of a second static one. Unset = use the static + # admin_token directly (bootstrap/degraded; breaks after admin-token rotation). + user_mgmt_username = optional(string) })) default = {} } diff --git a/policies/netbox/creds/vault-user-mgmt.yaml b/policies/netbox/creds/vault-user-mgmt.yaml new file mode 100644 index 0000000..ec02ea7 --- /dev/null +++ b/policies/netbox/creds/vault-user-mgmt.yaml @@ -0,0 +1,23 @@ +# Allow the vault deployer to mint the ephemeral user-admin token that +# netbox_user_management authenticates with (netbox/creds/vault-user-mgmt). The +# engine mints it from the single static admin token, so the deployer never holds +# a second static NetBox credential. +# +# The netbox admin policy (policies/netbox/admin.yaml) deliberately excludes +# netbox/creds/* - minting is normally for consumers, not the deployer. This is +# the one deliberate exception: the deployer needs a user-admin token during the +# run to reconcile NetBox users. Scoped to the single vault-user-mgmt role only. +# +# Bound to the same principals as the admin policy: the tf_vault AppRole and its +# Woodpecker k8s auth role. +--- +rules: + - path: "netbox/creds/vault-user-mgmt" + capabilities: + - read + +auth: + approle: + - tf_vault + k8s/au/syd1: + - woodpecker_terraform_vault -- 2.47.3