From 7eca2458ab2ed3f335e24bffbeb15f3ec502937f Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 16 Aug 2026 00:12:43 +1000 Subject: [PATCH 1/3] vault: wire up ghp secrets engine (backend + role + policies) Registers the vault-plugin-secrets-ghp catalog entry, mounts the ghp engine, defines an agent token-minting role, and grants the deployer + agents AppRole the matching policies. Modeled on the netbox engine, dropping the rotate/user-management machinery ghp does not have. --- config/config.hcl | 13 +++++ config/ghp_secret_backend/ghp.yaml | 15 +++++ config/ghp_secret_backend_role/ghp/agent.yaml | 15 +++++ config/plugins/vault-plugin-secrets-ghp.yaml | 11 ++++ environments/au/syd1/terragrunt.hcl | 3 + modules/vault_cluster/main.tf | 37 ++++++++++++ .../modules/ghp_secret_backend/main.tf | 31 ++++++++++ .../modules/ghp_secret_backend/terraform.tf | 13 +++++ .../modules/ghp_secret_backend/variables.tf | 49 ++++++++++++++++ .../modules/ghp_secret_backend_role/main.tf | 15 +++++ .../ghp_secret_backend_role/terraform.tf | 9 +++ .../ghp_secret_backend_role/variables.tf | 57 +++++++++++++++++++ modules/vault_cluster/variables.tf | 30 ++++++++++ policies/ghp/admin.yaml | 37 ++++++++++++ policies/ghp/creds/agent.yaml | 13 +++++ 15 files changed, 348 insertions(+) create mode 100644 config/ghp_secret_backend/ghp.yaml create mode 100644 config/ghp_secret_backend_role/ghp/agent.yaml create mode 100644 config/plugins/vault-plugin-secrets-ghp.yaml create mode 100644 modules/vault_cluster/modules/ghp_secret_backend/main.tf create mode 100644 modules/vault_cluster/modules/ghp_secret_backend/terraform.tf create mode 100644 modules/vault_cluster/modules/ghp_secret_backend/variables.tf create mode 100644 modules/vault_cluster/modules/ghp_secret_backend_role/main.tf create mode 100644 modules/vault_cluster/modules/ghp_secret_backend_role/terraform.tf create mode 100644 modules/vault_cluster/modules/ghp_secret_backend_role/variables.tf create mode 100644 policies/ghp/admin.yaml create mode 100644 policies/ghp/creds/agent.yaml 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/config/plugins/vault-plugin-secrets-ghp.yaml b/config/plugins/vault-plugin-secrets-ghp.yaml new file mode 100644 index 0000000..b8ea4cd --- /dev/null +++ b/config/plugins/vault-plugin-secrets-ghp.yaml @@ -0,0 +1,11 @@ +# config/plugins/vault-plugin-secrets-ghp.yaml +# Imports (registers) the ghp secrets plugin in the catalog. Filename = +# catalog name = mount type. The binary is installed on the OpenBao nodes by +# Puppet (openbao-plugin-secrets-ghp RPM -> +# /opt/openbao-plugins/vault-plugin-secrets-ghp). +# +# 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-ghp +sha256: "85761421cd532788ed28fb57e93d3868f3577320a538289936d9ed3be5f396de" 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))) diff --git a/policies/ghp/admin.yaml b/policies/ghp/admin.yaml new file mode 100644 index 0000000..58ea65f --- /dev/null +++ b/policies/ghp/admin.yaml @@ -0,0 +1,37 @@ +# Allow the vault deployer to manage the ghp token secrets engine: its +# connection config (seeded service token) and its token-minting roles. +# +# Scoped to ghp/* only, and deliberately excludes ghp/creds/* - minting tokens +# is for consumers, not the deployer. ghp has NO rotate endpoint, so unlike the +# gitea/netbox engines there is no config/rotate grant here. 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/netbox engines). +--- +rules: + # Engine connection config (base_url, TLS, seeded service token). + - path: "ghp/config" + capabilities: + - create + - read + - update + - delete + # Token-minting roles. + - path: "ghp/roles/*" + capabilities: + - create + - read + - update + - delete + - list + - path: "ghp/roles" + capabilities: + - read + - list + +auth: + approle: + - tf_vault + k8s/au/syd1: + - woodpecker_terraform_vault diff --git a/policies/ghp/creds/agent.yaml b/policies/ghp/creds/agent.yaml new file mode 100644 index 0000000..fb99925 --- /dev/null +++ b/policies/ghp/creds/agent.yaml @@ -0,0 +1,13 @@ +# Lets the agents AppRole mint ephemeral ghp agent tokens, so AI coding agents +# authenticate to ghp as their own short-lived, least-privilege identity. +# Reading ghp/creds/agent returns a lease-bound token scoped by the role +# (token_type agent, contents:read). Mirrors the gitea/creds/unkin-agent binding. +--- +rules: + - path: "ghp/creds/agent" + capabilities: + - read + +auth: + approle: + - agents -- 2.47.3 From 0ca7bea6f48a4e9a6e08ed6b638c3eca18b21ff7 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 16 Aug 2026 14:44:27 +1000 Subject: [PATCH 2/3] vault: move ghp policies to their own pre-apply PR (#122) Ordered plugin add requires the config-write policy to be effective in a prior apply before this PR writes ghp/config. Policies now live in #122; this PR is resources-only (catalog entry, backend config, role, modules, config.hcl wiring). --- policies/ghp/admin.yaml | 37 ----------------------------------- policies/ghp/creds/agent.yaml | 13 ------------ 2 files changed, 50 deletions(-) delete mode 100644 policies/ghp/admin.yaml delete mode 100644 policies/ghp/creds/agent.yaml diff --git a/policies/ghp/admin.yaml b/policies/ghp/admin.yaml deleted file mode 100644 index 58ea65f..0000000 --- a/policies/ghp/admin.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# Allow the vault deployer to manage the ghp token secrets engine: its -# connection config (seeded service token) and its token-minting roles. -# -# Scoped to ghp/* only, and deliberately excludes ghp/creds/* - minting tokens -# is for consumers, not the deployer. ghp has NO rotate endpoint, so unlike the -# gitea/netbox engines there is no config/rotate grant here. 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/netbox engines). ---- -rules: - # Engine connection config (base_url, TLS, seeded service token). - - path: "ghp/config" - capabilities: - - create - - read - - update - - delete - # Token-minting roles. - - path: "ghp/roles/*" - capabilities: - - create - - read - - update - - delete - - list - - path: "ghp/roles" - capabilities: - - read - - list - -auth: - approle: - - tf_vault - k8s/au/syd1: - - woodpecker_terraform_vault diff --git a/policies/ghp/creds/agent.yaml b/policies/ghp/creds/agent.yaml deleted file mode 100644 index fb99925..0000000 --- a/policies/ghp/creds/agent.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Lets the agents AppRole mint ephemeral ghp agent tokens, so AI coding agents -# authenticate to ghp as their own short-lived, least-privilege identity. -# Reading ghp/creds/agent returns a lease-bound token scoped by the role -# (token_type agent, contents:read). Mirrors the gitea/creds/unkin-agent binding. ---- -rules: - - path: "ghp/creds/agent" - capabilities: - - read - -auth: - approle: - - agents -- 2.47.3 From 724c0da17e922ecb30b02c934c8d685d205643c2 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 16 Aug 2026 15:41:18 +1000 Subject: [PATCH 3/3] vault: move ghp plugin-catalog entry to its own PR (#123) Ordered add registers the plugin in the catalog (#123) before this PR mounts + configures the engine. This PR is now mount/config/role + module wiring only. --- config/plugins/vault-plugin-secrets-ghp.yaml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 config/plugins/vault-plugin-secrets-ghp.yaml diff --git a/config/plugins/vault-plugin-secrets-ghp.yaml b/config/plugins/vault-plugin-secrets-ghp.yaml deleted file mode 100644 index b8ea4cd..0000000 --- a/config/plugins/vault-plugin-secrets-ghp.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# config/plugins/vault-plugin-secrets-ghp.yaml -# Imports (registers) the ghp secrets plugin in the catalog. Filename = -# catalog name = mount type. The binary is installed on the OpenBao nodes by -# Puppet (openbao-plugin-secrets-ghp RPM -> -# /opt/openbao-plugins/vault-plugin-secrets-ghp). -# -# 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-ghp -sha256: "85761421cd532788ed28fb57e93d3868f3577320a538289936d9ed3be5f396de" -- 2.47.3