Repository navigation
Release v0.20.0 - Composable Features
Release Notes by the team
This release makes nine features removable: accounts, rbac, tiers, tier_limits, ratelimit, api_keys, admin, cache and taskiq. If you don't want roles, or tiers, or the admin panel, you delete that feature's directories and the project still runs. It imports, it lints, it type-checks, and the tests that are left pass.
That was always the promise, but it didn't really hold: modules/rate_limit imported the tier models, the API throttle read tier_id off the user row, and the authorization checks imported the role tables. Delete one directory and four others broke, so in practice nobody deleted anything.
Three review rounds and a security pass went over the refactor afterwards. They found a privilege escalation the refactor itself had introduced and two races that corrupted data instead of failing. They also found a family of 500s that should have been 422 all along, and recovery routes whose timing told you which addresses had accounts. All of it is fixed here.
One thing to read before you deploy: the compose file this project generates took the database password from your shell, not from backend/.env. Step 1 of the upgrade says what to do about it.
How a feature plugs in
The rule is that a feature contributes to things it doesn't own, and nothing imports it back. The shapes it contributes through live in infrastructure/composition.py, and they're deliberately boring: RouterMount, Lifecycle, PermissionSource, RateLimitResolver, TierDeleteGuard, TierDeleteRelease, ReadinessCheck. A feature that wants to add a column to User contributes a mixin instead. That's how modules/tier/contrib.py adds tier_id without the user module ever hearing about tiers.
src/wiring/ is where the features meet. Every file in it is imports and literal names, so ruff, mypy and your editor can see what the project wired, and nothing in there decides anything at runtime. If something is conditional, like whether the OAuth router exists, the feature that owns it works that out and exposes the answer for the wiring to list.
Checking that removal still works
tools/removal_drill.py copies the repo to a scratch directory, deletes the paths of the features you left out, and regenerates the wiring for what's left. Then it runs six checks over the result: the app imports, every module imports, ruff, mypy, the remaining tests, and an Alembic baseline applied to an empty database. Nine presets run as a CI matrix, from core-only with nothing at all up to everything.
The baseline check is the one that catches quiet breakage. A feature's tables leave with it. The baseline compares what the models declare against what the migration creates, so dropping the key_permissions table showed up as one table and five indexes fewer, exactly where it should have.
Why copy the repo instead of adding a setting? A setting that turns a feature off leaves its imports in place and its code on disk, which is the state the old boilerplate was already in. Deleting the paths is the only thing that proves nothing else was reaching into them.
The generator is held to what the repo commits. A test regenerates src/wiring/, scripts/seeders.py and tests/wiring.py for the features your project carries, then compares them tree by tree, ignoring docstrings and formatting. Edit one of those files by hand and forget the generator, and that test tells you.
Auth moved to crudauth 0.8.2
The pin goes from crudauth[all]>=0.7.1,<0.8.0 to >=0.8.2,<0.9.0, and the code the library does better is gone from here: the session routes, the logout path, the session store, the lockout counters. crudauth's session-management router replaces them and brings a device list and per-device sign-out with it. The account routes come from the library now too. POST /api/v1/auth/change-password verifies your current password and then revokes the account's other sessions, and GET /api/v1/auth/me reads the caller back.
set-password isn't mounted. It would let anyone holding a session put a password on a provider-only account, and that's a takeover whenever a session has been borrowed. An account with no password sets one through the reset link instead, which proves the address first.
Everyone signs in again after this deploy. crudauth 0.8 stores a session under an HMAC of its id keyed with SECRET_KEY, so sessions written by 0.7 sit under the raw id and aren't found any more. A SECRET_KEY rotation does the same thing from now on, which the admin-panel guide now says plainly instead of recommending a rotation as the way to clear panel sessions.
Roles and API keys use the same permission names
Permissions are a registry. Any module declares into it without importing rbac, GET /api/v1/permissions lists what the project has, and /permissions/me lists what you hold. Roles and API key scopes use those same names, and there's no second vocabulary for either. There used to be: a table of key_permissions rows holding words like conversations, credits and *, describing access no route could actually check.
The role API runs on one rule: nobody hands out what they don't hold. Delegation and assignment compare against your effective permissions, resolved once per request and narrowed to whatever your credential is scoped to. Deleting a role or emptying it is held to the same rule, for every live holder. Taking a permission away from an account you can't reach is the same escalation as giving one.
A key is never a superuser. The flag gets cancelled where the principal is built, not at each gate, so no caller has to remember. A superuser's key scoped to nothing can't rename its owner's account, and one scoped user.update can. Owning the account doesn't widen what the key may do.
What an API key is
A key is secrets.token_urlsafe(48). That's 384 bits the server generated, so there's nothing to guess, and running a work factor over it buys you nothing. The old storage was scrypt, which made every key request pay a derivation, and since key_prefix shows up in listings, anyone holding a prefix could make your server pay it on junk requests. Storage is sha256$<digest> now, found with one indexed lookup. Keys stored the old way still authenticate and get rewritten on first use, and a script names the ones that never moved so you can revoke them.
Key management belongs to a session. Minting, reading, rescoping and revoking all refuse a key, so a key can't mint another one, widen itself, or revoke the key you'd use to stop it. Minting also needs a verified address, because an unverified one might belong to someone else. That's the gap that lets an attacker register under your address, mint a key, and keep API access after you claim the account through a provider.
Usage limits are counted when the key authenticates, per key id, so they cover every route that key can reach whether or not the route is throttled. Windows are counted shortest first, so a request the minute window refuses keeps its hour and day budgets. Counting fails open: a limiter nobody can reach should let the request through rather than take the API down with it. Each authenticated request writes one usage row after the response, and the rows are kept for KEY_USAGE_RETENTION_DAYS, pruned by a scheduled task where you run a worker and by scripts/prune_key_usage.py where you don't.
What a route answers
Signing up answers 201 and a fixed message whether the address was free or taken. If it was taken, the person who owns it gets an email about it, and the caller learns nothing. A password reset request works the same way. It also answers in the same time for an address that exists and one that doesn't, because the mail is handed over and delivered after the answer goes out. A taken username is still 409, because a username is already public in a URL and an address isn't.
Bounds that used to produce a 500 answer 422 now: items_per_page over 100, an id or page outside its range, an expiry in the past, text no column can hold. Lengths are enforced where the column has one, so a profile image URL and a redirect_to stop at 2048 characters and a usage row keeps the first 512 of a user agent. An emailed link's destination is bounded in the body and in the OAuth query, and an oversize one starts no flow at all.
Writes that race
Two assignments of one role both answer 201. Two replacements leave the role with one set of permissions rather than both. A superuser's stronger assignment to the same account waits for the write already in flight instead of landing between its check and its insert, which is the escalation the refactor had introduced.
Two role writes that share a holder still run at once, and an unrelated row whose foreign key names that account never waits. The locks are taken in one order, role then account, and the holder rows are taken for share rather than for update.
A second rate-limit rule for the same tier and path is refused by a partial unique index, uq_rate_limits_tier_path with WHERE is_deleted = false. Soft-delete a rule and the path is free to configure again.
Operations
GET /health/ready answers for what a request actually needs. It's 503 while the database, its schema, the lockout limiter or the session store is unreachable, and it stays 200 for the cache and the task broker, because no request waits on those. Probes run together, each with its own two-second timeout, and two checks pointed at one server are asked once. The report is reused for a couple of seconds, so a flood of probes can't take the connection pool away from real traffic.
The admin panel is off until you ask for it. ADMIN_ENABLED unset leaves /admin unmounted, and so does an empty ADMIN_USERNAME or ADMIN_PASSWORD, because a panel with no credentials can only ever serve a login form. When it is mounted, it sits inside the app's security rather than beside it. Its failed logins feed the same lockout the API uses and every refusal is logged. It refuses cross-origin writes, signs you out on a POST only, binds your session to the credentials that made it, and writes formula-like CSV cells as text.
Background work gained retries and a scheduler of its own, and bp deploy generate writes a scheduler service next to the worker. A project without the taskiq feature gets neither.
The security pass
Three reports came in against the project.
The published one is a registration that could mass-assign email_verified and the OAuth linkage fields, which lets someone pre-claim an account. The signup schema forbids extra fields now, those fields live only on the internal schema that server-side code builds, and a test holds the schema to crudauth's gated_register_fields().
Most of what the other two describe was already answered by the work above. User reads need the user.read permission, and the by-username lookup needs a session and returns no address. Login refusals are uniform, and the lockout counts per address and per identifier at the same time, so rotating either one runs out of attempts. allow_credentials is dropped whenever * is among the origins, production refuses * outright, and the rate-limit configuration routes are superuser-only. What was left was the admin panel's defaults, which is what the section above describes. The credentials are compared in constant time, against values that have to be non-empty.
Every dependency with a published advisory moved to a patched release: aiohttp 3.14.5, anyio 4.15.1, certifi 2026.10.23, Mako 1.4.3, multidict 7.2.1, pydantic-settings 2.16.1, PyJWT 2.15.1, python-multipart 0.0.32, sqladmin 0.32.0, starlette 1.10.0 and urllib3 2.8.0. That's what the lock resolves on every Python version the project supports, and what the image installs. The floors the project declares moved with them. anyio and starlette are declared for the first time, because the task groups, middleware, requests and responses the code imports come from those two rather than through FastAPI.
Upgrading, in order
- Regenerate your compose file with
bp deploy generate local|prod|nginx, before anything else runs through it. The file you have maps the database credentials through${POSTGRES_PASSWORD:-postgres}in thepostgresservice. Compose fills that from your shell, never from theenv_filelisted right above it, andenvironmentwins overenv_file, so the password you set inbackend/.envwas replaced inside the container by the literalpostgres. If your volume was created that way, recreate it and restore, or change the password inside the database to the onebackend/.envcarries. - Run
scripts/cleanup_api_key_json.pyand rescope the keys it reports. - Fix the rows the two new indexes will refuse: two live rate-limit rules for one tier and path, and any
profile_image_urllonger than 2048. The migrations guide has the query for each, next to the one for addresses that differ only in case. - Generate the baseline revision and apply it with the compose
migrateprofile. It addsuser.token_versionandapi_keys.owner_token_version, both defaulting to0, plus the(tier_id, path)index and the length onprofile_image_url, and it dropskey_permissions. - Rewrite rate-limit rows as the route templates your router declares, and set
CORS_ORIGINSexplicitly. An empty value allows nothing now, where it used to allow everything. - Decide on
KEY_USAGE_RETENTION_DAYSand schedule the prune: the taskiq task if you run a worker,scripts/prune_key_usage.pyfrom cron if you don't. - Expect one round of sign-ins, and tell anyone holding API keys to verify their address before minting another.
- Set
ADMIN_ENABLED=true, withADMIN_USERNAMEandADMIN_PASSWORD, if you were reaching/adminwithout setting them.
Breaking changes
The changelog has all of them with their migration steps. These are the ones you'll notice from a client or a deployment.
- A generated compose stack reads the database credentials from
backend/.env. It used to read them from your shell and fall back topostgres. Regenerate, and check what your database was created with. - The admin panel is off until you ask for it.
ADMIN_ENABLEDdefaults tofalse, and an emptyADMIN_USERNAMEorADMIN_PASSWORDleaves it unmounted. - Two new columns need a migration,
user.token_versionandapi_keys.owner_token_version. The models select both, so apply the revision before you deploy. - An API key's scope is a list of registry permission names.
usage_limitsmay only name the three windows the limiter counts,key_permissionsis gone, andsrc/modules/api_keys/__init__.pyexports nothing,KeyTypeandKeyStatusincluded. - Minting a key answers
403until the account's address is verified. POST /api/v1/users/answers201with a fixed message whether the address was free or taken, and it no longer returns the profile it created. If your client readsresponse.json()["id"], read the account back after signing in instead.POST /auth/refresh-csrfis nowPOST /auth/csrf/refresh, andlogout-allanswers{"detail", "revoked"}where it used to answer{"message", "terminated_count"}.PATCH /users/{username}no longer takesemail, and it needscurrent_passwordto change one, answering403without it. Those attempts share the password-change budget, five an hour per account.- Everyone signs out once when you upgrade, and again whenever you rotate
SECRET_KEY. - Rate-limit rows that name a concrete path stop matching. Rewrite them as the route template your router declares.
items_per_pageover 100 answers422, along with out-of-range ids, pages and expiries, and text no column can hold.- A profile image URL and a
redirect_tostop at 2048 characters, and a usage row keeps the first 512 of a user agent. - An empty
CORS_ORIGINSallows nothing, an unknownSESSION_BACKENDis refused at startup, andCREATE_TABLES_ON_STARTUPis refused in production. - A cached response is keyed by the caller as well as the query. If a route's answer is the same for everybody, opt out with
per_caller=False. - The production validator refuses more than it did, and checks staging at all. A deployment that used to start may be refused, so run
bp env validatefirst. - The caller-resolving dependency aliases moved to
infrastructure/auth/deps.py.
How this was tested
Every change started as a test that failed for the reason we were about to fix, then the smallest change that answers it. After that we mutated the new guard and ran its test again, to watch it fail without the guard. A mutation that leaves the suite green means the guard isn't tested, and three times it meant the test was asserting something else entirely.
Nothing lands without the gate: ruff, mypy, the suite forwards and in reversed collection order with zero skips, the CLI suite, and the removal drill over all nine presets.
Then we did the documented first run twice, on a fresh clone, from git clone through uv sync, bp deploy generate local, docker compose up, the seed, sign-in, signup, minting a key and spending it, the admin panel's pages, throttling, recovery mail, and the production refusal. Those two runs found six bugs no unit test could have caught, the compose credentials among them, and all six are fixed in this release.
The docs are held to the code by tests now, not by review:
- every
pythonblock's project imports have to resolve - the documented baseline command has to name the database it runs against
- no documented block may bring a stack up from
backend/ - the script trees and the wiring modules have to be drawn where the guides draw them
Documentation
Composable Features is the new guide: what each feature owns, what it contributes through, how the drill works, and how to write one that can be removed. Observability covers what the project already emits and how to put Logfire or a plain OpenTelemetry collector in front of it without exporting a session cookie. Migrating passwords covers taking in hashes another library wrote and rewriting them as people sign in.
Thanks
To the researchers who reported security issues and gave us time to fix them: @capyplivl, @EQSTLab and @itsmiu.
If you want to talk to us
Join us on Discord, open an issue, or ping @igorbenav on the repo. If a feature you want to remove turns out not to be removable, or a preset you depend on isn't in the matrix, that's worth an issue. Adding a preset to the drill is cheap, and it's how we'd check the fix.
Full Changelog: v0.19.0...v0.20.0

