> ## Documentation Index
> Fetch the complete documentation index at: https://docs.experio.cloud/llms.txt
> Use this file to discover all available pages before exploring further.

# Graph Access Control

> Set up who can see which records in the knowledge graph, in the order the policy compiler expects

## 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:

| Surface                                               | Use it to                                                           |
| ----------------------------------------------------- | ------------------------------------------------------------------- |
| **Staff admin** (`/staff/`) > **Graph Authorization** | **Create and edit** the policy rows                                 |
| **Admin > Graph > Access control**                    | **Read and check** the resulting policy — and switch org scoping on |

This page is the order to create the rows in.

<Warning>
  **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.
</Warning>

## The four things a policy is made of

| Table                    | Answers                                                         | Example                                    |
| ------------------------ | --------------------------------------------------------------- | ------------------------------------------ |
| **Roll-up axis**         | "Which edge describes containment, and which way do I walk it?" | A division contains its engagements        |
| **Baseline**             | "Is this label restricted, or can everyone read it?"            | `Engagement` is private, `Skill` is public |
| **Baseline → axis link** | "Which containment axis applies to this label?"                 | `Engagement` rolls down the division axis  |
| **Grant rule**           | "Which edge attaches a person to the graph in the first place?" | `WORKS_ON_PROJECT`                         |

A working policy needs all four. Three of them on their own grant nobody anything.

## Setting up a new deployment

<Steps>
  <Step title="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** — `down` 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.

    <Tip>
      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.
    </Tip>

    At this point the axes exist but govern nothing.
  </Step>

  <Step title="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.

    <Warning>
      **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.
    </Warning>
  </Step>

  <Step title="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](#an-axis-you-forgot-to-attach) below.
  </Step>

  <Step title="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:

    | Kind           | Attaches a person by                                                | Who writes that edge                     |
    | -------------- | ------------------------------------------------------------------- | ---------------------------------------- |
    | **Team**       | the work they are on — `WORKS_ON_PROJECT`, `IS_MANAGING_ENGAGEMENT` | **Ingestion**, as a fact it discovered   |
    | **Membership** | where they sit in the org — `IS_PART_OF_DIVISION`                   | Nothing, unless your ingestion writes it |

    <Warning>
      **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](/admin-guide/ontology) first, or use **Preview as user** afterwards and confirm the number moved.
    </Warning>

    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.

    <Warning>
      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.
    </Warning>
  </Step>
</Steps>

## 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**.

<Warning>
  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.
</Warning>

### 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.

<Note>
  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.
</Note>

### 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](/admin-guide/enrichment-rules) 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.

## Related

* [Ontology](/admin-guide/ontology) — the labels and relationship types this policy refers to
* [Graph backend (Neo4j & FalkorDB)](/admin-guide/graph-backend)
* [Enrichment rules](/admin-guide/enrichment-rules) — how to connect records that no relationship reaches
