# Handle Role Based Permissions

> Model every checkbox of your role screen as one small API Maker group, store group names in your own users table, and scope rows with a database level pre-hook.

Source: https://docs.apimaker.dev/v4/docs/authorization/handle-role-based-permissions.html

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](https://docs.apimaker.dev/assets/images/role-based-permissions/rbac-checkbox-is-a-group.svg)

---

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](https://docs.apimaker.dev/assets/images/role-based-permissions/rbac-two-gates.svg)

| | 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:

```text
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](https://docs.apimaker.dev/v4/docs/authorization/handle-role-based-permissions.html#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](https://docs.apimaker.dev/assets/images/role-based-permissions/rbac-group-grants.svg)

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.

```json
{
    "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](https://docs.apimaker.dev/assets/images/role-based-permissions/rbac-groups-column.svg)

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**

```ts
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](https://docs.apimaker.dev/v4/docs/authorization/AMDB.html).

**Basic Info of the provider erp_users_tg**

```typescript
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](https://docs.apimaker.dev/v4/docs/settings/collectionSettings.html) or an [API](https://docs.apimaker.dev/v4/docs/settings/apiSettings.html) can name its own, and `common.authProviders` of the [secret](https://docs.apimaker.dev/v4/docs/secrets/secrets.html#the-keys-of-common) is the default of every API.

**Database settings of erp_db**

```typescript
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](https://docs.apimaker.dev/v4/docs/authorization/sign-in-providers.html#google), [AWS Cognito](https://docs.apimaker.dev/v4/docs/authorization/sign-in-providers.html#aws) and [Azure AD](https://docs.apimaker.dev/v4/docs/authorization/sign-in-providers.html#azure) 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.

**TypeScript**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```typescript
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;
```

**Python**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```python
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},
    }
```

**PHP**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```php
<?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],
    ];
}
```

**C#**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```csharp
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"] },
        };
    }
}
```

**Go**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```go
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
}
```

**Rust**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```rust
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"] },
    }))
}
```

**Java**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```java
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")));
    }
}
```

**Kotlin**

**Custom API: login  (apiAccessType: IS_PUBLIC)**

```kotlin
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.

```text
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:

```json
{ "_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

**TypeScript**

**Database pre-hook on erp_mongo › erp_db**

```typescript
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;
```

**Python**

**Database pre-hook on erp_mongo › erp_db**

```python
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)
```

**PHP**

**Database pre-hook on erp_mongo › erp_db**

```php
<?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);
    }
}
```

**C#**

**Database pre-hook on erp_mongo › erp_db**

```csharp
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);
        }
    }
}
```

**Go**

**Database pre-hook on erp_mongo › erp_db**

```go
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
}
```

**Rust**

**Database pre-hook on erp_mongo › erp_db**

```rust
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)
}
```

**Java**

**Database pre-hook on erp_mongo › erp_db**

```java
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;
    }
}
```

**Kotlin**

**Database pre-hook on erp_mongo › erp_db**

```kotlin
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](https://docs.apimaker.dev/v4/docs/apis-security/api-security-report.html) 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.

```bash
# 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](https://docs.apimaker.dev/v4/docs/apis-security/api-security-report.html).

---

## 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 |

---

## Related reading

- [APIs Security Report & Actions](https://docs.apimaker.dev/v4/docs/apis-security/api-security-report.html)
- [API group permission](https://docs.apimaker.dev/v4/docs/apis-security/api-group-permission.html)
- [API user permission](https://docs.apimaker.dev/v4/docs/apis-security/api-user-permission.html)
- [Authorization of AM database user](https://docs.apimaker.dev/v4/docs/authorization/AMDB.html)
- [Pre-hook](https://docs.apimaker.dev/v4/docs/apis-all/hooks/preHook-api.html) · [Post-hook](https://docs.apimaker.dev/v4/docs/apis-all/hooks/postHook-api.html)
- [Global object `g`](https://docs.apimaker.dev/v4/docs/pre-defined-terms/global-object-g.html)
- [Secrets](https://docs.apimaker.dev/v4/docs/secrets/secrets.html#the-keys-of-common)
- [Single sign on authentication](https://docs.apimaker.dev/v4/docs/authorization/sign-in-providers.html)
