Skip to content

Permissions and Authorization

Authentication answers "who are you?". Authorization answers "what can you do?". This page covers the boilerplate's authorization patterns: role permissions, superuser flags, resource ownership, tier-based limits, and API key permissions.

Roles are the rbac feature, and the permission registry is core. A project without rbac keeps the registry and every require_permissions check, which then pass for superusers only: see Composable Features.

A credential's effective permissions are what the account holds, narrowed to what the credential carries A credential's effective permissions are what the account holds, narrowed to what the credential carries

Authorization Patterns

The boilerplate ships five overlapping mechanisms. Pick the one(s) that fit your use case.

Pattern Where it lives When to use
Role permissions Role + RolePermission + UserRole models, require_permissions Granting a named capability to a group of users
Superuser flag User.is_superuser boolean Admin-only operations
Resource ownership Service-layer permission checks "Users can only edit their own X"
Tier-based limits Tier model + RateLimit rules Subscription gating, rate limits
API key scope APIKey.permissions, a list of registry permission names What a key is allowed to do, once a key can authenticate a request

These compose. A typical request goes through:

  1. Authentication — the session cookie, or an API key, identifies who
  2. Coarse access — role permissions, or the superuser flag, for privileged endpoints
  3. Fine-grained access — service-layer ownership / tier checks
  4. Rate limiting — tier-based per-route limits (separate concern)

Role-Based Permissions

A permission is a flat resource.action string — user.read, tier.update. Permissions are never granted to a user directly: a Role carries a set of them, and a user is assigned roles.

User ──< UserRole >── Role ──< RolePermission

Three tables in modules/role/models.py:

Table Columns Notes
roles id, name (unique), description No soft delete — a deleted role is gone
role_permissions role_id, permission_name (composite PK) ON DELETE CASCADE from roles
user_roles user_id, role_id (composite PK) ON DELETE CASCADE from both sides

Declaring a Module's Permissions

Each module owns its permission names in its own permissions.py, as a StrEnum decorated with @register_permissions("<resource>"):

# modules/user/permissions.py
from enum import StrEnum

from ...infrastructure.permissions import register_permissions


@register_permissions("user")
class UserPermission(StrEnum):
    READ = "user.read"
    CREATE = "user.create"
    UPDATE = "user.update"
    DELETE = "user.delete"

Registration is validated: the resource must match ^[a-z][a-z0-9_]*$, every member must be <resource>.<action> with an action matching the same pattern, and the whole name must fit the 100-character permission_name column. A resource can only be registered once.

discover_permissions() walks src.modules.*.permissions and imports each one; modules/__init__.py calls it at import time, so a new permissions.py needs no registration elsewhere.

The registry in infrastructure/permissions.py is what the rest of the app reads:

Function Returns
all_permissions() Every registered name, as a frozenset[str]
permission_groups() {resource: (names, ...)} — for a UI that offers permissions per resource
is_known_permission(name) Whether a name is registered

Unregistered names can't be stored: RolePermission validates permission_name against the registry and raises. In the other direction, a name that was stored and has since been removed from the code is ignored when permissions are loaded, so deleting a permission from a StrEnum can never grant anything.

Protecting a Route

require_permissions(*names) returns a dependency that answers 403 unless the caller holds every name. It injects nothing into the handler, so it goes in the route's dependencies:

# modules/user/routes.py
from ...infrastructure.auth.authorization import require_permissions


@router.get(
    "/",
    response_model=PaginatedListResponse[UserRead],
    dependencies=[require_permissions("user.read")],
)
async def get_users(
    db: AsyncSessionDep,
    user_service: UserServiceDep,
    page: int = 1,
    items_per_page: int = 10,
) -> dict[str, Any]:
    ...

Unknown names are a programming error, not a runtime one: require_permissions raises when the route is declared, so a typo fails at import rather than on the first request.

Superusers bypass every permission check. require_permissions passes them without a lookup, and get_current_permissions reports them as holding every registered permission — so a superuser needs no roles.

Reading the Caller's Permissions

When the handler itself has to decide, take the permission set instead of a guard. CurrentPermissionsDep (from infrastructure/auth/deps.py) is get_current_permissions as an Annotated alias; FastAPI resolves it once per request, so several guards and parameters share one query:

from ...infrastructure.auth.deps import CurrentPermissionsDep


@router.patch("/{username}")
async def update_user_profile(
    username: str,
    values: UserUpdate,
    current_user: CurrentUserDep,
    permissions: CurrentPermissionsDep,
    db: AsyncSessionDep,
    user_service: UserServiceDep,
) -> dict[str, str]:
    await user_service.verify_update_permission(current_user, username, permissions)
    ...

load_permissions(db, user_id, is_superuser=...) is the same lookup as a plain function, for code outside a request — or, as in the route above, for reading another user's permissions to compare against the caller's.

Granting Permissions

A role is a row in roles plus one role_permissions row per permission; assigning it is a row in user_roles. Write them through the ORM (from a script, a migration, or your own admin tooling):

from src.modules.role.models import Role, RolePermission, UserRole

role = Role(name="support", description="Read-only access to user records")
db.add(role)
await db.flush()

db.add(RolePermission(role_id=role.id, permission_name="user.read"))
db.add(UserRole(user_id=user_id, role_id=role.id))
await db.commit()

Managing Roles

The rbac feature mounts a role API under /api/v1/roles. Every route is gated by its own role.* permission and by the delegation checks: a caller can neither grant a permission nor assign a role carrying one unless they hold it themselves. Superusers pass both.

Route Permission Also checked
GET /api/v1/roles/ role.read —
GET /api/v1/roles/{role_id} role.read —
POST /api/v1/roles/ role.create the caller holds every permission the new role carries
PATCH /api/v1/roles/{role_id} role.update the caller holds everything the role carries
PUT /api/v1/roles/{role_id}/permissions role.update the caller holds every permission the change adds or removes, and no live account holding the role is a superuser or holds anything the caller doesn't
DELETE /api/v1/roles/{role_id} role.delete the caller holds everything the role carries, and no live account holding the role is a superuser or holds anything the caller doesn't
POST /api/v1/roles/{role_id}/users/{user_id} role.assign the caller holds everything the role carries, and the account is not a superuser and holds nothing the caller doesn't
DELETE /api/v1/roles/{role_id}/users/{user_id} role.assign the same
GET /api/v1/users/{user_id}/roles role.read —
# Create a role carrying permissions you hold
curl -X POST http://localhost:8000/api/v1/roles/ -b cookies.txt \
  -H "Content-Type: application/json" -H "X-CSRF-Token: <token>" \
  -d '{"name": "editor", "description": "Edits profiles", "permissions": ["user.read", "user.update"]}'

# Hand it to somebody
curl -X POST http://localhost:8000/api/v1/roles/3/users/42 -b cookies.txt -H "X-CSRF-Token: <token>"

Only registered permission names are accepted: anything else answers 422, and the refusal doesn't echo what was sent. A refused delegation answers 403 and writes nothing — including the reverse cases: emptying or relabelling a role whose permissions the caller doesn't hold, and changing the roles of an account stronger than the caller's own. That last rule is the one a user.update holder is already held to when editing an account, because changing which roles an account holds is another way to take it over, and emptying or deleting a role is held to it for every account that holds the role, since either one takes the role's permissions away from all of them at once. An account a soft delete has taken out is not one of those: its grants reach nobody, while its assignment rows stay behind.

A stored name the registry no longer knows counts for nothing, and is not held against the caller either: a role carrying a name nothing declares any more can still be renamed, emptied and deleted by whoever holds the registered permissions it carries, and setting its permissions is what drops the stale name. Otherwise removing a feature would strand every role that carried one of its permissions, a superuser included.

Two writes to one role queue rather than race. Every write path takes the role's row first (SELECT … FOR UPDATE): two requests assigning one role to one account both answer 201, two replacing what it carries leave it carrying one of the two sets rather than both, and whichever arrives after the role was deleted answers 404. An account a soft delete took out answers 404 as well, and one the admin panel deletes while an assignment is being written waits for it.

The account the write is about is held too, since the stronger-account checks read what it holds. An assignment takes that row FOR NO KEY UPDATE, so a superuser handing the same account a role the caller cannot reach lands either side of the write rather than between its check and its own insert. Taking a role away, or replacing what it carries, is a change to every holder's grants, so those rows are held FOR SHARE: two of those run at once, while an assignment to one of the holders waits for them. The role's row is always taken before an account's, so the two orders never meet.

What does wait is every write to an account's own row while a role write holds it: a profile edit, and the bookkeeping a sign-in writes there. Taking a role away holds the row of every account holding it until the delete commits. Both are the length of one role write, which reads a handful of rows and commits, and a row that only names the account — an API key, a usage row — waits for none of it.

A role_id or a user_id outside 1..2147483647 answers 422. No Integer column holds a value past that, so the lookup used to reach the database and fail there.

A role name is unique, and the unique constraint has the last word: two requests racing for one name give the loser a 409, not a 500.

Reading the Permissions

Two routes read them, both needing a session (the rbac feature mounts them):

# Every permission the running project has, grouped by the resource that declared it —
# what a UI offers when it builds a role, since a role may carry nothing else.
curl http://localhost:8000/api/v1/permissions -b cookies.txt
# → {"permissions": {"api_key": ["api_key.read", ...], "role": ["role.read", ...], ...}}

# What the caller holds, from every source the project wired.
curl http://localhost:8000/api/v1/permissions/me -b cookies.txt
# → {"permissions": ["user.update"]}

A superuser's own listing is every registered permission. A stored grant whose name is no longer registered appears in neither: the registry is what makes a name mean anything.

RBAC here is global roles: a role means the same thing everywhere in the project, and there is no per-tenant or per-object scoping. A permission answers "may this account do this kind of thing", not "may it do this to that row" — ownership checks stay in the services.

With the admin feature the panel carries the same three tables — see Admin Panel. The panel signs in with the ADMIN_* credentials rather than as an account, and those already let it set is_superuser on anybody, so there is no weaker operator for the delegation checks above to hold back there. What the panel does enforce is that a grant names a permission the registry knows.

Superuser Authorization

The User model has an is_superuser: bool column. Endpoints that should only be accessible to admins use the get_current_superuser dependency:

from typing import Annotated, Any

from fastapi import APIRouter, Depends

from ...infrastructure.auth.dependencies import get_current_superuser

router = APIRouter()


@router.delete("/admin/users/{username}")
async def gdpr_anonymize(
    username: str,
    _: Annotated[dict[str, Any], Depends(get_current_superuser)],
) -> dict[str, str]:
    # Only superusers reach this code
    ...

The leading _: is the codebase convention for dependency-only parameters whose value isn't used.

get_current_superuser returns 401 if not authenticated and 403 if authenticated but not a superuser. See Sessions for the dependency reference.

When to Use the Superuser Flag

  • User management (create/delete other users)
  • Tier assignment (PATCH /api/v1/users/{username}/tier)
  • Rate limit configuration (PATCH /api/v1/rate-limits/{name})
  • GDPR data anonymization
  • System configuration changes

Anything a subset of staff should be able to do is better expressed as a permission on a role than as another superuser.

Bootstrapping the First Superuser

The first superuser is created by scripts/setup_initial_data.py from ADMIN_* env vars on first run:

cd backend
uv run --no-sync python -m scripts.setup_initial_data

To grant superuser to an existing user, flip the column directly via the admin UI (/admin) or a one-off SQL update.

Resource Ownership

Most "users can only modify their own data" rules belong in the service layer, not the route. The service raises a PermissionDeniedError, which the global handler maps to HTTP 403.

Real example from modules/user/service.py:

from ..common.exceptions import PermissionDeniedError


async def verify_user_permission(
    self,
    current_user: dict[str, Any],
    target_username: str,
    action: str,
) -> None:
    """Raise PermissionDeniedError if current_user can't act on target_username."""
    if current_user["username"] != target_username and not current_user["is_superuser"]:
        raise PermissionDeniedError(f"Cannot {action} for another user")

Routes call this before dispatching the operation.

Where ownership and a role permission both apply, the service takes the permission set too. PATCH /api/v1/users/{username} is the example that ships:

# modules/user/routes.py
@router.patch("/{username}")
async def update_user_profile(
    username: str,
    values: UserUpdate,
    current_user: CurrentUserDep,
    principal: CurrentPrincipalDep,
    permissions: CurrentPermissionsDep,
    db: AsyncSessionDep,
    user_service: UserServiceDep,
) -> dict[str, str]:
    await user_service.verify_update_permission(
        current_user, username, permissions, is_superuser=principal.is_superuser, scoped=carries_a_scope(principal)
    )
    user = await user_service.get_by_username(username, db)

    if not user_service.is_self_or_superuser(current_user, user["username"], is_superuser=principal.is_superuser):
        target_permissions = await load_permissions(db, user["id"])
        user_service.verify_no_privilege_escalation(user, values, permissions, target_permissions)

    await user_service.update(user["id"], values, db)
    return {"message": "User updated successfully"}

The rules this enforces:

  • A user may edit their own profile, and a credential that carries a scope needs user.update to edit that profile too.
  • A superuser may edit anyone.
  • A user.update holder may edit another user only when that user is not a superuser and holds no permission the requester lacks — editing an account is a way to take it over, so it can't reach a stronger one.
  • Only a superuser may change another user's email address: a verified provider email is how an OAuth login is matched to an existing account.

The public UserUpdate schema accepts name, username, email and profile_image_url only. google_id, github_id, oauth_provider, email_verified and oauth_updated_at moved to UserAdminUpdate, which the admin panel uses — sending them to the API returns 422.

The exception flows up to the global handler (registered in infrastructure/app_factory.py) which translates it via the EXCEPTION_MAPPING table — PermissionDeniedError → ForbiddenException (403). See Exceptions for the full mapping pipeline.

Generic Ownership Pattern

For your own modules:

# modules/widgets/service.py
from ..common.exceptions import PermissionDeniedError, ResourceNotFoundError


class WidgetService:
    async def delete(
        self, widget_id: int, current_user: dict[str, Any], db: AsyncSession,
    ) -> None:
        widget = await crud_widgets.get(db=db, id=widget_id)
        if widget is None:
            raise ResourceNotFoundError("Widget not found")

        if widget["owner_id"] != current_user["id"] and not current_user["is_superuser"]:
            raise PermissionDeniedError("Cannot delete another user's widget")

        await crud_widgets.delete(db=db, id=widget_id)

Three rules to follow:

  1. Service raises domain exceptions, not HTTP exceptions. Lets the same logic be reused outside routes (admin scripts, tests, taskiq jobs).
  2. Superuser bypass is explicit. not current_user["is_superuser"] makes the rule readable.
  3. Order: existence check first, then ownership. A 404 is preferred to a 403 for resources the user shouldn't even know about — see the Hide Resource Existence note.

Tier-Based Authorization

Every user has a tier_id foreign key to the Tier model. The boilerplate ships bare tiers — just name and description, no built-in feature mapping or pricing logic. You decide what tiers mean.

Reading the User's Tier

User.tier is loaded automatically via lazy="selectin", so a fetched user record includes their tier:

@router.get("/me", response_model=UserRead)
async def me(
    current_user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
    # current_user["tier"] is the joined Tier dict (or None)
    return current_user

Gating a Feature on Tier Name

For a simple feature gate, check the tier name directly in the service:

async def export_data(self, current_user: dict[str, Any], db: AsyncSession) -> bytes:
    tier = current_user.get("tier") or {}
    if tier.get("name") not in {"pro", "enterprise"}:
        raise PermissionDeniedError("Data export requires the Pro or Enterprise tier")
    # ...generate export...

This works for "binary" features. For more complex models (per-feature quotas, multiple add-ons), consider building an entitlements system on top — that's outside the scope of the boilerplate.

Tier-Based Rate Limits

Rate limiting is built-in: each RateLimit row binds a tier to a path with a limit and period. crudauth's limiter, wired in infrastructure/auth/setup.py, enforces these per request. See Rate Limiting.

To configure rate limits for a tier:

# Create a rate limit (admin only)
curl -X POST http://localhost:8000/api/v1/rate-limits/ \
  -b superuser_cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token>" \
  -d '{
    "tier_id": 2,
    "name": "pro_users",
    "path": "/api/v1/widgets/",
    "limit": 1000,
    "period": 60
  }'

API Key Scope

A key's scope is a list of registry permission names — the same vocabulary a role carries and require_permissions gates on — stored on the key row:

# modules/api_keys/models.py
class APIKey(Base, TimestampMixin):
    __tablename__ = "api_keys"

    permissions: Mapped[list[str]] = mapped_column(JSON, insert_default=list)

Create and update validate every name against the registry, so a scope can only name a permission the project declares. An unregistered name answers 422 without echoing what was sent, and the names come back deduplicated and sorted. A key created without permissions is unscoped: it names its owner and carries no permission at all.

curl -X POST http://localhost:8000/api/v1/api-keys/ \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token>" \
  -d '{"name": "Read-only integration", "permissions": ["user.read"], "usage_limits": {"requests_per_day": 10000}}'

A stored name the registry no longer knows is read back as it is and counts for nothing, the same way a role's stale grant does. A scope stored in a shape that is not a list of names, the object an older version wrote, is not read back at all: the key answers 401 with a warning naming it, until scripts/cleanup_api_key_json.py rewrites the row.

What a key holds

A key-authenticated request holds its owner's permissions, narrowed to the key's scope. The scope never grants: a permission the owner loses disappears from the key at the next request, and a key scoped to nothing holds nothing — it names its owner and that is all. GET /api/v1/permissions/me answers with the narrowed set, so a client can read exactly what its key can do.

A key's scope may name only permissions its creator holds at the time: POST /api/v1/api-keys/ and a PATCH that widens one answer 403 otherwise, so a key can never be minted stronger than the credential minting it.

A key is never a superuser

is_superuser grants what no scope can name — it bypasses the ownership checks in the services, not just permission gates — so a credential carrying a scope is held to that scope whatever the account is marked. A superuser's key scoped to ["user.read"] holds user.read, passes a route gated on it, and is refused by require_permissions("role.read") and by every superuser-only route. The superuser's own session is unaffected.

This holds everywhere the flag was read: the delegation checks behind the role routes compare against what the credential holds, so a superuser's key scoped to role.assign cannot assign a role carrying anything its scope lacks; and editing another account, or reading its tier or rate limits, goes by the acting credential's flag rather than the account's row.

Ownership is a separate axis: a scope narrows permissions, not what an account owns. It is no licence on its own, though. Editing a profile takes user.update of a scoped credential even where the profile is its owner's, so a key minted for reading cannot rename the account behind it. The other self-service routes, the password, the address, closing the account and managing keys, take a session and refuse a key outright (API keys).

A key's lifecycle is is_active and expires_at: revoking is one way, and an expiry can be brought forward but never pushed back. API Keys has the routes.

Combining Patterns

A real endpoint often uses several at once:

@router.delete("/widgets/{widget_id}", status_code=204)
async def delete_widget(
    widget_id: int,
    current_user: Annotated[dict[str, Any], Depends(get_current_user)],   # 1. authn
    db: Annotated[AsyncSession, Depends(async_session)],
    widget_service: Annotated[WidgetService, Depends(get_widget_service)],
) -> None:
    # The service handles:
    #   2. Existence check
    #   3. Ownership check (superuser bypass)
    #   4. Tier feature gate (e.g. "delete requires Pro tier")
    await widget_service.delete(widget_id, current_user, db)

The route stays trivial. Authorization rules accumulate in the service, where they're testable and reusable.

Testing Authorization

Test the service, not the route, for permission rules — they're easier to set up and faster to run.

import pytest
from src.modules.user.service import UserService
from src.modules.common.exceptions import PermissionDeniedError


@pytest.mark.asyncio
async def test_normal_user_cannot_update_other_users():
    service = UserService()
    current_user = {"username": "alice", "is_superuser": False}

    with pytest.raises(PermissionDeniedError):
        await service.verify_user_permission(current_user, "bob", "update profile")


@pytest.mark.asyncio
async def test_superuser_can_update_other_users():
    service = UserService()
    current_user = {"username": "alice", "is_superuser": True}

    # Should not raise
    await service.verify_user_permission(current_user, "bob", "update profile")

For end-to-end coverage, integration tests against TestClient exercise the full session-cookie + permission-check stack. See Testing.

Best Practices

Keep authorization in services

Routes do dependency injection and HTTP shaping; services hold rules. If a PermissionDeniedError raise feels out of place in your service, that's a sign your service is doing more than business logic.

Order checks: authn → existence → ownership → quota

# 1. Authenticated? — done by the dependency
# 2. Resource exists?
if widget is None:
    raise ResourceNotFoundError(...)
# 3. User owns it?
if widget["owner_id"] != current_user["id"] and not current_user["is_superuser"]:
    raise PermissionDeniedError(...)
# 4. Quota / tier OK?
if not within_tier_limits(...):
    raise PermissionDeniedError(...)

This order prevents leaking existence (404 before 403) and keeps the cheap checks first.

Don't reinvent rate limits

The built-in tier rate-limiter middleware is enforced before your route runs. Don't roll your own per-feature counters unless you need something the middleware can't express. See Rate Limiting.

Audit superuser actions

Superuser endpoints touch sensitive data. Log the actor + action server-side — the boilerplate's logging infrastructure (with correlation_id + support_id) makes this straightforward. See Logging for the setup.

Next Steps