All Docs
FeaturesAgentOS WorkUpdated March 11, 2026

Multi-Tenant Data Isolation

Multi-Tenant Data Isolation

The platform enforces strict tenant-level data isolation at both the application layer (tRPC middleware) and the database layer (PostgreSQL Row-Level Security). This document covers the architecture, configuration, and API for tenant management.


Architecture Overview

Isolation is applied in two complementary layers:

  1. Application layertenantProcedure tRPC middleware injects tenant context and checks membership/permissions before any procedure runs.
  2. Database layer — PostgreSQL RLS policies ensure that even if application-layer checks are bypassed, queries can only return rows belonging to the caller's org_id.
Request
  └─► tRPC tenantProcedure (loads tenant + membership, injects context)
        └─► Drizzle query (inside withTenantContext → SET LOCAL app.current_org_id)
              └─► PostgreSQL RLS policy (verifies org_id at DB level)

Database Schema

tenants

The canonical tenant registry. One record per organization.

ColumnTypeNotes
idtextPrimary key, UUID
org_idtextFK → organizations.id. Indexed.
tiertenant_tierstarter, professional, or enterprise
settingsjsonbArbitrary tenant configuration
ai_features_enabledtext[]List of enabled AI feature flags
soft_deleted_attimestampGDPR soft-delete timestamp
created_at / updated_attimestampAuto-managed

tenant_members

Explicit membership records with per-module permission grants.

ColumnTypeNotes
idtextPrimary key, UUID
tenant_idtextFK → tenants.id
user_idtextFK → users.id
roletenant_member_roleMember's role within the tenant
permissionsjsonbPer-module grants, e.g. { "hr": "read", "finance": "write" }
invited_bytextFK → users.id
joined_attimestampWhen the member joined
soft_deleted_attimestampSoft-delete timestamp

Unique constraint on (tenant_id, user_id).

data_isolation_policies

Per-module encryption and backup configuration.

ColumnTypeNotes
idtextPrimary key, UUID
tenant_idtextFK → tenants.id
moduledata_isolation_modulehr, finance, or legal
encryption_key_idtextReference to the module's encryption key
backup_policyjsonb{ retentionDays, frequency, crossRegion }

Unique constraint on (tenant_id, module).


Row-Level Security

All tenant-related tables have RLS enabled and enforced (FORCE ROW LEVEL SECURITY).

Policy Logic

  • tenantsorg_id = current_setting('app.current_org_id')::text
  • tenant_membersEXISTS (SELECT 1 FROM tenants t WHERE t.id = tenant_members.tenant_id AND t.org_id = current_setting('app.current_org_id')::text)
  • data_isolation_policies — same EXISTS pattern through tenants

Setting Tenant Context

Use withTenantContext() to execute queries inside a transaction with the correct app.current_org_id set:

import { withTenantContext } from "~/db/rls";

const result = await withTenantContext(db, orgId, async (tx) => {
  return tx.select().from(tenants).where(eq(tenants.orgId, orgId));
});

withTenantContext opens a transaction, calls SET LOCAL app.current_org_id = <orgId>, runs the callback, and commits. All RLS policies fire automatically against the set context.

Note: SET LOCAL scopes the setting to the current transaction only. It is automatically cleared when the transaction ends.


tRPC Middleware

tenantProcedure

Extends orgProcedure with tenant-specific context loading. Use it for any procedure that needs tenant or permission data.

import { tenantProcedure } from "~/lib/trpc/trpc";

export const myRouter = createTRPCRouter({
  doSomething: tenantProcedure.query(async ({ ctx }) => {
    // ctx.tenantId       — the resolved tenant's ID
    // ctx.tenantTier     — 'starter' | 'professional' | 'enterprise'
    // ctx.tenantPermissions — caller's per-module permission map
  }),
});

If the tenant record does not exist for the current org, the procedure throws a NOT_FOUND tRPC error.


Tenant API (tRPC)

All procedures are under the tenant namespace. Mutations require admin role or higher unless noted.

tenant.get

Fetch the tenant record for the current org. If no tenant record exists, one is automatically provisioned (idempotent). Also applies RLS policies for the tenant.

const tenant = await trpc.tenant.get.query();

tenant.update

Update tenant-level settings. Requires admin+.

await trpc.tenant.update.mutate({
  tier: "professional",
  settings: { someKey: "someValue" },
  aiFeaturesEnabled: ["smart-scheduling", "payroll-forecast"],
});

tenant.listMembers

List all active tenant members and their permissions.

const members = await trpc.tenant.listMembers.query();
// Returns: Array<{ userId, role, permissions, joinedAt, ... }>

tenant.upsertMember

Create or update a tenant member's role and permissions. Requires admin+.

await trpc.tenant.upsertMember.mutate({
  userId: "user_abc",
  role: "member",
  permissions: { hr: "read", finance: "write" },
});

tenant.removeMember

Soft-delete a tenant member record. Requires admin+.

await trpc.tenant.removeMember.mutate({ userId: "user_abc" });

tenant.getIsolationPolicies

Retrieve all data isolation policies for the tenant.

const policies = await trpc.tenant.getIsolationPolicies.query();
// Returns: Array<{ module, encryptionKeyId, backupPolicy }>

tenant.setIsolationPolicy

Create or update the isolation policy for a specific module. Requires admin+.

await trpc.tenant.setIsolationPolicy.mutate({
  module: "hr",
  encryptionKeyId: "key-arn-123",
  backupPolicy: {
    retentionDays: 90,
    frequency: "daily",
    crossRegion: true,
  },
});

Audit Logging

All mutating procedures (update, upsertMember, removeMember, setIsolationPolicy) automatically write an entry to the platform audit log. No additional configuration is required.


Tenant Tiers

TierValue
Starterstarter
Professionalprofessional
Enterpriseenterprise

The active tier is stored on the tenants record and exposed via ctx.tenantTier in any tenantProcedure.


Soft Deletes

Both tenants and tenant_members support soft deletion via a soft_deleted_at timestamp column. Records are not physically removed, supporting GDPR data-retention workflows and audit trails.