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 { Role } from "@prisma/client";
|
||||||
import { definePermissionsFor } from "./permissions";
|
import { definePermissionsFor } from "./permissions";
|
||||||
import type { AppAbility, AppActions, AppSubjects } from "./types";
|
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
|
* - Super-admins: unrestricted access to all subjects
|
||||||
* - Regular users: union of all permissions across their roles
|
* - 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
|
* 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.
|
* 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:
|
* Usage:
|
||||||
* const ability = defineAbilityFor(session.user);
|
* const ability = defineAbilityFor(session.user);
|
||||||
* ability.can("read", "Subscriber") // → boolean
|
* ability.can("read", "Subscriber") // → boolean
|
||||||
*/
|
*/
|
||||||
export function defineAbilityFor(user: AbilityUser): AppAbility {
|
export function defineAbilityFor(user: AbilityUser): AppAbility {
|
||||||
const { can, build } = new AbilityBuilder<AppAbility>(PureAbility);
|
|
||||||
|
|
||||||
if (user.isSuperAdmin) {
|
if (user.isSuperAdmin) {
|
||||||
// Super-admins bypass all permission checks
|
// Super-admins bypass all permission checks
|
||||||
|
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
|
||||||
can("manage", "all");
|
can("manage", "all");
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge permissions for all roles (union — additive)
|
if (user.roles.length === 0) {
|
||||||
// Each role's ability is built separately, then we extract its rules
|
// No roles — no permissions
|
||||||
for (const role of user.roles) {
|
return createMongoAbility<[AppActions, AppSubjects]>([]);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 ?? "");
|
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.
|
* 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.
|
* cannot() rules from one role are NOT applied when another role grants the same permission.
|
||||||
* This implements the principle of additive permissions (union).
|
* This implements additive permissions: more roles = more (or equal) access, never less.
|
||||||
*/
|
*/
|
||||||
function mergeAbilities(
|
function mergeAbilities(
|
||||||
roles: Role[],
|
roles: Role[],
|
||||||
userId: string,
|
userId: string,
|
||||||
tenantId: string
|
tenantId: string
|
||||||
): AppAbility {
|
): AppAbility {
|
||||||
if (roles.length === 0) {
|
// Collect all positive rules from all roles
|
||||||
// No roles — no permissions
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return new PureAbility<[AppActions, AppSubjects]>([]);
|
const allRules: any[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
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>;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
const roleAbility = definePermissionsFor(role, userId, tenantId);
|
const roleAbility = definePermissionsFor(role, userId, tenantId);
|
||||||
for (const rule of roleAbility.rules) {
|
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) {
|
if (!rule.inverted) {
|
||||||
|
// Only include positive (can) rules
|
||||||
allRules.push({
|
allRules.push({
|
||||||
action: rule.action as AppActions | AppActions[],
|
action: rule.action,
|
||||||
subject: rule.subject as AppSubjects | AppSubjects[],
|
subject: rule.subject,
|
||||||
conditions: rule.conditions as Record<string, unknown> | undefined,
|
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 { Role } from "@prisma/client";
|
||||||
import type { AppAbility, AppActions, AppSubjects } from "./types";
|
import type { AppAbility, AppActions, AppSubjects } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission matrix for each role.
|
* Permission matrix for each role.
|
||||||
*
|
*
|
||||||
* Builds and returns CASL permission rules for a given role.
|
* Builds and returns a CASL MongoAbility for a given role.
|
||||||
* Rules are merged (additive union) for multi-role users.
|
* 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
|
* Zone/ownership filtering (e.g., Collector assigned zones) is enforced
|
||||||
* at the data layer — CASL handles the coarse-grained capability check here.
|
* 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(
|
export function definePermissionsFor(
|
||||||
role: Role,
|
role: Role,
|
||||||
userId: string,
|
userId: string,
|
||||||
tenantId: string
|
tenantId: string
|
||||||
): PureAbility<[AppActions, AppSubjects]> {
|
): AppAbility {
|
||||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility);
|
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) {
|
switch (role) {
|
||||||
case Role.ADMIN: {
|
case Role.ADMIN: {
|
||||||
@@ -56,45 +73,36 @@ export function definePermissionsFor(
|
|||||||
can("create", "Payment");
|
can("create", "Payment");
|
||||||
// View payment history
|
// View payment history
|
||||||
can("read", "Payment");
|
can("read", "Payment");
|
||||||
// Explicitly blocked from billing management
|
// NOTE: No explicit cannot() needed — Collector simply has no rules for
|
||||||
cannot("manage", "Invoice");
|
// Invoice, User management, or Reports. Absence of a rule = no access.
|
||||||
// No user management
|
|
||||||
cannot("manage", "User");
|
|
||||||
// No report access
|
|
||||||
cannot("manage", "Report");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case Role.TECHNICIAN: {
|
case Role.TECHNICIAN: {
|
||||||
// Only their assigned jobs
|
// Only their assigned jobs (conditions enforced at data layer too)
|
||||||
can("read", "JobOrder", { assignedToId: userId });
|
can("read", "JobOrder", cond({ assignedToId: userId }));
|
||||||
can("update", "JobOrder", { assignedToId: userId });
|
can("update", "JobOrder", cond({ assignedToId: userId }));
|
||||||
// Limited subscriber access for contact info (data layer enforces scope)
|
// Limited subscriber access for contact info (data layer enforces scope)
|
||||||
can("read", "Subscriber");
|
can("read", "Subscriber");
|
||||||
// Only inventory checked out to them
|
// Only inventory checked out to them
|
||||||
can("read", "Inventory", { assignedToId: userId });
|
can("read", "Inventory", cond({ assignedToId: userId }));
|
||||||
// Explicitly blocked from billing, payments, and subscriber management
|
// NOTE: No cannot() needed — Technician simply has no billing/payment rules.
|
||||||
cannot("manage", "Invoice");
|
// Absence of a rule = no access for Invoice, Payment, Report, User management.
|
||||||
cannot("manage", "Payment");
|
|
||||||
cannot("manage", "Subscriber");
|
|
||||||
cannot("manage", "Report");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case Role.CLIENT: {
|
case Role.CLIENT: {
|
||||||
// Only their own invoices
|
// Only their own invoices (scoped by conditions)
|
||||||
can("read", "Invoice", { subscriberId: userId });
|
can("read", "Invoice", cond({ subscriberId: userId }));
|
||||||
// Only their own payments
|
// Only their own payments
|
||||||
can("read", "Payment", { subscriberId: userId });
|
can("read", "Payment", cond({ subscriberId: userId }));
|
||||||
// Only their own profile
|
// Only their own profile
|
||||||
can("read", "Subscriber", { id: userId });
|
can("read", "Subscriber", cond({ id: userId }));
|
||||||
// Submit support tickets
|
// Submit support tickets
|
||||||
can("create", "Ticket");
|
can("create", "Ticket");
|
||||||
// Only their own tickets
|
// Only their own tickets
|
||||||
can("read", "Ticket", { submittedById: userId });
|
can("read", "Ticket", cond({ submittedById: userId }));
|
||||||
// Blocked from user management and reports
|
// NOTE: No cannot() needed — Client simply has no rules for User management or Reports.
|
||||||
cannot("manage", "User");
|
|
||||||
cannot("manage", "Report");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PureAbility, AbilityBuilder } from "@casl/ability";
|
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subjects represent the resources in the system.
|
* Subjects represent the resources in the system.
|
||||||
@@ -28,12 +28,18 @@ export type AppSubjects =
|
|||||||
export type AppActions = "create" | "read" | "update" | "delete" | "manage";
|
export type AppActions = "create" | "read" | "update" | "delete" | "manage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The application's CASL ability type.
|
* Conditions type for permission rules.
|
||||||
* Parameterized with actions and subjects.
|
* 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