Multi-Tenancy
A typical SaaS server scopes every row to a tenant. Vyuh Server gives you the primitives — Actor.tenantId, EntityCrudConfig.scope, ContextProvider — to enforce it without scattering the policy across handlers.
The Pattern
| Concern | Where |
|---|---|
| Verify the actor's tenant | Auth plugin (actor.tenantId) |
| Tenancy decisions on each request | ContextProvider<TenantContext> |
| Filter rows in CRUD endpoints | EntityCrudConfig.scope |
| Bypass scoping for system admins | actor.roles.contains('system_admin') (sparingly) |
Each lives in one place. The handler sees the result.
1. Tenant ID in the Actor
Map the tenant claim during auth:
JwtAuthPlugin(
verifier: verifyToken,
tenantClaim: 'org_id',
);For API keys, return the tenant from the lookup:
ApiKeyAuthPlugin(
lookup: (key) async {
final row = await _findKey(key);
return ServiceActor(
id: row.partnerId,
serviceName: row.partnerName,
tenantId: row.tenantId,
roles: row.scopes,
);
},
);The framework now answers req.actor.tenantId for every authenticated request.
2. A Tenant Context Provider (Optional but Cleaner)
For richer per-tenant data (e.g., plan, region, feature flags), wire a ContextProvider:
class TenantContext {
TenantContext({required this.id, required this.plan, required this.region});
final String id;
final String plan; // 'free' | 'pro' | 'enterprise'
final String region; // 'us-east-1' | 'eu-west-1'
}
final tenancyFeature = FeatureDescriptor(
name: 'app.tenancy',
context: [
ContextProvider.of<TenantContext>((req) async {
final actor = req.actor;
if (actor.tenantId == null) {
throw MissingTenantException();
}
return await _loadTenant(actor.tenantId!);
}),
],
);
Future<TenantContext> _loadTenant(String id) async {
// Cache by id; refresh on miss.
return _cache.putIfAbsent(id, () => _fetchFromDb(id));
}In any handler:
Future<Response> listProducts(Request req) async {
final tenant = req.di.get<TenantContext>();
// tenant.id, tenant.plan, tenant.region
}3. Row Scoping via scope
The big win — every CRUD route auto-scopes by tenant without per-handler SQL:
final productsConfig = EntityCrudConfig<Product>(
schema: 'public',
table: 'products',
fromJson: Product.fromJson,
toJson: (p) => p.toJson(),
// ...
scope: (req, _) {
final actor = req.actor;
final tenantId = actor.tenantId;
if (tenantId == null) {
throw EntityValidationException(
field: 'tenant_id',
reason: 'Authenticated actor has no tenant id',
);
}
return FilterCondition(
field: 'tenant_id',
operator: FilterOps.equals,
value: FilterValue.single(tenantId),
);
},
);This filter AND's into every:
GET /productsandGET /products/count(read)GET /products/:id(read)PUT /products/:id(write — the row must match)DELETE /products/:id(write — the row must match)
A user from tenant A asking GET /products/<row-from-tenant-B> gets a clean 404 Not Found — the row is invisible at the SQL level.
4. Postgres Row-Level Security (Defense in Depth)
scope is the application-level enforcement. For belts and suspenders, enable RLS at the database too:
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
CREATE POLICY products_tenant_isolation ON products
FOR ALL TO app_role
USING (tenant_id = current_setting('app.current_tenant', true));Then set the session variable per request in a middleware:
Middleware tenantSetterMiddleware() {
return (inner) {
return (req) async {
final actor = req.actor;
if (actor.tenantId != null) {
await vyuh.db.execute(
'SELECT set_config(\'app.current_tenant\', @t, true)',
parameters: {'t': actor.tenantId},
);
}
return inner(req);
};
};
}If a misconfigured scope ever ships missing the predicate, RLS catches it.
5. Cross-Tenant Operations
Some operations should span tenants — admin panels, support tools, batch migrations. Two clean shapes:
Privileged role
scope: (req, _) {
final actor = req.actor;
if (actor.roles.contains('platform_admin')) {
return null; // opt out — every row visible
}
final tenantId = actor.tenantId;
if (tenantId == null) {
throw EntityValidationException(
field: 'tenant_id',
reason: 'Authenticated actor has no tenant id',
);
}
return FilterCondition(
field: 'tenant_id',
operator: FilterOps.equals,
value: FilterValue.single(tenantId),
);
},A "platform admin" sees everything. Other actors see only their tenant.
Service routes
Put cross-tenant operations on a separate RouteModule protected by API keys with a platform.scope claim:
final adminModule = RouteModule(
name: 'app.admin',
basePath: '/admin',
protectedPaths: ['/'],
setup: (scope) {
scope.post('/migrate/products', _migrateProductsHandler);
scope.get('/health/tenants', _tenantHealthHandler);
},
);
// Custom middleware on /admin paths:
FeatureDescriptor(
name: 'app.admin',
middlewares: [
MiddlewareSpec(
path: '/admin',
middleware: requireRoleMiddleware('platform_admin'),
order: 700,
),
],
extend: (scope) => adminModule.applyTo(scope),
);6. Audit Stamping
When EntityCrudCapability.audit is in EntityCrudConfig.capabilities, every write gets created_by / updated_by from the actor id — automatically. Pair with tenant_id:
CREATE TABLE products (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
-- ...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL
);The framework stamps both fields — your handlers don't need to thread the actor through.
Where to Go Next
- Entity CRUD Plugin —
scopein context - JWT Auth — claim mapping into
Actor.tenantId - Writing a Feature — apply these patterns to custom routes