Multi-tenancy & RLS
Vibesboard is multi-tenant: every workspace (tenant) shares one Postgres database and one application deployment. Isolation between workspaces is enforced by PostgreSQL row-level security (RLS), not only by application-level checks — a tenant-scoped query issued with no tenant context returns zero rows rather than leaking across workspaces. This page describes the actual mechanism: the two database roles, the policies, and the request-scoped context that drives them.
Two database roles
The app connects to Postgres as one of two roles, configured by two separate connection strings:
| Env var | Role | RLS | Used for |
|---|---|---|---|
DATABASE_URL | vibesboard_app | Enforced | Normal request path — everything application code does |
DATABASE_MIGRATE_URL | vibesboard_migrate | BYPASSRLS | Migrations, seeding, and trusted identity operations (Better Auth) |
Both roles are created in
packages/adapter-postgres/docker/init.sql:
CREATE ROLE vibesboard_migrate WITH LOGIN PASSWORD 'vibesboard_migrate' BYPASSRLS;
CREATE ROLE vibesboard_app WITH LOGIN PASSWORD 'vibesboard_app';vibesboard_migrate owns the schema and runs drizzle-kit migrate and the
seed script at build/deploy time. At runtime, the same role (via
getMigrateDb()) also backs Better Auth's identity operations and
admin-panel routes that need cross-tenant reads after an app-level
requireSuperAdmin() check — see "Super admin and the migrate role" below.
vibesboard_app is what request-handling code uses otherwise, and it has no
BYPASSRLS — every query it runs is subject to the RLS policies described
below.
Keep DATABASE_MIGRATE_URL out of request code
DATABASE_MIGRATE_URL bypasses tenant RLS by design. It exists for migrations
and for identity operations that run before a tenant/user context exists (see
getMigrateDb below). Application business logic must never use it — doing so
would defeat the isolation this page describes.
Row-level security policies
RLS is enabled per-table, table by table, starting in
drizzle/0001_rls_policies.sql
and extended by later migrations as new tenant-owned tables were added
(agent versioning, hybrid memory, embeddings, task configs, and so on). The
shape is the same everywhere: enable RLS on the table, then attach a policy
that checks the row's tenant_id against a Postgres session GUC:
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
CREATE POLICY agents_iso ON agents
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
OR current_setting('app.is_super_admin', true) = 'true'
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);The same pattern (generated via a DO $$ … $$ loop over a table list in the
initial migration) covers every tenant-owned table: agents, agent_links,
hooks, hook_jobs, conversations, messages, conversation_feedback,
notifications, files, embeddings, calendar_connections, bookings,
booking_enquiries, the WhatsApp/Instagram inbox tables, chatwoot_connections,
tenant_feature_toggles, usage_counters, data_connections,
data_action_logs, and tenant_branding. tenants, tenant_members, and
invitations get an equivalent policy keyed on their own id/tenant_id.
users and sessions are keyed on id/user_id instead, since they aren't
tenant-owned. A handful of tables are intentionally global: feature_flags
is readable by any authenticated user but writable only by a super admin,
and platform_branding is publicly readable.
Both USING (read/update/delete visibility) and WITH CHECK (insert/update
validity) clauses carry the condition, so a write that targets a different
tenant's tenant_id is rejected by Postgres itself, not merely hidden from
a later read.
Three GUCs drive every policy:
| GUC | Set from | Effect |
|---|---|---|
app.current_tenant_id | TenantContext.tenantId | Row must match this tenant to be visible/writable |
app.current_user_id | TenantContext.userId | Used by user/session-keyed policies (null for anonymous public-agent traffic) |
app.is_super_admin | TenantContext.isSuperAdmin | true grants cross-tenant visibility on every policy above |
A dedicated coverage test
asserts, against a live Postgres instance, that every table in the schema
(besides an explicitly justified exemption list) has RLS enabled, has at
least one policy, and that every tenant-owned table's policy actually
references app.current_tenant_id. A companion
behavior test
seeds two tenants and asserts that the app role sees only its own tenant's
rows, that a cross-tenant insert is rejected with a Postgres row-level
security error, and that a transaction with no tenant context set returns
zero rows.
The request-scoped tenant context
The GUCs above aren't set by hand per query — they come from a single
context object carried through an AsyncLocalStorage, defined in
packages/adapter-postgres/src/tenant-context.ts:
export type TenantContext = {
tenantId: string
userId: string | null
isSuperAdmin: boolean
}
export async function withTenant<T>(
ctx: TenantContext,
fn: () => Promise<T>
): Promise<T> {
return als.run(ctx, fn)
}userId is null for anonymous traffic — the public agent chat widget and
public hook endpoints run under a resolved tenantId with no signed-in user.
Every database call goes through withDb
(packages/adapter-postgres/src/client.ts),
which opens a transaction, reads the current withTenant context (or falls
back to an empty one), and issues SET LOCAL for each GUC via
set_config(name, value, true) before running the caller's query:
export function withDb<T>(fn: (tx: DbTx) => Promise<T> | T): Promise<T> {
const ctx = getContext() ?? {
tenantId: '',
userId: null,
isSuperAdmin: false
}
return getDb().transaction(async tx => {
for (const stmt of rlsSetLocalSql(ctx)) {
await tx.execute(stmt)
}
return fn(tx)
})
}SET LOCAL scopes the GUCs to the current transaction only, so they can
never leak between requests sharing a pooled connection. If withDb runs
outside any withTenant call, the fallback context has an empty
tenantId, current_setting(...) returns an empty string,
NULLIF('', '') turns that into NULL, and tenant_id = NULL is never
true — the query returns zero rows instead of throwing or leaking. This is
the fail-closed behavior referenced throughout the codebase.
The standard call shape combines both helpers:
import { withTenant } from '@vibesboard/adapter-postgres/tenant-context'
import { withDb } from '@vibesboard/adapter-postgres/client'
import { messages } from '@vibesboard/adapter-postgres/schema'
await withTenant({ tenantId, userId, isSuperAdmin: false }, async () => {
const rows = await withDb(tx =>
tx.select().from(messages).where(eq(messages.conversationId, convId))
)
})How a request gets a tenant context
Route handlers in apps/web don't call withTenant directly for every
request — they resolve the caller's membership first. For example,
requireTenantMember in
apps/web/lib/auth/route-handler.ts
takes the session user and a tenantId (typically from the [tenantSlug]
route or a request body), opens a withTenant/withDb block scoped to that
tenant to look up the caller's tenant_members row, and returns 403 if none
exists:
const rows = await withTenant(
{ tenantId, userId: a.user.id, isSuperAdmin: false },
() =>
withDb(tx =>
tx
.select({ role: tenantMembers.role })
.from(tenantMembers)
.where(
and(
eq(tenantMembers.tenantId, tenantId),
eq(tenantMembers.userId, a.user.id)
)
)
.limit(1)
)
)
if (rows.length === 0)
return { ok: false, response: new NextResponse('Forbidden', { status: 403 }) }requireTenantAdmin layers a role check (TENANT_ADMIN or SUPER_ADMIN) on
top of requireTenantMember. Note that this lookup itself runs with
isSuperAdmin: false and a real tenantId — it relies on RLS to confirm
membership, it doesn't assert admin status client-side.
Super admin and the migrate role
Two distinct mechanisms grant broader-than-one-tenant access, and they are not the same thing:
isSuperAdmin: truein aTenantContext— still goes through thevibesboard_approle and RLS, but every policy'sOR current_setting('app.is_super_admin', true) = 'true'clause passes, so the query can see and, where a policy allows it, write across tenants. This is a real, tested capability of the RLS layer — the "super-admin sees all tenants" case inrls-behavior.test.tsexercises it directly against Postgres — but as of this writing noapps/webcall site actually constructs aTenantContextwithisSuperAdmin: true. The admin panel reaches cross-tenant data through the mechanism below instead; check the source before assuming a given admin code path uses this one.getMigrateDb()/DATABASE_MIGRATE_URL— bypasses RLS entirely at the role level. This is what the admin panel's cross-tenant reads actually use oncerequireSuperAdmin()has authorized the request — for example,apps/web/app/api/admin/tenants/route.tslists all tenants viagetMigrateDb(), not the super-admin GUC above.getMigrateDb()is also used bydrizzle-kit migrate, the seed script, and Better Auth's identity operations (create user, find session), which by necessity run before anycurrent_user_id/current_tenant_idGUC can be established.getMigrateDb()'s doc comment is explicit that other app business logic must not call it. Note thatrequireSuperAdmin()itself uses neither escape hatch — it checksusers.is_super_adminfor the signed-in user via an ordinary self-scoped RLS read (isSuperAdmin: false), which theusers_selfpolicy already permits.
isSuperAdmin is not derived from the tenant context automatically — it's set
by whoever constructs the TenantContext, based on a database check
(users.is_super_admin) that itself has to run first. Treat it as a
capability that call sites opt into deliberately, not a default.
What this means when building on Vibesboard
- Any new tenant-owned table needs
ALTER TABLE … ENABLE ROW LEVEL SECURITYplus a policy following the pattern above — the coverage test will fail CI otherwise. - Always reach the database through
withTenant+withDb(orgetDb()/getMigrateDb()only where explicitly justified). CallinggetDb().select()directly without awithTenantwrapper is a fail-closed bug, not a security hole — you get zero rows, silently, which usually shows up as a confusing "empty" bug rather than a crash. - A single Postgres instance and schema serve all workspaces — there is no per-tenant schema or database. Isolation is entirely row-level, enforced by the policies above.