From db71896903d7c1d98924ab3bd880e0cca46ab79d Mon Sep 17 00:00:00 2001 From: Joseph Spiker Date: Wed, 15 Jul 2026 15:50:44 +0000 Subject: [PATCH] feat: add bao-secrets composite action (public, no secrets) (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared actions live in a PUBLIC repo so CI runners can clone them anonymously. They can't clone the private spikersoft-infrastructure repo (a job token is scoped to its own repo), which is why the notifications Bao cutover failed with "Repository not found". bao-secrets is pure curl/jq logic — role_id/secret_id come from the caller's Actions secrets — so nothing sensitive is exposed. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 24 ++++++++++++- bao-secrets/README.md | 62 ++++++++++++++++++++++++++++++++ bao-secrets/action.yml | 81 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 bao-secrets/README.md create mode 100644 bao-secrets/action.yml diff --git a/README.md b/README.md index 098cef0..34188f7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ # gitea-actions -Shared Gitea Actions composite actions (public: logic only, no secrets). bao-secrets = OpenBao AppRole fetch. spikersoft-issues#545 \ No newline at end of file +Shared **public** Gitea Actions composite actions for the SpikerSoft org. + +Public on purpose: these contain only workflow *logic* (shell/curl/jq), never +secrets. Runners can clone this repo anonymously, which is why shared actions +live here instead of the private `spikersoft-infrastructure` repo (a private +repo can't be cloned by a job token scoped to a different repo). + +## Actions + +- **bao-secrets** — AppRole-login to OpenBao and export requested KV v2 secrets + into the job env (masked). Phase 1, spikersoft-issues#545. + + ```yaml + - uses: https://git.spikersoft.com/spikerj/gitea-actions/bao-secrets@master + with: + role-id: ${{ secrets.BAO_ROLE_ID }} + secret-id: ${{ secrets.BAO_SECRET_ID }} + secrets: | + DOCKER_PASSWORD secret/ci/shared/registry password + ``` + +Reference by full URL (`https://git.spikersoft.com/...`) so Gitea resolves it +from this instance instead of github.com. diff --git a/bao-secrets/README.md b/bao-secrets/README.md new file mode 100644 index 0000000..e50666d --- /dev/null +++ b/bao-secrets/README.md @@ -0,0 +1,62 @@ +# bao-secrets — fetch OpenBao secrets in one step + +Composite action for Gitea Actions (spikersoft-issues#545). AppRole-logs in to +OpenBao, reads the KV v2 paths you list, and exports them as **masked** env vars +for the rest of the job — so workflows stop carrying a pile of per-repo Actions +secrets. + +## One-time setup per repo + +1. Provision the repo's AppRole (once, by an admin): + ```sh + export BAO_ADDR=https://bao.spikersoft.com BAO_TOKEN= + cd /mnt/infrastructure/openbao && bash provision-ci-approles.sh + ``` + It prints a `role_id` and `secret_id` per repo. +2. In the repo's **Settings → Actions → Secrets**, set exactly two: + `BAO_ROLE_ID` and `BAO_SECRET_ID`. These replace `DOCKER_PASSWORD`, + `*_S3_SECRET_KEY`, `ACCESS_TOKEN_GITEA`, `DISCORD_*`, `HF_TOKEN`, … — delete + those once the workflow is migrated. + +## Use it in a workflow + +```yaml + steps: + - uses: spikerj/spikersoft-infrastructure/.gitea/actions/bao-secrets@master + with: + role-id: ${{ secrets.BAO_ROLE_ID }} + secret-id: ${{ secrets.BAO_SECRET_ID }} + secrets: | + DOCKER_PASSWORD secret/ci/shared/registry password + VIDEOS_S3_KEY secret/ci/backend/minio/videos secret_key + + # DOCKER_PASSWORD and VIDEOS_S3_KEY are now normal (masked) env vars: + - run: echo "$DOCKER_PASSWORD" | docker login git.spikersoft.com -u ci --password-stdin +``` + +### `secrets` format + +One mapping per line: **`ENV_NAME `**, whitespace-separated. +The path is exactly what you'd pass to `bao kv get`. Blank lines and `#` +comments are ignored. Multiline values (PEM keys, etc.) are handled. + +### Inputs + +| input | required | default | notes | +|---|---|---|---| +| `role-id` | yes | — | `${{ secrets.BAO_ROLE_ID }}` | +| `secret-id` | yes | — | `${{ secrets.BAO_SECRET_ID }}` | +| `secrets` | yes | — | the mapping block above | +| `bao-addr` | no | `https://bao.spikersoft.com` | override for testing | + +## Notes + +- Tokens issued to CI are **short-lived (15m)** and **read-only**, scoped to the + repo's own tree + `secret/ci/shared/*` by its policy. The action revokes its + token when done. +- If a path is outside the role's policy the step **fails loudly** (`permission + denied`) rather than exporting an empty value — verified in + `provision-ci-approles.sh`'s policy scoping. +- Runner needs `curl` and `jq` (present on the standard act_runner images). +- Rotation: `secret_id`s expire in 90 days — re-run `provision-ci-approles.sh` + and update the two Actions secrets; nothing else changes. diff --git a/bao-secrets/action.yml b/bao-secrets/action.yml new file mode 100644 index 0000000..885d84a --- /dev/null +++ b/bao-secrets/action.yml @@ -0,0 +1,81 @@ +name: "Fetch OpenBao secrets" +description: "AppRole-login to OpenBao and export requested KV v2 secrets into the job env (masked). Phase 1, spikersoft-issues#545." + +inputs: + role-id: + description: "AppRole role_id — the repo's BAO_ROLE_ID Actions secret." + required: true + secret-id: + description: "AppRole secret_id — the repo's BAO_SECRET_ID Actions secret." + required: true + bao-addr: + description: "OpenBao address." + required: false + default: "https://bao.spikersoft.com" + secrets: + description: | + One mapping per line: ENV_NAME + The path is exactly what you'd pass to `bao kv get` (mount + logical path). + Blank lines and `#` comments are ignored. Example: + DOCKER_PASSWORD secret/ci/shared/registry password + VIDEOS_S3_KEY secret/ci/backend/minio/videos secret_key + required: true + +runs: + using: "composite" + steps: + - shell: bash + env: + BAO_ADDR: ${{ inputs.bao-addr }} + BAO_ROLE_ID: ${{ inputs.role-id }} + BAO_SECRET_ID: ${{ inputs.secret-id }} + BAO_SECRETS: ${{ inputs.secrets }} + run: | + set -euo pipefail + command -v curl >/dev/null || { echo "::error::bao-secrets needs 'curl' on the runner"; exit 1; } + command -v jq >/dev/null || { echo "::error::bao-secrets needs 'jq' on the runner"; exit 1; } + + # 1) AppRole login -> short-lived (15m) token. No -f so we can read the + # error body; the token check below is the real gate. + login=$(curl -s --max-time 15 -X POST \ + --data "{\"role_id\":\"${BAO_ROLE_ID}\",\"secret_id\":\"${BAO_SECRET_ID}\"}" \ + "${BAO_ADDR}/v1/auth/approle/login") || true + TOKEN=$(printf '%s' "$login" | jq -r '.auth.client_token // empty') + if [ -z "$TOKEN" ]; then + echo "::error::OpenBao AppRole login failed: $(printf '%s' "$login" | jq -rc '.errors // "no response / network error"')" + exit 1 + fi + echo "::add-mask::$TOKEN" + + # 2) fetch each requested secret into $GITHUB_ENV (masked, multiline-safe) + while IFS= read -r line; do + line="${line%%#*}" + # shellcheck disable=SC2086 + set -- $line + [ "$#" -eq 0 ] && continue + if [ "$#" -ne 3 ]; then + echo "::error::bad 'secrets' line (need: ENV_NAME ): ${line}" + exit 1 + fi + env_name="$1"; path="$2"; field="$3" + mount="${path%%/*}"; rest="${path#*/}" # KV v2: /data/ + resp=$(curl -s --max-time 15 -H "X-Vault-Token: $TOKEN" \ + "${BAO_ADDR}/v1/${mount}/data/${rest}") || true + val=$(printf '%s' "$resp" | jq -r --arg f "$field" '.data.data[$f] // empty') + if [ -z "$val" ]; then + echo "::error::no value at '${path}' field '${field}' (wrong path/field, or this role's policy denies it): $(printf '%s' "$resp" | jq -rc '.errors // "empty"')" + exit 1 + fi + # mask every line of the value (handles multiline secrets like keys) + while IFS= read -r vline; do [ -n "$vline" ] && echo "::add-mask::$vline"; done <<< "$val" + { + echo "${env_name}<<__BAO_EOF__" + printf '%s\n' "$val" + echo "__BAO_EOF__" + } >> "$GITHUB_ENV" + echo " ✓ ${env_name} <- ${path}#${field}" + done <<< "$BAO_SECRETS" + + # 3) drop the token (short-lived anyway — just tidy) + curl -s --max-time 10 -H "X-Vault-Token: $TOKEN" \ + -X POST "${BAO_ADDR}/v1/auth/token/revoke-self" >/dev/null 2>&1 || true