diff --git a/config/config.hcl b/config/config.hcl index cb6193f..f675790 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -266,5 +266,18 @@ locals { }) if startswith(file_path, "netbox_secret_backend_role/") } + ghp_secret_backend = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "ghp_secret_backend/") + } + ghp_secret_backend_role = { + for file_path, content in local.all_configs : + trimsuffix(replace(file_path, "ghp_secret_backend_role/", ""), ".yaml") => merge(content, { + name = trimsuffix(basename(file_path), ".yaml") + backend = dirname(replace(file_path, "ghp_secret_backend_role/", "")) + }) + if startswith(file_path, "ghp_secret_backend_role/") + } } } diff --git a/config/ghp_secret_backend/ghp.yaml b/config/ghp_secret_backend/ghp.yaml new file mode 100644 index 0000000..ecff20b --- /dev/null +++ b/config/ghp_secret_backend/ghp.yaml @@ -0,0 +1,15 @@ +# Mounts the ghp token secrets engine at "ghp" and writes its config. +# The seeded ghp service token is sensitive and read from KV, not stored here: +# kv/service/vault/au/syd1/secret_backend/ghp/config +# -> key: admin_token (required) the shared ghpsvc_... service token +# +# admin_token is a static shared secret provisioned into KV by an operator. The +# SAME token value must also be present in the running ghp deployment's accepted +# service tokens (GHP_AUTH_SERVICE_TOKENS) so ghp ACCEPTS what this engine +# PRESENTS. ghp has no rotate endpoint, so the engine never rotates it in place; +# the mount uses ignore_changes=[admin_token], making the KV seed create-only +# (re-reading a stale KV value never re-pushes it to a live mount). +description: "ghp ephemeral scoped agent token engine" +base_url: "https://ghp.unkin.net" +tls_skip_verify: false +request_timeout_seconds: 30 diff --git a/config/ghp_secret_backend_role/ghp/agent.yaml b/config/ghp_secret_backend_role/ghp/agent.yaml new file mode 100644 index 0000000..b5653c7 --- /dev/null +++ b/config/ghp_secret_backend_role/ghp/agent.yaml @@ -0,0 +1,15 @@ +# Role minting ephemeral, scoped ghp agent tokens. Reading ghp/creds/agent mints +# a lease-bound token deleted from ghp on revoke/expiry. token_type "agent" binds +# the minted token to a ghp App installation, so installation_id is REQUIRED. +# +# installation_id below is a PLACEHOLDER (0) and MUST be set to the real ghp App +# installation id before this role can mint usable tokens. scopes are ghp +# permission:level pairs; contents:read is the least-privilege default. +--- +token_type: agent +installation_id: 0 # PLACEHOLDER - set to the real ghp App installation id +scopes: + - contents:read +session_prefix: vault +ttl: 3600 # 1h +max_ttl: 86400 # 24h diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 44b3338..d12385e 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -90,6 +90,9 @@ inputs = { netbox_secret_backend = local.config.netbox_secret_backend netbox_secret_backend_role = local.config.netbox_secret_backend_role + ghp_secret_backend = local.config.ghp_secret_backend + ghp_secret_backend_role = local.config.ghp_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 459a4f5..a910dbd 100644 --- a/modules/vault_cluster/main.tf +++ b/modules/vault_cluster/main.tf @@ -536,6 +536,43 @@ module "netbox_secret_backend_role" { depends_on = [module.netbox_secret_backend, module.netbox_user_management] } +module "ghp_secret_backend" { + source = "./modules/ghp_secret_backend" + + for_each = var.ghp_secret_backend + + path = each.key + plugin = each.value.plugin + description = each.value.description + base_url = each.value.base_url + 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 "ghp_secret_backend_role" { + source = "./modules/ghp_secret_backend_role" + + for_each = var.ghp_secret_backend_role + + backend = each.value.backend + name = each.value.name + token_type = each.value.token_type + installation_id = each.value.installation_id + app_record_id = each.value.app_record_id + repositories = each.value.repositories + scopes = each.value.scopes + session_prefix = each.value.session_prefix + ttl = each.value.ttl + max_ttl = each.value.max_ttl + + depends_on = [module.ghp_secret_backend] +} + module "vault_policy" { source = "./modules/vault_policy" diff --git a/modules/vault_cluster/modules/ghp_secret_backend/main.tf b/modules/vault_cluster/modules/ghp_secret_backend/main.tf new file mode 100644 index 0000000..7ea3a17 --- /dev/null +++ b/modules/vault_cluster/modules/ghp_secret_backend/main.tf @@ -0,0 +1,31 @@ +# Mounts the ghp secrets engine and writes its connection config via the +# vault-secrets-ghp provider. The plugin is registered ("imported") in the +# catalog separately (config/plugins/vault-plugin-secrets-ghp.yaml). The seeded +# ghp service token is sensitive and read from KV, not stored in git: +# kv/service/vault///secret_backend//config +# Expected key: admin_token (a ghpsvc_... service token that ghp accepts via its +# GHP_AUTH_SERVICE_TOKENS list). ghp has no rotate endpoint, so this static +# shared secret is the single credential the engine authenticates with. +data "vault_kv_secret_v2" "config" { + mount = "kv" + name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config" +} + +resource "ghp_secret_backend" "this" { + path = var.path + plugin = var.plugin + description = var.description + base_url = var.base_url + admin_token = data.vault_kv_secret_v2.config.data["admin_token"] + 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 consumed only when the engine config + # is first created. ghp has no rotate endpoint, so re-reading a (possibly + # stale) KV value must never re-push it into the live mount. Ignoring the + # token makes this module create-only for it (mirrors gitea/netbox config). + ignore_changes = [admin_token] + } +} diff --git a/modules/vault_cluster/modules/ghp_secret_backend/terraform.tf b/modules/vault_cluster/modules/ghp_secret_backend/terraform.tf new file mode 100644 index 0000000..aaa5c5a --- /dev/null +++ b/modules/vault_cluster/modules/ghp_secret_backend/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.10" + required_providers { + vault = { + source = "hashicorp/vault" + version = "5.6.0" + } + ghp = { + source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-ghp" + version = "0.1.0" + } + } +} diff --git a/modules/vault_cluster/modules/ghp_secret_backend/variables.tf b/modules/vault_cluster/modules/ghp_secret_backend/variables.tf new file mode 100644 index 0000000..a243d28 --- /dev/null +++ b/modules/vault_cluster/modules/ghp_secret_backend/variables.tf @@ -0,0 +1,49 @@ +variable "path" { + description = "Mount path of the ghp secrets engine (e.g. \"ghp\")" + type = string +} + +variable "plugin" { + description = "Registered plugin name to mount (the catalog name = mount type)" + type = string + default = "vault-plugin-secrets-ghp" +} + +variable "description" { + description = "Human-friendly description of the mount" + type = string + default = null +} + +variable "base_url" { + description = "Base URL of the ghp server (e.g. https://ghp.unkin.net)" + type = string +} + +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 ghp 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 ghp server (not recommended)" + type = bool + default = false +} + +variable "request_timeout_seconds" { + description = "HTTP timeout in seconds for calls from the plugin to ghp" + type = number + default = 30 +} diff --git a/modules/vault_cluster/modules/ghp_secret_backend_role/main.tf b/modules/vault_cluster/modules/ghp_secret_backend_role/main.tf new file mode 100644 index 0000000..11f9f90 --- /dev/null +++ b/modules/vault_cluster/modules/ghp_secret_backend_role/main.tf @@ -0,0 +1,15 @@ +# A role that mints short-lived, scoped ghp tokens. Reading ghp/creds/ +# produces a lease-bound token that is revoked from ghp when the lease is +# revoked or reaches max_ttl. +resource "ghp_secret_role" "this" { + backend = var.backend + name = var.name + token_type = var.token_type + installation_id = var.installation_id + app_record_id = var.app_record_id + repositories = var.repositories + scopes = var.scopes + session_prefix = var.session_prefix + ttl = var.ttl + max_ttl = var.max_ttl +} diff --git a/modules/vault_cluster/modules/ghp_secret_backend_role/terraform.tf b/modules/vault_cluster/modules/ghp_secret_backend_role/terraform.tf new file mode 100644 index 0000000..50ab0e9 --- /dev/null +++ b/modules/vault_cluster/modules/ghp_secret_backend_role/terraform.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.10" + required_providers { + ghp = { + source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-ghp" + version = "0.1.0" + } + } +} diff --git a/modules/vault_cluster/modules/ghp_secret_backend_role/variables.tf b/modules/vault_cluster/modules/ghp_secret_backend_role/variables.tf new file mode 100644 index 0000000..1b29000 --- /dev/null +++ b/modules/vault_cluster/modules/ghp_secret_backend_role/variables.tf @@ -0,0 +1,57 @@ +variable "backend" { + description = "Mount path of the ghp secrets engine this role belongs to" + type = string +} + +variable "name" { + description = "Role name (read ghp/creds/ to mint a token)" + type = string +} + +variable "token_type" { + description = "ghp token type to mint: \"agent\" (default) or \"proxy\"" + type = string + default = null +} + +variable "installation_id" { + description = "ghp App installation id the minted agent token is bound to (required when token_type is \"agent\")" + type = number + default = null +} + +variable "app_record_id" { + description = "Optional ghp App record id (UUID) to pin agent tokens to; empty selects ghp's default app" + type = string + default = null +} + +variable "repositories" { + description = "Optional repositories the minted token is restricted to; empty is open-scoped (all repositories)" + type = list(string) + default = null +} + +variable "scopes" { + description = "Optional ghp permission:level scopes (e.g. [\"contents:read\"]); empty is open-scoped" + type = list(string) + default = null +} + +variable "session_prefix" { + description = "Prefix for the ghp session id of each minted token (default \"vault\")" + type = string + default = null +} + +variable "ttl" { + description = "Default lease TTL in seconds for minted tokens" + 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 01814f8..ff53cf8 100644 --- a/modules/vault_cluster/variables.tf +++ b/modules/vault_cluster/variables.tf @@ -468,6 +468,36 @@ variable "netbox_backend_aliases" { default = {} } +variable "ghp_secret_backend" { + description = "Map of ghp token secret engines to create (mount + config; seeded service token read from KV)" + type = map(object({ + plugin = optional(string, "vault-plugin-secrets-ghp") + description = optional(string) + base_url = string + ca_cert = optional(string) + tls_skip_verify = optional(bool, false) + request_timeout_seconds = optional(number, 30) + })) + default = {} +} + +variable "ghp_secret_backend_role" { + description = "Map of ghp engine roles; reading ghp/creds/ mints a short-lived scoped ghp token" + type = map(object({ + name = string + backend = string + token_type = optional(string) + installation_id = optional(number) + app_record_id = optional(string) + repositories = optional(list(string)) + scopes = optional(list(string)) + session_prefix = 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)))