Handle Role Based Permissions
Sooner or later every business application grows this screen. Modules down the left, operations across the top, a checkbox wherever the two meet.
Drawing the grid is a morning's work. Deciding what the backend should do when somebody ticks a box is what eats the rest of the sprint.
Two gates, and you need both
Every authenticated request in API Maker gets checked twice, and the two checks answer different questions.
| Question | Answered by | Enforced by | |
|---|---|---|---|
| Gate 1 β API gate | May this caller call this endpoint at all? | The caller's groups | API Maker, before your code runs |
| Gate 2 β Row gate | Of the rows this endpoint can reach, which ones are this person's? | A pre-hook that injects the user's id into the filter | Your hook, before the query runs |
Drop either one and you have left a hole:
- With only gate 1,
PURCHASE_INVOICE_VIEWis allowed to callget-by-id, so anyone in that group can walk ids and read the whole company's invoices. - With only gate 2, someone who can see just their own rows can still call
remove-by-idand delete them.
The rest of this page sets both up for a Purchase Invoice module.
How authorization works in API Maker
Two identities ride along on every request, in two separate headers. People mix them up constantly, so it is worth being precise about which is which.
| Header | Identity | Where it comes from | Read it with |
|---|---|---|---|
x-am-authorization |
The API user β which application | An API user created in API Maker | g.req.auth.authAMUser |
x-am-user-authorization |
The database user β which person | A row in your own users table | g.req.auth.authAMDB |
Groups hang off the API user and grants hang off the groups. When a request arrives, API Maker walks down this chain and stops at the first level that allows it:
Custom APIs, System APIs and WebSocket events do not cascade. They are flat lists: either the group names them or it does not.
Worth memorising, because it saves a lot of wasted debugging:
401means the token is missing, malformed or expired.403means the token is fine and no group grants that API. If you are seeing 403s, go and fix the group. Do not log the user out, and do not send them back to the login screen.
One group per checkbox
This is the part people push back on, so let me make the case for it.
Do not create a group called Accountant. Job titles are combinations, and combinations breed. The week HR invents "Junior Accountant, South Region" you are back in the console building another group, working out which APIs it needs, and hoping you remembered all of them.
Make each group the size of one checkbox instead. A job title then becomes nothing more than a list.
| Screen | API Maker group |
|---|---|
| Purchase βΊ Purchase Invoices βΊ View | PURCHASE_INVOICE_VIEW |
| Purchase βΊ Purchase Invoices βΊ Create | PURCHASE_INVOICE_CREATE |
| Purchase βΊ Purchase Invoices βΊ Edit | PURCHASE_INVOICE_EDIT |
| Purchase βΊ Purchase Invoices βΊ Delete | PURCHASE_INVOICE_DELETE |
| Purchase βΊ Purchase Invoices βΊ Export | PURCHASE_INVOICE_EXPORT |
| Purchase βΊ Purchase Invoices βΊ Confirm | PURCHASE_INVOICE_CONFIRM |
| Purchase βΊ Debit Notes βΊ View | DEBIT_NOTE_VIEW |
| Purchase βΊ Parcel Entry βΊ View | PARCEL_ENTRY_VIEW |
Small groups combine. Accountant is just PURCHASE_INVOICE_VIEW,PURCHASE_INVOICE_CREATE,DEBIT_NOTE_VIEW. That new job title is another list of the same groups, and nobody has to build anything.
You will want one deliberately coarser group per module, for supervisors:
| Group | Meaning |
|---|---|
PURCHASE_INVOICE_VIEW_ALL |
Same APIs as PURCHASE_INVOICE_VIEW, but exempt from row scoping. A manager or auditor sees the whole company's invoices |
That exemption is configuration rather than code. You gate the row-scoping hook with groupNames and it never runs for that group at all.
Step 1 β Grant the group exactly what it needs
Open APIs Security βΊ API group permission, add PURCHASE_INVOICE_VIEW, and then tick almost nothing.
Going back to the screenshot at the top of the page, the View checkbox on Purchase Invoices needs exactly two grants:
| Category | Granted |
|---|---|
| Instance API | erp_mongo βΊ erp_db βΊ purchase_invoices βΊ SCHEMA_GET_BY_ID |
| Custom API | purchase-invoice-print |
Everything else stays off, SCHEMA_GET_ALL and SCHEMA_POST_QUERY included. A detail screen opens one invoice at a time, so it has no business calling an API that hands back all of them. The list screen gets its own group for that.
The full set for the Purchase Invoice module:
| Group | Instance APIs granted | Custom APIs granted |
|---|---|---|
PURCHASE_INVOICE_VIEW |
SCHEMA_GET_BY_ID |
purchase-invoice-print |
PURCHASE_INVOICE_LIST |
SCHEMA_GET_ALL |
β |
PURCHASE_INVOICE_CREATE |
SCHEMA_POST_BULK_INSERT |
β |
PURCHASE_INVOICE_EDIT |
SCHEMA_PUT_UPDATE_BY_ID |
β |
PURCHASE_INVOICE_DELETE |
SCHEMA_DEL_DELETE_BY_ID |
β |
PURCHASE_INVOICE_EXPORT |
SCHEMA_GET_ALL_STREAM |
purchase-invoice-export-xlsx |
PURCHASE_INVOICE_CONFIRM |
β | purchase-invoice-confirm |
PURCHASE_INVOICE_CONFIRM is worth a second look. Confirming an invoice is not CRUD: it checks stock, posts ledger entries and locks the document. So it is a Custom API, and a group can grant one Custom API and nothing besides. The checkbox on screen still lines up with exactly one group.
Never switch the broad flags on for an application group. allCollections does not only grant what exists today, it grants whatever you add next month and next year too. That is how a salaries table ends up readable by a group nobody has looked at in two years.
Field level: hiding columns inside a row
Groups control individual fields as well. Anything a group cannot read gets stripped out of the response, deep-populated documents included. That last part is the one people forget.
PURCHASE_INVOICE_VIEW can now open an invoice without ever learning what the company paid for the goods.
Step 2 β You do not create groups or users in API Maker at runtime
This one trips up almost everyone arriving from another stack.
There is no "create group" API to call when you onboard a customer, no API user per employee, and no nightly job pushing your permission tables into API Maker's.
You write the groups once, in the console, and they go into Git along with everything else. Handing someone a role at runtime is an UPDATE on a column in your own table, which the admin screen you were already building can do.
Add a groups column to your users table
Rows look like this:
_id |
user_name |
groups |
|---|---|---|
101 |
nikita | PURCHASE_INVOICE_VIEW |
102 |
rakesh | PURCHASE_INVOICE_VIEW,PURCHASE_INVOICE_CREATE |
103 |
meera | PURCHASE_INVOICE_VIEW_ALL,DEBIT_NOTE_VIEW |
104 |
root | * |
* is shorthand for every group the API user has. Keep it for your own internal service account.
Point API Maker at that column
Set groupsColumn in the default secret, or override it per database, collection or API.
That is the whole wiring. From here on:
- The user logs in, and API Maker reads
groupsoff their row. - Access becomes the union of those groups' grants.
- Change the column, and the next token the user gets carries the new permissions.
Keep
_idinselect. The hook in the next step needs it to work out who owns a row, and anything you leave out ofselectis not in the token. You do not need to read group membership yourself. API Maker pulls that fromgroupsColumnand enforces it for you.
The same groupsColumn field turns up on the Google, AWS Cognito and Azure AD configurations, under groupsDataSource. Swap your own login screen for single sign-on later and the groups half of this does not change.
Step 3 β Log in and mint both tokens
One public Custom API, and it is the only place an API user password should ever show up. It hands the client both headers in a single round trip.
select earns its keep there. Leave it out and the lookup drags the whole row into your sandbox, password hash and all, where it can end up in a log line or an error payload without anyone meaning it to. getToken checks the credentials against that same table on its own, so this code never has a reason to hold the hash. Pass a comma-separated list of the columns you want, or -password to drop just that one.
Sending groups back lets the frontend grey out buttons the user cannot use. That is a usability thing, nothing more. The server has already made the real decision, and it makes it again on every call.
Step 4 β Row level security with a database level pre-hook
Groups answered "can rakesh call get-by-id on purchase_invoices?". They cannot answer "is PI-1042 rakesh's invoice?", because that is a fact about the data rather than about configuration. Pre-hooks handle the second question.
Pick the scope
Pre-hooks attach at four scopes and run outermost first. Post-hooks run the same chain in reverse.
pre : instance β database β collection β api
post : api β collection β database β instance
| Scope | Applies to |
|---|---|
| Instance | Every API on every collection in every database of that instance |
| Database | Every API on every collection in that database |
| Collection | Every API on that collection |
| API | Just that one endpoint |
Put row scoping at the database scope. One hook then covers purchase_invoices, debit_notes, parcel_entries, and whatever collection somebody adds next quarter without mentioning it. That last case is the one that kills the per-endpoint if.
Attach it under Instances β erp_mongo β erp_db β Pre-hooks.
What a pre-hook can change, per API
Worth knowing, because the generated APIs do not all keep their filter in the same place. By the time your hook runs, find has already been parsed into a plain object, so mutating it changes the query.
| API | Where the filter lives | How to scope it |
|---|---|---|
SCHEMA_GET_ALL, SCHEMA_GET_ALL_STREAM |
g.req.query.find |
merge the owner into find |
SCHEMA_POST_QUERY, SCHEMA_POST_QUERY_STREAM, SCHEMA_POST_QUERY_DELETE, SCHEMA_UPDATE_MANY, SCHEMA_POST_DISTINCT_QUERY, SCHEMA_ARRAY_OPERATIONS |
g.req.body.find |
merge the owner into find |
SCHEMA_GET_BY_ID, SCHEMA_PUT_UPDATE_BY_ID, SCHEMA_PUT_REPLACE_BY_ID, SCHEMA_DEL_DELETE_BY_ID |
g.req.query.find, ANDed onto the primary key |
merge the owner into find |
SCHEMA_POST_BULK_INSERT, SCHEMA_MASTER_SAVE |
g.req.body |
stamp the owner server-side |
SCHEMA_POST_AGGREGATE, SCHEMA_POST_COUNT, SCHEMA_GET_DISTINCT |
a pipeline / a raw query object | cannot be scoped generically, so do not grant them |
The third row catches people out. get-by-id and its write siblings take an id in the URL and the caller has no way to send a find of their own, but the engine still folds whatever your hook leaves on g.req.query.find into the filter it builds around the primary key:
const findQuery = {
[primaryKey]: _id,
...(sandboxReq?.query?.find || {}), // β your hook's filter
};
That is findOneById in MongodbOps. generateQueryGetById, generateUpdateQuery and generateDeleteQuery in CommonSqlUtils do the same for the SQL engines. One filter therefore scopes every row-driven API, so you do not have to load the row and compare owners yourself. You should not, either, since it costs a database round trip on every call.
You also get the better failure mode without asking for it. A row that is not yours never matches, so the response comes back empty rather than 403, and there is nothing for an attacker to distinguish between "no such id" and "not your id".
The database level pre-hook
A few details in there are easy to skim past.
The isApiRequestFromUser guard lets internal calls through untouched. Custom APIs, events and schedulers reaching the collection via g.sys.db run under their own rules, and a scheduler rebuilding a report is not a user at all, so there is no authAMDB to scope by.
The merge uses $and rather than a spread, and that is deliberate. { ...find, created_by: userId } looks like the same thing and is not: if the caller sends find={created_by:{$ne:null}} their key survives next to yours, and which one wins comes down to operator precedence. $and applies both conditions no matter what, so the caller cannot widen the result set.
Both lookup tables are Maps. The hook runs on every single request against the database, so scanning four arrays with indexOf is work you would be paying for constantly and getting nothing back for. A Map is also the safer shape here: ownerColumn is keyed off the URL, and a plain object would happily hand back constructor or toString for a collection with one of those names, sailing straight past the if (!ownerColumn) guard.
The default branch throws rather than falling through. A hook that ends in an implicit return quietly allows every API added after it was written. This way a mis-grant turns into a support ticket instead of a breach.
And because the scope goes into the filter rather than into a check afterwards, someone else's invoice just does not match. The caller cannot tell "no such id" from "not your id", which is what you want and costs nothing to get.
A narrower version, at collection scope
That hook fires on every call to every collection in erp_db. The two Map lookups keep it cheap, but if only one collection needs scoping there is no reason to carry the table around. Move the hook down to collection scope:
Same idea one level up. A hook at instance scope that hits the database adds that round trip to every single call against the instance. Pick the narrowest scope that still covers what you need, and keep whatever runs there fast.
Gating a hook to specific groups
Hooks can carry a groupNames list. Give it one and the hook only runs for callers who are in those groups.
Hook has groupNames? |
Caller's groups intersect? | Runs? |
|---|---|---|
| No | β | always |
| Yes | Yes | yes |
| Yes | No | skipped |
| Yes | Caller has no groups | skipped |
That is how you row-scope customer-facing API users while leaving an internal integration account alone, with no if inside the hook.
It is also how supervisors see everything. List only the row-scoped groups:
A token carrying PURCHASE_INVOICE_VIEW_ALL never triggers the hook, so nothing filters its reads. Resist writing that exemption in code. API Maker already knows the caller's groups and already decides whether the hook runs. A hand-rolled if (groups.includes('...VIEW_ALL')) return; puts the same decision in a second place, and sooner or later the two drift apart.
Step 5 β Try to break it
Nobody should believe a permission model until they have tried to get round it. Run these.
If any of them comes back wider than you expected, your grants are wider than you think they are.
Two more things that help here:
- Send
x-am-meta: trueand the response tells you which groups were consulted and whether each one granted access. - Every API user gets a Swagger document covering exactly the APIs that user can reach. Diff it in CI, and a group that quietly got wider shows up as a comment on a pull request rather than as an incident six months later.
Putting it together
| Question | Where it is answered |
|---|---|
| Which application is calling? | x-am-authorization β the API user |
| Which person is acting? | x-am-user-authorization β a row in your table |
| Which roles does that person hold? | The groups column on that row |
| What may those roles call? | API group permission, in API Maker, authored once |
| Which rows may they touch? | A database level pre-hook |
| Which fields may they see? | Field level access on the group |
What makes this hold up over time is that none of the enforcement lives in your endpoint code. A new collection is denied until some group grants it, and a new endpoint on an existing collection picks up the database hook the moment it exists. There is nothing you have to remember to write.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| One group per job title | Group count grows with the org chart; every new title is a fresh audit | One group per checkbox; job titles are comma-separated lists |
allCollections: true on an application group |
Every table you add later is instantly reachable | Allow-list collections explicitly |
allSystemApis: true |
Grants EXECUTE_PLAIN_QUERY and GET_SECRET |
Never set it; allow-list individual system APIs if you truly need them |
Merging the owner with a spread instead of $and |
A crafted find can widen the result set |
find = { $and: [caller, ours] } |
Loading the row to compare owners on get-by-id |
An extra database round-trip on every call, for a check the engine already does | Set g.req.query.find; API Maker ANDs it onto the primary key |
Omitting _id from select |
The hook cannot see who is calling | Keep _id in select |
| Checking group membership inside a hook | The same decision now lives in two places and will drift | Gate the hook with groupNames and let API Maker decide |
No passwordChangedAtColumn |
The password sits in the JWT, and changing it does not invalidate old tokens | Set the column and update it on every password change |
Trusting created_by from the request body |
A caller can create rows owned by someone else | Stamp the owner in the pre-hook |
| Row scoping enforced in a Custom API only | Schedulers and test cases bypass authorization entirely | Enforce at the hook, and treat a scheduler as being as privileged as your database credentials |