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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user