Audit API
The audit branch (/audit/{tenant}) answers questions about records — who can reach one, what a definition resolves to — rather than returning the record itself. Every route under this prefix is admin-only, the same way the /admin/ prefix is (see Enforcement); being administrative follows from the /audit/ prefix itself, regardless of which specific question is asked.
| Method | Path | Purpose |
|---|---|---|
GET | {typePluralName}/{id}/access | Report who can see one record and why. |
GET | access-control | Report what each access-control definition resolves to in the current model. |
Auditing who can see a record
GET /audit/{tenant}/{typePluralName}/{id}/access
Reports every ACL row one object carries, which roles hold each one, and — for an anchored definition — which people the grantToUsers path actually reaches.
It can only report on an object the caller can already read. Outside that reach it returns the same 404 a plain read would: an audit tool that reaches past the thing it audits would itself be a hole. An admin is no exception, since admin unlocks endpoints rather than data.
{
"grants": [
{
"definition": "purchaseOrders",
"acId": "…",
"nodeId": "…",
"nodeClass": "tradeflow.purchaseOrder",
"objectAllows": ["READ", "WRITE", "CREATE", "DELETE"],
"roles": [
{ "role": "purchaseManager" },
{ "role": "purchaseOrderApprover", "roleGrants": ["READ"] }
]
},
{
"definition": "countryDossiers",
"acId": "…",
"nodeId": "…",
"nodeClass": "tradeflow.purchaseOrder",
"objectAllows": ["READ"],
"roles": [
{ "role": "countryManager" }
],
"holders": [
{
"holderId": "…",
"holderName": "Jane Doe",
"accountId": "…",
"stepPath": "commons.country > managedBy > commons.person",
"hasRole": true,
"roles": ["countryManager"]
},
{
"holderId": "…",
"holderName": "John Roe",
"accountId": null,
"stepPath": "commons.country > managedBy > commons.person",
"hasRole": false,
"roles": []
}
]
}
],
"unresolved": []
}
| Field | Meaning |
|---|---|
grants[] | One entry per ACL row the object carries. |
objectAllows | What the node makes this object eligible for. |
roles[] | Each role that holds this acId. roleGrants appears only when that role's entry narrows the definition; omitted means the role asks for everything the definition allows. |
holders[] | Present for an anchored definition only: who the grantToUsers path actually reaches. Absent — not empty — for a path-less definition, where roles is already the whole answer. |
unresolved[] | Rows whose nodeId matches nothing in the current model. |
Inside a holders entry:
| Field | Meaning |
|---|---|
holderId / holderName | The object the path terminates on — ordinarily a commons.person, but for a zero-hop definition (grantToUsers: []) the anchor is its own terminus. |
accountId | The platform account linked to that person, or null when there isn't one — a real answer ("would hold it, but can't log in"), not a missing value. |
stepPath | The grantToUsers steps from the anchor down to this holder. |
hasRole / roles | Whether this holder also holds a role referencing the definition, and which ones. A hasRole: false entry answers "why can't this person see it despite being on the path?" |
There is deliberately no "effective operations" field. The obvious candidate — intersecting roleGrants with objectAllows — would be wrong for at least one holder, because the coarse viewer role clamps mutations for some holders of a role and not others. Both inputs are reported so a caller can compute the intersection knowing exactly what it does and doesn't include.
A non-empty unresolved means a rebuild is due. Such a row still grants READ — the read filter only ever checks acId — but never WRITE/CREATE/DELETE: an unresolvable node fails closed.
Auditing what a definition resolves to
GET /audit/{tenant}/access-control
A different question from the per-record audit: not "who can see this object" but "what does this definition actually resolve to in the current model."
A traverse node names types rather than one exact relation, and the platform works out which relations satisfy it (see Choosing which relation to follow). That resolution is derived, not written down, so this endpoint publishes it.
Per definition and per node, the response reports:
| Field | Meaning |
|---|---|
| node-id and path | The node's identity, as the stored rows carry it. |
| applies-to class | The class or relatedClass the node names. |
operations | What the node grants. |
| resolved relation pairs, with a count | Which relations this node actually walks. A node expected to pick up one relation that resolved to fourteen is visible at a glance, before it shows up as a surprise in the data. A root reports none, since it walks no relation. |
The response also carries orphanedNodeIds and orphanedStepIds: node-ids and grantToUsers step-ids the tenant's stored ACL still carries but the current model no longer resolves to anything. Renaming or removing a node or a definition orphans every row it wrote, silently — nothing about the model's shape announces it. Rather than requiring a rebuild to notice by its row counts moving, this reads what is actually stored and names what no longer matches. An empty list is a checked answer ("nothing orphaned"), not the absence of one — this is the one part of the response that reads the database rather than only the in-memory model.