Multi-Tenancy, Permission Libraries, and Architectural Decisions
Add multi-tenant isolation to your permission system, evaluate CASL as a library alternative, and use decision frameworks to choose the right authorization architecture.
Abstract
Post 101 established seven goals for a permission system and exposed the scattered-check anti-pattern. Post 102 centralized authorization inside a service layer. Post 103 added type-safe RBAC. Post 104 replaced the role-permission matrix with an ABAC policy engine. Post 105 extended ABAC with environment rules, field-level read/write permissions, and database query filtering.
Three production concerns remain. First, the system has no tenant boundary -- a user in Organization A can access Organization B's resources if the query is crafted correctly. Second, the custom ABAC engine is functional but the question arises: should the team maintain bespoke authorization code or migrate to a library like CASL? Third, there is no comprehensive decision framework for choosing between RBAC, custom ABAC, library-based ABAC, and external policy engines.
This capstone post closes all three gaps: multi-tenancy as a first-class permission concern, a working CASL migration with honest friction analysis, and the series' definitive comparison across every approach.
Multi-Tenancy Models
The Tenant Concept
In SaaS, a tenant is an organization, workspace, or account that groups users and their resources. Slack workspaces, GitHub organizations, and Notion workspaces are all tenants. The tenant boundary is the outermost permission boundary -- before checking roles, ownership, or field access, the system must verify the user belongs to the tenant that owns the resource.
Extending the Domain Model
The series' domain model gains a tenant dimension:
Every resource now carries a tenantId. Every user belongs to exactly one tenant (for simplicity -- multi-tenant membership is possible but adds complexity outside this scope).
Three Isolation Strategies
Row-Level Isolation (shared schema): All tenants share the same tables. Every table has a tenant_id column. This is the simplest infrastructure and cheapest option, but forgetting a WHERE tenant_id = ? clause leaks cross-tenant data. PostgreSQL Row-Level Security (RLS) can enforce this at the database level as a safety net.
Schema-Level Isolation: Each tenant gets a separate schema within the same database. Stronger isolation -- a missing WHERE clause produces an error rather than a data leak. Migrations must run across N schemas. Viable for dozens to hundreds of tenants.
Database-Level Isolation: Each tenant gets a dedicated database instance. Maximum isolation and the strongest compliance posture. Highest cost and operational complexity.
This series uses the row-level isolation model because it is the most common starting point and the most challenging from a permission perspective. Schema and database isolation solve tenant boundaries at the infrastructure level. Row-level isolation requires the application to enforce those boundaries.
Tenant-Aware Permission Layer
The Scattered Tenant Check Anti-Pattern
Without tenant-aware permissions, every service method manually checks tenant isolation:
This is Post 101's scattered-check pattern reappearing at the tenant level. If a developer forgets the tenant check on one endpoint, they create a cross-tenant data leak -- the most dangerous class of authorization bug because it exposes other customers' data.
Tenant Isolation as a Global ABAC Condition
The correct approach: tenant isolation becomes a built-in condition that runs automatically for every permission check:
The global() condition runs before any role-specific conditions. It acts as an implicit WHERE clause on every permission check. Even if a developer creates a new role or a new resource type, tenant isolation is automatically enforced.
Updating the can() Function
The can() function evaluates global conditions first:
Updating Database Query Filtering
The toWhereClause() function from Post 105 must include tenant filtering:
The tenant filter is always present. Even if buildRoleFilter() returns {} (no additional filter for an admin), the query still includes WHERE tenantId = ?.
Cross-Tenant Access: The Exception
Some scenarios require cross-tenant access:
- Platform admins (super-admins) who manage all tenants
- Shared resources (templates, public content) that exist outside any tenant
- Support tools for customer service to view tenant data
Warning: Cross-tenant exceptions must be explicit and auditable. The platform admin role should be separate from tenant-level admin, with additional authentication requirements (MFA, IP restrictions) enforced via environment conditions from Post 105.
Why Use a Permission Library?
The Build vs. Library Decision
Posts 101-105 built a custom permission system covering RBAC, ABAC, field-level permissions, DB query filtering, environment rules, and now multi-tenancy. This is approximately 300-500 lines of core permission logic. At what point does maintaining this code become more expensive than adopting a library?
Custom Implementation Strengths
- Zero dependencies: No third-party code in the critical security path
- Full control over API surface: The
can()signature evolves exactly as needed - Perfect TypeScript integration: Generic constraints, builder patterns, and type inference designed for the specific domain
- No serialization overhead: Plain functions, no class instances, RSC-compatible
- Team understanding: Every condition is a function the team wrote -- no black boxes
- Predictable behavior: Debugging follows standard function-call stacks
Custom Implementation Weaknesses
- Maintenance burden: The team owns bugs, edge cases, and security patches
- Limited community testing: Unusual edge cases may not surface until production
- Feature reimplementation: Field permissions, DB query conversion, condition operators (
$in,$ne,$gte) -- rebuilding what libraries already provide - Onboarding cost: New team members learn a bespoke API instead of a documented library
Library Strengths
- Community-tested: Thousands of projects, edge cases discovered and fixed
- Built-in features: Field permissions, MongoDB-style conditions, Prisma/Mongoose adapters
- Documentation and community: Tutorials, Stack Overflow answers, conference talks
- Reduced maintenance: Security patches and feature additions handled by maintainers
Library Weaknesses
- API constraints: The library's API may not match the series'
can()signature - Dependency risk: Library maintenance can slow or stop
- Integration friction: Class-based libraries clash with React Server Components
- Black box behavior: Debugging permission denials requires understanding library internals
Build vs. Library Decision Framework
In-Code vs. DSL-Based Approaches
CASL Integration
Why CASL for This Series
CASL is the most popular JavaScript/TypeScript authorization library (~6KB core). It is isomorphic (works on server and client), supports ABAC conditions, field-level permissions, and database query conversion. Since the series has already built everything CASL provides, a direct feature-for-feature comparison is possible.
Migration: AbilityBuilder
The custom PermissionBuilder from Post 104 maps to CASL's AbilityBuilder:
Custom (Posts 104-105):
CASL equivalent:
Key API differences:
- Conditions are MongoDB-style objects (
{ authorId: user.userId }) instead of functions - No builder-pattern chaining for roles -- uses
if/elsebranching on user role cannot()for negative rules (CASL exclusive -- the custom system did not have this)'manage'is CASL's wildcard for all CRUD actions;'all'for all subjects
The subject() Helper and Its Friction
CASL needs to know the type of an object being checked. With classes, this is automatic (via the class name). With plain objects -- which TypeScript applications typically use -- the subject() helper is required:
Workaround 1: Object spreading
Workaround 2: Custom detectSubjectType
Workaround 3: PureAbility with lambda matcher (RSC-compatible)
Tip: The
PureAbility+ lambda matcher approach is the most RSC-compatible option, but it loses CASL's MongoDB-style query operators and Prisma integration. There is a real tradeoff between CASL's full feature set and modern React compatibility.
Tenant Isolation in CASL
The custom system had a global() condition that applied tenant isolation automatically to every rule. CASL requires adding tenantId to every rule individually. Missing it on one rule creates a cross-tenant leak. This is a significant ergonomic difference.
CASL Field and DB Integration
Field-Level Permissions with permittedFieldsOf
Compare to the custom getVisibleFields() from Post 105 -- the concept is the same, the API is different. CASL requires a fieldsFrom callback that returns all possible fields when a rule has no field restriction.
CASL AST to Prisma Query Conversion
Compare to the custom toWhereClause() from Post 105:
- CASL's
accessibleBy()converts MongoDB-style conditions into Prismawheresyntax - The custom
toWhereClause()uses condition descriptors withtoFiltercallbacks - CASL automatically handles
ORlogic across multiple matching rules - CASL throws
ForbiddenErrorif no rules match at all (fail-closed)
Warning:
accessibleBy()only works with MongoDB-style conditions (fromcreateMongoAbility), not with lambda conditions (PureAbility). If you use the RSC-compatiblePureAbilitypattern, you lose Prisma query conversion. This is a hard tradeoff in the current CASL architecture.
The Comprehensive Comparison
RBAC vs. Custom ABAC vs. CASL ABAC
Decision Framework: Which System Should You Choose?
When to Choose Each
RBAC (Post 103) -- The Default Choice
- Team: Any size
- App: Internal tools, simple SaaS, content platforms with clear roles
- Complexity: Low -- 2-4 roles, permissions depend only on role
- Signal to choose: Permission requirements map cleanly to "this role can do these things"
- Signal to upgrade: Helper functions start proliferating alongside
can()
Custom ABAC (Posts 104-105) -- Full Control
- Team: Has authorization expertise, willing to maintain auth code
- App: SaaS with complex business rules, field-level visibility, large datasets
- Complexity: High -- ownership, department, status, time conditions
- Signal to choose:
can()must evaluate 3+ contextual conditions per resource - Signal to upgrade: Team bandwidth for auth maintenance decreases; need for DB query adapters across multiple ORMs
CASL ABAC (This Post) -- Community-Tested Library
- Team: Wants to focus on business logic, not auth internals
- App: SaaS using Prisma/MongoDB, needs field permissions and DB filtering
- Complexity: High -- but team prefers library API over custom code
- Signal to choose: The custom ABAC feature set matches CASL's capabilities
- Signal to avoid: Heavy RSC usage with plain objects; need for environment conditions; need for global tenant isolation
External PDP (Cerbos, OPA, Cedar) -- Authorization as a Service
- Team: Dedicated platform/security team
- App: Microservices, polyglot stack, shared authorization decisions across services
- Complexity: Very high -- multiple services need consistent authorization
- Signal to choose: Multiple backends need the same authorization decisions; compliance requires decoupled, auditable policy management
Common Pitfalls
-
Forgetting tenant isolation on one CASL rule: CASL has no global condition. Missing
tenantIdon one rule creates a cross-tenant leak. Write a lint rule or unit test that verifies every non-platform-admin rule includestenantId. -
Assuming CASL works seamlessly with RSC: The
subject()helper mutates objects. React Server Components require serializable data. Use one of the three workarounds from the CASL Integration section. -
PureAbility loses Prisma integration: The RSC-compatible
PureAbility+ lambda matcher pattern cannot convert conditions to Prismawhereclauses. Teams must choose between RSC compatibility and DB query filtering. -
Over-engineering early: Jumping to ABAC or CASL before RBAC fails is premature. The series' progression mirrors real-world evolution: start simple, add complexity when current limitations appear.
-
Multi-tenancy as an afterthought: Adding
tenant_idto every table after the schema is established is a painful migration. Design tenant isolation from the beginning, even if the first version has only one tenant. -
Confusing platform admin with tenant admin: Platform admins manage all tenants (cross-tenant access). Tenant admins manage their own tenant only. Mixing these roles creates either overly permissive tenant admins or insufficiently permissive platform admins.
-
Choosing an external PDP too early: Cerbos, OPA, and Cedar add infrastructure complexity. For a monolithic Next.js app, in-process authorization (custom or CASL) is simpler and faster. External PDPs make sense when authorization decisions must be shared across independently deployed services.
-
Not testing cross-tenant scenarios: Unit tests often use a single tenant ID. Add explicit test cases where User A (tenant 1) attempts to access User B's document (tenant 2). These tests catch missing tenant filters.
Series Retrospective
The Seven Goals Scorecard
Post 101 established seven goals for any permission system. Here is how each approach scores:
Series Architecture Evolution
The key insight across all six posts: the service layer from Post 102 never changes. It is the enforcement point for every authorization approach. The decision engine inside it evolves from simple role checks to RBAC to ABAC to CASL, but the architecture remains constant. This is what makes the progressive approach work -- each upgrade is contained within the service layer.
Microservices Authorization: A Forward Look
The series focused on a monolithic Next.js application. As applications grow, authorization decisions must work across service boundaries. Three patterns emerge:
Pattern 1: Centralized Authorization Service
A single service evaluates all permission decisions. Other services call it via gRPC/HTTP. Single source of truth, but a single point of failure with network latency on every request.
Pattern 2: Embedded PDP (Sidecar)
Each microservice runs its own policy engine (OPA sidecar, Cerbos sidecar). Policies are managed centrally and distributed to all sidecars. No network hop for decisions, but policy sync complexity and version drift risk.
Pattern 3: Token-Based Claims
Authorization data embedded in JWT claims (roles, permissions, tenantId). Services trust the token without additional policy checks. Simplest infrastructure, but stale claims and no resource-level authorization.
For teams moving from monolith to microservices: start with Pattern 3 (token claims) for service-to-service auth, and add Pattern 2 (embedded PDP) when fine-grained resource-level authorization is needed across services.
Permission Storage: Code vs. Database
The hybrid pattern works well for production SaaS: define the default permission set in code (type-safe, tested), allow tenants to override specific rules via a database table. The can() function checks code-based rules first, then applies database overrides.
Series Recap
Over six posts, the permission system evolved from scattered if-statements to a production-grade authorization architecture:
- Post 101: Identified the problem -- scattered checks, inconsistent enforcement, no fail-closed default
- Post 102: Established the architecture -- service layer as the single enforcement point
- Post 103: Added the first decision engine -- type-safe RBAC with generic constraints
- Post 104: Replaced role-based lookup with attribute-based policies -- ownership, department, status conditions
- Post 105: Extended ABAC with environment rules, field-level permissions, and database query filtering
- Post 106 (this post): Added multi-tenancy, evaluated CASL as a library alternative, and provided the definitive decision framework
The service layer is the one constant. The decision engine inside it can be RBAC, custom ABAC, CASL, or an external PDP. The architecture from Post 102 supports all of them without structural changes. That was the goal from the beginning.
References
- CASL - Isomorphic Authorization JavaScript Library - The primary authorization library evaluated in this post, supporting ABAC conditions, field-level permissions, and database query conversion
- CASL v6 - Prisma Integration - Official documentation for
@casl/prismaincluding theaccessibleBy()function for converting CASL rules into Prismawhereclauses - CASL v6 - Restricting Fields Access - Official CASL documentation on
permittedFieldsOf()and field arrays in rule definitions - Shipping Multi-Tenant SaaS Using PostgreSQL Row-Level Security (Nile) - Guide to implementing row-level security for multi-tenant SaaS including tenant context propagation and fail-secure defaults
- The Developer's Guide to SaaS Multi-Tenant Architecture (WorkOS) - Architectural overview of multi-tenancy models with decision criteria based on isolation requirements and tenant count
- How to Choose the Right Authorization Model for Your SaaS (WorkOS) - Decision framework for selecting between roles, permissions, ABAC, ReBAC, and policy-based authorization
- Policy Engines: OPA vs Cedar vs Zanzibar (Permit.io) - Comparative analysis of OPA (Rego-based), Cedar (AWS, formal verification), and Zanzibar (Google, graph-based ReBAC)
- 3 Most Common Authorization Designs for SaaS Products (Cerbos) - Comparison of ACL, RBAC, and ABAC patterns with guidance on when to use each
- Multi-Tenant Data Isolation with PostgreSQL Row Level Security (AWS) - AWS guide to implementing RLS for tenant isolation in PostgreSQL
- Best Practices for Authorization in Microservices (Permit.io) - Guide covering centralized vs. embedded PDP patterns and the recommended sidecar architecture
- RBAC vs ABAC: Main Differences and When to Use Each (Oso) - Comparison of RBAC and ABAC models with decision criteria for hybrid approaches
- OWASP Microservices Security Cheat Sheet - OWASP guidance on microservices authorization patterns including centralized PDP and embedded PDP sidecar
- An Introduction to Google Zanzibar and ReBAC (Authzed) - Overview of Google Zanzibar's relationship-based access control model and how it powers authorization at scale
Permission Systems that Scale
A comprehensive guide to building scalable permission systems in TypeScript and Next.js, progressing from naive checks through RBAC and ABAC to production-grade multi-tenant authorization.