#!/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 ] [--instances " "] # # 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//secrets/ # gitea--.token. Never printed. # MOSAIC_GITEA_INSTANCES space-separated instance names to mint on. # Default: every instance in the map below. # MOSAIC_GITEA_URL_ server URL override per instance (same # convention as seat-logins.sh). # MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (@). # 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