← Back to the section

When roles are not enough, you need to check whose resource it actually is. Let's look at how this second layer of authorization is built and why, without it, any authenticated user can read someone else's data.

Why RBAC without ABAC is a security hole

Suppose you have an endpoint POST /orders/:id/cancel. You put a RolesGuard on it — access only for the customer role. Sounds safe.

But imagine: the client alice has a JWT with the customer role. She knows the id of user bob's order and sends a request to cancel it. The RolesGuard sees the customer role — all good, it lets her through. bob's order is cancelled by alice.

This is called IDOR (Insecure Direct Object Reference) — when an object's id lets you reach someone else's data knowing only the identifier.

RBAC (Role-Based Access Control) answers the question "can this role call this endpoint". ABAC (Attribute-Based Access Control) answers the question "can this specific user work with this specific resource". Two different layers, both mandatory.

How the ownership check works

The idea is simple: load the resource from the database, look at the customerId field (or ownerId, userId — depends on the model) and compare it with principal.sub — the user's identifier from the token.

const order = await this.orders.byId(orderId);
if (order.customerId !== principal.sub) {
  throw new ForbiddenError(orderId);
}

Important: we check after loading the aggregate, not before. By id alone, without reading from the database, you cannot know who owns the resource.

Two ways to implement ABAC

Way 1: a separate AccessPolicy

Convenient for simple cases — when you only need to compare the owner. Move the logic into a separate injectable class, and the Handler uses it.

// core/order/access/order-access.policy.ts
@Injectable()
export class OrderAccessPolicy {
  constructor(private readonly orders: OrderRepository) {}

  async canEdit(orderId: string, principal: Principal): Promise<boolean> {
    if (principal.roles.includes('admin')) return true;
    const order = await this.orders.byId(orderId);
    return order?.customerId === principal.sub;
  }

  async canView(orderId: string, principal: Principal): Promise<boolean> {
    return this.canEdit(orderId, principal);
  }
}
// core/order/handlers/cancel-order.handler.ts
@Injectable()
export class CancelOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly policy: OrderAccessPolicy,
  ) {}

  async execute(cmd: CancelOrder, principal: Principal): Promise<void> {
    const allowed = await this.policy.canEdit(cmd.orderId, principal);
    if (!allowed) throw new ForbiddenError(cmd.orderId);

    const order = await this.orders.byId(cmd.orderId);
    order.cancel();
    await this.orders.save(order);
  }
}

All checks for Order are gathered in OrderAccessPolicy. When the model changes, you change it in one place.

Way 2: check inside the Handler

Suitable when you need to combine the ownership check with a business rule on the same object. For example: load the order with a write lock (FOR UPDATE), and right there check the owner and the status.

@Injectable()
export class CancelOrderHandler {
  constructor(private readonly orders: OrderRepository) {}

  async execute(cmd: CancelOrder, principal: Principal): Promise<void> {
    const order = await this.orders.byIdForUpdate(cmd.orderId);

    if (
      !principal.roles.includes('admin') &&
      order.customerId !== principal.sub
    ) {
      throw new ForbiddenError(cmd.orderId);
    }

    if (!order.canCancel()) {
      throw new OrderCannotBeCancelledError(order.id, order.status);
    }

    order.cancel();
    await this.orders.save(order);
  }
}

The aggregate is loaded once — and both the access check and the business rule work on the same object without repeated database queries.

When to choose which way

SituationWay
Simple comparison ownerId === principal.subAccessPolicy
Need to load with a lock (FOR UPDATE)Handler check
Ownership check + aggregate state checkHandler check
Read endpoint, data does not changeAccessPolicy
Several aggregates (e.g. admin or the seller of a related product)Handler check

Pick one of the two ways — not both at once. Duplicated checks drift apart when the model changes.

Why the check must not go in the controller

A common temptation is to write the check right in the controller while the Handler isn't written yet:

// Bad: check in the controller
@Post(':id/cancel')
async cancel(@Param('id') id: string, @CurrentUser() principal: Principal) {
  const order = await this.orders.byId(id);
  if (order.customerId !== principal.sub) {
    throw new ForbiddenException();
  }
  return this.cancelOrder.execute({ orderId: id }, principal);
}

The problem: when a second endpoint appears for the same operation, or the model changes (for example, shared ownership is added), you will have to update each controller separately. Business logic leaks out of the domain layer into the HTTP layer. The controller should be thin: accept the request, pass it to the Handler, return the response.

Admin bypasses the check but leaves a trace

A support administrator can cancel any user's order — this is needed for their work. But every such action must be recorded in the audit log. Without it, you cannot investigate incidents or meet security requirements.

@Injectable()
export class CancelOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly auditLog: AuditLogService,
    private readonly clock: Clock,
  ) {}

  async execute(cmd: CancelOrder, principal: Principal): Promise<void> {
    const order = await this.orders.byIdForUpdate(cmd.orderId);
    const isAdmin = principal.roles.includes('admin');

    if (!isAdmin && order.customerId !== principal.sub) {
      throw new ForbiddenError(cmd.orderId);
    }

    const previousStatus = order.status;
    order.cancel();
    await this.orders.save(order);

    if (isAdmin) {
      await this.auditLog.record({
        actorId: principal.sub,
        action: 'cancel-order',
        aggregateType: 'Order',
        aggregateId: order.id,
        occurredAt: this.clock.now(),
        metadata: { previousStatus },
      });
    }
  }
}

A support admin can cancel customer-99's order, but orders_audit_log keeps a row: who, what, when, and what the status was before the change. This settles the questions when incidents are reviewed.

For more on the structure of the audit log — Audit of admin commands.

ForbiddenError and UnauthorizedException are different things

ForbiddenError (403) — the user is authenticated but has no access to this resource. UnauthorizedException (401) — the user is not authenticated at all (no token or the token is invalid). You must not confuse them: on 401 the client should get a new token, on 403 that won't help.

Use a typed domain ForbiddenError, not NestJS's ForbiddenException directly. The domain error is handled in an Exception Filter and turned into an HTTP response — that is the only place where the domain knows about HTTP codes.

In short

  • RBAC checks the role, ABAC checks the specific resource. Both layers are mandatory — RBAC without ABAC leaves an IDOR vulnerability.
  • The ownership check is done after loading the aggregate from the database, comparing the owner field with principal.sub.
  • Two ways: AccessPolicy for a simple comparison, a Handler check when you need loading with a lock or composite logic. Use one of the two.
  • All ABAC logic for one aggregate lives in one place (AccessPolicy or Handler). Not in the controller.
  • Admin bypasses ABAC, but every such action is recorded in the audit log.
  • ForbiddenError → 403, UnauthorizedException → 401. These are different situations.
  • RBAC: role mapping — the layer before ABAC: RolesGuard and the @Roles decorator.
  • Which check goes where — a breakdown: Guard, AccessPolicy, Handler, Domain Service.
  • Audit of admin commands — the audit log structure for admin actions.
  • JWT validation — JwtStrategy, jwks-rsa, UnauthorizedException → 401.