Prisma ORM Data Filtering with ReBAC
- Share:
Prisma ORM is a popular Node.js toolkit for working with relational databases. It offers a clean, type-safe API for querying and managing data. However, one feature it doesn’t include out-of-the-box is data filtering based on user permissions - a feature that’s critical for handling systems where different users should only see the data they’re authorized to access.
As your application grows, manually filtering data for every query becomes extremely difficult to maintain, and thus, quite error-prone. Applying Relationship-Based Access Control (ReBAC) at the data layer to enforce permissions automatically offers a more scalable solution.
In this article, you'll learn how to implement Prisma data filtering with ReBAC, allowing you to enforce fine-grained, instance-level access control directly within your Prisma queries, without cluttering your code with manual permission checks.
We’ll walk through building a simple project management API to demonstrate how this works in practice. This includes setting up a ReBAC policy, integrating it into your Express app, and seeing how it enables secure, permission-aware data access by default.
What Will We Build?
To demonstrate how Prisma data filtering works in a real-world scenario, we’ll build a simple project management API. This kind of application often deals with sensitive, team-specific information, making it a great use case for enforcing data visibility rules at the query level.
Our backend will be built with Node.js, Express, and Prisma ORM and will be used to implement a Project-Task hierarchy where access control is defined by organizational relationships. Rather than writing custom filtering logic for every query, we’ll use a relationship-aware filtering layer that restricts access automatically based on who the user is and which team they belong to.
Let’s take a look at the details of this implementation:
The Business Scenario:
- Project Alpha belongs to the Marketing team
- Project Beta belongs to the Engineering team
- Each project contains sensitive tasks that should only be visible to team members
- Task permissions inherit from project permissions
The Access Control Flow:
- John (Marketing team member) can access Project Alpha and all its tasks
- Mary (Engineering team member) can access Project Beta and all its tasks
- Neither user can see the other team's projects or tasks
- The system automatically filters all database queries based on these relationships
This architecture provides data isolation and simplifies permission management so that:
- Team members only see what they’re allowed to
- New tasks automatically inherit project-level permissions
- There is no need to define permissions for each resource manually
Before we start with the actual implementation, let’s examine what ReBAC is and why it is useful for implementing data filtering with Prisma.
What is Relationship-Based Access Control (ReBAC)
Access control is often managed using Role-Based Access Control (RBAC). While simple to implement, RBAC falls short when access needs to be defined at the resource level, not just based on user roles.
Take the example of a project management system: assigning someone a generic “Task Editor” role might grant them access to all tasks, even those from unrelated teams. To restrict access, you’d have to create increasingly specific roles like Project-Alpha-Task-Editor
, which quickly becomes unmanageable.
ReBAC (Relationship-Based Access Control) offers a more scalable alternative by granting permissions based on how a user is related to a specific resource, not just what role they have globally.
Here’s ReBAC vs. RBAC in a nutshell:
RBAC | ReBAC |
---|---|
Editors can edit all tasks | Project members can edit their own project’s tasks |
Global, static permissions | Instance-level, dynamic permissions |
Risk of overexposure or role explosion | Natural, relationship-driven access |
Requires manual role management | Automatic access inheritance through relationships |
Why ReBAC Works Well for Prisma Data Filtering:
ReBAC aligns naturally with relational data models, especially when using Prisma. Instead of writing complex WHERE
clauses for every query, access can be determined automatically through defined relationships in your data schema:
- User → Project: John is a member of Project Alpha
- Project → Task: Tasks belong to Project Alpha
- Derived Access: John can access all tasks in Project Alpha
This model makes permission management cleaner, safer, and fully automatic. When a new task is created under a project, the right users already have access—no extra logic or manual assignments required.
With ReBAC, Prisma queries can be filtered automatically, ensuring users only see the data they’re allowed to access.
Having covered the basics, let’s get to building!
Prerequisites and Tech Stack
To follow along with this implementation guide, you'll need:
Development Environment
- Node.js (v16+) and TypeScript - For building a type-safe backend API
- PostgreSQL- a database perfect for hierarchical data models
- Prisma ORM - Handles database access using a clean, type-safe API that maps well to ReBAC
Application Framework
- Express.js - a lightweight web framework used to expose API endpoints and handle requests
- @permitio/permit-prisma - an extension that connects your Prisma queries to a ReBAC policy system, enabling automatic query filtering.
Authorization Infrastructure
- A Permit.io account - Permit.io is a hosted platform for defining and managing access control policies (free tier available).
- A Local PDP (Policy Decision Point) - A local runtime service that evaluates access decisions at high speed during API requests.
Let’s start by mapping out the authorization model we plan to implement -
Planning the Authorization Model
It’s very important that we plan our authorization implementation first to ensure our Prisma data filtering setup is aligned with the needs of the application and is ready to scale.
In the case of this specific project, tasks are sensitive, and access should be scoped to each user’s team. To achieve that, we define a single relationship: project membership, and use it to drive access to all related tasks.
This lets us keep data access tied to business context (e.g., team membership), avoid managing permissions on every individual record, and ensure new tasks are automatically protected.
Let’s map out this model to resources, relationships, instance-level roles, role derivations, and, finally, access policies:
Resources (What We're Protecting)
- Project: A shared workspace that may contain business-critical data (timelines, budgets, client deliverables)
- Task: A work item that inherits sensitivity from its parent project
Relationships (How Access Flows)
- Projects contain Tasks: Tasks belong to a specific project
- Users are members of Projects: Each user is assigned to one or more projects
Instance-Level Roles
These roles define what a user can do with a specific resource instance:
project#Member
: Grants access to a specific projecttask#Member
: Grants access to a task (inherited automatically)
Role Derivation (The Power of ReBAC)
When a user is assigned as a project#Member
:
- They automatically become a
task#Member
for all tasks in that project - New tasks inherit access—no manual updates required
- Permissions stay in sync with the business structure
Access Policies
project#Member
→ canread
the projecttask#Member
→ canread
the task- No cross-project visibility—strict data isolation between teams
This model enables automatic Prisma data filtering based on real relationships, not static roles or hardcoded rules. The result is cleaner code and safer access patterns.
In the next section, we’ll set up the actual database schema to reflect these relationships and prepare the foundation for automatic filtering.
Implementation
Setting up the Database Schema
To support relationship-based access control, we need a schema that clearly defines how resources relate to each other. In our case, each Task belongs to a Project, and access to tasks is inherited from project membership. This relational structure is key to enabling Prisma data filtering through ReBAC.
Here’s how we define that in Prisma:
// schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Project {
id String @id @default(uuid())
name String
tasks Task[] // One-to-many relationship - critical for permission inheritance
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(uuid())
name String
description String?
projectId String // Foreign key establishes the parent-child relationship
project Project @relation(fields: [projectId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Once your schema is ready, run the Prisma migration to apply it:
npx prisma migrate dev --name init
This schema gives us a clean foundation for defining permissions: users connect to projects, and tasks connect to projects, allowing us to derive access automatically.
In the next step, we’ll seed the database with some test data that reflects clear access boundaries, perfect for validating our Prisma data filtering setup.
Creating Test Data with Authorization Boundaries
To demonstrate Prisma data filtering in action, we’ll seed our database with projects and tasks that belong to two distinct teams: Marketing and Engineering. This setup will let us verify that users only see the data they’re authorized to access. Here’s the seeding script:
// scripts/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database with test data...');
// Clear existing data to ensure clean test environment
await prisma.task.deleteMany({});
await prisma.project.deleteMany({});
// Create Project Alpha (Marketing team workspace)
const projectAlpha = await prisma.project.create({
data: {
id: 'project_alpha',
name: 'Marketing Campaign Q2',
}
});
// Create Project Beta (Engineering team workspace)
const projectBeta = await prisma.project.create({
data: {
id: 'project_beta',
name: 'API Development Sprint',
}
});
// Create tasks for Project Alpha - Marketing team data
await prisma.task.create({
data: {
id: 'task-alpha-1',
name: 'Campaign Strategy Planning',
description: 'Define target audience and messaging strategy',
projectId: projectAlpha.id
}
});
await prisma.task.create({
data: {
id: 'task-alpha-2',
name: 'Budget Allocation Review',
description: 'Review and approve Q2 marketing budget',
projectId: projectAlpha.id
}
});
// Create tasks for Project Beta - Engineering team data
await prisma.task.create({
data: {
id: 'task-beta-1',
name: 'API Endpoint Development',
description: 'Implement user authentication endpoints',
projectId: projectBeta.id
}
});
await prisma.task.create({
data: {
id: 'task-beta-2',
name: 'Database Schema Migration',
description: 'Update user table schema for new features',
projectId: projectBeta.id
}
});
console.log('âś… Database seeded successfully');
console.log('📊 Created 2 projects and 4 tasks with clear team boundaries');
}
main()
.catch((e) => {
console.error('❌ Seeding failed:', e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());
Run the script using:
npx prisma db seed
At this stage, without any access control, a query like prisma.task.findMany()
would return all tasks to any user. In the next section, we’ll integrate authorization logic that automatically ensures users see only the tasks they’re allowed to access.
Integrating the Permit-Prisma Extension
First, install the permit-prisma
extension:
npm install @permitio/permit-prisma
Create a configuration file for the extension:
import dotenv from 'dotenv';
dotenv.config();
export const permitConfig = {
token: process.env.PERMIT_API_KEY!,
pdp: process.env.PERMIT_PDP_URL || '<http://localhost:7766>',
throwOnError: true,
debug: true,
};
export const clientExtensionConfig = {
permitConfig,
enableAutomaticChecks: true,
enableDataFiltering: true, // Enable automatic query filtering
};
The key setting here is enableDataFiltering: true
. With this turned on, Prisma queries like findMany()
will automatically include filters based on the current user’s permissions - no manual WHERE
clauses required.
What Happens Under the Hood?
Once configured, every Prisma query executed through the extended client will:
- Check the user’s permissions based on their relationships
- Automatically filter results to include only authorized records
- Keep your controller code clean, secure, and free of manual permission logic
In the next step, we’ll define our ReBAC policy to describe how user-to-project and project-to-task relationships determine access.
Setting Up the ReBAC Policy
With the permit-prisma
extension in place, the next step is to define the authorization rules that determine who can access what.
Instead of hardcoding logic in your app, you'll define a ReBAC policy that describes how access flows through relationships, like project membership and task ownership.
We’ll use the Permit CLI to set this up quickly and declaratively -
Step 1: Install the Permit CLI
npm install -g @permitio/cli
Step 2: Login to Your Permit Environment
permit login
This will open your browser to authenticate with your Permit.io account and connect the CLI to your environment.
Step 3: Apply the Project-Task ReBAC Template
Permit provides a prebuilt template tailored for relationship-based data filtering with Prisma.
Apply it with:
permit env template apply --template orm-data-filtering
This command sets up everything you need:
- Resources:
project
andtask
- Relationships:
project
is the parent oftask
- Roles:
project#Member
,task#Member
- Role Derivation: project membership → task access
- Policies: Read permissions based on instance-level roles
With this structure, when a user is assigned to a project, they automatically gain access to all tasks within it, perfect for filtering queries in Prisma without writing any custom logic.
Step 4: Confirm in the UI
To visualize the policy, open the Permit.io Policy Editor and navigate to your environment. You’ll see:
- The resource hierarchy
- Role derivations
- Access rules for each role
These relationships are what power automatic Prisma data filtering behind the scenes. You define the rules once, and they apply consistently across all data queries in your app.
Next, we’ll integrate the policy-aware Prisma client into your Express app and demonstrate how the filtering works in practice.
Creating the Express API
Now that the ReBAC policy is in place, we can wire up our Express app to use the extended Prisma client. Rather than retrofitting authorization into existing controllers, we'll integrate thepermit-prisma
extension at the middleware level.
Step 1: Configure the Permit-Prisma Extension
We already created a clientExtensionConfig
earlier. Now, let’s use it to extend the Prisma client:
// src/config/permit-config.ts
export const clientExtensionConfig = {
permitConfig: {
token: process.env.PERMIT_API_KEY!,
pdp: process.env.PERMIT_PDP_URL || '<http://localhost:7766>',
debug: true,
},
enableAutomaticChecks: true,
enableDataFiltering: true, // This is the key setting for automatic query filtering
enableResourceSync: true,
};
enableDataFiltering
: true activates the automatic query modificationenableResourceSync
: true keeps Permit in sync with your database changes- The PDP URL points to either your local or cloud policy decision point
Step 2: Authentication Middleware with Authorization Integration
We'll create middleware that attaches the filtered Prisma client to each request and sets the current user context for access decisions:
// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
import createPermitClientExtension from '@permitio/permit-prisma';
import { clientExtensionConfig } from '../config/permit-config';
// Extend PrismaClient with Permit - this is where the magic happens
const prisma = new PrismaClient().$extends(
createPermitClientExtension(clientExtensionConfig)
);
// Enhanced Request type with user info
export interface AuthRequest extends Request {
user?: { id: string; email: string };
prisma?: typeof prisma;
}
export const authenticate = (req: AuthRequest, res: Response, next: NextFunction): void => {
// In a real app, this would verify a JWT token or session
// For demo purposes, we'll use a header to simulate different users
const userEmail = req.headers['x-user-email'] as string;
if (!userEmail) {
res.status(401).json({ error: 'Authentication required' });
return;
}
// Set the user in Permit context - this determines what data they can access
prisma.$permit.setUser(userEmail);
// Add user info to request for controllers to use
req.user = {
id: userEmail,
email: userEmail
};
// Add prisma client to request
req.prisma = prisma;
next();
};
Calling prisma.$permit.setUser()
ensures that every Prisma query on this request is automatically filtered based on that user’s permissions.
Step 3: Clean Controllers with Automatic Filtering
Let’s look at a controller that fetches all projects. There are no WHERE
clauses, no role checks—just a standard Prisma query.
// src/controllers/project.controller.ts
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
export const getProjects = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const prisma = req.prisma!;
// This looks like a normal Prisma query, but it's automatically filtered
const projects = await prisma.project.findMany();
res.json({
user: req.user?.email,
count: projects.length,
projects
});
} catch (error: any) {
console.error('Error getting projects:', error);
res.status(500).json({ error: error.message });
}
};
The same applies to tasks:
// src/controllers/task.controller.ts
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const prisma = req.prisma!;
const projectId = req.query.projectId as string;
const where = projectId ? { projectId } : undefined;
// Even with additional WHERE clauses, authorization filtering still applies
const tasks = await prisma.task.findMany({
where
});
res.json({
user: req.user?.email,
count: tasks.length,
tasks
});
} catch (error: any) {
console.error('Error getting tasks:', error);
res.status(500).json({ error: error.message });
}
};
Even with custom filters like projectId
, Prisma still enforces access control. Users will only receive records they’re allowed to see.
Step 4: Bringing It All Together
Here’s a minimal Express app setup:
// src/app.ts
import express from 'express';
import cors from 'cors';
import { authenticate } from './middleware/auth.middleware';
import { getProjects } from './controllers/project.controller';
import { getTasks } from './controllers/task.controller';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes - notice how clean these are
app.get('/api/projects', authenticate, getProjects);
app.get('/api/tasks', authenticate, getTasks);
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`đź”’ Authorization-enabled API ready for testing`);
});
Prisma queries look exactly as they would in a non-secure app, but the middleware and ReBAC policy fully enforce access. This is the power of clean, automatic filtering.
Testing the Implementation
With everything wired up, it’s time to test the system and confirm that Prisma data filtering is working as expected. We'll simulate requests from different users and see how the API returns only the records they’re allowed to access—automatically.
Test 1: John (Marketing Team Member)
Let’s simulate a request from John, who is a member of Project Alpha:
curl -H "X-User-Email: john@company.com" <http://localhost:3000/api/projects>
Response:
{
"user": "john@company.com",
"count": 1,
"projects": [
{
"id": "project_alpha",
"name": "Marketing Campaign Q2",
"createdAt": "2025-05-11T21:02:44.792Z",
"updatedAt": "2025-05-11T21:02:44.792Z"
}
]
}
Testing Task Inheritance:
curl -H "X-User-Email: john@company.com" <http://localhost:3000/api/tasks>
John will only see tasks from Project Alpha, demonstrating how task permissions inherit from project permissions through our ReBAC role derivation rules.
Test 2: Mary (Engineering Team Member)
Now test with Mary, a member of Project Beta:
curl -H "X-User-Email: mary@company.com" <http://localhost:3000/api/projects>
Response:
{
"user": "mary@company.com",
"count": 1,
"projects": [
{
"id": "project_beta",
"name": "API Development Sprint",
"createdAt": "2025-05-11T21:02:44.792Z",
"updatedAt": "2025-05-11T21:02:44.792Z"
}
]
}
What This Confirms
- Same endpoint, different results — Prisma queries are automatically filtered per user
- No custom logic — You didn’t have to write a single
WHERE
clause in your controllers - Permission inheritance works — Tasks inherit access from project membership
- Data isolation is enforced by default — Ideal for multi-tenant or team-based systems
Prisma data filtering, powered by ReBAC, gives you secure, clean, and maintainable access control at the query level, with no performance compromise or messy code.
Conclusion
In this tutorial, you’ve learned how to implement Prisma ORM data filtering using Relationship-Based Access Control (ReBAC)—a powerful model for automatically enforcing access rules based on real-world relationships between users and resources.
By combining Prisma with a ReBAC policy layer, we’ve created a secure, scalable API that:
- Filters database records automatically based on user permissions
- Keeps controller logic clean and free of repetitive authorization checks
- Enforces fine-grained access control across both projects and tasks
- Makes onboarding new users and resources seamless, thanks to permission inheritance
With this setup, you no longer need to worry about missing a filter or accidentally exposing sensitive data. The system applies the correct access rules at the query level—every time.
Want to Learn More?
If you're looking to go deeper into data filtering and access control best practices:
Prisma gives you an excellent developer experience. ReBAC makes it secure. Combined, they let you build systems that are both powerful and protected by design.
Written by
Gabriel L. Manor
Full-Stack Software Technical Leader | Security, JavaScript, DevRel, OPA | Writer and Public Speaker