SaaS User Permissions: RBAC vs ABAC, Casbin, and OPA Integration
Design production SaaS permission systems: RBAC with role hierarchies, ABAC for attribute-based rules, Casbin policy engine in Node.js, OPA for distributed authorization, and permission UI patterns.
Authorization is the question your system asks on every request: "Is this user allowed to do this thing?" Getting it wrong in either direction is expensive โ too permissive exposes data, too restrictive breaks legitimate workflows. Most SaaS products start with simple role checks (user.role === 'admin') and end up with a tangle of if-statements scattered across hundreds of routes. A permission system built intentionally from the start scales without that technical debt.
This post covers three authorization models โ RBAC for most SaaS products, ABAC for fine-grained rules, and policy-as-code with Casbin and OPA โ with production TypeScript implementations.
The Three Models
| Model | What it controls | Best for |
|---|---|---|
| RBAC | Role โ Permission mappings | Most SaaS products, clean mental model |
| ABAC | Subject + Object + Action + Environment | Complex rules involving resource attributes |
| ReBAC | Relationships between users and resources | Google Drive-style sharing, hierarchical access |
| Policy-as-code | Externalized, version-controlled rules | Large teams, compliance requirements |
1. RBAC: Role-Based Access Control
Schema
-- Roles are defined per tenant (custom roles supported)
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- 'admin', 'editor', 'viewer', 'billing-manager'
is_system BOOLEAN NOT NULL DEFAULT false, -- System roles can't be deleted
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, name)
);
-- Permissions are application-defined (not per-tenant)
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource TEXT NOT NULL, -- 'post', 'user', 'billing', 'report'
action TEXT NOT NULL, -- 'create', 'read', 'update', 'delete', 'publish', 'export'
description TEXT,
UNIQUE (resource, action)
);
-- Many-to-many: roles have permissions
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- Users are assigned roles within a tenant
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id, role_id)
);
-- Efficient permission lookup
CREATE INDEX idx_user_roles_lookup ON user_roles (user_id, tenant_id);
-- Materialized view: flat user โ permissions mapping (refresh on role change)
CREATE MATERIALIZED VIEW user_permissions AS
SELECT DISTINCT
ur.user_id,
ur.tenant_id,
p.resource,
p.action
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id;
CREATE UNIQUE INDEX ON user_permissions (user_id, tenant_id, resource, action);
CREATE INDEX ON user_permissions (user_id, tenant_id);
Permission Constants and Checker
// src/lib/permissions/constants.ts
export const PERMISSIONS = {
posts: {
create: 'post:create',
read: 'post:read',
update: 'post:update',
delete: 'post:delete',
publish: 'post:publish',
export: 'post:export',
},
users: {
invite: 'user:invite',
read: 'user:read',
update: 'user:update',
delete: 'user:delete',
manageRoles: 'user:manage_roles',
},
billing: {
read: 'billing:read',
manage: 'billing:manage',
},
reports: {
read: 'report:read',
create: 'report:create',
export: 'report:export',
},
} as const;
// Derive permission type from constants
type PermissionKeys<T> = T extends Record<string, string>
? T[keyof T]
: T extends Record<string, Record<string, string>>
? { [K in keyof T]: T[K][keyof T[K]] }[keyof T]
: never;
export type Permission = PermissionKeys<typeof PERMISSIONS>;
// src/lib/permissions/checker.ts
import { db } from '../db';
import { redis } from '../redis';
const PERMISSION_CACHE_TTL = 300; // 5 minutes
export async function getUserPermissions(
userId: string,
tenantId: string
): Promise<Set<string>> {
const cacheKey = `perms:${userId}:${tenantId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return new Set(JSON.parse(cached) as string[]);
}
const rows = await db.$queryRaw<Array<{ resource: string; action: string }>>`
SELECT resource, action
FROM user_permissions
WHERE user_id = ${userId}::uuid
AND tenant_id = ${tenantId}::uuid
`;
const permissions = new Set(rows.map((r) => `${r.resource}:${r.action}`));
await redis.setEx(cacheKey, PERMISSION_CACHE_TTL, JSON.stringify([...permissions]));
return permissions;
}
export async function hasPermission(
userId: string,
tenantId: string,
permission: string
): Promise<boolean> {
const perms = await getUserPermissions(userId, tenantId);
return perms.has(permission) || perms.has('*:*'); // Superadmin wildcard
}
export async function requirePermission(
userId: string,
tenantId: string,
permission: string
): Promise<void> {
const allowed = await hasPermission(userId, tenantId, permission);
if (!allowed) {
throw new ForbiddenError(`Missing permission: ${permission}`);
}
}
// Invalidate on role change
export async function invalidatePermissionCache(
userId: string,
tenantId: string
): Promise<void> {
await redis.del(`perms:${userId}:${tenantId}`);
await db.$executeRaw`
REFRESH MATERIALIZED VIEW CONCURRENTLY user_permissions
`;
}
Permission Guard Middleware
// src/middleware/permission.middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { hasPermission } from '../lib/permissions/checker';
export function withPermission(permission: string) {
return async (
req: NextRequest,
handler: (req: NextRequest) => Promise<NextResponse>
): Promise<NextResponse> => {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const allowed = await hasPermission(
session.user.id,
session.user.tenantId,
permission
);
if (!allowed) {
return NextResponse.json(
{ error: 'Forbidden', required: permission },
{ status: 403 }
);
}
return handler(req);
};
}
// Usage in route handlers:
// export const POST = withPermission('post:create')(async (req) => { ... });
React Hook for UI Permission Checks
// src/hooks/usePermissions.ts
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export function usePermissions() {
const { data: session } = useSession();
const { data: permissions } = useQuery({
queryKey: ['permissions', session?.user?.id],
queryFn: async () => {
const res = await fetch('/api/auth/permissions');
return res.json() as Promise<string[]>;
},
enabled: !!session?.user,
staleTime: 5 * 60 * 1000,
});
const permSet = new Set(permissions ?? []);
return {
can: (permission: string) => permSet.has(permission) || permSet.has('*:*'),
canAny: (...perms: string[]) => perms.some((p) => permSet.has(p)),
canAll: (...perms: string[]) => perms.every((p) => permSet.has(p)),
};
}
// Usage in components:
// const { can } = usePermissions();
// {can('post:publish') && <PublishButton />}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
2. ABAC: Attribute-Based Access Control
RBAC checks roles. ABAC checks attributes of the subject, object, action, and environment together. Use ABAC when RBAC rules get too complex โ e.g., "editors can only edit their own posts" or "billing managers can only view invoices for their region."
// src/lib/permissions/abac.ts
interface Subject {
userId: string;
tenantId: string;
roles: string[];
department?: string;
region?: string;
}
interface Resource {
type: string;
id: string;
ownerId?: string;
tenantId?: string;
status?: string;
region?: string;
[key: string]: unknown;
}
interface Action {
name: string; // 'create' | 'read' | 'update' | 'delete' | 'publish'
}
interface Environment {
timestamp: Date;
ipAddress?: string;
isBusinessHours?: boolean;
}
type PolicyEffect = 'allow' | 'deny';
interface Policy {
id: string;
effect: PolicyEffect;
description: string;
condition: (
subject: Subject,
resource: Resource,
action: Action,
env: Environment
) => boolean;
}
// Policies evaluated in order; first match wins
const POLICIES: Policy[] = [
// Admins can do everything
{
id: 'admin-all',
effect: 'allow',
description: 'Admins have full access',
condition: (subject) => subject.roles.includes('admin'),
},
// Editors can edit their own posts
{
id: 'editor-own-posts',
effect: 'allow',
description: 'Editors can update/delete their own posts',
condition: (subject, resource, action) =>
subject.roles.includes('editor') &&
resource.type === 'post' &&
['update', 'delete'].includes(action.name) &&
resource.ownerId === subject.userId,
},
// Editors cannot publish (requires reviewer)
{
id: 'editor-no-publish',
effect: 'deny',
description: 'Editors cannot publish posts',
condition: (subject, resource, action) =>
subject.roles.includes('editor') &&
!subject.roles.includes('reviewer') &&
resource.type === 'post' &&
action.name === 'publish',
},
// Billing: region restriction
{
id: 'billing-region',
effect: 'allow',
description: 'Billing managers can only access invoices in their region',
condition: (subject, resource, action) =>
subject.roles.includes('billing-manager') &&
resource.type === 'invoice' &&
action.name === 'read' &&
resource.region === subject.region,
},
// Viewers have read-only access
{
id: 'viewer-read-only',
effect: 'allow',
description: 'Viewers can read anything in their tenant',
condition: (subject, resource, action) =>
subject.roles.includes('viewer') &&
action.name === 'read' &&
resource.tenantId === subject.tenantId,
},
];
export function evaluateAbac(
subject: Subject,
resource: Resource,
action: Action,
env: Environment = { timestamp: new Date() }
): { allowed: boolean; matchedPolicy?: string } {
for (const policy of POLICIES) {
if (policy.condition(subject, resource, action, env)) {
return {
allowed: policy.effect === 'allow',
matchedPolicy: policy.id,
};
}
}
// Default deny
return { allowed: false };
}
3. Casbin: Policy Engine in Node.js
Casbin separates policy (data) from enforcement (code) and supports multiple models (RBAC, ABAC, ACL) via a config file.
pnpm add casbin
Casbin Model Configuration
# config/casbin/rbac_model.conf
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _ # user, role, domain (tenant)
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act
// src/lib/permissions/casbin.ts
import { newEnforcer, Enforcer, StringAdapter } from 'casbin';
import { db } from '../db';
let enforcer: Enforcer | null = null;
async function loadPolicyFromDB(): Promise<string> {
// Load policies from database into Casbin format
const [rolePolicies, userRoles] = await Promise.all([
db.$queryRaw<Array<{ tenant_id: string; role_name: string; resource: string; action: string }>>`
SELECT r.tenant_id::text, r.name as role_name, p.resource, p.action
FROM roles r
JOIN role_permissions rp ON r.id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
`,
db.$queryRaw<Array<{ user_id: string; tenant_id: string; role_name: string }>>`
SELECT ur.user_id::text, ur.tenant_id::text, r.name as role_name
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
`,
]);
const lines: string[] = [];
// p, role, tenant, resource, action
for (const row of rolePolicies) {
lines.push(`p, ${row.role_name}, ${row.tenant_id}, ${row.resource}, ${row.action}`);
}
// g, user, role, tenant
for (const row of userRoles) {
lines.push(`g, ${row.user_id}, ${row.role_name}, ${row.tenant_id}`);
}
return lines.join('\n');
}
export async function getEnforcer(): Promise<Enforcer> {
if (!enforcer) {
const policy = await loadPolicyFromDB();
enforcer = await newEnforcer(
'config/casbin/rbac_model.conf',
new StringAdapter(policy)
);
}
return enforcer;
}
// Reload enforcer when policies change
export async function reloadPolicies(): Promise<void> {
const policy = await loadPolicyFromDB();
const newEnf = await newEnforcer(
'config/casbin/rbac_model.conf',
new StringAdapter(policy)
);
enforcer = newEnf;
}
export async function checkPermission(
userId: string,
tenantId: string,
resource: string,
action: string
): Promise<boolean> {
const enf = await getEnforcer();
return enf.enforce(userId, tenantId, resource, action);
}
// Batch check: get all allowed actions for a resource
export async function getAllowedActions(
userId: string,
tenantId: string,
resource: string
): Promise<string[]> {
const enf = await getEnforcer();
const allActions = ['create', 'read', 'update', 'delete', 'publish', 'export'];
const allowed: string[] = [];
for (const action of allActions) {
if (await enf.enforce(userId, tenantId, resource, action)) {
allowed.push(action);
}
}
return allowed;
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
4. OPA (Open Policy Agent) for Distributed Authorization
OPA externalizes policy evaluation. Your services query OPA via HTTP; OPA evaluates Rego policies. This is the right choice when you have 10+ services all needing the same authorization logic.
Rego Policy
# policies/saas_authz.rego
package saas.authz
import future.keywords.in
import future.keywords.if
import future.keywords.contains
default allow := false
# Admins can do anything
allow if {
input.subject.role == "admin"
input.subject.tenant_id == input.resource.tenant_id
}
# Editors can CRUD their own posts
allow if {
input.subject.role == "editor"
input.resource.type == "post"
input.action in {"create", "read", "update", "delete"}
input.subject.id == input.resource.owner_id
}
# Viewers can read anything in their tenant
allow if {
input.subject.role == "viewer"
input.action == "read"
input.subject.tenant_id == input.resource.tenant_id
}
# Billing managers can access billing for their tenant
allow if {
input.subject.role == "billing-manager"
input.resource.type in {"invoice", "subscription", "payment"}
input.action in {"read"}
input.subject.tenant_id == input.resource.tenant_id
}
# Deny if resource is archived (overrides all allows)
allow := false if {
input.resource.status == "archived"
input.action in {"update", "delete"}
}
// src/lib/permissions/opa.ts
import axios from 'axios';
const OPA_URL = process.env.OPA_URL ?? 'http://opa:8181';
interface OpaInput {
subject: {
id: string;
tenant_id: string;
role: string;
[key: string]: unknown;
};
resource: {
type: string;
id: string;
tenant_id?: string;
owner_id?: string;
status?: string;
[key: string]: unknown;
};
action: string;
}
interface OpaResult {
result: boolean;
}
export async function checkPolicyOpa(input: OpaInput): Promise<boolean> {
const { data } = await axios.post<OpaResult>(
`${OPA_URL}/v1/data/saas/authz/allow`,
{ input },
{ timeout: 100 } // OPA should respond in <10ms
);
return data.result;
}
// Middleware factory
export function opaGuard(getInput: (req: Request, session: Session) => OpaInput) {
return async (req: Request, session: Session): Promise<boolean> => {
const input = getInput(req, session);
return checkPolicyOpa(input);
};
}
Docker Compose: OPA Sidecar
# docker-compose.yml
services:
opa:
image: openpolicyagent/opa:0.70.0
ports:
- "8181:8181"
command:
- "run"
- "--server"
- "--log-format=json"
- "--set=decision_logs.console=true"
- "/policies"
volumes:
- ./policies:/policies:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8181/health"]
interval: 10s
timeout: 5s
retries: 3
Permission UI: Settings Page Pattern
// src/app/settings/team/page.tsx
export function TeamPermissionsPage() {
const { can } = usePermissions();
return (
<div>
<h1>Team Settings</h1>
{/* Only show role management to users with the right permission */}
{can('user:manage_roles') && (
<RoleManagementSection />
)}
{/* Always show โ but disable actions based on permissions */}
<TeamMembersList
canInvite={can('user:invite')}
canRemove={can('user:delete')}
canChangeRole={can('user:manage_roles')}
/>
{/* Billing section with permission gate */}
{can('billing:read') ? (
<BillingSection canManage={can('billing:manage')} />
) : (
<PermissionGate
permission="billing:read"
message="Contact your admin for billing access"
/>
)}
</div>
);
}
function PermissionGate({
permission,
message,
}: {
permission: string;
message: string;
}) {
return (
<div className="rounded-lg bg-gray-50 border border-gray-200 p-6 text-center">
<LockIcon className="mx-auto h-8 w-8 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">{message}</p>
</div>
);
}
Cost Reference
| Approach | Setup Cost | Ops Cost | Scale |
|---|---|---|---|
| Simple role checks in code | 0 | 0 | Poor โ becomes unmaintainable |
| RBAC with DB + Redis cache | $15Kโ30K build | $20โ50/mo infra | Good for most SaaS |
| RBAC + Casbin | $25Kโ45K build | $30โ80/mo | Excellent |
| OPA sidecar | $40Kโ70K build | $100โ300/mo | Best for microservices |
| Permit.io / Oso (managed) | $5Kโ15K integration | $200โ2K/mo | Good DX, vendor dependency |
See Also
- SaaS Audit Logging: Immutable Trails and SOC2 Compliance
- SaaS Security Checklist: Authentication, Authorization, and Data Protection
- Multi-Tenant SaaS Architecture: Shared vs Isolated Database
- API Gateway Authentication: JWT, API Keys, and mTLS
- Zero Trust Security Architecture
Working With Viprasol
Building a SaaS product that needs permission logic beyond user.role === 'admin'? We design and implement authorization systems โ from RBAC with materialized views and Redis caching, to ABAC policy engines, to OPA-based distributed authorization โ that scale with your product without becoming a maintenance burden.
Talk to our team โ | Explore our SaaS engineering services โ
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.