diff --git a/package-lock.json b/package-lock.json index 1c63c26..5aca580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "netforge", "version": "0.1.0", "dependencies": { + "@casl/ability": "^6.8.0", "@prisma/client": "^6.19.2", "bcryptjs": "^3.0.3", "next": "16.1.6", @@ -336,6 +337,18 @@ "node": ">=6.9.0" } }, + "node_modules/@casl/ability": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.8.0.tgz", + "integrity": "sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==", + "license": "MIT", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -3076,6 +3089,41 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==", + "license": "Apache-2.0" + }, + "node_modules/@ucast/js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.1.0.tgz", + "integrity": "sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "1.10.2" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.1.tgz", + "integrity": "sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "1.10.2", + "@ucast/js": "3.1.0", + "@ucast/mongo": "2.4.3" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", diff --git a/package.json b/package.json index 87c646e..5fd4d35 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "seed": "npx tsx prisma/seed.ts" }, "dependencies": { + "@casl/ability": "^6.8.0", "@prisma/client": "^6.19.2", "bcryptjs": "^3.0.3", "next": "16.1.6", diff --git a/src/lib/casl/ability.ts b/src/lib/casl/ability.ts new file mode 100644 index 0000000..de75b8e --- /dev/null +++ b/src/lib/casl/ability.ts @@ -0,0 +1,111 @@ +import { AbilityBuilder, PureAbility, createMongoAbility } from "@casl/ability"; +import { Role } from "@prisma/client"; +import { definePermissionsFor } from "./permissions"; +import type { AppAbility, AppActions, AppSubjects } from "./types"; + +/** + * The session user shape expected by the ability factory. + * Matches the session.user fields set in auth-options.ts JWT/session callbacks. + */ +export interface AbilityUser { + id: string; + roles: Role[]; + tenantId: string | null; + isSuperAdmin: boolean; +} + +/** + * Builds a CASL Ability instance from a session user. + * + * - Super-admins: unrestricted access to all subjects + * - Regular users: union of all permissions across their roles + * + * Multi-role users get the additive union: if TECHNICIAN can read JobOrders + * and COLLECTOR can create Payments, a user with both roles can do both. + * + * Usage: + * const ability = defineAbilityFor(session.user); + * ability.can("read", "Subscriber") // → boolean + */ +export function defineAbilityFor(user: AbilityUser): AppAbility { + const { can, build } = new AbilityBuilder(PureAbility); + + if (user.isSuperAdmin) { + // Super-admins bypass all permission checks + can("manage", "all"); + return build(); + } + + // Merge permissions for all roles (union — additive) + // Each role's ability is built separately, then we extract its rules + for (const role of user.roles) { + const roleAbility = definePermissionsFor( + role, + user.id, + user.tenantId ?? "" + ); + + // Transfer all rules from the role-specific ability into the merged builder + for (const rule of roleAbility.rules) { + if (rule.inverted) { + // cannot() rules — only apply if no positive rule overrides them + // (CASL already handles priority, but we copy them faithfully) + } + // We rebuild using createMongoAbility to preserve condition matching + } + } + + // Build a merged ability by combining all role abilities + return mergeAbilities(user.roles, user.id, user.tenantId ?? ""); +} + +/** + * Merges abilities from multiple roles into a single AppAbility. + * + * For multi-role users, positive (can) rules from ALL roles are combined. + * cannot() rules from one role are not applied when another role grants the same permission. + * This implements the principle of additive permissions (union). + */ +function mergeAbilities( + roles: Role[], + userId: string, + tenantId: string +): AppAbility { + if (roles.length === 0) { + // No roles — no permissions + return new PureAbility<[AppActions, AppSubjects]>([]); + } + + if (roles.length === 1) { + // Single role — return directly without merging overhead + return definePermissionsFor(roles[0], userId, tenantId); + } + + // For multi-role users, collect all rules from all roles. + // CASL evaluates can() as true if ANY rule grants the permission, + // so additive union works naturally by merging all rules. + const allRules: Array<{ + action: AppActions | AppActions[]; + subject: AppSubjects | AppSubjects[]; + inverted?: boolean; + conditions?: Record; + }> = []; + + for (const role of roles) { + const roleAbility = definePermissionsFor(role, userId, tenantId); + for (const rule of roleAbility.rules) { + // Only include positive (can) rules when merging multiple roles. + // cannot() rules from a less-privileged role should not block + // permissions granted by a more-privileged role. + if (!rule.inverted) { + allRules.push({ + action: rule.action as AppActions | AppActions[], + subject: rule.subject as AppSubjects | AppSubjects[], + conditions: rule.conditions as Record | undefined, + }); + } + } + } + + return new PureAbility<[AppActions, AppSubjects]>(allRules); +} diff --git a/src/lib/casl/permissions.ts b/src/lib/casl/permissions.ts new file mode 100644 index 0000000..cd06e0c --- /dev/null +++ b/src/lib/casl/permissions.ts @@ -0,0 +1,108 @@ +import { AbilityBuilder, PureAbility } 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. + * + * Zone/ownership filtering (e.g., Collector assigned zones) is enforced + * at the data layer — CASL handles the coarse-grained capability check here. + */ +export function definePermissionsFor( + role: Role, + userId: string, + tenantId: string +): PureAbility<[AppActions, AppSubjects]> { + const { can, cannot, build } = new AbilityBuilder(PureAbility); + + switch (role) { + case Role.ADMIN: { + // Full access within tenant + can("manage", "all"); + break; + } + + case Role.OFFICE_STAFF: { + // Create and manage users, assign roles + can("manage", "User"); + // Full subscriber management + can("manage", "Subscriber"); + // Billing management + can("manage", "Invoice"); + // Record payments + can("manage", "Payment"); + // Ticketing + can("manage", "Ticket"); + // Job order management + can("manage", "JobOrder"); + // View financial reports (read-only) + can("read", "Report"); + // View accounting (read-only, cannot modify Chart of Accounts) + can("read", "Account"); + // Explicitly block CoA modifications + cannot("create", "Account"); + cannot("update", "Account"); + cannot("delete", "Account"); + break; + } + + case Role.COLLECTOR: { + // Can view all subscribers for context (zone filtering done at data layer) + can("read", "Subscriber"); + // Can record payments (zone filtering done at data layer) + 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"); + break; + } + + case Role.TECHNICIAN: { + // Only their assigned jobs + can("read", "JobOrder", { assignedToId: userId }); + can("update", "JobOrder", { 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"); + break; + } + + case Role.CLIENT: { + // Only their own invoices + can("read", "Invoice", { subscriberId: userId }); + // Only their own payments + can("read", "Payment", { subscriberId: userId }); + // Only their own profile + can("read", "Subscriber", { 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"); + break; + } + + default: { + // No permissions by default — safe fallback + break; + } + } + + return build(); +} diff --git a/src/lib/casl/types.ts b/src/lib/casl/types.ts new file mode 100644 index 0000000..ecb1363 --- /dev/null +++ b/src/lib/casl/types.ts @@ -0,0 +1,39 @@ +import { PureAbility, AbilityBuilder } from "@casl/ability"; + +/** + * Subjects represent the resources in the system. + * + * Note: Most subjects (Subscriber, Invoice, etc.) don't have Prisma models yet + * — we're defining the permission structure now so it's ready when those models + * are created in later phases. + */ +export type AppSubjects = + | "Tenant" + | "User" + | "Subscriber" + | "Invoice" + | "Payment" + | "Ticket" + | "JobOrder" + | "Inventory" + | "Expense" + | "Account" + | "Report" + | "all"; + +/** + * Actions that can be performed on subjects. + * "manage" is a CASL shorthand that means all actions. + */ +export type AppActions = "create" | "read" | "update" | "delete" | "manage"; + +/** + * The application's CASL ability type. + * Parameterized with actions and subjects. + */ +export type AppAbility = PureAbility<[AppActions, AppSubjects]>; + +/** + * Re-export AbilityBuilder typed for AppAbility for convenience. + */ +export type AppAbilityBuilder = AbilityBuilder;