Overview
Graph access control decides which records in the knowledge graph a given person may see. It works by computing, for each person, the set of records they are entitled to, and then adding that set as a filter to every graph query made on their behalf. A person who is entitled to nothing sees nothing; there is no partial or best-effort answer. Policy lives in Postgres, in the Graph Authorization tables. It is deliberately not seeded: every deployment’s org structure differs, so a new install starts empty and an administrator enters the policy that matches their own graph. There are two surfaces, and they do different jobs:
This page is the order to create the rows in.
The four things a policy is made of
A working policy needs all four. Three of them on their own grant nobody anything.
Setting up a new deployment
1
Create the containment axes
In Staff admin > Graph Authorization > Roll-up axes, add one row per containment relationship in your
ontology — the edges that mean “this thing contains that thing”.For each axis set:
- Name — anything descriptive; it is only used for display and ordering.
- Edge element id — the relationship type, exactly as it appears in the graph.
- Direction —
downfollows the arrow,upwalks it in reverse. This is mechanical, not semantic: it describes how your graph happens to store the edge, not whether the axis means “upward” in the org chart.
2
Create the baselines
In Graph Authorization > Baselines (OWD), add one row per label in your graph. Set default access to:
privatefor labels whose records are access-controlled;public_readfor labels everyone may read.
3
Attach each containment axis to a baseline
Open each baseline that should inherit down a hierarchy and set its roll-up axis.This is the step that turns an axis into a containment axis, and it is the one with no visible symptom when
it is missed — see An axis you forgot to attach below.
4
Create the grant rules
In Graph Authorization > Grant rules, add the edges that attach a person to the graph. There are two
kinds, and they are not interchangeable — the difference is where the edge comes from:
Until at least one grant rule exists, a completely correct hierarchy still resolves to nothing for everyone,
because nothing connects a person to a starting point.
What each kind gives a person
Team rules work bottom-up. A person reaches the work they are attached to, and the containment axes then carry that outward — see the engagement a project belongs to, the client that engagement is for, and the other projects under it. On the reference deployment one consultant is directly attached to 146 projects and can see 313; the other 167 arrive purely through that fan-out.Membership rules were meant to work top-down — a person placed at a division reaching everything filed under it, including work they were never staffed on. That mechanism needsIS_PART_OF_* edges, and nothing
writes them, so it has never granted anybody anything here.A policy built only from team rules is the supported configuration. Its cost, stated plainly: a person
with no work edges sees nothing, and there is no rule that changes that. To give access to somebody who
delivers no work — a new joiner, or a leader who oversees — attach them to the work itself, or add the
management roll-up so they inherit what their reports can see.Optional: the management roll-up
If you want managers to inherit what their reports can see, add one further roll-up axis for the management edge — and do not attach it to any baseline. An axis with no baseline pointing at it is what the compiler treats as the management axis. Its direction must beup: the edge is stored report → manager, so the roll-up walks it in reverse.
Traps
An axis you forgot to attach
Skipping step 3 does not raise an error and does not empty anything. It reclassifies the axis: because it is attached to no baseline, the compiler reads it as the management axis instead. There is no warning to read, because nothing recorded what the axis was intended for. Symptom: records do not inherit down the hierarchy, and the management roll-up behaves oddly. Check: every axis you meant as containment is named by some baseline’s roll-up axis field.Two unattached axes
Only one management axis is supported. When there is more than one, the compiler picks the one whose name sorts first alphabetically.A management axis pointing down
A management axis with direction down is not evaluated, and every manager silently stops inheriting their
reports’ scope.
This one is especially hard to spot from the outside, because the compiled policy still reports the management
edge as IS_MANAGED_BY afterwards — that is a built-in default filling the hole, not your axis being
honoured. The field that actually says whether the roll-up is on is include report scopes.
Checking your work
Use Admin > Graph > Access control > Preview as user to check what a named person can see before you switch enforcement on, and the Diagnostics tab for what the compiler dropped. Two results are worth treating as failures even though neither raises an error:- Everyone sees the same thing. Scoping is not doing anything — usually a missing baseline or a missing axis attachment.
- Everyone sees nothing. Usually a missing grant rule, so nobody has a starting point.
Graph reachability
Diagnostics > Graph reachability answers a narrower question than either of the above: for each private label, can the policy reach that kind of record at all, and over which relationship? It starts from the labels the grant rules land on, follows the compiled roll-down and the propagation rules, and reports a count per label with the edge it travelled.Seeing the filter that was applied
When a superuser runs a graph query from chat, the tool result carries the query the filter actually produced, in two forms:authz_rewritten_query— what ran, with the access filter still held as a parameter.authz_executable_query— the same thing with the values filled in, so it can be pasted into a console and run as-is.
Both are shown to superusers only, and are absent — not blank — for everyone else. The filled-in form
lists every record the person is entitled to, so it describes their access in full.If the list is too long to show, you get a message saying how many entries were left out rather than a
shortened query. A cut-down filter is still valid and still runs — it just quietly answers a narrower
question, which is worse than showing nothing.
Citations, and why a scoped answer can have none
Every chat answer that draws on the graph is accompanied by an evidence index — the internal list of records the report writer is allowed to cite, which becomes the citation chips in the answer and the entries in the Provenance panel. Access control applies to that list as well as to the answer, so a person only ever sees citations for records they may read. Two consequences are worth knowing before you read them as faults:- An answer can be correct and carry no citations. The list is built from a capped sample of the records
a question touched, and the cap is applied by the graph engine before access control. On a broad question
— “list our companies” against several hundred — someone entitled to a small share of them can miss the
sample entirely, and the answer arrives with no chips. The figures in it are still correctly scoped; they
come from the filtered query, not from this list. The server log says so explicitly, on a line beginning
[EVIDENCE HYDRATION] clearance filter dropped ALL. - A narrow question is the way to get citations back. Asking about a named company or engagement puts the relevant records inside the sample, and the chips return.
If citations vanish for everyone, including people entitled to almost everything, that is not this — check
the server log for
[EVIDENCE HYDRATION] refusing to hydrate, which means enforcement is on but the request
carried no identity. That is a fault, and it fails safe: no records are disclosed.Records nobody can reach
Diagnostics > unreachable records counts records that no one can reach, by working out every person’s access and combining it. A record appears there because nothing in the graph connects it to anybody — not because the policy is wrong. The fix is upstream, in the data: an enrichment rule that gives those records the missing relationship. Widening the policy to cover them is the wrong instrument — it grants far more than the records in question.A worked policy, end to end
The demo deployment’s policy is reproduced below. It is worth reading even if your labels differ, because it shows the shape a working policy takes: a small number of private labels, each reachable over exactly one relationship from something a person already holds. Nine labels are private; the remaining 106 arepublic_read.
Read each row in the direction the rule itself is written: hold the left, and you may also read the right.
from_element_id on an AuthzPropagationRule is what you already have, and writing the table the other way
round is a good way to end up with rules that look right and grant nothing.
†
Division and Firm are public_read, so these three are not security grants — everyone can read all
divisions and the firm regardless of them. They exist to give the clearance objects their values.
Access originates from work: a grant rule on WORKS_ON_PROJECT / IS_MANAGING_PROJECT /
IS_MANAGING_ENGAGEMENT gives a person the projects and engagements they are on, and the rules above walk
outward from there. Project is private but never appears in the right-hand column — nothing propagates to
it, because it is where access starts.
Nothing in the table grants anything on its own. Each row only says “if you already hold the label in the
first column, you may also read the one in the last.”
What making a label private actually costs
Roughly half of each label above has no path to anybody. Those records become unreadable by ordinary clearance, and that is the correct outcome rather than a defect — nothing in the source data connected them to a person, so they have no parent in the security graph. See Records nobody can reach; the super-admin bypass is the designed way in when someone needs them. Measured on the demo dataset when these nine were first applied — before, every person could read 100% of all of them:
A person on no projects reads none of them, which is the intended answer and not a fault.
Where to stop
Coverage is the test.ContractClause (18,908 records) and Document (9,959) hang off documents rather than
off work, so only 29% and 11% of them are reachable at all. Making those private would hide 13,438 and 8,874
records from everybody — closing a disclosure by deleting the data from view, which is not a fix.
These are database rows, not code, so they do not travel with a release. A new deployment needs them
entered as part of Setting up a new deployment, and
Diagnostics > unreachable records is how you check the result.
Related
- Ontology — the labels and relationship types this policy refers to
- Graph backend (Neo4j & FalkorDB)
- Enrichment rules — how to connect records that no relationship reaches