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:
432
src/lib/__tests__/rbac.test.ts
Normal file
432
src/lib/__tests__/rbac.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
113
src/lib/middleware/authorize.ts
Normal file
113
src/lib/middleware/authorize.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user