All Docs
FeaturesCalmony Sanctions MonitorUpdated March 11, 2026

Tenant Isolation

Tenant Isolation

The sanctions monitor enforces multi-tenant data isolation so that each user only ever sees their own screening data. This page describes the current isolation model, the utility functions available for applying it consistently, and the path to organisation-level isolation in the future.


Current Behaviour (MVP)

All data is filtered by userId. Every database query that reads or writes user-owned records (people, matches, screening results, audit logs) includes a WHERE userId = :userId condition. This means:

  • Users cannot see each other's data.
  • Admins can optionally bypass this for support purposes.
  • Organisation membership has no effect on data visibility yet.

Tenant Isolation Utility (src/lib/tenant.ts)

To make tenant filtering consistent and reusable across API routes, three utility functions are provided.

Import

import { getTenantFilter, tenantFilter, isResourceOwnedByTenant } from "@/lib/tenant";
import { people } from "@/db/schema";

getTenantFilter(userId, organisationId?, config?)

Returns a higher-order function. Useful when you want to build the filter once and apply it to multiple queries.

const filter = getTenantFilter(userId, organisationId);

const results = await db
  .select()
  .from(people)
  .where(filter(people));

tenantFilter(table, userId, organisationId?, config?)

Direct filter utility for a single query.

const results = await db
  .select()
  .from(people)
  .where(tenantFilter(people, userId));

isResourceOwnedByTenant(resourceUserId, currentUserId, organisationId?, config?)

Ownership check to run before any update or delete operation. Returns true if the current user is permitted to modify the resource.

if (!isResourceOwnedByTenant(resource.userId, currentUserId)) {
  return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

TenantConfig

interface TenantConfig {
  /** If true, use organisationId for filtering when available. Default: false (MVP). */
  enableOrgIsolation?: boolean;
}

The config parameter is optional. The default configuration sets enableOrgIsolation: false, meaning all functions filter by userId regardless of whether an organisationId is supplied.


Organisation-Level Tenancy (Future Enhancement)

The utility is designed to support org-level isolation without breaking existing behaviour. When org-level tenancy is enabled:

  • Filtering switches from userId to organisationId, allowing all members of an organisation to share screening data.
  • This is activated by passing { enableOrgIsolation: true } in the TenantConfig.

Prerequisites before enabling org isolation:

  1. Add organisationId columns to all relevant data tables (people, matches, etc.).
  2. Enable enableOrgIsolation in the tenant config for the affected queries.
  3. Sync Clerk organisation membership so organisationId is consistently populated.

Until those steps are complete, enableOrgIsolation should remain false (the default).


RBAC Integration — Organisation ID Support

getOrCreateUserProfile() in src/lib/rbac.ts now stores an organisationId on the user record, which provides the source of truth for future org-level filtering.

Basic usage (per-user isolation, unchanged)

import { getOrCreateUserProfile } from "@/lib/rbac";

const profile = await getOrCreateUserProfile(userId, email, name, imageUrl);
// profile: { userId, role, organisationId }

With organisationId from Clerk org metadata

import { getOrCreateUserProfile } from "@/lib/rbac";

const profile = await getOrCreateUserProfile(userId, email, name, imageUrl, {
  organisationId: clerkOrgId,  // from Clerk's auth().orgId
});

Behaviour:

  • New users are created with the provided organisationId.
  • Existing users have their organisationId updated in the database if the value has changed.
  • If the database update fails, it is logged but the auth flow continues — the in-memory profile reflects the new organisationId.
  • The options parameter is fully optional. Omitting it is identical to prior behaviour.

API Route Convention

All API routes that query user-owned data must include a tenant filter. The recommended approach is:

import { tenantFilter } from "@/lib/tenant";
import { people } from "@/db/schema";

// Inside a route handler after auth check:
const results = await db
  .select()
  .from(people)
  .where(tenantFilter(people, userId));

For routes that do not yet use the utility, the equivalent raw Drizzle clause is eq(table.userId, userId).