feat(01-04): API authorization middleware and RBAC tests (66 passing)

- Create src/lib/middleware/authorize.ts with withPermission() HOF
- Returns 401 for unauthenticated, 403 for unauthorized access
- Passes ability + user to authorized handlers for fine-grained checks
- Add authorize() convenience alias for handler-first usage pattern
- Create src/lib/__tests__/rbac.test.ts with 66 unit tests covering:
  - Admin full access to all subjects
  - Office Staff: can manage billing, blocked from Chart of Accounts
  - Collector: can record payments, blocked from invoice management
  - Technician: blocked from billing/payments (critical security boundary)
  - Client: scoped to own data only
  - Multi-role additive union (TECHNICIAN+COLLECTOR gets both sets)
  - Super-admin bypasses all permission checks
- Fix CASL MongoAbility type: use createMongoAbility throughout
- Fix condition casting for string-based subjects (no Prisma models yet)
- Fix ability merging: cannot() rules excluded for multi-role union
This commit is contained in:
kevin-asprec
2026-03-04 18:57:31 +08:00
parent 67bb6cc95c
commit 1df2b2d87d
5 changed files with 623 additions and 86 deletions

View File

@@ -0,0 +1,432 @@
/**
* RBAC Unit Tests — Role-Based Access Control validation for all 5 roles.
*
* Tests the CASL permission system directly via defineAbilityFor().
* No HTTP requests, no database — pure unit tests of the permission matrix.
*
* Validates:
* - Each role's allowed permissions
* - Each role's denied permissions (boundaries)
* - Multi-role union (additive permissions)
* - Super-admin unrestricted access
*
* Key invariant: Technician cannot access billing or subscriber management.
* This is the most critical security boundary for the ISP use case.
*/
import { defineAbilityFor } from "@/lib/casl/ability";
import { Role } from "@prisma/client";
// Helper to build a test user fixture
function makeUser(overrides: {
id?: string;
roles: Role[];
tenantId?: string | null;
isSuperAdmin?: boolean;
}) {
return {
id: overrides.id ?? "user-test-001",
roles: overrides.roles,
tenantId: overrides.tenantId !== undefined ? overrides.tenantId : "tenant-001",
isSuperAdmin: overrides.isSuperAdmin ?? false,
};
}
// =============================================================================
// ADMIN TESTS
// =============================================================================
describe("ADMIN role", () => {
const adminUser = makeUser({ roles: [Role.ADMIN] });
let ability: ReturnType<typeof defineAbilityFor>;
beforeEach(() => {
ability = defineAbilityFor(adminUser);
});
it("can manage Subscribers", () => {
expect(ability.can("manage", "Subscriber")).toBe(true);
});
it("can manage Users", () => {
expect(ability.can("manage", "User")).toBe(true);
});
it("can manage Reports", () => {
expect(ability.can("manage", "Report")).toBe(true);
});
it("can manage Invoices", () => {
expect(ability.can("manage", "Invoice")).toBe(true);
});
it("can manage Payments", () => {
expect(ability.can("manage", "Payment")).toBe(true);
});
it("can manage JobOrders", () => {
expect(ability.can("manage", "JobOrder")).toBe(true);
});
it("can manage Accounts (Chart of Accounts)", () => {
expect(ability.can("manage", "Account")).toBe(true);
});
it("can create Accounts", () => {
expect(ability.can("create", "Account")).toBe(true);
});
it("can read all subjects", () => {
expect(ability.can("read", "Invoice")).toBe(true);
expect(ability.can("read", "Subscriber")).toBe(true);
expect(ability.can("read", "Ticket")).toBe(true);
});
});
// =============================================================================
// OFFICE_STAFF TESTS
// =============================================================================
describe("OFFICE_STAFF role", () => {
const officeStaffUser = makeUser({ roles: [Role.OFFICE_STAFF] });
let ability: ReturnType<typeof defineAbilityFor>;
beforeEach(() => {
ability = defineAbilityFor(officeStaffUser);
});
it("can manage Subscribers", () => {
expect(ability.can("manage", "Subscriber")).toBe(true);
});
it("can manage Users", () => {
expect(ability.can("manage", "User")).toBe(true);
});
it("can manage Invoices", () => {
expect(ability.can("manage", "Invoice")).toBe(true);
});
it("can manage Payments", () => {
expect(ability.can("manage", "Payment")).toBe(true);
});
it("can manage Tickets", () => {
expect(ability.can("manage", "Ticket")).toBe(true);
});
it("can manage JobOrders", () => {
expect(ability.can("manage", "JobOrder")).toBe(true);
});
it("can read Reports", () => {
expect(ability.can("read", "Report")).toBe(true);
});
it("can read Accounts", () => {
expect(ability.can("read", "Account")).toBe(true);
});
// Critical: Office Staff cannot modify Chart of Accounts
it("cannot create Account (Chart of Accounts)", () => {
expect(ability.can("create", "Account")).toBe(false);
});
it("cannot update Account (Chart of Accounts)", () => {
expect(ability.can("update", "Account")).toBe(false);
});
it("cannot delete Account (Chart of Accounts)", () => {
expect(ability.can("delete", "Account")).toBe(false);
});
});
// =============================================================================
// COLLECTOR TESTS
// =============================================================================
describe("COLLECTOR role", () => {
const collectorUser = makeUser({ roles: [Role.COLLECTOR] });
let ability: ReturnType<typeof defineAbilityFor>;
beforeEach(() => {
ability = defineAbilityFor(collectorUser);
});
it("can read Subscribers (for context during collection)", () => {
expect(ability.can("read", "Subscriber")).toBe(true);
});
it("can create Payments (record collections)", () => {
expect(ability.can("create", "Payment")).toBe(true);
});
it("can read Payments (view payment history)", () => {
expect(ability.can("read", "Payment")).toBe(true);
});
// Critical boundaries — Collector is not a billing manager
it("cannot manage Invoices", () => {
expect(ability.can("manage", "Invoice")).toBe(false);
});
it("cannot create Invoices", () => {
expect(ability.can("create", "Invoice")).toBe(false);
});
it("cannot manage Users", () => {
expect(ability.can("manage", "User")).toBe(false);
});
it("cannot read Reports", () => {
expect(ability.can("read", "Report")).toBe(false);
});
it("cannot manage Reports", () => {
expect(ability.can("manage", "Report")).toBe(false);
});
});
// =============================================================================
// TECHNICIAN TESTS
// =============================================================================
describe("TECHNICIAN role", () => {
const userId = "technician-user-001";
const technicianUser = makeUser({ id: userId, roles: [Role.TECHNICIAN] });
let ability: ReturnType<typeof defineAbilityFor>;
beforeEach(() => {
ability = defineAbilityFor(technicianUser);
});
// Critical: Technician MUST NOT access billing or subscriber management
it("cannot manage Invoices (critical billing boundary)", () => {
expect(ability.can("manage", "Invoice")).toBe(false);
});
it("cannot create Invoices", () => {
expect(ability.can("create", "Invoice")).toBe(false);
});
it("cannot read Invoices", () => {
expect(ability.can("read", "Invoice")).toBe(false);
});
it("cannot manage Payments", () => {
expect(ability.can("manage", "Payment")).toBe(false);
});
it("cannot create Payments", () => {
expect(ability.can("create", "Payment")).toBe(false);
});
it("cannot manage Subscribers (subscriber management boundary)", () => {
expect(ability.can("manage", "Subscriber")).toBe(false);
});
it("cannot create Subscribers", () => {
expect(ability.can("create", "Subscriber")).toBe(false);
});
it("cannot delete Subscribers", () => {
expect(ability.can("delete", "Subscriber")).toBe(false);
});
it("cannot read Reports", () => {
expect(ability.can("read", "Report")).toBe(false);
});
it("cannot manage Reports", () => {
expect(ability.can("manage", "Report")).toBe(false);
});
it("cannot manage Users", () => {
expect(ability.can("manage", "User")).toBe(false);
});
// Technician CAN read subscribers (contact info for job)
it("can read Subscribers (contact info for assigned jobs)", () => {
expect(ability.can("read", "Subscriber")).toBe(true);
});
});
// =============================================================================
// CLIENT TESTS
// =============================================================================
describe("CLIENT role", () => {
const userId = "client-subscriber-001";
const clientUser = makeUser({ id: userId, roles: [Role.CLIENT] });
let ability: ReturnType<typeof defineAbilityFor>;
beforeEach(() => {
ability = defineAbilityFor(clientUser);
});
it("can create Tickets (submit support requests)", () => {
expect(ability.can("create", "Ticket")).toBe(true);
});
// Clients can read their own data (tested without conditions for basic capability check)
it("can read their own Invoices", () => {
// Without conditions object — checks if action+subject is permitted at all
expect(ability.can("read", "Invoice")).toBe(true);
});
it("can read their own Payments", () => {
expect(ability.can("read", "Payment")).toBe(true);
});
it("can read their own Subscriber profile", () => {
expect(ability.can("read", "Subscriber")).toBe(true);
});
it("can read their own Tickets", () => {
expect(ability.can("read", "Ticket")).toBe(true);
});
// Critical: Clients cannot access management features
it("cannot manage Users", () => {
expect(ability.can("manage", "User")).toBe(false);
});
it("cannot create Users", () => {
expect(ability.can("create", "User")).toBe(false);
});
it("cannot manage Subscribers (not even subscriber management)", () => {
expect(ability.can("manage", "Subscriber")).toBe(false);
});
it("cannot create Subscribers", () => {
expect(ability.can("create", "Subscriber")).toBe(false);
});
it("cannot update Subscribers", () => {
expect(ability.can("update", "Subscriber")).toBe(false);
});
it("cannot delete Subscribers", () => {
expect(ability.can("delete", "Subscriber")).toBe(false);
});
it("cannot read Reports", () => {
expect(ability.can("read", "Report")).toBe(false);
});
it("cannot manage Reports", () => {
expect(ability.can("manage", "Report")).toBe(false);
});
it("cannot manage Invoices (only read own)", () => {
expect(ability.can("manage", "Invoice")).toBe(false);
});
it("cannot create Invoices", () => {
expect(ability.can("create", "Invoice")).toBe(false);
});
});
// =============================================================================
// MULTI-ROLE TESTS (Union of permissions)
// =============================================================================
describe("Multi-role users (additive union)", () => {
const userId = "multi-role-user-001";
it("TECHNICIAN + COLLECTOR: can read Subscribers (from COLLECTOR) AND update JobOrders (from TECHNICIAN)", () => {
const multiRoleUser = makeUser({
id: userId,
roles: [Role.TECHNICIAN, Role.COLLECTOR],
});
const ability = defineAbilityFor(multiRoleUser);
// From COLLECTOR role
expect(ability.can("read", "Subscriber")).toBe(true);
expect(ability.can("create", "Payment")).toBe(true);
// From TECHNICIAN role
expect(ability.can("read", "JobOrder")).toBe(true);
// Neither role has these — still blocked
expect(ability.can("manage", "Invoice")).toBe(false);
expect(ability.can("manage", "User")).toBe(false);
expect(ability.can("read", "Report")).toBe(false);
});
it("COLLECTOR + OFFICE_STAFF: gets full Office Staff permissions plus Collector permissions", () => {
const multiRoleUser = makeUser({
id: userId,
roles: [Role.COLLECTOR, Role.OFFICE_STAFF],
});
const ability = defineAbilityFor(multiRoleUser);
// From OFFICE_STAFF
expect(ability.can("manage", "Subscriber")).toBe(true);
expect(ability.can("manage", "Invoice")).toBe(true);
expect(ability.can("read", "Report")).toBe(true);
// From COLLECTOR (additive)
expect(ability.can("create", "Payment")).toBe(true);
expect(ability.can("read", "Payment")).toBe(true);
});
it("ADMIN + TECHNICIAN: Admin permissions dominate (manage all)", () => {
const multiRoleUser = makeUser({
id: userId,
roles: [Role.ADMIN, Role.TECHNICIAN],
});
const ability = defineAbilityFor(multiRoleUser);
// Admin gives full access
expect(ability.can("manage", "Invoice")).toBe(true);
expect(ability.can("manage", "Subscriber")).toBe(true);
expect(ability.can("manage", "Report")).toBe(true);
});
});
// =============================================================================
// SUPER-ADMIN TESTS
// =============================================================================
describe("Super-admin (isSuperAdmin=true)", () => {
const superAdminUser = makeUser({
roles: [],
tenantId: null,
isSuperAdmin: true,
});
let ability: ReturnType<typeof defineAbilityFor>;
beforeEach(() => {
ability = defineAbilityFor(superAdminUser);
});
it("can manage all subjects", () => {
expect(ability.can("manage", "all")).toBe(true);
});
it("can manage Subscribers", () => {
expect(ability.can("manage", "Subscriber")).toBe(true);
});
it("can manage Invoices", () => {
expect(ability.can("manage", "Invoice")).toBe(true);
});
it("can manage Users", () => {
expect(ability.can("manage", "User")).toBe(true);
});
it("can manage Reports", () => {
expect(ability.can("manage", "Report")).toBe(true);
});
it("can manage Accounts", () => {
expect(ability.can("manage", "Account")).toBe(true);
});
it("can read anything", () => {
expect(ability.can("read", "Tenant")).toBe(true);
expect(ability.can("read", "Expense")).toBe(true);
});
it("bypasses all permission checks (no roles needed)", () => {
// Super-admin has no roles but still gets full access
expect(superAdminUser.roles).toHaveLength(0);
expect(ability.can("create", "Invoice")).toBe(true);
expect(ability.can("delete", "Subscriber")).toBe(true);
});
});

View File

@@ -1,4 +1,4 @@
import { AbilityBuilder, PureAbility, createMongoAbility } from "@casl/ability";
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
import { Role } from "@prisma/client";
import { definePermissionsFor } from "./permissions";
import type { AppAbility, AppActions, AppSubjects } from "./types";
@@ -15,7 +15,7 @@ export interface AbilityUser {
}
/**
* Builds a CASL Ability instance from a session user.
* Builds a CASL MongoAbility instance from a session user.
*
* - Super-admins: unrestricted access to all subjects
* - Regular users: union of all permissions across their roles
@@ -23,89 +23,67 @@ export interface AbilityUser {
* Multi-role users get the additive union: if TECHNICIAN can read JobOrders
* and COLLECTOR can create Payments, a user with both roles can do both.
*
* IMPORTANT: cannot() rules from individual roles are NOT carried into merged
* multi-role abilities. With multiple roles, permissions are purely additive —
* if ANY role grants a capability, the user has that capability.
*
* Usage:
* const ability = defineAbilityFor(session.user);
* ability.can("read", "Subscriber") // → boolean
*/
export function defineAbilityFor(user: AbilityUser): AppAbility {
const { can, build } = new AbilityBuilder<AppAbility>(PureAbility);
if (user.isSuperAdmin) {
// Super-admins bypass all permission checks
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
can("manage", "all");
return build();
}
// Merge permissions for all roles (union — additive)
// Each role's ability is built separately, then we extract its rules
for (const role of user.roles) {
const roleAbility = definePermissionsFor(
role,
user.id,
user.tenantId ?? ""
);
// Transfer all rules from the role-specific ability into the merged builder
for (const rule of roleAbility.rules) {
if (rule.inverted) {
// cannot() rules — only apply if no positive rule overrides them
// (CASL already handles priority, but we copy them faithfully)
}
// We rebuild using createMongoAbility to preserve condition matching
}
if (user.roles.length === 0) {
// No roles — no permissions
return createMongoAbility<[AppActions, AppSubjects]>([]);
}
// Build a merged ability by combining all role abilities
if (user.roles.length === 1) {
// Single role — return role ability directly (including cannot() rules)
return definePermissionsFor(user.roles[0], user.id, user.tenantId ?? "");
}
// Multi-role: merge abilities additively (union of can() rules only).
// cannot() rules from individual roles are intentionally excluded to prevent
// a less-privileged role from blocking permissions granted by another role.
return mergeAbilities(user.roles, user.id, user.tenantId ?? "");
}
/**
* Merges abilities from multiple roles into a single AppAbility.
* Merges abilities from multiple roles into a single AppAbility (additive union).
*
* For multi-role users, positive (can) rules from ALL roles are combined.
* cannot() rules from one role are not applied when another role grants the same permission.
* This implements the principle of additive permissions (union).
* For multi-role users, only positive (can) rules from ALL roles are combined.
* cannot() rules from one role are NOT applied when another role grants the same permission.
* This implements additive permissions: more roles = more (or equal) access, never less.
*/
function mergeAbilities(
roles: Role[],
userId: string,
tenantId: string
): AppAbility {
if (roles.length === 0) {
// No roles — no permissions
return new PureAbility<[AppActions, AppSubjects]>([]);
}
if (roles.length === 1) {
// Single role — return directly without merging overhead
return definePermissionsFor(roles[0], userId, tenantId);
}
// For multi-role users, collect all rules from all roles.
// CASL evaluates can() as true if ANY rule grants the permission,
// so additive union works naturally by merging all rules.
const allRules: Array<{
action: AppActions | AppActions[];
subject: AppSubjects | AppSubjects[];
inverted?: boolean;
conditions?: Record<string, unknown>;
}> = [];
// Collect all positive rules from all roles
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allRules: any[] = [];
for (const role of roles) {
const roleAbility = definePermissionsFor(role, userId, tenantId);
for (const rule of roleAbility.rules) {
// Only include positive (can) rules when merging multiple roles.
// cannot() rules from a less-privileged role should not block
// permissions granted by a more-privileged role.
if (!rule.inverted) {
// Only include positive (can) rules
allRules.push({
action: rule.action as AppActions | AppActions[],
subject: rule.subject as AppSubjects | AppSubjects[],
conditions: rule.conditions as Record<string, unknown> | undefined,
action: rule.action,
subject: rule.subject,
conditions: rule.conditions,
});
}
}
}
return new PureAbility<[AppActions, AppSubjects]>(allRules);
return createMongoAbility<[AppActions, AppSubjects]>(allRules);
}

View File

@@ -1,22 +1,39 @@
import { AbilityBuilder, PureAbility } from "@casl/ability";
import { AbilityBuilder, createMongoAbility, MongoQuery } from "@casl/ability";
import { Role } from "@prisma/client";
import type { AppAbility, AppActions, AppSubjects } from "./types";
/**
* Permission matrix for each role.
*
* Builds and returns CASL permission rules for a given role.
* Rules are merged (additive union) for multi-role users.
* Builds and returns a CASL MongoAbility for a given role.
* Uses createMongoAbility which includes built-in conditions matching,
* supporting condition-based rules like { assignedToId: userId }.
*
* Rules are merged (additive union) for multi-role users via defineAbilityFor().
*
* Zone/ownership filtering (e.g., Collector assigned zones) is enforced
* at the data layer — CASL handles the coarse-grained capability check here.
*
* Note on conditions: Subjects are currently string literals (no Prisma models yet).
* Conditions are cast via `as MongoQuery` to bypass CASL's strict field inference.
* Once Prisma models are defined (Phase 2+), subjects can be replaced with class types
* for full type-safe condition checking.
*/
export function definePermissionsFor(
role: Role,
userId: string,
tenantId: string
): PureAbility<[AppActions, AppSubjects]> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility);
): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility
);
// Helper to cast condition objects — required because subjects are string literals
// (no Prisma model types yet). CASL infers MongoQuery<never> for string subjects,
// so we cast through unknown. Conditions are enforced at runtime by CASL's MongoDB
// query matcher. Type-safe conditions will be added when Prisma models are defined.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cond = (obj: Record<string, unknown>) => obj as unknown as any;
switch (role) {
case Role.ADMIN: {
@@ -56,45 +73,36 @@ export function definePermissionsFor(
can("create", "Payment");
// View payment history
can("read", "Payment");
// Explicitly blocked from billing management
cannot("manage", "Invoice");
// No user management
cannot("manage", "User");
// No report access
cannot("manage", "Report");
// NOTE: No explicit cannot() needed — Collector simply has no rules for
// Invoice, User management, or Reports. Absence of a rule = no access.
break;
}
case Role.TECHNICIAN: {
// Only their assigned jobs
can("read", "JobOrder", { assignedToId: userId });
can("update", "JobOrder", { assignedToId: userId });
// Only their assigned jobs (conditions enforced at data layer too)
can("read", "JobOrder", cond({ assignedToId: userId }));
can("update", "JobOrder", cond({ assignedToId: userId }));
// Limited subscriber access for contact info (data layer enforces scope)
can("read", "Subscriber");
// Only inventory checked out to them
can("read", "Inventory", { assignedToId: userId });
// Explicitly blocked from billing, payments, and subscriber management
cannot("manage", "Invoice");
cannot("manage", "Payment");
cannot("manage", "Subscriber");
cannot("manage", "Report");
can("read", "Inventory", cond({ assignedToId: userId }));
// NOTE: No cannot() needed — Technician simply has no billing/payment rules.
// Absence of a rule = no access for Invoice, Payment, Report, User management.
break;
}
case Role.CLIENT: {
// Only their own invoices
can("read", "Invoice", { subscriberId: userId });
// Only their own invoices (scoped by conditions)
can("read", "Invoice", cond({ subscriberId: userId }));
// Only their own payments
can("read", "Payment", { subscriberId: userId });
can("read", "Payment", cond({ subscriberId: userId }));
// Only their own profile
can("read", "Subscriber", { id: userId });
can("read", "Subscriber", cond({ id: userId }));
// Submit support tickets
can("create", "Ticket");
// Only their own tickets
can("read", "Ticket", { submittedById: userId });
// Blocked from user management and reports
cannot("manage", "User");
cannot("manage", "Report");
can("read", "Ticket", cond({ submittedById: userId }));
// NOTE: No cannot() needed — Client simply has no rules for User management or Reports.
break;
}

View File

@@ -1,4 +1,4 @@
import { PureAbility, AbilityBuilder } from "@casl/ability";
import { MongoAbility, MongoQuery } from "@casl/ability";
/**
* Subjects represent the resources in the system.
@@ -28,12 +28,18 @@ export type AppSubjects =
export type AppActions = "create" | "read" | "update" | "delete" | "manage";
/**
* The application's CASL ability type.
* Parameterized with actions and subjects.
* Conditions type for permission rules.
* Uses Record<string, unknown> to support arbitrary field conditions
* (e.g., { assignedToId: userId }) without requiring Prisma model definitions.
* When Prisma models are added in later phases, conditions can be tightened.
*/
export type AppAbility = PureAbility<[AppActions, AppSubjects]>;
export type AppConditions = MongoQuery<Record<string, unknown>>;
/**
* Re-export AbilityBuilder typed for AppAbility for convenience.
* The application's CASL ability type.
*
* Uses MongoAbility with loose conditions (Record<string, unknown>) so that
* condition-based rules like { assignedToId: userId } work without requiring
* the full Prisma model types — those will be added in later phases.
*/
export type AppAbilityBuilder = AbilityBuilder<AppAbility>;
export type AppAbility = MongoAbility<[AppActions, AppSubjects], AppConditions>;

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { defineAbilityFor } from "@/lib/casl/ability";
import type { AppAbility, AppActions, AppSubjects } from "@/lib/casl/types";
import type { Role } from "@prisma/client";
/**
* The context passed to authorized route handlers.
* Handlers receive both the session user and the built CASL ability
* for fine-grained permission checks within the handler body.
*/
export interface AuthorizedContext {
user: {
id: string;
email: string;
tenantId: string | null;
roles: Role[];
isSuperAdmin: boolean;
firstName: string;
lastName: string;
};
ability: AppAbility;
}
/**
* Handler signature for authorized route handlers.
* Receives the request and an authorized context with user + ability.
*/
type AuthorizedHandler = (
req: NextRequest,
ctx: AuthorizedContext
) => Promise<NextResponse> | NextResponse;
/**
* Higher-order function that wraps a Next.js route handler with RBAC enforcement.
*
* Flow:
* 1. Get current user session via getCurrentUser()
* 2. If no session → return 401 Unauthorized
* 3. Build CASL ability using defineAbilityFor(user)
* 4. Check ability.can(action, subject)
* 5. If cannot → return 403 Forbidden
* 6. If can → call the wrapped handler with (req, { user, ability })
*
* Usage:
* ```typescript
* // In route.ts:
* export const GET = withPermission("read", "Subscriber")(async (req, { user, ability }) => {
* // ability is available for fine-grained checks within the handler
* const subscribers = await getSubscribers(user.tenantId);
* return NextResponse.json(subscribers);
* });
* ```
*
* @param action - The CASL action to check ("create" | "read" | "update" | "delete" | "manage")
* @param subject - The CASL subject to check against (e.g., "Subscriber", "Invoice")
* @returns A function that takes an authorized handler and returns a Next.js route handler
*/
export function withPermission(action: AppActions, subject: AppSubjects) {
return function (handler: AuthorizedHandler) {
return async function (req: NextRequest): Promise<NextResponse> {
// Step 1: Get session user
const user = await getCurrentUser();
// Step 2: No session → 401 Unauthorized
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Step 3: Build CASL ability from session user
const ability = defineAbilityFor({
id: user.id,
roles: user.roles,
tenantId: user.tenantId,
isSuperAdmin: user.isSuperAdmin,
});
// Step 4: Check permission
if (!ability.can(action, subject)) {
// Step 5: Cannot → 403 Forbidden
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Step 6: Can → call handler with user and ability in context
return handler(req, { user, ability });
};
};
}
/**
* Alternative API: authorize(handler, action, subject)
*
* Convenience wrapper for `withPermission` when you prefer
* the handler-first style rather than curried style.
*
* Usage:
* ```typescript
* export const GET = authorize(
* async (req, { user, ability }) => {
* return NextResponse.json({ ok: true });
* },
* "read",
* "Subscriber"
* );
* ```
*/
export function authorize(
handler: AuthorizedHandler,
action: AppActions,
subject: AppSubjects
) {
return withPermission(action, subject)(handler);
}