Skip to content

Object-level permissions

Object-based authorization — "may this user edit this order?" — rather than only "does this token carry the orders:write capability?".

Authentication vs authorization

tempest_fastapi_sdk.auth handles who the user is (login, JWT, session). This module, tempest_fastapi_sdk.authz, handles what they may do — and takes the object into account. The two complement each other.

The problem

The static guard (make_permission_dependency) answers one question per token:

# "does the token carry the orders:write permission?"

from fastapi import Depends

from tempest_fastapi_sdk import JWTUtils, make_permission_dependency

from src.core.settings import settings

tokens = JWTUtils(settings)


Depends(make_permission_dependency(tokens, ["orders:write"]))

But the real decision almost always depends on the row: the owner may delete their own order, a moderator may delete any, everyone else may delete none. The token can't know that — only the object does.

The solution in 3 steps

1. Register a rule

A rule is a predicate (user, obj) -> bool. Decorate it with @permission(...):

from tempest_fastapi_sdk.authz import permission

from src.db.models import OrderModel, UserModel


@permission("order.delete")
def only_owner_can_delete(user: UserModel, order: OrderModel) -> bool:
    """Only the owner deletes their own order."""
    return order.owner_id == user.id

2. Ask the registry

import asyncio

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.authz import has_perm

from src.db.models import OrderModel, UserModel

# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))

current_user = UserModel(name="Ana", email="ana@example.com")
user = UserModel(name="Ana", email="ana@example.com")
order = OrderModel(user_id=user.id, total=100)
repository = BaseRepository(session, model=UserModel)


async def main() -> None:
    """Run this example."""
    allowed: bool = await has_perm(current_user, "order.delete", obj=order)
    if allowed:
        await repository.delete(order.id)


asyncio.run(main())

Or raise ForbiddenException directly on denial:

import asyncio

from tempest_fastapi_sdk.authz import check_permission

from src.db.models import OrderModel, UserModel

current_user = UserModel(name="Ana", email="ana@example.com")
user = UserModel(name="Ana", email="ana@example.com")
order = OrderModel(user_id=user.id, total=100)


async def main() -> None:
    """Run this example."""
    await check_permission(current_user, "order.delete", obj=order)
    # flow continues if allowed; 403 otherwise


asyncio.run(main())

3. (Optional) Guard the route

make_permission_checker builds a FastAPI dependency that resolves the user and the object and calls check_permission for you:

from uuid import UUID

from fastapi import APIRouter, Depends

from tempest_fastapi_sdk.authz import make_permission_checker

from src.api.dependencies import get_current_user, get_order_or_404

router = APIRouter()


require_delete = make_permission_checker(
    "order.delete",
    get_user=get_current_user,
    get_object=get_order_or_404,   # dependency returning the OrderModel
)


@router.delete("/orders/{order_id}", dependencies=[Depends(require_delete)])
async def delete_order(order_id: UUID) -> None:
    """Reached only by callers who passed the object-level check."""
    ...

Omit get_object for a model-level check (obj=None) — handy on POST /orders (create), where no object exists yet.

How the decision is made

has_perm(user, perm, obj) resolves in this order:

  1. user is Nonedenied.
  2. Superuseris_superuser(user) (default: reads user.is_admin) → allowed, always.
  3. Are there rules registered for perm?
  4. with obj → allowed when any rule returns truthy.
  5. without obj → allowed when the static set holds perm or any rule (called with obj=None) returns truthy.
  6. No rule for perm → fall back to the static permission set (permission_resolver(user)); a blanket capability applies to every object.

Wildcards

A rule can be registered on a pattern: order.* covers order.delete, order.update, …; * covers everything. A broad rule (order.* → moderator) and a specific one (order.delete → owner) can coexist — access is granted if any of them allows it.

Async handlers

The predicate can be async — useful when the decision needs the database:

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import permission

from src.db.models import ProjectModel, UserModel
from src.db.repositories import MembershipRepository

# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))

membership_repo = MembershipRepository(session)


@permission("project.invite")
async def is_project_member(user: UserModel, project: ProjectModel) -> bool:
    return await membership_repo.exists({"project_id": project.id, "user_id": user.id})

Tuning bypass and fallback

By default the superuser is user.is_admin and the static set comes from user.permissions. Both are injectable — build your own registry:

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import has_perm, permission
from tempest_fastapi_sdk.authz import PermissionRegistry

from src.db.models import OrderModel, UserModel
from src.db.repositories import RoleRepository

# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))

role_repo = RoleRepository(session)


async def perms_from_roles(user: UserModel) -> set[str]:
    """Derive permissions from the user's roles (async, from the DB)."""
    return await role_repo.permissions_for(user.id)


registry = PermissionRegistry(
    is_superuser=lambda u: u.is_admin or "root" in u.roles,
    permission_resolver=perms_from_roles,
)


@permission("order.delete", registry=registry)
def rule(user: UserModel, order: OrderModel) -> bool:
    return order.owner_id == user.id


async def can_delete(user: UserModel, order: OrderModel) -> bool:
    """Resolve the rule for one user/order pair."""
    return await has_perm(user, "order.delete", obj=order, registry=registry)

The user.has_perm(...) call site

Inherit PermissionMixin on your user model for the shortcut:

import asyncio

from tempest_fastapi_sdk import BaseUserModel
from tempest_fastapi_sdk.authz import PermissionMixin

from src.db.models import OrderModel, UserModel

user = UserModel(name="Ana", email="ana@example.com")
order = OrderModel(user_id=user.id, total=100)


class UserModel(BaseUserModel, PermissionMixin):
    __tablename__ = "users"


async def main() -> None:
    """Run this example."""
    # anywhere:
    if await user.has_perm("order.delete", obj=order):
        ...


asyncio.run(main())

The mixin delegates to the global registry (default_registry).

Recap

  • A rule is a (user, obj) -> bool predicate, registered with @permission("resource.action").
  • has_perm returns a bool; check_permission raises ForbiddenException.
  • make_permission_checker guards the route (with or without an object).
  • Superuser and static set are injectable via PermissionRegistry.
  • Rules match by exact string or wildcard (order.*, *).
  • PermissionMixin gives you await user.has_perm(...).

Already holding the user?

When the check is an invariant on the user itself (active, admin, owner of the resource) rather than a question for the registry, @requires runs (user) -> user | None guards at any layer — and a guard may call check_permission(...) to combine both approaches.