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:

  1. Coarse "can this user do X?" — can they reach the approve-invoice action at all?
  2. 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 dodil CLI, 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, pool prod-smoke, the user 57e11c12-… 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 permissions from. On Ignite with a user_pool attached, 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.

You

On the prod-smoke pool, define a role called approver that grants invoices:read and invoices:approve, then show me the catalog.

Agent

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.

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:

You

Give user 57e11c12-… the approver role on the prod-smoke pool.

Agent

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].

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 what approver means 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-expanded permissions claim lives — it is not in X-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.

You

On prod-smoke, create a tenant for the northwind subsidiary and add user 57e11c12-… to it as an approver.

Agent

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.

Now 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 of X-Dodil-User-Jwt (it is already verified) and check set membership. No JWT library, no JWKS client, no sidecar. (Express: a requirePermission("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 + audience pinned — the explicit User-Agent on the JWKS fetch still applies), then gate handlers on the same permissions array.
  • Row-level filtering is your normal DataK3 access — the app's k3.editor service 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 shouldrow-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.