Authentication¶
Info
For a detailed discussion of how Shaken Fist authentication works, please see the discussion in the developer guide.
Terms used here are defined in the glossary.
Key expiry and reaping¶
Namespace keys may carry an optional expiry. Expiry is enforced when the key is used, so an expired key stops authenticating the instant it lapses -- there is no window during which it still works because a cleanup task has not run yet.
Tidying up expired keys is a separate, purely cosmetic concern. The cluster
daemon sweeps every fifteen minutes and soft-deletes keys which expired more
than NAMESPACE_KEY_REAP_GRACE seconds ago; the standard object reaper then
hard-deletes them once they have been soft-deleted for CLEANER_DELAY. A key
whose static row has no state row at all (a "zombie", see the orphan
reconciliation section of the database guide) is skipped by
this sweep — the hourly orphan reconciliation repairs it. A zombie key which
is still live is repaired to created, never deleted, after which the expiry
sweep governs it normally. Re-running sf-ctl bootstrap-system-key for an
existing key also heals a missing state row.
| Setting | Default | Effect |
|---|---|---|
NAMESPACE_KEY_REAP_GRACE |
3600 |
Seconds after expiry before a key is soft-deleted. 0 disables reaping, retaining expired keys forever. |
The grace period is there so that an operator investigating automation which
suddenly stopped working can still see the key that lapsed. Lengthen it if you
want a longer forensic window; set it to 0 if you would rather expired keys
were never removed. Neither choice affects security, because enforcement does
not depend on the sweep having run.
You cannot currently read an expiry back
A key's expiry is recorded on the key object, and no API returns it.
GET /auth/namespaces/{namespace}/keys answers with a list of key
names only, and the same is true of the scopes and the provenance of
a key minted by the federated exchange.
This makes the forensic window above more important than it should be:
when automation stops authenticating, the evidence that the key lapsed
is the key's disappearance and its events, not a field you can query.
Setting NAMESPACE_KEY_REAP_GRACE to 0 while investigating keeps
that evidence indefinitely.
Tracked as issue #3672.
Cluster generated key secrets¶
Secrets Shaken Fist generates itself — the short-lived service keys used between nodes, keys minted by the federated exchange, and any key you ask the cluster to generate for you — carry a recognisable format:
The prefix makes a leaked credential greppable in logs and repositories; the checksum lets a secret scanner reject lookalikes without calling the API. It costs nothing cryptographically, because a bearer credential is a random identifier rather than ciphertext, so the prefix is a label beside the random part rather than a revealed piece of it.
To have the cluster generate a key for you, create the key without supplying a secret. The generated secret is returned exactly once — only its bcrypt hash is stored, so it cannot be recovered afterwards.
This is a REST API feature which the command line does not expose yet:
sf-client namespace add-key takes the secret as a mandatory argument,
so there is no way to omit it. Until that changes, generation means
curl:
curl -X POST https://sf.example.com/auth/namespaces/myproject/keys \
-H "Authorization: Bearer ${SF_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"key_name": "deploy"}'
{"key_name": "deploy", "key": "sfk_..."}
The same applies to expiry, which is also body-only.
The prefix is reserved¶
sfk_ may not be used at the start of an operator-supplied key secret;
attempting it is refused with a 400. This is not cosmetic. /auth
rejects a presented secret which carries the prefix but fails its
checksum before bcrypt comparing it against anything, and that
shortcut is only sound if no legitimate operator secret can be shaped
that way.
Upgrade note
If you have an existing key whose secret happens to begin with
sfk_, it will stop authenticating after this upgrade, and it will
fail as an ordinary 401 rather than with a distinctive error. A
four-character prefix on a secret somebody chose makes this very
unlikely, but it is not impossible. If an automation begins failing
to authenticate immediately after upgrading and you cannot explain
it, check whether its key starts with sfk_ and rotate it.
Key names are unaffected — only secrets.
Keys and the event log¶
Credentials are deliberately absent from Shaken Fist's events. This is worth knowing about because events are written to syslog and shipped to Loki, so anything in an event has left the cluster and is sitting in log aggregation, which usually has weaker access control than the credential does.
Events record the key name that was used, never the key secret, its stored
hash, its nonce, or any minted or presented token. Separately, the API request
tracing that records request and response bodies skips bodies entirely for
routes under /auth, since those carry plaintext key secrets inbound and
minted tokens outbound. The request URL is still logged, so you keep the
namespace and the key name.
If you have log tooling that greps for tokens in Shaken Fist events, it will find nothing from these releases onward. That is the intent, not a regression.
Upgrading¶
Keys used to live in a JSON column on the namespace_attributes table and are
now first-class objects in their own tables. Migration happens during
sf-ctl ensure-mariadb-schema, preserving each key's hash, nonce and expiry
exactly, so tokens minted before the upgrade keep validating and no operator
action is required.
The migration is one-way in the usual sense: keys created after the upgrade
are not written back to the old JSON column, so downgrading to a release that
predates this change loses them. Keys that existed before the upgrade are
unaffected either way. The exposure is one upgrade cycle, and it matches the
precedent set by the node_daemon_states migration.
Federated identity¶
Workloads with an identity elsewhere -- a GitHub Actions job, a service account in an Authentik instance -- can trade that identity for a scoped, expiring namespace key rather than holding a long-lived Shaken Fist secret. The mechanics are in the developer guide; this covers what an operator has to decide and configure.
Trusting an issuer is an administrative act¶
A trusted issuer is cluster-wide, and creating one is a system
namespace operation. It says the cluster will believe tokens this
provider signs, so the decision belongs with whoever is responsible
for the cluster rather than with an individual namespace owner.
Three fields are mandatory, and three more are optional:
| Field | Required | Notes |
|---|---|---|
name |
Yes | How rules refer to this issuer. Treat it as durable |
issuer_url |
Yes | Compared to a token's iss claim exactly: no normalisation, no trailing-slash tolerance. Must be https |
audience |
Yes | Tokens must be minted for this. Usually your cluster's API URL |
jwks_uri |
No | Where the signing keys are published. Always taken from here, never from the token. Must be https. Left out, it is discovered from the provider |
client_id |
No | The OAuth client identifier you registered at the provider for human login. A workload exchange never reads it |
oidc_scopes |
No | The OpenID Connect scopes to request at human login, as one space separated string such as openid profile groups. A workload exchange never reads it |
An issuer which only verifies workload tokens -- GitHub Actions, say --
has no client registration at its provider and never will, which is why
client_id and oidc_scopes are optional. An issuer with a client_id and a
discovered login endpoint is login capable.
Every one of issuer_url, jwks_uri, audience, client_id and
oidc_scopes may be up to 255 characters, which is what their columns
hold. Discovered endpoints may be up to 1024. A longer value is
refused with a 422 rather than stored, because a value between the
limit and the column was previously accepted and then truncated by the
database -- and a truncated issuer_url or jwks_uri means tokens
checked against the wrong issuer, or signing keys fetched from the
wrong place.
The exact issuer_url comparison is deliberate. A loose comparison
here is a way to accept tokens from somewhere else entirely, so if
your tokens are refused with an untrusted-issuer message, check for a
trailing slash before checking anything else.
Both URLs must begin with https://, in lower case, and anything
else is refused with a 400 on create and on update. (HTTPS:// is
refused too: an issuer_url spelled that way could never equal a real
token's iss.) For jwks_uri the reason is direct: a JWKS
fetched over HTTP can be substituted by anyone on the path, which
turns signature verification into theatre. For issuer_url it is one
step further away -- the value is compared against a token's iss
claim rather than fetched -- but it names the identity whose keys the
cluster is about to trust, and OIDC discovery builds its request from
that same field, so the scheme is checked once here rather than again
in everything that reads it.
Rules reference issuers by name
Deleting an issuer does not delete the mapping rules that name it; those rules simply stop working. Recreating an issuer under the same name silently rebinds every rule that named it, potentially to a different identity provider than the rules' authors intended. Renaming rather than recreating avoids this.
Discovery¶
Most identity providers publish an OpenID Connect discovery document
at <issuer_url>/.well-known/openid-configuration, saying where their
keys and login endpoints are. Shaken Fist fetches it so that you can
configure a provider by its issuer URL and audience alone.
Discovery runs when an administrator asks and at no other time: when an issuer is created, when it is updated, and when it is explicitly refreshed (below). It never runs on a timer and never while a request is being served, because putting an identity provider on a path that cluster operation depends on would make the cluster less reliable in the name of making it more convenient. The price is that an endpoint which moves at the provider stays stale here until somebody refreshes the issuer.
What it fills in on the issuer:
jwks_uri, when you did not supply one.authorization_endpoint,token_endpointanddevice_authorization_endpoint, which a human login flow starts from. A provider which does not offer the device code grant simply publishes no device endpoint, and the issuer then has none. A successful run replaces all three, so a provider which stops publishing one stops being advertised as having it.discovered_at, the time of the last successful run.
It cannot fill in client_id or oidc_scopes. Those are a
registration you make at the provider, and no document says what you
registered.
The document's issuer member must equal the issuer_url you
configured exactly, character for character, or the document is
refused and nothing is taken from it. Without that, a document fetched
from one place could name endpoints belonging to somewhere else, and
users would be sent to log in there. The commonest cause of a refusal
is a trailing slash: Authentik's issuer ends in one
(https://idp.example.com/application/o/sf/) and Keycloak's does not
(https://idp.example.com/realms/sf), and the value you configure has
to be the one the provider writes in its own document, which is also
the one it writes in the iss claim of its tokens. The refusal names
both values. Shaken Fist takes care of the slash when it builds the
URL to fetch, so configuring Authentik's issuer with its slash does not
produce a doubled one.
The fetch is also refused if issuer_url is not https:// or carries
a URL query or fragment (an OpenID Connect issuer identifier may have
neither, and the discovery URL is built by appending to it), if the
response is a redirect, is larger than
FEDERATION_DISCOVERY_MAX_BYTES, or takes longer than
FEDERATION_DISCOVERY_FETCH_TIMEOUT_SECONDS to send its body, or if
the document is not JSON, does not carry an https:// jwks_uri, or
names an endpoint longer than this cluster can store. Redirects are not followed,
for the same reason and with the same symptom as the JWKS fetch: see
The JWKS URI must be the final URL.
Certificate authorities are handled as they are for JWKS fetches too,
so FEDERATION_JWKS_CA_BUNDLE covers a private provider for discovery
as well.
Configure issuers after a deploy finishes, not during one
The discovery columns were added to trusted_issuer_attributes in
the same release as this feature, so during a rolling upgrade a
new sf-api may be talking to an sf-database that does not know
them yet. A create in that window writes the three original
columns and silently drops the client_id and the discovered
endpoints, so the issuer comes back with them null and never
appears in GET /auth/login. An update fails loudly instead,
which is the better half of the behaviour.
Nothing needs backfilling -- null correctly means "not discovered
yet" -- but an issuer configured mid-deploy needs its client_id
re-sent and its discovery re-run once the deploy has finished.
When discovery must succeed¶
If you did not supply a jwks_uri, the issuer depends on discovery to
have one, so a failed fetch fails the request with a 502 carrying
the reason, and nothing is created or changed. If this cluster cannot
build a TLS trust store at all (a bad FEDERATION_JWKS_CA_BUNDLE) the
answer is a 503 instead, because the provider was never asked.
If you did supply a jwks_uri, discovery is best effort: it is still
attempted, for the login endpoints, but a failure is logged, recorded
as an audit event on the issuer, and the request succeeds. This is what
keeps an issuer for a provider which publishes no document, or none
reachable from here, working exactly as it did before discovery
existed.
Your jwks_uri always wins. If the document names a different one,
yours is kept and the disagreement is recorded as an audit event
naming both. It is not refused because the right value is sometimes
deliberately not the published one, such as a provider whose published
JWKS path redirects to a CDN.
To skip the fetch entirely, send "discover": false. A jwks_uri is
then mandatory, since nothing else could supply it.
Re-running discovery¶
curl -X POST https://sf.example.com/auth/issuers/corporate/discover \
-H "Authorization: Bearer ${SF_ADMIN_TOKEN}"
This fetches the document again and returns the issuer. It changes
the three endpoints and discovered_at, and fills in a jwks_uri the
issuer does not have yet. It never touches your audience,
client_id or oidc_scopes. Unlike
a create or an update it always treats a failure as a failure, with a
502 or 503 as above, because a fetch is the only thing you
asked for and a 200 for one that did not happen would be a lie.
A refresh keeps the jwks_uri the issuer already has, whether you
supplied it or an earlier run found it. If a provider has moved its
keys, send an update without a jwks_uri so that discovery supplies
it afresh.
Updating an issuer¶
An update (PUT /auth/issuers/<name>) replaces issuer_url,
jwks_uri and audience together, as it always has: they are one
coherent configuration, and a new issuer_url kept alongside a stale
jwks_uri is a broken issuer rather than a partly updated one.
Sending the update without a jwks_uri therefore does not keep the old
one; it asks discovery for a fresh one.
client_id and oidc_scopes behave differently. If the request does
not mention them they are left alone, and sending one as an empty
string clears it. They are a client registration at the provider
rather than part of the identity of the issuer, and every script
written before they existed sends an update without them, so
correcting an audience must not quietly turn off human login. An
explicit null counts as not mentioning it.
The discovered endpoints are left alone too when no discovery ran, or when a best effort run failed. They are replaced only by a successful run.
An issuer which ends a write with a client_id and no oidc_scopes is
stored with oidc_scopes set to openid, because every OpenID Connect
flow must request that scope and a registration without it is one no
client could use. This holds whichever request supplied the pieces: set
the scopes first and the client later, or the reverse, and you get the
same result. To manage the list yourself, send oidc_scopes as an
empty string in the same request; the default is not applied over an
explicit clear.
The default has no converse: clearing a client_id on its own leaves
the oidc_scopes behind. The issuer stops being login capable, because
client_id is the half that decides that, so nothing is served for it
and no flow breaks -- but the row keeps a scope list for a registration
that no longer exists. Clear both in the same request if you want the
row to read cleanly.
Human login metadata¶
GET /auth/login lists the identity providers this cluster offers for
human login. It needs no credential, because a person about to log
in does not have one yet, and the endpoint which lists issuers for
administrators is not available to them. An issuer is login
capable, and appears, when it has a client_id and at least one of
an authorization_endpoint or a device_authorization_endpoint. An
issuer which only verifies workload tokens never appears. An empty
list means the cluster offers no human login.
Each entry serves name, issuer_url, client_id, oidc_scopes and
whichever of the three endpoints are set, and nothing else. In
particular the jwks_uri, the audience, and the issuer's uuid and state
are withheld. You should still know that it exists, because it tells
anyone who asks which identity provider your cluster trusts for human
login, and so where they might aim a phishing page. The provider's
endpoints are public at the provider anyway, which is why this is
judged acceptable.
Requests are rate limited per source address, using the same
FEDERATION_RATE_LIMIT_PER_MINUTE value as the exchange but a counter
of its own. The separation means a CI fleet behind one NAT address
exhausting its exchange budget does not stop people in the same office
logging in, nor the reverse. The same caveat about reverse proxies
applies as for the exchange. A limited caller gets a 429, and if the
database cannot be reached to count the request, a 503.
Setting FEDERATION_RATE_LIMIT_PER_MINUTE to 0 disables the limit on
this endpoint too, not only on the exchange. That is worth knowing
before you choose it: this endpoint reads one row per configured issuer
on every request and caches nothing, so with the limit off an anonymous
caller turns each request into a read per issuer with nothing counting
them. The exchange, by contrast, bounds its body and does its cheap
local checks before touching anything.
Delegating to namespace owners¶
A mapping rule is owned by the namespace it mints into, and creating one requires ownership of that namespace -- the same gate as adding a key, because a rule is the same privilege granted in advance and conditioned on claims. Once you have configured an issuer, namespace owners can write their own rules without coming back to you.
Two things to watch:
- Rules targeting
system. A rule that mints into thesystemnamespace is a standing offer of administrative credentials to whoever satisfies its claims. This is permitted, because there are legitimate uses, but the cluster logs a warning and writes an audit event when such a rule is created. Those events are worth alerting on. - Scope breadth. A rule grants exactly the scopes it lists.
artifact.*is a whole family;["cluster-admin", "node.read"]is a genuinely least-privilege monitoring credential. Listing a rule's scopes is how you audit what a federated workload can do.
Listing a namespace's rules answers "who can get into this namespace", which is the inbound counterpart to listing its trusts.
A worked GitHub Actions example¶
GitHub can mint an OIDC token for a workflow job, describing the repository, the branch and the workflow that asked for it. This is the end to end shape of granting a repository's workflows scoped access to a namespace.
The sf-client command line does not wrap these routes yet, so the
examples below call the REST API directly.
Configure the issuer once, as an administrator:
curl -X POST https://sf.example.com/auth/issuers \
-H "Authorization: Bearer ${SF_ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "github",
"issuer_url": "https://token.actions.githubusercontent.com",
"jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks",
"audience": "https://sf.example.com"
}'
Then, as the owner of the namespace the workflow should reach, write a rule saying which jobs qualify and what they get:
curl -X POST https://sf.example.com/auth/namespaces/ci/rules \
-H "Authorization: Bearer ${SF_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "ryll",
"issuer": "github",
"bound_claims": {
"repository": "shakenfist/ryll",
"ref": ["refs/heads/develop", "refs/heads/main"]
},
"scopes": ["blob.read", "artifact.*"],
"key_ttl": 3600,
"key_name_prefix": "ryll-ci"
}'
The workflow needs id-token: write permission to ask GitHub for a
token, and nothing else:
permissions:
contents: read
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Get a Shaken Fist key
run: |
IDENTITY=$(curl -sS \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://sf.example.com" \
| jq -r .value)
RESPONSE=$(curl -sS -X POST https://sf.example.com/auth/federated \
-H "Content-Type: application/json" \
-d "{\"token\": \"${IDENTITY}\",
\"namespace\": \"ci\",
\"rule\": \"ryll\"}")
echo "::add-mask::$(echo "${RESPONSE}" | jq -r .key)"
echo "SHAKENFIST_KEY=$(echo "${RESPONSE}" | jq -r .key)" >> "${GITHUB_ENV}"
echo "SHAKENFIST_NAMESPACE=ci" >> "${GITHUB_ENV}"
The audience in the token request must match the issuer's
configured audience exactly, and the add-mask line matters: the
minted secret is a credential, and the response body is the only place
it will ever appear.
There is no repository secret anywhere in this. The credential the job
ends up holding is scoped to blob.read and artifact.*, expires an
hour after it was minted, and its provenance records which rule minted
it and which claims were satisfied -- so an audit of "what did that
workflow have access to" is a query rather than an investigation.
Nothing here is specific to GitHub's hosted runners; a self hosted runner asks for its token the same way.
Abuse resistance¶
/auth/federated is unauthenticated by nature, so it carries its own
protections.
Replay. An identity token is single-use per rule. The same token
may still be exchanged through a different rule, so a workflow can
reach two namespaces with one identity. Seen pairs live in the
federation_replay table until the token they describe expires, and
are reaped by the cluster daemon.
Rate limiting. Attempts are counted per source address per minute in the database, so the limit is cluster-wide rather than per API worker. Note that behind a reverse proxy which does not rewrite the source address, every request appears to come from the proxy and the limit becomes a single global one -- size it accordingly, or disable it and rate limit at the proxy instead.
Every attempt is counted, including ones refused for naming an issuer this cluster does not trust. Only the checks that read nothing but the request itself -- a missing field, an oversized body -- happen before the counter. So a misconfigured workflow pointed at the wrong issuer consumes its source's budget in the same way a wrong key does, which is worth knowing when a CI fleet sharing one NAT address starts seeing 429s.
Both checks fail closed: if the database cannot be reached the exchange answers 503 rather than assuming the request is fine.
Settings¶
| Setting | Default | Notes |
|---|---|---|
FEDERATION_JWKS_CACHE_SECONDS |
300 | How long an issuer's published keys are cached. Lower shortens the window in which a revoked key is still accepted; higher reduces load on the provider. An unknown key id can also force a refetch before this expires, so raising this does not by itself delay recognising a rotated key |
FEDERATION_JWKS_ROTATION_COOLDOWN_SECONDS |
30 | The shortest interval between a successful JWKS fetch and a refetch forced by an unrecognised key id, measured from any successful fetch and not only from a previous forced one. A rotation landing inside the window is not recognised until it elapses; without a window, every invented key id a stranger sends is another fetch. 0 fetches on every unrecognised key id |
FEDERATION_JWKS_FETCH_TIMEOUT_SECONDS |
5 | How long to wait for an issuer's JWKS endpoint. The fetch happens while holding that issuer's refetch lock, so this is also the longest one unreachable provider can pin an API worker |
FEDERATION_DISCOVERY_FETCH_TIMEOUT_SECONDS |
5 | How long to wait when fetching an issuer's discovery document. Separate from the JWKS timeout because discovery only runs when an administrator configures an issuer, where a slow provider costs that administrator one request, while the JWKS fetch can pin an API worker per anonymous exchange. Applied three times: to the connect, to each read, and as a deadline over the body once the response starts, so one fetch is bounded at roughly twice this value. Must be at least 1 |
FEDERATION_DISCOVERY_MAX_BYTES |
65536 | Largest discovery document accepted, in bytes. The body is read up to this limit and refused beyond it, rather than trusting the provider's Content-Length, so a login page or a misbehaving proxy cannot hand us an unbounded response. A real document is a few kilobytes |
FEDERATION_MAX_TOKEN_BYTES |
16384 | Largest exchange request accepted, refused before parsing. A real identity token is one to two kilobytes. A request with no Content-Length is refused with 411 rather than measured, so chunked encoding cannot opt out of the limit |
FEDERATION_RATE_LIMIT_PER_MINUTE |
60 | Attempts allowed per source address per minute, counted separately for the exchange and for GET /auth/login. 0 disables rate limiting entirely, on both |
FEDERATION_JWKS_CA_BUNDLE |
(empty) | Path to a PEM bundle of extra certificate authorities to trust when fetching an issuer's JWKS. Empty means the system trust store alone |
When an issuer rotates its signing keys¶
An identity provider replaces its signing keys periodically, and the
first warning a cluster gets is a token naming a key id that is not in
the cached key set. That is worth a refetch, and one happens -- but no
more often than FEDERATION_JWKS_ROTATION_COOLDOWN_SECONDS, because
/auth/federated takes no credential and an unrecognised key id is
something anyone can invent. Without a floor each invented key id
would be another fetch of the provider's JWKS, made by us, on someone
else's say-so.
The cost is bounded and worth naming: a rotation which happens just
after a successful fetch is not recognised until the window elapses,
and exchanges presenting the new key are refused in the meantime with
no signing key for this token. Thirty seconds is the default. Lower
it if an issuer rotates often enough for that to matter, or set it to
0 to fetch on every unrecognised key id, accepting that a stranger
can then set the fetch rate.
When sizing the window, note that it starts at any successful fetch,
not only at a fetch an unrecognised key id forced. The first fetch of
an issuer's JWKS starts it, and so does the refetch after
FEDERATION_JWKS_CACHE_SECONDS expires -- so a rotation arriving ten
seconds after a routine cache-expiry refetch waits out the remaining
twenty, even though no forced fetch has ever happened for that issuer.
That also means setting it at or above FEDERATION_JWKS_CACHE_SECONDS
turns forced refetches off entirely rather than merely slowing them:
every cache-expiry fetch restarts the window before it can elapse, so
an unrecognised key id never gets to force anything and a rotation is
picked up only when the cache next expires. Nothing refuses that
configuration — it is a longer delay, not an incorrect decision — but
it is not what the two settings look like they do. Keep the cooldown
comfortably below the cache lifetime.
The JWKS URI must be the final URL¶
Redirects are not followed when fetching an issuer's JWKS, so
jwks_uri has to be the URL which serves the key set rather than one
which points at it. A provider which answers /.well-known/jwks with a
301 or 302 to a CDN path needs the CDN path configured on the issuer.
This is a change in PyJWT 2.14, which builds its fetch opener with
redirects refused, so an issuer which worked before an upgrade to that
release can start failing at it.
The symptom is every exchange against that issuer answering 401 with
no signing key for this token from issuer <name>: Fail to fetch data
from the url, which is the same message an unreachable provider
produces. curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n'
<jwks_uri> distinguishes the two.
An identity provider behind a private CA¶
A self hosted Authentik or Keycloak usually presents a certificate
signed by the organisation's own CA rather than a public one, and the
jwks_uri fetch will refuse it. Point FEDERATION_JWKS_CA_BUNDLE at a
PEM file of the extra authorities to trust, and place that file on
every node — any node may serve /auth/federated. The ansible
collection's node role takes this as federation_jwks_ca_bundle;
getting the file itself onto the nodes is yours to arrange, since it is
usually the same bundle the rest of your fleet already has. The same
bundle is used when fetching the provider's discovery document.
These anchors are added to the system ones rather than replacing them, so configuring a private provider does not stop a public one such as GitHub from verifying, and both can be trusted issuers at once.
Nothing else is relaxed by setting it. jwks_uri must still be
https://, the hostname must still match, expiry is still checked, and
a certificate chaining to neither set is still refused. There is
deliberately no option to skip verification: a JWKS fetched over a
connection nobody authenticated can be substituted by anyone on the
path, which makes signature verification theatre.
If the path is wrong, or the file is not a PEM bundle, sf-api says so
twice. Each worker complains at startup, so a bad path is a line in the
log at deploy time rather than a surprise at somebody's first federated
login weeks later. If it is still wrong when a token arrives,
/auth/federated answers 503 rather than 401 — the token was never
examined, and calling it rejected would send the caller to their
identity provider to look for a fault which is in your config file. The
log line names FEDERATION_JWKS_CA_BUNDLE and the path; the response
deliberately does not.
If nobody uses it¶
Federation is inert until an issuer exists. A cluster which never
creates one behaves exactly as it did before: the two tables stay
empty, the reaper does nothing, and /auth/federated refuses
everything with an untrusted-issuer message.
Trusts¶
Info
Trusts are a newer way of sharing between namespaces with granular control. If you instead are interested in making artifacts available to all users of a Shaken Fist cluster, then you should also consider artifact sharing, which is discussed in the artifacts section of the operators guide.
The system namespace is special in a Shaken Fist cluster in that it can see objects in all other namespaces. That is, if you are authenticated as the system namespace and list instances, you get not only the instances in the system namespace, but also all those in other namespaces. The same is true for other namespaced objects such as networks and artifacts.
In older versions of Shaken Fist this behavior was hard coded, but as of Shaken Fist v0.7 this is now implemented more flexibly. The system namespace must still be able to see every other namespace, but you can also create a "trust" relationship between two arbitrary namespaces to achieve the same result on a smaller scale. In fact, the system namespace is now simply a default trust that all other namespaces have a relationship with.
The Shaken Fist CI system uses these trusts for base images for CI runs. Each night we rebuild a series of base test images -- Debian 10, Debian 11, Ubuntu 20.04 and so on. Each Shaken Fist CI job is run in its own namespace, so we needed a place to store these base images, as well as a mechanism for other CI jobs to be able to see them.
What we implemented was:
- a namespace to store the base images (we called it
ci-images). - when our CI system creates a new CI runner and associated namespace, it
creates a trust between that ephemeral namespace and the
ci-imagesnamespace. - jobs to create new images build them in their local namespace, and then "gift"
them to the
ci-imagesnamespace via a label. - jobs which need to boot a test image can now see the images from the
ci-imagesnamespace by virtue of this trust relationship.
What a trust does and does not grant¶
A trust grants visibility over your resources — instances, networks,
artifacts and the like. It is the system namespace's ability to see across
namespaces, scaled down. A namespace you trust can list and read those
objects. It cannot delete them, rename them, share them, or change their
metadata — those all require the object's own namespace, or system.
A trust also does not outlive the namespace it names. Deleting a namespace
revokes every trust pointing at it, on every other namespace, at the moment
of deletion. This matters because a trust names a namespace by name and
namespace names can be reused once the cluster has finished cleaning the
deleted one up: without the revocation, whoever created that name next would
inherit your trust without you doing anything or being told. You will see a
trust revoked, trusted namespace is gone event on your namespace when this
happens.
For the same reason you cannot grant a trust to a namespace which does not
exist: the request is refused with external namespace not found. Create the
namespace first. The cluster maintainer also checks hourly for trust entries
naming namespaces which are gone and removes them, with the same event, which
is how entries predating these checks are cleaned up.
Giving is a separate question from taking, and it is still allowed: a
namespace you trust may create an object in your namespace, which is exactly
the "gift" step in the ci-images example above. Creation is additive, you
opted into it by extending the trust, and nothing you already had is lost by
it. Deletion is none of those things.
Giving the same thing twice is not creation, though, and it is worth being
precise about where the line falls. A trusted namespace may upload an artifact
into yours when nothing of that source_url is there yet. It may not upload
again over the artifact that first upload produced: adding a version ends in
delete_old_versions, the caller supplies the blob, and the practical result
is that an instance of yours booting that artifact afterwards gets somebody
else's image. So the second upload is refused, and a CI job which pushes a
nightly image into a shared namespace needs a key in that namespace rather
than a trust.
Labels fall on the same side of that line, which they did not until v0.8.
A label may be named <namespace>/<label>, so a caller can reach into
your namespace through one, and nothing used to stop it — updating a label
in your namespace did not even require a trust. A trusted namespace may now
create a label in yours and may not update one that already exists, matching
artifacts exactly. This does affect the ci-images pattern above: the first
gift of a given label works, and a job which republishes under the same label
name every night needs a key in the receiving namespace.
Namespace administration is the exception, and it is a large one.
Adding a key to your namespace, and writing a mapping rule in it, are both
gated on this same trust relationship rather than on ownership. A namespace
you trust can therefore mint credentials in yours — directly with
add-key, or by writing a mapping rule, which is a standing offer to mint
a key for anyone holding a matching identity token. It can also delete the
rules you wrote.
So a trust is not only a window onto your resources; it is administrative access to your namespace's credentials. Extend one to a namespace you would be willing to hand a key to, and read Federated identity before extending one to a namespace that has mapping rules configured.
Note
Artifacts behaved differently until recently: a trusted namespace could
delete another namespace's artifacts. Instances and networks never
permitted this, and artifacts now match them. If you have tooling which
relied on deleting artifacts across a trust, it needs a key in the owning
namespace, or the system namespace, instead.