Mint the netbox user-management credential dynamically from the single admin token #119

Merged
benvin merged 3 commits from benvin/netbox-user-mgmt-token-decouple into master 2026-08-12 00:03:13 +10:00
Owner

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).
## 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).
unkinben added 1 commit 2026-08-11 21:52:47 +10:00
Decouple netbox user management from the rotating engine seed
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
28d0a6ed79
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.
unkinben added 1 commit 2026-08-11 21:56:49 +10:00
Validate token_version matches the admin token kind; document live-mount repair
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
743ad5ac33
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=<BARE>), warning against
  -replace which would drop the mount's roles.
Author
Owner

Follow-up from live re-test (pipeline 162):

Repair the live mount (immediate). The mount uses ignore_changes=[token], so re-seeding KV did not reach it - it still holds the old scheme-prefixed Bearer nbt_..., which the plugin double-mangles into Token Bearer nbt_... (the 403 on GET /api/users/users/?username=terraform-infra). Push the bare token straight into the running mount (agents AppRole, not root):

vault write netbox/config token=<BARE_TOKEN>

netbox_url/token_version are preserved on a partial update. Do NOT -replace the mount to force a KV re-read - that recreates the mount and drops all roles.

token_version verdict: keep 2. It does not affect how the plugin authenticates its own calls - authHeaderFor(cfg.Token) derives the scheme from the token value's nbt_ prefix (Bearer=v2, Token=v1), not from token_version, which only sets the version of the per-user tokens the engine mints. The original mount value Bearer nbt_... is unambiguously v2; stripping Bearer leaves a bare nbt_... (still v2), so token_version=2 is correct. user-management succeeding under the provider's Token <token> scheme does NOT imply a v1 token - NetBox 4.6.5 infers the version from the nbt_ value prefix, so a bare v2 token authenticates under either keyword.

Latest commit adds a postcondition asserting token_version matches the admin-token kind (nbt_ v2 <-> 2; bare v1 <-> 1) so a mismatch fails at plan time instead of 403ing at runtime.

Follow-up from live re-test (pipeline 162): **Repair the live mount (immediate).** The mount uses `ignore_changes=[token]`, so re-seeding KV did not reach it - it still holds the old scheme-prefixed `Bearer nbt_...`, which the plugin double-mangles into `Token Bearer nbt_...` (the 403 on `GET /api/users/users/?username=terraform-infra`). Push the bare token straight into the running mount (agents AppRole, not root): vault write netbox/config token=<BARE_TOKEN> `netbox_url`/`token_version` are preserved on a partial update. Do NOT `-replace` the mount to force a KV re-read - that recreates the mount and drops all roles. **token_version verdict: keep 2.** It does not affect how the plugin authenticates its own calls - `authHeaderFor(cfg.Token)` derives the scheme from the token value's `nbt_` prefix (Bearer=v2, Token=v1), not from `token_version`, which only sets the version of the per-user tokens the engine mints. The original mount value `Bearer nbt_...` is unambiguously v2; stripping `Bearer ` leaves a bare `nbt_...` (still v2), so `token_version=2` is correct. user-management succeeding under the provider's `Token <token>` scheme does NOT imply a v1 token - NetBox 4.6.5 infers the version from the `nbt_` value prefix, so a bare v2 token authenticates under either keyword. Latest commit adds a postcondition asserting `token_version` matches the admin-token kind (nbt_ v2 <-> 2; bare v1 <-> 1) so a mismatch fails at plan time instead of 403ing at runtime.
unkinben added 1 commit 2026-08-11 22:19:39 +10:00
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
483fd21acb
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.
unkinben changed title from Decouple netbox user management from the rotating engine seed to Mint the netbox user-management credential dynamically from the single admin token 2026-08-11 22:20:07 +10:00
benvin merged commit 9e7687fccb into master 2026-08-12 00:03:13 +10:00
benvin deleted branch benvin/netbox-user-mgmt-token-decouple 2026-08-12 00:03:14 +10:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unkin/terraform-vault#119