Skip to main content

Access Control (ReBAC)

Every object on the platform is already scoped to its tenant. Access control narrows that further: it decides which objects within a tenant a particular user may see and change.

This page builds an access model up one step at a time. For a field-by-field listing, see the schema reference.

The problem​

Say you're building an order system. Three people log in:

  • Ada buys. She works with purchase orders and needs to read the product catalogue.
  • Ben sells. Same catalogue, but sales orders.
  • Cleo manages the Netherlands. She should see everything about Dutch companies — their orders, the documents on those orders — and nothing about German ones.

Ada and Ben are easy to describe as job titles. Cleo isn't. "The orders of companies in the countries I manage" is not a category of thing, it's a path through the data, and it's different for every country manager. A list of permissions can't say it; you'd have to keep re-listing which orders qualify every time a company moves country or an order is created.

That's what this feature is for. You describe the path once, in your data model, and the platform keeps track of who that path currently reaches.

How it works​

The mechanism is deliberately simple, because reading a list of a thousand objects has to stay fast.

Every object carries a set of labels. Every user carries a set of labels. A user may see an object when the two sets overlap.

Cleo carries:   [ countryDossiers:NL ]
│
│ overlap → visible
▼
Order #4711 carries: [ countryDossiers:NL, salesOrders ]
Order #4712 carries: [ countryDossiers:DE, salesOrders ] no overlap → invisible

The label is called an AC-id. It's an opaque identifier, not a word — countryDossiers:NL above is just a readable stand-in for a UUID.

Two things follow from this design, and they shape everything else on this page:

Labels are attached when an object is written, not when it's read. When Cleo asks for a list of orders, the platform doesn't walk any relations. It compares two sets of identifiers inside the query it was already running. All the path-walking happened earlier, when the order was saved.

Labels can only ever widen access. Access is an overlap, so adding a label to an object can only let more people in. There is no "except", no "but not", no "must also have". To restrict something, you withhold a broad label and hand out a narrower one instead — see Confidentiality tiers.

Step 1: grant access to a class​

The smallest useful rule: everyone with the purchasing role may work with purchase orders.

You declare it in a class file, under accessControl. Any class will do, but in practice you give the access model its own kind: CONFIG class: only the root of a definition is local to one type anyway — the objects it reaches span the model — and a security model is far easier to audit sitting together in one file.

{
"version": 1,
"class": "my-project.accessControl",
"kind": "CONFIG",

"accessControl": [
{ "name": "purchaseOrders",
"grantToObjects": [
{ "class": "my-project.purchaseOrder",
"operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] }
],

"roles": [
{ "name": "purchaseManager",
"description": "Works with purchase orders.",
"accessControl": [ { "definition": "purchaseOrders" } ] }
]
}

Two halves, and you need both:

  • The definition (purchaseOrders) says which objects get the label. grantToObjects lists one or more roots — each root names a class, and every object of that class gets labelled.
  • The role (purchaseManager) says which users get it. Assign the role to a user profile and they carry the label.

Give Ada the purchaseManager role and she can now do all four operations on every purchase order. She can't see anything else at all.

name is important: it's unique across your entire model, and it's what the label is derived from. Renaming it is a bigger deal than it looks — see Identifier derivation.

Several roots in one definition​

A root is a selector, not a path — so listing several is just a compact way to label several unrelated classes with the same identifier:

{ "name": "masterData",
"grantToObjects": [
{ "class": "commons.company", "operations": ["READ"] },
{ "class": "commons.person", "operations": ["READ"] },
{ "class": "commons.country", "operations": ["READ"] },
{ "class": "my-project.product", "operations": ["READ"] }
] }

One definition, one label, four classes. Both Ada and Ben's roles can now reference masterData and each carries one extra identifier instead of four.

Step 2: spread along relations​

Ada's rule stops at purchase orders. But an order has lines, and documents, and memos — and nobody wants to write a separate rule for each.

Add traverse to a root and the label spreads outwards, hop by hop:

{ "name": "purchaseOrders",
"grantToObjects": [
{ "class": "my-project.purchaseOrder",
"operations": ["READ", "WRITE"],
"traverse": [
{ "relatedClass": "my-project.purchaseOrderLine",
"operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] }
] }

Every purchase order gets the label because it matches the root. Then every line related to a labelled order gets the same label, because of the traversal. The spread is a copy — the label doesn't change as it travels, it just reaches further.

You can nest as deep as you like. Cleo's rule from the introduction is four hops:

{ "name": "countryDossiers",
"grantToObjects": [
{ "class": "commons.country",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.company",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "my-project.order",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.content", "operations": ["READ"] }
] } ] } ] } ] }

Country → the companies in it → their orders → the documents on those orders.

This keeps itself up to date. Move a company to another country and every order and document below it is relabelled — not at their own next save, but immediately, because the relation change is what triggers the recalculation. Add a document to an order tomorrow and it's labelled as it's created.

Step 3: choosing which relation to follow​

Here's the thing to understand about traverse, and it's the one place the model asks something of you.

A traversal node names a class, not a relation. { "relatedClass": "commons.content" } means "from here, go to the content" — and the platform works out which relation in your model that refers to. Usually there's exactly one and you never think about it. But your model may offer several, and then it matters which ones qualify.

The rule is one question, asked at both ends of every relation:

Is the class the relation declares the same as the class the node names, or does one inherit the other?

Three answers qualify, and it's worth being able to name two of them.

Exact — the ordinary case​

relation:  my-project.order  →  commons.content
node: "relatedClass": "commons.content"

Same class. Nothing to think about.

Asking broadly​

Your node names a class above what the relations declare. One node then covers several relations on purpose.

Suppose an order relates to a supplier and to a shipping agent, and both of those are kinds of commons.entity:

{ "relatedClass": "commons.entity", "operations": ["READ"] }

That one node reaches both. Everything it reaches is a commons.entity by inheritance, so there's nothing to check — the label just goes on.

Use this when the rule genuinely means "everyone involved in this order".

Narrowing​

Your node names a class below what the relation declares. This is the common case in commons, where a dossier's contents all hang off a single relation:

commons.dossier
└─ commons.content ← the relation is declared here
├─ commons.document
├─ commons.memo
├─ commons.task
├─ commons.reminder
├─ commons.invoice
└─ commons.emailMessage

"relatedClass": "commons.content" labels all six. "relatedClass": "commons.document" labels the documents only — the platform checks each object it reaches before labelling it.

Use this when the rule means "the documents of this dossier, not its memos".

What doesn't qualify: two roles over the same kind of object​

This one is worth a moment, because it looks like it should work and deliberately doesn't.

commons.invoice relates to a customer and to a supplier:

"relations": [
{ "class": "commons.customer" },
{ "class": "commons.supplier" }
]

Both of those are roles a company can take on — the same company can be your customer on one invoice and your supplier on another. Neither inherits the other; they're siblings.

{ "relatedClass": "commons.supplier", "operations": ["READ"] }

This node resolves to the supplier relation and only the supplier relation. It will not quietly pick up the customer as well.

That's deliberate, and the reason is worth knowing, because it explains why the platform decides this for you rather than letting you pin it down yourself. A class like commons.supplier is granted: put a company in a supplier slot anywhere and it carries commons.supplier from then on. So "check whether this object is a supplier" would pass for a company that is a supplier on some other invoice, even on the invoice where it's actually the customer. There's no check that could tell the two apart afterwards — the only reliable moment is now, when the node is resolved.

Contrast that with narrowing above, which also involves a check: a commons.memo is not a commons.document and nothing anywhere can make it one. That check means something.

Reaching a relation declared on a role: asClass​

The mirror image of the previous section, and here you do have to say what you mean.

A role class sits beside the class that takes it on, not above it. commons.emailMessage declares its relations to commons.emailSender, commons.emailReceiver and commons.emailCcReceiver, all of which live under commons.emailAccount:

commons.entity
├─ commons.person ← what the object is
└─ commons.emailAccount
├─ commons.emailSender ← what the relation is declared on
├─ commons.emailReceiver
└─ commons.emailCcReceiver

A node standing on a commons.person inherits none of those three, so "the messages this person sent" has to name the role:

{ "relatedClass": "commons.emailMessage",
"asClass": "commons.emailSender",
"operations": ["READ"] }

Leave asClass off and the node reaches nothing — a model error you'll see at load, not a rule that silently covers all three roles.

Being made to name it is the point. asClass is part of the node's identity, so sender and receiver are two nodes, and the stored label records which one reached each message:

"traverse": [
{ "relatedClass": "commons.emailMessage", "asClass": "commons.emailSender", "operations": ["READ"] },
{ "relatedClass": "commons.emailMessage", "asClass": "commons.emailReceiver", "operations": ["READ"] }
]

asClass is also how you disambiguate a hop that runs against the direction the relation was declared in, when several relations could satisfy it: traversing from a company up into the agreements that name it, specifically as the supplier rather than as the counterparty.

Checking what a node actually resolved to​

Because the platform derives this rather than reading it off your JSON, it also publishes the result. GET /audit/{tenant}/access-control lists, per node, the relations it resolved to.

If a node lists more relations than you expected, that's your signal — see Auditing what a definition resolves to.

Step 4: limit with a condition​

A condition decides whether a node labels an object at all:

{ "relatedClass": "commons.document",
"condition": "confidentiality < 4",
"operations": ["READ"] }

Documents with confidentiality of 4 or higher simply don't get the label from this node.

The condition is evaluated when the object is written and the outcome is baked into the stored label. That's what keeps reading fast — but it also sets a firm boundary on what a condition may say:

Only the object's own fields. confidentiality < 4 is fine. dossier.status = "open" (a related object's field) and count(documents) > 0 (an aggregate) are rejected at model load. If a condition could depend on a neighbour, every write would have to chase down everything that might now have changed.

No clock. now() < endDate would freeze at whatever was true the last time the object was saved. Put the time-dependence in an ordinary formula field instead — inFuture(endDate) — and have the condition read the field's result. A time-sensitive formula reschedules its own recalculation, so access lapses on its own.

Step 5: choose the operations​

operations lists what a node's objects are eligible for:

"operations": ["READ", "WRITE", "CREATE", "DELETE"]

READ comes with the label. An object is readable the moment a user holds a label the object carries — nothing consults operations on the read path. Writing READ in the list is documentation; omitting it changes nothing. Only WRITE, CREATE and DELETE are actually looked up per node.

That's why a node can sensibly list ["WRITE", "DELETE"] with no READ, and still leave everything it reaches perfectly readable.

On a root, operations is optional and defaults to read-only. On a traversal node it's required — there's no default to fall back on.

Step 6: give each user their own slice​

So far every holder of a role gets the same label. Cleo's rule needs more: one label per country, handed to that country's own manager.

Add grantToUsers — a single path from the root to whoever should carry the label:

{ "name": "countryDossiers",
"grantToUsers": [
{ "relatedClass": "commons.person" }
],
"grantToObjects": [
{ "class": "commons.country", "operations": ["READ"], "traverse": [ … ] }
] }

The root, commons.country, is now the anchor: it parameterizes the label. Instead of one countryDossiers label there's one per country — countryDossiers:NL, countryDossiers:DE — and the path walks from each country to the people related to it. Cleo is related to the Netherlands, so she carries countryDossiers:NL and nothing else.

The role still gates it. Being on the path is necessary but not sufficient: Cleo also has to hold a role that references countryDossiers. Otherwise every relation you ever use to reach a person would quietly hand out rights. The path decides which instances apply to someone; the role decides whether the definition applies at all.

Absent, empty, or a path​

grantToUsers has three meaningful states:

MeaningLabel
omittedno user path; anyone with the role carries itone, global
[]zero hops — the root class is the personone per person
[{…}]walk from the root to the peopleone per anchor instance

The [] form is worth knowing. It gives you personal data for free:

{ "name": "personalNote",
"grantToUsers": [],
"grantToObjects": [
{ "class": "commons.person",
"traverse": [
{ "relatedClass": "my-project.note",
"operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] } ] }

Every person automatically carries a label nothing but their own notes has. No role enumerates anyone, and a new colleague has it the moment their commons.person record exists.

One caveat: an anchored definition may declare only one root. Several roots would leave the platform with no single instance to parameterize the label with. Several roots are fine on a path-less definition.

What makes a right personal​

It's which node's identity goes into the label — not where the user path starts:

AnchorHow many labelsExample
the person themself (grantToUsers: [])one per user"my own notes"
a shared node a few hops awayfew labels, many users per label"HR access for business unit DE"
none (path-less)one, global"all purchase orders"

"All business units where I am manager" starts at the caller, but the label it produces (businessUnit:{id}) is shared with every other manager of that unit. The account is the entry point for working out which instances apply, not part of the label.

Step 7: bundle into roles​

A role is a named bundle of definitions, referenced by system.userProfile.accessRoles:

"roles": [
{ "name": "countryManager",
"description": "Reads everything about companies in the countries they manage.",
"accessControl": [
{ "definition": "masterData" },
{ "definition": "countryDossiers" }
] }
]

A user may hold any number of roles; what they can do is the union.

Leave operations off a role entry unless you're narrowing. Omitted means "whatever this definition's nodes already allow", which is the usual intent — the nodes did the narrowing already. Name it only for the mixed case, where one role should write in one scope and only read in another:

{ "name": "purchaseOrderApprover",
"accessControl": [
{ "definition": "purchaseOrders", "operations": ["READ"] }
] }

A user who should never write anything is better served by the coarse viewer role on their profile — see Enforcement — than by narrowing every role entry by hand.

So operations are specified in two places, deliberately: the node says which operations an object is eligible for (this is where the write scope lives), and the role entry optionally narrows what a user is granted. The effective capability is the intersection.

Patterns​

Pattern: confidentiality tiers​

Restricting access means withholding a broad label, never adding a restricting one. So a tier is two definitions, not two nodes on one:

{ "name": "hrDossier",
"grantToObjects": [
{ "class": "my-project.businessUnit", "operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.document", "condition": "confidentiality < 4", "operations": ["READ"] }
] } ] },

{ "name": "hrDossierConfidential",
"grantToObjects": [
{ "class": "my-project.businessUnit", "operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.document", "condition": "confidentiality >= 4", "operations": ["READ"] }
] } ] }

An ordinary role references hrDossier; a cleared role references both. Clearance is a property of which definitions a role pulls in, not a field on the user.

It has to be two definitions because two nodes of the same definition mint the same label — a confidential document would end up carrying exactly the identifier everyone already holds.

Pattern: a right the object gains and loses​

"Read every invoice, edit only the drafts" looks similar but isn't: it's one audience with two operation profiles, depending on the invoice's own state. Two definitions would work at the cost of a second label in every holder's set for what is really one right.

Two sibling nodes on one definition are the better fit, told apart by name:

{ "name": "invoices",
"grantToObjects": [
{ "class": "my-project.invoice", "operations": ["READ"] },
{ "class": "my-project.invoice", "name": "drafts",
"condition": "status = \"concept\"", "operations": ["READ", "WRITE"] }
] }

A draft invoice carries two rows under the same label — one per root — so both profiles apply. Mark it final and the drafts row disappears by itself: no longer writable, still readable, no extra rule.

The trade-off is legibility. "Who may edit drafts" now means reading a node's operations rather than checking which roles reference an editDrafts definition. Reach for two definitions when that auditability matters more than the smaller label set — it's a judgement call per case.

Sibling nodes and name​

Both patterns above lean on name, so it's worth explaining what it's for.

A node's identity is derived from the classes along its branch, never from its position in the JSON array — reordering traverse must never change a stored identifier. Which means two siblings that name the same class collide, and model load rejects them rather than letting the second silently overwrite the first's rows.

name breaks the tie by overriding that node's contribution to the path. It's optional, and only needed when two siblings would otherwise be identical — typically because they differ solely in condition.

Two tempting alternatives were both rejected, for the same reason: a node's identity must survive an edit that doesn't change what the node is.

  • Deriving it from the condition. Rewriting status = "concept" as "concept" = status would change the identifier and orphan every row it wrote, in a security model, with no error. Worse, paths accumulate, so re-wording a mid-level node would re-identify its whole subtree.
  • Using the array index. Inserting one sibling at the front would shift everything after it.

The price of a modeller-chosen name is real and stated rather than engineered away: renaming a node orphans its rows and needs a rebuild, exactly like renaming a definition. GET /audit/{tenant}/access-control reports an orphaned node explicitly, so you don't have to infer it from row counts.

name is not available on a grantToUsers step — that path is a single non-branching list, so there are no siblings to disambiguate.

Known limitations​

Properties of the design rather than gaps waiting to be filled.

The response envelope isn't filtered​

Access control decides which objects you may reach. It says nothing about what an object's response volunteers about its neighbours:

  • _links carries each related object's href and its denormalized name.
  • _classes lists the object's full class set.
  • Full-text search matches on those same denormalized names, so a query can find an object by a neighbour's name.
  • distinct over a relation returns { name, href } for related objects, which were never filtered themselves.

None of that is fetched through the ACL — a denormalized name is a plain field on the object you are allowed to read. So reading an order you may see tells you the display name of its customer, whether or not that customer is within your reach. And a company that is also a supplier says so in _classes, even to a caller whose access is entirely about customers: nothing is disclosed about the supplier relationship itself, only that one exists — which can be the sensitive part.

This is a deliberate boundary, not a gap. Filtering the envelope means an access check per related object per row: a page of 50 orders with four relations each costs 200 extra checks to serve one response, against a read path built to be a single set intersection. Paying that on every response, to cover a case most models don't have, is the wrong default — so the line sits between what may I open and what may I be told, and only the first is enforced.

What this means for your model. Treat it as a rule you design against, in this order:

  1. Assume a neighbour's name and classes are visible to anyone who can read an object that links to it. If that is acceptable — and it usually is — there is nothing to do.
  2. Where it isn't acceptable, don't put the relation there. If a neighbour's existence must stay hidden from someone who can read the other side, the two must not be directly related in the model. Route the link through an intermediate class that carries its own access, or hold the reference as data the response doesn't volunteer.
  3. Don't rely on a confidential display name. If the name itself is the secret, keep the secret out of the name field — it is copied onto every object that links to it, and copied into the search index.

There is no per-link filter to reach for instead, so a model that gets this wrong cannot be corrected with a permission afterwards. A marker that makes a response omit a particular link or class is a plausible future addition, but nothing today relies on it.

Not expressible at all​

  • An ad-hoc grant on one object ("let this one person see this one document"). Everything derives from the model, so it needs a relation the model already declares, with a definition anchored on it.
  • AND across two grants, or a deny rule ("in team A and team B", "everyone except contractors"). Access is an overlap; a grant can only widen. A tiered restriction is two definitions, not an AND.
  • User-to-user delegation ("my assistant sees what I see"). Would mean duplicating whichever relations reach the delegating user.

Worked example​

The reference application (tradeflow) keeps its whole access model in one kind: CONFIG class. Four definitions, roots only:

{
"version": 1,
"class": "tradeflow.accessControl",
"kind": "CONFIG",

"accessControl": [
{ "name": "allData",
"grantToObjects": [
{ "class": "commons.item", "operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] },

{ "name": "masterData",
"grantToObjects": [
{ "class": "commons.company", "operations": ["READ"] },
{ "class": "commons.person", "operations": ["READ"] },
{ "class": "commons.country", "operations": ["READ"] },
{ "class": "tradeflow.product", "operations": ["READ"] },
{ "class": "tradeflow.productCategory", "operations": ["READ"] }
] },

{ "name": "purchaseOrders",
"grantToObjects": [
{ "class": "tradeflow.purchaseOrder", "operations": ["READ", "WRITE", "CREATE", "DELETE"] },
{ "class": "tradeflow.purchaseOrderLine", "operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] },

{ "name": "salesOrders",
"grantToObjects": [
{ "class": "tradeflow.salesOrder", "operations": ["READ", "WRITE", "CREATE", "DELETE"] },
{ "class": "tradeflow.salesOrderLine", "operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] }
],

"roles": [
{ "name": "fullAccess", "description": "Full CRUD on everything in the model.",
"accessControl": [ { "definition": "allData" } ] },

{ "name": "purchaseManager", "description": "Works with purchase orders; reads the shared master data.",
"accessControl": [ { "definition": "masterData" }, { "definition": "purchaseOrders" } ] },

{ "name": "salesManager", "description": "Works with sales orders; reads the shared master data.",
"accessControl": [ { "definition": "masterData" }, { "definition": "salesOrders" } ] }
]
}

allData roots on commons.item, the universal base every class inherits, so every object in the model carries its label.

Neither purchaseManager nor salesManager names operations on its role entries, and both are correct: masterData's roots only ever grant READ, and the order definitions grant full CRUD. The role entry doesn't need to know which case it's in, because the node always does the narrowing.

See Enforcement for what a purchaseManager can and can't actually do against this model.

Adding anchored definitions​

Two more definitions reach a countryManager role:

    { "name": "ownMemos",
"grantToUsers": [],
"grantToObjects": [
{ "class": "commons.person",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.memo", "operations": ["WRITE", "DELETE"] }
] } ] },

{ "name": "countryDossiers",
"grantToUsers": [
{ "relatedClass": "commons.person" }
],
"grantToObjects": [
{ "class": "commons.country",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.company",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "tradeflow.order",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.content", "operations": ["READ"] },
{ "relatedClass": "commons.memo", "operations": ["READ", "CREATE"] }
] } ] } ] } ] }
    { "name": "countryManager",
"description": "Reads every order, document and memo of companies in the countries they manage; may also edit and delete memos they personally authored, anywhere.",
"accessControl": [ { "definition": "countryDossiers" }, { "definition": "ownMemos" } ] }

ownMemos is the zero-hop anchor applied to editing rather than visibility. Its one hop reaches commons.memo with ["WRITE", "DELETE"] and no READ — which is not an oversight: holding the label already makes those memos readable, and the node's operations adds editing on top, scoped to the memos this traversal reaches. Since commons.memo's author relation defaults to me(), "authored by me" and "reached from my own person record" are the same set.

countryDossiers is the shared anchor, one label per country. From the country it reaches the companies there, from each company its orders, and from each order the attached documents and memos — READ throughout, plus CREATE on memos, so a country manager can add a memo without being able to remove someone else's.

The two combine additively. A country manager's own memo, on an order in a country they manage, carries two labels: countryDossiers:{country} (READ, CREATE) and ownMemos:{their person} (WRITE, DELETE). Both are theirs, so the union is full CRUD on that one memo — built from two definitions that individually grant neither WRITE nor DELETE. Neither had to be widened to cover the other's case.