The problem — and why it matters
Once your app knows who signed in (see the companion post, App Sign-In on DODIL), the next question is what they can do. A sales rep should see only their deals; a finance user with the "approver" role should be able to approve invoices; everyone else should not. This is authorization, and it splits into two questions that are easy to conflate and expensive to get wrong:
- Coarse "can this user do X?" — can they reach the approve-invoice action at all?
- Fine "which rows?" — of the invoices they can act on, which ones are theirs?
The industry's default answer to (1) is a policy server — OPA, or a home-grown permissions microservice — that every request calls to ask "is this allowed?" That's a network hop on your hot path, a second service to run, and a cache to keep coherent. The default answer to (2) is authz logic smeared through handlers, or a hope that the database's own RBAC will save you (it won't — a DataK3 bucket grant is per-bucket, not per-row).
dodil-appid answers (1) in the token and leaves (2) to the app — cleanly. A pool
carries a role → permission catalog: you declare that approver means
invoices:read + invoices:approve. Assign a user the approver role and their JWT
comes back carrying both app_roles: ["approver"] and the catalog-expanded
permissions: ["invoices:approve","invoices:read"]. Your app checks permissions
locally, with zero round-trips — no policy server, no per-request authz call. For
(2), the app filters rows by the verified sub/roles using its own service account —
which is exactly the two-plane rule, and exactly the per-row authorization no bucket
RBAC can express.
NOTE
The two-plane rule, restated for authz. appid tells the app the user's identity
(sub, email) and their app-level grants (app_roles, permissions). It grants
the user no data-plane access. The app reaches DataK3 through its own
k3.editor service account and uses sub/roles as filter values — the row-level
"only their deals" authorization the plane itself cannot do. An app-user is never a
bucket principal.
What you'll prove: define a role catalog on a pool, assign a role to a real user,
re-login, and read the expanded app_roles + permissions off the token — then add a
tenant and see per-tenant roles merge into a tenant-pinned token.
Prerequisites
- The
dodilCLI, logged in (dodil auth login) — the org is resolved from your token. - A pool with a user in it. If you don't have one, do
Step 1 of the sign-in post first. Everything below was
run live on 2026-09-04 against prod (org
ihdiash, poolprod-smoke, the user57e11c12-…from that post) and the catalog was re-read live on 2026-09-05; the outputs are the real ones. - It helps to know how the token reaches your handler, because that decides where you read
permissionsfrom. On Ignite with auser_poolattached, the gateway verifies the token and injects it — you read a header. Off the gateway, you verify it yourself. Both are in App Sign-In on DODIL; the catalog below is identical either way.
Step 1 — Define the role → permission catalog
A pool's role catalog maps each role to a list of resource:action permissions.
This is the whole policy: declare it once on the pool, and it's the source of truth every
token is expanded against. Here approver grants read and approve on invoices.
On the prod-smoke pool, define a role called approver that grants invoices:read and invoices:approve, then show me the catalog.
Role catalog updated on prod-smoke: approver → [invoices:read, invoices:approve]. Any user assigned 'approver' will now get those two permissions expanded into their token's permissions claim on next login — no policy server, no per-request lookup.
# role = comma-separated resource:action permission list
dodil appid roles set prod-smoke approver=invoices:read,invoices:approve
# read it back
dodil appid roles get prod-smoke
# → approver: [invoices:read, invoices:approve]You can define as many roles as the app needs in the same style —
rep=deals:read,deals:write, admin=users:*,invoices:*, and so on. Permissions are lowercase
resource:action strings and * is allowed as the action. The catalog lives on the pool as
data (a JSON column), validated server-side; there's no policy language to learn and no policy
service to deploy.
WARNING
roles set REPLACES the whole catalog, exactly like appid settings set replaces the
whole settings object. Roles you omit are dropped — and dropping one is quiet, not loud: users
keep the role name in their token, it just expands to no permissions, so authorization
silently starts failing while login still works. Pass every role on every call, or keep the
catalog in a file and use dodil appid roles set POOL --file @catalog.json.
Step 2 — Assign a role to a user
Roles set with user set-roles are pool-wide — they apply everywhere in the app.
Assign approver to the user from the sign-in post:
Give user 57e11c12-… the approver role on the prod-smoke pool.
User 57e11c12-… now has appRoles: [approver] on prod-smoke. Their next token will carry app_roles:[approver] plus the catalog-expanded permissions:[invoices:approve, invoices:read].
# pool-wide role assignment (user id from signup/login)
dodil appid user set-roles prod-smoke 57e11c12-… approver
# → appRoles: ["approver"]The assignment is stored on the user's row in the pool's user store (appid's control-plane
Postgres by default, or the app's own DataK3 bucket if you bound one with
dodil appid user-store use-bucket). It takes effect on the user's next token — pool-wide
roles ride every login and refresh from now on. Behind the Ignite gateway that means the change
lands on the next single-flight refresh, not instantly on the current request.
Step 3 — Roles + permissions land in the token
Have the user log in again — through the gateway's /.dodil/auth/login if the app is on
Ignite, or with the password grant on POST {issuer}/token if you are driving the pool
directly — and decode the new access token. It now carries the role and the expanded
permission set:
{
"iss": "https://appid.dodil.io/ihdiash/prod-smoke",
"aud": "pool:prod-smoke",
"sub": "57e11c12-…",
"email": "[email protected]",
"connection": "local",
"app_roles": ["approver"],
"permissions": ["invoices:approve", "invoices:read"],
"amr": ["pwd"],
"iat": 1757000000,
"exp": 1757000900
}Two claims, two jobs:
app_roles: ["approver"]— the roles the user holds, as assigned.permissions: ["invoices:approve","invoices:read"]— the catalog expanded at mint time. This is the RBAC payoff: the app does not look up whatapprovermeans on each request — the token already carries the resolved permissions.
So the coarse check is a local set membership test with zero round-trips. The only question is where your handler gets those claims from — and that depends on whether the app sits behind the Ignite gateway.
How your app reads them — on Ignite (the default)
With a user_pool attached to the app, the gateway has already verified the token at the
edge and injected it. Your handler reads headers and verifies nothing:
X-Dodil-User— the convenience object:sub,email,connection,app_roles.X-Dodil-User-Jwt— the full verified token. This is the only place the catalog-expandedpermissionsclaim lives — it is not inX-Dodil-User.
So roles come from one header and permissions from the other. Reach for permissions for the
actual gate (it's the expanded, authoritative set); app_roles is for display and for
role-shaped logic like "show the admin tab".
# your FastAPI handler, behind the Ignite gateway.
# No signature check: the gateway verified the token and stripped any inbound copy of
# these headers, so they cannot be forged. See /library/app-authentication.
from fastapi import Request, HTTPException
def claims(request: Request) -> dict:
tok = request.headers.get("X-Dodil-User-Jwt")
if not tok:
raise HTTPException(401, "end-user login required")
body = tok.split(".")[1]
body += "=" * (-len(body) % 4) # base64url padding
return json.loads(base64.urlsafe_b64decode(body)) # sub, app_roles, permissions, tenant, …
def require(request: Request, perm: str):
if perm not in claims(request).get("permissions", []):
raise HTTPException(403, f"missing {perm}")
def roles(request: Request) -> list[str]: # the cheap header, for UI affordances
return json.loads(request.headers.get("X-Dodil-User", "{}")).get("app_roles", [])How your app reads them — off the gateway
An app hosted elsewhere, or a machine-to-machine caller holding a pool token, verifies the JWT
itself — EdDSA, iss and aud both mandatory, exactly as in the sign-in post — and then
reads the same permissions claim off the result:
# your API handler, NOT behind the Ignite gateway
claims = verify(bearer_token, ISSUER, "pool:prod-smoke") # raises on bad iss/aud/sig
def require(perm):
if perm not in claims.get("permissions", []):
raise PermissionError(f"missing {perm}")The catalog, the expansion and the claim are identical in both paths. Only the trust step differs: injected-and-pre-verified, or fetched-and-self-verified.
And then the rows
Either way, the coarse gate is in-process and the fine one is yours:
# coarse authz — no policy server, no network hop:
require(request, "invoices:approve") # 403 if the token lacks it
# fine authz — the app filters ROWS by the verified sub, over ITS OWN service account.
# The bucket grant is per-bucket; per-row "only theirs" is the app's job:
uid = claims(request)["sub"]
# psql "$APP_SA_PGURL" -c \
# "SELECT * FROM invoices WHERE status='pending' AND assigned_to = '$uid'"That second half is the two-plane rule in code: permissions gates the action
in-process; the rows are filtered by sub in the app's own query, run as the app's
service account — never as the user, who is not a bucket principal. No bucket RBAC can
express "only this user's rows"; that authorization is precisely what the app owns.
NOTE
Platform principals carry no permissions. If the app also serves service accounts
(X-Dodil-Auth-Source: platform), there is no pool token and therefore no expanded
permission set — authorize those callers by their platform grant, on a separate branch. The
config matrix that decides which principals reach you is in
App Sign-In on DODIL.
Step 4 — Tenants: the ERP layer
Pool-wide roles cover a single-org app. Real ERP and B2B apps have sub-orgs — subsidiaries, departments, customer workspaces — where the same person holds different roles in each. That's a tenant: a named sub-org inside the pool, with per-member roles, stored in the pool's user store.
On prod-smoke, create a tenant for the northwind subsidiary and add user 57e11c12-… to it as an approver.
Tenant northwind created in prod-smoke. User 57e11c12-… added as a member with role approver in that tenant. The user's logins now carry a tenants map (northwind → [approver]); passing tenant:northwind on the grant pins the token to that tenant and merges its role permissions with the pool-wide ones.
# a tenant is a sub-org inside the pool
dodil appid tenant create prod-smoke northwind
# per-tenant role assignment (independent of pool-wide roles)
dodil appid tenant member add prod-smoke northwind 57e11c12-… --role approverNow the tenant dimension shows up on login. A plain login carries a tenants map
(slug → roles) so the UI can offer a "choose a workspace" picker; passing tenant on
the grant pins the token to that tenant and merges its roles into permissions:
export ISSUER="https://appid.dodil.io/ihdiash/prod-smoke"
# log in pinned to the northwind tenant
curl -s "$ISSUER/token" \
-H 'content-type: application/json' \
-d '{"grant_type":"password","email":"[email protected]","password":"S3cur3-passphrase!","tenant":"northwind"}'The resulting token is scoped to that tenant, and its permissions claim is the
union of the user's pool-wide roles and their roles within northwind — so a user
who is a plain rep company-wide but an approver inside one subsidiary approves
invoices only in that workspace's tokens. Non-membership is rejected like a bad
credential (you can't pin to a tenant you don't belong to). The app reads the pinned
tenant and, again, filters rows by it over its own service account.
One-shot
App authorization on DODIL with dodil-appid — RBAC in the token, row authz in the app (org from your login token):
1. Catalog: dodil appid roles set prod-smoke approver=invoices:read,invoices:approve
dodil appid roles get prod-smoke → approver: [invoices:read, invoices:approve]
⚠ roles set REPLACES the whole catalog — omitted roles expand to NO permissions (silent 403s).
Keep it in a file: dodil appid roles set POOL --file @catalog.json
2. Assign: dodil appid user set-roles prod-smoke USER_ID approver → appRoles:["approver"] (pool-wide)
Takes effect on the user's NEXT token (next login/refresh), not the current request.
3. Token carries: "app_roles":["approver"], "permissions":["invoices:approve","invoices:read"]
(catalog expanded at MINT time — no per-request lookup, no policy server).
WHERE THE APP READS IT:
- On Ignite with user_pool attached: the gateway verified it and injected it.
app_roles → X-Dodil-User (JSON: sub, email, connection, app_roles)
permissions → X-Dodil-User-Jwt (the verified token — the ONLY place permissions live)
Verify NOTHING; inbound copies of those headers are stripped by the gateway.
Platform principals (X-Dodil-Auth-Source: platform) carry NO permissions — separate branch.
- Off the gateway: verify the token yourself (EdDSA, iss AND aud mandatory) and read the
same permissions claim off the result.
Either way the check is LOCAL set membership; 403 if missing.
Row-level authz = app filters by sub over its OWN k3.editor service account (bucket RBAC can't do per-row).
4. Tenants (ERP layer): dodil appid tenant create prod-smoke northwind ;
dodil appid tenant member add prod-smoke northwind USER_ID --role approver.
Login carries tenants{slug→roles}; POST /token with {"tenant":"northwind"} pins the token and
MERGES tenant + pool-wide role permissions. Non-membership fails like a bad credential.
5. Two-plane rule throughout: app-user is NEVER a bucket principal; appid says who + what-role,
the app does row/action authz as itself.Connect your tools
Authorization is just claims on a standard JWT, so it plugs into ordinary middleware:
- Behind the Ignite gateway there is nothing to connect. Any framework that can read a
request header can gate on
permissions— decode the payload ofX-Dodil-User-Jwt(it is already verified) and check set membership. No JWT library, no JWKS client, no sidecar. (Express: arequirePermission("invoices:approve")guard; FastAPI: a dependency that reads the claim.) - Off the gateway, any JWT middleware can enforce it: verify the pool token (JWKS,
algorithms: ["EdDSA"],issuer+audiencepinned — the explicitUser-Agenton the JWKS fetch still applies), then gate handlers on the samepermissionsarray. - Row-level filtering is your normal DataK3 access — the app's
k3.editorservice account over Postgres wire,WHERE assigned_to = SUB— exactly the driver path in Four Datastores, One Bucket. And if you bind the pool's user store to the app's own bucket (dodil appid user-store use-bucket POOL BUCKET --sa SA_ID), the identity tables (_appid_*) land next to the app's data, so "their rows" can be a SQL join rather than a value shipped from another system. That bind is opt-in — by default a pool's users live in appid's control-plane store — and it does not migrate existing users, so do it on a fresh pool.
Test
Run live on 2026-09-04 against prod (org ihdiash, pool prod-smoke, user
57e11c12-…), and re-read live on 2026-09-05. What executed, and the real results:
# 1. define the catalog (2026-09-04)
dodil appid roles set prod-smoke approver=invoices:read,invoices:approve
# ... and re-read on 2026-09-05, still intact:
dodil appid roles get prod-smoke -o json
# → {"approver":["invoices:read","invoices:approve"]}
# 2. assign the role
dodil appid user set-roles prod-smoke 57e11c12-… approver
# → appRoles: ["approver"]
# 3. re-login → decode the access token
# → app_roles: ["approver"]
# permissions: ["invoices:approve","invoices:read"] (catalog expanded at mint)
# 4. tenant path
dodil appid tenant create prod-smoke northwind
dodil appid tenant member add prod-smoke northwind 57e11c12-… --role approver
# login carries tenants{northwind:[approver]}; POST /token {"tenant":"northwind"}
# → token pinned to northwind; permissions = union(pool-wide, tenant roles)Confirmed end to end: the catalog set and read back
(approver → invoices:read, invoices:approve); the pool-wide assignment returned
appRoles: ["approver"]; and a fresh login carried both app_roles: ["approver"]
and the catalog-expanded permissions: ["invoices:approve","invoices:read"] —
proving the RBAC expansion happens at mint time and the app needs zero round-trips to
enforce it. Tenants add a per-(user, tenant) role dimension whose permissions merge
into a tenant-pinned token.
Re-verified on 2026-09-05: dodil appid roles get prod-smoke -o json still returns
{"approver":["invoices:read","invoices:approve"]}, and dodil appid roles set --help
documents the replace-the-whole-catalog semantics called out above.
Not re-run today (verified 2026-09-04, unchanged since): the re-login that produced the
expanded token, and the tenant create / member add / tenant-pinned grant. Not independently
re-verified this session: the byte-level split of app_roles into X-Dodil-User and
permissions into X-Dodil-User-Jwt inside a live handler — that is Ignite v1.0.0's documented
gateway contract (see App Sign-In on DODIL, where the gateway's
401, PKCE redirect and platform-principal pass-through were re-proven on 2026-09-05). Log the
two headers once on your first pool-attached deploy.
Where the token is verified differs by path — injected pre-verified by the gateway on Ignite,
or self-verified off it (EdDSA, iss and aud mandatory; a wrong aud is rejected) — but
the catalog, the expansion and the permissions claim are identical in both.
Conclusion
Authorization is where most stacks bolt on a policy server and pay a network hop per
request. dodil-appid puts the coarse decision in the token: a per-pool role catalog,
resolved to a permissions claim your app checks in-process with zero round-trips,
and tenants for the per-subsidiary roles real ERP apps need. What stays in the app is
the part that should — row-level authorization, filtered by the verified sub over
the app's own service account, because "only their rows" is something no bucket grant
can express. appid says who and what role; the app decides which rows. That's the
two-plane rule, and it's what keeps a business user firmly out of the data plane while
still gating every action they take.
Start with identity if you haven't: App Sign-In on DODIL: Business-User Auth with dodil-appid.