diff --git a/config/config.hcl b/config/config.hcl index 5566cfb..ef787cb 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -265,5 +265,10 @@ locals { }) if startswith(file_path, "netbox_secret_backend_role/") } + netbox_user = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "netbox_user/") + } } } diff --git a/config/netbox_secret_backend_role/netbox/terraform-infra.yaml b/config/netbox_secret_backend_role/netbox/terraform-infra.yaml index fb61903..06074a5 100644 --- a/config/netbox_secret_backend_role/netbox/terraform-infra.yaml +++ b/config/netbox_secret_backend_role/netbox/terraform-infra.yaml @@ -4,7 +4,7 @@ # 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 +netbox_username: terraform-infra write_enabled: true ttl: 120 # 2m max_ttl: 300 # 5m diff --git a/config/netbox_user/terraform-infra.yaml b/config/netbox_user/terraform-infra.yaml new file mode 100644 index 0000000..4661870 --- /dev/null +++ b/config/netbox_user/terraform-infra.yaml @@ -0,0 +1,31 @@ +# Declarative NetBox service identity for the terraform-infra CI runner. The +# filename stem is the NetBox username and matches the netbox engine role name +# 1:1 (netbox/roles/terraform-infra mints tokens for this user). Ben seeds only +# the engine admin token; this user and its permissions are created from here, +# never by hand. Write access covers the IPAM/DCIM objects terraform-infra +# manages (prefixes, ip-addresses, ip-ranges, devices, interfaces, mac +# addresses, plus the supporting role/tag/type objects it also touches). +--- +backend: netbox +active: true +staff: false +permissions: + - name: terraform-infra + description: terraform-infra IPAM/DCIM write access (tokens minted by Vault) + object_types: + - ipam.prefix + - ipam.ipaddress + - ipam.iprange + - ipam.role + - dcim.device + - dcim.interface + - dcim.macaddress + - dcim.manufacturer + - dcim.devicetype + - dcim.devicerole + - extras.tag + actions: + - view + - add + - change + - delete diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 310d91a..0e2a05b 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 { @@ -83,6 +89,7 @@ inputs = { netbox_secret_backend = local.config.netbox_secret_backend netbox_secret_backend_role = local.config.netbox_secret_backend_role + netbox_user = local.config.netbox_user # Pass policy maps to vault_cluster module policy_auth_map = local.policies.policy_auth_map @@ -90,4 +97,7 @@ inputs = { # 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 98bb0b8..4953d39 100644 --- a/modules/vault_cluster/main.tf +++ b/modules/vault_cluster/main.tf @@ -475,6 +475,18 @@ module "netbox_secret_backend" { 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). +module "netbox_user_management" { + source = "./modules/netbox_user_management" + + country = var.country + region = var.region + netbox_backends = var.netbox_secret_backend + netbox_users = var.netbox_user + netbox_backend_aliases = var.netbox_backend_aliases +} + module "netbox_secret_backend_role" { source = "./modules/netbox_secret_backend_role" @@ -489,7 +501,7 @@ module "netbox_secret_backend_role" { ttl = each.value.ttl max_ttl = each.value.max_ttl - depends_on = [module.netbox_secret_backend] + depends_on = [module.netbox_secret_backend, module.netbox_user_management] } module "vault_policy" { 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..7142d2f --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/main.tf @@ -0,0 +1,78 @@ +# 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_users + + length = 32 + special = true +} + +# Declarative NetBox service users, one per engine role (username == role name). +resource "netbox_user" "users" { + for_each = var.netbox_users + + provider = netbox.by_backend[each.value.backend] + + username = each.key + password = random_password.user[each.key].result + active = each.value.active + staff = each.value.staff + email = each.value.email +} + +locals { + # Flatten users x permissions into one map keyed by ":". + netbox_permissions = merge([ + for username, user in var.netbox_users : { + for perm in user.permissions : + "${username}:${perm.name}" => { + backend = user.backend + user = username + name = perm.name + 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..ecd8e5c --- /dev/null +++ b/modules/vault_cluster/modules/netbox_user_management/variables.tf @@ -0,0 +1,42 @@ +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_users" { + description = "Map of NetBox service users to create declaratively, keyed by username (1:1 with the engine role name)" + type = map(object({ + backend = string + active = optional(bool, true) + staff = optional(bool, false) + email = optional(string) + permissions = optional(list(object({ + name = 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 7005a02..3d89fa6 100644 --- a/modules/vault_cluster/variables.tf +++ b/modules/vault_cluster/variables.tf @@ -445,6 +445,31 @@ variable "netbox_secret_backend_role" { default = {} } +variable "netbox_user" { + description = "Map of NetBox service users to create declaratively (keyed by username; 1:1 with the engine role name)" + type = map(object({ + backend = string + active = optional(bool, true) + staff = optional(bool, false) + email = optional(string) + permissions = optional(list(object({ + name = 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)))