Permission guards (@requires)¶
You already hold the user — it came from a dependency, or from a service parameter — and you want to assert an invariant before the function body runs: "must be active", "must own the order", "must be an admin".
@requires does that with plain functions: no framework, no registry, no magic
strings. 🚀
The problem¶
Without a decorator, the check becomes noise at the top of every function:
from uuid import UUID
from tempest_fastapi_sdk import ForbiddenException, UnauthorizedException
from src.db.models import UserModel
async def delete_order(order_id: UUID, user: UserModel | None) -> None:
if user is None:
raise UnauthorizedException(message="Authentication required")
if not user.is_active:
raise ForbiddenException(message="User account is inactive")
if order_id not in user.owned_orders:
raise ForbiddenException(message="Not the order owner")
...
Three problems: repetition across every route, authorization mixed into business
logic, and nothing stops someone from writing if not allowed: return None
instead of raising — the route answers 200 on a denial.
The solution in 2 steps¶
1. Write the guard¶
A guard is an ordinary function: it takes the user, returns the user (or
None) and denies by raising an AppException.
from tempest_fastapi_sdk import ForbiddenException
from src.core.exceptions import NotOrderOwnerException
from src.db.models import UserModel
def order_owner(user: UserModel) -> UserModel:
"""Assert the user owns the order under edit.
Args:
user (UserModel): The authenticated user.
Returns:
UserModel: The same user.
Raises:
NotOrderOwnerException: When the user does not own the order.
"""
if not user.owns_current_order:
raise NotOrderOwnerException()
return user
A guard denies by raising, never by returning False
return False denies nothing — @requires ignores the value and warns
with GuardContractWarning, and tempest permissions reports
guard-returns-bool as an error. The reason is error standardization:
raising an AppException gets you the HTTP status, the code and the
{detail, code, details} envelope for free from the SDK handlers.
2. Decorate the function¶
from uuid import UUID
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import error_responses, requires
from tempest_fastapi_sdk.auth import require_active
from src.api.dependencies import get_current_user
from src.api.guards import order_owner
from src.controllers import OrderController
from src.core.exceptions import NotOrderOwnerException
from src.db.models import UserModel
controller = OrderController(...)
router = APIRouter()
@router.delete(
"/orders/{order_id}",
responses=error_responses(NotOrderOwnerException),
)
@requires(require_active, order_owner)
async def delete_order(
order_id: UUID,
user: UserModel = Depends(get_current_user),
) -> None:
"""Delete an order the caller owns.
Args:
order_id (UUID): The order to delete.
user (UserModel): The authenticated, active, owning user.
"""
await controller.delete(order_id)
That is it. Guards run left to right before the body; the body runs only if all of them pass.
Decorator order
@requires goes below the route decorator. The router must register the
already-guarded function.
Where the user comes from¶
@requires finds the user parameter by annotation: the one whose type is a
BaseModel / BaseUserModel subclass. Nothing to configure in the common case.
When the signature holds more than one user, point at the right one:
from fastapi import Depends
from tempest_fastapi_sdk import requires
from src.api.dependencies.auth import get_current_user, get_target_user
from src.core.guards import can_ban_users
from src.db.models import UserModel
@requires(can_ban_users, user_param="target")
async def ban_user(
actor: UserModel = Depends(get_current_user),
target: UserModel = Depends(get_target_user),
) -> None:
"""Ban the target user.
Args:
actor (UserModel): The moderator performing the ban.
target (UserModel): The user being banned.
"""
...
Technical details — annotation resolution
The order is: an explicit user_param=; then the single parameter whose
annotation resolves to a user model; then — only when no annotation resolved
to one — the single parameter whose name is in USER_PARAM_NAMES
(user, current_user, actor, requester, principal) or whose textual
annotation mentions User. That last step exists because
from __future__ import annotations plus a TYPE_CHECKING import leaves the
annotation impossible to evaluate at decoration time.
When nothing resolves — or two candidates tie — the import fails with
TempestPermissionError. Better an application that refuses to start than
one running a check that never fires.
The guard's return narrows the type¶
A guard that returns the user replaces the user seen by the next guard and by
the function body. That is how the SDK guards
(require_authenticated / require_active / require_admin) turn
UserT | None into UserT:
from fastapi import Depends
from tempest_fastapi_sdk import require_active, requires
from src.api.dependencies.auth import get_current_user_soft
from src.db.models import UserModel
@requires(require_active)
async def me(user: UserModel | None = Depends(get_current_user_soft)) -> UserModel:
"""Return the authenticated user.
Args:
user (UserModel | None): Filled by the soft dependency; guaranteed
non-None inside the body.
Returns:
UserModel: The active user.
"""
return user
Returning None is allowed and means "I did not touch the user".
Metadata: one generic guard, many call sites¶
A guard may declare a second parameter meta: dict[str, Any]. That is what turns a generic guard into a specific check per route — instead of writing manager_only, auditor_only, admin_only, you write has_role once and each call site says which role it requires.
from typing import Any
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import ForbiddenException, requires
from src.api.dependencies.auth import get_current_user
from src.core.exceptions import MissingRoleException
from src.db.models import UserModel
router = APIRouter()
def has_role(user: UserModel, meta: dict[str, Any]) -> UserModel:
"""Assert the user holds the role the route declared.
Args:
user (UserModel): The authenticated user.
meta (dict[str, Any]): Metadata injected by ``@requires``.
Returns:
UserModel: The same user.
Raises:
MissingRoleException: When the declared role is missing.
"""
if meta["role"] not in user.roles:
raise MissingRoleException(role=meta["role"])
return user
@router.post("/reports/close-month")
@requires(has_role, meta={"role": "manager"})
async def close_month(user: UserModel = Depends(get_current_user)) -> None:
"""Close the accounting month.
Args:
user (UserModel): The authenticated manager.
"""
...
One-parameter guards behave exactly as before — the second argument only goes to guards that declare it. You can mix both in the same decoration.
include_args=True: the guard sees the call's arguments¶
meta= carries literals fixed at decoration. When the check depends on the resource of the request, turn on include_args=True and the call's arguments (path params, body, other dependencies) join the same dict:
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import require_active, requires
from src.api.dependencies.auth import get_current_user
from src.core.exceptions import NotOrderOwnerException
from src.db.models import UserModel
router = APIRouter()
def order_owner(user: UserModel, meta: dict[str, Any]) -> UserModel:
"""Assert the user owns the order named by the metadata.
Args:
user (UserModel): The authenticated user.
meta (dict[str, Any]): Metadata injected by ``@requires``.
Returns:
UserModel: The same user.
Raises:
NotOrderOwnerException: When the user does not own the order.
"""
if meta["order_id"] not in user.order_ids:
raise NotOrderOwnerException()
return user
@router.delete("/orders/{order_id}")
@requires(require_active, order_owner, include_args=True)
async def delete_order(
order_id: UUID,
user: UserModel = Depends(get_current_user),
) -> None:
"""Delete an order the caller owns.
Args:
order_id (UUID): The order to delete.
user (UserModel): The authenticated, active, owning user.
"""
...
The guard receives {"order_id": UUID(...)} without the route handing anything over.
Merge rules
- The user is excluded — the guard already receives it as the first parameter.
- A parameter the caller left out contributes its default, so the guard sees the values the body will run with. A default that is an injection marker (
Depends(...)) is dropped, never handed over as a value. - A key declared in
meta=wins over an argument of the same name: the decoration is the explicit declaration, the argument is ambient data.tempest permissionsreports it (meta-key-collision), because that argument never reaches the guard. - The dict is fresh per call and shared by that call's guards — one guard may write a key the next one reads, and nothing leaks into the next request.
Configuration errors¶
| Situation | What happens |
|---|---|
meta= is not a mapping |
TempestPermissionError at import |
meta= / include_args=True with no two-parameter guard |
TempestPermissionError at import — the configuration would do nothing |
a two-parameter guard and a decoration with no meta=/include_args= |
runs with {}; tempest permissions reports guard-meta-missing |
| a guard with 3+ required parameters | TempestPermissionError at import (expected 1 (user) or 2 (user, meta)) |
To audit what a route declared:
from tempest_fastapi_sdk import guard_metadata
from src.services.billing import close_month
assert guard_metadata(close_month) == {"role": "manager"}
guard_metadata returns only the meta= literals — whatever include_args=True merges exists per call and cannot be read off the function object.
Works at any layer¶
Nothing here depends on FastAPI. The same decorator works on a controller or a
service, sync or async:
from uuid import UUID
from tempest_fastapi_sdk import requires
from src.api.guards import order_owner
from src.db.models import UserModel
class OrderService:
"""Business logic for orders."""
@requires(order_owner)
async def delete(self, order_id: UUID, user: UserModel) -> None:
"""Delete an order the caller owns.
Args:
order_id (UUID): The order to delete.
user (UserModel): The owning user.
"""
await self.repository.delete(order_id)
async guards may only decorate async functions — otherwise the coroutine
would never be awaited, so the import fails with TempestPermissionError.
The linter catches your mistakes¶
Two layers, because each sees what the other cannot.
At import time — TempestPermissionError¶
The decorator validates while the module is imported. The application does not start with:
| Situation | Message |
|---|---|
@requires() with no guard |
needs at least one guard |
| non-callable guard | is not callable |
| guard with 2 required params | takes 2 required params, expected 1 (user) |
async guard on a sync function |
is async but ... is not |
| no user parameter | no parameter annotated with a user model |
| two user parameters | several parameters are user models |
meta=/include_args= with no consumer |
no guard declares a second parameter |
At call time — GuardContractWarning¶
What only shows up while running: a guard raising ValueError (the API would
answer 500 with no code) and a guard returning False (the denial would be
ignored). @requires warns and lets the original exception propagate — it
must not change the outcome of a call it is only observing.
Turn it into an error in tests
Run the suite with
-W error::tempest_fastapi_sdk.authz.GuardContractWarning (or
filterwarnings = ["error"] in pyproject.toml) and a guard outside the
contract fails the test instead of becoming a log line.
In CI — tempest permissions¶
What neither layer reaches: a guard whose raise sits behind an if no test
exercises, or a guard that is never imported. The command reads the contract off
the source with ast, without importing the application:
tempest permissions # informative report (exit 0)
tempest permissions --check # exit 1 on any error (CI gate)
tempest permissions --check --strict # fail on warnings too
tempest permissions --path src --path libs
src/api/routers/orders.py:41 delete_order
error: guard-foreign-exception: guard 'order_owner' raises ValueError, which is
not an AppException subclass; the API layer answers it as HTTP 500 without an
error code
warning: guard-missing-annotation: guard 'order_owner' has no return annotation
2 finding(s), 1 error(s).
The codes it reports:
| Code | Severity | What it means |
|---|---|---|
no-guards |
error | @requires() with no guard: everything passes |
user-param-missing |
error | no parameter is a user model |
user-param-ambiguous |
error | several candidates, no user_param= |
guard-arity |
error | the guard takes neither 1 parameter (user) nor 2 (user, meta) |
meta-unused |
error | meta=/include_args= with no guard to receive it |
guard-async-in-sync |
error | async guard on a sync function |
guard-returns-bool |
error | predicate-style guard: its False is ignored |
guard-foreign-exception |
error | raises outside the AppException hierarchy |
guard-never-denies |
warning | nothing in the call graph raises |
guard-missing-annotation |
warning | parameter or return unannotated |
guard-return-type |
warning | return is neither the user, None, nor their union |
guard-meta-missing |
warning | the guard asks for metadata and the decoration supplies none |
guard-meta-annotation |
warning | the 2nd parameter is annotated as something that cannot hold dict[str, Any] |
meta-key-collision |
warning | a meta= key shadows a parameter under include_args=True |
guard-unresolved |
warning | the guard is a lambda, lives outside the scanned paths, or its name maps to several definitions |
Reports instead of guessing
A guard whose name exists in two modules becomes guard-unresolved, not a
check against the wrong definition. Same policy as openapi-errors:
over-reporting is acceptable, a confident wrong verdict is not.
OpenAPI error-docs integration¶
A guard denies by raising, so its exception is as reachable as that of any
function the body calls. tempest openapi-errors reads the @requires guards
when building the reachable set:
--fix writes responses=error_responses(NotOrderOwnerException) into the
route, like any other exception in the flow. Details in the
OpenAPI errors » recipe.
Auditing a route's guards¶
from tempest_fastapi_sdk import declared_guards, guarded_user_param, require_active
from src.core.guards import order_owner
from src.services.orders import delete_order
assert declared_guards(delete_order) == (require_active, order_owner)
assert guarded_user_param(delete_order) == "user"
Handy in a test asserting that every write route carries at least one guard.
@requires vs. the other authorization tools¶
| Tool | Question it answers | Where it lives |
|---|---|---|
make_permission_dependency |
"does the token carry orders:write?" |
route dependency, before the handler |
has_perm / make_permission_checker |
"may this user act on this object?" | registry of (user, obj) -> bool rules |
@requires |
"does this user pass these invariants?" | any function that already receives the user |
The three compose: a guard may call check_permission(user, "order.delete",
obj=order) and pull the whole registry inside @requires.
Recap¶
- A guard is
(user) -> user | None— or(user, meta) -> user | None— that denies by raising anAppException. @requires(g1, g2)runs the guards in order, below the route decorator.- The user parameter comes from the annotation;
user_param=breaks ties. meta={...}parameterizes a generic guard;include_args=Truehands the call's arguments to it.- A non-
Nonereturn replaces the user — that is how the type narrows. - Works on routers, controllers and services, sync or
async. - Misuse:
TempestPermissionErrorat import,GuardContractWarningat call time,tempest permissions --checkin CI. - Guard exceptions reach
error_responses(...)throughtempest openapi-errors.