Skip to main content

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 sub claim, and the module scopes granted to them.
  • Which merchant they are acting for — the org_id claim.

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
salessales:viewsales:act
adsads:viewads:act
paymentspayments:viewpayments:act
hrhr:viewhr:act
legallegal:viewlegal:act

Two rules come with them:

  • :act never travels without :view. A token carrying hr:act alone 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

  1. 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.
  2. 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.

CoordinateKindNeeds
AdAccounttypeads:view
AdPlatformtypeads:view
Campaigntypeads:view
Contracttypelegal:view
Disputetypepayments:view
Documenttypelegal:view
Employeetypehr:view
LeaveRequesttypehr:view
Merchanttypesales:view
Mutation.acknowledgeDisputefieldpayments:act
Mutation.adjustProductPricefieldsales:act
Mutation.finalizeDocumentfieldlegal:act
Mutation.pauseCampaignfieldads:act
Mutation.requestLeavefieldhr:act
Mutation.startDocumentUploadfieldlegal:act
Ordertypesales:view
Order.settlementfieldpayments:view
Payouttypepayments:view
Producttypesales:view
Query.adAccountsfieldads:view
Query.adPlatformsfieldads:view
Query.blendedMetricsfieldads:view
Query.campaignsfieldads:view
Query.contractfieldlegal:view
Query.contractsfieldlegal:view
Query.disputesfieldpayments:view
Query.documentfieldlegal:view
Query.documentsfieldlegal:view
Query.employeefieldhr:view
Query.employeesfieldhr:view
Query.leaveRequestsfieldhr:view
Query.merchantfieldsales:view
Query.orderfieldsales:view
Query.ordersfieldsales:view
Query.paymentsSummaryfieldpayments:view
Query.payoutsfieldpayments:view
Query.productfieldsales:view
Query.productsfieldsales:view
Query.salesSummaryfieldsales:view
Query.settlementfieldpayments:view
Query.settlementsfieldpayments:view
Settlementtypepayments: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.

UserMerchantScopesWhat they are for
rinaRina's Glowall tenThe owner: everything, everywhere
mayaRina's Glowads:view ads:act sales:viewA valid token that is still refused people data
anaRina's Glowhr:view hr:actReads people and runs the mutation
davidRina's Glowads:view sales:viewAuthenticated, authorised, and still cannot change anything
budiBatik Nusantarahr:view ads:view sales:viewThe 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.