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:
Start with team rules, and check that a membership edge actually exists before you rely on one.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.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 work top-down. A person placed at a division reaches everything filed under it, including work they were never staffed on. That is the only way to give access to somebody with no work edges at all — a new joiner before their first project, or a leader who oversees rather than delivers.A policy built only from team rules is a legitimate and tighter configuration. Its cost, stated plainly: a person with no work edges sees nothing, and there is no rule that changes that.
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.

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.

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.