System Design ·

Designing a Permission System: RBAC, ABAC, and ReBAC for Multi-Tenant SaaS

Most permission systems start simple and become unmaintainable. This guide covers RBAC, ABAC, and ReBAC with TypeScript implementations, multi-tenant isolation patterns, caching strategies, and how to migrate between models without breaking production.

Designing a Permission System: RBAC, ABAC, and ReBAC for Multi-Tenant SaaS

Authorization is one of those problems that looks simple at first. A user has a role. The role has permissions. Done.

Then the product grows. A customer asks for resource-level permissions. Another customer wants permissions based on department. A third wants document sharing like Google Docs. Now you have three different authorization models bolted onto each other, none of them consistent, all of them a maintenance nightmare.

This is not a hypothetical. It is the default trajectory of every permission system that starts with “just add a role column to the users table.”

This article covers the three models that actually matter: RBAC, ABAC, and ReBAC. Each solves a real set of problems. Each has real tradeoffs. The goal is to help you pick the right one for your current stage, implement it cleanly, and migrate when your requirements outgrow it.


The Problem Space

Before picking a model, clarify what you are actually solving. Authorization systems need to answer one question: “Can subject S perform action A on resource R?”

The models differ in how they answer that question:

  • RBAC: “S has role X, and X is allowed to do A on any R of type T.”
  • ABAC: “S has attribute age=25, R has attribute classification=public, environment is weekday: allow.”
  • ReBAC: “S is a direct member of the group that owns R: allow.”

The complexity of your policy logic determines which model fits.


Role-Based Access Control (RBAC)

RBAC is the right starting point for most SaaS products. Roles are coarse-grained, easy to audit, and easy to explain to non-engineers.

Core model

type Role = "owner" | "admin" | "editor" | "viewer";

interface Permission {
  action: "create" | "read" | "update" | "delete";
  resource: "project" | "document" | "invoice" | "member";
}

const rolePermissions: Record<Role, Permission[]> = {
  owner: [
    { action: "create", resource: "project" },
    { action: "read", resource: "project" },
    { action: "update", resource: "project" },
    { action: "delete", resource: "project" },
    { action: "create", resource: "member" },
    { action: "delete", resource: "member" },
    // ...
  ],
  admin: [
    { action: "create", resource: "project" },
    { action: "read", resource: "project" },
    { action: "update", resource: "project" },
    // no delete project, no member management
  ],
  editor: [
    { action: "create", resource: "document" },
    { action: "read", resource: "document" },
    { action: "update", resource: "document" },
  ],
  viewer: [
    { action: "read", resource: "project" },
    { action: "read", resource: "document" },
  ],
};

function canPerform(
  userRole: Role,
  action: Permission["action"],
  resource: Permission["resource"]
): boolean {
  return rolePermissions[userRole].some(
    (p) => p.action === action && p.resource === resource
  );
}

Multi-tenant RBAC

In a multi-tenant system, roles are scoped to a tenant. A user can be an admin in tenant A and a viewer in tenant B.

interface TenantMembership {
  userId: string;
  tenantId: string;
  role: Role;
}

async function getUserRole(
  userId: string,
  tenantId: string,
  db: Database
): Promise<Role | null> {
  const membership = await db.query<TenantMembership>(
    "SELECT role FROM tenant_memberships WHERE user_id = $1 AND tenant_id = $2",
    [userId, tenantId]
  );
  return membership?.role ?? null;
}

async function authorize(
  userId: string,
  tenantId: string,
  action: Permission["action"],
  resource: Permission["resource"],
  db: Database
): Promise<boolean> {
  const role = await getUserRole(userId, tenantId, db);
  if (!role) return false;
  return canPerform(role, action, resource);
}

The critical invariant: every authorization check must verify tenant membership before checking the role. Never query resources without scoping by tenantId. A user with an admin role in tenant A must never read tenant B’s data, regardless of role.

Where RBAC breaks down

RBAC works until you need “an editor can only edit documents they created” or “a billing manager can only see invoices under $10,000.” Those are attribute conditions, not role conditions. When you start adding if statements inside your permission checks, you are building ABAC without calling it that.


Attribute-Based Access Control (ABAC)

ABAC evaluates policies against attributes of the subject, the resource, and the environment. It is more flexible than RBAC and significantly more complex to implement and audit.

Policy engine

interface Subject {
  id: string;
  role: Role;
  department: string;
  clearanceLevel: number;
}

interface Resource {
  id: string;
  type: string;
  ownerId: string;
  classification: "public" | "internal" | "confidential";
  tenantId: string;
}

interface Environment {
  time: Date;
  ipAddress: string;
}

type PolicyDecision = "allow" | "deny";

type PolicyRule = (
  subject: Subject,
  resource: Resource,
  action: string,
  env: Environment
) => PolicyDecision | "abstain";

const ownershipRule: PolicyRule = (subject, resource) => {
  if (resource.ownerId === subject.id) return "allow";
  return "abstain";
};

const classificationRule: PolicyRule = (subject, resource) => {
  if (
    resource.classification === "confidential" &&
    subject.clearanceLevel < 3
  ) {
    return "deny";
  }
  return "abstain";
};

const businessHoursRule: PolicyRule = (_subject, _resource, _action, env) => {
  const hour = env.time.getHours();
  if (hour < 8 || hour > 18) return "deny";
  return "abstain";
};

function evaluate(
  subject: Subject,
  resource: Resource,
  action: string,
  env: Environment,
  rules: PolicyRule[]
): boolean {
  // deny-overrides: any explicit deny wins
  for (const rule of rules) {
    if (rule(subject, resource, action, env) === "deny") return false;
  }
  // permit-overrides: any explicit allow wins
  for (const rule of rules) {
    if (rule(subject, resource, action, env) === "allow") return true;
  }
  // default deny
  return false;
}

Combining RBAC and ABAC

In practice, most systems benefit from layering these models. Use RBAC to establish coarse-grained role checks (is this user even allowed to touch invoices?), then ABAC for fine-grained attribute conditions (can they access this specific invoice?).

async function authorizeInvoiceRead(
  userId: string,
  invoiceId: string,
  tenantId: string,
  db: Database
): Promise<boolean> {
  // Layer 1: RBAC gate
  const role = await getUserRole(userId, tenantId, db);
  if (!role || !canPerform(role, "read", "invoice")) return false;

  // Layer 2: ABAC conditions
  const [user, invoice, env] = await Promise.all([
    db.getUser(userId),
    db.getInvoice(invoiceId),
    buildEnvironment(),
  ]);

  if (invoice.tenantId !== tenantId) return false; // tenant isolation

  return evaluate(user, invoice, "read", env, [
    ownershipRule,
    classificationRule,
    businessHoursRule,
  ]);
}

Where ABAC breaks down

ABAC is expressive but the policy surface area explodes quickly. When you need “user A can share document D with user B, and B can then share it with C,” attributes alone cannot model that without encoding the relationship graph into attribute values. That is the problem ReBAC solves.


Relationship-Based Access Control (ReBAC)

ReBAC, popularized by Google’s Zanzibar paper, models permissions as a graph of relationships between subjects and objects. Access is determined by traversing that graph.

The canonical example is Google Docs: a document is owned by a user, shared with a group, the group contains other users, and those users can share with additional users. No attribute system can cleanly represent transitive sharing chains.

Relationship store

interface Tuple {
  object: string;    // e.g., "document:abc"
  relation: string;  // e.g., "viewer"
  subject: string;   // e.g., "user:xyz" or "group:eng#member"
}

// Core data model: who has what relation to what object
const tuples: Tuple[] = [
  { object: "document:abc", relation: "owner", subject: "user:alice" },
  { object: "document:abc", relation: "viewer", subject: "group:eng#member" },
  { object: "group:eng", relation: "member", subject: "user:bob" },
  { object: "group:eng", relation: "member", subject: "user:carol" },
];

Permission schema with inheritance

interface PermissionSchema {
  relations: Record<string, string[]>; // relation -> set of relations that imply it
}

const documentSchema: PermissionSchema = {
  relations: {
    owner: [],
    editor: ["owner"],     // owner implies editor
    viewer: ["editor"],    // editor implies viewer
    commenter: ["viewer"], // viewer implies commenter
  },
};

function impliedRelations(
  relation: string,
  schema: PermissionSchema,
  visited = new Set<string>()
): Set<string> {
  if (visited.has(relation)) return visited;
  visited.add(relation);
  for (const parent of schema.relations[relation] ?? []) {
    impliedRelations(parent, schema, visited);
  }
  return visited;
}

Check algorithm

async function check(
  object: string,
  relation: string,
  subject: string,
  tupleStore: Tuple[],
  schema: PermissionSchema
): Promise<boolean> {
  const implied = impliedRelations(relation, schema);

  for (const rel of implied) {
    // Direct match
    const direct = tupleStore.some(
      (t) => t.object === object && t.relation === rel && t.subject === subject
    );
    if (direct) return true;

    // Indirect match through group membership
    const groupTuples = tupleStore.filter(
      (t) =>
        t.object === object &&
        t.relation === rel &&
        t.subject.includes("#")
    );

    for (const gt of groupTuples) {
      // gt.subject is "group:eng#member"
      const [groupRef, memberRelation] = gt.subject.split("#");
      const isMember = await check(
        groupRef,
        memberRelation,
        subject,
        tupleStore,
        schema
      );
      if (isMember) return true;
    }
  }

  return false;
}

This is a simplified version of Zanzibar’s check algorithm. Production implementations need cycle detection, recursive depth limits, and distributed caching (Zanzibar uses a “zookie” consistency token to prevent new enemy problems).


Tradeoffs Comparison

DimensionRBACABACReBAC
Implementation complexityLowMediumHigh
Policy expressivenessLowHighMedium-High
Audit simplicityHighMediumLow
Query performanceFast (index on role)Varies (attribute joins)Slow without caching (graph traversal)
Multi-tenant isolationEasyEasyRequires careful tuple scoping
User-managed sharingNoNoYes
Good fitEarly SaaS, fixed rolesEnterprise with conditionsCollaborative/social products
Bad fitFine-grained resource sharingTransitive sharing chainsSimple apps, small teams

Production Considerations

Caching

Authorization checks happen on every request. Without caching, your permission system becomes your bottleneck.

interface AuthzCache {
  get(key: string): Promise<boolean | null>;
  set(key: string, value: boolean, ttlSeconds: number): Promise<void>;
  invalidate(pattern: string): Promise<void>;
}

function authzCacheKey(
  userId: string,
  tenantId: string,
  action: string,
  resourceId: string
): string {
  return `authz:${tenantId}:${userId}:${action}:${resourceId}`;
}

async function cachedAuthorize(
  userId: string,
  tenantId: string,
  action: string,
  resourceId: string,
  cache: AuthzCache,
  fallback: () => Promise<boolean>
): Promise<boolean> {
  const key = authzCacheKey(userId, tenantId, action, resourceId);
  const cached = await cache.get(key);
  if (cached !== null) return cached;

  const result = await fallback();
  await cache.set(key, result, 60); // 60-second TTL
  return result;
}

Cache invalidation is the hard part. When a role changes, you need to invalidate all cache entries for that user in that tenant. Tag your cache entries:

// On role change: invalidate by user+tenant pattern
await cache.invalidate(`authz:${tenantId}:${userId}:*`);

For ReBAC systems, invalidation is more aggressive: any tuple write invalidates all checks that could have traversed that tuple. Zanzibar handles this with its consistency token system. For most applications, a short TTL (30-60 seconds) with explicit invalidation on writes is sufficient.

Denormalization for query performance

In RBAC, the common mistake is joining users -> tenant_memberships -> role_permissions on every request. Denormalize permissions into a Redis set or a pre-computed column:

-- Materialized permission set per user per tenant
CREATE TABLE user_effective_permissions (
  user_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  resource_type TEXT NOT NULL,
  action TEXT NOT NULL,
  PRIMARY KEY (user_id, tenant_id, resource_type, action)
);

-- Rebuild on role change
CREATE OR REPLACE FUNCTION rebuild_user_permissions(p_user_id UUID, p_tenant_id UUID)
RETURNS void AS $$
BEGIN
  DELETE FROM user_effective_permissions
  WHERE user_id = p_user_id AND tenant_id = p_tenant_id;

  INSERT INTO user_effective_permissions (user_id, tenant_id, resource_type, action)
  SELECT p_user_id, p_tenant_id, rp.resource_type, rp.action
  FROM tenant_memberships tm
  JOIN role_permissions rp ON rp.role = tm.role
  WHERE tm.user_id = p_user_id AND tm.tenant_id = p_tenant_id;
END;
$$ LANGUAGE plpgsql;

For ReBAC, pre-compute the “expanded” relation set for frequently-checked tuples and store it in Redis as a sorted set, scored by creation time. The Zanzibar paper calls this “leopard indexing.”

Tenant isolation enforcement

Regardless of the authorization model, multi-tenant isolation is a separate concern that must be enforced at the data layer, not just the permission layer.

// Never do this: authorization check passes but resource belongs to different tenant
async function getDocument(documentId: string, userId: string): Promise<Document> {
  const doc = await db.query("SELECT * FROM documents WHERE id = $1", [documentId]);
  if (!doc) throw new NotFoundError();
  // Bug: authorization check doesn't verify tenantId
  if (!await canRead(userId, documentId)) throw new ForbiddenError();
  return doc;
}

// Always do this: scope the query to the authenticated tenant
async function getDocument(
  documentId: string,
  userId: string,
  tenantId: string
): Promise<Document> {
  const doc = await db.query(
    "SELECT * FROM documents WHERE id = $1 AND tenant_id = $2",
    [documentId, tenantId]
  );
  if (!doc) throw new NotFoundError(); // same response for not-found and wrong-tenant
  if (!await canRead(userId, documentId, tenantId)) throw new ForbiddenError();
  return doc;
}

Return 404, not 403, when a resource exists in a different tenant. Leaking that a resource exists is itself an information disclosure.


Migrating Between Models

The typical migration path is RBAC to ABAC (when you need attribute conditions) or RBAC to ReBAC (when you need user-managed sharing). Both are possible without a big-bang rewrite.

RBAC to ABAC

  1. Keep the role check as the first gate (fast path, no schema changes).
  2. Add an attribute evaluation layer after the role check.
  3. Introduce attribute columns incrementally, defaulting to permissive values that preserve existing behavior.
  4. Write attribute rules as new conditions rather than replacing existing logic.

This is a pure additive change. Existing roles continue to work. New attribute conditions layer on top.

RBAC to ReBAC

This is more disruptive because the data model changes fundamentally.

  1. Build the tuple store alongside the existing role system.
  2. Write a migration script that converts tenant_memberships rows into tuples: (document:*, owner, user:alice) for every resource owned by alice.
  3. Run both systems in parallel with a feature flag. Compare decisions and log divergences.
  4. Cut over by tenant, starting with internal or low-risk tenants.
  5. Remove the old role system after all tenants are migrated and the comparison period is clean.

The parallel-run phase is not optional. ReBAC semantics differ subtly from RBAC in edge cases, and you will find discrepancies before they become incidents.


Closing

Pick the simplest model that solves your current requirements. RBAC is not a compromise, it is the correct choice for most early SaaS products. Add ABAC when business logic demands attribute conditions. Migrate to ReBAC when users need to manage their own sharing.

The mistake is not starting with RBAC. The mistake is staying with a pure role model years after the product requirements have outgrown it, because the “add a column” fix always looks cheaper than a real authorization layer until it is not.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.