feat(01-04): CASL permission definitions and ability factory

- Install @casl/ability for role-based access control
- Create src/lib/casl/types.ts with AppAbility, AppSubjects, AppActions types
- Create src/lib/casl/permissions.ts with permission matrix for all 5 roles
- Create src/lib/casl/ability.ts with defineAbilityFor() factory function
- Support multi-role users via additive union of permissions
- Super-admin bypasses all permission checks via can("manage", "all")
This commit is contained in:
kevin-asprec
2026-03-04 18:52:39 +08:00
parent 36729343cc
commit 67bb6cc95c
5 changed files with 307 additions and 0 deletions

111
src/lib/casl/ability.ts Normal file
View File

@@ -0,0 +1,111 @@
import { AbilityBuilder, PureAbility, createMongoAbility } from "@casl/ability";
import { Role } from "@prisma/client";
import { definePermissionsFor } from "./permissions";
import type { AppAbility, AppActions, AppSubjects } from "./types";
/**
* The session user shape expected by the ability factory.
* Matches the session.user fields set in auth-options.ts JWT/session callbacks.
*/
export interface AbilityUser {
id: string;
roles: Role[];
tenantId: string | null;
isSuperAdmin: boolean;
}
/**
* Builds a CASL Ability instance from a session user.
*
* - Super-admins: unrestricted access to all subjects
* - Regular users: union of all permissions across their roles
*
* 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.
*
* 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
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
}
}
// Build a merged ability by combining all role abilities
return mergeAbilities(user.roles, user.id, user.tenantId ?? "");
}
/**
* Merges abilities from multiple roles into a single AppAbility.
*
* 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).
*/
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>;
}> = [];
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) {
allRules.push({
action: rule.action as AppActions | AppActions[],
subject: rule.subject as AppSubjects | AppSubjects[],
conditions: rule.conditions as Record<string, unknown> | undefined,
});
}
}
}
return new PureAbility<[AppActions, AppSubjects]>(allRules);
}

108
src/lib/casl/permissions.ts Normal file
View File

@@ -0,0 +1,108 @@
import { AbilityBuilder, PureAbility } 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.
*
* Zone/ownership filtering (e.g., Collector assigned zones) is enforced
* at the data layer — CASL handles the coarse-grained capability check here.
*/
export function definePermissionsFor(
role: Role,
userId: string,
tenantId: string
): PureAbility<[AppActions, AppSubjects]> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility);
switch (role) {
case Role.ADMIN: {
// Full access within tenant
can("manage", "all");
break;
}
case Role.OFFICE_STAFF: {
// Create and manage users, assign roles
can("manage", "User");
// Full subscriber management
can("manage", "Subscriber");
// Billing management
can("manage", "Invoice");
// Record payments
can("manage", "Payment");
// Ticketing
can("manage", "Ticket");
// Job order management
can("manage", "JobOrder");
// View financial reports (read-only)
can("read", "Report");
// View accounting (read-only, cannot modify Chart of Accounts)
can("read", "Account");
// Explicitly block CoA modifications
cannot("create", "Account");
cannot("update", "Account");
cannot("delete", "Account");
break;
}
case Role.COLLECTOR: {
// Can view all subscribers for context (zone filtering done at data layer)
can("read", "Subscriber");
// Can record payments (zone filtering done at data layer)
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");
break;
}
case Role.TECHNICIAN: {
// Only their assigned jobs
can("read", "JobOrder", { assignedToId: userId });
can("update", "JobOrder", { 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");
break;
}
case Role.CLIENT: {
// Only their own invoices
can("read", "Invoice", { subscriberId: userId });
// Only their own payments
can("read", "Payment", { subscriberId: userId });
// Only their own profile
can("read", "Subscriber", { 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");
break;
}
default: {
// No permissions by default — safe fallback
break;
}
}
return build();
}

39
src/lib/casl/types.ts Normal file
View File

@@ -0,0 +1,39 @@
import { PureAbility, AbilityBuilder } from "@casl/ability";
/**
* Subjects represent the resources in the system.
*
* Note: Most subjects (Subscriber, Invoice, etc.) don't have Prisma models yet
* — we're defining the permission structure now so it's ready when those models
* are created in later phases.
*/
export type AppSubjects =
| "Tenant"
| "User"
| "Subscriber"
| "Invoice"
| "Payment"
| "Ticket"
| "JobOrder"
| "Inventory"
| "Expense"
| "Account"
| "Report"
| "all";
/**
* Actions that can be performed on subjects.
* "manage" is a CASL shorthand that means all actions.
*/
export type AppActions = "create" | "read" | "update" | "delete" | "manage";
/**
* The application's CASL ability type.
* Parameterized with actions and subjects.
*/
export type AppAbility = PureAbility<[AppActions, AppSubjects]>;
/**
* Re-export AbilityBuilder typed for AppAbility for convenience.
*/
export type AppAbilityBuilder = AbilityBuilder<AppAbility>;