diff --git a/config/config.hcl b/config/config.hcl index 19b655d..5566cfb 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -252,5 +252,18 @@ 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") + backend = dirname(replace(file_path, "netbox_secret_backend_role/", "")) + }) + if startswith(file_path, "netbox_secret_backend_role/") + } } } diff --git a/config/netbox_secret_backend/netbox.yaml b/config/netbox_secret_backend/netbox.yaml new file mode 100644 index 0000000..4e22f42 --- /dev/null +++ b/config/netbox_secret_backend/netbox.yaml @@ -0,0 +1,16 @@ +# 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) +# Populate that KV path 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. +# +# 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. +description: "NetBox ephemeral scoped API token engine" +netbox_url: "https://netbox.k8s.syd1.au.unkin.net" +token_version: 2 +request_timeout_seconds: 30 diff --git a/config/netbox_secret_backend_role/netbox/puppet-facts.yaml b/config/netbox_secret_backend_role/netbox/puppet-facts.yaml new file mode 100644 index 0000000..7b4b014 --- /dev/null +++ b/config/netbox_secret_backend_role/netbox/puppet-facts.yaml @@ -0,0 +1,16 @@ +# Role minting ephemeral read-only NetBox tokens for the puppet netbox fact. +# The fact (profiles::netbox::facts) only GETs this node's device/VM + interface +# data, so tokens are read-only (write_enabled false). Longer TTLs than the +# terraform-infra role because the token feeds a persistent, cache-backed fact. +# Reading netbox/creds/puppet-facts mints a lease-bound token deleted from +# NetBox on revoke/expiry. +# +# There is no puppet-host -> Vault auth in the estate, so no consumer policy +# binds netbox/creds/puppet-facts: an operator mints from this role by hand and +# seeds the token into hieradata (profiles::netbox::facts::api_token). See the +# PR body for the flagged design choice. +--- +netbox_username: svc-puppet-facts +write_enabled: false +ttl: 86400 # 24h +max_ttl: 259200 # 72h diff --git a/config/netbox_secret_backend_role/netbox/terraform-infra.yaml b/config/netbox_secret_backend_role/netbox/terraform-infra.yaml new file mode 100644 index 0000000..880d607 --- /dev/null +++ b/config/netbox_secret_backend_role/netbox/terraform-infra.yaml @@ -0,0 +1,10 @@ +# Role minting ephemeral NetBox tokens for the terraform-infra CI runner. +# terraform-infra manages NetBox IPAM/devices, so tokens carry write access +# (write_enabled true). Short TTLs because a token is minted per plan/apply and +# revoked when the run's lease ends. Reading netbox/creds/terraform-infra mints +# a lease-bound token deleted from NetBox on revoke/expiry. +--- +netbox_username: svc-terraform-infra +write_enabled: true +ttl: 3600 # 1h +max_ttl: 14400 # 4h diff --git a/config/plugins/vault-plugin-secrets-netbox.yaml b/config/plugins/vault-plugin-secrets-netbox.yaml new file mode 100644 index 0000000..9ff80c1 --- /dev/null +++ b/config/plugins/vault-plugin-secrets-netbox.yaml @@ -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" diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 98a9415..310d91a 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -81,6 +81,9 @@ 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 diff --git a/modules/vault_cluster/main.tf b/modules/vault_cluster/main.tf index e7851bb..98bb0b8 100644 --- a/modules/vault_cluster/main.tf +++ b/modules/vault_cluster/main.tf @@ -456,6 +456,42 @@ 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] +} + +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 "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/variables.tf b/modules/vault_cluster/variables.tf index 87787f5..7005a02 100644 --- a/modules/vault_cluster/variables.tf +++ b/modules/vault_cluster/variables.tf @@ -416,6 +416,35 @@ 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 token-minting roles to create" + 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) + })) + default = {} +} + variable "policy_auth_map" { description = "Map of auth mounts -> auth roles -> policy names" type = map(map(list(string))) diff --git a/policies/netbox/admin.yaml b/policies/netbox/admin.yaml new file mode 100644 index 0000000..9a0cc6c --- /dev/null +++ b/policies/netbox/admin.yaml @@ -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 diff --git a/policies/netbox/creds/terraform-infra.yaml b/policies/netbox/creds/terraform-infra.yaml new file mode 100644 index 0000000..d7ff814 --- /dev/null +++ b/policies/netbox/creds/terraform-infra.yaml @@ -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