@damzoindistress/permissions-builder
provides a centralized and flexible approach to managing permissions across your app's resources. Built on top of CASL and zod, it extends the expressive power of permissions management with MongoDB's query language.
- Built on CASL: Utilizes CASL's established abilities for permissions management.
- Centralized Configuration: Manage permissions related to a specific resource in a single place.
- Flexibility: Take advantage of MongoDB's query language to create intricate permission rules based on object properties.
- Reusability: Consistently apply the same permission rules throughout different areas of your app.
npm install @damzoindistress/permissions-builder zod
You'll also need to install zod as it's a peer dependency.
To initialize the permissions, utilize the setupPermissionsContext
function:
import { setupPermissionsContext } from "@damzoindistress/permissions-builder";
import { z } from "zod";
const ContextSchema = z.object({
userId: z.string(),
});
const { defineResource, createPermissionsBuilder } = setupPermissionsContext({
contextSchema: ContextSchema, // Your zod schema goes here
});
This method sets up the context required for defining resources and creating permissions. This context will be available for every resource to access when defining permissions. You can define the context as whatever object you want, for instance, it might be the schema of a user in your app. Do note that when you've set up your permissions and ready to check whether they're valid, you will have to provide data that matches whatever context schema.
Next, use the defineResource
method to define a resource and its associated permissions. Like the context, the resource schema must be a zod object schema:
const WorkspaceSchema = z.object({
createdAt: z.date(),
name: z.string(),
id: z.string(),
createdBy: z.string(),
});
export const buildPermissions = createPermissionsBuilder({
workspace: defineResource({
actions: z.enum(["read", "update", "delete"]),
schema: WorkspaceSchema, // Your schema goes here
defineAbility: function ({ can, cannot, context }) {
// Define your rules here using `can` and `cannot` functions.
},
}),
// You can define more resources as needed, e.g files, documents.
});
When defining abilities, you have access to the MongoDB-like query language operators to shape your rules.
$eq
and$ne
: Check if a value equals or doesn't equal a specified value.$lt
and$lte
: Check if a value is less than or less than and equal to a specified value.$gt
and$gte
: Check if a value is greater than or greater than and equal to a specified value.$in
and$nin
: Ensure that an object's property matches any of the specified array values.$nin
is the opposite of$in
.$all
: Ensure an object's property contains all elements from a specified array.$size
: Confirm that an array's length matches a specified value.$regex
: Test an object's property value with a regular expression.$exists
: Check if a particular property exists in an object.$elemMatch
: Examine nested elements' structure and ensure they match specified criteria.
For a more in-depth explanation and usage of these operators, you can refer to MongoDB's documentation.
The can
and cannot
functions from CASL provide the primary means to define your permissions:
defineAbility: function ({ can, cannot, context }) {
const bannedUsers = ["steve"]
can("read"); // Allows reading
cannot("update", { createdBy: { $in: bannedUsers } }); // Disallows updating for banned users
}
For a deep dive into how to use these functions (along with more examples), check out the CASL documentation.
When you invoke buildPermissions
, it returns an ability instance with three main methods: can
, cannot
, and throwErrorIfCannot
. You would need to pass in data with the context schema you defined in order to initialize it. The buildPermissions
function turns your permission rules into an internal index, so that the permission checkers run really fast.
const currentUserId = "james";
const ability = buildPermissions({
userId: currentUserId,
});
const workspace = {
name: "test",
createdAt: new Date(),
id: "jeoobeo3",
createdBy: currentUserId,
};
const canReadWorkspace = ability.can({
subject: "workspace",
action: "read",
data: workspace,
});
The can
method checks if a certain action on a subject is permissible.
Usage:
const allowed = ability.can({
subject: "workspace",
action: "read",
data: workspace,
});
In this example, it checks if the workspace
can be read. If the user has permission, it returns true
, otherwise false
.
The cannot
method is the opposite of the can
method. It returns true
if the user cannot perform the action and false
if they can.
Usage:
const workspace = {
id: "jeoobeo3",
name: "test",
createdAt: new Date(),
createdBy: "james",
};
const notAllowed = ability.cannot({
subject: "workspace",
action: "update",
data: workspace,
});
While can
and cannot
return boolean values, the throwErrorIfCannot
method will throw a ForbiddenError if the user doesn't have the permission. It's particularly useful in scenarios where an operation should not proceed under any circumstance without the required permission.
Usage:
try {
ability.throwErrorIfCannot({
subject: "workspace",
action: "update",
data: workspace,
});
// proceed with the update
} catch (error) {
console.error("Permission denied:", error.message);
}
If you want to customise the error message, you can pass a reason
string as a third parameter to the cannot
function when defining the permissions for a resource:
defineAbility: function ({ can, cannot, context }) {
const bannedUsers = ["steve"];
cannot(["update", "delete", "read"], { createdBy: { $in: bannedUsers } }, "because steve");
}
Let's say you want to ensure that the user who created a workspace can perform all CRUD operations on it:
const currentUserId = "james";
const ability = buildPermissions({
userId: currentUserId,
});
if (ability.can({ subject: "workspace", action: "read", data: workspace })) {
// User can read the workspace
}
if (ability.can({ subject: "workspace", action: "update", data: workspace })) {
// User can update the workspace
}
// ... you can add similar checks for "delete" and other actions.
In another scenario, you might want guests to read workspaces, but not modify them:
const guestAbility = buildPermissions({
userId: "david",
});
if (guestAbility.can({ subject: "workspace", action: "read", data: workspace })) {
// Guest can read the workspace
}
if (guestAbility.cannot({ subject: "workspace", action: "update", data: workspace })) {
// Guest cannot update the workspace, so don't show the update button or functionality
}
In some cases, there might be workspaces created by specific users where no one is allowed to perform any operations:
const ability = buildPermissions({
userId: "mark",
});
["read", "update", "delete"].forEach((action) => {
if (ability.cannot({ subject: "workspace", action, data: workspace })) {
console.log(`User cannot ${action} this workspace.`);
}
});
The permission system allows developers to create sophisticated rules and enforce them consistently across different parts of an app.