On this page
Security
Where does trust start and stop in a Stado deployment? This page names each credential class and what it opens, the shape of the host channel, what never leaves a host, how credentials rotate, and what enrollment actually proves. Command surfaces live in cli; this page is the boundary map.
Tokens and what each opens
There is no global dashboard bearer. Every API bearer is verified against a value held in Skarbiec, the separate credential service, and every comparison is constant-time. A missing or empty verifier item is an error, never access: the object, release, and service routes report it as a redacted 503, and the host-health route refuses with a 401.
| Bearer | Verified against | Opens |
|---|---|---|
| Object API token | The configured namespace's own Skarbiec item | Object actions (get, put, stat, list, delete) inside that one namespace, and only on keys matching the namespace's prefix allowlist. An out-of-scope key or action is unauthorized before the token is even compared. |
| Release publisher token | A per-product Skarbiec item resolved from the exact product prefix inside stado://releases |
Immutable release writes for that product prefix. The former global object token is never consulted on this route. |
| Service deployer token | A per-service Skarbiec item resolved from the service name and action | The service API's status and restart routes for that service and action, nothing wider. |
| Registry API client token | The exact registry_api.clients.<client>.item, named <client>-registry-api, and its Skarbiec token field |
Only the client's declared policy-read, policy-write, cleanup-read, cleanup-run, converge-read, and converge-apply actions. A Wisent account session does not open these routes. |
| Host-health beacon token | The bearer stored as stado-host-health-api/token, and nothing else |
PUT /api/host-health — one route. Machine clients are authorized separately through exact client policies. |
Sources: authorize_object, authorize_release, and authorize_host_health
in stado-rs/src/dashboard/listener/auth/object.rs, and authorize_service
in stado-rs/src/dashboard/listener/auth/client.rs; the namespace
prefix-allowlist model in stado-rs/src/config/boundaries/object/policy.rs.
Every active product namespace must hold explicit object-gateway credentials;
releases is intentionally absent from that list because it stays on the
dedicated public GET-only release route (ACTIVE_OBJECT_NAMESPACES in
src/config/boundaries/object/mod.rs). The gateway authorizes a write by
matching its key against the namespace's prefix allowlist — a write under a
prefix no namespace declares is refused with a 401, which is why the whole
autonomy layer is rooted under state/ (operations).
The beacon writer holds the narrowest grant in the fleet: the dedicated
stado-host-health-beacon Skarbiec consumer resolves only
stado-host-health-api/token, an unreadable or over-broad grant is a
failure, and the opaque grant sits owner-only at
~/.stado/host-health-beacon-skarbiec-token (operations).
The registry verifier uses consumer stado-registry-api-verifier and its own
owner-only grant file, by default
~/.stado/stado-registry-api-verifier-skarbiec-token. That grant is not the
Desktop client bearer. Desktop reads its raw client bearer from the token file
assigned to the exact active endpoint, by default
~/.stado/registry-api-desktop-token, on every request. It does not send that
bearer to a different selected source. Missing client permission returns 401;
an unreadable verifier or malformed client declaration returns 503.
Configuration and permitted actions are described in
Registry API access.
The host channel
Every host mutation rides one shared SSH channel whose option set is derived
from a single source (deploy/host_reboot.rs::ssh_reboot_argv) rather than
re-typed per command, so BatchMode=yes, ConnectTimeout and
StrictHostKeyChecking=accept-new cannot drift between the stado host and
stado service commands. The remote program is fixed and narrow, it reports
through the tab-delimited STADO_* marker protocol, and registry data never
becomes a shell fragment — the splice rules that refuse it are in
stado-rs/src/deploy/service/remote/validate.rs.
stado host exec is the only operator-worded remote execution, and it is an
allowlist, not a shell. Three independent barriers stand between the
operator's words and the host, all three enforced in
stado-rs/src/deploy/host_exec/allowlist/mod.rs:
- Character rejection. Every word must consist only of characters no
shell treats specially;
;,|,&,$, backtick, quote, newline, redirection and glob are refused by name first. - Exact allowlist match. The joined words must equal one approved entry
exactly — no prefix match, no extra arguments, no operator-supplied path,
because a command that took a path could read
~/.ssh/id_ed25519. - Fixed argv. What runs is the matched entry's own compile-time argv of absolute paths. The operator's words select an entry; they never become part of the command line.
Extending the allowlist is a commit: each ApprovedCommand entry carries the
fixed argv and a why field justifying unattended execution as the
registry-managed login user. An entry without a defensible answer there does
not belong in the table. Every entry is read-only, and no entry takes an
operator-supplied path, so the allowlist cannot be pointed at a file.
GUI identity placement and Apple challenges
A trusted Apple device is a macOS user session, not a host-wide property. An
apple-account identity binding therefore names both the target and the user.
stado host gui-automation status <target> reports the current console user,
the user whose TCC database carries the grants, the declared identity binding,
the signed helper version, and separate gui-ready and
apple-challenge-ready results. A helper installed for one user cannot make
another user's session ready.
stado host gui-automation grant-accessibility <target> compiles the Apple
challenge helper from the Stado source, signs it with the fixed
com.wisent.stado.apple-challenge-capture identifier, installs it at
/usr/local/libexec/stado-apple-challenge-capture, and grants the exact code
requirement in the registry-bound user's TCC database. Its --preflight mode
runs in that user's Aqua bootstrap, calls AXIsProcessTrusted() without the
prompt option, and opens no consent window or Apple notification.
An authorized Weles login asks Stado to resolve both ends before it submits a
password: one observed and drivable Apple-account holder, and the Weles broker
on the execution target. Only after Apple displays the trusted-device prompt
does the signed helper scan it, press exact Apple buttons, and write one
six-digit code to an owner-only file under ~/.stado/work. Stado removes that
file after reading it and sends the digits only on standard input to
skarbiec apple-challenge-put on the execution host. The code never enters
argv, the registry, a receipt, or a diagnostic.
What never leaves a host
Stored credential values are moved onto hosts or minted on them; they are not read back.
stado service secret-syncin its item form resolves the Skarbiec item on the host, by the host's own Stado identity, so the value never travels on the channel and the operator's consumer needs no grant for it (deploy/service/ops/secrets/bearer.rs::sync_service_item_secret).- The value-carrying form reads the secret through the isolated
service-verifier grant and carries it in the SSH request body; it is never
printed or placed in argv (
stado service secret-sync --help). Existing unrelated variables in the env file stay on the host and never cross back to the operator. stado service auth-checkwith an item reference resolves the bearer on the host and reports only the HTTP outcome; the bearer itself never leaves the host (deploy/service/ops/secrets/bearer.rs::check_service_item_bearer).- Consumer grants can be reminted against the host's own authoritative vault
and landed owner-only at their token path; the value never crosses the
channel (
deploy/service/ops/files/sync.rs::remint_consumer_grant_on_host). - SSH channel keys:
stado fleet key generateprints the public half only; the private half never leaves the credential store (cli).
Rotation
Rotation is a first-class operation, not a re-enrollment:
- Application credentials live in Skarbiec and are managed with
stado credentials:putreads from STDIN,getis the one subcommand that renders a value,migratemoves every credential to a new backend and commits the selector, andmint-acquisition-tokenmints a request-only bootstrap token directly into an owner-only file (stado credentials --help). - Service runtime secrets rotate with
stado service secret-sync, which atomically replaces one variable in the unit's env file (a mode-600 atomic rename on the host, in the fixed remote programdeploy/service/remote/scripts/query/secrets.rs) and restarts the service only when asked with--restart(stado service secret-sync --help). - Channel keys rotate end to end with
stado fleet key rotate, with rollback on failure (cli).
The rotation blast radius is the namespace boundary itself: the entitlements
rotator holds its own object namespace (entitlements-rotator in
ACTIVE_OBJECT_NAMESPACES, src/config/boundaries/object/mod.rs), so
rotating one product's credential changes one Skarbiec verifier item and
touches nothing another namespace uses.
Enrollment trust
A machine is not trusted because it answered SSH. Enrollment is
agent-attested (examples, examples/fleet/): SSH
reachability and hostname discovery create only a non-routable
provisioning_targets entry, and the machine enters targets and a fleet
only after the installed agent publishes fresh capacity with its Stado
version. An unreachable host, failed installation, missing attestation, or
registry conflict removes the provisioning entry and leaves no registered
target. Once the registry catalog sets require_agent_attestation, the
validator also rejects every local target without a valid receipt.
The join API is equally narrow: both invite-token routes are authorized by
the invite token alone, and POST /api/fleet/join writes a pending request —
it creates no registry entry and cannot modify one (cli).
The money boundary
Nothing in Stado links a billing account or raises spend by itself. Stado's
billing credentials are readers — the Azure billing principal carries Billing
account reader plus subscription Billing Reader, resolved from Skarbiec with
exact per-field capabilities (cli) — and the autonomy loop carries
a budget guard that blocks new cloud placement outright when the cost
forecast exceeds the configured budget
(stado-rs/src/coordinator/passes/autonomy.rs,
autonomy). Spend follows submitted work under the scheduler's
own cost limits (costs); it is never a side effect of holding a
token.
Source: this website