fix(credentials): gitea arms resolve seat slots — fail loud, no silent service fallback
ci/woodpecker/pr/ci Pipeline was successful

On a fleet host, load_credentials gitea-mosaicstack / gitea-usc now resolve
a git identity's SEAT SLOT (MOSAIC_GIT_IDENTITY with a directory under
MOSAIC_BRAIN_HOME/fleet/agents/) as the token source, mirroring
get_gitea_token in detect-platform.sh (the #1311 lineage). URL still comes
from credentials.json (provider config, not identity). A seat with an empty
or missing slot REFUSES (rc 1, identity and slot path named) rather than
falling back to the shared service store — the same no-fallback rule the
git credential helper and detect-platform enforce; a silent fallback would
act as the wrong identity (#1343 family, usc/uconnect#3084 precedent).
No identity resolved, or a non-seat identity: the service store, unchanged.
Other services untouched (no seat concept to invent).

Hermetic suite test-credentials-gitea-seats.sh (sandbox brain + sandbox
credentials.json; no real credential read), CI-reachable via
test:framework-shell. Six pins: slot-sourced token, seat-miss refusal
without fallback (token compared against the service value), no-identity
service path, non-seat identity, woodpecker indifference to the identity,
pre-set GITEA_TOKEN never overridden. Mutants killed: seat-miss-falls-back
(dies at G2), seat-reads-service-store (dies at G1).

Known pre-existing (out of scope, unchanged by this PR): this host's
credentials.json stores gitea tokens under .gitea.<inst>.default and
per-identity keys, while the loader reads .gitea.<inst>.token — the
no-identity service path fails identically before and after this change
(verified against origin/next's loader). Filed for the wrapper-defect
batch rather than widened here.
This commit is contained in:
2026-08-22 00:24:03 -05:00
parent 24294d3b77
commit cb77c7d629
3 changed files with 152 additions and 3 deletions
@@ -24,6 +24,24 @@
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
#
# GITEA SEAT SLOTS (gitea-mosaicstack / gitea-usc arms only):
# On a fleet host, a resolved git identity is a SEAT whose live credential is
# its slot file, not the shared service store. Resolution, mirroring
# get_gitea_token() in tools/git/detect-platform.sh (mosaicstack#1311 lineage):
# - MOSAIC_GIT_IDENTITY names a seat with a directory under
# ${MOSAIC_BRAIN_HOME:-~/.mosaic}/fleet/agents/<identity>/ → its token is
# read from <slot>/secrets/gitea-<instance>-<identity>.token and exported
# as GITEA_TOKEN. The URL still comes from credentials.json (it is
# provider config, not identity).
# - A seat with an EMPTY/missing slot is a REFUSAL (fail loud), not a
# fallback: there is no precedence between the seat and service stores,
# and a silent service fallback would act as the wrong identity (#1343
# family; usc/uconnect#3084 precedent).
# - No identity resolved → the service store in credentials.json, exactly
# as before. Non-fleet hosts are unchanged.
# Other services (woodpecker, authentik, ...) have no seat concept and are
# untouched by this.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +112,35 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
_gitea_seat_token() {
# Echo the seat-slot token path for $1=identity $2=instance-prefix, or rc 1
# when the identity is not a seat. Reads nothing; path logic only.
local ident="$1" pfx="$2" brain_home slot
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
slot="$brain_home/fleet/agents/$ident/secrets/gitea-$pfx-$ident.token"
if [[ -d "$brain_home/fleet/agents/$ident" ]]; then
printf '%s' "$slot"
return 0
fi
return 1
}
_gitea_resolve_seat_or_refuse() {
# $1=identity $2=instance-prefix $3=service-name (for messages).
# Seat with a readable slot → echoes the token (caller exports).
# Seat with an empty/missing slot → rc 1 with a named refusal.
# Not a seat → rc 2 (caller falls to the service store).
local ident="$1" pfx="$2" svc="$3" slot
slot="$(_gitea_seat_token "$ident" "$pfx")" || return 2
if [[ -r "$slot" ]] && [[ -s "$slot" ]]; then
tr -d '\n' <"$slot"
return 0
fi
echo "Error: load_credentials $svc: git identity '$ident' resolves to a SEAT but its slot is empty or unreadable: $slot" >&2
echo " Refusing to fall back to the shared service store — that would act as the wrong identity. Provision the slot or unset MOSAIC_GIT_IDENTITY." >&2
return 1
}
load_credentials() {
local service="$1"
@@ -183,16 +230,30 @@ EOF
;;
gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" && -n "${MOSAIC_GIT_IDENTITY:-}" ]]; then
local _seat_tok
_seat_tok="$(_gitea_resolve_seat_or_refuse "$MOSAIC_GIT_IDENTITY" mosaicstack gitea-mosaicstack)" \
&& export GITEA_TOKEN="$_seat_tok" && return 0
local _src_rc=$?
[[ "$_src_rc" -eq 2 ]] || return 1
fi
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;;
gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" && -n "${MOSAIC_GIT_IDENTITY:-}" ]]; then
local _seat_tok
_seat_tok="$(_gitea_resolve_seat_or_refuse "$MOSAIC_GIT_IDENTITY" usc gitea-usc)" \
&& export GITEA_TOKEN="$_seat_tok" && return 0
local _src_rc=$?
[[ "$_src_rc" -eq 2 ]] || return 1
fi
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)