Skip to content
This page View Markdown Open in ChatGPT Open in Claude

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.

Role permission matrix mapped to an API Maker group


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.

The API gate and the row gate

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_VIEW is allowed to call get-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-id and 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:

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.


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 one line of the row-scoping hook, which reads the groups of the person: see the database level pre-hook.


Step 1 — Grant the group exactly what it needs

Open APIs Security › API group permission, add PURCHASE_INVOICE_VIEW, and then tick almost nothing.

Granting a single API to one group

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.

{
    "fields": {
        "erp_mongo": {
            "erp_db": {
                "purchase_invoices": {
                    "landed_cost":       { "allowRead": false, "allowWrite": false },
                    "supplier_margin":   { "allowRead": false, "allowWrite": false },
                    "invoice_total":     { "allowRead": true,  "allowWrite": false }
                }
            }
        }
    }
}

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.

The groups column drives everything

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

erp_users schema
import { ISchemaType, EType, ISchemaProperty, IPropertyValidation } from 'types';

let schema: 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 column
    groups: <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.

Point API Maker at that column

Declare a DB auth provider on that table in Security › Auth Providers, and name the column in groupsColumn. The whole page: Auth of a database user.

Basic Info of the provider erp_users_tg
import * as T from 'types';

let dbTokenGenerator: 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 itself
    groupsColumn: 'groups',

    // The columns of the person in g.req.auth.authAMDB
    select: { _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.

Database settings of erp_db
1
2
3
4
5
6
7
import * as T from 'types';

let settings: T.IInstanceApiSettingsTypes = {
    apiAccessType: T.EAPIAccessType.TOKEN_ACCESS,
    authProviders: ['erp_users_tg'],
};
module.exports = settings;

That is the whole wiring. From here on:

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


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.

Custom API: login (apiAccessType: IS_PUBLIC)
import * as T from 'types';
import * as db from 'db-interfaces';

const INSTANCE = 'erp_mongo';
const DATABASE = 'erp_db';
const USERS = 'erp_users';

async function main(g: T.IAMGlobal) {
    const userName = g.req.body.user_name;
    const password = g.req.body.password;

    if (!userName || !password) {
        throw [{ code: T.EStatusCode.BAD_REQUEST, message: 'user_name and password are required.' }];
    }

    const user: db.erp_mongo.erp_db.IErpUsers = await g.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.
    const apiUserPassword = await g.sys.system.getSecret('common.apiUserPasswords.erp_app');

    const [app, person] = await g.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-authorization
        amDbToken: person.token,     // → send as x-am-user-authorization
        user: { _id: user._id, user_name: user.user_name, groups: user.groups }
    };
}
module.exports = main;
Custom API: login (apiAccessType: IS_PUBLIC)
from am import Errors, G, GetToken

INSTANCE = 'erp_mongo'
DATABASE = 'erp_db'
USERS = 'erp_users'


async def main(g: G):
    user_name = g.req.body.user_name
    password = g.req.body.password

    if not user_name or not password:
        raise Errors('user_name and password are required.', code=400)

    user = await g.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',
    )

    if not user or user.is_active is not True:
        raise Errors('Invalid credentials.', code=401)

    # The API user password lives in the secret, never in code and never in the frontend.
    api_user_password = await g.sys.system.get_secret('common.apiUserPasswords.erp_app')

    app, person = await g.sys.system.get_tokens([
        # No name : the token of the API user
        GetToken('erp_app', api_user_password),
        # The DB auth provider 'erp_users_tg' checks the password against the erp_users table
        GetToken(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},
    }
Custom API: login (apiAccessType: IS_PUBLIC)
<?php
const INSTANCE = 'erp_mongo';
const DATABASE = 'erp_db';
const USERS = 'erp_users';

function main(AmG $g)
{
    $userName = $g->req->body->user_name;
    $password = $g->req->body->password;

    if (empty($userName) || empty($password)) {
        throw new AmErrors('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) {
        throw new AmErrors('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 user
        new GetToken('erp_app', $apiUserPassword),
        // The DB auth provider 'erp_users_tg' checks the password against the erp_users table
        new GetToken($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],
    ];
}
Custom API: login (apiAccessType: IS_PUBLIC)
file static class Api
{
    const string Instance = "erp_mongo";
    const string Database = "erp_db";
    const string Users = "erp_users";

    public static async Task<object?> Main(G g)
    {
        var userName = g.Req.Body.GetString("user_name");
        var password = g.Req.Body.GetString("password");

        if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
            throw new AmErrors("user_name and password are required.", 400);

        var user = await g.Sys.Db.GetById(new 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 is null || !user.GetBoolean("is_active"))
            throw new AmErrors("Invalid credentials.", 401);

        // The API user password lives in the secret, never in code and never in the frontend.
        var apiUserPassword = (await g.Sys.System.GetSecret("common.apiUserPasswords.erp_app"))?.GetValue<string>();

        var tokens = await g.Sys.System.GetTokens([
            // No name : the token of the API user
            new GetToken("erp_app", apiUserPassword),
            // The DB auth provider 'erp_users_tg' checks the password against the erp_users table
            new GetToken(userName, password) { Name = "erp_users_tg" },
        ]);

        return new
        {
            amToken = tokens[0].Token,       // → send as x-am-authorization
            amDbToken = tokens[1].Token,     // → send as x-am-user-authorization
            user = new { _id = user["_id"], user_name = user["user_name"], groups = user["groups"] },
        };
    }
}
Custom API: login (apiAccessType: IS_PUBLIC)
package code

import "apimaker/am"

const (
    instance = "erp_mongo"
    database = "erp_db"
    users    = "erp_users"
)

func Main(g *am.G) (any, error) {
    body := g.Req.Body()
    userName := body.String("user_name")
    password := body.String("password")

    if userName == "" || password == "" {
        return nil, 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",
    })
    if err != nil {
        return nil, err
    }

    if user == nil || !user.Bool("is_active") {
        return nil, 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")
    if err != nil {
        return nil, 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},
    })
    if err != nil {
        return nil, err
    }

    return am.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
}
Custom API: login (apiAccessType: IS_PUBLIC)
use am::{Errors, G, GetById, GetToken, Value, json};

const INSTANCE: &str = "erp_mongo";
const DATABASE: &str = "erp_db";
const USERS: &str = "erp_users";

pub fn main(g: &mut G) -> Result<Value, Errors> {
    let body = g.req.body();
    let user_name = body["user_name"].as_str().unwrap_or_default().to_owned();
    let password = body["password"].as_str().unwrap_or_default().to_owned();

    if user_name.is_empty() || password.is_empty() {
        return Err(am::fail(400, "user_name and password are required."));
    }

    let user = 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"),
    )?;

    let Some(user) = user.filter(|user| user["is_active"] == true) else {
        return Err(am::fail(401, "Invalid credentials."));
    };

    // The API user password lives in the secret, never in code and never in the frontend.
    let api_user_password = g.sys.system.get_secret("common.apiUserPasswords.erp_app", None)?;

    let tokens = g.sys.system.get_tokens(vec![
        // No name : the token of the API user
        GetToken::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 table
        GetToken::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"] },
    }))
}
Custom API: login (apiAccessType: IS_PUBLIC)
import am.AmErrors;
import am.G;
import am.GetById;
import am.GetToken;
import am.JsonObject;
import am.TokenResponse;
import java.util.List;

class Api {
    static final String INSTANCE = "erp_mongo";
    static final String DATABASE = "erp_db";
    static final String USERS = "erp_users";

    static Object main(G g) {
        JsonObject body = g.req.getBody();
        String userName = body.getString("user_name", "");
        String password = body.getString("password", "");

        if (userName.isEmpty() || password.isEmpty()) {
            throw new AmErrors("user_name and password are required.", 400);
        }

        JsonObject user = g.sys.db.getById(new 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)) {
            throw new AmErrors("Invalid credentials.", 401);
        }

        // The API user password lives in the secret, never in code and never in the frontend.
        String apiUserPassword = (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 user
            new GetToken("erp_app", apiUserPassword),
            // The DB auth provider 'erp_users_tg' checks the password against the erp_users table
            new GetToken(userName, password).name("erp_users_tg")));

        return JsonObject.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")));
    }
}
Custom API: login (apiAccessType: IS_PUBLIC)
import am.AmErrors
import am.G
import am.GetById
import am.GetToken

const val INSTANCE = "erp_mongo"
const val DATABASE = "erp_db"
const val USERS = "erp_users"

fun main(g: G): Any? {
    val userName = g.req.body.getString("user_name", "")
    val password = g.req.body.getString("password", "")

    if (userName.isEmpty() || password.isEmpty()) throw AmErrors("user_name and password are required.", 400)

    val user = 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)) throw AmErrors("Invalid credentials.", 401)

    // The API user password lives in the secret, never in code and never in the frontend.
    val apiUserPassword = g.sys.system.getSecret("common.apiUserPasswords.erp_app") as String

    val (app, person) = g.sys.system.getTokens(
        listOf(
            // No name : the token of the API user
            GetToken("erp_app", apiUserPassword),
            // The DB auth provider 'erp_users_tg' checks the password against the erp_users table
            GetToken(userName, password).name("erp_users_tg"),
        ),
    )

    return mapOf(
        "amToken" to app.token,          // → send as x-am-authorization
        "amDbToken" to person.token,     // → send as x-am-user-authorization
        "user" to mapOf("_id" to user["_id"], "user_name" to user["user_name"], "groups" to user["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.

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, SCHEMA_POST_COUNT g.req.body.find merge the owner into find ; for update many, also stamp the owner into updateData
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 ; 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".

The database level pre-hook

Database pre-hook on erp_mongo › erp_db
import * as T from '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.
const OWNED = new Map<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.
const SCOPE_BY_API = new 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 ──────────────
const READS = new Set<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'
]);

async function main(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;

    const owned = 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.
    const person = g.req.auth.authAMDB;
    const apiId = g.req.reqInfo.apiInfo.id;

    // A supervisor reads every row. The groups come from the groups column of the person ; '*' is every group.
    const groups = String(person.groups || '').split(',');
    if (READS.has(apiId) && (groups.includes(owned.seesAll) || groups.includes('*'))) return;

    const 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.
    const merge = (existing) => (existing && Object.keys(existing).length
        ? { $and: [existing, scope] }
        : scope);

    // The owner of a row is written by the server, never by the caller.
    const stamp = (row) => { if (row && typeof row === '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 over
            return;

        case 'BODY_FIND':
            g.req.body.find = merge(g.req.body.find);
            if (g.req.body.updateData) stamp(g.req.body.updateData);   // update-many
            return;

        // Inserts and master save: stamp the owner on the server. Never trust the body.
        case 'STAMP_OWNER': {
            const rows = Array.isArray(g.req.body) ? g.req.body : [g.req.body];
            for (const row of rows) 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;
Database pre-hook on erp_mongo › erp_db
from am import Errors, 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',
}


async def main(g: G):

    # 1. Only scope real HTTP traffic. Custom APIs, events and schedulers calling
    #    g.sys.db run under their own rules.
    if not g.req.is_api_request_from_user:
        return

    owned = OWNED.get(g.req.params.collection)
    if not 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.
    person = g.req.auth.authAMDB
    api_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.groups or '').split(',')
    if api_id in READS and (owned['sees_all'] in groups or '*' in groups):
        return

    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.
    def merge(existing):
        return {'$and': [existing, scope]} if existing else scope

    # The owner of a row is written by the server, never by the caller.
    def stamp(row):
        if isinstance(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.
    if where == '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

    elif where == '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.
    elif where == 'STAMP_OWNER':
        rows = g.req.body if isinstance(g.req.body, list) else [g.req.body]
        for row in rows:
            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:
        raise Errors('This API is not available for row-scoped users.', code=403)
Database pre-hook on erp_mongo › erp_db
<?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.
const OWNED = [
    '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.
const 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 ──────────────
const 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',
];

function main(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) {
        return null;
    }

    $owned = OWNED[$g->req->params->collection ?? ''] ?? null;
    if ($owned === null) {
        return 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.
    $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))) {
        return null;
    }

    $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 ($row instanceof AmObject) {
            $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 over
            return null;

        case 'BODY_FIND':
            $g->req->body->find = $merge($g->req->body->find);
            $stamp($g->req->body->updateData);    // update-many
            return null;

        // 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 ($rows as $row) {
                $stamp($row);
            }
            return null;

        // Fail closed. Aggregate and distinct cannot be scoped generically,
        // so if one of them is ever granted by mistake, it stops here.
        default:
            throw new AmErrors('This API is not available for row-scoped users.', 403);
    }
}
Database pre-hook on erp_mongo › erp_db
file static class Api
{
    // ── 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.
    static readonly Dictionary<string, (string Column, string SeesAll)> 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.
    static readonly Dictionary<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 ──────────────
    static readonly HashSet<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",
    ];

    public static async Task<object?> Main(G 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) return null;

        if (!Owned.TryGetValue(g.Req.Params.GetString("collection") ?? "", out var owned)) return 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.
        var person = g.Req.Auth.GetObject("authAMDB");
        var apiId = 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.
        var groups = (person.GetString("groups") ?? "").Split(',');
        if (Reads.Contains(apiId) && (groups.Contains(owned.SeesAll) || groups.Contains("*"))) return null;

        JsonObject Scope() => 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.
        JsonNode Merge(JsonNode? existing) => existing is JsonObject { Count: > 0 }
            ? new JsonObject { ["$and"] = new JsonArray(existing.DeepClone(), Scope()) }
            : Scope();

        // The owner of a row is written by the server, never by the caller.
        void Stamp(JsonNode? row)
        {
            if (row is JsonObject fields) 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 over
                return null;

            case "BODY_FIND":
                g.Req.Body["find"] = Merge(g.Req.Body["find"]);
                Stamp(g.Req.Body["updateData"]);       // update-many
                return null;

            // Inserts and master save: stamp the owner on the server. Never trust the body.
            case "STAMP_OWNER":
                IEnumerable<JsonObject> rows = g.Req["body"] is JsonArray list ? list.OfType<JsonObject>() : [g.Req.Body];
                foreach (var row in rows) Stamp(row);
                return null;

            // Fail closed. Aggregate and distinct cannot be scoped generically,
            // so if one of them is ever granted by mistake, it stops here.
            default:
                throw new AmErrors("This API is not available for row-scoped users.", 403);
        }
    }
}
Database pre-hook on erp_mongo › erp_db
package code

import (
    "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.
type ownership struct{ column, seesAll string }

var owned = 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.
var scopeByApi = 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 ──────────────
var reads = 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,
}

func Main(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() {
        return nil, nil
    }

    own, isOwned := owned[g.Req.Params().String("collection")]
    if !isOwned {
        return nil, 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(name string) bool {
        for _, group := range strings.Split(person.String("groups"), ",") {
            if group == name {
                return true
            }
        }
        return false
    }
    if reads[apiID] && (holds(own.seesAll) || holds("*")) {
        return nil, 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(existing am.M) am.M {
        if len(existing) > 0 {
            return am.M{"$and": []any{existing, scope}}
        }
        return scope
    }

    // The owner of a row is written by the server, never by the caller.
    stamp := func(row am.M) {
        if row != nil {
            row[own.column] = person.Get("_id")
        }
    }

    // 3. One lookup decides where the filter goes.
    switch scopeByApi[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 over

    case "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")
        if rows == nil {
            rows = []any{g.Req.Body()}
        }
        for _, row := range rows {
            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:
        return nil, am.Fail(403, "This API is not available for row-scoped users.")
    }
    return nil, nil
}
Database pre-hook on erp_mongo › erp_db
use am::{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.
const OWNED: [(&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.
fn scope_of(api_id: &str) -> Option<&'static str> {
    match api_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 ──────────────
const READS: [&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",
];

pub fn main(g: &mut G) -> 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() {
        return Ok(Value::Null);
    }

    let collection = g.req.params()["collection"].as_str().unwrap_or_default().to_owned();
    let Some(&(_, column, sees_all)) = OWNED.iter().find(|(name, ..)| *name == collection) else {
        return Ok(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.
    let person = g.req.auth()["authAMDB"].clone();
    let api_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.
    let groups: Vec<&str> = person["groups"].as_str().unwrap_or_default().split(',').collect();
    if READS.contains(&api_id.as_str()) && (groups.contains(&sees_all) || groups.contains(&"*")) {
        return Ok(Value::Null);
    }

    let scope = 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.
    let merge = |existing: &Value| match existing {
        Value::Object(find) if !find.is_empty() => json!({ "$and": [existing, scope] }),
        _ => scope.clone(),
    };

    // 3. One lookup decides where the filter goes.
    match scope_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") => {
            let find = merge(&g.req.query()["find"]);
            g.req.query_mut()["find"] = find;
            let body = g.req.body_mut();
            if body.is_object() {
                body[column] = person["_id"].clone(); // update-by-id, replace-by-id : the owner can not be handed over
            }
        }

        Some("BODY_FIND") => {
            let find = merge(&g.req.body()["find"]);
            g.req.body_mut()["find"] = find;
            if let Some(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") => {
            let rows = match g.req.body_mut() {
                Value::Array(rows) => rows.iter_mut().collect(),
                row => vec![row],
            };
            for row in rows {
                if row.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.
        _ => return Err(am::fail(403, "This API is not available for row-scoped users.")),
    }
    Ok(Value::Null)
}
Database pre-hook on erp_mongo › erp_db
import am.AmErrors;
import am.G;
import am.JsonObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;

class Api {
    // ── 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.
    record Owned(String column, String seesAll) {
    }

    static final Map<String, Owned> OWNED = Map.of(
        "purchase_invoices", new Owned("created_by", "PURCHASE_INVOICE_VIEW_ALL"),
        "debit_notes", new Owned("created_by", "DEBIT_NOTE_VIEW_ALL"),
        "parcel_entries", new Owned("entered_by", "PARCEL_ENTRY_VIEW_ALL"));

    // ── apiId → where that API carries its filter ──────────────
    // One O(1) lookup per request instead of scanning lists.
    static final Map<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 ──────────────
    static final Set<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")
    static Object main(G 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()) return null;

        Owned owned = OWNED.get(g.req.getParams().getString("collection", ""));
        if (owned == null) return 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.
        JsonObject person = g.req.getAuth().getObject("authAMDB");
        String apiId = 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("*"))) return null;

        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 -> existing instanceof Map<?, ?> 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 (row instanceof Map<?, ?>) ((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") instanceof List<?> list ? new ArrayList<>(list) : List.of(g.req.getBody());
                for (Object row : 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 -> throw new AmErrors("This API is not available for row-scoped users.", 403);
        }
        return null;
    }
}
Database pre-hook on erp_mongo › erp_db
import am.AmErrors
import am.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.
data class Owned(val column: String, val seesAll: String)

val OWNED = mapOf(
    "purchase_invoices" to Owned("created_by", "PURCHASE_INVOICE_VIEW_ALL"),
    "debit_notes" to Owned("created_by", "DEBIT_NOTE_VIEW_ALL"),
    "parcel_entries" to Owned("entered_by", "PARCEL_ENTRY_VIEW_ALL"),
)

// ── apiId → where that API carries its filter ──────────────
// One O(1) lookup per request instead of scanning lists.
val SCOPE_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 ──────────────
val READS = 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")
fun main(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) return null

    val owned = OWNED[g.req.params.getString("collection", "")] ?: return 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.
    val person = g.req.auth.getObject("authAMDB")
    val apiId = 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.
    val groups = person.getString("groups", "").split(",")
    if (apiId in READS && (owned.seesAll in groups || "*" in groups)) return null

    val scope = mapOf(owned.column to 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.
    fun merge(existing: Any?): Any = if (existing is Map<*, *> && existing.isNotEmpty()) mapOf("\$and" to listOf(existing, scope)) else scope

    // The owner of a row is written by the server, never by the caller.
    fun stamp(row: Any?) {
        if (row is MutableMap<*, *>) (row as MutableMap<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" -> {
            val rows = g.req["body"] as? List<*> ?: listOf(g.req.body)
            for (row in rows) 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 -> throw AmErrors("This API is not available for row-scoped users.", 403)
    }
    return null
}

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.

Gating a hook to specific groups

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.


Step 5 — Try to break it

Nobody should believe a permission model until they have tried to get round it. Run these.

# 1. log in — one call, both tokens
TOKENS=$(curl -s -X POST "$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 → 200
curl -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' -X DELETE \
  "$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 → 403
curl -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 null
curl -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 client
curl -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.

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
Putting a person's group in a hook's groupNames groupNames is compared with the groups of the API user, so the hook skips for every person of the application or for none Read the person's groups from g.req.auth.authAMDB inside the hook
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