Access Control (ReBAC)
Relation-Based Access Control (ReBAC) lets a data model declare object-level access rules driven by the relationships between objects — for example, "a regional HR manager may read and edit employment documents for their own business unit." It sits on top of tenant-level isolation: every object is always scoped to its tenant, and ReBAC narrows that further to specific objects within the tenant, based on how those objects relate to the user.
This page documents the accessControl/roles schema — the part of ReBAC that is implemented today: the JSON grammar, how it's validated at model load, and how the identifiers it produces are derived. Read/write enforcement and the access-visibility endpoint are not yet implemented. Declaring accessControl/roles on a tenant model today has no runtime effect on what users can read or write — this page will be extended once enforcement lands.
Why relationships, not roles alone
A plain role (admin / member / viewer) can express what kind of thing a user may generally do, but not which specific objects — "the documents of the business units this person manages" isn't expressible as a static role. ReBAC closes that gap: a rule is declared once, on the type that anchors it, and it's evaluated against the actual graph of relations between objects and the user, rather than against a fixed list.
Two constraints shaped the design:
- Reading large lists must stay fast. The access check has to be a cheap set intersection, evaluated inside the same query that already fetches a list of objects — not a graph walk done at read time.
- Writing may be more expensive. A write touches one object at a time, so it can afford more work than a list read.
The resolution is to materialize everything at write time: every condition a rule expresses (a confidentiality threshold, a path a document sits on, …) is evaluated once, when an object is written, and baked into an opaque identifier stored on the object. Reading an object list never evaluates a condition again — it only checks whether the object's stored identifiers overlap with the identifiers the requesting user carries.
Core concepts
| Concept | What it is |
|---|---|
| Access-control definition | A declarative rule, declared under accessControl on an anchor type, that describes which objects carry a given identifier (grantToObjects) and which users carry it (grantToUsers). |
| Anchor | The type the definition is declared on. Its identity (or absence of one) determines how "individual" the resulting access right is — see Anchors. |
| AC-id | The identifier that is compared between a user and an object to decide access. One per definition (per anchor instance). Stamped on eligible objects and granted to users who qualify. |
| Node-id | A more specific identifier for one node of a grantToObjects tree. Carries the operation (READ/WRITE/…) the node grants, and is used to work out which operations an object is eligible for. |
| Role | A named bundle of access-control definitions (and which operations each grants) assigned to a user. |
An access-control definition works from two sides at once:
- Object side (
grantToObjects) — which objects should carry this definition's AC-id, and for which operations. - User side (
grantToUsers) — which users should carry this definition's AC-id.
Both sides derive the same AC-id from the same definition, so access reduces to: does the AC-id set stamped on the object overlap with the AC-id set the user carries?
OR-only semantics
Access is a non-empty intersection between the identifiers a user carries and the identifiers an object carries. There is no AND — adding an identifier to an object can only ever widen who can access it, never narrow it. This has a direct modeling consequence: you cannot express "confidential AND HR" by adding a restricting identifier on top of a broad one. Restrictions are instead modeled by withholding a broad identifier and using a separate, narrower definition — see Confidentiality and thresholds.
Definition JSON schema
An accessControl definition lives in an array on its anchor type — the class declaring it — co-located with views, formulas, and layouts. The anchor is implicit: whichever class the accessControl array is declared on.
// on my-project.businessUnit (the anchor type)
"accessControl": [
{
"name": "hrDossier",
"grantToUsers": [
{ "relatedClass": "commons.person" }
],
"grantToObjects": [
{
"relatedClass": "my-project.employmentAgreement",
"operations": ["READ"],
"traverse": [
{
"relatedClass": "commons.document",
"condition": "confidentiality < 4",
"operations": ["READ", "WRITE", "CREATE", "DELETE"]
}
]
}
]
}
]
| Property | Description |
|---|---|
name | Unique across the entire tenant model, not just within one class. It is the basis of every identifier the definition produces — see Identifier derivation. Validated at model load. |
grantToUsers | WHO carries the AC-id: a single ordered path (no branching) from the anchor to the user accounts that qualify. Omit for a global definition not tied to any particular user path. |
grantToObjects | WHICH objects carry the AC-id: a tree of branches from the anchor, each node with its own condition and granted operations. |
Path grammar
grantToUsers and grantToObjects share the same node grammar for traversing relations — deliberately close to the related-view grammar (fromClass/relatedClass/filterClass) used for related-object lists, since both solve "traverse a relation to a target type." A grantToObjects node additionally carries a condition, operations, and nested traverse; a grantToUsers node is just relatedClass/asClass, since the path is a flat list rather than a tree.
| Field | Applies to | Meaning |
|---|---|---|
relatedClass | both | The type to traverse to (required). As concrete as needed — resolution is inheritance-aware, so naming the exact subtype you mean is enough; there's no separate "restrict to subtype" field. Multiple concrete subtypes become multiple branches (grantToObjects) or a separate definition (grantToUsers). |
asClass | both | Only needed to disambiguate an inverse hop between several candidate relations that all reach relatedClass. Relations are stored in both directions, so direction is free; asClass names the relation's target type as declared on the far side. Omitted means "any relation reaching relatedClass will do" — a union across every matching relation, consistent with OR-only semantics. |
condition | grantToObjects only | An object-side predicate over the object's own fields, evaluated at write time. It can never reference the user — all user-dependent logic lives in the path itself (which users are reached), not in a condition. |
operations | grantToObjects only | Which CRUD operations this node grants — see Operations per node. Every node grants at least READ; there is no non-readable "transit" node used purely to traverse through. |
traverse | grantToObjects only | Child hops. A child's starting point is implicitly its parent's resolved class, so paths don't repeat fromClass at every level. |
grantToUsers is always a flat list (no traverse) — if a definition needs alternative user paths, declare a separate definition rather than branching this one.
Operations per node
A grantToObjects node's operations accepts two forms. The flat form lists operation names and they all share the node's own condition:
"operations": ["READ", "WRITE", "CREATE", "DELETE"]
The expanded form is needed when different operations require different conditions on the same node — for example, a document may be read under a looser threshold than it may be written:
"operations": [
{ "op": "READ", "condition": "level < 4" },
{ "op": "WRITE", "condition": "level < 3" }
]
An entry's condition overrides the node's own condition for that operation only; omitting it means "use the node's condition."
Roles schema
A role bundles access-control definitions with the operations each one grants, plus it is what actually gates whether a user reached by grantToUsers receives a definition's AC-id at all — see Role gating. Roles are declared centrally, typically on a dedicated type: NONE config class, and aggregated tenant-wide during model load — the same pattern used for archetype definitions.
{
"class": "my-project.roles",
"type": "NONE",
"roles": [
{
"name": "regionalHr",
"description": "Regional HR — read.",
"accessControl": [
{ "definition": "hrDossier", "operations": ["READ"] }
]
},
{
"name": "regionalHrEditor",
"description": "Regional HR — may edit documents.",
"accessControl": [
{ "definition": "hrDossier", "operations": ["READ", "WRITE", "CREATE", "DELETE"] }
]
}
]
}
| Property | Description |
|---|---|
name | Unique tenant-wide. A user profile can hold one or more roles by name; effective rights are the union across all of them. |
description | Free-text, for administrators assigning roles. |
accessControl | Which definitions this role pulls in, and which operations it grants for each. |
accessControl[].definition | Name of the referenced access-control definition. |
accessControl[].operations | Operations this role grants its holders for that definition. |
Two places specify operations — on purpose
Operations show up on both sides, and they mean different things:
- Node operations (
grantToObjects[].operations) — which operations an object is eligible for. This is where the write scope actually lives: in the worked example below, documents get a write node but the business unit itself does not, so no role can ever grant write access to the business unit object. - Role operations (
roles[].accessControl[].operations) — which operations a user is granted, for a definition they qualify for viagrantToUsers.
The effective capability for a user on a given object is the intersection of the two: the object must carry a node that grants the operation, and the user's role must also grant that operation for the same definition.
Anchors: how individual is a right?
What makes an access right "personal" versus "shared" isn't where grantToObjects/grantToUsers start walking — it's which type is the anchor (the declaring class), because the anchor's identity parameterizes the AC-id.
| Anchor pattern | Cardinality | Example |
|---|---|---|
Declared on the user's own type (e.g. commons.person) | One AC-id per person | "my own employee record" |
Declared on a shared type a few hops from the user (e.g. businessUnit) | One AC-id per anchor instance, shared by everyone who reaches it | HR-DE and HR-Benelux are different AC-ids because the anchoring business unit differs |
| No meaningful anchor identity (a global definition) | One constant AC-id | "all purchasing contracts" |
A useful check for which bucket a definition falls into: "all business units where I am manager" has a path that starts at the user's own account, but the AC-id it produces is parameterized by the business unit, not the person — so it's shared (every manager of that business unit converges on the same AC-id), not personal. The account is only the entry point for computing which business units apply to this particular user; it isn't what the identifier is keyed on.
This matters for modeling because a shared, parameterized AC-id keeps the number of distinct identifiers small and stable — one per business unit, not one per (person × business unit) pair.
Confidentiality and thresholds
Because access is OR-only, a tiered restriction can't be modeled by adding a restricting identifier on top of a broad one — that would make the object more accessible, not less. Instead, model each tier as a separate definition, so a different audience produces a different AC-id:
"accessControl": [
{
"name": "hrDossier",
"grantToUsers": [ { "relatedClass": "commons.person" } ],
"grantToObjects": [
{ "relatedClass": "my-project.employmentAgreement", "operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.document", "condition": "confidentiality < 4", "operations": ["READ"] }
] }
]
},
{
"name": "hrDossierConfidential",
"grantToUsers": [ { "relatedClass": "commons.person" } ],
"grantToObjects": [
{ "relatedClass": "my-project.employmentAgreement", "operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.document", "condition": "confidentiality >= 4", "operations": ["READ"] }
] }
]
}
]
A non-confidential document only ever carries the hrDossier AC-id; a confidential one only ever carries hrDossierConfidential. A role that should see confidential documents references both definitions; an ordinary role references only the first one, and can never be granted the second AC-id, so it can never intersect with a confidential document no matter what path it's reachable through. Clearance is therefore a property of which definitions a role references — not a data attribute stored on the user.
Reusing a single definition with two READ nodes (one per threshold) would not achieve the same thing: both nodes belong to the same definition, so they'd mint the same AC-id, and the confidential document would carry the exact same identifier as the non-confidential one — visible to the whole audience regardless of clearance.
For a threshold with many levels, this pattern means many near-duplicate definitions — acceptable for a handful of tiers, but not something to reach for automatically; a graded construct might be worth revisiting only if a concrete case with many levels actually appears.
Role gating
Being reachable via grantToUsers is not sufficient on its own for a user to receive a definition's AC-id — the user must also hold a role whose accessControl references that definition. The path determines which anchor instances apply to a given user (e.g. which business units they manage); the role determines whether the definition applies to them at all.
This matters because grantToUsers paths tend to reuse generic relations — a managedBy relation, say — that exist for reasons unrelated to HR access. Without role gating, every manager reachable through that relation would silently inherit HR-dossier access the moment the relation exists, regardless of whether granting HR access was ever the intent of that particular relation.
Identifier derivation
name is deliberately load-bearing: it's the input to deterministic derivation of both identifiers a definition produces, so that the object side and the user side always agree on the same value without needing to look anything up.
- AC-id —
UUIDv5("{name}:{anchorId}")for an anchored definition (the anchor instance's identity is folded in, sohrDossierfor BU "DE" and BU "Benelux" produce different AC-ids), orUUIDv5("{name}")for a path-less, global definition. - Node-id —
UUIDv5("{name}:{path}:{operation}"), one per node ingrantToObjects. The operation is part of the node-id (not the AC-id) because the same object type can appear as different nodes for different operations with different conditions — e.g. documents may be read atconfidentiality < 4but written only atconfidentiality < 3, which is two nodes on the same path distinguished purely by operation.
Because name feeds both derivations, it must be unique across the entire tenant model — not just within the declaring class. This is validated at model load: two accessControl definitions with the same name, anywhere in the model (including one contributed by an EXTENSION class), fail model load with an error identifying the duplicate.
Namespace governance
grantToUsers/grantToObjects are relation-traversal paths, so they're bound by the same system-namespace boundary as ordinary relations: a path may never reference a system.* type, whether via relatedClass or asClass, at any depth of traverse. A path always stays inside the tenant model and terminates on a tenant type (typically commons.person) — resolving that tenant object to an actual platform account is a separate, out-of-band lookup that happens outside the modeled path, not something grantToUsers itself expresses.
Worked example
Model: my-project.businessUnit (the anchor) relates to my-project.employmentAgreement (which is a commons.dossier), which relates to commons.document (which is a commons.content) carrying a confidentiality field. Managers relate to a business unit via commons.person.
// on my-project.businessUnit
"accessControl": [
{
"name": "hrDossier",
"grantToUsers": [ { "relatedClass": "commons.person" } ],
"grantToObjects": [
{ "relatedClass": "my-project.employmentAgreement",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.document",
"condition": "confidentiality < 4",
"operations": ["READ", "WRITE", "CREATE", "DELETE"] }
] }
]
},
{
"name": "hrDossierConfidential",
"grantToUsers": [ { "relatedClass": "commons.person" } ],
"grantToObjects": [
{ "relatedClass": "my-project.employmentAgreement",
"operations": ["READ"],
"traverse": [
{ "relatedClass": "commons.document",
"condition": "confidentiality >= 4",
"operations": ["READ"] }
] }
]
}
]
// on a type: NONE roles class
"roles": [
{ "name": "regionalHr", "description": "Regional HR — read.",
"accessControl": [ { "definition": "hrDossier", "operations": ["READ"] } ] },
{ "name": "regionalHrEditor", "description": "Regional HR — may edit documents.",
"accessControl": [ { "definition": "hrDossier", "operations": ["READ", "WRITE", "CREATE", "DELETE"] } ] },
{ "name": "regionalHrCleared", "description": "Regional HR — cleared for confidential documents.",
"accessControl": [
{ "definition": "hrDossier", "operations": ["READ"] },
{ "definition": "hrDossierConfidential", "operations": ["READ"] }
] }
]
For business unit "DE", this produces:
| Object | Carries |
|---|---|
| Business unit DE | hrDossier:DE (READ node) |
| Employment agreement | hrDossier:DE (READ node) |
Document, confidentiality: 2 | hrDossier:DE (READ+WRITE+CREATE+DELETE node) |
Document, confidentiality: 5 | hrDossierConfidential:DE (READ node) |
And for a manager of business unit DE:
- Holding
regionalHr: carrieshrDossier:DEas a read-only identifier → can read the business unit, the employment agreement, and the non-confidential document; cannot write anything; the confidential document never intersects, since it doesn't carryhrDossier:DEat all. - Holding
regionalHrEditor: carrieshrDossier:DEas a read/write identifier → can additionally write the non-confidential document (its node grantsWRITE) — but still not the business unit itself, since the business unit's node only ever grantsREAD. - Holding
regionalHrCleared: additionally carrieshrDossierConfidential:DE→ can also read the confidential document, still read-only.
A manager of a different business unit (say "Benelux") never carries hrDossier:DE at all — the AC-id is parameterized by the anchor instance, so it simply doesn't overlap with anything under BU DE, regardless of role.