9e7687fccb
ci/woodpecker/push/apply Pipeline was successful
## Why netbox_user_management authenticates to NetBox to reconcile service users + permissions on every apply. It must not depend on a second static admin token, and it must not break when the engine rotates its admin seed (`netbox/config/rotate` mints a fresh admin token and deletes the old one). The durable shape: keep exactly ONE static admin token, and have the netbox engine mint an ephemeral, user-admin-capable token that the e-breuninger provider uses to manage users. ## How - `module.netbox_user_mgmt_role` creates `netbox/roles/vault-user-mgmt`, a write-enabled role for a pre-existing NetBox superuser named by `user_mgmt_username`. Minted tokens authenticate AS that superuser (NetBox tokens carry no scope beyond `write_enabled`; the user's permissions apply), so they can create users. - `netbox_user_management` reads `netbox/creds/vault-user-mgmt` and configures the netbox provider with the minted token. When `user_mgmt_username` is unset it falls back to the single static `admin_token` (a `check` block warns that rotation would then break it) - a bootstrap/degraded path, never a second static token. - Grant the deployer `read` on `netbox/creds/vault-user-mgmt` (the one deliberate exception to the admin policy's `netbox/creds/*` exclusion). - Keep the bare-token + `token_version`-match postconditions on the single static admin token. ## Feasibility constraints (worked through, documented in-module) 1. **The engine CAN mint a user-admin token** - roles map to a pre-existing user with only a `write_enabled` gate (`vault-plugin-secrets-netbox` `path_roles.go`, `client.go` `MintToken`); point it at a superuser and minted tokens can manage users. 2. **Token transits state.** The hashicorp/vault provider (5.6.0) exposes 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 is written to state (sensitive, lease-revoked) and re-minted each plan. Migrate to an ephemeral resource once the vault provider ships a dynamic-secret one. 3. **A clean single fresh apply is not possible.** A provider cannot be configured from a role created in the same run (data sources don't defer; OpenTofu 1.11 defers only ephemeral resources, which the vault provider doesn't offer here). So enabling the dynamic path on a backend needs a one-time targeted bootstrap of the mount + role, then normal applies. Documented in `config/netbox_secret_backend/netbox.yaml`. ## Operator follow-up - Repair the live mount first (unchanged): `vault write netbox/config token=<BARE>` (the mount uses `ignore_changes=[token]`), keep `token_version=2`. - To enable dynamic minting: set `user_mgmt_username` to the pre-existing superuser, apply the deployer creds policy, then bootstrap once: `tofu apply -target=...netbox_secret_backend -target=...netbox_user_mgmt_role`, then apply normally. Until then user management stays on the static token (non-breaking, with a warning). Reviewed-on: #119 Co-authored-by: Ben Vincent <ben@unkin.net> Co-committed-by: Ben Vincent <ben@unkin.net>
150 lines
6.4 KiB
Terraform
150 lines
6.4 KiB
Terraform
# 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:
|
|
#
|
|
# 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 = local.netbox_static_backends
|
|
|
|
mount = "kv"
|
|
name = "service/vault/${var.country}/${var.region}/secret_backend/${each.key}/config"
|
|
|
|
lifecycle {
|
|
postcondition {
|
|
condition = nonsensitive(
|
|
!startswith(self.data["admin_token"], "Bearer ") &&
|
|
!startswith(self.data["admin_token"], "Token ")
|
|
)
|
|
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) 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 = 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 {
|
|
# Per backend: the dynamically-minted user-admin token, else the static seed.
|
|
netbox_admin_tokens = {
|
|
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 (dynamic or
|
|
# static) admin token.
|
|
provider "netbox" {
|
|
alias = "by_backend"
|
|
for_each = var.netbox_backend_aliases
|
|
|
|
server_url = var.netbox_backends[each.key].netbox_url
|
|
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.
|
|
skip_version_check = true
|
|
}
|
|
|
|
# NetBox users authenticate only via Vault-minted API tokens, never the web UI,
|
|
# so give each a random unknown password (required by the API) that no one holds.
|
|
resource "random_password" "user" {
|
|
for_each = var.netbox_roles
|
|
|
|
length = 32
|
|
special = true
|
|
}
|
|
|
|
# Declarative NetBox service users, one per engine role. The role's filename-
|
|
# derived name is the username, so the engine role and its user match 1:1.
|
|
resource "netbox_user" "users" {
|
|
for_each = var.netbox_roles
|
|
|
|
provider = netbox.by_backend[each.value.backend]
|
|
|
|
username = each.value.name
|
|
password = random_password.user[each.key].result
|
|
active = each.value.active
|
|
staff = each.value.staff
|
|
email = each.value.email
|
|
}
|
|
|
|
locals {
|
|
# Flatten roles x permissions into one map keyed by "<role_path>:<index>". A
|
|
# permission's name defaults to the role name (the username) so a single-
|
|
# permission identity repeats nothing already encoded by the filename.
|
|
netbox_permissions = merge([
|
|
for role_key, role in var.netbox_roles : {
|
|
for idx, perm in role.permissions :
|
|
"${role_key}:${idx}" => {
|
|
backend = role.backend
|
|
user = role_key
|
|
name = coalesce(perm.name, length(role.permissions) == 1 ? role.name : "${role.name}-${idx}")
|
|
object_types = perm.object_types
|
|
actions = perm.actions
|
|
constraints = perm.constraints
|
|
description = perm.description
|
|
enabled = perm.enabled
|
|
}
|
|
}
|
|
]...)
|
|
}
|
|
|
|
# Object permissions granting each user its object-type/action scope.
|
|
resource "netbox_permission" "perms" {
|
|
for_each = local.netbox_permissions
|
|
|
|
provider = netbox.by_backend[each.value.backend]
|
|
|
|
name = each.value.name
|
|
object_types = each.value.object_types
|
|
actions = each.value.actions
|
|
enabled = each.value.enabled
|
|
description = each.value.description
|
|
constraints = each.value.constraints
|
|
users = [tonumber(netbox_user.users[each.value.user].id)]
|
|
}
|