gitea: add the gitea token secrets engine (mount, config, teabot roles) (#101)
ci/woodpecker/push/apply Pipeline was successful
ci/woodpecker/push/apply Pipeline was successful
## 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: #101 Co-authored-by: Ben Vincent <ben@unkin.net> Co-committed-by: Ben Vincent <ben@unkin.net>
This commit was merged in pull request #101.
This commit is contained in:
@@ -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/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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/<country>/<region>/secret_backend/<path>/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]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# A role that mints short-lived, scoped gitea tokens for a target Gitea user.
|
||||
# Reading gitea/creds/<name> 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<name> 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
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user