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
| Boundary | Enforced by | You do |
|---|---|---|
| Org from org. Your Dodil org vs. everyone else’s | The 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 bucket | IAM, if you scope the app’s service account to that bucket | Grant one bucket, not the org (below) |
| Person from person, client from client. Inside one app | Your app’s queries | Filter 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 rowRules 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-Jwton 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 listA 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
audagainst 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
/mcptools as well as the UI and the API. - Send a request with a forged
X-Dodil-Userheader 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).