Skip to Content
We are live but in Staging 🎉
RecipesKeep Customer Data Separate

Keep each customer’s data separate

When one app serves several people, or several client companies, the worst bug is showing one person’s rows to someone else. This page explains which layer enforces which boundary, what your app still has to do, and how to test it.

Who enforces what

BoundaryEnforced byYou do
Org from org. Your Dodil org vs. everyone else’sThe platform. Every request is scoped to the org in its token. A bucket, app, or pipeline in another org reports not found.Nothing
App from app. This app can touch only its own bucketIAM, if you scope the app’s service account to that bucketGrant one bucket, not the org (below)
Person from person, client from client. Inside one appYour app’s queriesFilter every read and write by the signed-in identity (below)

The third row is the one that matters here. A pool user is never a database principal. The app reaches its bucket as itself, through its service account, and bucket permissions can’t express “only Jane’s rows”. The platform tells you reliably who is calling (sub, roles, tenant), and your app turns that into a WHERE clause.

1. Scope every query by the caller

Put the filter inside the use case, not in the route, so the web UI, the API, and the /mcp tools all go through the same check:

def list_deals(caller: dict): return db.fetch( "SELECT * FROM deals WHERE tenant_id = $1 AND (owner_id = $2 OR $3)", caller["tenant"], caller["sub"], "deals:read_all" in caller["permissions"], ) def get_deal(caller: dict, deal_id: str): row = db.fetchrow( "SELECT * FROM deals WHERE id = $1 AND tenant_id = $2", deal_id, caller["tenant"]) if row is None: raise NotFound() # 404 for "not yours", not 403: don't confirm it exists return row

Rules that keep this safe:

  • Every owned table has a tenant_id (and an owner where it applies), set from the caller’s identity on insert and never from the request body.
  • Take identity only from the gateway: X-Dodil-User / X-Dodil-User-Jwt on Ignite, or a token you verified yourself off-gateway (see Sign-in for Ignite apps). Never from a query parameter or a form field.
  • A row the caller may not see is a 404, on reads and writes alike.
  • Key composite indexes on the tenant first (PRIMARY KEY (tenant_id, id)), so the filter is also the fast path.

2. One client company = one tenant

When your customer’s own clients each need a separate space (subsidiaries, client workspaces), make each one an AppID tenant in the same pool:

dodil appid tenant create acme northwind dodil appid tenant member set acme northwind <user-id> manager # roles are positional; replaces the list

A token can be pinned to one tenant at sign-in. It then carries a top-level tenant claim and that tenant’s role permissions. Your app uses tenant as the tenant_id filter above. Signing in to a tenant the person doesn’t belong to fails exactly like a wrong password, so outsiders can’t even discover that the tenant exists. See Core Concepts → App tenants.

If a client needs a hard separation (its own database, its own retention, or a contract that says so), give it its own bucket and pool instead of a tenant. That boundary is then enforced by the platform rather than by your queries.

3. Give the app only its own bucket

Scope the app’s service account to its bucket, not the whole org:

dodil auth service-account grant-role <sa-id> k3-authorization-service k3.editor \ --on resource=drn:dodil:k3:<region>:<org>:bucket/<bucket>

Then a bug in one app can’t read another app’s bucket. Narrower grants on a directory prefix (…/bucket/<b>/dir/<prefix>/) or a single table (…/bucket/<b>/table/<t>) also exist. A table grant covers the row RPCs; SQL over the pg wire needs the bucket grant. See Service accounts .

4. Agents see what their person sees

A person who connects Claude.ai or ChatGPT to your app’s /mcp signs in as themselves, so the same row filters apply to their agent.

Apps that share a pool. A pool token is accepted by every Ignite app attached to that pool: the gateway doesn’t check the token’s aud against the app yet. Within one customer’s system (one pool, one app, many modules) that’s the intended single sign-on. If two apps must not trust each other’s sessions, give each its own pool. Never give an assistant a service-client token for this: a service token has no person behind it, so there is nothing to filter by and the agent would see every row.

5. Test it

Unit tests that set the identity header by hand can’t prove isolation, because they agree with themselves. Test with two real identities against the deployed test copy:

  • Sign in as user A in tenant 1 and create a record. Sign in as user B in tenant 2: B’s list doesn’t include it, and fetching it by id returns 404.
  • As B, update or delete A’s record by id. Expect 404, and confirm nothing changed.
  • Repeat each check through the /mcp tools as well as the UI and the API.
  • Send a request with a forged X-Dodil-User header to the public URL. The gateway must ignore it (the request is anonymous, or uses the real session’s identity).
  • Search and export endpoints are filtered too (these are the most common source of leaks).