Authentication and Namespaces¶
Authentication in Shaken Fist has three moving parts, and most of this page is an expansion of them.
A namespace key is a first-class object owned by a namespace, with its own UUID, state machine and event stream. It may carry an expiry, a set of scopes, and a record of where it came from.
An access token is a short lived JWT derived from a key by a request to the REST API, and passed on subsequent calls as an HTTP header. It inherits the key's scopes, and it is bound to the key by a nonce, so changing or removing the key invalidates every token already derived from it. Tokens also expire on their own, in which case a caller re-authenticates and retries.
A scope names a class of operation that a key, and any token derived from it, is permitted to perform. A key with no scopes recorded may do anything its namespace may do, which is how every key created before scopes existed continues to behave.
Keys arrive two ways. An administrator creates one, or a workload that already has an identity somewhere else -- a GitHub Actions job, a service account in an Authentik instance -- exchanges that identity for one. A key obtained by exchange is an ordinary namespace key in every respect; nothing downstream knows or cares how it was created.
Terms used here are defined in the glossary.
Shaken Fist logically divides objects into "namespaces". These namespaces can be thought of as tenants, although there might be other reasons to divide resources into their own buckets -- for example the Shaken Fist CI system uses a namespace to store an archive of the images used for CI runs, and that namespace is referred to by the namespaces actually running tests. The process to create a namespace is discussed in the Creating namespaces section below.
Namespaces¶
All resources (instances, networks, network interfaces, and artifacts) are assigned to a namespace. Notably, blobs are not within namespaces and more than one artifact can refer to a given blob even if those artifacts are in different namespaces. It is assumed that knowing the UUID of a given blob implies that you can access it.
All requests to Shaken Fist have a namespace context. The namespace "system" is reserved and is used for administrative actions. Please note that the authentication configuration created by the deployer is for the system namespace, and if used directly will result in instances and other objects being created in that namespace. While this is supported and will function as expected, it is probably undesirable for anything other than a single user installation.
By default only requests in the system namespace are able to access resources
in other (foreign) namespaces. Before Shaken Fist v0.7 this behavior was hard
coded and not configurable. As of Shaken Fist v0.7, this is implemented in
the form of "trusts", where every namespace is configured to "trust" the system
namespace. This makes the resources visible to the system namespace. You cannot
remove the trust of the system namespace from your namespaces. However, you can
choose to trust additional namespaces, and this is done via the
sf-client namespace trust ... series of commands and associated API calls.
Configuring a client¶
When the deploy playbook ran, it created two authentication artifacts on each
node which are useful to get started with Shaken Fist. First off, there
is /etc/sf/sfrc, which is a file you can source in your shell to provide
authentication environment variables. These environment variables can be used
by Shaken Fist command line clients, Ansible modules, and the Python API client
implementation itself. An example sfrc looks like this:
# Command line hinting
eval "$(_SF_CLIENT_COMPLETE=bash_source sf-client)"
# Client auth
export SHAKENFIST_NAMESPACE="system"
export SHAKENFIST_KEY="oisoSe7T"
export SHAKENFIST_API_URL="https://shakenfist/api"
The first line of the file enables tab completion for sf-client in a bash
shell. The last three lines are the important authentication details:
- the namespace we want to use is called "system".
- our access key is "oisoSe7T".
- the URL the API exists at is "https://shakenfist/api"
sfrc is only useful to users of Unix-like shells, so there is also a JSON form
of this configuration information, which is written by the deployer at
/etc/sf/shakenfist.json. Here's an example:
The Shaken Fist command line clients, Ansible modules, and the Python API client will look for configuration in the following locations:
- environment variables.
.shakenfistin your home directory, that is~/.shakenfist./etc/sf/shakenfist.json.
Creating namespaces¶
You can create your first namespace like this, assuming you are authenticated as the system namespace:
By default a new namespace has no access keys or trusts configured, and therefore is only accessible to users of the system namespace.
Namespace keys¶
Namespaces are accessed by providing a valid "key" for the namespace. While keys have names, they do not have to be usernames and passwords -- my mental model is more like API access tokens in something like GitHub than usernames and passwords. I tend to create a new key for each program which is interacting with the namespace, and then give it a descriptive name.
You can create a new key like this:
There can be more than one key for a namespace. The key name is not used as part of the authentication process, and is largely used for key management (deleting the key) and logging which access token was used in the event logs.
A key may also carry an expiry, and may have the cluster choose its secret rather than supplying one; both are described below, and both are REST API features which the command line does not expose yet.
Info
Please note the key prefix "_service_key" is reserved for internal use within Shaken Fist. This usage is discussed in the Inter-node authentication section below.
Keys are objects¶
A key is a first-class object owned by its namespace, with the same lifecycle machinery as an instance or a network: its own UUID, state machine, event stream, and soft delete. Practically, that means a key's history is auditable -- when it was created, when it was rotated, when it expired -- rather than being an anonymous entry in a JSON blob hanging off the namespace.
Nothing about this is visible on the wire. The key management endpoints and their responses are unchanged, and a client written against an older release behaves identically.
That backwards compatibility has a cost worth knowing about: the key listing
endpoint still returns a list of key names, so a key's expiry, scopes and
provenance are recorded on the object but are not readable through any API.
NamespaceKey.external_view() renders them and nothing serves it.
Secrets the cluster generates¶
Creating a key without supplying a secret has the cluster generate one, which
is returned exactly once in the response. Generated secrets carry a recognisable
sfk_ prefix and a checksum, which makes a leaked credential greppable and lets
a secret scanner reject lookalikes without calling the API. The prefix is
reserved against operator-supplied secrets so that /auth can reject a
malformed one before doing any bcrypt work.
The format, the reservation and the upgrade caveat are covered in the operator guide.
Rotation¶
Adding a key whose name already exists rotates that key rather than creating a second one. The stored hash is replaced, and so is the nonce -- which means every access token minted from the old secret stops validating immediately. The key object itself survives with its UUID and its event history intact, so a rotation shows up as an event on the key rather than as a delete and a create.
This is long-standing behaviour: add-key has always overwritten a key of the
same name. It is called out here because it is the mechanism behind revocation,
and because rotation clears any expiry the key previously carried.
Expiry¶
A key may carry an optional expiry, as epoch seconds, supplied when the key is created or updated. A key with no expiry never expires, which is the default and matches every key created before this feature existed.
Enforcement is at the point of use, not on a timer. An expired key can neither
mint new tokens at /auth nor validate a request, from the instant it lapses
-- there is no window in which an expired key still works because a cleanup
task has not run yet. Tokens already minted from a key that later expires
remain valid until their own expiry, which is nominally fifteen minutes; delete
the key if you need them invalidated immediately, since that changes the nonce.
Expired keys are tidied up by the cluster daemon rather than vanishing the
moment they lapse. It soft-deletes keys that expired more than
NAMESPACE_KEY_REAP_GRACE seconds ago (one hour by default; set it to 0 to
disable reaping and keep expired keys forever), and the standard object reaper
hard-deletes them once they have been soft-deleted for CLEANER_DELAY. The
grace period exists so that an operator debugging automation which suddenly
stopped working can still see the key that lapsed. Because enforcement is at
the point of use, none of these timings affect security -- only how long the
evidence sticks around.
Scopes¶
A token carries a set of scopes naming the classes of operation it
may perform. A scope is a <family>.<verb> string, and there are three
verbs:
| Verb | Meaning | HTTP methods |
|---|---|---|
read |
Observe without changing | GET, HEAD |
write |
Create or modify | POST, PUT, PATCH |
delete |
Destroy | DELETE |
Scopes are not hand-assigned per endpoint. The verb comes from the HTTP method and the family from the resource class, so coverage is automatic and a newly added endpoint is governed the moment it exists. The families in use are:
admin, agentoperation, artifact, auth, blob,
clusteroperation, instance, interface, label, network,
node, upload
So GET /blobs needs blob.read, and DELETE /instances/{ref} needs
instance.delete.
Where the derivation would mislead, a resource sets scope_family, or
a method carries @api_base.scope(...) to override the verb. Both are
deliberate and greppable; tools/check-endpoint-authentication.sh
requires the decorator form to be outermost so it cannot be swallowed
by another decorator.
Two verbs exist only as overrides, because the HTTP method describes the mechanism rather than the privilege:
| Verb | Endpoints | Why not the derived verb |
|---|---|---|
console |
/instances/{ref}/vdiconsolehelper, /instances/{ref}/vdiconsoleproxy |
Both are GET, but both return credentials for interactive keyboard and mouse control of the guest. instance.read must mean observation, or a monitoring credential can take a machine over. |
execute |
/instances/{ref}/agent/execute |
Arbitrary command execution inside a guest is a different privilege from creating an instance, and an operator would sensibly grant one without the other. |
Adding a verb is a vocabulary decision, not a convenience: the test is whether anyone would sensibly write a mapping rule granting it alone. The full set is pinned by a test over the real routing table.
Wildcards and compatibility¶
A key with no scopes recorded mints a token carrying the wildcard *,
which satisfies everything. Every key created before scopes existed is
in that state, so nothing an operator already has behaves differently.
Tokens minted before the scopes claim existed carry no claim at all
and are likewise treated as wildcard, so an upgrade does not
invalidate tokens already in flight.
A scope of the form <family>.* grants every verb in one family, so
blob.* is blob.read, blob.write and blob.delete together.
Granting a whole family is the common case when writing a mapping
rule, and spelling out three verbs invites getting one wrong. The
match is on the family, not on characters: node.* does not reach
nodegroup.read.
Scoped keys are produced by the federated exchange. A scoped token is refused with a 403 for anything outside its scopes, and is refused for any endpoint whose scope cannot be derived — a scope system which allows what it cannot classify is not one.
Administrative endpoints¶
Endpoints guarded by caller_is_admin require both the system
namespace and the cluster-admin scope, in addition to the derived
scope for the operation itself. Being in the system namespace used to
be sufficient, which meant a narrowly scoped key minted into system
could reach every administrative endpoint. Unscoped keys carry the
wildcard and satisfy all of this, so existing administrative
automation is unaffected.
cluster-admin is hyphenated rather than dotted because it is not a
<family>.<verb> scope and does not name a family. Of the twenty
methods caller_is_admin guards, only two derive an admin.* scope;
the rest derive node.*, issuer.*, auth.* and blob.read. No
family wildcard can produce it, which is why it carries no dot.
Requiring both axes is deliberate. cluster-admin says the caller may
act administratively at all; the derived scope says which operation.
That is what makes a least-privilege administrative credential
possible:
grants cluster-wide visibility to a monitoring workload that provably cannot delete a node. If administration were a single all-or-nothing flag, that credential could not be expressed.
Access tokens¶
A key is not sent on every request. It is exchanged once for a short lived access token, and that token is what subsequent requests carry.
Obtaining a token¶
The authentication endpoint /auth is used to obtain a token to authenticate
future API requests. For example, I can obtain an authentication token from the
REST API using curl like this:
curl -X POST https://shakenfist/api/auth -d '{"namespace": "system", "key": "oisoSe7T"}'
{
"access_token": "eyJhbG...IkpXVCJ9.eyJmc...wwQ",
"token_type": "Bearer",
"expires_in": 900
}
That is, a HTTP POST request to the /auth endpoint for the REST API (in our
case hosted at https://shakenfist/api) with a JSON body containing a dictionary
of the namespace name and the key to use.
In the response the access_token value of eyJhbG...IkpXVCJ9.eyJmc...wwQ is
our JWT token and has been truncated in this example for readability. Authentication
tokens expire after a fixed period of time (nominally 15 minutes), but you will
be informed that the token as expired by receiving a 401 Unauthorized response.
If that occurs, simply create a new token as above and retry your request.
Subsequent requests to the REST API pass the token via an Authorization HTTP
header, and should request a Content-Type of application/json. For example,
to list the namespaces in our deployment we would make a curl request like this:
curl -X GET https://shakenfist/api/auth/namespaces \
-H 'Authorization: Bearer eyJhbG...IkpXVCJ9.eyJmc...wwQ' \
-H 'Content-Type: application/json'
[
{
"name": "adhoc",
"state": "created",
"trust": {"full": ["system"]}
}, {
"name": "ci",
"state": "created",
"trust": {"full": ["system"]}
}, {
"name": "system",
"state": "created",
"trust": {"full": ["system"]}
}
]
The JSON response here has been formatted for readability.
Info
Note the word "Bearer" before the access token in the Authorization header.
What is inside a token¶
JWT authentication tokens are base64 encoded parts separated by the . character.
They are therefore trivial to decode. A decoded example (generated by the online
decoder at https://jwt.io/) is:
{
"alg": "HS256",
"typ": "JWT"
}
.
{
"fresh": false,
"iat": 1669786988,
"jti": "906f4bfa-3218-4d07-a036-ac6b44ded67e",
"type": "access",
"sub": [
"system",
"deploy"
],
"nbf": 1669786988,
"exp": 1669787888,
"iss": "shakenfist",
"nonce": "ByKNRUVBfMBoQC1Z"
}
.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
You can see here that Shaken Fist stores the authenticated namespace system and
the key used to authenticate deploy under the sub key in this token. You should
not assume that the content of JWT tokens produced by Shaken Fist are opaque to
users.
The nonce¶
For releases prior to v0.7, the token was blindly trusted for authentication. From v0.7 we verify that the named key still exists in the namespace before authorizing API requests. This test is performed by updating a "nonce" value for a given key when the key is updated. The JWT token a caller is handed includes this nonce, and if the nonce we are handed on a request does not match the current value in the database the request is rejected.
The nonce is therefore the revocation mechanism for derived tokens: rotating, deleting or expiring a key changes or removes its nonce, and every token already minted from that key stops validating on its next request.
Federated identity¶
A workload that already has an identity somewhere else -- a GitHub Actions job, a service account in an Authentik instance -- can trade that identity for a scoped, expiring namespace key without anyone having stored a long-lived Shaken Fist secret alongside it. The long-lived CI secret is the thing this exists to delete.
Three objects make up the chain, and each is a real database-backed object with its own events:
- A trusted issuer is an identity provider the cluster will believe. It is cluster-wide and administrative, because deciding who may vouch for identities here is not a per-namespace decision.
- A mapping rule is a namespace's standing offer: an identity from this issuer, carrying these claims, may mint a key here with these scopes. It is owned by the namespace it mints into.
- The minted key is an ordinary namespace key. Nothing downstream knows or cares that it was federated; it authenticates, carries scopes, expires and is reaped exactly as any other key is. Its provenance records the rule, the issuer, and the claims that were actually satisfied.
The exchange¶
POST /auth/federated takes {token, namespace, rule} and returns a
key. The order it works in is a security property rather than an
implementation detail, because the endpoint is reachable by anyone:
- Refuse if the body is larger than
FEDERATION_MAX_TOKEN_BYTES, before anything parses it. - Count the attempt against the source address's rate limit.
- Read the
issclaim without verifying anything and refuse if no trusted issuer matches. No network yet. - Verify the signature against the issuer's JWKS.
- Check
aud,expandnbf. - Load the named rule and check its bound claims.
- Claim this
(token, rule)pair, refusing a replay. - Mint the key.
Steps 1 to 3 preceding step 4 matter more than they look. The JWKS
fetch is a synchronous outbound HTTP request made inside a request
worker, so an unfiltered path would let anyone with a made-up iss
tie up workers on connections to a host of their choosing.
The meter is step 2 rather than step 3, and that is the ordering most likely to be undone by accident. Only the argument checks sit above it. Resolving the issuer is a scan of every configured issuer, reading state and URL per row, so it is itself work worth metering -- with the meter below it the cheapest request to send would be among the more expensive ones to answer. A new step belongs below step 2 unless it touches neither the database nor the network.
Signature verification is pinned to asymmetric algorithms. If HS256
were accepted, an attacker could sign a token using the issuer's
public key as the HMAC secret -- the public key being, by
definition, public.
Claim matching¶
bound_claims values are exact strings, or lists of exact strings
meaning "any of these". There is no globbing, no regular expressions,
no prefix matching and no type coercion: a claim of 1 does not match
a matcher of "1".
This is deliberate and is the most likely thing to feel restrictive.
Prefix matching on repository reads naturally right up until someone
registers shakenfist-evil, and the anchored patterns needed to make
it safe are exactly what reviewers get wrong. Enumerating the branches
and repositories you mean covers the realistic cases; if practice
proves otherwise, patterns can be added later with anchoring enforced
at rule creation. Shipping them as the default is the part that would
be hard to undo.
Single use¶
An identity token is single-use per rule. Once exchanged through a given rule it cannot be exchanged through that rule again -- but it can still be exchanged through a different rule, which is how a workflow that needs two namespaces gets into both.
The pair is claimed by inserting it into a table with a composite primary key, so the failing insert is the detection and there is no window in which two simultaneous presentations both succeed. The claim is the last thing that happens before minting, so a refusal for any other reason -- claims that do not match, a rule that has been deleted -- leaves the token still usable.
Configuring it¶
Configuring an issuer and writing a mapping rule are operator tasks, and the worked end to end example -- registering GitHub as an issuer, writing a rule, and the workflow YAML that exchanges a token -- lives with them in the operator guide.
Inter-node authentication¶
Requests between Shaken Fist nodes use the same authentication system and REST API as external API requests. When a node makes an API request to another node, the originating node will create (or reuse) a "service key" specific to the namespace of the original request.
When a request is made from the "system" namespace for a resource in a different namespace, the API request is made using the foreign namespace and the foreign namespace's service key.
Service keys exist in the namespace's key data structures just as other keys do, and are therefore visible when you list keys. As of v0.7, service keys expire after five minutes, and are never reused. Before v0.7 service keys were always named "_service_key". From v0.7 service keys have a name of the form "_service_key[a-zA-Z]+".
Key storage¶
Shaken Fist stores keys in MariaDB across two tables: namespace_keys holds
the immutable values (UUID, owning namespace, key name), and
namespace_key_attributes holds what rotation changes (the hash, the nonce,
the expiry) along with the key's scopes and its provenance. The secret itself
is never stored -- what is kept is the base64 encoding of the secret after
salting and hashing, with the python bcrypt library performing salting,
hashing and verification.
The (namespace, name) pair carries a unique index, which is what makes a key
name unique within its namespace and what serves the per-namespace listing on
the authentication path.
Keys previously lived in a keys JSON column on the namespace_attributes
table. Existing keys are migrated into the new tables by
sf-ctl ensure-mariadb-schema during upgrade, preserving each key's hash,
nonce and expiry exactly, so tokens minted before the upgrade continue to
validate. The old column is left in place but is no longer read or written.
Note that this means downgrading after the migration loses any key created
afterwards; keys that predate the upgrade are unaffected.
Secrets and the event log¶
Credentials never appear in events. This matters more than it might sound, because events are written to syslog and shipped to Loki, so a credential in an event is a credential in log aggregation -- somewhere with weaker access control than the credential itself.
Concretely: minted tokens, presented tokens, stored hashes and nonces are all absent from event payloads. What is recorded is the key name, which is what makes an audit trail useful without making it a credential store.
The generic API request tracing, which records request and response bodies for
debugging, does not log bodies for any route under /auth. Those routes carry
plaintext key secrets inbound and minted tokens outbound. The request URL is
still recorded, so the trace retains the namespace and the key name and loses
only the credential.