Skip to content

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

ConcernWhere
Verify the actor's tenantAuth plugin (actor.tenantId)
Tenancy decisions on each requestContextProvider<TenantContext>
Filter rows in CRUD endpointsEntityCrudConfig.scope
Bypass scoping for system adminsactor.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:

dart
JwtAuthPlugin(
  verifier: verifyToken,
  tenantClaim: 'org_id',
);

For API keys, return the tenant from the lookup:

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

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

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

dart
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 /products and GET /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:

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

dart
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

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

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

sql
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