Authentication and scopes
Every request carries one OAuth 2.0 bearer token, and that token answers two different questions at once:
- Who is acting — the
subclaim, and the module scopes granted to them. - Which merchant they are acting for — the
org_idclaim.
Both travel inside the signed token. Neither can be supplied any other way.
The tenant is a claim, not a parameter
There is no merchant argument on any field, no merchant header, and no way to
ask for another organisation's data. org_id is the tenant, it is
cryptographically bound to the token, and the databases underneath apply
row-level security keyed on it — so even a bug in a resolver returns nothing
rather than someone else's rows.
The practical consequence: a token for the wrong merchant produces an empty result, not an error. "You may see none of these" and "there are none" are deliberately the same answer, because the difference is itself information about another merchant.
The ten module scopes
Five modules, two verbs:
:view | :act | |
|---|---|---|
| sales | sales:view | sales:act |
| ads | ads:view | ads:act |
| payments | payments:view | payments:act |
| hr | hr:view | hr:act |
| legal | legal:view | legal:act |
Two rules come with them:
:actnever travels without:view. A token carryinghr:actalone is a provisioning bug, not a valid state. Nothing in the graph is act-only.- Absence is the denial. There are no negative grants. A module your token says nothing about is a module you cannot read.
The scopes arrive in a single module_scopes claim, as one space-separated
string — "hr:act hr:view", not a JSON array. This is not cosmetic: the
router reads that claim as a string and splits it on whitespace, and an array
leaves it seeing no scopes at all, which fails closed and looks exactly like a
permissions bug.
Two independent checks, on purpose
- At the router. It validates the signature against the realm's key set, pins the audience, and refuses any operation whose scope requirements the token does not satisfy — before a single module is called. An under-scoped field never reaches the service that owns it.
- In the module. Each service re-validates the same token itself, with its own pinned issuer and audience, and then applies row-level authority. It does not trust the network it sits on.
The second check is not redundancy for its own sake. Trusting internal ingress alone is not a defensible answer for a product that lending partners look at, so the token is verified twice, independently, by code with different bugs.
Tokens carry ai4msme-graph in aud plus one audience per module. That is a
routing pin, not an authorization boundary — the module scopes are the
boundary.
What each coordinate requires
Generated from the same composed schema as the reference pages, so it cannot drift from what the router enforces. A coordinate needs every scope in any one of the listed alternatives; a scope on a type applies to every field that returns it.
| Coordinate | Kind | Needs |
|---|---|---|
AdAccount | type | ads:view |
AdPlatform | type | ads:view |
Campaign | type | ads:view |
Contract | type | legal:view |
Dispute | type | payments:view |
Document | type | legal:view |
Employee | type | hr:view |
LeaveRequest | type | hr:view |
Merchant | type | sales:view |
Mutation.acknowledgeDispute | field | payments:act |
Mutation.adjustProductPrice | field | sales:act |
Mutation.finalizeDocument | field | legal:act |
Mutation.pauseCampaign | field | ads:act |
Mutation.requestLeave | field | hr:act |
Mutation.startDocumentUpload | field | legal:act |
Order | type | sales:view |
Order.settlement | field | payments:view |
Payout | type | payments:view |
Product | type | sales:view |
Query.adAccounts | field | ads:view |
Query.adPlatforms | field | ads:view |
Query.blendedMetrics | field | ads:view |
Query.campaigns | field | ads:view |
Query.contract | field | legal:view |
Query.contracts | field | legal:view |
Query.disputes | field | payments:view |
Query.document | field | legal:view |
Query.documents | field | legal:view |
Query.employee | field | hr:view |
Query.employees | field | hr:view |
Query.leaveRequests | field | hr:view |
Query.merchant | field | sales:view |
Query.order | field | sales:view |
Query.orders | field | sales:view |
Query.paymentsSummary | field | payments:view |
Query.payouts | field | payments:view |
Query.product | field | sales:view |
Query.products | field | sales:view |
Query.salesSummary | field | sales:view |
Query.settlement | field | payments:view |
Query.settlements | field | payments:view |
Settlement | type | payments:view |
Development personas
The local realm seeds five people. They exist so that every layer of the model can be demonstrated with a real token rather than asserted in a comment.
| User | Merchant | Scopes | What they are for |
|---|---|---|---|
rina | Rina's Glow | all ten | The owner: everything, everywhere |
maya | Rina's Glow | ads:view ads:act sales:view | A valid token that is still refused people data |
ana | Rina's Glow | hr:view hr:act | Reads people and runs the mutation |
david | Rina's Glow | ads:view sales:view | Authenticated, authorised, and still cannot change anything |
budi | Batik Nusantara | hr:view ads:view sales:view | The same grants at a different merchant — the isolation test |
budi is the interesting one. His token authenticates, passes the scope check,
and still returns zero of Rina's rows. That is tenant isolation being enforced
by the database rather than promised by a resolver.
Password for all of them, in local development only: dev-Passw0rd1.
Machine clients
Service accounts authenticate with the client-credentials flow and carry no
org_id: they act for the platform, not for a merchant. Any code path that
needs a tenant has to get it from a user's token — there is no way to assert
one.