Mint the user-management credential dynamically from the single admin token
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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.
This commit is contained in:
2026-08-11 22:19:25 +10:00
parent 743ad5ac33
commit 483fd21acb
6 changed files with 145 additions and 50 deletions
+24 -14
View File
@@ -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"
+33 -3
View File
@@ -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" {
@@ -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 <t>` (e-breuninger provider) or `Bearer <t>` (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_<key>.<secret>; 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_<key>.<secret>; 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
@@ -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)
}))
}
+6
View File
@@ -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 = {}
}
@@ -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