Row-Level Security & database roles

Creating tables is only half of persistence; the other half is making sure a caller can only ever touch the rows they're allowed to. Chitragupt emits that enforcement in the database itself — as PostgreSQL Row-Level Security (RLS) policies and a least-privilege role — so tenant isolation holds even if application code forgets to filter.

Single-tenant by default

Whether an entity is isolated is a property of the entity. Chowk is a single-tenant demo: its aggregates are declared global, so their rows belong to no tenant and carry no RLS.

sub-domains/ordering/entities/order_line.vishwakarma
entity OrderLine aggregate_root global {
  // … a global aggregate — one shared table, no tenant column, no RLS …
}

global is the right choice for a single-tenant app. The interesting case is what happens when an aggregate is tenant-scoped instead.

Tenant-scoping turns on RLS automatically

Declare an aggregate isolated by tenant — isolation_axes [tenant_id] (or the tenant_user shorthand) — and it gains a tenant_id column, and Chitragupt emits the RLS to enforce it. For a tenant-scoped Order, the generated DDL is:

ALTER TABLE ordering.order ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON ordering.order
  USING      (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
  • USING filters reads: a SELECT only ever sees rows whose tenant_id matches the current session's tenant.
  • WITH CHECK guards writes: an INSERT or UPDATE cannot set a row's tenant_id to anyone else's.
  • current_setting('app.tenant_id') is a per-request session variable. The framework's middleware resolves the authenticated caller's tenant and sets it on the connection before any query runs — so the tenant boundary is applied by the database on every statement, not by hand in each query.

The payoff: a query that forgets its where this.tenant_id == … still can't leak another tenant's rows, because the database refuses to return them. Isolation is a property of the table, not the diligence of the code above it.

The two-pool role model

RLS only means something if the connection running the query isn't allowed to bypass it — a superuser ignores every policy. So Chitragupt sets up two roles, and the datastore connects through two pools:

PoolRoleRunsRLS
app (url)the app role (app_user by default)request-path queriesenforced
sys (sys_url)a superuserDDL and migrationsbypassed

This is why the datastore declares both url and sys_url. Request traffic runs as the least-privileged app role, which RLS gates; schema changes and migrations run as the elevated role, which needs to create and alter tables. The bootstrap migration emits a CREATE ROLE for the app role, and every RLS policy targets it — a policy that gated a superuser would silently no-op.

Grants and database-per-app

The app role's permissions ride the migration: every CREATE SCHEMA in the diff bundles the GRANTs the role needs on the tables Chitragupt creates, and a bootstrap CREATE ROLE provisions the role itself. You don't run a separate command to keep grants in sync — they come with generate/migrate.

The app role name is resolved per datastore. It defaults to app_user, but a datastore can override it (app_role: "kalki_app"). That override matters when several apps share one PostgreSQL cluster (database-per-app): each gets a distinct, non-colliding role with real isolation, rather than every app silting up as the same app_user and stepping on each other's grants. Because the role is resolved from the datastore at diff time — not frozen into a committed migration — a shared-cluster deployment gets correct role isolation by construction.

In short

You declare who owns a row (global vs isolation_axes [tenant_id]); Chitragupt emits the RLS policy, the tenant-scoped column, the least-privilege role, and the grants that enforce it — and wires the request path to run under that role while migrations run elevated. Multi-tenant isolation becomes a line on an entity, not a security review of every query.