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:
userisNone→ denied.- Superuser —
is_superuser(user)(default: readsuser.is_admin) → allowed, always. - Are there rules registered for
perm? - with
obj→ allowed when any rule returns truthy. - without
obj→ allowed when the static set holdspermor any rule (called withobj=None) returns truthy. - 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) -> boolpredicate, registered with@permission("resource.action"). has_permreturns a bool;check_permissionraisesForbiddenException.make_permission_checkerguards the route (with or without an object).- Superuser and static set are injectable via
PermissionRegistry. - Rules match by exact string or wildcard (
order.*,*). PermissionMixingives youawait 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.