Files
unkinben 20613afb26 Initial vault-plugin-secrets-gitea engine
Add a Vault/OpenBao secrets engine that mints ephemeral, scoped Gitea
access tokens on demand. The engine holds a single seeded Gitea site-admin
Basic-Auth credential and, per role, mints a fresh per-user token via the
admin API, bound to a Vault lease and deleted from Gitea on revocation.
Gitea requires Basic Auth for token management (token auth is rejected),
and reqSelfOrAdmin lets a site admin manage any user's tokens, which is the
mechanism this relies on. Gitea tokens never expire server-side, so the
Vault lease is the sole expiry mechanism.

- add backend wiring, config (+ rotate-root), roles, creds paths
- add the gitea client (Basic Auth create/delete token, admin password change)
- add scope validation against Gitea's access-token scope set
- add unit tests (fake Gitea API) and a Vault+OpenBao e2e harness
- add Makefile, nfpm RPM packaging, and Woodpecker build/test/release pipelines

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 00:54:59 +10:00

135 lines
6.4 KiB
Markdown

# vault-plugin-secrets-gitea
A Vault / OpenBao secrets engine that mints **ephemeral, scoped Gitea access
tokens** on demand.
## Why
Static Gitea bot users (teabot personalities, CI identities, ...) should never
hold long-lived personal access tokens: a leaked token is valid until someone
notices and deletes it, and Gitea tokens **never expire on their own**. This
engine removes standing tokens entirely:
- You seed it once with a single Gitea **site-admin** credential.
- A **role** binds a target Gitea username to a set of token scopes and TTLs.
- Each read of `creds/<role>` mints a fresh token for that user, bound to a
Vault lease, and **deletes it from Gitea when the lease is revoked or expires**.
### Why an admin username + password, not an admin token
Gitea's token-management endpoints (`POST/DELETE /api/v1/users/{username}/tokens`)
are guarded by `reqBasicOrRevProxyAuth()` — they **reject token/bearer auth** and
accept only HTTP Basic Auth or reverse-proxy auth
([go-gitea/gitea#21186](https://github.com/go-gitea/gitea/issues/21186)). The
same routes are also guarded by `reqSelfOrAdmin()`, so a **site admin** using
Basic Auth may mint and delete tokens for *any* user by naming them in the path.
That is the mechanism this engine relies on, which is why the seeded credential
is an admin **username + password**, not a token.
### The Vault lease is the only expiry
Gitea access tokens have no server-side TTL. The Vault lease is therefore the
sole expiry mechanism: when the lease is revoked or reaches `max_ttl`, the engine
issues `DELETE /api/v1/users/{username}/tokens/{id}` to remove the token. A
delete that returns 404 (token already gone) is treated as success, so revocation
is idempotent and Vault's retries converge.
## Paths
| Path | Description |
|------|-------------|
| `config` | Gitea URL + TLS settings + seeded admin `admin_username`/`admin_password`. Verifies the credentials are a site admin on write. |
| `config/rotate-root` | Rotate the seeded admin password in place (generates a new random password, changes it via the admin API, stores it). |
| `roles/<name>` | Mint policy: `username`, `scopes`, `ttl`, `max_ttl`, `token_name_prefix`. |
| `roles` | List roles. |
| `creds/<role>` | Read to mint a short-lived, lease-bound token for the role's user. |
## Scopes
`scopes` is validated against Gitea's scope set (see
`models/auth/access_token_scope.go`): `all`, `public-only`, and the `read:` /
`write:` forms of `activitypub`, `admin`, `misc`, `notification`, `organization`,
`package`, `issue`, `repository`, `user`. `write:` implies `read:`.
## Usage
```sh
vault secrets enable -path=gitea vault-plugin-secrets-gitea
# Seed with a Gitea site-admin username + password (Basic Auth).
vault write gitea/config \
gitea_url=https://git.example.com \
admin_username=bot-admin \
admin_password='...' \
ca_cert=@gitea-ca.pem
# Immediately rotate the seeded password so only Vault knows it.
vault write -f gitea/config/rotate-root
# A role that mints 1h tokens for the "teabot" user, scoped to repo + issues.
vault write gitea/roles/teabot username=teabot \
scopes=read:repository,write:issue ttl=1h max_ttl=24h
# Mint one. The token is deleted from Gitea when the lease is revoked.
vault read gitea/creds/teabot
```
### Root rotation requirements & limits
`config/rotate-root` changes the seeded admin's password via
`PATCH /api/v1/admin/users/{admin_username}`. Honestly documented constraints:
- The admin must be a **local** Gitea user (external-auth users can't have their
password changed this way). Gitea requires `login_name` on the edit call; the
engine sends `admin_login_name` (default: `admin_username`) and `admin_source_id`
(default `0`, i.e. local).
- The admin account must **not** have TOTP/2FA enabled — Basic Auth with 2FA
requires an OTP header the engine cannot supply.
- Rotation changes Gitea first, then persists to Vault. If the persist fails the
engine rolls the password back. If *both* the persist and the rollback fail
(extremely unlikely), the response says so — reset the admin password manually
and re-seed `config`.
## Development
```sh
make build # build the plugin binary into ./dist
make test # unit tests (race)
make e2e # full lifecycle vs mock Gitea on Vault + OpenBao (Docker)
make rpm # build Vault + OpenBao RPMs via nfpm
```
Releases are tag-driven (`make patch|minor|major`): a Woodpecker pipeline builds
the Vault and OpenBao RPMs and uploads them to the internal artifactapi yum repo.
## Deployment (out of scope for this repo; documented for the operator)
Live registration in the real cluster is done exactly like the sibling
`vault-plugin-secrets-rancher` engine. The steps, in order:
1. **Repos**: the Gitea repo is provisioned via `terraform-git`
(`config/git.unkin.net/unkin/repository/vault-plugin-secrets-gitea.yaml`) →
PR → plan/apply. After the repo auto-inits, enable its Woodpecker webhook
manually in the Woodpecker UI (new repos get no webhook automatically) so the
required `pre-commit`/`build`/`test` checks run.
2. **Release**: tag `v0.1.0` (`make minor` from `v0.0.0`). CI publishes
`rpm-internal/files/Packages/{vault,openbao}-plugin-secrets-gitea-<ver>-1.x86_64.rpm`.
3. **terraform-vault** (mirrors the rancher wiring — merge in this order):
- `policies/gitea/admin.yaml` — deployer catalog + engine grant.
- `config/plugins/vault-plugin-secrets-gitea.yaml` — plugin catalog entry
with the release `sha256`.
- `puppet-prod`: add `openbao-plugin-secrets-gitea` (pinned to the exact
version) to `profiles::packages::include` on the vault storage role.
- `gitea_secret_backend` + `gitea_secret_backend_role` module instances and
their config yamls (via the companion `terraform-provider-giteavaultsecret`).
4. **Seed the admin credential in KV** before the backend module applies, e.g.
`kv/service/vault/au/syd1/secret_backend/gitea/config` with
`admin_username` + `admin_password` for a purpose-built Gitea site-admin bot
account (2FA disabled). Then run `vault write -f gitea/config/rotate-root` so
the standing seed password is replaced by one only Vault holds.
5. **Reload after a binary upgrade**:
`vault write sys/plugins/reload/backend plugin=vault-plugin-secrets-gitea`
(the `vault plugin reload -plugin=…` CLI form is broken on this OpenBao).
Bump the RPM version in puppet-prod and the catalog `sha256` in terraform-vault
*together* so the on-disk binary stays in lockstep with the catalog.