Skip to main content

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 order matters, and three of the steps fail silently when they are done wrongly. The policy still compiles, the admin screens still look correct, and the only symptom is that somebody sees fewer records than they should — or, in one case, that a manager quietly stops seeing their team’s work. Each trap below is covered by a test in server/experio/graph_authz/tests/test_manual_bootstrap.py; if you change this page, change that file too.

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.
  • Directiondown follows the arrow, up walks 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.
To pick the direction, look up the relationship in your own ontology and ask which way its arrow points — then ask which way you want to walk it. Following the arrow is down; walking it in reverse is up.Worked through on one deployment, whose ontology names these edges HAS_DIVISION and IS_IN_DIVISION: the first is stored Firm → Division, so walking firm-to-divisions follows the arrow and is down; the second is stored Engagement → Division, so walking division-to-engagements goes against it and is up.Check the names against your own ontology rather than copying these. They are not fixed across deployments — the same two relationships ship as HAS and BELONGS_TO in the default ontology. An axis naming an edge your graph does not have is not an error: it simply matches nothing, and the symptom is a flat tree or a count that reads zero.
At this point the axes exist but govern nothing.
2

Create the baselines

In Graph Authorization > Baselines (OWD), add one row per label in your graph. Set default access to:
  • private for labels whose records are access-controlled;
  • public_read for labels everyone may read.
A label with no baseline row at all is treated as private. The default is deny, so forgetting a label is not a no-op — it makes that label invisible to everyone until somebody notices. List every label your graph actually contains, not just the ones you intend to restrict.
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:
Use team rules. Membership is retired — do not build a policy on it.A membership rule only grants something if your graph really contains that edge. On the reference deployment there are no IS_PART_OF_DIVISION or IS_PART_OF_FIRM edges at all — ingestion never writes them — so those rules sat in the policy granting nothing, and removing them changed no one’s access.The kind still exists in the data model, and a rule saved with it still compiles and still walks its edge. What changed is that no screen offers it, measures from it, or recommends it any more. Access control previously derived its coverage numbers from membership rules and, finding none, advised admins to create them — advice that widens every scope to fix a problem that does not exist.There is no screen that tells you an edge is missing before you save the rule. Check the relationship in Ontology first, or use Preview as user afterwards and confirm the number moved.
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 needs IS_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.
Leave grantee principal, grantee selector, source principal and record filter empty. They are not yet implemented, and a rule that sets them would grant access to everyone rather than to the principal named. The admin form disables them and refuses to save a rule that has them set.

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 be up: 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.
The resulting diagnostic names the wrong axis. It is filed against the axis that lost, so it reads as “the management axis was not applied” when the real cause is that some other axis was never attached to a baseline. If you see that message, check for unattached axes before you touch the management axis itself.

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.
Test with at least three people at different depths of the org structure. A single account cannot distinguish a working policy from either failure above.

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.
Read it as a ceiling on the policy, not as anybody’s access. A person still sees only the records they are linked to; the manager roll-up and superuser accounts start somewhere else and are not counted here. What it is good for is a label whose count is far below its total, or collapses toward zero: that says the relationship it depends on is missing from the data, and names the edge to go and look for. What it is not is a target to drive to 100% — plenty of records legitimately have no such edge. For “which specific records can nobody reach”, use unreachable records below, which measures real people rather than a walk.

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.
This is the fastest way to answer “did the filter apply, and to what?” — before blaming the policy for an answer you did not expect. If the number looks wrong, read the query the model wrote before assuming the filter is at fault; a question the model misread produces a wrong number with a perfectly correct filter.
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 are public_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.”
Use a propagation rule, never the controlled_by field on the baseline. controlled_by reads like the answer and is not evaluated: a label set private with only controlled_by compiles to plain private with no derivation at all, which means invisible to everyone.

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.