Guards de permissão (@requires)¶
Você já tem o usuário na mão — veio de uma dependência, de um parâmetro de serviço — e quer garantir uma invariante antes de rodar o corpo da função: "tem que estar ativo", "tem que ser o dono do pedido", "tem que ser admin".
O @requires faz isso com funções normais, sem framework, sem registry, sem
string mágica. 🚀
O problema¶
Sem decorator, a checagem vira ruído no começo de cada função:
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")
...
Três problemas: repetição em toda rota, mistura de autorização com regra de
negócio, e nada impede que alguém escreva if not allowed: return None em vez
de levantar exceção — a rota devolve 200 numa negação.
A solução em 2 passos¶
1. Escreva o guard¶
Um guard é uma função comum: recebe o usuário, devolve o usuário (ou
None) e nega levantando uma 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
Guard nega levantando, nunca devolvendo False
return False não nega nada — o @requires ignora o valor e avisa com
GuardContractWarning, e o tempest permissions reporta
guard-returns-bool como erro. O motivo é a padronização de erros: quem
levanta uma AppException ganha status HTTP, code e envelope
{detail, code, details} de graça pelos handlers do SDK.
2. Decore a função¶
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)
Pronto. Os guards rodam da esquerda para a direita antes do corpo; o corpo só executa se todos passarem.
Ordem dos decorators
@requires vai abaixo do decorator de rota. O router precisa registrar
a função já protegida.
De onde vem o usuário¶
O @requires acha o parâmetro do usuário pela anotação: aquele cujo tipo é
uma subclasse de BaseModel / BaseUserModel. Você não configura nada no caso
comum.
Quando há mais de um usuário na assinatura, aponte qual:
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.
"""
...
Detalhes técnicos — resolução da anotação
A ordem é: user_param= explícito → o único parâmetro cuja anotação
resolve para um modelo de usuário → (só quando nenhuma anotação resolveu)
o único parâmetro cujo nome está em USER_PARAM_NAMES
(user, current_user, actor, requester, principal) ou cuja
anotação em texto menciona User. Esse último passo existe porque
from __future__ import annotations + import sob TYPE_CHECKING deixa a
anotação impossível de avaliar em tempo de decoração.
Se nada resolver — ou se dois candidatos empatarem — o import falha com
TempestPermissionError. Melhor a aplicação não subir do que subir com uma
checagem que não roda.
O retorno do guard estreita o tipo¶
Um guard que devolve o usuário substitui o usuário visto pelo próximo guard e
pelo corpo da função. É assim que os guards do SDK
(require_authenticated / require_active / require_admin) transformam
UserT | None em 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
Devolver None é permitido e significa "não mexi no usuário".
Metadata: um guard genérico, vários call sites¶
Um guard pode declarar um segundo parâmetro meta: dict[str, Any]. É isso que transforma um guard genérico numa checagem específica por rota — em vez de escrever manager_only, auditor_only, admin_only, você escreve has_role uma vez e cada call site diz qual papel exige.
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.
"""
...
Guards de um parâmetro continuam exatamente como antes — o segundo argumento só vai para quem o declara. Você pode misturar os dois numa mesma decoração.
include_args=True: o guard vê os argumentos da chamada¶
meta= carrega literais fixados na decoração. Quando a checagem depende do recurso da requisição, ligue include_args=True e os argumentos da chamada (path params, body, outras dependências) entram no mesmo dicionário:
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.
"""
...
O guard recebe {"order_id": UUID(...)} sem a rota precisar repassar nada.
Regras da mesclagem
- O usuário sai do dicionário — o guard já o recebe no primeiro parâmetro.
- Parâmetro que o chamador omitiu contribui com o default, então o guard vê os valores com que o corpo vai rodar. Default que é marcador de injeção (
Depends(...)) é descartado, nunca entregue como valor. - Chave declarada em
meta=ganha de um argumento com o mesmo nome: a decoração é a declaração explícita, o argumento é dado ambiente. Otempest permissionsavisa (meta-key-collision) quando isso acontece, porque o argumento nunca chega ao guard. - O dicionário é novo em cada chamada e compartilhado pelos guards daquela chamada — um guard pode gravar uma chave que o próximo lê, e nada vaza para a próxima requisição.
Erros de configuração¶
| Situação | O que acontece |
|---|---|
meta= não é um mapping |
TempestPermissionError no import |
meta= / include_args=True sem nenhum guard de 2 parâmetros |
TempestPermissionError no import — a configuração não faria nada |
guard de 2 parâmetros e decoração sem meta=/include_args= |
roda com {}; tempest permissions avisa guard-meta-missing |
| guard com 3+ parâmetros obrigatórios | TempestPermissionError no import (expected 1 (user) or 2 (user, meta)) |
Para auditar o que uma rota declarou:
from tempest_fastapi_sdk import guard_metadata
from src.services.billing import close_month
assert guard_metadata(close_month) == {"role": "manager"}
guard_metadata devolve só os literais de meta= — o que include_args=True mescla existe por chamada e não dá para ler do objeto função.
Funciona em qualquer camada¶
Nada aqui depende do FastAPI. O mesmo decorator vale para controller e service,
sync ou 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)
Guards async só podem decorar funções async — o contrário levantaria uma
corrotina nunca aguardada, então o import falha com TempestPermissionError.
O linter pega o erro por você¶
Duas camadas, porque cada uma vê o que a outra não vê.
Em tempo de import — TempestPermissionError¶
O decorator valida na hora em que o módulo é importado. A aplicação não sobe com:
| Situação | Mensagem |
|---|---|
@requires() sem guard |
needs at least one guard |
| guard não-callable | is not callable |
| guard com 2 parâmetros obrigatórios | takes 2 required params, expected 1 (user) |
guard async em função sync |
is async but ... is not |
| nenhum parâmetro de usuário | no parameter annotated with a user model |
| dois parâmetros de usuário | several parameters are user models |
meta=/include_args= sem consumidor |
no guard declares a second parameter |
Em tempo de chamada — GuardContractWarning¶
O que só aparece rodando: guard que levanta ValueError (a API responderia 500
sem code) e guard que devolve False (a negação seria ignorada). O
@requires avisa e deixa a exceção original propagar — ele não muda o
resultado de uma chamada que só está observando.
Trate como erro nos testes
Rode a suíte com -W error::tempest_fastapi_sdk.authz.GuardContractWarning
(ou filterwarnings = ["error"] no pyproject.toml) e um guard fora do
contrato quebra o teste em vez de virar linha de log.
Em CI — tempest permissions¶
O que nenhuma das duas alcança: guard cujo raise está atrás de um if que
nenhum teste exercita, ou guard nunca importado. O comando lê o contrato do
código-fonte com ast, sem importar a aplicação:
tempest permissions # relatório informativo (exit 0)
tempest permissions --check # exit 1 se houver erro (gate de CI)
tempest permissions --check --strict # falha também nos warnings
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).
Códigos reportados:
| Código | Severidade | O que é |
|---|---|---|
no-guards |
erro | @requires() sem guard: tudo passa |
user-param-missing |
erro | nenhum parâmetro é modelo de usuário |
user-param-ambiguous |
erro | vários candidatos, sem user_param= |
guard-arity |
erro | guard não recebe 1 parâmetro (user) nem 2 (user, meta) |
meta-unused |
erro | meta=/include_args= sem nenhum guard que receba |
guard-async-in-sync |
erro | guard async em função sync |
guard-returns-bool |
erro | guard-predicado: o False é ignorado |
guard-foreign-exception |
erro | levanta fora da hierarquia AppException |
guard-never-denies |
warning | nada no grafo de chamadas levanta |
guard-missing-annotation |
warning | parâmetro ou retorno sem anotação |
guard-return-type |
warning | retorno não é o usuário, None ou a união |
guard-meta-missing |
warning | guard pede metadata e a decoração não passa nenhuma |
guard-meta-annotation |
warning | 2º parâmetro anotado como algo que não recebe dict[str, Any] |
meta-key-collision |
warning | chave de meta= encobre um parâmetro sob include_args=True |
guard-unresolved |
warning | guard é lambda, está fora do escopo ou o nome bate com várias definições |
Reporta em vez de adivinhar
Um guard cujo nome existe em dois módulos vira guard-unresolved, não uma
checagem contra a definição errada. Mesma política do openapi-errors:
super-reportar é aceitável, dar um veredito confiante e errado não é.
Integração com os erros do OpenAPI¶
Um guard nega levantando, então a exceção dele é tão alcançável quanto a de
qualquer função que o corpo chama. O tempest openapi-errors lê os guards do
@requires ao montar o conjunto alcançável:
--fix escreve responses=error_responses(NotOrderOwnerException) na rota,
igual a qualquer outra exceção do fluxo. Detalhes na receita
Erros no OpenAPI ».
Auditar os guards de uma rota¶
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"
Útil num teste que garante que toda rota de escrita tem pelo menos um guard.
@requires vs. as outras ferramentas de autorização¶
| Ferramenta | Pergunta que responde | Onde vive |
|---|---|---|
make_permission_dependency |
"o token carrega orders:write?" |
dependência de rota, antes do handler |
has_perm / make_permission_checker |
"esse usuário pode nesse objeto?" | registry de regras (user, obj) -> bool |
@requires |
"esse usuário passa por essas invariantes?" | qualquer função que já recebe o usuário |
As três se compõem: um guard pode chamar check_permission(user, "order.delete",
obj=order) e ganhar o registry inteiro dentro do @requires.
Recap¶
- Guard =
(user) -> user | None— ou(user, meta) -> user | None— que nega levantandoAppException. @requires(g1, g2)roda os guards na ordem, abaixo do decorator de rota.- O parâmetro do usuário sai da anotação;
user_param=desempata. meta={...}parametriza um guard genérico;include_args=Trueentrega os argumentos da chamada ao guard.- Retorno não-
Nonesubstitui o usuário — é assim que o tipo estreita. - Funciona em router, controller e service, sync ou
async. - Erro de uso:
TempestPermissionErrorno import,GuardContractWarningna chamada,tempest permissions --checkna CI. - As exceções dos guards entram no
error_responses(...)viatempest openapi-errors.