Multi-tenancy
Apso provides application-layer data isolation through the scopeBy property on entities. Use it to limit users to the tenant data that they can read or change.
When you add scopeBy to an entity, Apso generates guards that filter queries, inject scope values on create, and verify ownership for single-resource operations.
Compare isolation approaches
Common approaches to multi-tenant data isolation have different tradeoffs:
- Database RLS, as used by Supabase, ties authorization to PostgreSQL policies and database tooling.
- Manual filtering repeats authorization logic across endpoints and can leave new endpoints unprotected.
- ORM middleware centralizes filtering but can require framework-specific customization.
Apso generates application-layer guards from the schema:
- Declarative: Define scope once in
.apsorc, and Apso applies it to generated operations. - Inspectable: Read, debug, test, and extend the generated guard code.
- Portable: Use the same schema-level pattern with each supported framework and database adapter.
- Configurable: Set injection, enforcement, and role-based bypass behavior for each entity.
The generated data isolation logic is explicit code. You can inspect it with a debugger, test it, and extend it as requirements change.
How scopeBy works
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 every tenant-isolated entity:
{
"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 /Projectsreturns only projects whereworkspaceIdmatches the authenticated user’s workspacePOST /Projectsautomatically setsworkspaceIdfrom the request contextGET /Projects/:idverifies the project belongs to the user’s workspace before returning itPATCH /Projects/:idandDELETE /Projects/:idverify ownership before modifying it
The scope value (workspaceId) comes from the authenticated request context, which the auth guard populates. See Authentication and scoping for the complete request flow.
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"] | CRUD operations that enforce scope checking. For example, an audit log can allow reads and block direct writes through the API. |
bypassRoles | string[] | [] | Roles that skip scope checking. List only administrative roles that require cross-tenant access. |
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 generate 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. Implement layer 3 in your product logic because fine-grained permissions vary by application. For example, a custom rule can decide whether a user can edit a specific resource.
Compare scoping and 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
Add scopeBy to every entity that contains tenant-owned data.
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
Use the same scope for related entities. If Project is scoped by organizationId, also scope its Task entities 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.