Implementing Prisma RBAC: Fine-Grained Prisma Permissions
- Share:
Prisma has become a go-to ORM for developers who want to simplify database workflows in their applications. While Prisma simplifies database interactions, it lacks built-in tools for handling fine-grained access control and authorization. This limitation can pose challenges as your application scales and needs stricter control over who can access sensitive data.
Most applications outgrow basic CRUD operations as they scale - a growing SaaS application, for example, might need to ensure only managers can approve financial reports while employees can only view their assigned tasks. This requires more than just CRUD—it demands fine-grained, role-based permissions that account for resource-level and instance-level access across the application.
In this guide, I’ll share how we can leverage Prisma Client Extensions (a feature in Prisma that allows developers to customize the Prisma Client) and Permit.io to create a scalable authorization layer that feels natural to your database operations.
What We’ll Be Building
In this guide, we will show you how to create a generic resource management API system where resources can represent anything (documents, products, assets). The authorization layer using Permit.io will be implemented through Prisma Client Extensions, making the system reusable across different projects.
By the end, you’ll know how to:
- Set up Permit for role-based and attribute-based access control.
- Extend the Prisma Client with custom authorization logic using Client Extensions.
- Test your implementation to ensure secure and accurate access control.
Ready? Let’s get started!
Prerequisites
To follow along with this guide, you must have:
- Prisma installed
- VS Code or your favorite IDE
What are Prisma Client Extensions?
Let’s go over the basics of Prisma:
Prisma is an ORM (Object-Relational Mapping) tool that simplifies database interactions. You can extend its functionality using Prisma Client extensions, which allow you to add custom logic to your models, queries, and client-level methods.
Here’s an example of a Prisma client extension:
// Example of a Client Extension
const permitExtension = Prisma.defineExtension((client) => {
return client.$extends({
query: {
resource: {
async findMany({ args, query }) {
// Add authorization logic before database queries
return query(args);
},
},
},
});
});
Prisma authorization brings several key benefits to your database operations. It allows you to intercept actions and add custom logic for authorization, which can be reused across different queries. This approach is type-safe and maintains Prisma’s API structure, making integration seamless.
With these advantages in mind, let’s implement the Permit configuration and Client Extension in the next steps.
Where Does Permit Come in?
Permit is an authorization-as-a-service platform that provides fine-grained permission management. With Permit, you don’t have to manually build authorization for your application from scratch but rather focus on your product’s offering.
The core concepts of Authorization when using Permit include:
- Resources: What we're protecting
- Actions: What can be performed on said resources
- Users: Who can perform those actions
- Policies: Sets of rules defining permissions
First, let’s create our project structure and install the necessary dependencies:
mkdir prisma-permit-auth
cd prisma-permit-auth
npm init -y
Also, these packages for the TypeScript-based API
npm install typescript @types/node ts-node prisma @prisma/client express @types/express dotenv
Install the Permit SDK
To start, you need to install the Permit.io SDK in your project. We’ll use the SDK to check permissions before database operations using the Prisma authorization with the client extension.
Open your terminal and run the following command:
npm install permitio --save
This will add the Permit.io SDK to your project, allowing you to integrate authorization management seamlessly.
Then create TypeScript config:
npx tsc --init
The project structure for this application is as shown in the GitHub repository.
Flow of How the Resource Management API Works with Prisma Authorization and Permit
In this project, the flow of the application begins when a user makes an API request, such as a GET request to retrieve resources. This request is handled by an Express endpoint, where the Prisma Client Extension is triggered to intercept the database query. The extension then calls Permit.io to verify the user’s permissions. If the user is authorized, Prisma proceeds to execute the requested database operation, and a response is sent back to the user. However, if the user is deemed unauthorized, the request is promptly rejected, ensuring secure access to the application’s resources.
Application flow
Building the Asset Management API
To create a secure and scalable Asset Management API, we’ll start by integrating the Prisma Client Extensions with Permit.io. This integration forms the foundation of our resource-level authorization, ensuring that every access request is evaluated against secure policies.
Let’s begin by implementing the Prisma Client Extension, enabling seamless authorization checks for our resources.
Prisma Setup
To kick things off, we need to set up a Prisma project as the backbone of our asset management system. Prisma helps manage database workflows efficiently and, by defining models in the schema.prisma
file, we can create a structured representation of our data.
For a generic asset management system, you might define a Resource
model as follows:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Resource {
id String @id @default(cuid())
name String
description String
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
The IDs in our Asset Management API are automatically assigned by Prisma using the @default(cuid())
attribute in our schema models. CUIDs (Collision-resistant Unique IDentifiers) provide benefits such as:
- Collision-resistant and Chronologically Sortable: The Asset Management API uses CUIDs (
@default(cuid())
) to ensure mathematically unique IDs across distributed systems. These IDs contain timestamp information, making them ideal for asset tracking, auditing, and efficient database indexing. Example:cm5i8anddbabdanad7da9aa
. - URL-safe: The IDs are designed with URL-friendly characters, making them perfect for RESTful API endpoints. For example,
/api/assets/cm5i8j0050000nt1glj4ccpgy
. - Distributed Systems Ready: The system operates seamlessly across multiple servers without central coordination. It scales horizontally, ensuring reliability and efficiency for asset management.
Setting up Neon
Neon is a serverless Postgres database designed to help build reliable and scalable applications faster. We will be using it to host the database that is set up to work with the Prisma client extension.
To get started with Neon, create an account with your preferred authentication method
Create a Neon account
In your dashboard, create a new project like so
Setup a new project
Enter the name of the project, Postgres version (16 recommended), and then click on Create project.
After you create the project, the Quickstart modal comes up for connection to the local database.
Click on Postgres and then copy the database connection URL as shown below:
Neon database URL
Then, enter the database connection URL in your .env
file.
DATABASE_URL="your-neon-database-url" PORT=3000
Setting up Permit
After creating an account on Permit (Permit is free to use—simply sign up and log in to access its features), navigate to the Development Home section of your Permit.io dashboard to create your policy for authorization. This policy will serve as the foundation for implementing resource-level access control in the application.
Create Policy on Permit Dashboard
Understanding Fine-Grained Authorization
ReBAC focuses on the relationships between resources and identities, allowing for more flexible and context-specific access control policies.
For the purpose of this guide, we will use ReBAC to define access based on groups (e.g., “Who has access to which category”). However, the true power of ReBAC lies in role derivation, where permissions can propagate to individual resources related to a category without needing explicit attribution (e.g., allowing access to a single asset tied to a category).
What we are implementing here using ReBAC for groups can also be achieved using multi-tenancy RBAC, which may be more suitable for certain use cases. If you’re interested in exploring ReBAC’s full potential, including advanced role derivation techniques,check out this documentation, which outlines how ReBAC can enable dynamic permission modeling, such as in Google Drive.
The approach we’re using in this guide involves:
- Role-Based Access Control (RBAC):
We will first implement RBAC, where access to resources is controlled based on predefined roles (e.g., Admin, Editor, Viewer). Each role determines what actions users can perform on all resources globally. - Relationship-Based Access Control (ReBAC):
Then we will extend it to ReBAC. This involves adding resource-level roles and permissions for specific resource instances (e.g.,financial-reports
,software-licenses
). ReBAC allows us to manage granular access control for specific assets within your application.
This approach ensures a smooth transition from a general authorization layer to fine-grained, instance-level control.
The following are steps to set up and configure Permit:
Navigate to the Resource tab: Go to the Policy tab in Permit’s sidebar, click Resources, and then click Create a Resource.
Create a Resource for the Assets API
Resources represent the objects in your system that you want to manage access to. To secure these resources, you need to define what they are and the actions that can be performed on them. You can create a Resource
model in Prisma like this:
Create a Resource
Input the resource name asset and designate the actions; “read”, “create”, “update”, “delete”, and keep the ReBAC Option empty.
Permissions consist of standard roles like admin, editor, and viewer, adhering to Role-Based Access Control principles.
Save resource
View Created Roles: Navigate to the Roles tab to view all the roles for your application. Permit creates the default roles (admin, editor, user).
Note that the Permit includes default roles such as admin, editor, and viewer based on Role-Based Access Control (RBAC). For ReBAC, you can extend these roles by assigning them to specific resource instances.
View roles
Assign Policies: After setting up the asset resource and roles, you can assign policies that control which users can perform specific actions based on their role in the asset management system. To do this, click on the Policy Editor in the dashboard.
Set Policy
This ensures that:
- Admin can perform all operations (create, read, update, delete) on any asset.
- Editors can create new assets, view existing ones, and update asset information but cannot delete assets.
- Viewers can only read asset information, maintaining a clear separation of access levels.
The Prisma Client Extension enforces these policies, which check permissions via Permit before executing any database operations. For example, when updating an asset, the extension checks if the user has the ‘update’ permission on the ‘asset’ resource:
const assetExtension = Prisma.defineExtension({
query: {
asset: {
async update({ args, query }) {
// Editor and Manager can update
const allowed = await permit.check(args.user, 'update', 'asset');
if (!allowed) {
throw new Error('Unauthorized');
}
return query(args);
},
},
},
});
ReBAC Policy Configuration
Earlier, when creating the Policy, we did not create specific roles. However, for our use case, we create these roles:
- manager
- accountant
- developer
Then also update the Policy Editor
Assigning Instance Roles in Permit
To assign roles to a user, navigate to the Permit dashboard and click on the “Directory” tab in the sidebar. Then, click on the “Users” tab to view all users in your workspace. Select the user you want to assign a role to, click the three dots, and choose “Edit.”
From the side panel, scroll down to the “Instance Access” section, select the resource and role you want to assign the user, and then enter the resource instance key. Click “Save” to assign the role to the user.
After successful registration, we’ll assign specific roles via the Permit dashboard:
- Go to Directory → Users
- Find the email of the new user you registered
- Click Edit
- Assign roles:
- Manager for financial-reports
- Editor for software-licenses
- Viewer for other categories
Add Instance Access
Creating Asset Instances
Navigate to the Directory tab in Permit’s sidebar and switch to the “Instances” tab. Click “Add Instance” to begin creating your asset instances. Select the “asset” resource we created earlier.
For our Asset Management system, create these specific instances with these instance keys
- financial-reports
- software-licenses
- office-supplies
- marketing-materials
- hardware-inventory
Select the resource type, enter the instance keys above for each instance, and leave the tenant option as Default Tenant.
Create Instance
Each instance represents a category of assets in our management system. These instances work with our Prisma Client Extension to enforce role-based permissions at the category level. When a Manager, Editor, or Viewer interacts with assets in these categories, Permit.io checks their permissions against these specific instances.
The instance keys are straightforward category names like financial-reports
and software-licenses
, making it easy to track and manage permissions across different asset categories in our system. When a Manager works with financial reports or an Editor accesses software licenses, Permit.io checks their permissions against these specific instance keys, ensuring proper access control at the category level.
This maps perfectly with our Prisma Client Extension because when we create a new asset in our database, we’ll also create its corresponding instance in Permit.
async create({ args, query }) {
const { ownerId, category, name, description } = args.data;
try {
// Check category-level permission
const categoryAllowed = await permit.check(ownerId, 'create', category);
if (!categoryAllowed) {
throw new Error('Unauthorized to create assets in this category.');
}
// Create in Prisma
const newAsset = await query(args);
// Create in Permit
await permit.api.resources.createInstance('asset', newAsset.id, {
name: newAsset.name,
description: newAsset.description,
category: newAsset.category,
});
return newAsset;
} catch (error) {
console.error('Error during asset creation:', error);
throw new Error(error.message || 'Internal server error');
}
}
Now that you’ve set up Permit with resources, roles, and instances, it will enforce these rules, restricting actions based on each user’s relationship with the asset.
Setting up Permit in the Asset Management API
In this section, we integrate Permit with Prisma client extensions and use the Permit’s Policy Decision Point PDP.
- Step 1: Implement the Permit Prisma Extension
Create thepermitExtension.ts
file insrc/extensions
directory and define the Prisma extension. This extension will enforce category-level and resource-level permissions when querying or modifying assets.
import { Prisma } from '@prisma/client';
import { permit } from '../lib/permit';
export const permitExtension = Prisma.defineExtension({
name: 'permitAuthorization',
query: {
resource: {
async create({ args, query }) {
try {
// Category-level permission check
const allowed = await permit.check(
args.data.ownerId,
'create',
args.data.category
);
if (!allowed) {
throw new Error('Unauthorized: Insufficient category permissions.');
}
return query(args);
} catch (error) {
console.error('Permit check error:', error);
throw new Error('Unauthorized');
}
},
async findMany({ args, query }) {
const { ownerId, category, assetId } = args.where;
try {
// Check permissions based on the presence of an assetId
const allowed = assetId
? await permit.check(ownerId, 'read', `asset:${assetId}`)
: await permit.check(ownerId, 'read', category);
if (!allowed) {
throw new Error('Unauthorized: Permission denied.');
}
return query(args);
} catch (error) {
console.error('Permit check error:', error);
throw new Error('Unauthorized');
}
},
},
},
});
This sets up our foundation for resource-level authorization and permission checks.
- Step 2: Extend the Prisma Client
Next, we will implement the extended Prisma client and API routes to use this extension using permitExtension
above.
Create a prisma.ts
file and enter this code in the src/lib
directory.
import { PrismaClient } from '@prisma/client';
import { permitExtension } from '../extensions/permitExtension';
const prisma = new PrismaClient();
const extendedPrisma = prisma.$extends(permitExtension);
export default extendedPrisma;
- Step 3: Create API Routes
We implement the API endpoints (CRUD) that will use this extension. Let’s create our API endpoints that utilize our Permit-authorized Prisma Client Extension.
Enter this code at src/routes/asset.ts
import express from 'express';
import prisma from '../lib/prisma';
const router = express.Router();
router.post('/assets', async (req, res) => {
const { name, description, category, ownerId } = req.body;
try {
const asset = await prisma.resource.create({
data: {
name,
description,
category,
ownerId,
},
});
res.json(asset);
} catch (err) {
const error = err as Error;
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
router.get('/assets', async (req, res) => {
const { category } = req.query;
try {
const assets = await prisma.resource.findMany({
where: {
category: category as string,
},
});
res.json(assets);
} catch (err) {
const error = err as Error;
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
router.put('/assets/:id', async (req, res) => {
const { id } = req.params;
const { name, description, category, ownerId } = req.body;
try {
const asset = await prisma.resource.update({
where: { id },
data: {
name,
description,
category,
ownerId,
},
});
res.json(asset);
} catch (err) {
const error = err as Error;
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
router.delete('/assets/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.resource.delete({
where: { id },
});
res.json({ message: 'Asset deleted successfully' });
} catch (err) {
const error = err as Error;
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
export default router;
- Step 4: Prisma Schema Setup
Theschema.prisma
with the Resource model has been setup earlier like so:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Resource {
id String @id @default(cuid())
name String
description String
category String
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Push the Prisma schema to the Neon Postgres database with this command:
npx prisma db push
When successful, it creates the table and record on the database based on the Prisma schema we defined; you can view it in your Neon project dashboard.
Neon database
Then generate the Prisma Client using the Prisma CLI
npx prisma generate
If successful, you should see a similar response in your terminal. This shows that Prisma Client has been generated, and we can now interact with our database using Prisma’s type-safe queries.
Prisma client generated
This is the table we’re using to store our assets (financial-reports
, software-licenses
, office-supplies
, marketing-materials
, hardware-inventory
).
- Step 5: Run the Application
Then we connect these routes to our main application in the src/index.ts
file.
import express from 'express';
import dotenv from 'dotenv';
import assetRouter from './routes/asset';
dotenv.config();
const app = express();
app.use(express.json());
app.use('/api', assetRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
To fully utilize ReBAC in your implementation, you need to run a local Policy Decision Point (PDP), as the cloud PDP supports only Role-Based Access Control (RBAC) policies. The local PDP offers the additional capabilities required for ReBAC, including improved performance and flexibility.
To deploy and run a local PDP using Docker:
Download the Permit PDP Docker image from Docker Hub.
bash docker pull permitio/pdp:latest
Start the PDP container with this command:
bash docker run -d --name permit-pdp -p 7766:7766 permitio/pdp:latest
This command:
- Runs the PDP in detached mode (
d
). - Exposes it on port
7766
.
Configure the Permit SDK to use the local PDP by replacing the
pdp
field in your code:
import { Permit } from 'permit';
const permit = new Permit({
token: '',
pdp: 'http://localhost:7766', // Local PDP URL
});
export default permit;
Now let’s run the application, start the server, and test the endpoints
npm run dev
We can make requests to the endpoints with Thunder Client extension in VS Code on http://localhost:3000
POST /api/assets
{
"name": "Q4 Financial Report",
"description": "End of year financial documentation",
"category": "financial-reports",
"ownerId": "user123"
}
The Prisma Client Extension automatically enforces these permission checks before any database operation occurs.
Syncing Users with Permit
Before testing our asset endpoints, we must set up user synchronization between our API and Permit. When users register in our system, we’ll automatically sync them to Permit and assign default permissions.
First, let’s create our user sync functionality at src/lib/userSync.ts
import { permit } from './permit'
export async function syncUserToPermit(userId: string, email: string) {
try {
// Create user in Permit
await permit.api.createUser({
key: userId,
email: email
})
// Assign default viewer role
await permit.api.assignRole({
user: userId,
role: 'viewer',
tenant: 'default'
})
return true
} catch (error) {
console.error('Error syncing user to Permit:', error)
return false
}
}
Now, let’s create the user registration endpoint at src/routes/user.ts
import express from 'express'
import prisma from '../lib/prisma'
import { syncUserToPermit } from '../lib/userSync'
const router = express.Router()
router.post('/users', async (req, res) => {
const { email } = req.body
try {
// Create user in database
const user = await prisma.user.create({
data: { email }
})
// Sync to Permit
await syncUserToPermit(user.id, email)
res.json(user)
} catch (error) {
res.status(500).json({ error: 'Failed to create user' })
}
})
export default router
After users are registered and synced, we can assign specific roles through the Permit dashboard:
- Navigate to Directory → Users
- Select a user
- Click Edit
- Under Instance Access, assign roles for each asset category:
- Manager for financial-reports
- Editor for software-licenses
- Viewer for other categories
Enforcing the Policy
Let’s test the user registration flow. Start the server:
npm run dev
Create a new user with Thunder Client: POST
http://localhost:3000/api/users
{
"email": "manager@assets.com"
}
This will:
- Create a user in our database
- Sync user to Permit
- Assign default viewer role
When we run the app and the server is running, and then the POST request to the API, to create a new user, the 200 OK response is as follows, with the ID of the new user created in the Asset management system.
Server running
To verify that the function works, sign up a new user in your app and check the Permit dashboard to see if the user has been synced. You should see the user listed under the default tenant with the viewer
role assigned.
We can also see that the new user is registered on the database through Permit authorization and the client extension
Let’s test our Asset Management API with the newly configured permissions. The new user now has:
Manager role with access to:
financial-reports
office-supplies
We will demonstrate this through API calls. First, confirm your user ID from the previous registration, then we’ll make these API calls using Thunder Client:
You can get the userID from the database (Neon Postgres in this case) or the Permit dashboard in the User Key section under the Directory tab, as shown below
Get user ID
- Create Financial Report (Should Succeed): POST http://localhost:3000/api/assets
Make a POST request to the API URL (when the app is running) using Thunder Client VS Code extension or any other tool you prefer (Postman, Reqbin)
{
"name": "Q4 Financial Report",
"description": "End of year financial documentation",
"category": "financial-reports",
"ownerId": "your-user-id"
}
- Create Office Supply Request (Should Succeed): POST http://localhost:3000/api/assets
{
"name": "Office Chairs Request",
"description": "New chairs for dev team",
"category": "office-supplies",
"ownerId": "your-user-id"
}
- Create Software License (Should Fail): POST http://localhost:3000/api/assets
{
"name": "Visual Studio License",
"description": "IDE License",
"category": "software-licenses",
"ownerId": "your-user-id"
}
Output from command line
When we make the above request to the Asset API, the Prisma client extension runs a check and performs a Prisma authorization to ensure the user can access the resource system, which, in this case, does now. Hence, the error in the image above indicates the Prisma authorization check is working correctly, and the user doesn’t have the required permissions in Permit for creating assets in the “financial-reports” category.
The Prisma Authorization flow in our Asset Management API showcases a powerful integration between Prisma Client Extensions and Permit.io. When a request hits our API, the Prisma Client Extension intercepts the database operation and triggers a permission check through Permit.io before any data access occurs.
This happens in our permitExtension.ts
where each database operation (create, read, update, delete) first validates the user’s permissions against their assigned roles and instance access. For example, when our manager attempts to create a financial report, the extension checks if they have the ‘create’ permission for the ‘financial-reports’ category through Permit. Only after successful authorization will Prisma proceed with the actual database operation. This seamless integration ensures consistent access control across the entire application without cluttering our route handlers with authorization logic.
Follow these steps to run the application locally and try out the Prisma-Permit authorization implementation:
Clone the project GitHub repository and install dependencies.
git clone <https://github.com/Tabintel/prisma-permit-auth.git>
cd prisma-permit-auth
npm install
Create a .env
file in your project root to configure the environment variables. Get your PERMIT_SDK_KEY
from the Permit dashboard after creating your project.
DATABASE_URL="postgresql://your-database-url"
PERMIT_SDK_KEY="your-permit-sdk-key"
PORT=3000
Then run this command to setup the database connection and Prisma migrations
npx prisma migrate dev
This creates the User model for authentication, a Resource model for asset management, and the necessary database tables.
And now, run the application with
npm run dev
The API will be available at http://localhost:3000
- http://localhost:3000/api/users - to register a new user
- http://localhost:3000/api/assets - to make changes or updates to the asset API
Next Steps
To extend the functionality of everything we’ve built, I suggest the following:
- Add real-time asset tracking using WebSockets: When assets are created or updated, notify all connected users in real time. Perfect for teams managing shared resources!
- Setup a CLI tool for asset management: Create a command-line tool for quick asset operations. Great for automation and scripting!
These additions will make your asset management system more interactive and give you deeper insights into how permissions flow through your application!
Wrapping Up
Integrating Permit with Prisma provides a scalable and maintainable solution for application authorization. By leveraging Prisma Client Extensions, you can centralize and streamline your access control logic while ensuring your application remains secure.
With this guide, you now have a blueprint for implementing authorization in your Prisma-powered projects. Try it out, and let me know how it goes!
Got questions? Join our Slack community, where there are hundreds of devs building and implementing authorization.
Written by
Ekemini Samuel
Building efficient software and creating clear technical content and documentation