9 Commits

Author SHA1 Message Date
unkin-agent 1dace21b37 vault: add ghp k8s auth role + kv read policy
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ghp (a GitHub proxy) reads its GitHub App credentials and encryption key from kv/kubernetes/ghp/*; it uses the postgres storage backend so it only needs read access to those kv secrets.

- add k8s/au/syd1 auth role ghp bound to ServiceAccount ghp in namespace ghp (ttl 600s, audience vault)
- add read-only kv policy granting read on kv/data/kubernetes/ghp/* and read+list on kv/metadata/kubernetes/ghp/*
- mirrors the artifactapi / logging_logarchiver per-app pattern (app-SA-bound k8s role + single kv read policy consumed by VSO)
2026-08-13 19:31:46 +10:00
unkinben 9e7687fccb Mint the netbox user-management credential dynamically from the single admin token (#119)
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>
2026-08-12 00:03:13 +10:00
unkinben 8ccc5f1393 Add the netbox backend and terraform-infra role (#117)
ci/woodpecker/push/apply Pipeline was successful
## Why

- The netbox engine modules stand ready but mount nothing and create no identity until backend and role data exist, so terraform-infra still reads a static NetBox token instead of minting ephemeral scoped tokens.

## How

- Add `config/netbox_secret_backend/netbox.yaml` to mount the engine at `netbox` and point it at the syd1 NetBox URL; the admin token is read from KV, not stored here.
- Add `config/netbox_secret_backend_role/netbox/terraform-infra.yaml` as the single declarative source for the terraform-infra identity: filename-derived role name and NetBox username, write access, short TTLs, and an inline permissions block. Nothing in the file repeats the filename.
- Scope terraform-infra to view/add/change/delete on the IPAM/DCIM objects it manages: prefixes, ip-addresses, ip-ranges, devices, interfaces, mac addresses.
- Add `policies/netbox/creds/terraform-infra.yaml` letting the terraform-infra AppRole and its Woodpecker k8s role read `netbox/creds/terraform-infra`; it attaches to nothing until the separate terraform-infra Vault onboarding lands.

## Dependency order

- Stacked on the modules PR (#115), which stacks on the plugin registration PR. Merge order: plugin -> #115 -> this.

## CI note

- The plan step is red only on the external admin_token KV seed at `kv/data/service/vault/au/syd1/secret_backend/netbox/config` (a NetBox token with add_token + grant_token / superuser). Seeding that path is an environmental prerequisite, not a code defect; everything else validates.

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #117
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-11 20:43:04 +10:00
unkinben 521ef4f0f3 Add the netbox secrets engine modules and wiring (#115)
ci/woodpecker/push/apply Pipeline was successful
## Why

- Managing NetBox from Vault needs three capabilities the repo does not yet have: mounting the netbox engine, minting scoped tokens through roles, and creating the NetBox service users those roles mint tokens for. Landing the modules and config scaffolding before any backend or role data lets each concrete identity be added as pure data later.

## How

- Add three modules under `modules/vault_cluster/modules`: `netbox_secret_backend` (mount + engine config, admin token read from KV), `netbox_secret_backend_role` (mint ephemeral scoped tokens for a filename-derived NetBox username), and `netbox_user_management` (mirror consul_acl_management: read the seeded admin token, drive one e-breuninger/netbox provider per backend, and synthesize the NetBox user + object permissions from the role map's inline permissions).
- Derive the `netbox_secret_backend` and `netbox_secret_backend_role` maps in `config.hcl`, deriving each role's name and netbox_username from its filename so the engine role and NetBox username match by construction.
- Wire the three module blocks and their variables through `vault_cluster` and the syd1 terragrunt inputs, reusing the sanitized backend-alias pattern the Consul providers use.
- Leave the backend and role maps empty: the modules stand ready and create nothing until backend and role config data are added.

## Dependency order

- Stacked on the plugin registration PR (branch `benvin/netbox-plugin`); merge that first, then this, then the backend + role PR (#117). Plans clean with empty netbox maps.

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #115
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 16:36:18 +10:00
unkinben d080279728 Register the netbox secrets plugin in the catalog (#118)
ci/woodpecker/push/apply Pipeline was successful
## Why

- The netbox secrets engine cannot be mounted until its plugin binary is registered in the OpenBao catalog, so the catalog entry must land before any engine mount or role config references it.

## How

- Add `config/plugins/vault-plugin-secrets-netbox.yaml` registering the plugin as a secret plugin, pinned to the released v0.1.0 binary sha256 that Puppet installs on the OpenBao nodes. Bump the sha in lockstep with any RPM upgrade.

## Dependency order

- First of three stacked PRs: this plugin registration, then the netbox modules + wiring (#115), then the netbox backend + terraform-infra role (#117). Merges to master independently.

Reviewed-on: #118
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 16:30:06 +10:00
unkinben 03dc436a89 Add netbox engine admin policy (#116)
ci/woodpecker/push/apply Pipeline was successful
## Why
- The netbox secrets engine mount + roles land in a follow-up PR (#115); its manage policy must exist first so the deployer can create the engine config and roles the moment that PR applies (policy-first split).

## How
- Add `policies/netbox/admin.yaml` granting the deployer `netbox/config`, `netbox/config/rotate` and `netbox/roles/*` (deliberately excludes `netbox/creds/*`), bound to the `tf_vault` AppRole and `woodpecker_terraform_vault` k8s role, mirroring the gpg/gitea admin policies.

Land this before #115 (the engine mount + roles).

Reviewed-on: #116
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 12:02:13 +10:00
unkinben c20e7e4664 Let the agents AppRole mint unkin-agent Gitea tokens (#114)
ci/woodpecker/push/apply Pipeline was successful
Why: AI coding agents authenticate to Gitea as Ben using Ben's token. With the unkin-agent identity now in place (terraform-git PR #59), the agents AppRole should issue that account's tokens directly so agent commits and PRs are attributable and carry only least-privilege scopes.

How:
- add a gitea secrets-engine role minting ephemeral tokens for unkin-agent scoped to write:repository, write:issue, read:user — push branches and open PRs, never merge or administer
- add a policy granting read on gitea/creds/unkin-agent, bound to the agents AppRole, mirroring the agent-* Kubernetes creds bindings

Depends on terraform-git PR #59: the unkin-agent Gitea account must exist before minted tokens work. The vault-plugin-secrets-gitea engine is already live (plugin v0.1.0 registered, gitea mount configured), so no engine/plugin change is needed here.

Reviewed-on: #114
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 23:41:53 +10:00
unkinben aac651a5e4 Grant terraform-infra kv metadata read (#113)
ci/woodpecker/push/apply Pipeline was successful
Follow-up to the merged #111 (which shipped `kv/data/service/terraform/infra` read only).

`terraform-infra`'s providers.tf uses a `vault_kv_secret_v2` **data source**, which reads the kv-v2 **metadata** path on every plan/apply (same behaviour that 403'd a prior terraform-git apply — see `policies/kv/service/vault/.../gitea/config_write.yaml`). Add `kv/metadata/service/terraform/infra` read so the plan doesn't 403 once the secret is seeded.

Verified against terraform-infra PR #5: `skip_child_token` cleared the child-token 403 and the data-read policy works (plan now reaches "no secret found"); metadata read is the remaining policy gap before a seeded plan can pass.

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #113
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-06 23:17:35 +10:00
unkinben 95927202ba Rename terraform-ipam CI Vault access -> terraform-infra (#111)
ci/woodpecker/push/apply Pipeline was successful
Follows the `terraform-ipam` -> `terraform-infra` repo rename. Renames the k8s auth role (`woodpecker_terraform_infra`), consul secret-backend role + ACL policy (`terraform-infra`, state path `infra/terraform/infra/*`), consul creds read policy, and kv read policy (`kv/service/terraform/infra`).

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #111
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-06 22:25:38 +10:00
32 changed files with 844 additions and 27 deletions
@@ -0,0 +1,7 @@
bound_service_account_names:
- ghp
bound_service_account_namespaces:
- ghp
token_ttl: 600
token_max_ttl: 600
audience: vault
@@ -1,5 +1,5 @@
bound_service_account_names:
- terraform-ipam
- terraform-infra
bound_service_account_namespaces:
- woodpecker
token_ttl: 600
+14
View File
@@ -252,5 +252,19 @@ locals {
})
if startswith(file_path, "gitea_secret_backend_role/")
}
netbox_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "netbox_secret_backend/")
}
netbox_secret_backend_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "netbox_secret_backend_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
netbox_username = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "netbox_secret_backend_role/", ""))
})
if startswith(file_path, "netbox_secret_backend_role/")
}
}
}
@@ -1,5 +1,5 @@
consul_roles:
- terraform-ipam
- terraform-infra
ttl: 120
max_ttl: 300
datacenters: []
@@ -0,0 +1,20 @@
# Role minting ephemeral tokens for the unkin-agent bot user -- the shared
# identity Ben's AI coding agents use to submit work. The agent clones/pushes
# code and opens pull requests, so it gets write on repositories (clone + push +
# PR create) and write on issues (PR/issue comments). Read is implied by write.
# No admin/org/user-write scopes, so it can never merge via API privilege; merge
# is blocked separately by branch protection (merge whitelist = Owners).
# read:user is required because tea (and most API clients) validate the login
# via GET /api/v1/user, which 403s without it.
# Reading gitea/creds/unkin-agent mints a lease-bound token deleted from Gitea
# on revoke/expiry. Consumed by the "agents" AppRole (see
# policies/gitea/creds/unkin-agent.yaml).
---
username: unkin-agent
scopes:
- write:repository
- write:issue
- read:user
token_name_prefix: vault-unkin-agent
ttl: 3600 # 1h
max_ttl: 14400 # 4h
+48
View File
@@ -0,0 +1,48 @@
# 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) the SINGLE static admin credential
#
# 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 adds the keyword
# itself. A prefixed value yields a malformed header + 403.
#
# 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.
#
# 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
# 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=<BARE_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
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"
@@ -0,0 +1,25 @@
# Single declarative source for the terraform-infra NetBox service identity. The
# filename stem is the engine role name AND the NetBox username (1:1); config.hcl
# derives both from it, so neither is repeated below. Creating this file creates
# the user: the netbox_user_management module synthesizes the NetBox user + object
# permissions from the permissions block, and the engine role mints ephemeral
# tokens for that same user. write_enabled true because terraform-infra manages
# NetBox IPAM/DCIM; very short TTLs because a token is minted per plan/apply and
# revoked when the run's lease ends.
---
write_enabled: true
ttl: 120 # 2m
max_ttl: 300 # 5m
permissions:
- object_types:
- ipam.prefix
- ipam.ipaddress
- ipam.iprange
- dcim.device
- dcim.interface
- dcim.macaddress
actions:
- view
- add
- change
- delete
@@ -0,0 +1,11 @@
# config/plugins/vault-plugin-secrets-netbox.yaml
# Imports (registers) the netbox secrets plugin in the catalog. Filename =
# catalog name = mount type. The binary is installed on the OpenBao nodes by
# Puppet (openbao-plugin-secrets-netbox RPM ->
# /opt/openbao-plugins/vault-plugin-secrets-netbox).
#
# sha256 pins the released v0.1.0 binary; bump it in lockstep with any RPM
# upgrade or OpenBao will refuse to launch the plugin.
type: secret
command: vault-plugin-secrets-netbox
sha256: "362b7f6c9e21179ad51d2d810684d9387fe50e3a1887f171700122a0b2a05cef"
+12
View File
@@ -39,6 +39,12 @@ locals {
for backend_name, _ in local.config.consul_secret_backend :
backend_name => replace(backend_name, "/", "_")
}
# Same sanitized alias mapping for the NetBox providers.
netbox_backend_aliases = {
for backend_name, _ in local.config.netbox_secret_backend :
backend_name => replace(backend_name, "/", "_")
}
}
terraform {
@@ -81,10 +87,16 @@ inputs = {
gitea_secret_backend = local.config.gitea_secret_backend
gitea_secret_backend_role = local.config.gitea_secret_backend_role
netbox_secret_backend = local.config.netbox_secret_backend
netbox_secret_backend_role = local.config.netbox_secret_backend_role
# Pass policy maps to vault_cluster module
policy_auth_map = local.policies.policy_auth_map
policy_rules_map = local.policies.policy_rules_map
# Pass sanitized consul backend aliases for provider configuration
consul_backend_aliases = local.consul_backend_aliases
# Pass sanitized netbox backend aliases for provider configuration
netbox_backend_aliases = local.netbox_backend_aliases
}
+80
View File
@@ -456,6 +456,86 @@ module "gitea_secret_backend_role" {
depends_on = [module.gitea_secret_backend]
}
module "netbox_secret_backend" {
source = "./modules/netbox_secret_backend"
for_each = var.netbox_secret_backend
path = each.key
plugin = each.value.plugin
description = each.value.description
netbox_url = each.value.netbox_url
token_version = each.value.token_version
country = var.country
region = var.region
ca_cert = each.value.ca_cert
tls_skip_verify = each.value.tls_skip_verify
request_timeout_seconds = each.value.request_timeout_seconds
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, 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"
country = var.country
region = var.region
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" {
source = "./modules/netbox_secret_backend_role"
for_each = var.netbox_secret_backend_role
backend = each.value.backend
name = each.value.name
netbox_username = each.value.netbox_username
netbox_user_id = each.value.netbox_user_id
write_enabled = each.value.write_enabled
description = each.value.description
ttl = each.value.ttl
max_ttl = each.value.max_ttl
depends_on = [module.netbox_secret_backend, module.netbox_user_management]
}
module "vault_policy" {
source = "./modules/vault_policy"
@@ -0,0 +1,53 @@
# Mounts the netbox secrets engine and writes its connection config via the
# vault-secrets-netbox provider. The plugin is registered ("imported") in the
# catalog separately (config/plugins/vault-plugin-secrets-netbox.yaml). The
# seeded NetBox admin token is sensitive and read from KV, not stored in git:
# kv/service/vault/<country>/<region>/secret_backend/<path>/config
# Expected key: admin_token (a NetBox token with add_token + grant_token, i.e.
# able to provision and delegate per-user API tokens).
data "vault_kv_secret_v2" "config" {
mount = "kv"
name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config"
lifecycle {
# 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 <token>" (v2), otherwise "Token <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"], "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, AND its version must match token_version: a v2 token (nbt_<key>.<secret>) requires token_version=2; a v1 token (bare 40-char value) requires token_version=1."
}
}
}
resource "netbox_secret_backend" "this" {
path = var.path
plugin = var.plugin
description = var.description
netbox_url = var.netbox_url
token = data.vault_kv_secret_v2.config.data["admin_token"]
token_version = var.token_version
ca_cert = var.ca_cert
tls_skip_verify = var.tls_skip_verify
request_timeout_seconds = var.request_timeout_seconds
lifecycle {
# The KV seed is a bootstrap credential: it is consumed only when the engine
# config is first created. After creation the live admin token is rotated in
# place (vault write -f netbox/config/rotate) and diverges from the seed, so
# re-reading the (possibly stale) KV value must never push it back. Ignoring
# the token makes this module create-only for it (mirrors gitea/config).
ignore_changes = [token]
}
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
netbox = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-netbox"
version = "0.1.0"
}
}
}
@@ -0,0 +1,55 @@
variable "path" {
description = "Mount path of the netbox secrets engine (e.g. \"netbox\")"
type = string
}
variable "plugin" {
description = "Registered plugin name to mount (the catalog name = mount type)"
type = string
default = "vault-plugin-secrets-netbox"
}
variable "description" {
description = "Human-friendly description of the mount"
type = string
default = null
}
variable "netbox_url" {
description = "Base URL of the NetBox server (e.g. https://netbox.k8s.syd1.au.unkin.net)"
type = string
}
variable "token_version" {
description = "NetBox API token format: 2 (default, requires API_TOKEN_PEPPERS on the NetBox server) or 1 (legacy plaintext-key)."
type = number
default = 2
}
variable "country" {
description = "Country segment of the KV path holding the seeded admin token"
type = string
}
variable "region" {
description = "Region segment of the KV path holding the seeded admin token"
type = string
}
variable "ca_cert" {
description = "PEM CA certificate that signed the NetBox server's TLS cert (optional; omit to use the system trust store)"
type = string
default = null
}
variable "tls_skip_verify" {
description = "Skip TLS verification of the NetBox server (not recommended)"
type = bool
default = false
}
variable "request_timeout_seconds" {
description = "HTTP timeout in seconds for calls from the plugin to NetBox"
type = number
default = 30
}
@@ -0,0 +1,13 @@
# A role that mints short-lived, scoped NetBox tokens for a pre-existing NetBox
# service user. Reading netbox/creds/<name> produces a lease-bound token that is
# deleted from NetBox when the lease is revoked or reaches max_ttl.
resource "netbox_secret_backend_role" "this" {
backend = var.backend
name = var.name
netbox_username = var.netbox_username
netbox_user_id = var.netbox_user_id
write_enabled = var.write_enabled
description = var.description
ttl = var.ttl
max_ttl = var.max_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
netbox = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-netbox"
version = "0.1.0"
}
}
}
@@ -0,0 +1,45 @@
variable "backend" {
description = "Mount path of the netbox secrets engine this role belongs to"
type = string
}
variable "name" {
description = "Role name (read netbox/creds/<name> to mint a token)"
type = string
}
variable "netbox_username" {
description = "NetBox service username the minted tokens belong to (set this or netbox_user_id)"
type = string
default = null
}
variable "netbox_user_id" {
description = "NetBox service user id the minted tokens belong to (set this or netbox_username)"
type = number
default = null
}
variable "write_enabled" {
description = "Whether minted tokens carry NetBox write access (default read-only)"
type = bool
default = false
}
variable "description" {
description = "Human-friendly description of the role"
type = string
default = null
}
variable "ttl" {
description = "Default lease TTL in seconds for minted tokens (the token's NetBox expiry is aligned to the lease)"
type = number
default = null
}
variable "max_ttl" {
description = "Maximum lease TTL in seconds for minted tokens"
type = number
default = null
}
@@ -0,0 +1,7 @@
rule "terraform_required_providers" {
enabled = false
}
rule "terraform_required_version" {
enabled = false
}
@@ -0,0 +1,149 @@
# 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)]
}
@@ -0,0 +1,19 @@
output "netbox_users" {
description = "Map of created NetBox users (id + username; password is intentionally omitted)"
value = {
for k, u in netbox_user.users : k => {
id = u.id
username = u.username
}
}
}
output "netbox_permissions" {
description = "Map of created NetBox object permissions"
value = {
for k, p in netbox_permission.perms : k => {
id = p.id
name = p.name
}
}
}
@@ -0,0 +1,17 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
netbox = {
source = "e-breuninger/netbox"
version = "4.3.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.5"
}
}
}
@@ -0,0 +1,46 @@
variable "netbox_backends" {
description = "Map of netbox secret backends (keyed by mount path); only the URL and TLS mode are needed to reach NetBox"
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)
}))
}
variable "netbox_roles" {
description = "Map of netbox engine roles (the netbox_secret_backend_role config). Each role's filename-derived name is the NetBox username to create, and its permissions block is the user's object-permission set. Keyed by the role's config path."
type = map(object({
name = string
backend = string
active = optional(bool, true)
staff = optional(bool, false)
email = optional(string)
permissions = optional(list(object({
name = optional(string)
object_types = list(string)
actions = optional(list(string), ["view", "add", "change", "delete"])
constraints = optional(string)
description = optional(string)
enabled = optional(bool, true)
})), [])
}))
default = {}
}
variable "netbox_backend_aliases" {
description = "Map of netbox backend names to sanitized provider aliases"
type = map(string)
default = {}
}
variable "country" {
description = "Country identifier"
type = string
}
variable "region" {
description = "Region identifier"
type = string
}
+52
View File
@@ -416,6 +416,58 @@ variable "gitea_secret_backend_role" {
default = {}
}
variable "netbox_secret_backend" {
description = "Map of netbox token secret engines to create (mount + config; seeded admin token read from KV)"
type = map(object({
plugin = optional(string, "vault-plugin-secrets-netbox")
description = optional(string)
netbox_url = string
token_version = optional(number, 2)
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 = {}
}
variable "netbox_secret_backend_role" {
description = "Map of netbox engine roles; each role's filename-derived name is both the engine role and the NetBox username it mints tokens for, and its permissions block is the user's object-permission set"
type = map(object({
name = string
backend = string
netbox_username = optional(string)
netbox_user_id = optional(number)
write_enabled = optional(bool, false)
description = optional(string)
ttl = optional(number)
max_ttl = optional(number)
active = optional(bool, true)
staff = optional(bool, false)
email = optional(string)
permissions = optional(list(object({
name = optional(string)
object_types = list(string)
actions = optional(list(string), ["view", "add", "change", "delete"])
constraints = optional(string)
description = optional(string)
enabled = optional(bool, true)
})), [])
}))
default = {}
}
variable "netbox_backend_aliases" {
description = "Map of netbox backend names to sanitized provider aliases"
type = map(string)
default = {}
}
variable "policy_auth_map" {
description = "Map of auth mounts -> auth roles -> policy names"
type = map(map(list(string)))
@@ -0,0 +1,11 @@
---
rules:
- path: "consul_root/au/syd1/creds/terraform-infra"
capabilities:
- read
auth:
approle:
- terraform_infra
k8s/au/syd1:
- woodpecker_terraform_infra
@@ -1,11 +0,0 @@
---
rules:
- path: "consul_root/au/syd1/creds/terraform-ipam"
capabilities:
- read
auth:
approle:
- terraform_ipam
k8s/au/syd1:
- woodpecker_terraform_ipam
+14
View File
@@ -0,0 +1,14 @@
# Lets the agents AppRole mint ephemeral Gitea tokens for the unkin-agent bot,
# so AI coding agents authenticate to git.unkin.net as their own least-privilege
# identity instead of Ben's account. Reading gitea/creds/unkin-agent returns a
# lease-bound token scoped by the role (write:repository, write:issue, read:user
# -- never merge/admin). Mirrors the agent-* Kubernetes creds binding pattern.
---
rules:
- path: "gitea/creds/unkin-agent"
capabilities:
- read
auth:
approle:
- agents
+17
View File
@@ -0,0 +1,17 @@
# Allow ghp to read its GitHub App credentials and encryption key
#
# kv/kubernetes/ghp/github-app (app_id/client_id/client_secret/private_key)
# kv/kubernetes/ghp/app (encryption_key)
---
rules:
- path: "kv/data/kubernetes/ghp/*"
capabilities:
- read
- path: "kv/metadata/kubernetes/ghp/*"
capabilities:
- read
- list
auth:
k8s/au/syd1:
- ghp
+18
View File
@@ -0,0 +1,18 @@
# Allow the terraform-infra runner to read the NetBox + KeaAPI tokens
# (netbox_token / kea_token fields) used by the netbox and kea providers.
---
rules:
- path: "kv/data/service/terraform/infra"
capabilities:
- read
# vault_kv_secret_v2 (terraform-infra providers.tf data source) reads the kv-v2
# metadata path on every plan/apply; a 403 here fails the plan.
- path: "kv/metadata/service/terraform/infra"
capabilities:
- read
auth:
approle:
- terraform_infra
k8s/au/syd1:
- woodpecker_terraform_infra
-13
View File
@@ -1,13 +0,0 @@
# Allow the Terraform IPAM runner to read the NetBox + KeaAPI tokens
# (netbox_token / kea_token fields) used by the netbox and kea providers.
---
rules:
- path: "kv/data/service/terraform/ipam"
capabilities:
- read
auth:
approle:
- terraform_ipam
k8s/au/syd1:
- woodpecker_terraform_ipam
+42
View File
@@ -0,0 +1,42 @@
# Allow the vault deployer to manage the netbox token secrets engine: its
# connection config (seeded admin token), in-place token rotation, and
# token-minting roles.
#
# Scoped to netbox/* only, and deliberately excludes netbox/creds/* - minting
# tokens is for consumers, not the deployer. The plugin-catalog grant needed to
# import the plugin is the shared, sudo-protected wildcard in
# policies/sys/plugins/catalog/admin.yaml (already covers this plugin), and
# mounting the engine uses the deployer's existing sys/mounts/* access, so no
# new catalog/mount grant is added here (mirrors the gitea/rancher engines).
---
rules:
# Engine connection config (NetBox URL, TLS, token_version, seeded admin token).
- path: "netbox/config"
capabilities:
- create
- read
- update
- delete
# In-place rotation of the seeded admin token (write-only trigger).
- path: "netbox/config/rotate"
capabilities:
- create
- update
# Token-minting roles.
- path: "netbox/roles/*"
capabilities:
- create
- read
- update
- delete
- list
- path: "netbox/roles"
capabilities:
- read
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -0,0 +1,21 @@
# Allow the terraform-infra runner to mint an ephemeral NetBox token from the
# terraform-infra role (netbox/creds/terraform-infra), replacing the static
# netbox_token it used to read from kv/service/terraform/*. The e-breuninger
# netbox provider authenticates with the minted token; the lease revokes it when
# the run ends.
#
# Bound to both the terraform-infra AppRole and its Woodpecker k8s auth role,
# mirroring the terraform-ipam pattern. Both principals are created by the
# terraform-infra Vault onboarding (separate from this netbox change); until
# that onboarding lands this policy exists but attaches to nothing.
---
rules:
- path: "netbox/creds/terraform-infra"
capabilities:
- read
auth:
approle:
- terraform_infra
k8s/au/syd1:
- woodpecker_terraform_infra
@@ -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
@@ -1,4 +1,4 @@
key_prefix "infra/terraform/ipam/" {
key_prefix "infra/terraform/infra/" {
policy = "write"
}