- Add Collection and Remittance to AppSubjects union type - Grant OFFICE_STAFF manage:Collection and manage:Remittance - Grant COLLECTOR create/read:Collection and create/read:Remittance - Update all 8 collection/remittance route handlers from Subscriber to their dedicated CASL subjects (Collection or Remittance) - Update JSDoc comments in route files to reflect new subject names Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
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 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
|
|
): 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: {
|
|
// 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");
|
|
// Zone management (assign subscribers and collectors to zones)
|
|
can("manage", "Zone");
|
|
// Ticketing
|
|
can("manage", "Ticket");
|
|
// Job order management
|
|
can("manage", "JobOrder");
|
|
// Technician profile management (read/update, not create — admin only for compensation config)
|
|
can("read", "TechnicianProfile");
|
|
can("update", "TechnicianProfile");
|
|
// Inventory management
|
|
can("manage", "Inventory");
|
|
// Expense management (record, list, view)
|
|
can("manage", "Expense");
|
|
// Vendor management (CRUD)
|
|
can("manage", "Vendor");
|
|
// Collection and remittance management
|
|
can("manage", "Collection");
|
|
can("manage", "Remittance");
|
|
// Job type rates (read-only for office staff — admin configures rates)
|
|
can("read", "JobTypeRate");
|
|
// 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 read zones they are assigned to (data layer enforces which zones)
|
|
can("read", "Zone");
|
|
// Can record payments (zone filtering done at data layer)
|
|
can("create", "Payment");
|
|
// View payment history
|
|
can("read", "Payment");
|
|
// Can create and view collections
|
|
can("create", "Collection");
|
|
can("read", "Collection");
|
|
// Can create and view remittances
|
|
can("create", "Remittance");
|
|
can("read", "Remittance");
|
|
// 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 (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", 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 (scoped by conditions)
|
|
can("read", "Invoice", cond({ subscriberId: userId }));
|
|
// Only their own payments
|
|
can("read", "Payment", cond({ subscriberId: userId }));
|
|
// Only their own profile
|
|
can("read", "Subscriber", cond({ id: userId }));
|
|
// Submit support tickets
|
|
can("create", "Ticket");
|
|
// Only their own tickets
|
|
can("read", "Ticket", cond({ submittedById: userId }));
|
|
// NOTE: No cannot() needed — Client simply has no rules for User management or Reports.
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
// No permissions by default — safe fallback
|
|
break;
|
|
}
|
|
}
|
|
|
|
return build();
|
|
}
|