user: add gitea-vault-admin site-admin bot and one-time Vault KV seeding
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Why: the vault-plugin-secrets-gitea engine needs a purpose-built Gitea
site-admin credential to mint and delete per-user tokens. Provision that
account and its Vault seed here so the credential is generated once and never
exposed. Ben asked that the password be written to Vault exactly once and
never updated afterwards.

Change:
- Add config/git.unkin.net/user/gitea-vault-admin.yaml: a local site-admin bot
  (admin: true, limited visibility, no org/repo creation).
- Extend the user module with an optional vault_seed_path/vault_seed_mount: when
  set, write the account's generated password to Vault KV as admin_username and
  admin_password via vault_kv_secret_v2, with lifecycle ignore_changes on
  data_json so the write is create-only and never churns (random_password
  already never regenerates). This keeps the seed stable and prevents a re-apply
  from overwriting a password later rotated out-of-band by rotate-root.
- Add the hashicorp/vault provider (module plus root generate block); it reads
  VAULT_ADDR and VAULT_TOKEN already exported by the Makefile k8s login.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
2026-07-27 19:26:24 +10:00
parent 96092cf7a1
commit bd7bef9f99
8 changed files with 77 additions and 0 deletions
@@ -0,0 +1,21 @@
# Purpose-built Gitea site-admin bot for the vault-plugin-secrets-gitea engine.
# The engine seeds itself from this account's credentials (Basic Auth) to mint
# and delete per-user tokens for any user. Its randomly generated password is
# written ONCE to Vault KV (vault_seed_path below) and never updated after; the
# Vault gitea engine reads that seed only when first creating gitea/config and
# then rotates it (rotate-root) so only Vault holds the live password.
#
# A local (not external-auth) account with 2FA disabled is required so the
# engine can change the password via the admin API during rotate-root.
email: gitea-vault-admin@unkin.net
full_name: "Gitea Vault Admin"
description: "site-admin bot; credentials seeded to Vault for vault-plugin-secrets-gitea"
# Site admin so the engine may mint/delete tokens for any user. No org/repo
# creation; profile visible only to signed-in users.
visibility: limited
admin: true
allow_create_organization: false
max_repo_creation: 0
# Seed this account's generated password to Vault KV (mount "kv") at this path,
# where the Vault gitea secrets engine reads it at creation time. Written once.
vault_seed_path: "service/vault/au/syd1/secret_backend/gitea/config"
+5
View File
@@ -11,6 +11,11 @@ provider "woodpecker" {
server = "https://ci.k8s.syd1.au.unkin.net" server = "https://ci.k8s.syd1.au.unkin.net"
} }
# Address + token come from VAULT_ADDR / VAULT_TOKEN in the environment (the
# Makefile authenticates via k8s auth before running terragrunt). Used to seed
# bot-account credentials into Vault KV (see modules/user vault_seed_path).
provider "vault" {}
terraform { terraform {
backend "consul" { backend "consul" {
address = "https://consul.service.consul" address = "https://consul.service.consul"
+2
View File
@@ -55,6 +55,8 @@ module "user" {
allow_create_organization = each.value.allow_create_organization allow_create_organization = each.value.allow_create_organization
max_repo_creation = each.value.max_repo_creation max_repo_creation = each.value.max_repo_creation
must_change_password = each.value.must_change_password must_change_password = each.value.must_change_password
vault_seed_path = each.value.vault_seed_path
vault_seed_mount = each.value.vault_seed_mount
} }
module "team" { module "team" {
@@ -35,3 +35,24 @@ resource "gitea_user" "this" {
ignore_changes = [password] ignore_changes = [password]
} }
} }
# Optional one-time seed of the account's credentials to Vault KV, for the
# vault-plugin-secrets-gitea engine to consume when it first creates gitea/config.
# random_password never regenerates (no keepers), so the seed is stable; the
# ignore_changes below makes the write strictly create-only, so a later
# rotate-root on the Vault side (which diverges the live password from this seed)
# is never clobbered by a re-apply here.
resource "vault_kv_secret_v2" "seed" {
count = var.vault_seed_path != null ? 1 : 0
mount = var.vault_seed_mount
name = var.vault_seed_path
data_json = jsonencode({
admin_username = var.username
admin_password = random_password.this.result
})
lifecycle {
ignore_changes = [data_json]
}
}
@@ -9,5 +9,9 @@ terraform {
source = "hashicorp/random" source = "hashicorp/random"
version = ">= 3.5" version = ">= 3.5"
} }
vault = {
source = "hashicorp/vault"
version = ">= 4.3"
}
} }
} }
@@ -67,3 +67,21 @@ variable "must_change_password" {
type = bool type = bool
default = false default = false
} }
variable "vault_seed_path" {
description = <<-EOT
Optional. When set, the account's generated password is seeded ONCE to Vault
KV at this secret name (under vault_seed_mount) as admin_username +
admin_password, for the vault-plugin-secrets-gitea engine to consume at
creation time. The write is create-only; subsequent changes are ignored so
the seed never churns (and never overwrites a rotated password).
EOT
type = string
default = null
}
variable "vault_seed_mount" {
description = "KV v2 mount holding the seeded credential (used only when vault_seed_path is set)"
type = string
default = "kv"
}
+4
View File
@@ -13,5 +13,9 @@ terraform {
source = "hashicorp/random" source = "hashicorp/random"
version = ">= 3.5" version = ">= 3.5"
} }
vault = {
source = "hashicorp/vault"
version = ">= 4.3"
}
} }
} }
+2
View File
@@ -79,6 +79,8 @@ variable "user" {
allow_create_organization = optional(bool, false) allow_create_organization = optional(bool, false)
max_repo_creation = optional(number, 0) max_repo_creation = optional(number, 0)
must_change_password = optional(bool, false) must_change_password = optional(bool, false)
vault_seed_path = optional(string)
vault_seed_mount = optional(string, "kv")
})) }))
default = {} default = {}
} }