Back to Blog

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.

Viprasol Tech Team
November 9, 2026
14 min read

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

ModelWhat it controlsBest for
RBACRole โ†’ Permission mappingsMost SaaS products, clean mental model
ABACSubject + Object + Action + EnvironmentComplex rules involving resource attributes
ReBACRelationships between users and resourcesGoogle Drive-style sharing, hierarchical access
Policy-as-codeExternalized, version-controlled rulesLarge 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

ApproachSetup CostOps CostScale
Simple role checks in code00Poor โ€” becomes unmaintainable
RBAC with DB + Redis cache$15Kโ€“30K build$20โ€“50/mo infraGood for most SaaS
RBAC + Casbin$25Kโ€“45K build$30โ€“80/moExcellent
OPA sidecar$40Kโ€“70K build$100โ€“300/moBest for microservices
Permit.io / Oso (managed)$5Kโ€“15K integration$200โ€“2K/moGood DX, vendor dependency

See Also


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 โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.