← Back to the section

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

SituationOption
Simple comparison: order.customer_id == principal.subAccessPolicy
Read endpoints without state changesAccessPolicy
Write commands with SELECT FOR UPDATEHandler check
Ownership + status check + business rule togetherHandler check
One aggregate, several possible ownersHandler 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.sub is 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 Principal from a token.