Files
NetForge/.planning/phases/01-foundation/01-04-SUMMARY.md
kevin-asprec d25885aaeb docs(01-04): complete CASL RBAC plan
Tasks completed: 2/2
- Task 1: CASL permission definitions and ability factory
- Task 2: API authorization middleware and RBAC tests

SUMMARY: .planning/phases/01-foundation/01-04-SUMMARY.md
2026-03-04 18:59:14 +08:00

9.1 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established duration completed
01-foundation 04 auth
casl
rbac
authorization
middleware
permissions
nextjs
phase provides
01-02 NextAuth JWT session with tenantId and roles fields; getCurrentUser() helper
phase provides
01-01 Prisma Role enum (ADMIN, OFFICE_STAFF, COLLECTOR, TECHNICIAN, CLIENT)
CASL permission matrix for all 5 roles (Admin, Office Staff, Collector, Technician, Client)
defineAbilityFor() factory that builds ability from session user
API-layer authorization middleware returning 401/403
66 unit tests validating every role boundary
All future API routes that need authorization enforcement
Phase 2+ (Invoice, Subscriber, Payment routes use withPermission wrapper)
Phase 3+ (JobOrder routes use Technician permission conditions)
added patterns
@casl/ability (v6+) with createMongoAbility
CASL MongoAbility with string-based subjects for forward-compatibility
withPermission() higher-order function wraps Next.js route handlers
Additive permission union for multi-role users (cannot() rules excluded in merge)
Condition casting via any for string subjects (tightened when Prisma models added)
created modified
src/lib/casl/types.ts
src/lib/casl/permissions.ts
src/lib/casl/ability.ts
src/lib/middleware/authorize.ts
src/lib/__tests__/rbac.test.ts
package.json (added @casl/ability dependency)
createMongoAbility used throughout (not PureAbility) — string subjects require conditionsMatcher which createMongoAbility provides built-in
cannot() rules excluded when merging multi-role abilities — additive union means more roles = more (never less) access
Condition objects cast via as unknown as any for string subjects — CASL infers MongoQuery<never> for strings, type-safe tightening deferred to Phase 2 when Prisma models exist
Technician can('read', 'Subscriber') — coarse-grained access, data-layer enforces actual scope to assigned job contacts
Collector has no cannot() rules — absence of rule = no access (simpler than explicit denials)
CASL ability factory pattern: defineAbilityFor(user) returns ability, used in middleware and handlers
withPermission('action', 'Subject')(handler) wraps route for coarse-grained guard
Fine-grained checks done inside handler using passed ability object
Single-role users get full ability including cannot() rules; multi-role gets positive-only union
7min 2026-03-04

Phase 1 Plan 04: CASL RBAC Permission System Summary

CASL MongoAbility permission system with 5-role matrix, withPermission() API middleware, and 66 unit tests enforcing all role boundaries including critical Technician billing block

Performance

  • Duration: 7 min
  • Started: 2026-03-04T10:50:45Z
  • Completed: 2026-03-04T10:57:37Z
  • Tasks: 2 completed
  • Files modified: 7 (5 created, 2 modified)

Accomplishments

  • CASL permission matrix defined for all 5 roles (Admin, Office Staff, Collector, Technician, Client) plus super-admin bypass
  • API-layer authorization middleware (withPermission HOF) returns 401 for unauthenticated, 403 for unauthorized
  • 66 unit tests validating every role boundary — all pass, including critical Technician billing/subscriber management block
  • Multi-role additive union implemented (TECHNICIAN + COLLECTOR gets both roles' permissions)

Task Commits

Each task was committed atomically:

  1. Task 1: CASL permission definitions and ability factory - 67bb6cc (feat)
  2. Task 2: API authorization middleware and RBAC tests - 1df2b2d (feat)

Plan metadata: (included in this summary commit)

Files Created/Modified

  • src/lib/casl/types.ts - AppAbility type (MongoAbility), AppSubjects, AppActions, AppConditions
  • src/lib/casl/permissions.ts - definePermissionsFor() — permission matrix for all 5 roles
  • src/lib/casl/ability.ts - defineAbilityFor() — ability factory from session user; mergeAbilities() for multi-role union
  • src/lib/middleware/authorize.ts - withPermission() HOF and authorize() convenience wrapper
  • src/lib/__tests__/rbac.test.ts - 66 unit tests covering all role boundaries
  • package.json - Added @casl/ability dependency

Decisions Made

createMongoAbility over PureAbility: PureAbility throws "You need to pass conditionsMatcher" at runtime when any rule has conditions. createMongoAbility includes the MongoDB conditions matcher built-in, which is required since Technician and Client rules use conditions ({ assignedToId: userId }, { subscriberId: userId }, etc.).

cannot() rules excluded in multi-role merge: When merging rules from multiple roles, cannot() (inverted) rules from a less-privileged role should not block permissions granted by a more-privileged role. The merge function only copies positive (can) rules. Single-role users still get the full ability including cannot() rules.

Technician can("read", "Subscriber") — coarse-grained: The Technician needs subscriber contact info to reach customers for assigned jobs. The CASL rule grants broad read capability; the data layer (Prisma query scoping) enforces that only subscribers related to assigned jobs are returned. This matches the plan's specification.

Condition casting via any: CASL's TypeScript types infer MongoQuery<never> for string-based subjects (no known model fields to validate against). Since Prisma models for Subscriber, Invoice, etc. don't exist yet, condition objects are cast through as unknown as any. When models are added in Phase 2+, subjects can be replaced with class types for fully type-safe conditions.

No cannot() for Collector/Technician/Client: Absence of a can() rule already blocks access — CASL's default is deny. Explicit cannot() rules are only needed to override a prior can() (like Office Staff's can("read", "Account") followed by cannot("create", "Account")). Using cannot() where unnecessary added noise.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] PureAbility conditionsMatcher error at runtime

  • Found during: Task 2 (running RBAC tests)
  • Issue: Tests using Technician and Client roles threw "You need to pass conditionsMatcher option in order to restrict access by conditions" because PureAbility doesn't include a conditions matcher by default
  • Fix: Replaced PureAbility with createMongoAbility throughout — this includes the MongoDB conditions matcher built-in
  • Files modified: src/lib/casl/types.ts, src/lib/casl/permissions.ts, src/lib/casl/ability.ts
  • Verification: All 66 tests pass after fix
  • Committed in: 1df2b2d (Task 2 commit)

2. [Rule 1 - Bug] TypeScript compile errors with MongoAbility string subjects

  • Found during: Task 2 (tsc --noEmit check after test fix)
  • Issue: CASL infers MongoQuery<never> for string-based subjects, making condition objects incompatible with the can() overloads
  • Fix: Added cond() helper that casts condition objects via as unknown as any, with explanatory comment about when this will be tightened
  • Files modified: src/lib/casl/permissions.ts
  • Verification: tsc --noEmit passes with --skipLibCheck
  • Committed in: 1df2b2d (Task 2 commit)

3. [Rule 1 - Bug] cannot() rules in multi-role merge blocked valid permissions

  • Found during: Task 2 (test failure for TECHNICIAN+COLLECTOR multi-role test)
  • Issue: The original ability.ts mergeAbilities() copied all rules including inverted (cannot) ones. This meant COLLECTOR's absent billing rules could conflict with other role abilities in edge cases
  • Fix: mergeAbilities() now only copies non-inverted (positive) rules. Single-role users still get full ability including cannot() semantics
  • Files modified: src/lib/casl/ability.ts
  • Verification: Multi-role tests pass; single-role tests (Office Staff COA block) still pass
  • Committed in: 1df2b2d (Task 2 commit)

Total deviations: 3 auto-fixed (3x Rule 1 - Bug) Impact on plan: All auto-fixes required for correct operation. CASL's TypeScript types with string subjects have documented friction; fixes establish the pattern for future phases. No scope creep.

Issues Encountered

  • CASL's TypeScript types for string-based subjects are strict: MongoQuery<never> makes condition objects incompatible. Pattern: cast conditions and upgrade to class-based subjects when Prisma models arrive in Phase 2+.

User Setup Required

None - no external service configuration required.

Next Phase Readiness

  • RBAC system complete and tested — all API routes in Phase 2+ should use withPermission() wrapper
  • authorize() convenience alias available for handler-first style
  • Ability factory accepts session user shape directly from getCurrentUser() return value
  • Condition-based permissions (Technician, Client) will be tightened when Prisma models added in Phase 2
  • Phase 1 Foundation is now: DB + Schema (01), Auth (02), Multi-tenancy (03), RBAC (04) — ready for Phase 1-05 (final foundation task)

Phase: 01-foundation Completed: 2026-03-04