Enforcement
Access Control is about describing rules. This one is about what the platform does with them: how a request is decided, and where the stored labels that decide it come from.
It follows one request through, from the caller to the answer, and then turns around and shows how the data it read got there.
First: enforcement is all or nothing
| Your model | What happens |
|---|---|
declares no accessControl | Nothing changes. Tenant scoping stays the only filter. |
| declares anything at all | Enforcement applies to everything. An object no definition reaches is invisible to everyone. |
There is no per-class opt-out. The moment you write your first definition you have to declare enough to cover everything that should stay visible.
That is a real burden and it is the intended one. The alternative — "no rows means unrestricted" — would let a gap in your model silently widen access, and that is the one direction a security feature must never fail in.
It is also why the reference model's broadest definition roots on commons.item, the universal base every class inherits. A model that wants "anyone with a role can at least read everything" says so in one line rather than leaving gaps by omission.
Step 1: what the caller carries
Every request arrives with a set of AC-ids. It comes from two places:
- Role-derived — every path-less definition referenced by a role the user holds, contributing that definition's one global id.
- Path-derived — every anchored definition whose
grantToUserspath reaches this user, and which a role they hold also references. Being on the path is not enough on its own.
The user is found through their linked commons.person; the platform account correlates to it by personRef/email.
Both are resolved fresh on every request, never cached on the account. Reassign someone as a business unit's manager and their access changes on the very next call, instead of surviving for however long the account happened to stay cached.
An empty set means the user sees nothing
Access is an overlap, so a user with no access roles signs in successfully, passes every route their coarse role allows, and finds every list empty. That is a legitimate state.
It is a completely different thing from a machine context — the system account, or an API key with no user profile — which has no roles and is exempt from the check altogether, like background tasks and rebuilds. Every unassigned human becomes an empty-result user; every machine context keeps working. Confusing the two in either direction is what this distinction exists to prevent.
Assigning roles
A profile carries any number of roles in system.userProfile.accessRoles:
{ "accessRoles": ["purchaseManager", "salesManager"] }
Effective rights are the union — adding a role can only add identifiers, never remove one.
It is a multi-valued text field rather than a LIST, because the valid names are whatever the tenant's own model currently declares, which differs per tenant and changes as the model evolves. The form still offers a dropdown: accessRoles is a reserved field name, so its options are resolved per tenant when the client metadata is built. The option label is the role's name — a description reads as documentation, not as something to pick from a list. A tenant with no roles gets the field dropped from the form rather than shown empty.
A role name the current model doesn't declare is silently skipped, not rejected. A profile can be filled in against a model that changes afterwards, and a stale name is an ordinary state rather than a fault — failing the request over somebody else's typo would lock the user out entirely. The stale name still displays on the profile, which is the visible symptom.
The coarse role is a separate layer
system.userProfile.role (admin/member/viewer) is unrelated to access-model roles. Both layers apply to every request, independently.
adminnever bypasses the ACL. It unlocks maintenance and audit endpoints — the Admin API, the Audit API, thesystemarea — and grants no data access at all. An admin sees exactly what their access-model roles grant, like anyone else. So the per-record audit endpoint can only report on records its caller can already read; an admin without a granting definition gets the ordinary 404.viewerclamps. It refuses every mutating request at the route, regardless of what the ACL would grant. That is the cheap way to get a read-only flavour of an existing scope: give someone an editor's access-model role plusviewer, instead of writing and maintaining a second, read-only definition.
A consequence worth knowing: an access-model role's operations never resolve to a fixed capability per person. Two holders of purchaseManager end up with different rights if one is also a viewer.
Step 2: reading
The read filter is a set intersection inside the query that was already running. No relation is walked, no condition is evaluated, nothing is fetched to be thrown away afterwards. Lists, aggregations, distinct and search results are all filtered the same way.
Fetching one object by id is the same test, with one deliberate difference in how it answers: a caller without a shared identifier gets the ordinary 404, not a 403. A 403 would confirm that the record exists, which is itself a disclosure.
Step 3: writing
A write asks two questions, in this order:
- Can you see it? The object is loaded through the ordinary read filter. No → 404.
- May you do this to it? No → an honest 403, which discloses nothing new since you could already see the record.
That order is why you never get a 403 for something you weren't allowed to know about.
create, patch, delete, merge, batch upserts and the Excel import all go through it, whether the caller used /data/** or a script.
Creating is the case with no stored rows to intersect, so the question is asked of the rows the object would carry. For a parentless object that follows from its own classes: does any definition rooted on one of them grant CREATE to a role you hold. Once traverse is involved it also depends on where you are filing it — the same definition can grant CREATE under one parent and only READ under another — so the prospective rows that parent would produce are what gets checked.
Moving an object between parents is checked twice: WRITE on the object's current rows, so you can't push something out of your own reach, and CREATE on the prospective rows, so you can't pull something into reach you weren't allowed to place it in. An ordinary patch that touches no ACL-relevant relation only needs the first, at no extra cost.
Merging asks WRITE on both sides and DELETE on neither. A merge doesn't remove information: relations repoint, the history is kept, and the loser's id still resolves to the winner. It corrects an identity mistake, so what needs authorising is the move — the two records may be covered by different definitions, and afterwards the loser's values are readable by anyone who can read the winner.
A client can ask any of these questions in advance, before rendering an edit button or a New button — see Capabilities.
Retaining read, not retaining write
A PATCH is checked a second time, after the merge of incoming data and before the save: can you still read the result? It is evaluated live, against the object's fields as they will be, using the same condition logic that materialises rows in the first place.
If no identifier you hold survives the patch, it is refused. The model never lets you write an object out of your own sight. Someone who may only see documents with confidentiality < 4 cannot set it to 4, even on their own document.
This checks read, not write-again. Ending your own ability to write something a second time is allowed, and is exactly what the drafts pattern is for: marking an invoice final drops the drafts node's row, but the plain root's row — carrying the same AC-id — stays, so what the object carries is untouched and you can still see what you just wrote.
Contrast the confidentiality case, where a different definition reaches the object on each side of the threshold. Crossing it drops the object's only qualifying identifier outright, with nothing to fall back on, so the write is refused — otherwise the document's own author could no longer open it.
Step 4: where the rows come from
Everything above reads one small table. This is how it gets filled.
One row per node that reaches the object
(object_id, ac_id, node_id)
ac_id is what the read filter intersects. node_id says which node put the row there, and it does two jobs: it is looked up in the model to find which operations apply, and it is the provenance the recalculation follows when something changes.
One object can carry several rows for the same ac_id — one per node that reaches it. That is the drafts pattern working as intended.
An unknown node_id fails closed. A node removed from the model leaves rows behind that resolve to nothing. Such a row still grants READ, because the read path only ever looks at ac_id, but never WRITE, CREATE or DELETE.
The object's own rows are written during its save
Not queued. A client normally issues a GET straight after its POST, and with enforcement always on, an object whose rows haven't landed yet is an object its own creator can't read back. Letting the read path make an exception for creators would turn "create" into a way to see things, so the work happens inline instead.
It stays cheap. At root level the answer follows from the object's own classes with no database reads at all. Each traversal node that could apply adds one query — does a neighbour carry this node's parent row — and nodes that can't apply are never consulted. Most objects have none.
Changing a relation reviews the other end too
This is the trigger a row-level signal cannot produce. Hanging a memo under a dossier changes no row on the dossier, and unhanging it changes none either — yet the memo's rows depend on both.
So a write that adds or removes a relation some node traverses queues a review of the far end. That review re-derives that object's rows, and if they changed, its own neighbours are reviewed in turn. The cascade stops when a level changes nothing, which is also why a cycle in your data converges by itself.
The label is copied, never recomputed
A traversal hop takes the parent's ac_id and writes it on the child unchanged. Nothing along the way re-derives it.
That is what makes the spread indifferent to whether the identifier is global or anchored: countryDossiers:NL travels from the country to a company to an order to a document without any hop needing to know what NL was.
Conditions are evaluated here, once
A node's condition decides whether that node's row is written at all. It is evaluated at this moment, against the object being saved, and the outcome is what gets stored.
This is the whole reason a condition may only read the object's own fields. If it could read a neighbour's, every write would have to chase down every object whose condition might now have changed — and the read path could no longer be a pure intersection.
Every review is a full re-derivation, not a delta
A review works out the complete set of rows the object should have and diffs it against what is stored, then writes the difference. It never applies an increment.
Three things follow, and they are why it is built this way:
- Two concurrent writes converge, because each reads current state instead of adjusting a state it assumed.
- "A class was added to this object" needs no detection at all — the review always uses the object's current classes.
- The tenant-wide rebuild is the same derivation with the starting set removed, rather than a second implementation that could drift from the first.
Step 5: keeping the rows in step
Rows are derived from your definitions, and both identifiers come from a definition's name (see Identifier derivation). So renaming or removing a definition — or a named node — orphans every row it produced. Those objects keep their old, now-meaningless rows.
A rebuild is therefore required, not optional, after any change to accessControl or roles. It is not triggered automatically on model reload. And because enforcement is on from the moment a model declares its first definition, a model that has declared access control shows an empty database until the first rebuild has run.
The endpoint is POST /admin/{tenant}/rebuildAccessControl — see Admin API. It is deliberately separate from the tenant-wide rebuild, which discards and reconstructs every SQL and Elasticsearch record: a mistyped parameter must not be able to escalate a routine ACL repair into rebuilding everything.
It reads no data objects through the access filter, so recovery never depends on being able to see data. An admin staring at an empty UI after a model change can still fix the cause.
Checking your work
Three places tell you what is actually going on, rather than what you meant:
- The app shows, per record, which roles it is visible to. The quickest answer to "why can this person see this?"
GET /audit/{tenant}/{type}/{id}/accessreports every row one object carries, which roles hold each, and for an anchored definition which people the path reaches — including those it reaches who lack the role. See the Audit API.GET /audit/{tenant}/access-controlreports what each node actually resolved to, and which stored node-ids no longer match the model. See the Audit API.
Worked example: checking a request
Against the reference model — definitions allData, masterData, purchaseOrders, salesOrders; roles fullAccess, purchaseManager, salesManager.
A user holding only purchaseManager:
| Request | Outcome |
|---|---|
| List purchase orders | Full list, full CRUD. The objects carry purchaseOrders, the role grants it without narrowing, and the node allows all four operations. |
| Read the product catalogue | Read-only. The role's masterData entry doesn't narrow anything, but masterData's roots only ever grant READ. |
PATCH a sales order by id | 404. tradeflow.salesOrder carries only salesOrders, which this user never holds — so the write never gets as far as asking whether they may change it. |
The same user is also a coarse viewer | Every mutation refused at the route, purchase orders included. Reads unaffected. |
A user holding fullAccess can read, write, create and delete everything: allData roots on commons.item, and the role asks for everything that root allows.
A user holding countryManager (countryDossiers + ownMemos), reached by countryDossiers for NL and DE:
| Request | Outcome |
|---|---|
| Read an order and its documents for a Dutch company | Allowed. The order carries countryDossiers:NL, which they hold because they're on that country's manager relation and their role references the definition. |
| Add a memo to that order | Allowed — the commons.memo node grants CREATE. |
| Edit a colleague's memo on that order | Refused. That node never lists WRITE or DELETE. |
| Edit their own memo, on an order in a country they don't manage | Allowed. It carries ownMemos:{their person} wherever it hangs, and that node grants WRITE and DELETE. countryDossiers doesn't come into it. |
| Read a colleague's memo in a country they don't manage | 404. It carries neither identifier they hold. |
That last pair is the point of the whole design: two definitions, neither of which grants what the other does, combining into exactly the right answer per object without either being widened.