Access Control Lists
Table of Contents
BAASIX permissions can restrict rows (conditions), related-table rows (relConditions), columns (fields), and auto-injected values (defaultValues) directly on a baasix_Permission row. Named ACL entries let you define these rule sets once, give them a name, and assign them to any number of permissions instead of retyping the same JSON everywhere.
This guide covers the concept, a working walkthrough, the exact merge semantics, the five built-in entries, the admin UI, and the full API/MCP reference.
Concept
An ACL entry (baasix_ACL collection) is a reusable permission template. It carries the same four fields a permission can define inline:
conditions— row-level security filter (same filter DSL as query filters)relConditions— row-level security on related tables, keyed by relation namefields— allowed columns (["*"]for all, or an explicit list)defaultValues— values auto-injected on create/update
A permission (baasix_Permission) assigns zero or more ACL entries via an ordered array of UUIDs, acl_Ids. When acl_Ids is non-empty, the entries it references are merged into the permission's effective conditions/fields/relConditions/defaultValues at evaluation time (see the merge rules in the Semantics Reference). The permission's own inline conditions/relConditions/fields/defaultValues columns are ignored in that case.
Why ACL-first Beats Inline Conditions
- Reuse across roles and collections. The same rule — "only rows the current user created" — typically applies to many collections and many roles. Written inline, that's the same JSON copy-pasted into every permission row. Written as a named entry (
Update_Own), it's created once and assigned everywhere it applies. - Central edits propagate everywhere. If the rule needs to change (e.g. broaden
Read_Ownto include a manager override), editing the one ACL entry updates every permission that references it on the next cache reload — no need to hunt down and edit N permission rows. - Self-documenting.
acl_Ids: ["<Update_Own id>"]tells a reviewer what a permission does.conditions: {"userCreated_Id": {"eq": "$CURRENT_USER"}}requires reading and understanding raw filter JSON to reach the same conclusion.
When Inline Conditions Are Still the Right Choice
Named ACL entries add a layer of indirection that only pays for itself when a rule is reused or benefits from a name. For a condition that is genuinely specific to one permission row and used exactly once — e.g. a one-off filter like {"status": {"eq": "archived"}} scoped to a single role/collection/action combination that won't be reused elsewhere — inline conditions on the permission remain simpler. Don't create a named entry you'll only ever assign to one permission.
Quick Start
This walkthrough assigns the built-in Update_Own entry to a role's update permission on a tasks collection, then verifies the effect. It assumes tasks was created with usertrack: true (required for Update_Own — see Built-in Entries) and that you're authenticated as an administrator for the write calls.
Step 1 — list the available ACL entries:
curl -X GET "http://localhost:8056/acls" \
-H "Authorization: Bearer <admin-access-token>"Response (abbreviated — five system entries are seeded on startup):
{
"data": [
{
"id": "b1e7...",
"name": "Read_All",
"description": "Read all rows, all fields",
"conditions": {},
"fields": ["*"],
"system": true
},
{
"id": "c2f8...",
"name": "Update_Own",
"description": "Update rows created by the current user (requires usertrack)",
"conditions": { "userCreated_Id": { "eq": "$CURRENT_USER" } },
"fields": ["*"],
"system": true
}
],
"total": 5
}Note the id of Update_Own — you'll need it for the next step.
Step 2 — assign it to a permission:
curl -X POST "http://localhost:8056/permissions" \
-H "Authorization: Bearer <admin-access-token>" \
-H "Content-Type: application/json" \
-d '{
"role_Id": "<role-uuid>",
"collection": "tasks",
"action": "update",
"acl_Ids": ["c2f8..."]
}'Do not also send conditions, relConditions, fields, or defaultValues in this request — the server returns 400 if any of those are set alongside a non-empty acl_Ids (see the Semantics Reference). The route reloads the in-memory permission cache and invalidates the auth cache before responding, so the change is effective immediately for new requests.
Step 3 — verify as the target role:
Sign in (or reuse a session) as a user who holds the role, then attempt to update a tasks row that user did not create, and one that they did:
# Row created by someone else — expect a 403/empty result, the row is outside the ACL's conditions
curl -X PATCH "http://localhost:8056/items/tasks/<other-users-row-id>" \
-H "Authorization: Bearer <member-access-token>" \
-H "Content-Type: application/json" \
-d '{"title": "Attempted edit"}'
# Row created by this user — expect success
curl -X PATCH "http://localhost:8056/items/tasks/<own-row-id>" \
-H "Authorization: Bearer <member-access-token>" \
-H "Content-Type: application/json" \
-d '{"title": "Updated title"}'The second call succeeds because Update_Own's condition {"userCreated_Id": {"eq": "$CURRENT_USER"}} resolves $CURRENT_USER to the requesting user's id and matches only rows that user created.
Semantics Reference
Multiple ACLs on One Permission: Additive (OR)
acl_Ids is evaluated as an ordered list. Assigning more than one entry to a permission can only grant more access, never narrow it — this mirrors policy-style (Directus-style) additive merging. There is no way to express "AND" between two ACL entries assigned to the same permission; if you need a rule that's a strict intersection of two conditions, express that as a single ACL entry (or inline condition) rather than two assigned entries.
Replace-inline Rule
A permission's acl_Ids and its inline conditions/relConditions/fields/defaultValues are mutually exclusive:
- When
acl_Idsis a non-empty array, the assigned entries' conditions/relConditions/fields/defaultValues are used, and the permission's own inline columns are not consulted. - Sending a non-empty
acl_Idsand any ofconditions,relConditions,fields, ordefaultValuesin the same create/update payload is rejected with 400:A permission cannot set both acl_Ids and inline conditions/relConditions/fields/defaultValues. Assigned ACL entries replace inline values.This is a payload-level guard — it does not stop you from having stale inline values sitting in the database column from before ACLs were assigned; it only stops you from setting both in one request. - Sending
acl_Ids: []explicitly detaches all ACL entries from the permission, reverting it to using its inline conditions.
The Four Merge Rules
When a permission resolves two or more ACL entries, they are combined as follows:
- conditions — OR, with unrestricted short-circuit. If any assigned entry has empty/null conditions, the merged result is
{}(unrestricted) regardless of the other entries — an unrestricted entry always wins because the merge is additive. If there's exactly one entry, itsconditionsis used as-is (no wrapper). With two or more entries and none unrestricted, the result is{"OR": [entryA.conditions, entryB.conditions, ...]}. - fields — union; all-null means unrestricted. If no assigned entry defines a non-empty
fieldsarray, the mergedfieldsisnull(all columns). If at least one entry defines fields, the merged result is the union of every entry's field list; an entry that has nofields(null/empty) contributes"*"into that union so that explicit field names granted by other entries still survive (this matters for privilege fields that are only writable when named explicitly). - defaultValues — shallow merge in assignment order, later wins. Entries are merged in the order they appear in
acl_Ids; if two entries set the same key, the later entry (further along inacl_Ids) overwrites the earlier one. - relConditions — key intersection, OR'd values. If any assigned entry has empty/null
relConditions, the merged result is{}(unrestricted). Otherwise a relation key is present in the merged result only if every assigned entry restricts that same relation (set intersection of keys across all entries); for each shared key, the values from all entries are OR'd together ({"OR": [...]}, or the single value as-is if only one entry defines that key after intersection).
Dynamic Variables
Both inline conditions/defaultValues and ACL-entry conditions/defaultValues support the same dynamic variable syntax, resolved per-request against the caller's identity:
| Variable | Resolves to |
|---|---|
$CURRENT_USER | The authenticated user's id |
$CURRENT_USER.field | Any field on the authenticated user's row, e.g. $CURRENT_USER.tenant_Id, $CURRENT_USER.department_Id |
$CURRENT_ROLE | The id of the role the request is evaluated under |
$CURRENT_ROLE.field | Any field on that role's row |
$CURRENT_TENANT | The current tenant id |
$CURRENT_TENANT.field | Any field on the current tenant's row |
$CURRENT_SETTINGS.field | A field from tenant-aware settings (merged with global settings) |
$NOW | The current timestamp |
$NOW+DAYS_7, $NOW-MONTHS_1, etc. | Timestamp math — $NOW offset by a signed amount of DAYS_N, MONTHS_N, and similar unit suffixes |
Variables can appear as filter values (e.g. {"userCreated_Id": {"eq": "$CURRENT_USER"}}) or as defaultValues (e.g. {"tenant_Id": "$CURRENT_TENANT"}). See the Complete Filter Reference for the full filter DSL.
Built-in Entries
Five system ACL entries are seeded automatically on server startup (idempotent — re-running startup does not duplicate or reset them). They have system: true and cannot be modified or deleted through the API (see the API Reference).
| Name | Conditions | Fields | Notes |
|---|---|---|---|
Read_All | {} (unrestricted) | ["*"] | Read every row, every column. |
Read_Own | {"userCreated_Id": {"eq": "$CURRENT_USER"}} | ["*"] | Read only rows the current user created. |
Update_Own | {"userCreated_Id": {"eq": "$CURRENT_USER"}} | ["*"] | Update only rows the current user created. |
Delete_Own | {"userCreated_Id": {"eq": "$CURRENT_USER"}} | null (no field restriction; delete doesn't select columns) | Delete only rows the current user created. |
Own_Tenant | {"tenant_Id": {"eq": "$CURRENT_TENANT"}} | ["*"] | Rows belonging to the caller's current tenant. |
The userCreated_Id / usertrack Assumption
Read_Own, Update_Own, and Delete_Own all filter on userCreated_Id. That column only exists — and is only populated — on collections whose schema was created with usertrack: true. Enabling usertrack adds userCreated_Id/userUpdated_Id tracking columns, and BAASIX populates userCreated_Id with the creating user's id on every create.
If you assign Update_Own to a permission on a collection that does not have usertrack: true, the condition will filter on a column that is always null, and the permission will effectively grant access to nothing (fail closed) rather than erroring.
Collections With a Different Owner Field
If your collection tracks ownership under a different column name — for example an author_Id foreign key instead of the generic userCreated_Id — the *_Own system entries do not apply directly, because they're hard-coded to userCreated_Id. Create your own named entry pointing at the right column, e.g.:
curl -X POST "http://localhost:8056/acls" \
-H "Authorization: Bearer <admin-access-token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Update_Own_Author",
"description": "Update rows authored by the current user (author_Id column)",
"conditions": { "author_Id": { "eq": "$CURRENT_USER" } },
"fields": ["*"]
}'Then assign Update_Own_Author's id via acl_Ids on the relevant permission(s), the same way you would assign the built-in Update_Own.
Admin UI Walkthrough
ACL entries and the permission drawer's ACL picker are managed from the admin app (Settings).
Settings → Access Control Lists. A dedicated management screen lists every ACL entry in a table with columns for name, description, a "System" badge on the five built-in entries, and a usage count showing how many permissions currently reference each entry. Clicking "Create ACL" opens a drawer to define a new entry's name, description, conditions, relConditions, fields, and defaultValues; clicking a row opens the same drawer in edit mode. System entries open read-only — their inputs are disabled and there is no save or delete action, matching the API's 403 on write/delete for system: true entries. Deleting a custom entry that is still referenced by one or more permissions is blocked with the server's usage-count error surfaced in the UI, the same 409 the API returns.
Permission drawer — ACL picker. The permission create/edit drawer (Settings → Permissions) includes an "Access Control Lists" section above the inline Fields/Conditions/Default Values editors. It renders the assigned entries as an ordered, reorderable multi-select: assigned entries appear as a list (with a system badge where applicable, and controls to reorder or remove), plus a dropdown to add any not-yet-assigned entry. Order matters because defaultValues merge with "later wins" semantics (see the Semantics Reference). As soon as one or more ACL entries are assigned, the inline Fields/Conditions/Relational Conditions/Default Values editors are disabled (replaced with a note explaining that those are now controlled by the assigned entries) — this mirrors the API's replace-inline rule so the UI can never produce a payload that mixes both. A read-only preview shows the merged/OR'd effective conditions for the currently assigned entries. Removing all assigned entries re-enables the inline editors.
API Reference
All ACL routes operate on the baasix_ACL collection. Reads are subject to normal collection permissions; writes are restricted to administrators. Every successful write reloads the permission cache and invalidates the auth cache so the effect is immediate; updates additionally invalidate the item-query cache for every collection that has a permission referencing the changed entry.
Errors follow the standard BAASIX error envelope: { "error": { "message": "...", "details": ..., "statusCode": ... } }.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /acls | Standard collection read permissions | List ACL entries. Supports the usual query params (filter, sort, page, limit). |
GET | /acls/:id | Standard collection read permissions | Get a single ACL entry by id, including conditions, relConditions, fields, defaultValues, system. |
POST | /acls | Admin only | Create a new ACL entry. name is required and must be unique. system in the request body is ignored/stripped — entries created through the API are never system entries. |
PATCH | /acls/:id | Admin only | Update an existing ACL entry. Rejected for entries where system: true. |
DELETE | /acls/:id | Admin only | Delete an ACL entry. Rejected for system: true entries, and rejected if any permission still references the entry. |
Error Cases
-
400 — bad payload.
POST/PATCH /acls: creating without a non-empty stringname(create only), or sendingconditions/relConditions/defaultValuesas something other than a JSON object, orfieldsas something other than an array of strings. -
400 — unknown ACL id(s).
POST/PATCH /permissionswith anacl_Idsentry that doesn't exist:Unknown ACL entry id(s): <id, id, ...>(all unresolved ids are listed in the message). -
400 — mixing inline and acl_Ids.
POST/PATCH /permissionswith a non-emptyacl_Idsplus any ofconditions/relConditions/fields/defaultValuesin the same payload:A permission cannot set both acl_Ids and inline <keys>. Assigned ACL entries replace inline values. -
403 — system entry write.
PATCH/DELETE /acls/:idon an entry withsystem: true:System ACL entries cannot be modified/System ACL entries cannot be deleted. -
409 — delete in use.
DELETE /acls/:idwhen one or more permissions still reference the entry viaacl_Ids. The response'serror.details.usedByis an array describing every referencing permission:{ "error": { "message": "ACL entry 'Update_Own_Author' is assigned to 2 permission(s). Detach it first.", "details": { "usedBy": [ { "permission_Id": "...", "collection": "articles", "action": "update", "role_Id": "..." }, { "permission_Id": "...", "collection": "articles", "action": "delete", "role_Id": "..." } ] }, "statusCode": 409 } }Detach the entry from each listed permission (e.g.
acl_Ids: [], or remove just that id from the array) before retrying the delete.
MCP Tools
Five MCP tools mirror the REST routes for AI-driven administration, plus acl_Ids support on the existing permission tools:
baasix_list_acls— list ACL entries (filter,sort,page,limit); use this before writing inline conditions to check for a reusable entry.baasix_get_acl— get one ACL entry byid.baasix_create_acl— create a new ACL entry (name,description,conditions,relConditions,fields,defaultValues).baasix_update_acl— update an existing entry byid; fails for system entries.baasix_delete_acl— delete an entry byid; fails 403 for system entries, 409 if any permission still references it.baasix_create_permission/baasix_update_permission— both accept an optionalacl_Idsarray of ordered ACL entry UUIDs. Onbaasix_update_permission, passingacl_Ids: []detaches all assigned entries and reverts the permission to its inline values.
Related Documentation
Permissions
- Permission Routes - Role-based permissions, fields, conditions, and relConditions
Query System
- Complete Filter Reference - Filter DSL and dynamic variables used in ACL conditions
Multi-tenant
- Multi-tenant Guide - Tenant isolation and the
Own_Tenantentry