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
This commit is contained in:
kevin-asprec
2026-03-04 18:59:14 +08:00
parent 1df2b2d87d
commit d25885aaeb
2 changed files with 182 additions and 12 deletions

View File

@@ -0,0 +1,165 @@
---
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<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)"
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<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*