Multi-Tenancy
Apso provides application-layer data isolation through the scopeBy property on entities. This is Apso’s answer to one of the most critical challenges in SaaS development: ensuring users only see and modify data they are authorized to access.
When you add scopeBy to an entity, Apso generates NestJS guards that automatically filter queries, inject scope values on create, and verify ownership on single-resource operations — all from a single line of configuration.
Philosophy: Application-Layer RLS
Traditional approaches to multi-tenant data isolation each have drawbacks:
- Database RLS (e.g., Supabase) — Powerful but opaque, tied to PostgreSQL, difficult to debug and test
- Manual filtering — Error-prone, repetitive, easy to forget on new endpoints
- ORM middleware — Often complex, hard to customize per-entity
Apso takes a different approach that delivers the best of all three:
- Declarative — Define scope once in
.apsorc, applied everywhere automatically - Transparent — Generated guards are standard NestJS code you can read, debug, and extend
- Portable — Works with any database, not locked to PostgreSQL RLS policies
- Flexible — Configure per-entity behavior: auto-injection, selective enforcement, role-based bypass
Your data isolation logic is explicit code, not hidden database configuration. You can step through it with a debugger, write unit tests for it, and modify it when your requirements change.
What scopeBy Does
The scopeBy property tells Apso which field(s) determine the authorization boundary for an entity. When configured, the generated scope guard:
- Auto-injects scope values on create operations (POST requests) — the scope field is set from the authenticated user’s context, preventing users from creating data in another tenant’s scope
- Auto-filters list queries by scope values (GET without ID) — users only see rows that belong to their scope
- Verifies ownership on single-resource operations (GET/PUT/PATCH/DELETE by ID) — the guard confirms the requested resource belongs to the user’s scope before allowing access
Basic Configuration
Add scopeBy to any entity that should be tenant-isolated:
{
"name": "Project",
"scopeBy": "workspaceId",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "name", "type": "text" },
{ "name": "status", "type": "enum", "values": ["Active", "Archived"] }
]
}With this configuration:
GET /projectreturns only projects whereworkspaceIdmatches the authenticated user’s workspacePOST /projectautomatically setsworkspaceIdfrom the request contextGET /project/:idverifies the project belongs to the user’s workspace before returning itPATCH /project/:idandDELETE /project/:idverify ownership before modifying
The scope value (workspaceId) comes from the authenticated request context, which is populated by the auth guard. See the Authentication + Scoping section below for how they work together.
Scoping Modes
Single field scoping
The simplest form. One field defines the scope boundary:
{
"name": "Project",
"scopeBy": "workspaceId"
}All Project operations are filtered by workspaceId.
Multiple field scoping
For entities that need scoping by more than one dimension. For example, tasks that are scoped both to a workspace and to a specific project within that workspace:
{
"name": "Task",
"scopeBy": ["workspaceId", "projectId"]
}Both fields must match the request context for the operation to succeed. This provides finer-grained isolation — a user cannot access tasks from a project they do not have access to, even within the same workspace.
Nested path scoping
For entities that do not have a direct scope field but inherit scope through a relationship chain. Use dot notation to traverse relationships:
{
"name": "Comment",
"scopeBy": "task.workspaceId"
}The guard looks up the task relationship on the Comment, then checks the workspaceId on the related Task. This is useful for deeply nested resources that inherit their tenant scope from a parent entity rather than storing it directly.
scopeOptions
Fine-tune how scope enforcement behaves for a specific entity using scopeOptions:
{
"name": "AuditLog",
"scopeBy": "workspaceId",
"scopeOptions": {
"injectOnCreate": false,
"enforceOn": ["find", "get"],
"bypassRoles": ["admin", "superadmin"]
}
}Available Options
| Option | Type | Default | Description |
|---|---|---|---|
injectOnCreate | boolean | true | Automatically set the scope field from request context on POST operations. Set to false for entities where the scope value is set by your application logic rather than the request context. |
enforceOn | string[] | ["find", "get", "create", "update", "delete"] | Which CRUD operations enforce scope checking. Useful for entities like audit logs that should be readable but not directly writable through the API. |
bypassRoles | string[] | [] | User roles that skip scope checking entirely. Superadmin users who need cross-tenant access should be listed here. |
enforceOn operations
The enforceOn array accepts any combination of:
| Operation | HTTP Method | Description |
|---|---|---|
find | GET /entity | List/search operations |
get | GET /entity/:id | Single-resource retrieval |
create | POST /entity | Creating new records |
update | PATCH /entity/:id | Updating existing records |
delete | DELETE /entity/:id | Deleting records |
Example — enforce scope on reads only, allowing the system to write without scope constraints:
{
"name": "Notification",
"scopeBy": "workspaceId",
"scopeOptions": {
"enforceOn": ["find", "get"]
}
}bypassRoles
Roles listed in bypassRoles skip all scope checks for this entity. The role values must match what your auth system provides in the AuthContext.roles array:
{
"name": "BillingRecord",
"scopeBy": "organizationId",
"scopeOptions": {
"bypassRoles": ["superadmin", "billing_admin"]
}
}A user with the superadmin role can query all billing records across all organizations.
Generated Guard Code
When entities have scopeBy configured, apso server scaffold generates guard files:
src/
guards/
scope.guard.ts # Scope enforcement logic
guards.module.ts # NestJS module with providers
index.ts # ExportsThese are standard NestJS guards. You can inspect the generated code to understand exactly how scoping works.
Enabling Guards
Guards are generated but not enabled globally by default, preserving backward compatibility. Enable them based on your needs:
Global enable (recommended for most applications)
Uncomment the APP_GUARD provider in src/guards/guards.module.ts:
providers: [
ScopeGuard,
{
provide: APP_GUARD,
useClass: ScopeGuard,
},
],This applies scope enforcement to every route automatically. Use the @SkipScopeCheck() decorator to exempt specific routes.
Per-controller enable
Apply to specific controllers:
import { ScopeGuard } from '../guards';
@UseGuards(ScopeGuard)
@Controller('projects')
export class ProjectController { }Per-route enable
Apply to individual routes:
@UseGuards(ScopeGuard)
@Get(':id')
findOne(@Param('id') id: string) { }Decorator Reference
The generated guards provide decorators to control enforcement:
import { Public, SkipScopeCheck } from './guards';
// Skip ALL guards (no authentication or scope checking)
@Public()
@Get('health')
healthCheck() { }
// Skip only scope checking (authentication still required)
@SkipScopeCheck()
@Get('admin/stats')
adminStats() { }Authentication and Scoping
Auth and scoping are designed to work together. When both are configured:
- AuthGuard runs first — validates the session or token, populates
request.authwith theAuthContext - ScopeGuard runs second — reads scope values (like
organizationIdorworkspaceId) fromrequest.authand enforces isolation
The AuthContext provides the scope values that scopeBy needs:
// AuthGuard populates this on every authenticated request:
request.auth = {
userId: "user_123",
organizationId: "org_456", // ScopeGuard uses this
workspaceId: "org_456", // Or this (alias)
roles: ["admin"],
// ...
}
// ScopeGuard then uses organizationId/workspaceId to:
// - Filter: GET /projects -> only org_456's projects
// - Inject: POST /projects -> auto-set organizationId
// - Verify: GET /projects/:id -> ensure it belongs to org_456Enable both guards globally for automatic protection:
// src/guards/guards.module.ts
providers: [
AuthGuard,
ScopeGuard,
{
provide: APP_GUARD,
useClass: AuthGuard, // Runs first
},
{
provide: APP_GUARD,
useClass: ScopeGuard, // Runs second
},
],The Security Stack
| Layer | Guard | Question Answered | Configuration |
|---|---|---|---|
| 1. Identity | AuthGuard | ”Who is this user?” | auth in .apsorc |
| 2. Isolation | ScopeGuard | ”Which data can they see?” | scopeBy on entities |
| 3. Authorization | Your custom guard | ”What actions can they take?” | Custom RBAC logic |
Apso handles layers 1 and 2. Layer 3 — fine-grained permissions like “can this user edit this specific resource?” — is left to your business logic since permission models vary widely between applications.
Scoping vs. Authorization
These are separate concerns, and Apso intentionally keeps them distinct:
Scoping (what scopeBy provides):
- Answers: “Which rows can this user see or modify?”
- Data isolation based on tenant or workspace membership
- Automatic filtering and injection
- Declarative, configuration-driven
Authorization (separate concern):
- Answers: “Can this user perform this specific action?”
- Role-based access control (RBAC)
- Permission checking (create, read, update, delete)
- Typically implemented with custom guards or decorators
A user might be scoped to a workspace (they can only see their workspace’s data) but still have limited permissions within that scope (they can read projects but not delete them). scopeBy handles the first concern; your custom authorization logic handles the second.
Complete Example
A multi-tenant project management application with workspace isolation, tiered scoping, audit logs, and role-based bypass:
{
"version": 2,
"rootFolder": "src",
"auth": {
"provider": "better-auth",
"sessionEntity": "session",
"userEntity": "User",
"accountUserEntity": "AccountUser",
"organizationField": "organizationId"
},
"entities": [
{
"name": "User",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "email", "type": "text", "unique": true, "is_email": true },
{ "name": "name", "type": "text", "nullable": true }
]
},
{
"name": "session",
"fields": [
{ "name": "token", "type": "text", "unique": true },
{ "name": "expiresAt", "type": "timestamptz" },
{ "name": "userId", "type": "text" }
]
},
{
"name": "Organization",
"fields": [
{ "name": "name", "type": "text" },
{ "name": "plan", "type": "enum", "values": ["free", "pro", "enterprise"] }
]
},
{
"name": "AccountUser",
"fields": [
{ "name": "role", "type": "enum", "values": ["owner", "admin", "member"] }
]
},
{
"name": "Project",
"scopeBy": "organizationId",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "name", "type": "text" },
{ "name": "status", "type": "enum", "values": ["Active", "Archived"] }
]
},
{
"name": "Task",
"scopeBy": ["organizationId", "projectId"],
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "title", "type": "text" },
{ "name": "completed", "type": "boolean", "default": false }
]
},
{
"name": "Comment",
"scopeBy": "task.organizationId",
"created_at": true,
"fields": [
{ "name": "text", "type": "text" }
]
},
{
"name": "AuditLog",
"scopeBy": "organizationId",
"scopeOptions": {
"injectOnCreate": true,
"enforceOn": ["find", "get"],
"bypassRoles": ["superadmin"]
},
"created_at": true,
"fields": [
{ "name": "action", "type": "text" },
{ "name": "details", "type": "json", "nullable": true }
]
}
],
"relationships": [
{ "from": "AccountUser", "to": "User", "type": "ManyToOne" },
{ "from": "AccountUser", "to": "Organization", "type": "ManyToOne" },
{ "from": "Project", "to": "Organization", "type": "ManyToOne" },
{ "from": "Task", "to": "Project", "type": "ManyToOne" },
{ "from": "Task", "to": "Organization", "type": "ManyToOne" },
{ "from": "Comment", "to": "Task", "type": "ManyToOne" },
{ "from": "AuditLog", "to": "Organization", "type": "ManyToOne" }
]
}In this example:
- Project is scoped by
organizationId— users only see their organization’s projects - Task is scoped by both
organizationIdandprojectId— double isolation - Comment inherits scope through the
task.organizationIdpath — no direct scope field needed - AuditLog is scoped but only enforced on reads (
find,get), and superadmins can see all logs across organizations
Best Practices
1. Scope all tenant-specific entities
Any entity that contains data belonging to a specific tenant should have scopeBy. When in doubt, scope it.
2. Use consistent scope field names
Pick a scope field name (workspaceId, organizationId, tenantId) and use it consistently across all entities. This makes the schema easier to understand and reduces mistakes.
3. Scope from the start
Adding scopeBy to an existing entity later requires backfilling the scope column for all existing rows. Design for multi-tenancy from the beginning.
4. Match scope across related entities
Related entities should typically share the same scope. If Project is scoped by organizationId, then Task (which belongs to a Project) should also be scoped by organizationId:
{ "name": "Project", "scopeBy": "organizationId" },
{ "name": "Task", "scopeBy": ["organizationId", "projectId"] }5. Use bypassRoles sparingly
Scope bypass is a powerful capability. Limit bypassRoles to administrative roles that genuinely need cross-tenant access, and audit their usage.
6. Consider nested scoping for deeply nested entities
For entities three or more levels deep in the relationship hierarchy, use nested path scoping ("scopeBy": "parent.grandparent.scopeField") rather than duplicating the scope field on every entity. This reduces data redundancy while maintaining isolation.