feat(01-04): CASL permission definitions and ability factory
- Install @casl/ability for role-based access control
- Create src/lib/casl/types.ts with AppAbility, AppSubjects, AppActions types
- Create src/lib/casl/permissions.ts with permission matrix for all 5 roles
- Create src/lib/casl/ability.ts with defineAbilityFor() factory function
- Support multi-role users via additive union of permissions
- Super-admin bypasses all permission checks via can("manage", "all")
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "netforge",
|
"name": "netforge",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.8.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -336,6 +337,18 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
@@ -3076,6 +3089,41 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"seed": "npx tsx prisma/seed.ts"
|
"seed": "npx tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.8.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
111
src/lib/casl/ability.ts
Normal file
111
src/lib/casl/ability.ts
Normal file
@@ -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<AppAbility>(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<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
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<string, unknown> | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PureAbility<[AppActions, AppSubjects]>(allRules);
|
||||||
|
}
|
||||||
108
src/lib/casl/permissions.ts
Normal file
108
src/lib/casl/permissions.ts
Normal file
@@ -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<AppAbility>(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();
|
||||||
|
}
|
||||||
39
src/lib/casl/types.ts
Normal file
39
src/lib/casl/types.ts
Normal file
@@ -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<AppAbility>;
|
||||||
Reference in New Issue
Block a user