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.
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);USINGfilters reads: aSELECTonly ever sees rows whosetenant_idmatches the current session's tenant.WITH CHECKguards writes: anINSERTorUPDATEcannot set a row'stenant_idto 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:
| Pool | Role | Runs | RLS |
|---|---|---|---|
app (url) | the app role (app_user by default) | request-path queries | enforced |
sys (sys_url) | a superuser | DDL and migrations | bypassed |
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.