#!/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 (@). # 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): the admin # token, the generated password, and the minted seat token all pass through # 0600 curl --config / --data files — the landed in-tree standard # (gitea_write_auth_config in detect-platform.sh). argv is world-readable via # /proc//cmdline for the life of each request, and a bash -x trace would # print every secret otherwise. The staging files are unlinked after each use. set -Eeuo pipefail # Stage secrets into 0600 files; nothing secret reaches argv or a trace. # write_auth_config -> curl --config carrying the Authorization header # (same shape as gitea_write_auth_config in # detect-platform.sh, local so this script stays # standalone under tools/fleet). # write_user_config -> curl --config with `user =` (covers -u). # write_body -> 0600 file for --data @file. write_auth_config() { local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-auth.XXXXXX") || return 1 printf 'header = "Authorization: token %s"\n' "$1" >"$f" || { rm -f "$f"; return 1; } chmod 600 "$f"; printf '%s' "$f" } write_user_config() { local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-user.XXXXXX") || return 1 printf 'user = "%s:%s"\n' "$1" "$2" >"$f" || { rm -f "$f"; return 1; } chmod 600 "$f"; printf '%s' "$f" } write_body() { local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-body.XXXXXX") || return 1 printf '%s' "$1" >"$f" || { rm -f "$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; } T="$(cat "$ADMIN_TOKEN_FILE")" AUTH_CFG="$(write_auth_config "$T")" PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)" USER_CFG="$(write_user_config "$SEAT" "$PW")" if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then BODY="$(write_body "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}")" curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \ --config "$AUTH_CFG" --data "@$BODY" \ "$BASE/api/v1/admin/users/$SEAT" rm -f "$BODY"; BODY="" act="reset-pw" else BODY="$(write_body "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}")" curl -s -o /dev/null -X POST -H "Content-Type: application/json" \ --config "$AUTH_CFG" --data "@$BODY" \ "$BASE/api/v1/admin/users" rm -f "$BODY"; BODY="" act="create" fi tmp="$(mktemp)"; chmod 600 "$tmp" MINT_BODY="$(write_body "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}")" 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")" rm -f "$MINT_BODY"; MINT_BODY="" if [[ "$code" != "201" ]]; then echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"; 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=""; rm -f "$AUTH_CFG" "$USER_CFG" VERIFY_CFG="$(write_auth_config "$(cat "$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)" rm -f "$VERIFY_CFG" 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