Skip to content

API Authorization Patterns: RBAC, ABAC and Policy-as-Code

Robust API authorization with RBAC, ABAC and policy-as-code — protecting resources at every layer while staying maintainable as the system grows.

4 min read
Layered authorization architecture showing request flowing through authentication, role checks, attribute policies, and resource-level permissions

Authentication answers "who are you?" Authorization answers "what can you do?" Most APIs nail authentication with JWT or OAuth but implement authorization as scattered if statements throughout the codebase. When a new role needs access to an existing endpoint, developers play whack-a-mole across dozens of files. Centralizing authorization logic into a coherent policy engine prevents this decay.

The Problem with Inline Authorization

Authorization logic tends to start simple and grow into an unmaintainable mess.

tstypescript
// ❌ Authorization scattered across handlers
async function deleteProject(req: Request, res: Response) {
  const user = req.user;
  const project = await db.projects.findById(req.params.id);
 
  // First check: is this their project?
  if (project.ownerId !== user.id) {
    // Wait, admins can delete anything
    if (user.role !== "admin") {
      // Actually, org admins can delete within their org
      if (
        user.role !== "org_admin" ||
        project.orgId !== user.orgId
      ) {
        // Team leads can delete in their team's projects
        if (
          user.role !== "team_lead" ||
          !user.teamIds.includes(project.teamId)
        ) {
          return res.status(403).json({ error: "Forbidden" });
        }
      }
    }
  }
 
  // Duplicate logic exists in updateProject, archiveProject,
  // transferProject, and every new endpoint touching projects
  await db.projects.delete(project.id);
  return res.json({ success: true });
}
tstypescript
// ✅ Centralized authorization with clear policy
async function deleteProject(req: Request, res: Response) {
  const project = await db.projects.findById(req.params.id);
 
  const allowed = await authorize({
    subject: req.user,
    action: "delete",
    resource: project,
    resourceType: "project",
  });
 
  if (!allowed) {
    return res.status(403).json({ error: "Forbidden" });
  }
 
  await db.projects.delete(project.id);
  return res.json({ success: true });
}

The centralized version is readable, testable, and maintainable. The policy definition lives in one place, and every handler delegates to it.

Role-Based Access Control (RBAC)

RBAC assigns permissions to roles, then assigns roles to users. It works well for systems with clearly defined user categories but struggles with contextual permissions.

tstypescript
interface Role {
  name: string;
  permissions: Permission[];
}
 
interface Permission {
  resource: string;
  actions: string[];
}
 
const roles: Role[] = [
  {
    name: "viewer",
    permissions: [
      { resource: "project", actions: ["read", "list"] },
      { resource: "document", actions: ["read", "list"] },
    ],
  },
  {
    name: "editor",
    permissions: [
      { resource: "project", actions: ["read", "list", "update"] },
      { resource: "document", actions: ["read", "list", "create", "update"] },
    ],
  },
  {
    name: "admin",
    permissions: [
      { resource: "project", actions: ["read", "list", "create", "update", "delete"] },
      { resource: "document", actions: ["read", "list", "create", "update", "delete"] },
      { resource: "user", actions: ["read", "list", "create", "update", "delete"] },
    ],
  },
];
 
class RBACEngine {
  private roleMap: Map<string, Role>;
 
  constructor(roles: Role[]) {
    this.roleMap = new Map(roles.map(r => [r.name, r]));
  }
 
  check(
    userRoles: string[],
    resource: string,
    action: string
  ): boolean {
    for (const roleName of userRoles) {
      const role = this.roleMap.get(roleName);
      if (!role) continue;
 
      const permission = role.permissions.find(
        p => p.resource === resource
      );
      if (permission && permission.actions.includes(action)) {
        return true;
      }
    }
    return false;
  }
}
 
const rbac = new RBACEngine(roles);
rbac.check(["editor"], "document", "create");  // true
rbac.check(["viewer"], "document", "delete");   // false

RBAC is straightforward but hits its ceiling when permissions depend on resource ownership, team membership, or other contextual attributes.

Attribute-Based Access Control (ABAC)

ABAC evaluates policies against attributes of the subject, resource, action, and environment. This handles contextual permissions that RBAC cannot express.

tstypescript
interface PolicyRule {
  id: string;
  description: string;
  effect: "allow" | "deny";
  condition: (ctx: AuthorizationContext) => boolean;
}
 
interface AuthorizationContext {
  subject: {
    id: string;
    roles: string[];
    orgId: string;
    teamIds: string[];
    department: string;
  };
  action: string;
  resource: {
    type: string;
    ownerId: string;
    orgId: string;
    teamId: string;
    sensitivity: "public" | "internal" | "confidential";
    status: string;
  };
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
const policies: PolicyRule[] = [
  {
    id: "owner-full-access",
    description: "Resource owners can do anything with their resources",
    effect: "allow",
    condition: (ctx) =>
      ctx.subject.id === ctx.resource.ownerId,
  },
  {
    id: "team-member-read-write",
    description: "Team members can read and update team resources",
    effect: "allow",
    condition: (ctx) =>
      ctx.subject.teamIds.includes(ctx.resource.teamId) &&
      ["read", "update", "list"].includes(ctx.action),
  },
  {
    id: "confidential-requires-mfa",
    description: "Confidential resources require MFA verification",
    effect: "deny",
    condition: (ctx) =>
      ctx.resource.sensitivity === "confidential" &&
      !ctx.environment.mfaVerified,
  },
  {
    id: "no-delete-archived",
    description: "Cannot delete archived resources",
    effect: "deny",
    condition: (ctx) =>
      ctx.action === "delete" &&
      ctx.resource.status === "archived",
  },
];
 
function evaluatePolicies(
  policies: PolicyRule[],
  ctx: AuthorizationContext
): boolean {
  // Deny rules take priority
  for (const policy of policies) {
    if (policy.effect === "deny" && policy.condition(ctx)) {
      return false;
    }
  }
 
  // Check for at least one allow
  for (const policy of policies) {
    if (policy.effect === "allow" && policy.condition(ctx)) {
      return true;
    }
  }
 
  // Default deny
  return false;
}

The deny-overrides evaluation strategy means protective rules always win, regardless of ordering. This prevents accidental access grants when new allow policies are added.

Middleware-Based Enforcement

Authorization should be enforced at the middleware layer so individual handlers can't accidentally skip permission checks.

tstypescript
interface ResourceResolver {
  resolve: (req: Request) => Promise<AuthorizableResource>;
}
 
interface AuthorizableResource {
  type: string;
  ownerId: string;
  orgId: string;
  teamId: string;
  sensitivity: string;
  status: string;
}
 
function requirePermission(
  action: string,
  resourceResolver: ResourceResolver
) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const resource = await resourceResolver.resolve(req);
 
    const ctx: AuthorizationContext = {
      subject: {
        id: req.user.id,
        roles: req.user.roles,
        orgId: req.user.orgId,
        teamIds: req.user.teamIds,
        department: req.user.department,
      },
      action,
      resource: {
        type: resource.type,
        ownerId: resource.ownerId,
        orgId: resource.orgId,
        teamId: resource.teamId,
        sensitivity: resource.sensitivity,
        status: resource.status,
      },
      environment: {
        ipAddress: req.ip ?? "unknown",
        time: new Date(),
        mfaVerified: req.user.mfaVerified ?? false,
      },
    };
 
    const allowed = evaluatePolicies(policies, ctx);
 
    if (!allowed) {
      return res.status(403).json({
        error: "Insufficient permissions",
        action,
        resource: resource.type,
      });
    }
 
    next();
  };
}
 
// Usage in routes
const projectResolver: ResourceResolver = {
  resolve: async (req) => {
    const project = await db.projects.findById(req.params.id);
    return {
      type: "project",
      ownerId: project.ownerId,
      orgId: project.orgId,
      teamId: project.teamId,
      sensitivity: project.sensitivity,
      status: project.status,
    };
  },
};
 
router.delete(
  "/projects/:id",
  requirePermission("delete", projectResolver),
  deleteProjectHandler
);

Testing Authorization Policies

Authorization logic is critical enough to deserve comprehensive tests. Policy tests should cover every role-resource-action combination and edge cases.

tstypescript
describe("Authorization Policies", () => {
  const baseCtx: AuthorizationContext = {
    subject: {
      id: "user-1",
      roles: ["editor"],
      orgId: "org-1",
      teamIds: ["team-1"],
      department: "engineering",
    },
    action: "read",
    resource: {
      type: "project",
      ownerId: "user-2",
      orgId: "org-1",
      teamId: "team-1",
      sensitivity: "internal",
      status: "active",
    },
    environment: {
      ipAddress: "10.0.0.1",
      time: new Date("2023-04-26T10:00:00Z"),
      mfaVerified: true,
    },
  };
 
  test("team members can read team resources", () => {
    expect(evaluatePolicies(policies, baseCtx)).toBe(true);
  });
 
  test("confidential resources denied without MFA", () => {
    const ctx = {
      ...baseCtx,
      resource: { ...baseCtx.resource, sensitivity: "confidential" },
      environment: { ...baseCtx.environment, mfaVerified: false },
    };
    expect(evaluatePolicies(policies, ctx)).toBe(false);
  });
 
  test("cannot delete archived resources even as owner", () => {
    const ctx = {
      ...baseCtx,
      action: "delete",
      subject: { ...baseCtx.subject, id: "user-2" }, // is owner
      resource: { ...baseCtx.resource, ownerId: "user-2", status: "archived" },
    };
    expect(evaluatePolicies(policies, ctx)).toBe(false);
  });
});

Key Takeaways

Authorization is too important to scatter across handler functions and too complex to model with simple role checks alone. Start with RBAC for clear-cut role boundaries, then layer ABAC policies for contextual rules like ownership, team membership, sensitivity levels, and MFA requirements. Use deny-overrides evaluation so protective rules always take precedence. Enforce authorization at the middleware layer so handlers cannot accidentally skip checks. Test policies as thoroughly as you test business logic—every role-action-resource combination should have a corresponding test case. The goal is a system where adding a new permission rule means editing one policy file, not hunting through dozens of route handlers.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX