diff --git a/src/lib/__tests__/rbac.test.ts b/src/lib/__tests__/rbac.test.ts new file mode 100644 index 0000000..2a4cb27 --- /dev/null +++ b/src/lib/__tests__/rbac.test.ts @@ -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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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); + }); +}); diff --git a/src/lib/casl/ability.ts b/src/lib/casl/ability.ts index de75b8e..69e8ac4 100644 --- a/src/lib/casl/ability.ts +++ b/src/lib/casl/ability.ts @@ -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(PureAbility); - if (user.isSuperAdmin) { // Super-admins bypass all permission checks + const { can, build } = new AbilityBuilder(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; - }> = []; + // 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 | undefined, + action: rule.action, + subject: rule.subject, + conditions: rule.conditions, }); } } } - return new PureAbility<[AppActions, AppSubjects]>(allRules); + return createMongoAbility<[AppActions, AppSubjects]>(allRules); } diff --git a/src/lib/casl/permissions.ts b/src/lib/casl/permissions.ts index cd06e0c..f878620 100644 --- a/src/lib/casl/permissions.ts +++ b/src/lib/casl/permissions.ts @@ -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(PureAbility); +): AppAbility { + const { can, cannot, build } = new AbilityBuilder( + createMongoAbility + ); + + // Helper to cast condition objects — required because subjects are string literals + // (no Prisma model types yet). CASL infers MongoQuery 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) => 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; } diff --git a/src/lib/casl/types.ts b/src/lib/casl/types.ts index ecb1363..d9697af 100644 --- a/src/lib/casl/types.ts +++ b/src/lib/casl/types.ts @@ -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 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>; /** - * Re-export AbilityBuilder typed for AppAbility for convenience. + * The application's CASL ability type. + * + * Uses MongoAbility with loose conditions (Record) 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; +export type AppAbility = MongoAbility<[AppActions, AppSubjects], AppConditions>; diff --git a/src/lib/middleware/authorize.ts b/src/lib/middleware/authorize.ts new file mode 100644 index 0000000..5d87804 --- /dev/null +++ b/src/lib/middleware/authorize.ts @@ -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; + +/** + * 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 { + // 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); +}