Security model¶
The trust boundaries Shaken Fist enforces, and the authorisation model that backs them. The rules below are properties the code is expected to hold; the ones which exist because a real defect broke them are written up in coding_rules.md.
- Multi-tenant with namespace isolation
- JWT-based authentication, minted from namespace keys and bound to the minting key's nonce so that rotating or deleting a key revokes its outstanding tokens immediately
- Namespace keys are database-backed objects with optional expiry, enforced when the key is used rather than by a sweep
- Credentials never enter events, which are shipped to syslog and Loki;
events record the key name, and neither of the two request loggers records
a body for routes under
/auth. Both consult one predicate,api_base.handles_credentials(), and drop the body wholesale rather than redacting named fields — a name-based rule leaks the day a route arrives whose credential field it has not heard of - Secret-carrying fields are
pydantic.SecretStr, so stringifying one anywhere yields**********and the plaintext comes back only from an explicit.get_secret_value()call. This covers the namespace key hash and nonce plusAUTH_SECRET_SEED,MARIADB_PASSWORDandLOKI_AUTH_HEADER, and complements rather than replaces the structural rules above: the request-body rule catches a credential arriving before any model exists, and thesf-queuesstartup banner redacts by configuration key name because it iterates every option, including ones not yet typed. Both leaks closed during this work — that banner, andBlobTransfer.external_view()publishing a transfer's authorisation token into events — were found by querying Loki for the credential rather than by review. Seeauthentication.md, which also explains why assertions about secrets must compare.get_secret_value(): both obvious alternatives make a leak guard pass while checking nothing - RBAC with admin/user roles
- Network isolation via VXLAN
Object visibility and the two artifact guards¶
Namespace isolation is enforced at two different granularities, and the artifact endpoints are where both are visible.
Listing endpoints filter in the query or in a filter callable —
namespace_or_shared_filter(namespace, obj) for artifacts, which admits the
caller's own namespace, any namespace whose trust list names the caller,
system, and anything flagged shared.
Single-object endpoints cannot filter, because they resolve the object before
they know who is asking. arg_is_artifact_ref short-circuits a UUID straight
to Artifact.from_db with no namespace filter at all — deliberately, since
system callers legitimately reach across namespaces — so the whole of the
authorization decision rests on the decorator that runs next. There are two:
requires_artifact_accessguards the read-only routes (the artifact, its events, versions and cluster operations) and callsnamespace_or_shared_filter, the same predicate the listing uses. That reuse is deliberate: "appears in the list" and "is readable by UUID" have to be one rule, and for as long as they were two copies of a rule they disagreed.requires_artifact_ownershipguards everything that mutates, and testsrequest_namespace() not in [a.namespace, 'system']— the caller's own namespace, or the cluster admin. It consults neither thesharedflag nor the trust list. Sharing publishes an artifact for reading rather than transferring it, and a trust is a visibility grant: the operator guide introduces it as the system namespace's cross-namespace sight on a smaller scale, and being able to delete somebody's artifacts is not a smaller-scale version of being able to see them. This matchesrequires_instance_ownershipandrequires_network_ownership, which have always read exactly this way; artifacts were the one object type where trust reached past reading.
Creating an object in a namespace which trusts you is a different question
and remains allowed — see the namespace_is_trusted checks on the artifact
cache and upload routes, and on instance creation. That is the "gifting"
pattern the operator guide's ci-images example is built on. It is additive,
the receiving namespace opted in by extending the trust, and nothing it
already had is lost.
Both refuse with 404 rather than 403, so a refusal does not confirm that
the object exists. Scope enforcement is a separate and earlier gate — a caller
who fails the scope check gets 403 without either decorator running.
Resolving a name¶
Guarding a lookup is a separate problem from performing one, and for a name
the two have to agree. {artifact_ref} accepts a UUID or a name; a UUID
identifies one artifact, but names are unique only within a namespace, so
resolution needs a scope and the obvious scope — the caller's own namespace —
is narrower than what the caller can see. That gap is visible from outside: a
tenant reads a shared image's name out of GET /artifacts and then gets a
404 asking for it by that name.
Artifact.from_db_by_ref_visible_to(ref, requestor) closes it in two phases,
and the ordering carries more weight than the widening:
from_db_by_ref(ref, requestor), unchanged, including raisingMultipleObjectswhen the name is ambiguous inside the caller's own namespace. Whatever your own namespace resolves to wins. Without this, sharing an artifact calleddebian-11would silently retarget every tenant who already had one of their own by that name.- Only on a miss, an unscoped query by name filtered through
namespace_or_shared_filter— the same predicate the listing and the read guard use. More than one survivor raisesMultipleObjects, because a tenant cannot disambiguate with thenamespacebody field (they may only name their own) and picking one would be a guess. The error points at the UUID, which is never ambiguous.
Phase one is the fast path and stays free of phase two's per-candidate trust lookups.
Widening applies to reading only, and the split is expressed as two decorators
over one shared body (_resolve_artifact_ref) so the pairing is visible at
each route:
| Decorator | Pairs with | A name resolves to |
|---|---|---|
arg_is_visible_artifact_ref |
requires_artifact_access |
anything you can see, own namespace first |
arg_is_artifact_ref |
requires_artifact_ownership |
your own namespace only |
The split follows the authorization guard, not the HTTP verb — GET
/artifacts/{ref}/metadata is ownership-guarded and therefore resolves
narrowly. Either decorator also drops back to the narrow behaviour when the
caller named a namespace in the request body, since that caller asked about
one namespace specifically.
Since requires_artifact_ownership no longer honours trust, narrow resolution
mostly agrees with the guard that follows it, and the split is defence in
depth rather than the only thing standing between a name and somebody else's
artifact. It still earns its place twice over. It keeps resolution from
silently following if the guard is ever widened again, and it is observable:
faced with a name matching two artifacts the caller can see but does not own,
the read route must answer 400 and ask which one, while a write route
answers a flat 404 rather than confirming that two exist.
Instances and networks have the same shape in arg_is_instance_ref and
arg_is_network_ref and have not been widened. Sharing is an artifact-only
concept by design, so only the trust half would apply to them.
Resolving a url¶
The same read/write split applies to URLs, and that is the one that went
unnoticed for longer. Artifact.from_url filters by
namespace_or_shared_filter, so it can return an artifact belonging to
whoever shares with or trusts the caller. Right for a caller which will read
the result; wrong for one which will write to it, because the write is
add_index and add_index ends in delete_old_versions.
| Resolver | Answers | Predicate | Creates |
|---|---|---|---|
from_url() |
what may I read | visibility | optionally |
owned_from_url() |
what may I write to | ownership | never |
owned_from_url_or_new() |
as above, target namespace already settled | ownership | yes |
owned_from_url() deliberately does not create, because a route which accepts
a caller-nominated namespace has two cases to authorise apart: a trust is
enough to gift a namespace an artifact it did not have, and not enough to
replace what one it already owns resolves to. owned_from_url_or_new() exists
for the callers which have no such split — their target namespace is their own
or has already been checked — so they need not restate it. The artifact fetch
and upload routes are the ones which do have the split, and spell it out
deliberately; the resemblance between them is the authorisation, not a
duplication waiting to be factored out.
owned_from_url() also passes the namespace to SQL as a query criterion, not
only to the Python predicate. Ownership is a plain equality, and this runs on
the instance create path. The predicate is still there, and has to be: the
object iterator drops a namespace criterion of system so that listing as
system sees the whole cluster, so a pushdown standing on its own would turn an
ownership test into no test at all. Visibility cannot be pushed down the same
way — it is a trust graph walk, and narrowing the query to one namespace would
drop the shared and trusted rows from_url() exists to find.
Instance creation from a plain URL is where the two verbs meet, and it is
worth knowing why it is not simply the ownership call. Resolving disk.base
by ownership alone would give every namespace its own artifact for a shared
image's URL, and transfer_image treats an artifact with no versions as
"cluster does not have a copy" — so each namespace would download and store
its own copy of every shared image, which is the opposite of what sharing one
is for. A visible foreign artifact is therefore resolved to a blob and booted
from, exactly as the label, snapshot and upload branches of the same loop
already do, and never fetched into. Read theirs, write only your own.
Instance.snapshot() is the fourth write path, and was missed by the original
sweep because that only covered external_api/ and operations/. It is not
reachable across namespaces — the URL carries the instance UUID and the type
filter pins it to TYPE_SNAPSHOT — but it resolves by ownership anyway, so
the next artifact type minted against an instance URL does not have to
rediscover the rule. When looking for write paths, grep for the sink
(add_index) rather than for callers of the resolver.
VDI console token trust model¶
The Kerbside VDI console proxy integration uses offline signature
verification. Shaken Fist is the sole signer: sf-api mints short lived
Ed25519 (EdDSA) JWTs describing the instance, namespace, audience, expiry,
and a single-use jti. The Kerbside proxy is a pure verifier — it holds only
the public key (fetched from GET /admin/vditokenpubkey) and never any
private material, so a compromised proxy cannot mint valid tokens. There is
no callback to sf-api on the connection hot path.
The private signing key lives in a single cluster_config row,
KERBSIDE_JWT_SIGNING_KEY, with custody parallel to AUTH_SECRET_SEED. The
row holds a newest-first, two-key window of Ed25519 keypairs; rotation
(sf-ctl rotate-kerbside-signing-key) prepends a fresh key and trims to two,
so tokens signed by the previous key stay verifiable until the next rotation.
shakenfist/util/vdi_tokens.py is the only module that parses the row.
Per-node spice_server_cert_subject (published by shakenfist/node.py) is
consumed by Kerbside as the enforced backend host_subject. See the
VDI console tokens operator guide
for the operator runbook.