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
This commit is contained in:
2026-07-27 00:54:59 +10:00
commit 20613afb26
30 changed files with 2933 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}"
BINARY="vault-plugin-secrets-gitea"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine for ephemeral, scoped Gitea access tokens"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-gitea"
export PACKAGE_LICENSE="MIT"
build_flavor() {
export PACKAGE_NAME="$1"
export PACKAGE_PLUGIN_DIR="$2"
export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh"
envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}"
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml"
nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm
}
build_flavor "vault-plugin-secrets-gitea" "/opt/vault-plugins"
build_flavor "openbao-plugin-secrets-gitea" "/opt/openbao-plugins"
echo "Built:"
ls -1 "${DIST}"/*.rpm
Executable
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
#
# End-to-end test for vault-plugin-secrets-gitea.
#
# Builds the plugin, brings up a mock Gitea REST API plus both Vault and OpenBao,
# then drives the identical lifecycle against each engine to prove the same
# binary works on both:
# configure -> rotate-root -> role -> creds (mint) -> revoke.
#
# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao.
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml"
COMPOSE="docker compose -f ${COMPOSE_FILE}"
BINARY="vault-plugin-secrets-gitea"
ADMIN_USER="bot-admin"
ADMIN_PASS="seed-password"
MOUNT="gitea"
ENGINES="${ENGINES:-vault openbao}"
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m==> %s\033[0m\n' "$*"; }
cleanup() { blue "Tearing down containers"; ${COMPOSE} down -v >/dev/null 2>&1 || true; }
trap cleanup EXIT
fail() { red "FAIL: $*"; exit 1; }
wait_for() {
local desc="$1"; shift
local i=0
until "$@" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -ge "${WAIT_RETRIES:-90}" ] && fail "timed out waiting for ${desc}"
sleep 2
done
green "ready: ${desc}"
}
jq_field() { python3 -c "import sys,json;print(json.load(sys.stdin)$1)"; }
run_engine() {
local engine="$1" container="$2" cli="$3"
blue "[${engine}] exercising the plugin"
ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; }
local sha; sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')"
ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true
ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true
ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null
green "[${engine}] plugin registered and mounted"
# The plugin runs inside the engine container, so it reaches gitea by name.
ex write "${MOUNT}/config" gitea_url="http://gitea:3000" \
admin_username="${ADMIN_USER}" admin_password="${ADMIN_PASS}" >/dev/null
green "[${engine}] configured"
# --- rotate the seeded admin password; subsequent mints must still work ---
ex write -f "${MOUNT}/config/rotate-root" >/dev/null
green "[${engine}] rotated root password"
# --- role + dynamic creds ---
ex write "${MOUNT}/roles/teabot" username="teabot" \
scopes="read:repository,write:issue" ttl=1h max_ttl=24h >/dev/null
local json id lease tok user
json="$(ex read -format=json "${MOUNT}/creds/teabot")"
id="$(printf '%s' "${json}" | jq_field '["data"]["token_id"]')"
lease="$(printf '%s' "${json}" | jq_field '["lease_id"]')"
tok="$(printf '%s' "${json}" | jq_field '["data"]["token"]')"
user="$(printf '%s' "${json}" | jq_field '["data"]["username"]')"
[ -n "${id}" ] || fail "[${engine}] no dynamic token id returned"
[ -n "${tok}" ] || fail "[${engine}] dynamic creds returned empty token value"
[ "${user}" = "teabot" ] || fail "[${engine}] wrong username ${user}"
green "[${engine}] dynamic token id=${id} issued for ${user} (lease ${lease})"
# revoke -> token deleted from gitea (idempotent: a second revoke is a no-op)
ex lease revoke "${lease}" >/dev/null
green "[${engine}] revoked lease ${lease}"
green "[${engine}] PASSED"
}
blue "Building plugin for linux/amd64"
OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build
blue "Starting Docker stack (gitea + vault + openbao)"
${COMPOSE} up -d --build
wait_for "gitea" curl -fsS "http://127.0.0.1:3000/healthz"
for engine in ${ENGINES}; do
case "${engine}" in
vault) wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200; run_engine vault vault vault ;;
openbao) wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200; run_engine openbao openbao bao ;;
*) fail "unknown engine: ${engine}" ;;
esac
done
green "ALL END-TO-END CHECKS PASSED (${ENGINES})"