Files
stack/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh
T
fred 836ec3cb1d
ci/woodpecker/pr/ci Pipeline was successful
feat(fleet-tools): mint-seat-credential.sh joins the framework toolkit
Moves seat credential minting out of a brain-local fleet/bin into
packages/mosaic/framework/tools/fleet/, parameterized for any deployment:

- MOSAIC_ADMIN_SEAT (or --admin-seat) names the seat whose admin token calls
  the Gitea admin API; no seat name is hardcoded.
- MOSAIC_GITEA_INSTANCES / MOSAIC_GITEA_URL_<INSTANCE> select and override
  instances, the same convention seat-logins.sh already uses.
- MOSAIC_SEAT_EMAIL_DOMAIN sets the account email domain.
- tea projection calls the sibling seat-logins.sh, not a brain-local copy.

Hermetic suite test-mint-seat-credential.sh (mock curl, sandboxed brain home,
no tea, no network) pins: slot written from the mint response at mode 600;
admin seat must be configured (rc=3) and its token present (rc=1, no API
call); instance selection and URL override; admin token value never echoed.
Joins ci.yml and the verify-release canonical list. Adds tools/fleet/README.md.

Plan: docs/plans/2026-08-21_git-operations-toolkit.md step 6 (first of three
scripts; new-seat.sh and launch-seat.sh need a design ruling, see the plan).
2026-08-21 19:24:03 -05:00

151 lines
7.0 KiB
Bash

#!/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>).
# 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.
set -Eeuo pipefail
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:-mosaicstack.dev}"
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; }
# Instance -> server URL. Same map and override convention as seat-logins.sh.
declare -A INSTANCE_URL=(
[mosaicstack]="https://git.mosaicstack.dev"
[usc]="https://git.uscllc.com"
)
for inst in "${!INSTANCE_URL[@]}"; do
ov="MOSAIC_GITEA_URL_${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="MOSAIC_GITEA_URL_${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; }
T="$(cat "$ADMIN_TOKEN_FILE")"
PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)"
if curl -sf -o /dev/null -H "Authorization: token $T" "$BASE/api/v1/users/$SEAT"; then
curl -s -o /dev/null -X PATCH -H "Authorization: token $T" -H "Content-Type: application/json" \
-d "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}" \
"$BASE/api/v1/admin/users/$SEAT"
act="reset-pw"
else
curl -s -o /dev/null -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \
-d "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}" \
"$BASE/api/v1/admin/users"
act="create"
fi
tmp="$(mktemp)"
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -u "$SEAT:$PW" -H "Content-Type: application/json" \
-d "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}" "$BASE/api/v1/users/$SEAT/tokens")"
if [[ "$code" != "201" ]]; then
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; 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"; PW=""
login="$(curl -s -H "Authorization: token $(cat "$D/gitea-$KEY-$SEAT.token")" "$BASE/api/v1/user" \
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
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