From 96a6a7d728ed16643aae40f441f0b72ec4109662 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 27 Jul 2026 23:42:13 +1000 Subject: [PATCH] gitea: add the gitea token secrets engine (mount, config, teabot roles) (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why teabot's implementer and reviewer bot users should mint **ephemeral, scoped Gitea tokens** on demand rather than holding standing personal access tokens — Gitea tokens never expire on their own, so a leak lasts until someone notices. This registers and mounts the new `vault-plugin-secrets-gitea` engine (released v0.1.0) and declares its roles, mirroring the rancher engine wiring. ## Change - Register the plugin in the catalog (`config/plugins/vault-plugin-secrets-gitea.yaml`), pinned to the released v0.1.0 binary `sha256 8f67fbc216effada5fd7399888a710b62fad83be0b31761a439e7dec3d56509b` (sha256 of `/opt/openbao-plugins/vault-plugin-secrets-gitea` from the released `openbao-plugin-secrets-gitea-0.1.0` RPM). - Add `gitea_secret_backend` + `gitea_secret_backend_role` modules and wire them through `config.hcl`, `environments/au/syd1/terragrunt.hcl`, and `modules/vault_cluster` variables/main, using the `giteavaultsecret` provider from the `terraform-unkin` registry (v0.1.0). - Mount the engine at `gitea/` against `https://git.unkin.net`; seeded site-admin credentials are read from KV (`service/vault/au/syd1/secret_backend/gitea/config`, keys `admin_username`/`admin_password`) — not stored in git. - **The seed is consumed create-only**: `lifecycle ignore_changes` on `admin_username`/`admin_password` means the engine reads the KV seed only when first creating `gitea/config`. After `rotate-root` diverges the live password from the seed, a later apply never pushes the stale seed back. - Add roles with conservative, minimal scopes (write: implies read:): - `teabot-implementer` — `write:repository`, `write:issue` (clone/push, open PRs, comment). - `teabot-reviewer` — `read:repository`, `write:issue` (read diffs, post PR reviews/comments). - TTLs: `ttl` 1h / `max_ttl` 4h on both roles. ## The site-admin bot + KV seed are now provisioned by Terraform (no manual gap) Per Ben's review, creating the site-admin bot and seeding its credential is no longer a manual step: - **terraform-git #46** creates the `gitea-vault-admin` site-admin bot and writes its generated password **once** to `kv/service/vault/au/syd1/secret_backend/gitea/config` (create-only KV write; never updated). - **terraform-vault #102** grants terraform-git write access to that KV path. ## Ordering (merge + apply) 1. **puppet-prod #498** — installs the plugin binary on the vault nodes (Puppet must run). 2. **terraform-vault #100** (`benvin/gitea-deployer-access`) — deployer access to the gitea mount. 3. **terraform-vault #102** (`benvin/gitea-kv-writer`) — terraform-git KV write grant. 4. **terraform-git #46** (`benvin/gitea-vault-admin`) — creates the bot + seeds KV. 5. **This PR** — mounts the engine (reads the seed) and declares roles. Files here are disjoint from #100 and #102 (no conflict). **CI note:** the plan for this PR may hard-fail in CI if the plugin isn't yet registered/installed or the KV seed isn't present in the plan's target. If CI plan fails for that ordering reason, that is expected — do not force; apply only once steps 1–4 are live. ## Remaining manual step (one, ordered) After this PR's first apply, run `vault write -f gitea/config/rotate-root` so the standing seed password is replaced by one only Vault holds. (On future binary upgrades, bump the RPM version in puppet-prod and the catalog `sha256` here together, then `vault write sys/plugins/reload/backend plugin=vault-plugin-secrets-gitea`.) https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv Reviewed-on: https://git.unkin.net/unkin/terraform-vault/pulls/101 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- config/config.hcl | 13 +++++ config/gitea_secret_backend/gitea.yaml | 11 +++++ .../gitea/teabot-implementer.yaml | 14 ++++++ .../gitea/teabot-reviewer.yaml | 14 ++++++ .../plugins/vault-plugin-secrets-gitea.yaml | 11 +++++ environments/au/syd1/terragrunt.hcl | 3 ++ modules/vault_cluster/main.tf | 34 +++++++++++++ .../modules/gitea_secret_backend/main.tf | 34 +++++++++++++ .../modules/gitea_secret_backend/terraform.tf | 13 +++++ .../modules/gitea_secret_backend/variables.tf | 49 +++++++++++++++++++ .../modules/gitea_secret_backend_role/main.tf | 12 +++++ .../gitea_secret_backend_role/terraform.tf | 9 ++++ .../gitea_secret_backend_role/variables.tf | 37 ++++++++++++++ modules/vault_cluster/variables.tf | 27 ++++++++++ 14 files changed, 281 insertions(+) create mode 100644 config/gitea_secret_backend/gitea.yaml create mode 100644 config/gitea_secret_backend_role/gitea/teabot-implementer.yaml create mode 100644 config/gitea_secret_backend_role/gitea/teabot-reviewer.yaml create mode 100644 config/plugins/vault-plugin-secrets-gitea.yaml create mode 100644 modules/vault_cluster/modules/gitea_secret_backend/main.tf create mode 100644 modules/vault_cluster/modules/gitea_secret_backend/terraform.tf create mode 100644 modules/vault_cluster/modules/gitea_secret_backend/variables.tf create mode 100644 modules/vault_cluster/modules/gitea_secret_backend_role/main.tf create mode 100644 modules/vault_cluster/modules/gitea_secret_backend_role/terraform.tf create mode 100644 modules/vault_cluster/modules/gitea_secret_backend_role/variables.tf diff --git a/config/config.hcl b/config/config.hcl index 3ad9136..19b655d 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -239,5 +239,18 @@ locals { }) if startswith(file_path, "rancher_secret_backend_role/") } + gitea_secret_backend = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "gitea_secret_backend/") + } + gitea_secret_backend_role = { + for file_path, content in local.all_configs : + trimsuffix(replace(file_path, "gitea_secret_backend_role/", ""), ".yaml") => merge(content, { + name = trimsuffix(basename(file_path), ".yaml") + backend = dirname(replace(file_path, "gitea_secret_backend_role/", "")) + }) + if startswith(file_path, "gitea_secret_backend_role/") + } } } diff --git a/config/gitea_secret_backend/gitea.yaml b/config/gitea_secret_backend/gitea.yaml new file mode 100644 index 0000000..363c065 --- /dev/null +++ b/config/gitea_secret_backend/gitea.yaml @@ -0,0 +1,11 @@ +# Mounts the gitea token secrets engine at "gitea" and writes its config. +# The seeded site-admin credentials are sensitive and read from KV, not stored +# here: +# kv/service/vault/au/syd1/secret_backend/gitea/config +# -> keys: admin_username (required), admin_password (required) +# Populate that KV path with a purpose-built Gitea site-admin bot (2FA disabled) +# BEFORE applying, then run `vault write -f gitea/config/rotate-root` after the +# first apply so only Vault holds the admin password. +description: "Gitea ephemeral scoped access token engine" +gitea_url: "https://git.unkin.net" +request_timeout_seconds: 30 diff --git a/config/gitea_secret_backend_role/gitea/teabot-implementer.yaml b/config/gitea_secret_backend_role/gitea/teabot-implementer.yaml new file mode 100644 index 0000000..3f2ce54 --- /dev/null +++ b/config/gitea_secret_backend_role/gitea/teabot-implementer.yaml @@ -0,0 +1,14 @@ +# Role minting ephemeral tokens for the teabot-implementer bot user. +# The implementer clones/pushes code and opens pull requests, so it gets write +# on repositories (clone + push + PR create) and write on issues (PR/issue +# comments). Read is implied by write. No admin/org/user-write scopes. +# Reading gitea/creds/teabot-implementer mints a lease-bound token deleted from +# Gitea on revoke/expiry. +--- +username: teabot-implementer +scopes: + - write:repository + - write:issue +token_name_prefix: vault-teabot-implementer +ttl: 3600 # 1h +max_ttl: 14400 # 4h diff --git a/config/gitea_secret_backend_role/gitea/teabot-reviewer.yaml b/config/gitea_secret_backend_role/gitea/teabot-reviewer.yaml new file mode 100644 index 0000000..b1473f4 --- /dev/null +++ b/config/gitea_secret_backend_role/gitea/teabot-reviewer.yaml @@ -0,0 +1,14 @@ +# Role minting ephemeral tokens for the teabot-reviewer bot user. +# The reviewer reads code and posts pull-request reviews/comments, so it gets +# read on repositories (fetch diffs) and write on issues (PR reviews + issue/PR +# comments). No repository-write, admin, org, or user scopes. +# Reading gitea/creds/teabot-reviewer mints a lease-bound token deleted from +# Gitea on revoke/expiry. +--- +username: teabot-reviewer +scopes: + - read:repository + - write:issue +token_name_prefix: vault-teabot-reviewer +ttl: 3600 # 1h +max_ttl: 14400 # 4h diff --git a/config/plugins/vault-plugin-secrets-gitea.yaml b/config/plugins/vault-plugin-secrets-gitea.yaml new file mode 100644 index 0000000..b101877 --- /dev/null +++ b/config/plugins/vault-plugin-secrets-gitea.yaml @@ -0,0 +1,11 @@ +# config/plugins/vault-plugin-secrets-gitea.yaml +# Imports (registers) the gitea secrets plugin in the catalog. Filename = +# catalog name = mount type. The binary is installed on the OpenBao nodes by +# Puppet (openbao-plugin-secrets-gitea RPM -> +# /opt/openbao-plugins/vault-plugin-secrets-gitea). +# +# 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-gitea +sha256: "8f67fbc216effada5fd7399888a710b62fad83be0b31761a439e7dec3d56509b" diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index a772255..98a9415 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -78,6 +78,9 @@ inputs = { rancher_secret_backend_service_account = local.config.rancher_secret_backend_service_account rancher_secret_backend_role = local.config.rancher_secret_backend_role + gitea_secret_backend = local.config.gitea_secret_backend + gitea_secret_backend_role = local.config.gitea_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 0721e81..3940844 100644 --- a/modules/vault_cluster/main.tf +++ b/modules/vault_cluster/main.tf @@ -421,6 +421,40 @@ module "rancher_secret_backend_role" { depends_on = [module.rancher_secret_backend_service_account] } +module "gitea_secret_backend" { + source = "./modules/gitea_secret_backend" + + for_each = var.gitea_secret_backend + + path = each.key + plugin = each.value.plugin + description = each.value.description + gitea_url = each.value.gitea_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 "gitea_secret_backend_role" { + source = "./modules/gitea_secret_backend_role" + + for_each = var.gitea_secret_backend_role + + backend = each.value.backend + name = each.value.name + username = each.value.username + scopes = each.value.scopes + token_name_prefix = each.value.token_name_prefix + ttl = each.value.ttl + max_ttl = each.value.max_ttl + + depends_on = [module.gitea_secret_backend] +} + module "vault_policy" { source = "./modules/vault_policy" diff --git a/modules/vault_cluster/modules/gitea_secret_backend/main.tf b/modules/vault_cluster/modules/gitea_secret_backend/main.tf new file mode 100644 index 0000000..997e128 --- /dev/null +++ b/modules/vault_cluster/modules/gitea_secret_backend/main.tf @@ -0,0 +1,34 @@ +# Mounts the gitea secrets engine and writes its connection config via the +# giteavaultsecret provider. The plugin is registered ("imported") in the +# catalog separately (config/plugins/vault-plugin-secrets-gitea.yaml). The +# seeded site-admin credentials are sensitive and read from KV, not stored in +# git: +# kv/service/vault///secret_backend//config +# Expected keys: admin_username (required), admin_password (required). +data "vault_kv_secret_v2" "config" { + mount = "kv" + name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config" +} + +resource "gitea_secret_backend" "this" { + path = var.path + plugin = var.plugin + description = var.description + gitea_url = var.gitea_url + admin_username = data.vault_kv_secret_v2.config.data["admin_username"] + admin_password = data.vault_kv_secret_v2.config.data["admin_password"] + 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 password is rotated + # in place (vault write -f gitea/config/rotate-root) and diverges from the + # seed, so re-reading the (possibly stale) KV value must never push it back. + # Ignoring the credential attributes makes this module create-only for them. + # (The sibling rancher/litellm seed modules do not yet do this and would + # re-push their seed on a subsequent apply.) + ignore_changes = [admin_username, admin_password] + } +} diff --git a/modules/vault_cluster/modules/gitea_secret_backend/terraform.tf b/modules/vault_cluster/modules/gitea_secret_backend/terraform.tf new file mode 100644 index 0000000..98f55df --- /dev/null +++ b/modules/vault_cluster/modules/gitea_secret_backend/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.10" + required_providers { + vault = { + source = "hashicorp/vault" + version = "5.6.0" + } + gitea = { + source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/giteavaultsecret" + version = "0.1.0" + } + } +} diff --git a/modules/vault_cluster/modules/gitea_secret_backend/variables.tf b/modules/vault_cluster/modules/gitea_secret_backend/variables.tf new file mode 100644 index 0000000..11467f6 --- /dev/null +++ b/modules/vault_cluster/modules/gitea_secret_backend/variables.tf @@ -0,0 +1,49 @@ +variable "path" { + description = "Mount path of the gitea secrets engine (e.g. \"gitea\")" + type = string +} + +variable "plugin" { + description = "Registered plugin name to mount (the catalog name = mount type)" + type = string + default = "vault-plugin-secrets-gitea" +} + +variable "description" { + description = "Human-friendly description of the mount" + type = string + default = null +} + +variable "gitea_url" { + description = "Base URL of the Gitea server (e.g. https://git.unkin.net)" + type = string +} + +variable "country" { + description = "Country segment of the KV path holding the seeded admin credentials" + type = string +} + +variable "region" { + description = "Region segment of the KV path holding the seeded admin credentials" + type = string +} + +variable "ca_cert" { + description = "PEM CA certificate that signed the Gitea 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 Gitea server (not recommended)" + type = bool + default = false +} + +variable "request_timeout_seconds" { + description = "HTTP timeout in seconds for calls from the plugin to Gitea" + type = number + default = 30 +} diff --git a/modules/vault_cluster/modules/gitea_secret_backend_role/main.tf b/modules/vault_cluster/modules/gitea_secret_backend_role/main.tf new file mode 100644 index 0000000..be9943b --- /dev/null +++ b/modules/vault_cluster/modules/gitea_secret_backend_role/main.tf @@ -0,0 +1,12 @@ +# A role that mints short-lived, scoped gitea tokens for a target Gitea user. +# Reading gitea/creds/ produces a lease-bound token that is deleted from +# Gitea when the lease is revoked or reaches max_ttl. +resource "gitea_secret_backend_role" "this" { + backend = var.backend + name = var.name + username = var.username + scopes = var.scopes + token_name_prefix = var.token_name_prefix + ttl = var.ttl + max_ttl = var.max_ttl +} diff --git a/modules/vault_cluster/modules/gitea_secret_backend_role/terraform.tf b/modules/vault_cluster/modules/gitea_secret_backend_role/terraform.tf new file mode 100644 index 0000000..6a5585b --- /dev/null +++ b/modules/vault_cluster/modules/gitea_secret_backend_role/terraform.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.10" + required_providers { + gitea = { + source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/giteavaultsecret" + version = "0.1.0" + } + } +} diff --git a/modules/vault_cluster/modules/gitea_secret_backend_role/variables.tf b/modules/vault_cluster/modules/gitea_secret_backend_role/variables.tf new file mode 100644 index 0000000..b8472a5 --- /dev/null +++ b/modules/vault_cluster/modules/gitea_secret_backend_role/variables.tf @@ -0,0 +1,37 @@ +variable "backend" { + description = "Mount path of the gitea secrets engine this role belongs to" + type = string +} + +variable "name" { + description = "Role name (read gitea/creds/ to mint a token)" + type = string +} + +variable "username" { + description = "Target Gitea username the minted tokens belong to" + type = string +} + +variable "scopes" { + description = "Gitea access-token scopes granted to minted tokens (write: implies read:)" + type = list(string) +} + +variable "token_name_prefix" { + description = "Prefix for the generated Gitea token name (optional)" + 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 a013a8a..2c59ddc 100644 --- a/modules/vault_cluster/variables.tf +++ b/modules/vault_cluster/variables.tf @@ -388,6 +388,33 @@ variable "rancher_secret_backend_role" { default = {} } +variable "gitea_secret_backend" { + description = "Map of gitea token secret engines to create (mount + config; seeded admin creds read from KV)" + type = map(object({ + plugin = optional(string, "vault-plugin-secrets-gitea") + description = optional(string) + gitea_url = string + ca_cert = optional(string) + tls_skip_verify = optional(bool, false) + request_timeout_seconds = optional(number, 30) + })) + default = {} +} + +variable "gitea_secret_backend_role" { + description = "Map of gitea token-minting roles to create" + type = map(object({ + name = string + backend = string + username = string + scopes = list(string) + token_name_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)))