ci/woodpecker/pr/ci Pipeline was successful
Blocker 2 (secret at rest on error paths): all staging now lives in ONE per-run mktemp -d removed by an EXIT/INT/TERM trap; a curl dying rc=7 mid-run (the reviewer's transport-failure case) leaves nothing behind. M10 pins it against a dying mock in an isolated TMPDIR; trap-removed mutant killed. Blocker 1 (bash -x trace channel, upheld above landed parity because this is the admin-token minter): secrets are assembled FILE-TO-FILE — stage_auth/stage_user take token/password FILE PATHS and build the curl configs with jq --rawfile; the password is generated straight into its staging file; bodies are composed by jq from the template + password file. No secret is ever expanded into a shell word a trace would print. M11 runs a real bash -x and asserts the admin-token value, the minted token value, and any password-shaped 32-char expansion are all absent; expansion mutant killed (measured: the mutant's trace shows '+ PW_VALUE=<32 chars>', the fixed script's trace shows paths only). Header comment corrected to state what is actually true, including the explicit note that detect-platform's gitea_write_auth_config still leaks under -x — that parity gap is now tracked as #1369, opened per review 263 and fred's ruling; issue-comment/pr-review/pr-edit left untouched in this PR.
228 lines
11 KiB
Bash
Executable File
228 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# mint-seat-credential.sh — create the Gitea account and mint a token for one seat,
|
|
# on every configured instance, writing the result into that seat's credential slot.
|
|
#
|
|
# mint-seat-credential.sh [--admin-seat <seat>] [--instances "<a> <b>"] <seat>
|
|
#
|
|
# Configuration (environment; flags win over environment):
|
|
# MOSAIC_ADMIN_SEAT seat whose admin token is used to call the Gitea
|
|
# admin API. Required. Its token is read from
|
|
# $MOSAIC_BRAIN_HOME/fleet/agents/<admin>/secrets/
|
|
# gitea-<instance>-<admin>.token. Never printed.
|
|
# MOSAIC_GITEA_INSTANCES space-separated instance names to mint on.
|
|
# Default: every instance in the map below.
|
|
# MOSAIC_GITEA_URL_<INSTANCE> server URL override per instance (same
|
|
# convention as seat-logins.sh).
|
|
# MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (<seat>@<domain>).
|
|
# Required, no default: the framework tree
|
|
# carries no estate-specific domain
|
|
# (framework-PR firewall; the instance host
|
|
# map stays per seat-logins.sh precedent).
|
|
# MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic.
|
|
#
|
|
# Exit codes: 0 minted and projected on every instance; 1 at least one instance
|
|
# failed (the others are untouched or complete); 3 usage error.
|
|
#
|
|
# WHY BASIC AUTH, WHICH LOOKS WRONG AT FIRST
|
|
# Gitea refuses token auth on POST /users/{user}/tokens by design, and the Sudo
|
|
# header and sudo query parameter are both rejected there (probed 2026-08-19, probe
|
|
# token deleted). So minting for another account needs a password: this script
|
|
# generates a random one, uses it once, and never stores or prints it. Agents
|
|
# authenticate by token; the password is not a credential anyone keeps.
|
|
#
|
|
# The .scopes file is written from the mint RESPONSE rather than from what was
|
|
# requested, so the record is what was granted rather than what was asked for.
|
|
#
|
|
# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259), and the
|
|
# staging area is a single trap-swept directory (review 263 blocker 2): the
|
|
# admin token, the generated password, and the minted seat token all pass
|
|
# through 0600 files under a per-run staging dir removed by an EXIT/INT/TERM
|
|
# trap, so a transport failure mid-run cannot leave secrets at rest in /tmp.
|
|
#
|
|
# TRACE CHANNEL, stated plainly (review 263 blocker 1): this script is held to
|
|
# a higher bar than ordinary wrappers because it mints admin-grade
|
|
# credentials and a durable password. Secrets here are assembled FILE-TO-FILE
|
|
# — source token file, password generated straight into its staging file,
|
|
# bodies composed with jq from those files — so no secret is ever expanded
|
|
# into a shell word a trace would print. A plain `bash -x` of this script
|
|
# shows staging PATHS only. (The landed gitea_write_auth_config in
|
|
# detect-platform.sh still expands tokens into shell words and DOES leak
|
|
# under -x; that fleet-wide parity gap is tracked in its own issue — see the
|
|
# framework-hardening issue referenced from this PR.)
|
|
set -Eeuo pipefail
|
|
|
|
STAGE_DIR=""
|
|
cleanup_stage() {
|
|
[ -n "$STAGE_DIR" ] && rm -rf -- "$STAGE_DIR"
|
|
STAGE_DIR=""
|
|
}
|
|
trap cleanup_stage EXIT INT TERM
|
|
|
|
# All staging lives in one per-run dir, swept by the trap above. Files are
|
|
# created 0600 and secrets are moved between them only by tool reads
|
|
# (jq/cat), never through shell-word expansion.
|
|
new_stage() { STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-mint.XXXXXX")"; chmod 700 "$STAGE_DIR"; }
|
|
|
|
# stage_auth <token-file> — curl --config carrying the Authorization header,
|
|
# reading the token from the file with jq so it never
|
|
# becomes a shell word.
|
|
# stage_user <seat> <pw-file> — curl --config with `user =`; the password is
|
|
# read from its file by jq. <seat> is not a secret.
|
|
# stage_body <template-json> <pw-file> — body file; jq injects the password
|
|
# file's value into the template. The mint body has
|
|
# no secret and is written directly.
|
|
stage_auth() {
|
|
local f="$STAGE_DIR/auth.cfg"
|
|
jq -rn --rawfile t "$1" '"header = \"Authorization: token " + $t + "\""' >"$f" || return 1
|
|
chmod 600 "$f"; printf '%s' "$f"
|
|
}
|
|
stage_user() {
|
|
local f="$STAGE_DIR/user.cfg"
|
|
jq -rn --rawfile p "$2" --arg u "$1" '"user = \"" + $u + ":" + $p + "\""' >"$f" || return 1
|
|
chmod 600 "$f"; printf '%s' "$f"
|
|
}
|
|
stage_body() {
|
|
# $1 is a JSON template string (no secrets); $2 is the password file. jq
|
|
# parses the template and injects the password read straight from the file.
|
|
local f="$STAGE_DIR/body.json"
|
|
jq -c --rawfile p "$2" '.password = $p' <<<"$1" >"$f" || return 1
|
|
chmod 600 "$f"; printf '%s' "$f"
|
|
}
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
|
ADMIN="${MOSAIC_ADMIN_SEAT:-}"
|
|
INSTANCES="${MOSAIC_GITEA_INSTANCES:-}"
|
|
EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}"
|
|
SEAT=""
|
|
|
|
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; }
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--admin-seat) ADMIN="${2:-}"; shift 2 ;;
|
|
--instances) INSTANCES="${2:-}"; shift 2 ;;
|
|
-h|--help) usage ;;
|
|
-*) echo "mint: unknown flag: $1" >&2; exit 3 ;;
|
|
*) [[ -z "$SEAT" ]] || { echo "mint: one seat only" >&2; exit 3; }; SEAT="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
[[ -n "$SEAT" ]] || usage
|
|
[[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; }
|
|
[[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; }
|
|
[[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; }
|
|
[[ -n "$EMAIL_DOMAIN" ]] || { echo "mint: no email domain. Set MOSAIC_SEAT_EMAIL_DOMAIN (the framework ships no estate default)." >&2; exit 3; }
|
|
|
|
# Instance -> server URL. Same map and override convention as seat-logins.sh:
|
|
# hyphens in instance names map to underscores in the override variable
|
|
# (MOSAIC_GITEA_URL_MY-INST is not a valid shell name; MY_INST is).
|
|
url_override_var() { printf 'MOSAIC_GITEA_URL_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')"; }
|
|
declare -A INSTANCE_URL=(
|
|
[mosaicstack]="https://git.mosaicstack.dev"
|
|
[usc]="https://git.uscllc.com"
|
|
)
|
|
for inst in "${!INSTANCE_URL[@]}"; do
|
|
ov="$(url_override_var "$inst")"
|
|
[[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}"
|
|
done
|
|
[[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')"
|
|
|
|
SCOPES='["read:user","write:repository","write:issue","read:organization"]'
|
|
D="$BRAIN/fleet/agents/$SEAT/secrets"
|
|
mkdir -p "$D"; chmod 700 "$D"
|
|
|
|
rc=0
|
|
for KEY in $INSTANCES; do
|
|
ov="$(url_override_var "$KEY")"
|
|
BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}"
|
|
[[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; }
|
|
ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token"
|
|
[[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; }
|
|
# Per-instance staging dir: everything under it dies with the trap, so a
|
|
# transport failure (review 263 blocker 2) cannot leave secrets at rest.
|
|
new_stage
|
|
AUTH_CFG="$(stage_auth "$ADMIN_TOKEN_FILE")"
|
|
# The password is generated STRAIGHT INTO its staging file; the variable
|
|
# below is its path, never the value (review 263 blocker 1).
|
|
openssl rand -base64 33 | tr -d '\n/+=' | head -c 32 >"$STAGE_DIR/pw"
|
|
chmod 600 "$STAGE_DIR/pw"
|
|
USER_CFG="$(stage_user "$SEAT" "$STAGE_DIR/pw")"
|
|
|
|
if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then
|
|
BODY="$(stage_body '{"login_name":"'"$SEAT"'","source_id":0,"password":"","must_change_password":false}' "$STAGE_DIR/pw")"
|
|
curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \
|
|
--config "$AUTH_CFG" --data "@$BODY" \
|
|
"$BASE/api/v1/admin/users/$SEAT"
|
|
act="reset-pw"
|
|
else
|
|
BODY="$(stage_body '{"username":"'"$SEAT"'","email":"'"$SEAT@$EMAIL_DOMAIN"'","password":"","must_change_password":false,"full_name":"Mosaic fleet seat '"$SEAT"'"}' "$STAGE_DIR/pw")"
|
|
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
|
|
--config "$AUTH_CFG" --data "@$BODY" \
|
|
"$BASE/api/v1/admin/users"
|
|
act="create"
|
|
fi
|
|
|
|
tmp="$STAGE_DIR/mint-response.json"
|
|
printf '{"name":"mosaic-seat","scopes":%s}' "$SCOPES" >"$STAGE_DIR/mint-body.json"; chmod 600 "$STAGE_DIR/mint-body.json"
|
|
MINT_BODY="$STAGE_DIR/mint-body.json"
|
|
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \
|
|
--config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")"
|
|
if [[ "$code" != "201" ]]; then
|
|
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; cleanup_stage; continue
|
|
fi
|
|
|
|
python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY'
|
|
import json,sys,pathlib
|
|
tmp,d,key,seat=sys.argv[1:5]
|
|
t=json.load(open(tmp))
|
|
p=pathlib.Path(d)
|
|
(p/f"gitea-{key}-{seat}.token").write_text(t["sha1"]+"\n")
|
|
(p/f"gitea-{key}-{seat}.scopes").write_text(json.dumps(t.get("scopes",[]))+"\n")
|
|
(p/f"gitea-{key}-{seat}.principal").write_text(seat+"\n")
|
|
for suf in ("token","scopes","principal"):
|
|
(p/f"gitea-{key}-{seat}.{suf}").chmod(0o600)
|
|
PY
|
|
rm -f "$tmp"; cleanup_stage
|
|
|
|
new_stage # fresh staging for the verify read
|
|
VERIFY_CFG="$(stage_auth "$D/gitea-$KEY-$SEAT.token")"
|
|
login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \
|
|
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
|
|
cleanup_stage
|
|
if [[ "$login" == "$SEAT" ]]; then
|
|
echo " $KEY: $act, minted, GET /user -> $login"
|
|
else
|
|
echo " $KEY: minted but identity check returned '$login', expected '$SEAT'" >&2; rc=1
|
|
fi
|
|
done
|
|
|
|
# ── Project into tea ─────────────────────────────────────────────────────────
|
|
# A token in the secrets dir is only half a credential. tea 0.14.0 cannot read
|
|
# that store, it only uses logins already in its own config, so a seat minted
|
|
# but not projected holds a working token and no login. Minting and projecting
|
|
# are therefore ONE operation.
|
|
#
|
|
# --adopt is deliberately NOT passed. Adopting deletes an operator-made login,
|
|
# which is a human decision. A collision reports BLOCK and a nonzero rc instead.
|
|
#
|
|
# tea absent is not a minting failure. The REST-path wrappers still work with
|
|
# the token that was just written, so warn and carry on.
|
|
SEAT_LOGINS="$SCRIPT_DIR/seat-logins.sh"
|
|
if [[ "$rc" -eq 0 ]]; then
|
|
if command -v tea >/dev/null 2>&1; then
|
|
if "$SEAT_LOGINS" --apply --seat "$SEAT"; then
|
|
:
|
|
else
|
|
echo " projection FAILED: token is minted and valid, but no tea login exists for $SEAT." >&2
|
|
echo " tea-path wrappers will not act as this seat. Re-run:" >&2
|
|
echo " $SEAT_LOGINS --apply --seat $SEAT" >&2
|
|
rc=1
|
|
fi
|
|
else
|
|
echo " tea not on PATH: token minted, no login projected (REST-path wrappers still work)." >&2
|
|
fi
|
|
fi
|
|
|
|
exit $rc
|