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:
instance → database → collection → supported API → field
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: 401 means the token is missing, malformed or expired. 403 means 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.
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 one line of the row-scoping hook, which reads the groups of the person: see the database level pre-hook.
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.
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.
import{ISchemaType,EType,ISchemaProperty,IPropertyValidation}from'types';letschema:ISchemaType={_id:EType.objectId,user_name:<ISchemaProperty>{__type:EType.string,validations:<IPropertyValidation>{required:true}},password:<ISchemaProperty>{__type:EType.string,validations:<IPropertyValidation>{required:true},conversions:{hashing:true}},passwordChangedAt:<ISchemaProperty>{__type:EType.number},department:<ISchemaProperty>{__type:EType.string},is_active:<ISchemaProperty>{__type:EType.boolean},// 👇 the whole role model, in one columngroups:<ISchemaProperty>{__type:EType.string}};module.exports={schema};
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.
import*asTfrom'types';letdbTokenGenerator:T.IAuthTokenAMDB={name:'erp_users_tg',instance:'erp_mongo',database:'erp_db',collection:'erp_users',usernameColumn:'user_name',passwordColumn:'password',// A password change ends every token made before it.passwordChangedAtColumn:'passwordChangedAt',// 👇 API Maker reads this column and resolves the grants itselfgroupsColumn:'groups',// The columns of the person in g.req.auth.authAMDBselect:{_id:1,user_name:1,department:1,groups:1},};module.exports=dbTokenGenerator;
Then make the APIs of erp_db ask for that provider, in the settings of the database. A table or an API can name its own, and common.authProviders of the secret is the default of every API.
The user logs in, and API Maker reads groups off their row.
Access becomes the union of those groups' grants.
Change the column, and the user's next calls follow it. The token carries no groups: API Maker reads them off the row.
Keep _id in select. The hook in the next step needs it to work out who owns a row, and anything you leave out of select is not in g.req.auth.authAMDB. Keep groups there too, for the supervisor rule of that hook. You do not need to check which APIs the groups grant: API Maker reads groupsColumn and 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.
import*asTfrom'types';import*asdbfrom'db-interfaces';constINSTANCE='erp_mongo';constDATABASE='erp_db';constUSERS='erp_users';asyncfunctionmain(g:T.IAMGlobal){constuserName=g.req.body.user_name;constpassword=g.req.body.password;if(!userName||!password){throw[{code:T.EStatusCode.BAD_REQUEST,message:'user_name and password are required.'}];}constuser:db.erp_mongo.erp_db.IErpUsers=awaitg.sys.db.getById({instance:INSTANCE,database:DATABASE,collection:USERS,primaryKey:'user_name',id:userName,// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them.select:'_id,user_name,department,groups,is_active'});if(!user||user.is_active!==true){throw[{code:T.EStatusCode.UNAUTHORIZED,message:'Invalid credentials.'}];}// The API user password lives in the secret, never in code and never in the frontend.constapiUserPassword=awaitg.sys.system.getSecret('common.apiUserPasswords.erp_app');const[app,person]=awaitg.sys.system.getToken([// No name : the token of the API user{u:'erp_app',p:apiUserPassword},// The DB auth provider 'erp_users_tg' checks the password against the erp_users table{name:'erp_users_tg',u:userName,p:password}]);return{amToken:app.token,// → send as x-am-authorizationamDbToken:person.token,// → send as x-am-user-authorizationuser:{_id:user._id,user_name:user.user_name,groups:user.groups}};}module.exports=main;
fromamimportErrors,G,GetTokenINSTANCE='erp_mongo'DATABASE='erp_db'USERS='erp_users'asyncdefmain(g:G):user_name=g.req.body.user_namepassword=g.req.body.passwordifnotuser_nameornotpassword:raiseErrors('user_name and password are required.',code=400)user=awaitg.sys.db.get_by_id(INSTANCE,DATABASE,USERS,user_name,primary_key='user_name',# 👇 Ask for the columns this API uses, and nothing else.# The password column is never one of them.select='_id,user_name,department,groups,is_active',)ifnotuseroruser.is_activeisnotTrue:raiseErrors('Invalid credentials.',code=401)# The API user password lives in the secret, never in code and never in the frontend.api_user_password=awaitg.sys.system.get_secret('common.apiUserPasswords.erp_app')app,person=awaitg.sys.system.get_tokens([# No name : the token of the API userGetToken('erp_app',api_user_password),# The DB auth provider 'erp_users_tg' checks the password against the erp_users tableGetToken(user_name,password,name='erp_users_tg'),])return{'amToken':app.token,# → send as x-am-authorization'amDbToken':person.token,# → send as x-am-user-authorization'user':{'_id':user._id,'user_name':user.user_name,'groups':user.groups},}
<?phpconstINSTANCE='erp_mongo';constDATABASE='erp_db';constUSERS='erp_users';functionmain(AmG$g){$userName=$g->req->body->user_name;$password=$g->req->body->password;if(empty($userName)||empty($password)){thrownewAmErrors('user_name and password are required.',400);}$user=$g->sys->db->getById(INSTANCE,DATABASE,USERS,$userName,primaryKey:'user_name',// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them.select:'_id,user_name,department,groups,is_active',);if($user===null||$user->is_active!==true){thrownewAmErrors('Invalid credentials.',401);}// The API user password lives in the secret, never in code and never in the frontend.$apiUserPassword=$g->sys->system->getSecret('common.apiUserPasswords.erp_app');[$app,$person]=$g->sys->system->getTokens([// No name : the token of the API usernewGetToken('erp_app',$apiUserPassword),// The DB auth provider 'erp_users_tg' checks the password against the erp_users tablenewGetToken($userName,$password,name:'erp_users_tg'),]);return['amToken'=>$app->token,// → send as x-am-authorization'amDbToken'=>$person->token,// → send as x-am-user-authorization'user'=>['_id'=>$user->_id,'user_name'=>$user->user_name,'groups'=>$user->groups],];}
filestaticclassApi{conststringInstance="erp_mongo";conststringDatabase="erp_db";conststringUsers="erp_users";publicstaticasyncTask<object?>Main(Gg){varuserName=g.Req.Body.GetString("user_name");varpassword=g.Req.Body.GetString("password");if(string.IsNullOrEmpty(userName)||string.IsNullOrEmpty(password))thrownewAmErrors("user_name and password are required.",400);varuser=awaitg.Sys.Db.GetById(newGetById(Instance,Database,Users,userName){PrimaryKey="user_name",// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them.Select="_id,user_name,department,groups,is_active",});if(userisnull||!user.GetBoolean("is_active"))thrownewAmErrors("Invalid credentials.",401);// The API user password lives in the secret, never in code and never in the frontend.varapiUserPassword=(awaitg.Sys.System.GetSecret("common.apiUserPasswords.erp_app"))?.GetValue<string>();vartokens=awaitg.Sys.System.GetTokens([// No name : the token of the API usernewGetToken("erp_app",apiUserPassword),// The DB auth provider 'erp_users_tg' checks the password against the erp_users tablenewGetToken(userName,password){Name="erp_users_tg"},]);returnnew{amToken=tokens[0].Token,// → send as x-am-authorizationamDbToken=tokens[1].Token,// → send as x-am-user-authorizationuser=new{_id=user["_id"],user_name=user["user_name"],groups=user["groups"]},};}}
packagecodeimport"apimaker/am"const(instance="erp_mongo"database="erp_db"users="erp_users")funcMain(g*am.G)(any,error){body:=g.Req.Body()userName:=body.String("user_name")password:=body.String("password")ifuserName==""||password==""{returnnil,am.Fail(400,"user_name and password are required.")}user,err:=g.Sys.Db.GetById(am.GetById{Instance:instance,Database:database,Collection:users,PrimaryKey:"user_name",Id:userName,// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them.Select:"_id,user_name,department,groups,is_active",})iferr!=nil{returnnil,err}ifuser==nil||!user.Bool("is_active"){returnnil,am.Fail(401,"Invalid credentials.")}// The API user password lives in the secret, never in code and never in the frontend.secret,err:=g.Sys.System.GetSecret("common.apiUserPasswords.erp_app")iferr!=nil{returnnil,err}apiUserPassword,_:=secret.(string)tokens,err:=g.Sys.System.GetTokens([]am.GetToken{// No name : the token of the API user{U:"erp_app",P:apiUserPassword},// The DB auth provider 'erp_users_tg' checks the password against the erp_users table{Name:"erp_users_tg",U:userName,P:password},})iferr!=nil{returnnil,err}returnam.M{"amToken":tokens[0].Token,// → send as x-am-authorization"amDbToken":tokens[1].Token,// → send as x-am-user-authorization"user":am.M{"_id":user.Get("_id"),"user_name":user.Get("user_name"),"groups":user.Get("groups")},},nil}
useam::{Errors,G,GetById,GetToken,Value,json};constINSTANCE:&str="erp_mongo";constDATABASE:&str="erp_db";constUSERS:&str="erp_users";pubfnmain(g:&mutG)->Result<Value,Errors>{letbody=g.req.body();letuser_name=body["user_name"].as_str().unwrap_or_default().to_owned();letpassword=body["password"].as_str().unwrap_or_default().to_owned();ifuser_name.is_empty()||password.is_empty(){returnErr(am::fail(400,"user_name and password are required."));}letuser=g.sys.db.get_by_id(GetById::new(INSTANCE,DATABASE,USERS,json!(user_name)).primary_key("user_name")// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them..select("_id,user_name,department,groups,is_active"),)?;letSome(user)=user.filter(|user|user["is_active"]==true)else{returnErr(am::fail(401,"Invalid credentials."));};// The API user password lives in the secret, never in code and never in the frontend.letapi_user_password=g.sys.system.get_secret("common.apiUserPasswords.erp_app",None)?;lettokens=g.sys.system.get_tokens(vec![// No name : the token of the API userGetToken::new("erp_app",api_user_password.as_str().unwrap_or_default()),// The DB auth provider 'erp_users_tg' checks the password against the erp_users tableGetToken::new(&user_name,&password).name("erp_users_tg"),])?;Ok(json!({"amToken":tokens[0].token,// → send as x-am-authorization"amDbToken":tokens[1].token,// → send as x-am-user-authorization"user":{"_id":user["_id"],"user_name":user["user_name"],"groups":user["groups"]},}))}
importam.AmErrors;importam.G;importam.GetById;importam.GetToken;importam.JsonObject;importam.TokenResponse;importjava.util.List;classApi{staticfinalStringINSTANCE="erp_mongo";staticfinalStringDATABASE="erp_db";staticfinalStringUSERS="erp_users";staticObjectmain(Gg){JsonObjectbody=g.req.getBody();StringuserName=body.getString("user_name","");Stringpassword=body.getString("password","");if(userName.isEmpty()||password.isEmpty()){thrownewAmErrors("user_name and password are required.",400);}JsonObjectuser=g.sys.db.getById(newGetById(INSTANCE,DATABASE,USERS,userName).primaryKey("user_name")// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them..select("_id,user_name,department,groups,is_active"));if(user==null||!user.getBoolean("is_active",false)){thrownewAmErrors("Invalid credentials.",401);}// The API user password lives in the secret, never in code and never in the frontend.StringapiUserPassword=(String)g.sys.system.getSecret("common.apiUserPasswords.erp_app");List<TokenResponse>tokens=g.sys.system.getTokens(List.of(// No name : the token of the API usernewGetToken("erp_app",apiUserPassword),// The DB auth provider 'erp_users_tg' checks the password against the erp_users tablenewGetToken(userName,password).name("erp_users_tg")));returnJsonObject.of("amToken",tokens.get(0).getToken(),// → send as x-am-authorization"amDbToken",tokens.get(1).getToken(),// → send as x-am-user-authorization"user",JsonObject.of("_id",user.get("_id"),"user_name",user.get("user_name"),"groups",user.get("groups")));}}
importam.AmErrorsimportam.Gimportam.GetByIdimportam.GetTokenconstvalINSTANCE="erp_mongo"constvalDATABASE="erp_db"constvalUSERS="erp_users"funmain(g:G):Any?{valuserName=g.req.body.getString("user_name","")valpassword=g.req.body.getString("password","")if(userName.isEmpty()||password.isEmpty())throwAmErrors("user_name and password are required.",400)valuser=g.sys.db.getById(GetById(INSTANCE,DATABASE,USERS,userName).primaryKey("user_name")// 👇 Ask for the columns this API uses, and nothing else.// The password column is never one of them..select("_id,user_name,department,groups,is_active"),)if(user==null||!user.getBoolean("is_active",false))throwAmErrors("Invalid credentials.",401)// The API user password lives in the secret, never in code and never in the frontend.valapiUserPassword=g.sys.system.getSecret("common.apiUserPasswords.erp_app")asStringval(app,person)=g.sys.system.getTokens(listOf(// No name : the token of the API userGetToken("erp_app",apiUserPassword),// The DB auth provider 'erp_users_tg' checks the password against the erp_users tableGetToken(userName,password).name("erp_users_tg"),),)returnmapOf("amToken"toapp.token,// → send as x-am-authorization"amDbToken"toperson.token,// → send as x-am-user-authorization"user"tomapOf("_id"touser["_id"],"user_name"touser["user_name"],"groups"touser["groups"]),)}
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.
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.
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.
merge the owner into find ; for update and replace, also stamp the owner into the body
SCHEMA_POST_BULK_INSERT, SCHEMA_MASTER_SAVE
g.req.body
stamp the owner server-side
SCHEMA_POST_AGGREGATE, SCHEMA_GET_DISTINCT
a pipeline (aggregate), the column in the URL (distinct)
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, for MongoDB and the SQL databases alike. With the hook below, get-by-id/PI-1042 called by rakesh reads:
{"_id":"PI-1042","created_by":102}
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".
import*asTfrom'types';// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this map is not row-scoped at all.constOWNED=newMap<string,{column:string,seesAll:string}>([['purchase_invoices',{column:'created_by',seesAll:'PURCHASE_INVOICE_VIEW_ALL'}],['debit_notes',{column:'created_by',seesAll:'DEBIT_NOTE_VIEW_ALL'}],['parcel_entries',{column:'entered_by',seesAll:'PARCEL_ENTRY_VIEW_ALL'}]]);// ── apiId → where that API carries its filter ──────────────// One O(1) lookup per request instead of scanning arrays.constSCOPE_BY_API=newMap<string,string>([// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike.['SCHEMA_GET_ALL','QUERY_FIND'],['SCHEMA_GET_ALL_STREAM','QUERY_FIND'],['SCHEMA_GET_BY_ID','QUERY_FIND'],['SCHEMA_PUT_UPDATE_BY_ID','QUERY_FIND'],['SCHEMA_PUT_REPLACE_BY_ID','QUERY_FIND'],['SCHEMA_DEL_DELETE_BY_ID','QUERY_FIND'],['SCHEMA_POST_QUERY','BODY_FIND'],['SCHEMA_POST_QUERY_STREAM','BODY_FIND'],['SCHEMA_POST_QUERY_DELETE','BODY_FIND'],['SCHEMA_UPDATE_MANY','BODY_FIND'],['SCHEMA_POST_DISTINCT_QUERY','BODY_FIND'],['SCHEMA_ARRAY_OPERATIONS','BODY_FIND'],['SCHEMA_POST_COUNT','BODY_FIND'],['SCHEMA_POST_BULK_INSERT','STAMP_OWNER'],['SCHEMA_MASTER_SAVE','STAMP_OWNER']]);// ── The APIs which only read : a supervisor sees every row with these ──────────────constREADS=newSet<string>(['SCHEMA_GET_ALL','SCHEMA_GET_ALL_STREAM','SCHEMA_GET_BY_ID','SCHEMA_POST_QUERY','SCHEMA_POST_QUERY_STREAM','SCHEMA_POST_DISTINCT_QUERY','SCHEMA_POST_COUNT']);asyncfunctionmain(g:T.IAMGlobal){// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if(!g.req.isApiRequestFromUser)return;constowned=OWNED.get(g.req.params.collection);if(!owned)return;// not a user-owned collection// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.constperson=g.req.auth.authAMDB;constapiId=g.req.reqInfo.apiInfo.id;// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.constgroups=String(person.groups||'').split(',');if(READS.has(apiId)&&(groups.includes(owned.seesAll)||groups.includes('*')))return;constscope={[owned.column]:person._id};// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.constmerge=(existing)=>(existing&&Object.keys(existing).length?{$and:[existing,scope]}:scope);// The owner of a row is written by the server, never by the caller.conststamp=(row)=>{if(row&&typeofrow==='object')row[owned.column]=person._id;};// 3. One lookup decides where the filter goes.switch(SCOPE_BY_API.get(apiId)){// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe.case'QUERY_FIND':g.req.query.find=merge(g.req.query.find);stamp(g.req.body);// update-by-id, replace-by-id : the owner can not be handed overreturn;case'BODY_FIND':g.req.body.find=merge(g.req.body.find);if(g.req.body.updateData)stamp(g.req.body.updateData);// update-manyreturn;// Inserts and master save: stamp the owner on the server. Never trust the body.case'STAMP_OWNER':{constrows=Array.isArray(g.req.body)?g.req.body:[g.req.body];for(constrowofrows)stamp(row);return;}// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here.default:throw[{code:T.EStatusCode.FORBIDDEN,message:'This API is not available for row-scoped users.'}];}}module.exports=main;
fromamimportErrors,G# ── Which collections are owned by a user, by which column, and which group reads every row ──────────────# A collection that is not in this map is not row-scoped at all.OWNED={'purchase_invoices':{'column':'created_by','sees_all':'PURCHASE_INVOICE_VIEW_ALL'},'debit_notes':{'column':'created_by','sees_all':'DEBIT_NOTE_VIEW_ALL'},'parcel_entries':{'column':'entered_by','sees_all':'PARCEL_ENTRY_VIEW_ALL'},}# ── apiId → where that API carries its filter ──────────────# One O(1) lookup per request instead of scanning lists.SCOPE_BY_API={# API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,# so one filter covers the list, the single read, and the writes alike.'SCHEMA_GET_ALL':'QUERY_FIND','SCHEMA_GET_ALL_STREAM':'QUERY_FIND','SCHEMA_GET_BY_ID':'QUERY_FIND','SCHEMA_PUT_UPDATE_BY_ID':'QUERY_FIND','SCHEMA_PUT_REPLACE_BY_ID':'QUERY_FIND','SCHEMA_DEL_DELETE_BY_ID':'QUERY_FIND','SCHEMA_POST_QUERY':'BODY_FIND','SCHEMA_POST_QUERY_STREAM':'BODY_FIND','SCHEMA_POST_QUERY_DELETE':'BODY_FIND','SCHEMA_UPDATE_MANY':'BODY_FIND','SCHEMA_POST_DISTINCT_QUERY':'BODY_FIND','SCHEMA_ARRAY_OPERATIONS':'BODY_FIND','SCHEMA_POST_COUNT':'BODY_FIND','SCHEMA_POST_BULK_INSERT':'STAMP_OWNER','SCHEMA_MASTER_SAVE':'STAMP_OWNER',}# ── The APIs which only read : a supervisor sees every row with these ──────────────READS={'SCHEMA_GET_ALL','SCHEMA_GET_ALL_STREAM','SCHEMA_GET_BY_ID','SCHEMA_POST_QUERY','SCHEMA_POST_QUERY_STREAM','SCHEMA_POST_DISTINCT_QUERY','SCHEMA_POST_COUNT',}asyncdefmain(g:G):# 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling# g.sys.db run under their own rules.ifnotg.req.is_api_request_from_user:returnowned=OWNED.get(g.req.params.collection)ifnotowned:return# not a user-owned collection# 2. Which person is acting? Not the application — the person.# API Maker has already rejected the request if the user token is missing.person=g.req.auth.authAMDBapi_id=g.req.req_info.apiInfo.id# A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.groups=str(person.groupsor'').split(',')ifapi_idinREADSand(owned['sees_all']ingroupsor'*'ingroups):returnscope={owned['column']:person._id}# MERGE, never replace. $and keeps whatever the caller asked for and adds ours# on top, so a caller can never widen the scope by sending its own created_by.defmerge(existing):return{'$and':[existing,scope]}ifexistingelsescope# The owner of a row is written by the server, never by the caller.defstamp(row):ifisinstance(row,dict):row[owned['column']]=person._id# 3. One lookup decides where the filter goes.where=SCOPE_BY_API.get(api_id)# get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.# A row that is not yours is simply never matched — no extra round-trip,# and no 404-vs-403 difference for an attacker to probe.ifwhere=='QUERY_FIND':g.req.query.find=merge(g.req.query.find)stamp(g.req.body)# update-by-id, replace-by-id : the owner can not be handed overelifwhere=='BODY_FIND':g.req.body.find=merge(g.req.body.find)stamp(g.req.body.get('updateData'))# update-many# Inserts and master save: stamp the owner on the server. Never trust the body.elifwhere=='STAMP_OWNER':rows=g.req.bodyifisinstance(g.req.body,list)else[g.req.body]forrowinrows:stamp(row)# Fail closed. Aggregate and distinct cannot be scoped generically,# so if one of them is ever granted by mistake, it stops here.else:raiseErrors('This API is not available for row-scoped users.',code=403)
<?php// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this map is not row-scoped at all.constOWNED=['purchase_invoices'=>['column'=>'created_by','seesAll'=>'PURCHASE_INVOICE_VIEW_ALL'],'debit_notes'=>['column'=>'created_by','seesAll'=>'DEBIT_NOTE_VIEW_ALL'],'parcel_entries'=>['column'=>'entered_by','seesAll'=>'PARCEL_ENTRY_VIEW_ALL'],];// ── apiId → where that API carries its filter ──────────────// One O(1) lookup per request instead of scanning arrays.constSCOPE_BY_API=[// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike.'SCHEMA_GET_ALL'=>'QUERY_FIND','SCHEMA_GET_ALL_STREAM'=>'QUERY_FIND','SCHEMA_GET_BY_ID'=>'QUERY_FIND','SCHEMA_PUT_UPDATE_BY_ID'=>'QUERY_FIND','SCHEMA_PUT_REPLACE_BY_ID'=>'QUERY_FIND','SCHEMA_DEL_DELETE_BY_ID'=>'QUERY_FIND','SCHEMA_POST_QUERY'=>'BODY_FIND','SCHEMA_POST_QUERY_STREAM'=>'BODY_FIND','SCHEMA_POST_QUERY_DELETE'=>'BODY_FIND','SCHEMA_UPDATE_MANY'=>'BODY_FIND','SCHEMA_POST_DISTINCT_QUERY'=>'BODY_FIND','SCHEMA_ARRAY_OPERATIONS'=>'BODY_FIND','SCHEMA_POST_COUNT'=>'BODY_FIND','SCHEMA_POST_BULK_INSERT'=>'STAMP_OWNER','SCHEMA_MASTER_SAVE'=>'STAMP_OWNER',];// ── The APIs which only read : a supervisor sees every row with these ──────────────constREADS=['SCHEMA_GET_ALL','SCHEMA_GET_ALL_STREAM','SCHEMA_GET_BY_ID','SCHEMA_POST_QUERY','SCHEMA_POST_QUERY_STREAM','SCHEMA_POST_DISTINCT_QUERY','SCHEMA_POST_COUNT',];functionmain(AmG$g){// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if(!$g->req->isApiRequestFromUser){returnnull;}$owned=OWNED[$g->req->params->collection??'']??null;if($owned===null){returnnull;// not a user-owned collection}// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.$person=$g->req->auth->authAMDB;$apiId=$g->req->reqInfo->apiInfo->id;// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.$groups=explode(',',(string)($person->groups??''));if(in_array($apiId,READS,true)&&(in_array($owned['seesAll'],$groups,true)||in_array('*',$groups,true))){returnnull;}$scope=[$owned['column']=>$person->_id];// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.$merge=fn($existing)=>$existing&&count($existing)?['$and'=>[$existing,$scope]]:$scope;// The owner of a row is written by the server, never by the caller.$stamp=function($row)use($owned,$person){if($rowinstanceofAmObject){$row->{$owned['column']}=$person->_id;}};// 3. One lookup decides where the filter goes.switch(SCOPE_BY_API[$apiId]??null){// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe.case'QUERY_FIND':$g->req->query->find=$merge($g->req->query->find);$stamp($g->req->body);// update-by-id, replace-by-id : the owner can not be handed overreturnnull;case'BODY_FIND':$g->req->body->find=$merge($g->req->body->find);$stamp($g->req->body->updateData);// update-manyreturnnull;// Inserts and master save: stamp the owner on the server. Never trust the body.case'STAMP_OWNER':$rows=array_is_list($g->req->body->toArray())?$g->req->body:[$g->req->body];foreach($rowsas$row){$stamp($row);}returnnull;// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here.default:thrownewAmErrors('This API is not available for row-scoped users.',403);}}
filestaticclassApi{// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this map is not row-scoped at all.staticreadonlyDictionary<string,(stringColumn,stringSeesAll)>Owned=new(){["purchase_invoices"]=("created_by","PURCHASE_INVOICE_VIEW_ALL"),["debit_notes"]=("created_by","DEBIT_NOTE_VIEW_ALL"),["parcel_entries"]=("entered_by","PARCEL_ENTRY_VIEW_ALL"),};// ── apiId → where that API carries its filter ──────────────// One O(1) lookup per request instead of scanning arrays.staticreadonlyDictionary<string,string>ScopeByApi=new(){// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike.["SCHEMA_GET_ALL"]="QUERY_FIND",["SCHEMA_GET_ALL_STREAM"]="QUERY_FIND",["SCHEMA_GET_BY_ID"]="QUERY_FIND",["SCHEMA_PUT_UPDATE_BY_ID"]="QUERY_FIND",["SCHEMA_PUT_REPLACE_BY_ID"]="QUERY_FIND",["SCHEMA_DEL_DELETE_BY_ID"]="QUERY_FIND",["SCHEMA_POST_QUERY"]="BODY_FIND",["SCHEMA_POST_QUERY_STREAM"]="BODY_FIND",["SCHEMA_POST_QUERY_DELETE"]="BODY_FIND",["SCHEMA_UPDATE_MANY"]="BODY_FIND",["SCHEMA_POST_DISTINCT_QUERY"]="BODY_FIND",["SCHEMA_ARRAY_OPERATIONS"]="BODY_FIND",["SCHEMA_POST_COUNT"]="BODY_FIND",["SCHEMA_POST_BULK_INSERT"]="STAMP_OWNER",["SCHEMA_MASTER_SAVE"]="STAMP_OWNER",};// ── The APIs which only read : a supervisor sees every row with these ──────────────staticreadonlyHashSet<string>Reads=[ "SCHEMA_GET_ALL", "SCHEMA_GET_ALL_STREAM", "SCHEMA_GET_BY_ID", "SCHEMA_POST_QUERY", "SCHEMA_POST_QUERY_STREAM", "SCHEMA_POST_DISTINCT_QUERY", "SCHEMA_POST_COUNT", ];publicstaticasyncTask<object?>Main(Gg){// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if(!g.Req.IsApiRequestFromUser)returnnull;if(!Owned.TryGetValue(g.Req.Params.GetString("collection")??"",outvarowned))returnnull;// not a user-owned collection// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.varperson=g.Req.Auth.GetObject("authAMDB");varapiId=g.Req.ReqInfo.GetObject("apiInfo").GetString("id")??"";// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.vargroups=(person.GetString("groups")??"").Split(',');if(Reads.Contains(apiId)&&(groups.Contains(owned.SeesAll)||groups.Contains("*")))returnnull;JsonObjectScope()=>new(){[owned.Column]=person["_id"]?.DeepClone()};// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.JsonNodeMerge(JsonNode?existing)=>existingisJsonObject{Count:>0}?newJsonObject{["$and"]=newJsonArray(existing.DeepClone(),Scope())}:Scope();// The owner of a row is written by the server, never by the caller.voidStamp(JsonNode?row){if(rowisJsonObjectfields)fields[owned.Column]=person["_id"]?.DeepClone();}// 3. One lookup decides where the filter goes.switch(ScopeByApi.GetValueOrDefault(apiId)){// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe.case"QUERY_FIND":g.Req.Query["find"]=Merge(g.Req.Query["find"]);Stamp(g.Req["body"]);// update-by-id, replace-by-id : the owner can not be handed overreturnnull;case"BODY_FIND":g.Req.Body["find"]=Merge(g.Req.Body["find"]);Stamp(g.Req.Body["updateData"]);// update-manyreturnnull;// Inserts and master save: stamp the owner on the server. Never trust the body.case"STAMP_OWNER":IEnumerable<JsonObject>rows=g.Req["body"]isJsonArraylist?list.OfType<JsonObject>():[g.Req.Body];foreach(varrowinrows)Stamp(row);returnnull;// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here.default:thrownewAmErrors("This API is not available for row-scoped users.",403);}}}
packagecodeimport("strings""apimaker/am")// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this map is not row-scoped at all.typeownershipstruct{column,seesAllstring}varowned=map[string]ownership{"purchase_invoices":{column:"created_by",seesAll:"PURCHASE_INVOICE_VIEW_ALL"},"debit_notes":{column:"created_by",seesAll:"DEBIT_NOTE_VIEW_ALL"},"parcel_entries":{column:"entered_by",seesAll:"PARCEL_ENTRY_VIEW_ALL"},}// ── apiId → where that API carries its filter ──────────────// One O(1) lookup per request instead of scanning slices.varscopeByApi=map[string]string{// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike."SCHEMA_GET_ALL":"QUERY_FIND","SCHEMA_GET_ALL_STREAM":"QUERY_FIND","SCHEMA_GET_BY_ID":"QUERY_FIND","SCHEMA_PUT_UPDATE_BY_ID":"QUERY_FIND","SCHEMA_PUT_REPLACE_BY_ID":"QUERY_FIND","SCHEMA_DEL_DELETE_BY_ID":"QUERY_FIND","SCHEMA_POST_QUERY":"BODY_FIND","SCHEMA_POST_QUERY_STREAM":"BODY_FIND","SCHEMA_POST_QUERY_DELETE":"BODY_FIND","SCHEMA_UPDATE_MANY":"BODY_FIND","SCHEMA_POST_DISTINCT_QUERY":"BODY_FIND","SCHEMA_ARRAY_OPERATIONS":"BODY_FIND","SCHEMA_POST_COUNT":"BODY_FIND","SCHEMA_POST_BULK_INSERT":"STAMP_OWNER","SCHEMA_MASTER_SAVE":"STAMP_OWNER",}// ── The APIs which only read : a supervisor sees every row with these ──────────────varreads=map[string]bool{"SCHEMA_GET_ALL":true,"SCHEMA_GET_ALL_STREAM":true,"SCHEMA_GET_BY_ID":true,"SCHEMA_POST_QUERY":true,"SCHEMA_POST_QUERY_STREAM":true,"SCHEMA_POST_DISTINCT_QUERY":true,"SCHEMA_POST_COUNT":true,}funcMain(g*am.G)(any,error){// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if!g.Req.IsApiRequestFromUser(){returnnil,nil}own,isOwned:=owned[g.Req.Params().String("collection")]if!isOwned{returnnil,nil// not a user-owned collection}// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.person:=g.Req.Auth().Map("authAMDB")apiID:=g.Req.ReqInfo().Map("apiInfo").String("id")// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.holds:=func(namestring)bool{for_,group:=rangestrings.Split(person.String("groups"),","){ifgroup==name{returntrue}}returnfalse}ifreads[apiID]&&(holds(own.seesAll)||holds("*")){returnnil,nil}scope:=am.M{own.column:person.Get("_id")}// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.merge:=func(existingam.M)am.M{iflen(existing)>0{returnam.M{"$and":[]any{existing,scope}}}returnscope}// The owner of a row is written by the server, never by the caller.stamp:=func(rowam.M){ifrow!=nil{row[own.column]=person.Get("_id")}}// 3. One lookup decides where the filter goes.switchscopeByApi[apiID]{// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe.case"QUERY_FIND":query:=g.Req.Query()query["find"]=merge(query.Map("find"))stamp(g.Req.Map("body"))// update-by-id, replace-by-id : the owner can not be handed overcase"BODY_FIND":body:=g.Req.Body()body["find"]=merge(body.Map("find"))stamp(body.Map("updateData"))// update-many// Inserts and master save: stamp the owner on the server. Never trust the body.case"STAMP_OWNER":rows:=g.Req.List("body")ifrows==nil{rows=[]any{g.Req.Body()}}for_,row:=rangerows{stamp(am.AsM(row))}// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here.default:returnnil,am.Fail(403,"This API is not available for row-scoped users.")}returnnil,nil}
useam::{Errors,G,Value,json};// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this list is not row-scoped at all.constOWNED:[(&str,&str,&str);3]=[("purchase_invoices","created_by","PURCHASE_INVOICE_VIEW_ALL"),("debit_notes","created_by","DEBIT_NOTE_VIEW_ALL"),("parcel_entries","entered_by","PARCEL_ENTRY_VIEW_ALL"),];// ── apiId → where that API carries its filter ──────────────// One match per request.fnscope_of(api_id:&str)->Option<&'staticstr>{matchapi_id{// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike."SCHEMA_GET_ALL"|"SCHEMA_GET_ALL_STREAM"|"SCHEMA_GET_BY_ID"|"SCHEMA_PUT_UPDATE_BY_ID"|"SCHEMA_PUT_REPLACE_BY_ID"|"SCHEMA_DEL_DELETE_BY_ID"=>Some("QUERY_FIND"),"SCHEMA_POST_QUERY"|"SCHEMA_POST_QUERY_STREAM"|"SCHEMA_POST_QUERY_DELETE"|"SCHEMA_UPDATE_MANY"|"SCHEMA_POST_DISTINCT_QUERY"|"SCHEMA_ARRAY_OPERATIONS"|"SCHEMA_POST_COUNT"=>Some("BODY_FIND"),"SCHEMA_POST_BULK_INSERT"|"SCHEMA_MASTER_SAVE"=>Some("STAMP_OWNER"),_=>None,}}// ── The APIs which only read : a supervisor sees every row with these ──────────────constREADS:[&str;7]=["SCHEMA_GET_ALL","SCHEMA_GET_ALL_STREAM","SCHEMA_GET_BY_ID","SCHEMA_POST_QUERY","SCHEMA_POST_QUERY_STREAM","SCHEMA_POST_DISTINCT_QUERY","SCHEMA_POST_COUNT",];pubfnmain(g:&mutG)->Result<Value,Errors>{// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if!g.req.is_api_request_from_user(){returnOk(Value::Null);}letcollection=g.req.params()["collection"].as_str().unwrap_or_default().to_owned();letSome(&(_,column,sees_all))=OWNED.iter().find(|(name,..)|*name==collection)else{returnOk(Value::Null);// not a user-owned collection};// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.letperson=g.req.auth()["authAMDB"].clone();letapi_id=g.req.req_info()["apiInfo"]["id"].as_str().unwrap_or_default().to_owned();// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.letgroups:Vec<&str>=person["groups"].as_str().unwrap_or_default().split(',').collect();ifREADS.contains(&api_id.as_str())&&(groups.contains(&sees_all)||groups.contains(&"*")){returnOk(Value::Null);}letscope=json!({column:person["_id"]});// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.letmerge=|existing:&Value|matchexisting{Value::Object(find)if!find.is_empty()=>json!({"$and":[existing,scope]}),_=>scope.clone(),};// 3. One lookup decides where the filter goes.matchscope_of(&api_id){// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe.Some("QUERY_FIND")=>{letfind=merge(&g.req.query()["find"]);g.req.query_mut()["find"]=find;letbody=g.req.body_mut();ifbody.is_object(){body[column]=person["_id"].clone();// update-by-id, replace-by-id : the owner can not be handed over}}Some("BODY_FIND")=>{letfind=merge(&g.req.body()["find"]);g.req.body_mut()["find"]=find;ifletSome(update_data)=g.req.body_mut().get_mut("updateData").filter(|update_data|update_data.is_object()){update_data[column]=person["_id"].clone();// update-many}}// Inserts and master save: stamp the owner on the server. Never trust the body.Some("STAMP_OWNER")=>{letrows=matchg.req.body_mut(){Value::Array(rows)=>rows.iter_mut().collect(),row=>vec![row],};forrowinrows{ifrow.is_object(){row[column]=person["_id"].clone();}}}// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here._=>returnErr(am::fail(403,"This API is not available for row-scoped users.")),}Ok(Value::Null)}
importam.AmErrors;importam.G;importam.JsonObject;importjava.util.ArrayList;importjava.util.Arrays;importjava.util.List;importjava.util.Map;importjava.util.Set;importjava.util.function.Consumer;importjava.util.function.UnaryOperator;classApi{// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this map is not row-scoped at all.recordOwned(Stringcolumn,StringseesAll){}staticfinalMap<String,Owned>OWNED=Map.of("purchase_invoices",newOwned("created_by","PURCHASE_INVOICE_VIEW_ALL"),"debit_notes",newOwned("created_by","DEBIT_NOTE_VIEW_ALL"),"parcel_entries",newOwned("entered_by","PARCEL_ENTRY_VIEW_ALL"));// ── apiId → where that API carries its filter ──────────────// One O(1) lookup per request instead of scanning lists.staticfinalMap<String,String>SCOPE_BY_API=Map.ofEntries(// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike.Map.entry("SCHEMA_GET_ALL","QUERY_FIND"),Map.entry("SCHEMA_GET_ALL_STREAM","QUERY_FIND"),Map.entry("SCHEMA_GET_BY_ID","QUERY_FIND"),Map.entry("SCHEMA_PUT_UPDATE_BY_ID","QUERY_FIND"),Map.entry("SCHEMA_PUT_REPLACE_BY_ID","QUERY_FIND"),Map.entry("SCHEMA_DEL_DELETE_BY_ID","QUERY_FIND"),Map.entry("SCHEMA_POST_QUERY","BODY_FIND"),Map.entry("SCHEMA_POST_QUERY_STREAM","BODY_FIND"),Map.entry("SCHEMA_POST_QUERY_DELETE","BODY_FIND"),Map.entry("SCHEMA_UPDATE_MANY","BODY_FIND"),Map.entry("SCHEMA_POST_DISTINCT_QUERY","BODY_FIND"),Map.entry("SCHEMA_ARRAY_OPERATIONS","BODY_FIND"),Map.entry("SCHEMA_POST_COUNT","BODY_FIND"),Map.entry("SCHEMA_POST_BULK_INSERT","STAMP_OWNER"),Map.entry("SCHEMA_MASTER_SAVE","STAMP_OWNER"));// ── The APIs which only read : a supervisor sees every row with these ──────────────staticfinalSet<String>READS=Set.of("SCHEMA_GET_ALL","SCHEMA_GET_ALL_STREAM","SCHEMA_GET_BY_ID","SCHEMA_POST_QUERY","SCHEMA_POST_QUERY_STREAM","SCHEMA_POST_DISTINCT_QUERY","SCHEMA_POST_COUNT");@SuppressWarnings("unchecked")staticObjectmain(Gg){// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if(!g.req.isApiRequestFromUser())returnnull;Ownedowned=OWNED.get(g.req.getParams().getString("collection",""));if(owned==null)returnnull;// not a user-owned collection// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.JsonObjectperson=g.req.getAuth().getObject("authAMDB");StringapiId=g.req.getReqInfo().getObject("apiInfo").getString("id","");// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.List<String>groups=Arrays.asList(person.getString("groups","").split(","));if(READS.contains(apiId)&&(groups.contains(owned.seesAll())||groups.contains("*")))returnnull;Map<String,Object>scope=JsonObject.of(owned.column(),person.get("_id"));// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.UnaryOperator<Object>merge=existing->existinginstanceofMap<?,?>find&&!find.isEmpty()?Map.of("$and",List.of(existing,scope)):scope;// The owner of a row is written by the server, never by the caller.Consumer<Object>stamp=row->{if(rowinstanceofMap<?,?>)((Map<String,Object>)row).put(owned.column(),person.get("_id"));};// 3. One lookup decides where the filter goes.switch(SCOPE_BY_API.getOrDefault(apiId,"")){// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe.case"QUERY_FIND"->{g.req.getQuery().put("find",merge.apply(g.req.getQuery().get("find")));stamp.accept(g.req.get("body"));// update-by-id, replace-by-id : the owner can not be handed over}case"BODY_FIND"->{g.req.getBody().put("find",merge.apply(g.req.getBody().get("find")));stamp.accept(g.req.getBody().get("updateData"));// update-many}// Inserts and master save: stamp the owner on the server. Never trust the body.case"STAMP_OWNER"->{List<Object>rows=g.req.get("body")instanceofList<?>list?newArrayList<>(list):List.of(g.req.getBody());for(Objectrow:rows)stamp.accept(row);}// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here.default->thrownewAmErrors("This API is not available for row-scoped users.",403);}returnnull;}}
importam.AmErrorsimportam.G// ── Which collections are owned by a user, by which column, and which group reads every row ──────────────// A collection that is not in this map is not row-scoped at all.dataclassOwned(valcolumn:String,valseesAll:String)valOWNED=mapOf("purchase_invoices"toOwned("created_by","PURCHASE_INVOICE_VIEW_ALL"),"debit_notes"toOwned("created_by","DEBIT_NOTE_VIEW_ALL"),"parcel_entries"toOwned("entered_by","PARCEL_ENTRY_VIEW_ALL"),)// ── apiId → where that API carries its filter ──────────────// One O(1) lookup per request instead of scanning lists.valSCOPE_BY_API=mapOf(// API Maker ANDs g.req.query.find onto the primary key for the *-by-id APIs,// so one filter covers the list, the single read, and the writes alike."SCHEMA_GET_ALL"to"QUERY_FIND","SCHEMA_GET_ALL_STREAM"to"QUERY_FIND","SCHEMA_GET_BY_ID"to"QUERY_FIND","SCHEMA_PUT_UPDATE_BY_ID"to"QUERY_FIND","SCHEMA_PUT_REPLACE_BY_ID"to"QUERY_FIND","SCHEMA_DEL_DELETE_BY_ID"to"QUERY_FIND","SCHEMA_POST_QUERY"to"BODY_FIND","SCHEMA_POST_QUERY_STREAM"to"BODY_FIND","SCHEMA_POST_QUERY_DELETE"to"BODY_FIND","SCHEMA_UPDATE_MANY"to"BODY_FIND","SCHEMA_POST_DISTINCT_QUERY"to"BODY_FIND","SCHEMA_ARRAY_OPERATIONS"to"BODY_FIND","SCHEMA_POST_COUNT"to"BODY_FIND","SCHEMA_POST_BULK_INSERT"to"STAMP_OWNER","SCHEMA_MASTER_SAVE"to"STAMP_OWNER",)// ── The APIs which only read : a supervisor sees every row with these ──────────────valREADS=setOf("SCHEMA_GET_ALL","SCHEMA_GET_ALL_STREAM","SCHEMA_GET_BY_ID","SCHEMA_POST_QUERY","SCHEMA_POST_QUERY_STREAM","SCHEMA_POST_DISTINCT_QUERY","SCHEMA_POST_COUNT",)@Suppress("UNCHECKED_CAST")funmain(g:G):Any?{// 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling// g.sys.db run under their own rules.if(!g.req.isApiRequestFromUser)returnnullvalowned=OWNED[g.req.params.getString("collection","")]?:returnnull// not a user-owned collection// 2. Which person is acting? Not the application — the person.// API Maker has already rejected the request if the user token is missing.valperson=g.req.auth.getObject("authAMDB")valapiId=g.req.reqInfo.getObject("apiInfo").getString("id","")// A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.valgroups=person.getString("groups","").split(",")if(apiIdinREADS&&(owned.seesAllingroups||"*"ingroups))returnnullvalscope=mapOf(owned.columntoperson["_id"])// MERGE, never replace. $and keeps whatever the caller asked for and adds ours// on top, so a caller can never widen the scope by sending its own created_by.funmerge(existing:Any?):Any=if(existingisMap<*,*>&&existing.isNotEmpty())mapOf("\$and"tolistOf(existing,scope))elsescope// The owner of a row is written by the server, never by the caller.funstamp(row:Any?){if(rowisMutableMap<*,*>)(rowasMutableMap<String,Any?>)[owned.column]=person["_id"]}// 3. One lookup decides where the filter goes.when(SCOPE_BY_API[apiId]){// get-all, get-by-id, update-by-id, replace-by-id, remove-by-id.// A row that is not yours is simply never matched — no extra round-trip,// and no 404-vs-403 difference for an attacker to probe."QUERY_FIND"->{g.req.query["find"]=merge(g.req.query["find"])stamp(g.req["body"])// update-by-id, replace-by-id : the owner can not be handed over}"BODY_FIND"->{g.req.body["find"]=merge(g.req.body["find"])stamp(g.req.body["updateData"])// update-many}// Inserts and master save: stamp the owner on the server. Never trust the body."STAMP_OWNER"->{valrows=g.req["body"]as?List<*>?:listOf(g.req.body)for(rowinrows)stamp(row)}// Fail closed. Aggregate and distinct cannot be scoped generically,// so if one of them is ever granted by mistake, it stops here.else->throwAmErrors("This API is not available for row-scoped users.",403)}returnnull}
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.
The lookup tables are a Map and a Set. The hook runs on every single request against the database, so scanning arrays with indexOf is work you would be paying for constantly and getting nothing back for. A Map is also the safer shape here: the collection 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 (!owned) guard.
Supervisors are one line. The person's groups come from the groups column, through g.req.auth.authAMDB, and PURCHASE_INVOICE_VIEW_ALL lifts the scope on the reads only. A supervisor who also holds PURCHASE_INVOICE_EDIT still edits their own invoices alone. *, every group, counts as holding it.
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.
The owner is written by the server on every write : the rows of an insert or a master save, the body of an update or a replace by id, and the updateData of an update many. A caller who sends created_by: 7 cannot hand an invoice to someone else.
The map lists the APIs of tables with a schema (SCHEMA_…). The generated APIs without a schema (GEN_…, under /api/gen) reach the default branch, so a row-scoped person gets 403 from them. Add their ids next to the others if those people need them : the hook of the APIs Security Report lists both families.
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.
Hooks can carry a groupNames list. Give it one and the hook only runs when the API user of the call, the one of x-am-authorization, is in one of those groups.
Hook has groupNames?
API user's groups intersect?
Runs?
No
—
always
Yes
Yes
yes
Yes
No
skipped
Yes
API user 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.
groupNames never looks at the person. The groups column decides which APIs the person may call, not which hooks run, and every person of the application comes through the same API user. So the supervisor exemption stays the line in the hook that reads g.req.auth.authAMDB. Put PURCHASE_INVOICE_VIEW_ALL in groupNames instead and the hook skips for every person whose API user lacks that group, and a row-scoping hook that does not run scopes nothing.
# 1. log in — one call, both tokensTOKENS=$(curl-s-XPOST"$BASE/api/custom-api/erp/login"\-H'content-type: application/json'\-d'{"user_name":"rakesh","password":"<password>"}')AM=$(echo"$TOKENS"|jq-r'.data.amToken')USR=$(echo"$TOKENS"|jq-r'.data.amDbToken')# 2. his own invoice → 200curl-s-o/dev/null-w'%{http_code}\n'\"$BASE/api/schema/erp/erp_mongo/erp_db/purchase_invoices/get-by-id/PI-1042"\-H"x-am-authorization: $AM"-H"x-am-user-authorization: $USR"# 3. somebody else's invoice → no data (the row gate: it never matches)curl-s"$BASE/api/schema/erp/erp_mongo/erp_db/purchase_invoices/get-by-id/PI-2001"\-H"x-am-authorization: $AM"-H"x-am-user-authorization: $USR"\|jq'.data'# → null, same as an id that does not exist# 4. an API his groups never granted → 403 (the API gate)curl-s-o/dev/null-w'%{http_code}\n'-XDELETE\"$BASE/api/schema/erp/erp_mongo/erp_db/purchase_invoices/PI-1042"\-H"x-am-authorization: $AM"-H"x-am-user-authorization: $USR"# 5. a collection nobody granted → 403curl-s-o/dev/null-w'%{http_code}\n'\"$BASE/api/schema/erp/erp_mongo/erp_db/salaries"\-H"x-am-authorization: $AM"-H"x-am-user-authorization: $USR"# 6. a field the group cannot read is absent, not nullcurl-s"$BASE/api/schema/erp/erp_mongo/erp_db/purchase_invoices/get-by-id/PI-1042"\-H"x-am-authorization: $AM"-H"x-am-user-authorization: $USR"\|jq'.data | has("landed_cost")'# → false# 7. a list call cannot be widened from the clientcurl-s"$BASE/api/schema/erp/erp_mongo/erp_db/purchase_invoices?find={created_by:{\$ne:null}}"\-H"x-am-authorization: $AM"-H"x-am-user-authorization: $USR"\|jq'[.data[].created_by] | unique'# → ["102"]
If any of them comes back wider than you expected, your grants are wider than you think they are.
Three more things that help here:
Send x-am-meta: true and 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.
Security › APIs Security Report & Actions reads every group, auth provider and pre-hook of the account and flags most of the mistakes below: a collection no hook scopes, a spread merge, an owner taken from the body, allCollections, allSystemApis, a password in the token. The missing hook and the broad grants are fixed in one click, the others open the place to change. See APIs Security Report & Actions.
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.