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:
kevin-asprec
2026-03-04 18:57:31 +08:00
parent 67bb6cc95c
commit 1df2b2d87d
5 changed files with 623 additions and 86 deletions

View File

@@ -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;
}