From d25885aaeb8163ae972857442d89a2d0ebb1e4ee Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 18:59:14 +0800 Subject: [PATCH] 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 --- .planning/STATE.md | 29 +-- .../phases/01-foundation/01-04-SUMMARY.md | 165 ++++++++++++++++++ 2 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 .planning/phases/01-foundation/01-04-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index c2a4c3f..9f1903d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -10,28 +10,28 @@ See: .planning/PROJECT.md (updated 2026-03-04) ## Current Position Phase: 1 of 5 (Foundation) -Plan: 3 of 5 in current phase +Plan: 4 of 5 in current phase Status: In progress -Last activity: 2026-03-04 — Completed 01-03-PLAN.md (tenant provisioning, Prisma middleware, RLS, isolation tests) +Last activity: 2026-03-04 — Completed 01-04-PLAN.md (CASL RBAC, 5-role permission matrix, API middleware, 66 tests) -Progress: [███░░░░░░░] 15% (3/20 plans across all phases) +Progress: [████░░░░░░] 20% (4/20 plans across all phases) ## Performance Metrics **Velocity:** -- Total plans completed: 3 -- Average duration: 9.3 min -- Total execution time: 28 min +- Total plans completed: 4 +- Average duration: 8.75 min +- Total execution time: 35 min **By Phase:** | Phase | Plans | Total | Avg/Plan | |-------|-------|-------|----------| -| 01-foundation | 3/5 complete | 28 min | 9.3 min | +| 01-foundation | 4/5 complete | 35 min | 8.75 min | **Recent Trend:** -- Last 5 plans: 01-01 (11 min), 01-02 (8 min), 01-03 (9 min) -- Trend: stable at ~9 min/plan +- Last 5 plans: 01-01 (11 min), 01-02 (8 min), 01-03 (9 min), 01-04 (7 min) +- Trend: stable at ~8-9 min/plan *Updated after each plan completion* @@ -59,6 +59,11 @@ Recent decisions affecting current work: - [01-03]: findUnique cross-tenant protection routes through findFirst internally — Prisma unique key cannot have tenantId injected without changing where shape - [01-03]: RLS USING allows null app.current_tenant_id — super-admin mode (no tenant context) sees all rows - [01-03]: Initial migration baselined with migrate resolve --applied (schema was created via db push in 01-01) +- [01-04]: createMongoAbility used throughout (not PureAbility) — string subjects require conditionsMatcher which createMongoAbility provides built-in +- [01-04]: cannot() rules excluded when merging multi-role abilities — additive union means more roles = more (never less) access +- [01-04]: Condition objects cast via any for string subjects — CASL infers MongoQuery for strings; tighten when Prisma models defined in Phase 2+ +- [01-04]: Technician can("read", "Subscriber") coarse-grained — data layer enforces actual scope to assigned job contacts only +- [01-04]: withPermission() HOF wraps Next.js route handlers; authorize() as convenience alias ### Pending Todos @@ -68,10 +73,10 @@ None. - [Phase 1 research flag]: MikroTik RouterOS Node.js client library maintenance status is LOW confidence — verify `node-routeros` vs `mikronode` before implementing router integration (MikroTik integration is v2, but adapter interface should be planned) - [Phase 3 research flag]: Semaphore SMS API pricing/stability for 2026 is MEDIUM confidence — verify before any SMS work (SMS is v2, but abstraction layer design is relevant) -- [01-03 resolved]: prisma-tenant.ts TypeScript errors fixed — Prisma $extends create/upsert required destructuring tenant relation before spreading tenantId +- [01-04 note]: CASL condition types for string subjects use any cast — upgrade to class-based subjects when Phase 2 Prisma models (Subscriber, Invoice, etc.) are defined ## Session Continuity -Last session: 2026-03-04T10:45:22Z -Stopped at: Completed 01-03-PLAN.md (tenant provisioning + Prisma middleware + RLS + isolation tests) +Last session: 2026-03-04T10:57:37Z +Stopped at: Completed 01-04-PLAN.md (CASL RBAC + 5-role permission matrix + API middleware + 66 tests) Resume file: None diff --git a/.planning/phases/01-foundation/01-04-SUMMARY.md b/.planning/phases/01-foundation/01-04-SUMMARY.md new file mode 100644 index 0000000..307406c --- /dev/null +++ b/.planning/phases/01-foundation/01-04-SUMMARY.md @@ -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 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*