--- phase: 01-foundation plan: "04" subsystem: auth tags: [casl, rbac, authorization, middleware, permissions, nextjs] # Dependency graph requires: - phase: 01-02 provides: NextAuth JWT session with tenantId and roles fields; getCurrentUser() helper - phase: 01-01 provides: Prisma Role enum (ADMIN, OFFICE_STAFF, COLLECTOR, TECHNICIAN, CLIENT) provides: - 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 affects: - 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) # Tech tracking tech-stack: added: ["@casl/ability (v6+) with createMongoAbility"] patterns: - "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)" key-files: created: - 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 modified: - package.json (added @casl/ability dependency) key-decisions: - "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 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)" patterns-established: - "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" # Metrics duration: 7min completed: 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` 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` 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` 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*