diff --git a/packages/mosaic/framework/tools/fleet/README.md b/packages/mosaic/framework/tools/fleet/README.md index 89a65120..0d781c17 100644 --- a/packages/mosaic/framework/tools/fleet/README.md +++ b/packages/mosaic/framework/tools/fleet/README.md @@ -13,11 +13,12 @@ Seat lifecycle tools for a Mosaic fleet. Paths are relative to ## Onboarding a seat's credential ``` -MOSAIC_ADMIN_SEAT= mint-seat-credential.sh +MOSAIC_ADMIN_SEAT= MOSAIC_SEAT_EMAIL_DOMAIN= mint-seat-credential.sh ``` - The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents//secrets/gitea--.token`. It is never printed. -- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_` overrides a server URL. +- `MOSAIC_SEAT_EMAIL_DOMAIN` is required (no default): the framework ships no estate-specific domain. +- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_` overrides a server URL (hyphens in the instance name become underscores in the variable, as in `seat-logins.sh`). - The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600. - `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone. - Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network). diff --git a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh old mode 100644 new mode 100755 index 1c452346..4bf38e08 --- a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh +++ b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh @@ -14,6 +14,10 @@ # 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 @@ -28,13 +32,43 @@ # # 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:-mosaicstack.dev}" +EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}" SEAT="" usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; } @@ -53,14 +87,18 @@ done [[ "$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. +# 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="MOSAIC_GITEA_URL_${inst^^}" + ov="$(url_override_var "$inst")" [[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}" done [[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')" @@ -71,31 +109,39 @@ mkdir -p "$D"; chmod 700 "$D" rc=0 for KEY in $INSTANCES; do - ov="MOSAIC_GITEA_URL_${KEY^^}" + 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 -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}" \ + 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 - 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\"}" \ + 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)" - 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")" + 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=""; continue + 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' @@ -109,10 +155,12 @@ p=pathlib.Path(d) for suf in ("token","scopes","principal"): (p/f"gitea-{key}-{seat}.{suf}").chmod(0o600) PY - rm -f "$tmp"; PW="" + rm -f "$tmp"; PW=""; rm -f "$AUTH_CFG" "$USER_CFG" - login="$(curl -s -H "Authorization: token $(cat "$D/gitea-$KEY-$SEAT.token")" "$BASE/api/v1/user" \ + 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 diff --git a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh index e7a01f1c..97fffce0 100755 --- a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh +++ b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh @@ -11,6 +11,17 @@ # override MOSAIC_GITEA_URL_ is honoured. # M4 no admin seat configured is a usage error (rc=3), nothing written. # M5 the admin token value never appears on stdout or stderr. +# M6 secrets never touch argv: no Authorization header, no -u user:pass, no +# inline --data JSON carrying the password, on any curl invocation; auth +# travels in --config files and bodies in --data @files (#1343 class, +# rev-security-01 review 259 blocker). +# M7 the scopes record discriminates: a requested-but-not-granted scope +# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE, +# and a mutant writing the requested set fails here (both reviewers). +# M8 hyphenated instance names resolve their override through the underscore +# variable, matching seat-logins.sh (SF3). +# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3), +# nothing written (framework-PR firewall answer). set -euo pipefail WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}" @@ -38,23 +49,34 @@ done export PATH="$MOCK_BIN:$SYS_BIN" CALLS export MOSAIC_BRAIN_HOME="$BRAIN" export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test" +export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test" unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES -# --- mock curl: records method + URL, answers the minting sequence ----------- +# --- mock curl: records method + URL + a REDACTED auth marker, answers minting -- cat > "$MOCK_BIN/curl" <<'EOF' #!/bin/bash -method=GET; url=""; out=""; wcode=0 +method=GET; url=""; out=""; wcode=0; auth=""; body="" while [ $# -gt 0 ]; do case "$1" in -X) method="$2"; shift 2 ;; -o) out="$2"; shift 2 ;; -w) wcode=1; shift 2 ;; - -H|-d|-u) shift 2 ;; + --config) + if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi + if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi + shift 2 ;; + --data) + case "$2" in + @*) body="@file" ;; + *) body="inline" ;; + esac + shift 2 ;; + -H|-u) shift 2 ;; http*) url="$1"; shift ;; *) shift ;; esac done -printf '%s %s\n' "$method" "$url" >> "$CALLS" +printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS" emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; } case "$method $url" in "GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet @@ -77,6 +99,13 @@ rc=$(run newseat) grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT" [ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat" +# M9: email domain is required, unset is a usage error, nothing written. +rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat) +[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")" +grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN" +[ ! -s "$CALLS" ] || fail "M9: API called without an email domain" +[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain" + # M1 + M3 + M5: mint on the single configured instance. : > "$CALLS" rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat) @@ -92,7 +121,33 @@ grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1 grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")" if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")" -if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err"; then fail "M5: admin token value leaked to output"; fi +if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi + +# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT. +if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then + fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response" +fi + +# M6: no secret ever travels argv — every call authenticates via --config +# (token header or user= basic-auth directive) and bodies go as --data @file. +while IFS= read -r line; do + case "$line" in + *auth=NONE*) fail "M6: unauthenticated call: $line" ;; + *body=inline*) fail "M6: inline body (secret in argv risk): $line" ;; + esac +done < "$CALLS" +[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")" +grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")" + +# M8: hyphenated instance name resolves its override via the underscore variable. +printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token" +chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token" +export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test" +: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat" +rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat) +[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")" +grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")" +unset MOSAIC_GITEA_URL_MY_INST # M2: admin token missing for the instance is reported, rc=1, nothing written. rm -rf "$BRAIN/fleet/agents/newseat"