Once your API grows endpoints like GET /orders/{id}, a question arises: can any authenticated user read someone else's orders? A role check (RBAC) is not enough for this — you need a second layer.
The problem: RBAC does not protect against horizontal enumeration
Imagine you have a customer role that allows calling GET /orders/{id}. User customer-42 knows their own order order-100 and simply changes the number in the URL to order-101 — which is customer-99's order. RBAC lets the request through, because the role is present. The resource turns out to belong to someone else — but no one checked.
Such an attack is called IDOR (Insecure Direct Object Reference) — direct access to another user's object by its identifier. It is one of the most common API vulnerabilities.
ABAC (Attribute-Based Access Control) solves exactly this problem. RBAC says "the customer role may call this endpoint"; ABAC adds "but only for its own resources". Together they provide complete protection.
What a Principal is
A user's JWT always contains a sub field — the unique user identifier. When FastAPI extracts the token from a request, it is convenient to wrap this data in a dataclass:
# adapters/in/http/security.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Principal:
sub: str # UUID or numeric user id from the JWT
roles: list[str]
def is_admin(self) -> bool:
return "admin" in self.roles
principal.sub is a string. When comparing it with order.customer_id, the types must match: if the database stores a UUID, cast it explicitly, otherwise the comparison will not work.
Option 1: AccessPolicy for simple checks
If you just need a "this resource belongs to the current user" check, a dedicated AccessPolicy class works well. It is an ordinary service injected via Depends into the router.
# application/access_policy.py
from fastapi import Depends, HTTPException
from adapters.in.http.security import Principal, principal as get_principal
from domain.order.order_repository import OrderRepository
class OrderAccessPolicy:
def __init__(
self,
repo: OrderRepository = Depends(),
p: Principal = Depends(get_principal),
):
self._repo = repo
self._principal = p
def _load(self, order_id: str):
order = self._repo.find_by_id(order_id)
if order is None:
raise HTTPException(status_code=404, detail="not found")
return order
def can_view(self, order_id: str):
order = self._load(order_id)
if not self._principal.is_admin() and order.customer_id != self._principal.sub:
raise HTTPException(status_code=403, detail="forbidden")
return order
def can_edit(self, order_id: str):
return self.can_view(order_id)
The router calls the policy and receives an already-verified object:
# adapters/in/http/order_router.py
from fastapi import APIRouter, Depends
from application.access_policy import OrderAccessPolicy
from adapters.in.http.security import require_roles
router = APIRouter(prefix="/orders")
@router.get("/{order_id}")
async def get_order(
order_id: str,
policy: OrderAccessPolicy = Depends(),
_: None = Depends(require_roles("customer", "admin")),
):
order = policy.can_view(order_id)
return OrderResponse.from_domain(order)
AccessPolicy is a single point of ownership logic for all of the aggregate's endpoints. If the business rule changes tomorrow (for example, a co-owner is added), you change one place, not every router.
Option 2: the check inside the Handler
For state-changing commands (for example, cancelling an order), it is more convenient to place ABAC directly inside the Handler. The Handler owns the transaction, loads the aggregate with a lock, and checks business rules — ABAC fits naturally into the same flow:
# application/cancel_order_handler.py
from dataclasses import dataclass
from adapters.in.http.security import Principal
from domain.order.order_repository import OrderRepository
from domain.order.errors import OrderNotFound, OrderCannotBeCancelled
from application.errors import ForbiddenError
from application.audit_log import AuditLog, AdminAction
from datetime import datetime, timezone
@dataclass(frozen=True)
class CancelOrderCommand:
order_id: str
principal: Principal
class CancelOrderHandler:
def __init__(self, repo: OrderRepository, audit_log: AuditLog):
self._repo = repo
self._audit_log = audit_log
def handle(self, cmd: CancelOrderCommand) -> Order:
order = self._repo.find_by_id_for_update(cmd.order_id)
if order is None:
raise OrderNotFound(cmd.order_id)
if not cmd.principal.is_admin() and order.customer_id != cmd.principal.sub:
raise ForbiddenError("order does not belong to current user")
if not order.can_cancel():
raise OrderCannotBeCancelled(order.id, order.status)
order.cancel()
saved = self._repo.save(order)
if cmd.principal.is_admin():
self._audit_log.record(AdminAction(
actor_id=cmd.principal.sub,
action="cancel-order",
resource_type="Order",
resource_id=order.id,
occurred_at=datetime.now(timezone.utc),
metadata={"previous_status": order.previous_status},
))
return saved
The order is: load with a lock → ABAC → business rule → save → audit. The Handler stays thin — the router passes it a command and checks nothing itself.
When to choose which option
| Situation | Option |
|---|---|
Simple comparison: order.customer_id == principal.sub | AccessPolicy |
| Read endpoints without state changes | AccessPolicy |
Write commands with SELECT FOR UPDATE | Handler check |
| Ownership + status check + business rule together | Handler check |
| One aggregate, several possible owners | Handler check |
Important: choose one of the two options per aggregate, not both at once. A duplicate diverges when business rules change.
A common mistake: ABAC in every router
The anti-pattern is writing the ownership check directly in every router function:
# BAD — the check is duplicated in every endpoint
@router.get("/{order_id}")
async def get_order(order_id: str, p: Principal = Depends(get_principal)):
order = repo.find_by_id(order_id)
if order.customer_id != p.sub:
raise HTTPException(status_code=403)
return order
@router.post("/{order_id}/cancel")
async def cancel_order(order_id: str, p: Principal = Depends(get_principal)):
order = repo.find_by_id(order_id)
if order.customer_id != p.sub: # the same thing again
raise HTTPException(status_code=403)
...
When the rules change (add a co-owner, check the resource status before the ownership check), you will have to go through every endpoint. Miss one and the vulnerability is back.
Admin bypasses ABAC but leaves a trace
An administrator often must be able to see any resource — for support, verification, investigation. That is fine, but every such action must be recorded in the log:
if query.principal.is_admin():
self._audit_log.record(AdminAction(
actor_id=query.principal.sub,
action="view-product",
resource_type="Product",
resource_id=product.id,
occurred_at=datetime.now(timezone.utc),
metadata={"seller_id": product.seller_id},
))
The log will contain an entry: actor_id=admin-7, action=view-product, resource_id=prod-555. If someone abuses admin access, it shows up during an audit.
The is_admin() check must be in all branches — not only for audit, but also to skip the ownership check. The usual pattern is: if not principal.is_admin() and resource.owner != principal.sub: raise ForbiddenError(...).
In short
- RBAC says "the role permits the call"; ABAC adds "only for its own resources" — both layers are needed together.
- Without ABAC, an RBAC endpoint is open to IDOR: any user with the right role enumerates other users' objects by id.
- Two ways to place ABAC: AccessPolicy (for reads and simple ownership) and a Handler check (for writes with a transaction and business rules).
- Ownership logic lives in one place; it is not duplicated across routers.
principal.subis a string from the JWT; watch the types when comparing it with an id from the database.- Admin bypasses ABAC, but each of its actions is recorded in the audit log.
Further reading
- RBAC: role mapping — the layer before ABAC.
- Audit of admin commands — the mandatory companion to admin override.
- JWT validation — how to obtain a
Principalfrom a token.