ci/woodpecker/pr/ci Pipeline was successful
rev-code-01's non-blocking nit on #1313 round 2. The no-linking-step paragraph pointed at the bridge explanation as 'described below'; it is above. Now names the section, which survives further reordering better than a direction word does. Text-only. Verified with the repo's PINNED prettier (3.8.1 via pnpm-lock.yaml) and the sanitization gate, both clean.
134 lines
6.2 KiB
Markdown
134 lines
6.2 KiB
Markdown
# Seat Identity & Credentials Guide
|
|
|
|
Every agent that touches a Mosaic-managed git host acts as a named seat with its own credential.
|
|
This guide is how that works on a host, and what an agent must never do with it.
|
|
|
|
The mechanism below is the framework's. The specific paths, seats and stores are per-host:
|
|
measure yours before trusting any of them.
|
|
|
|
## The rule
|
|
|
|
**One seat, one identity, one token file.** A seat never borrows another seat's credential, never
|
|
falls back to a shared owner account, and never carries a second copy of its own token. A second
|
|
copy is drift, and drift surfaces as the stale copy returning 401 — which reads as a revoked
|
|
token and sends whoever debugs it somewhere else entirely.
|
|
|
|
A credential refusal is correct behavior, not a bug to route around. If git refuses with a
|
|
fail-closed diagnostic, the fix is to provision or correct _your_ identity. Escalate; do not
|
|
substitute.
|
|
|
|
## How a credential is resolved
|
|
|
|
Find the helper the way **git** does, not with `command -v`. Git runs whatever
|
|
`credential.helper` names, and on a Mosaic host that is an absolute path — so a PATH lookup
|
|
answers a different question and the two disagree the moment the PATH copy is removed. It was
|
|
removed on hosts that have completed that migration.
|
|
|
|
```bash
|
|
git config --get-all credential.helper # every helper, in the order git tries them
|
|
```
|
|
|
|
Git tries **each** configured helper in turn until one supplies a credential. A fail-closed
|
|
helper supplies nothing, so a second helper configured behind it silently becomes the one that
|
|
answers. When you care which binary serves a credential, read the whole list.
|
|
Resolve all three forms git accepts — absolute path, `!command`, and a bare name looked up on
|
|
PATH — not just the one your host happens to use.
|
|
|
|
The helper resolves the identity in this order:
|
|
|
|
1. `$MOSAIC_GIT_IDENTITY`
|
|
2. `git config --get mosaic.gitIdentity`
|
|
3. the username git supplied on stdin
|
|
|
|
It maps the host to a store prefix — `git.mosaicstack.dev` to `gitea-mosaicstack`,
|
|
`git.uscllc.com` to `gitea-usc`. Any other host is declined quietly with rc=0, which is not an
|
|
error and raises no escalation.
|
|
|
|
Then it chooses **one** of two stores, and reads exactly one file:
|
|
|
|
```
|
|
brain_home = ${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}
|
|
|
|
seat — when $brain_home/fleet/agents/<identity>/ EXISTS
|
|
$brain_home/fleet/agents/<identity>/secrets/<prefix>-<identity>.token
|
|
service — otherwise
|
|
~/.config/mosaic/secrets/gitea-tokens/<prefix>-<identity>.token
|
|
```
|
|
|
|
**There is no precedence between the two and no fallback from one to the other.** The existence
|
|
of the seat directory decides it. A seat that has a directory and an empty slot fails closed; it
|
|
does not reach the service store. That is the intended behavior — the alternative is an agent
|
|
silently acting as somebody else.
|
|
|
|
If the file is unreadable the helper **fails closed**: it refuses and writes a durable record to
|
|
the escalation spool. It does not fall back to a shared account. The record is what exists — any
|
|
alerting built on top of it is a separate, best-effort concern and is not performed by the helper,
|
|
so do not wait for a notification that nothing sends. That fallback is what made
|
|
`usc/uconnect#3084` unattributable, and it was removed deliberately.
|
|
|
|
Verify the helper you actually have:
|
|
|
|
```bash
|
|
h=$(git config --get credential.helper)
|
|
grep -c 'FAIL CLOSED' "$h" # expect >= 1
|
|
grep -c 'fleet/agents' "$h" # expect >= 1; 0 means it predates mosaicstack#1311
|
|
```
|
|
|
|
## Where a seat's token lives
|
|
|
|
The seat slot is the **only** copy:
|
|
|
|
```
|
|
~/.mosaic/fleet/agents/<seat>/secrets/<prefix>-<seat>.token real file, mode 600
|
|
```
|
|
|
|
The framework store at `~/.config/mosaic/secrets/gitea-tokens/` holds tokens for **service
|
|
identities only** — identities with no seat directory. A seat's token does not belong there.
|
|
|
|
Before mosaicstack#1311 the deployed helper knew only the service store, and seats were bridged
|
|
with a symlink from the store into the slot. **Those bridges must be removed once a seat-aware helper is deployed, and must not be
|
|
recreated.** Remove them only after the helper can reach the slot without them; the reverse order
|
|
takes every seat offline. A symlink
|
|
is not how a system finds a credential; the helper resolving the right store is.
|
|
|
|
`.principal` and `.scopes` beside the token are grant records, not secrets. They are tracked. The
|
|
`.token` never is.
|
|
|
|
### Provisioning a new seat
|
|
|
|
1. Create `~/.mosaic/fleet/agents/<seat>/secrets/` mode 700.
|
|
2. Write `.principal` (the Gitea login) and `.scopes` (the granted scopes), mode 600.
|
|
3. The estate operator mints the token into the seat slot, mode 600. Agents do not mint their
|
|
own, and do not ask another agent to mint one for them.
|
|
4. Verify with an authenticated `GET /user` and confirm the returned login is the seat, **not the
|
|
minting account**. Record the date in `ENTITY.md`. Never record the value.
|
|
|
|
There is no step that links the framework store to the slot. A seat-aware helper reads the slot
|
|
directly; a store entry pointing at a slot is the bridge described in **Where a seat's token lives** above,
|
|
and it is not part of provisioning.
|
|
|
|
Until step 3, the seat is unminted and its git writes fail closed. That is the designed state and
|
|
is safe to launch in — the seat is told at launch so it does not discover it mid-task.
|
|
|
|
## Acting as yourself
|
|
|
|
Name the identity on every invocation:
|
|
|
|
```bash
|
|
MOSAIC_GIT_IDENTITY=<seat> git push
|
|
git -c user.name=<seat> -c user.email=<seat>@mosaicstack.dev commit -m "..."
|
|
```
|
|
|
|
**Never persist `git config mosaic.gitIdentity` inside a `~/src/stack` worktree.** Every worktree
|
|
of that clone shares one `.git/config`, so a persisted identity there silently rewrites the
|
|
identity of every other seat working in that clone. The per-invocation form has no exception.
|
|
|
|
## Handling
|
|
|
|
1. **Never print a token value.** Compare by SHA-256 digest, or write `<REDACTED>`.
|
|
2. **Never stage a `.token`, `secrets.json`, or `ENTITY.md`.** Stage explicit paths and **never
|
|
`git add -A`** — `secrets/*.principal` and `secrets/*.scopes` are covered by no ignore rule.
|
|
3. **Never place a token in an environment variable** in an interactive session. A `declare -x`
|
|
dump has leaked the whole environment to a terminal before.
|
|
4. **No real credential or operator data on a sandbox VM, ever.**
|