Deep DivesAPI

Authorization

How authorization works in the RawStack API.

The RawStack API uses authorization to decide what an authenticated user is allowed to do once their identity has been established. In practice, authorization combines broad role-based access with more targeted guard logic for resource-specific checks.

Authentication establishes who the user is. Authorization establishes what that user can do. This page focuses on the authorization side of that flow.

Overview

At a high level, authorization in the API happens in two layers:

  • role-based checks for broad access rules such as admin-only actions
  • custom guards for finer-grained decisions such as whether a user can act on a specific resource

This keeps the common cases simple while still allowing more detailed rules where needed.

The logged-in user

Once a request has been authenticated, the API creates a LoggedInUser value and makes it available throughout the request lifecycle. That object contains the minimum identity information needed for downstream authorization decisions, typically the user ID and roles.

export class LoggedInUser {
  constructor(
    public id: string,
    public roles: string[] = [],
  ) {}
}

This logged-in user can then be accessed in a few different places:

  • directly in controllers through a user decorator
  • from request.user inside guards
  • through a LoggedInUserProvider in application or domain-facing orchestration code

That shared actor context is what allows both guards and services to reason about who is performing an action.

Roles and broad access rules

Out of the box, RawStack includes two core roles:

  • VERIFIED_USER
  • ADMIN

These roles provide a simple starting point for broad authorization rules. For example, an ADMIN may be allowed to perform management actions across many resources, while a VERIFIED_USER may only have access to capabilities intended for normal signed-in users.

Role checks are useful when the rule is global and easy to express. They are less useful when access depends on the specific record being accessed. That is where custom guards become more helpful.

Custom guards

For more detailed authorization rules, the API uses custom guards. A guard can inspect the current request, look at the authenticated user, and decide whether the current action should be allowed.

This is especially useful for cases such as:

  • allowing admins to manage any user
  • allowing normal users to update only their own record
  • denying access when the request does not include enough context to make a safe decision

Here is a simplified example of that pattern:

type GuardUser = {
  id: string;
  roles: string[];
};

type UserUserGuardOptions = {
  action: string;
  userIdFrom?: (request: { params?: { userId?: string } }) => string | undefined;
};

export function canAccessUserAction(
  user: GuardUser | undefined,
  request: { params?: { userId?: string } },
  options: UserUserGuardOptions,
): boolean {
  const { action, userIdFrom = (request) => request.params?.userId } = options;
  const userId = userIdFrom(request);

  if (!user || !userId) {
    return false;
  }

  if (user.roles.indexOf('ADMIN') !== -1) {
    return true;
  }

  switch (action) {
    case 'UPDATE_USER':
    case 'DELETE_USER':
      return user.id === userId;
    default:
      return false;
  }
}

In this example, the guard logic grants access immediately if the user has the ADMIN role. Otherwise, it falls back to action-specific logic and only allows certain operations when the authenticated user is acting on their own user ID.

That gives you a useful blend of broad administrative access and fine-grained ownership checks.

Where guards fit in the request flow

Guards usually run at the HTTP boundary, before the main application logic executes. That makes them a good fit for request-level authorization decisions.

For example, a route may apply both authentication and authorization before it reaches the main use-case logic:

authenticated request
  -> JwtGuard confirms identity
  -> UserUserGuard confirms the user can perform UPDATE_USER on :userId
  -> controller/action executes
  -> command or query layer runs

In that flow:

  1. JwtGuard ensures the request is authenticated.
  2. UserUserGuard decides whether the authenticated user is allowed to perform the requested action.
  3. Only then does the controller hand off to the command or query layer.

This keeps controllers thin and prevents unauthorized requests from reaching the main use-case logic.

Guards are not the whole story

Even when a guard allows a request through, important authorization rules may still exist deeper in the application. This is especially true for business rules that belong in the domain layer rather than at the HTTP edge.

For example, a service may still enforce rules such as:

  • only admins can update roles
  • users cannot modify their own elevated roles
  • certain state transitions are forbidden even for otherwise authenticated users

That separation is useful. Guards answer, "should this request be allowed to proceed at all?" Domain services answer, "is this business operation valid?"

Why this approach works well

This authorization model stays practical without becoming overly complicated:

  • roles handle simple, broad access rules
  • custom guards handle request-specific checks
  • LoggedInUser gives the system a consistent actor model
  • domain services remain free to enforce deeper business rules

Taken together, this gives the API a clear and extensible authorization model that works well for both simple role checks and more nuanced ownership-based access control.