From 521ef4f0f377bf9a835871e078be958eb6e7ff79 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 9 Aug 2026 16:36:18 +1000 Subject: [PATCH] Add the netbox secrets engine modules and wiring (#115) ## 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 Reviewed-on: https://git.unkin.net/unkin/terraform-vault/pulls/115 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- config/config.hcl | 14 ++++ environments/au/syd1/terragrunt.hcl | 12 +++ modules/vault_cluster/main.tf | 50 ++++++++++++ .../modules/netbox_secret_backend/main.tf | 32 ++++++++ .../netbox_secret_backend/terraform.tf | 13 +++ .../netbox_secret_backend/variables.tf | 55 +++++++++++++ .../netbox_secret_backend_role/main.tf | 13 +++ .../netbox_secret_backend_role/terraform.tf | 9 +++ .../netbox_secret_backend_role/variables.tf | 45 +++++++++++ .../netbox_user_management/.tflint.hcl | 7 ++ .../modules/netbox_user_management/main.tf | 81 +++++++++++++++++++ .../modules/netbox_user_management/outputs.tf | 19 +++++ .../netbox_user_management/terraform.tf | 17 ++++ .../netbox_user_management/variables.tf | 43 ++++++++++ modules/vault_cluster/variables.tf | 46 +++++++++++ 15 files changed, 456 insertions(+) create mode 100644 modules/vault_cluster/modules/netbox_secret_backend/main.tf create mode 100644 modules/vault_cluster/modules/netbox_secret_backend/terraform.tf create mode 100644 modules/vault_cluster/modules/netbox_secret_backend/variables.tf create mode 100644 modules/vault_cluster/modules/netbox_secret_backend_role/main.tf create mode 100644 modules/vault_cluster/modules/netbox_secret_backend_role/terraform.tf create mode 100644 modules/vault_cluster/modules/netbox_secret_backend_role/variables.tf create mode 100644 modules/vault_cluster/modules/netbox_user_management/.tflint.hcl create mode 100644 modules/vault_cluster/modules/netbox_user_management/main.tf create mode 100644 modules/vault_cluster/modules/netbox_user_management/outputs.tf create mode 100644 modules/vault_cluster/modules/netbox_user_management/terraform.tf create mode 100644 modules/vault_cluster/modules/netbox_user_management/variables.tf diff --git a/config/config.hcl b/config/config.hcl index 19b655d..cb6193f 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -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/") + } } } diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 98a9415..44b3338 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -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 } diff --git a/modules/vault_cluster/main.tf b/modules/vault_cluster/main.tf index e7851bb..bfd7e0d 100644 --- a/modules/vault_cluster/main.tf +++ b/modules/vault_cluster/main.tf @@ -456,6 +456,56 @@ 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] +} + +# 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. +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 +} + +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" diff --git a/modules/vault_cluster/modules/netbox_secret_backend/main.tf b/modules/vault_cluster/modules/netbox_secret_backend/main.tf new file mode 100644 index 0000000..19c99b0 --- /dev/null +++ b/modules/vault_cluster/modules/netbox_secret_backend/main.tf @@ -0,0 +1,32 @@ +# 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///secret_backend//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" +} + +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] + } +} diff --git a/modules/vault_cluster/modules/netbox_secret_backend/terraform.tf b/modules/vault_cluster/modules/netbox_secret_backend/terraform.tf new file mode 100644 index 0000000..4f336fd --- /dev/null +++ b/modules/vault_cluster/modules/netbox_secret_backend/terraform.tf @@ -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" + } + } +} diff --git a/modules/vault_cluster/modules/netbox_secret_backend/variables.tf b/modules/vault_cluster/modules/netbox_secret_backend/variables.tf new file mode 100644 index 0000000..c830f68 --- /dev/null +++ b/modules/vault_cluster/modules/netbox_secret_backend/variables.tf @@ -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 +} diff --git a/modules/vault_cluster/modules/netbox_secret_backend_role/main.tf b/modules/vault_cluster/modules/netbox_secret_backend_role/main.tf new file mode 100644 index 0000000..59246f3 --- /dev/null +++ b/modules/vault_cluster/modules/netbox_secret_backend_role/main.tf @@ -0,0 +1,13 @@ +# A role that mints short-lived, scoped NetBox tokens for a pre-existing NetBox +# service user. Reading netbox/creds/ 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 +} diff --git a/modules/vault_cluster/modules/netbox_secret_backend_role/terraform.tf b/modules/vault_cluster/modules/netbox_secret_backend_role/terraform.tf new file mode 100644 index 0000000..d86a21f --- /dev/null +++ b/modules/vault_cluster/modules/netbox_secret_backend_role/terraform.tf @@ -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" + } + } +} diff --git a/modules/vault_cluster/modules/netbox_secret_backend_role/variables.tf b/modules/vault_cluster/modules/netbox_secret_backend_role/variables.tf new file mode 100644 index 0000000..da03cbd --- /dev/null +++ b/modules/vault_cluster/modules/netbox_secret_backend_role/variables.tf @@ -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/ 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 +} diff --git a/modules/vault_cluster/modules/netbox_user_management/.tflint.hcl b/modules/vault_cluster/modules/netbox_user_management/.tflint.hcl new file mode 100644 index 0000000..3657e1d --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/.tflint.hcl @@ -0,0 +1,7 @@ +rule "terraform_required_providers" { + enabled = false +} + +rule "terraform_required_version" { + enabled = false +} diff --git a/modules/vault_cluster/modules/netbox_user_management/main.tf b/modules/vault_cluster/modules/netbox_user_management/main.tf new file mode 100644 index 0000000..a5692d0 --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/main.tf @@ -0,0 +1,81 @@ +# 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. +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" +} + +# One NetBox provider instance per backend, authenticated with its admin token. +provider "netbox" { + alias = "by_backend" + 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"] + 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 ":". 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)] +} diff --git a/modules/vault_cluster/modules/netbox_user_management/outputs.tf b/modules/vault_cluster/modules/netbox_user_management/outputs.tf new file mode 100644 index 0000000..d7f4472 --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/outputs.tf @@ -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 + } + } +} diff --git a/modules/vault_cluster/modules/netbox_user_management/terraform.tf b/modules/vault_cluster/modules/netbox_user_management/terraform.tf new file mode 100644 index 0000000..ee20fc0 --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/terraform.tf @@ -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" + } + } +} diff --git a/modules/vault_cluster/modules/netbox_user_management/variables.tf b/modules/vault_cluster/modules/netbox_user_management/variables.tf new file mode 100644 index 0000000..051907c --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/variables.tf @@ -0,0 +1,43 @@ +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) + })) +} + +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 +} diff --git a/modules/vault_cluster/variables.tf b/modules/vault_cluster/variables.tf index 87787f5..be7b04d 100644 --- a/modules/vault_cluster/variables.tf +++ b/modules/vault_cluster/variables.tf @@ -416,6 +416,52 @@ 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) + })) + 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)))