Coding rules learned the hard way¶
Each rule below came out of a real defect. They are not style preferences: breaking one has previously shipped a bug. Read this before touching authorisation predicates, parsers, lookup keys, or metrics.
Never restate a visibility predicate¶
A listing endpoint filters; a single-object endpoint cannot, because it has already resolved the object by the time it knows who is asking. The two must still agree, so the single-object guard calls the same function the listing filters with rather than open-coding the equivalent test.
For artifacts that function is namespace_or_shared_filter(namespace, obj):
own namespace, a namespace whose trust list names the caller, system, or
shared. requires_artifact_access calls it.
requires_artifact_ownership is the deliberately stricter mutation guard and
tests request_namespace() not in [obj.namespace, 'system'] — the same test
requires_instance_ownership and requires_network_ownership use. It
consults neither the shared flag nor the trust list: sharing publishes an
object for reading, and a trust is a visibility grant, so neither one hands
out a delete button. Creating an object in a namespace that trusts you is a
separate question and is still allowed.
This is not a style preference. requires_artifact_access used to restate the
rule as if a.shared and requestor not in [a.namespace, 'system'], which is
inverted in both directions, and because arg_is_artifact_ref resolves a UUID
straight to Artifact.from_db with no namespace filter, that decorator was the
only guard on the path. Any caller who knew a UUID could read any namespace's
unshared artifacts. Refuse with 404 rather than 403 so the refusal does not
confirm the object exists, and pair every new refusal test with a control that
shows the same request succeeding when the one thing under test changes.
The same predicate governs how a name resolves on the read routes, not just
whether a resolved object is allowed through. arg_is_visible_artifact_ref
(paired with requires_artifact_access) resolves through
Artifact.from_db_by_ref_visible_to, which searches the caller's own
namespace first and only then widens to what namespace_or_shared_filter
admits. Two rules to preserve if you touch this:
- The caller's own namespace must win, or sharing an object silently retargets every tenant who already used that name.
- Routes which change an object use plain
arg_is_artifact_refand resolve names narrowly. The ownership guard already refuses the write whichever way the object was named, so this is defence in depth rather than the only gate — but a name must never resolve into someone else's namespace on a route that then deletes what it found. New route, ownership guard, narrow ref decorator — the pairing goes together.
The same split applies to url resolution, and this is where it was missed.
Artifact.from_url filters by namespace_or_shared_filter, so it can return
an artifact belonging to whoever shares with or trusts the caller. That is the
right answer for a caller which will read the result and the wrong one for a
caller which will write to it: the upload and cache routes used it to pick a
write target, so a trusted namespace could name the owner's source_url and
have its own blob added as the newest version — and because add_index ends in
delete_old_versions, the owner's older versions went with it. Write paths use
Artifact.owned_from_url(), which resolves by ownership and does not create.
Two rules fell out of fixing it, and both generalise past artifacts:
- A predicate is part of a function's contract, not an implementation detail. If one lookup serves both intents, say which it is in the name and make the other one a separate function. A default that silently suits readers is how a write path inherits a read's authorisation.
- Creating and modifying are different grants. A trust may let a namespace give you an object it did not have — additive, and the operator guide promises it. It must not let that namespace replace what an object you already own resolves to. When a route can do either, authorise the two cases separately rather than once at the top, and put the audit event after the check so a refused caller cannot write to the event log of a namespace it is about to be told does not exist.
The remaining call sites were narrowed in #3640, and one of them was
worse than the sweep recorded. LabelEndpoint.post had been read as safe
because it builds its URL from the request — but _label_url accepts
<namespace>/<label> and hands back the namespace it was given, so any
authenticated caller could push a version into any namespace's label. The
requires_admin=True in its swag_from is documentation and enforces
nothing, and the route carried no ownership decorator, so nothing stopped it.
Two lessons worth carrying:
- "Built from the request" is not the same as "not caller-controlled." The URL was assembled by our code out of a value the caller chose. Follow the value, not the construction.
- A read path can be broken in a way that hides the write path's bug.
LabelEndpoint.getanddeletehad answered 500 to every request since 2024 (a pair handed to a filter expecting a string, and a 404 that was computed but never returned), so nobody exercised the endpoint hard enough to notice whatpostwould accept.
The instance path is the case where the obvious narrowing was the wrong fix, and it is worth knowing why before someone "simplifies" it:
- Resolving
disk.baseby ownership alone would have broken sharing. Reuse is the entire point of a shared image, andtransfer_imagetreats an artifact with no versions as "cluster does not have a copy", so giving every namespace its own artifact would have meant its own download and its own stored copy of every shared image. - The split is therefore per verb, not per artifact. A visible foreign
artifact is resolved to a blob and booted from — the same move the label and
snapshot branches already made — and never fetched into.
owned_from_url()picks the write target;from_url()still picks what you may read.
Artifact.owned_from_url_or_new() exists for the write paths whose target
namespace is fixed as the caller's own, or already authorised: they have no
two cases to tell apart, so they get the create for free. Routes which accept
a caller-nominated namespace must still authorise creating and modifying by
hand, which is why owned_from_url() itself does not create. The artifact
fetch and upload routes both spell the two cases out for that reason, and the
apparent duplication between them is the authorisation rather than a missing
abstraction.
One more lesson, from the review of that change:
- A sweep is only as wide as the directory it was run over. The original
#3640 audit listed three sites because it looked at
external_api/andoperations/.Instance.snapshot()is a fourth, in the core object, and neither the issue nor the first draft of the fix saw it. It was not exploitable — the URL carries the instance UUID andtype_filterpins the type — but "not reachable today" is an argument for the guard being cheap, not for going without it. Grep for the sink (add_index), not for the callers of the resolver you happen to be changing.
Credential-carrying routes are not logged, not redacted¶
Two independent loggers see a request body: app.py's before_request audit
event, and log_request in base.py, which merges the parsed body into the
decorated method's kwargs and logs those. Both ask
api_base.handles_credentials() — one predicate, in one place — and drop the
whole body when it answers yes.
Redacting by field name was tried and is wrong. key means a metadata key
name on most endpoints and a secret on a few, so a name-based rule has to know
which route it is on anyway, and it silently starts leaking the day somebody
adds a route it has not heard of. That is exactly what happened: the federated
exchange's credential field is token, which was on neither redaction list,
so identity tokens were logged verbatim at INFO and shipped to the log
aggregator.
Every route which takes a credential lives under /auth/. Keep it that way,
or extend the predicate — never the redaction lists.
A check that runs after the parse is not a check¶
The endpoint-method decorators are not the outermost thing in a request.
log_request calls get_json(force=True) before any method body runs, so a
size or shape check written inside a post() cannot prevent work that has
already happened. Anything protecting an unauthenticated endpoint from
attacker-controlled input has to be an @app.before_request hook registered
ahead of log_request_info — see limit_federated_body_size.
While you are there: flask.request.content_length is None for chunked
transfer encoding. Treating unknown as small enough lets any caller opt out of
a size limit by choosing a header, so refuse with 411 rather than measuring.
Two records must not claim one lookup key¶
federation.issuer_for_token resolves a trusted issuer by scanning for a
matching issuer_url. Uniqueness on that column is therefore a correctness
property, not tidiness: two live records claiming one URL make which
provider's signing keys are trusted depend on listing order, so an
administrator repointing an issuer would believe they had while some requests
kept verifying against the old JWKS. The create and update endpoints refuse a
duplicate by calling federation.issuer_claiming_url, the same function the
resolution path uses.
A check-then-write is not an invariant on its own. issuer_url lives in the
attributes row and has no unique index -- and cannot easily get one, because a
soft-deleted issuer keeps its row and its URL is deliberately reusable -- so
both endpoints hold _issuer_url_lock() across the check and the write it
guards. Without that, two administrators configuring one provider at the same
moment both read "free" and both write. This is the vsock_cids pattern from
instance.py: where a unique index cannot be the arbiter, a cluster lock
around the check-then-act has to be.
Put the meter above the expensive thing, not below it¶
/auth/federated is unauthenticated, so every step above the rate limit is a
step an anonymous caller gets for free, as often as they can send. Ordering
there is a security property, and the question to ask of each step is not "is
this cheap?" but "does this touch the database or the network?". Issuer
resolution reads once per configured issuer, so it belongs below the meter
even though there are only ever a handful of issuers; only the argument
checks, which touch nothing but the request, belong above it. The original
ordering had this backwards on its own stated logic -- it placed the meter
below the lookup to avoid writing a counter row, when the lookup it was
skipping cost more than the row did. If you add a step to that endpoint, place
it relative to enforce_rate_limit on that basis, and say why in a comment.
A guard has to sit where the exception is raised¶
CorruptMappingRule comes from decoding bound_claims or scopes, which
only happens on the attributes read. MappingRule.from_db_by_name reads the
static row and the object state, so wrapping the lookup in a try looks like
protection and is none. Before writing a guard, find the raise site; before
believing a regression test, check that the thing it patches is on the path
that can actually fail. Use MappingRule.policy() to read the whole policy in
one go when you need more than one field -- it is also the single place to
catch this.
The same guard has to answer differently in different places, and that is not
inconsistency. The exchange endpoint refuses a damaged rule, because bound
claims it cannot read are bound claims it cannot check and minting anyway would
be authorising on a guess. MappingRule.external_view() describes one, with
an explicit unusable marker, because the CRUD routes exist to tell an owner
which rule is broken -- raising there turned one bad row into a 500 that hid
every healthy rule in the namespace, and made a successful delete report
failure on the one call that would have cleaned it up. Ask what the caller will
do with the answer before choosing.
Fail closed on a field, not on a formatting accident¶
A reply that says "this went wrong" must say so in a field set only on the
success path. CountFederatedAttemptReply used to signal failure by carrying a
non-empty error, and the client read anything else as an answer -- so an
exception raised with no args, whose str() is empty, arrived as
attempts=0, error='' and was read as "nobody has tried this minute, allow".
That is the one direction a rate limiter must never fail, on the one endpoint
anybody may call.
Both federation replies now carry bool ok, set only where the work actually
succeeded, and the client tests that. error is diagnostics. When you add an
RPC whose reply has a permissive-looking default -- a zero count, an empty
list, a False that means "go ahead" -- carry an explicit success field rather
than inferring one, and write the test that returns the empty-error reply and
asserts the refusal. The invariant is not that today's code produces a message;
it is that no reply can be mistaken for a permissive one.
or [] is a decision about what a failed read means¶
mariadb.get_objects_by_state() returns None when the read failed and []
when nothing matched, and says so in its docstring. Every or [] at a call
site erases that distinction, and the erasure is not neutral: it asserts that
the caller treats "we could not find out" and "there is nothing" the same way.
Sometimes that is true. Decide it deliberately, because for one caller in this
codebase it was catastrophically false.
get_active_blob_uuids() ended in or []. The cleaner uses its result as a
complement set -- it unlinks every blob file on disk whose uuid is not in the
list -- so a failed read arrived as "no blobs are active", which is an
instruction to delete the node's entire blob store. The trigger was not exotic:
an oversized GetObjectsByState reply (issue 3638) is a non-retryable
RESOURCE_EXHAUSTED, raised as-is by _grpc_call, mapped to None by the
client wrapper, and flattened to [] one line later. The same call in the
cluster daemon only ever iterates the list, so there the empty list was
merely a skipped pass. One accessor, two callers, opposite consequences.
So the rule is about the caller, not the accessor. Before collapsing an error
into a value, ask what each caller does with it. A caller that iterates can
usually tolerate a skipped pass -- catch explicitly, log, and move on. A caller
that complements, gates, or diffs against the list cannot, and must see the
failure. get_active_blob_uuids() now raises exceptions.DatabaseUnavailable,
which is the same shape as the fix for issue 3373 (an unreachable database must
not be indistinguishable from a missing object) and gets the REST path a clean
503 for free via handle_database_unavailable.
A sweep that reads a work list has a quieter version of the same problem: it
does not delete anything wrongly, it just does nothing, reports a healthy pass
over an empty queue, and lets the backlog grow -- which in issue 3638 grew the
very reply that could not be read. _sweep_work_list() in the cluster daemon's
scheduled tasks is the shared answer: a failed read returns None, is counted
into cluster_sweep_work_list_failure_streak, and is logged as a skipped pass.
The general form: silence is not success, and an empty result set is not the
absence of an answer.
One trap when handling this deliberately: a failed read from the mariadb
client arrives in two shapes. The client wrappers map grpc.RpcError to a
None/False/[] return, but _grpc_call() raises
exceptions.DatabaseUnavailable once its retry budget is spent, and that is
deliberately not an RpcError subclass (issue 3373), so it propagates through
the wrapper untouched. Handling only the return value covers the oversized-reply
case and misses the tier outage -- which is the more likely reason a read fails,
and the condition an alert on the streak most needs to see. Cover both, and
prove it with a test that sets side_effect = DatabaseUnavailable(...) rather
than a None return.
Having covered both, do not then treat them alike inside a loop. The two shapes
differ in blast radius. A None return is per-reply, so the next object type is
still worth reading. A DatabaseUnavailable is tier-wide: every remaining read
will fail identically, and each will spend a full _grpc_call retry budget
before it does. Continuing therefore costs one budget per iteration, and both
the deleted-object sweep (28 object types) and orphan reconciliation run inside
a single scheduled job whose watchdog is only petted between jobs. The
tolerant continue that is right for the per-reply case would turn a database
outage into a SIGABRT of the elected cluster maintainer and a lock failover.
Continue on the per-reply shape; stop the loop on the tier-wide one. The
general form: when one read becomes N reads, recompute the caller's worst-case
wall time rather than inheriting it.
And when you stop a loop early, ask what it does to whatever is behind the stop.
Both of those loops iterate a fixed list of object types from the start of the
list every pass, so break on its own would mean a single persistently slow
type hides every type after it forever -- and the backlog that then accumulates
makes that type slower still, which is the #3638 ratchet rebuilt one level up.
They keep a resume offset and begin the next pass after whichever type stopped
the last one, so the pass stays bounded and every type is reached within a
bounded number of passes.
The same rule applies to the reply itself, one layer down. A gRPC reply whose
only payload is a repeated field has nowhere to put "the read failed", so
returning object_uuids=uuids or [] from a servicer hands the client the same
ambiguity the or [] at a call site does -- except the client cannot even see
that a decision was made. DatabaseServicer.GetObjectsByState did exactly this,
which left the blob-store deletion hazard fully reachable through the likelier
failure (MariaDB down while sf-database is up and answering) after the
client-side half had been fixed. Signal it on the status instead:
context.set_code(grpc.StatusCode.UNAVAILABLE) for a transient database error,
which _grpc_call retries and converts to DatabaseUnavailable, and INTERNAL
for a handler bug, which is non-retryable and so becomes a None return. When
you fix an empty-means-failure bug, check both ends of the wire.
And check every operand of the decision, not just the one that led you there. The cleaner's test is an or:
Hardening get_active_blob_uuids() fixed the first operand and left the store
exactly as deletable through the second, which reads the node's own blob
locations and flattened failure to [] at all three layers. Worse, the second
operand fails more often than the first, because a plain MariaDB
OperationalError -- a lock wait timeout, a deadlock, a dropped connection --
breaks it while sf-database itself stays healthy and answers everything else
normally. A guard on one input of a multi-input decision is not a guard on the
decision. Enumerate the inputs, and write the negative-control test for each.
That said, the fix is per-caller and not per-accessor, so it does not follow
that every accessor should raise. get_references_from() still collapses a
failed read to [], deliberately and with the reasoning in its docstring,
because every one of its callers iterates. The complement-set caller gets its
own raising accessor, get_node_blob_uuids(), built on the truthful internal
_get_references_from(). Two accessors over one read, named for what their
callers may assume, beats one accessor with a raise_on_failure flag that
every call site has to remember to set.
Cluster CI tests only run in the merge queue¶
The (collection) matrix in Functional tests -- everything under
deploy/shakenfist_ci/cluster_ci_tests/ -- is skipped on pull_request and
runs on merge_group. A green PR therefore says nothing about whether those
tests pass, or whether they can even reach their first assertion.
cluster_ci_tests/test_federation.py sat through four commits registering a
trusted issuer with an http:// jwks_uri while the API had refused
non-HTTPS jwks_uri since the object was added. Every test in the class died
in setUp on a 400, and nothing said so until the branch entered the queue.
Two habits follow. When you add or change a cluster CI test, run it, or at
minimum drive the validator it depends on directly -- these tests import
cleanly given pip install shakenfist-client testtools oslo.concurrency
prettytable in a scratch venv, so "the client is not installed" is not a
reason to skip verification. And when a test needs input that a validator
rejects, the validator is usually right: change the test, never carve a
loopback or test-only exemption into a security check. If that makes the test
unrunnable, make it skip loudly and file the issue -- see #3639 for the JWKS
certificate case.
That issue is a worked example of how the second habit tends to resolve. The
test needed the cluster to trust a certificate it had minted, and the tempting
fix was a test-only exemption in the deploy. What it became instead was
FEDERATION_JWKS_CA_BUNDLE -- extra trust anchors for JWKS fetches, which a
self hosted Authentik or Keycloak needs anyway -- with CI as its first user.
When a test cannot run because a security check is doing its job, the useful
question is usually "what would a real operator need here", not "how do we get
around this in CI".
Two traps it also left behind, both of which cost a debugging round:
ssl.create_default_context(cafile=...)replaces the system trust store rather than adding to it. Build the default context and then callload_verify_locationson it. The wrong spelling passes every test that only checks the new anchor is present.- Python 3.13 enables
ssl.VERIFY_X509_STRICTby default, so a leaf certificate with no Authority Key Identifier is refused with "Missing Authority Key Identifier". If you generate certificates in a test, give them AKI, SKI and basic constraints -- and be aware the symptom is indistinguishable from the CA not being trusted at all.
If a cluster test can run on one node, share it with the smoke suite¶
cluster_ci_tests/test_database_tier.py landed with two independent defects at
once (#3694, #3708) and blocked the merge queue for four days, because the
merge queue was the first place it ever ran. Both defects were reproducible on
a single node.
The two suites are disjoint directories -- smoke-ci.conf discovers only
smoke_ci_tests/, cluster-ci.conf only cluster_ci_tests/ -- so "add it to
smoke" cannot mean moving it without losing the multi-node coverage. Define the
test bodies once in a mixin under deploy/shakenfist_ci/ (not in either suite
directory, or stestr collects it twice from one run) and subclass the mixin
from both suites. database_tier.DatabaseTierTestsMixin is the worked example:
the two tests needing one sf-database run in both, and the load-balancer test
needing N>=2 stays in the cluster suite where its skip is honest.
Prefer this whenever a cluster test's preconditions are met by the single-node
localhost topology. Sometimes that topology is also the better test: the
API and sf-database share a machine there, so it is the most exposed to the
direct-MariaDB routing regression #3708 was about.
tearDown runs before addCleanup, and the base class already deletes¶
BaseNamespacedTestCase.tearDown() deletes every instance in the namespace and
blocks until they are gone, swallowing ResourceNotFoundException as it goes.
testtools runs tearDown() before the addCleanup stack:
So self.addCleanup(self.test_client.delete_instance, uuid) in a namespaced
test can only ever 404, and unlike tearDown's own deletes it is not guarded --
it fails the test after the assertions have passed. This is what kept
test_instance_get_fetches_the_attributes_row_once red even once its
measurement bug was fixed. Do not register instance cleanups in a namespaced
test; the base class already reaps them. Reserve addCleanup for state the
base class knows nothing about, such as the host devices in
test_stray_vxlan.py or a namespace the test made itself.
The last thing tearDown() does is delete the namespace, so anything
reached through a /auth/namespaces/<namespace>/... route -- a key, a
claim, a federation mapping rule -- has to be removed before
super().tearDown() is called, not from a cleanup. ClaimAPIMixin in
test_namespace_claims.py and TestLoki in test_loki.py are both
written that way, and both say why in a comment.
That was not true until 2026. _remove_namespace() asked whether a
namespace name was in the result of get_namespaces(), which is a list
of external_view() dicts, so the answer was always no and no namespaced
test deleted its namespace for six years. The rule above was correct
advice for the wrong reason, and it will be correct advice for the right
one only as long as the comparison stays fixed --
smoke_ci_tests/test_ci_harness.py is what holds it there. When a
helper's failure mode is to succeed quietly, the guard has to assert the
effect on the cluster rather than that the helper returned.
A column-to-column comparison has no bind processor¶
SQLAlchemy coerces a bound value through the column type on its way into the statement. It cannot do that for a comparison between two columns, because there is no Python value to coerce. Any join whose two sides store the same logical value in different physical forms therefore matches nothing, silently, and a query that returns no rows is indistinguishable from a feature with no work to do.
Both of object_states' join columns have that problem:
object_states.object_uuidissa.String(36)holding the dashed uuid form, while the static tables usesa.Uuid, which is undashed CHAR(32) on MariaDB. Join through_dashed_uuid_expr(), never a baresa.cast.object_states.object_typeissa.Enum(ObjectType), which SQLAlchemy stores by enum name ('NET_OP'), while the operation and object tables carry the enum value ('net_op'). Bind anObjectTypemember; do not compare the two columns.
The second is the subtler one, because binding a plain 'net_op' string
does work -- ObjectType subclasses str, so the member and its value
hash identically in the Enum type's _valid_lookup -- which makes every
other object_states query in mariadb.py correct by accident and makes
the broken join look like all of them.
The two coalescing primitives in mariadb.py had both defects in one
ON clause, each individually fatal, and shipped that way for three
months of green CI (issue #3878). Every test mocked either the primitive
or the engine, so nothing ever ran the statement against a table. The
operator-facing symptom was documented before the cause was: the
coalesced sibling ops event simply never fired. Both queries now share
_coalescible_states_join(), and shakenfist/tests/test_mariadb_coalescing.py
runs them against a real database.
The general rule: a query which can fail by matching nothing needs a test that proves it matches something. Asserting the statement's shape against a mocked engine cannot do that, and neither can a test that rewrites the query to suit sqlite -- register the missing SQL functions instead, so the statement under test is the statement that ships.
A per-item convenience wrapper does not belong in a loop¶
instance_usage_for_blob_uuid() answers "which instances use this
blob?". It does so by calling instance_blob_usage(), which walks
every healthy instance in the cluster: one FindInstances, then a
block_devices attribute read per instance and a get_references_from
per link of every disk's dependency chain. The wrapper is a single line
over a cluster-wide scan, and nothing about the call site says so.
Called once per request that is fine. Called once per item rendered it multiplies the whole scan by the length of the response, and the cost is invisible in the handler:
# Wrong: one cluster-wide instance walk per version.
for idx in ev['blobs']:
ev['blobs'][idx]['instances'] = instance_usage_for_blob_uuid(
ev['blobs'][idx]['uuid'])
# Right: one walk, indexed per version.
blob_usage = instance_blob_usage()
for idx in ev['blobs']:
ev['blobs'][idx]['instances'] = blob_usage.get(
str(ev['blobs'][idx]['uuid']), [])
Issue #3502 found this in the cluster cleanup loop and fixed it there,
and left a docstring on instance_blob_usage() saying which form to
use. Four API handlers kept the per-blob form anyway -- an artifact GET
walked once per version, the artifact and snapshot listings once per
artifact and once per version of every snapshot -- and it took issue
3876, filed against an unrelated symptom, to find them. The lesson is¶
not "read the docstring": it is that a function whose cost is proportional to the size of the cluster must be seen to be, so a per-item wrapper over one needs the bulk form to be the obvious thing to reach for and the loop body to be plainly an index lookup.
The same shape of trap exists wherever a property hides a query.
Blob.depends_on and Blob.transcoded each ran their own filtered
get_references_from while the caller had already read the same rows
unfiltered; that was the other half of #3876.