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)))