Skip to Content
Apso is in public beta. Get started
GuidesSchemaMulti-tenancy

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:

  1. 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
  2. Auto-filters list queries by scope values (GET without ID): users only see rows that belong to their scope
  3. 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:

.apsorc (entity excerpt)
{ "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 /Projects returns only projects where workspaceId matches the authenticated user’s workspace
  • POST /Projects automatically sets workspaceId from the request context
  • GET /Projects/:id verifies the project belongs to the user’s workspace before returning it
  • PATCH /Projects/:id and DELETE /Projects/:id verify 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

OptionTypeDefaultDescription
injectOnCreatebooleantrueAutomatically 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.
enforceOnstring[]["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.
bypassRolesstring[][]Roles that skip scope checking. List only administrative roles that require cross-tenant access.

enforceOn operations

The enforceOn array accepts any combination of:

OperationHTTP methodDescription
findGET /entityList/search operations
getGET /entity/:idSingle-resource retrieval
createPOST /entityCreating new records
updatePATCH /entity/:idUpdating existing records
deleteDELETE /entity/:idDeleting 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 # Exports

These 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:

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:

  1. AuthGuard runs first: validates the session or token, populates request.auth with the AuthContext
  2. ScopeGuard runs second: reads scope values (like organizationId or workspaceId) from request.auth and 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_456

Enable 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

LayerGuardQuestion answeredConfiguration
1. IdentityAuthGuard”Who is this user?”auth in .apsorc
2. IsolationScopeGuard”Which data can they see?”scopeBy on entities
3. AuthorizationYour 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:

.apsorc
{ "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 organizationId and projectId: double isolation
  • Comment inherits scope through the task.organizationId path: 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.

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.

Next steps

Last updated on