Security Policies
Security policies provide row-level access control based on data relationships — this is the platform's built-in row-level security (RLS) and multi-tenancy (per-tenant / per-customer data isolation) mechanism. They restrict access to records based on how they relate to the current user, and are enforced on every query — so you never hand-roll tenant filtering in application code or the frontend.
Reach for a security policy when who-can-see-which-rows depends on how the data is linked (a user sees the tournaments their club is in, the players on their team, …). If access is purely method-level — "any authenticated user may read this collection," "only admins may write" — you don't need a security policy at all; plain access rules are simpler and cheaper. A security policy adds a per-row filter to every query on the collection, so add it only where you actually need row-level scoping.
Setting a lookup's direction: pick a single direction (↑ or ↓) — the junction
pattern. Bidirectional/BidirectionalWithReentry is almost always wrong — it
multiplies the access-path count and makes reads expensive (a real misconfiguration
put 243 joins on one GET). If one direction seems not to resolve, you need two
single-direction lookups, not a bidirectional one (rare exceptions: two
shared-FK cases). A write 403 is usually a role-match
problem, not a reach problem: the body lookup's path must carry a role the
method's rule names — or no role at all (the four rules).
Never "fix" a 403 by widening a lookup to Bidirectional — first diagnose
which edge severs the role-carrying path (the three-level
trap). After any change, check the path summary
for a smell. Details: Prefer a single direction.
How It Works
Security policies evaluate access through relationship paths in your data. A user gains access to a record if they can reach it through an allowed path.
Example scenario:
Users → ProjectMembers → Projects → Tasks
A user can access tasks in projects where they are a member.
Two Layers: The Token Gate and the Data Filter
Authorization happens in two stages, in order:
- The token gate (roles, before any data). When a request arrives, the caller's roles are read from their access token and checked against the collection's access rules for the requested method. This is a method-level yes/no decision made before any record is read. If the caller's roles don't grant the method, the request is rejected immediately — no data is ever queried.
- The data filter (security policy, on the records). Only after the gate passes does the security policy apply. It narrows the request to the records the caller can actually reach, by tracing relationship paths from the caller's own identity to each record. A request that clears the gate can still return nothing if no record is reachable.
The distinction matters: a role on the token says who you are everywhere; a security policy decides which rows that identity can touch. The gate is about the caller; the policy is about the data.
LISTEN is the exception: it stops at the gate. Real-time notifications are not
run through the data filter, so a caller who can subscribe is told the ids of
every row that changes in the collection, including rows the policy would hide
on a read. The notification carries no field values, and a follow-up read of a
collection or a view is filtered normally — see
Access and Scope.
Global roles
A global role is one carried directly on the token. It is evaluated at the gate and can grant a method, but it does not bypass the data filter — the security policy still applies, and a caller with no path to the record is filtered out (see Global Roles and the Security Policy). A global role says who you are, not which rows you may touch.
Roles that live in the data (SP-roles)
A role can also be enforced inside the data filter — an SP-role (a security-policy role that lives on the data, not on the token). When a junction collection carries a role lookup with a security policy (see Junction collections with a role), the role required for the method comes from the access rules, but it is matched against the role stored on the junction record — not against the token. A user holds an SP-role by having a linked junction row that carries it.
This is the subtle case where the answer is "both": your identity (from
the token) determines which junction rows are yours, and the role recorded
on those rows determines what you may do. A user can therefore hold
different rights on different records — TournamentOrganizer on one
tournament, plain member on another — even though their token never changes.
One role name, two jobs
A name in roleNames is checked in both layers, and the same name can
mean different things depending on which other names sit beside it:
{ "method": "POST", "roleNames": ["_AUTHENTICATED_USER", "TournamentOrganizer"] }
Here _AUTHENTICATED_USER opens the gate for any logged-in caller, while
TournamentOrganizer becomes the row-level role the data filter enforces:
the create only survives if the caller holds a TournamentOrganizer
membership on the gating relationship.
{ "method": "POST", "roleNames": ["TournamentOrganizer"] }
Drop _AUTHENTICATED_USER and the same name now also gates the door: the
caller must carry TournamentOrganizer on their token, globally, before
the request is even evaluated against data. Same role name, entirely
different rule — so be deliberate about whether you pair a named role with a
built-in like _AUTHENTICATED_USER or not.
Setting Up Security Policies
1. Create a Relationship Path
Your collections need lookup properties that create a path from users to data:
tasks.project → projects
projects.members → users (via projectMembers)
2. Mark the Lookup Property
In the Developer Portal:
- Navigate to your collection
- Select the lookup property
- Set the Security Policy value on the property (see Security Policy on Lookup Properties below for the four values)
3. Configure Access Rules
Set access rules that work with the security policy:
{
"access": [
{ "method": "GET", "roleNames": ["_AUTHENTICATED_USER"] }
]
}
Security Policy on Lookup Properties
Security Policy on a lookup isn't a boolean — it's a small enum that
describes which side of the lookup is filtered by the user's access to the
other side. PathFinder walks lookup edges from the requested record
toward the current user — the record is the starting point and each step
moves one hop closer to the user — and only edges whose policy is set in a
compatible direction are eligible.
The Portal labels match the enum values (also what swagger advertises), paired with an arrow icon for compactness:
Because the walk runs record → user, the direction you set on each lookup is simply which way PathFinder is allowed to step through it:
| Portal label | Enum value | Direction & when to use |
|---|---|---|
| Disabled | (unset) | Property is not part of any security path. |
| Target filtered by entity (↑) | TargetFilteredByEntity | PathFinder steps up this lookup — from the entity that holds it to the target it points at, i.e. one hop closer to the user. Use it on a record's lookup to its parent (tasks.project), and on a junction's user-side and role lookups. |
| Entity filtered by target (↓) | EntityFilteredByTarget | PathFinder steps down this lookup — from the target into the entity rows that reference it. Use it on a junction's parent/resource-side lookup, so the path can descend from the protected record into the junction rows that point at it (projectMembers.project). |
| Bidirectional (↕) | Bidirectional | Both of the above. Use when a single lookup must support paths approaching from either side. |
| Bidirectional with re-entry (↻) | BidirectionalWithReentry | Same as Bidirectional but PathFinder may revisit the same target table when approaching from the opposite direction. For symmetric paths that include cycles. |
If you're not sure: a lookup that points toward the user (a record's
lookup to its parent like tasks.project, and a junction's user and role
lookups) is TargetFilteredByEntity (↑); a junction's lookup to the parent
resource it protects is EntityFilteredByTarget (↓).
Prefer a single direction — Bidirectional is a smell
Almost every lookup wants a single direction (↑ or ↓). The two Bidirectional
options exist for a genuinely rare case — one lookup that legitimately sits on
paths approaching from both sides (the two real cases).
If you reach for Bidirectional, stop: it
is almost always wrong, and it is a leading cause of slow — and sometimes
over-permissive — security policies.
Bidirectional is the chmod 777 of lookupsIt makes a path resolve from either side, so it always "works" — which is
exactly why it's dangerous: it hides its damage behind a green access test. The
extra traversal both explodes the path count (a cost problem — always) and
can open unintended paths that over-grant rows (a correctness problem —
situational). When a path won't resolve, flip the edge that's backwards;
don't open both. Reaching for Bidirectional because "the path won't resolve"
is trial-and-error, not design.
- A junction with both sides
Bidirectionalhas no ascending-vs-descending shape at all — that's always a mistake, never a legitimate "both sides" case. BidirectionalWithReentry(↻) is stronger still (it lets a path revisit a table). A last resort for the narrow case where a legitimate path must revisit a table via a shared parent (Case 2) — never a way to force a path, and it does nothing for a self-referential lookup (an immediate self-loop is simply ignored).
How to pick the direction — ask "which way does PathFinder step to get one hop closer to the user?"
| The lookup is… | Set it | Example |
|---|---|---|
| a record's lookup to its parent | ↑ TargetFilteredByEntity | tasks.project, teams.club |
| a junction's resource/parent side (descend from the protected record) | ↓ EntityFilteredByTarget | projectMembers.project, clubMembers.club |
| a junction's user side or role side | ↑ TargetFilteredByEntity | projectMembers.user, projectMembers.role |
| a link/join table's two sides | one ↓, one ↑ — never both the same, never both Bidirectional | tournamentClubs.tournament ↓, tournamentClubs.club ↑ |
If a lookup fits no row, it's probably not part of a security path — leave it
Disabled; don't mark it Bidirectional "just in case."
Why this matters — the cost. Each Bidirectional edge doubles PathFinder's
branching and can open cycles; on an interconnected schema that turns a handful
of intended paths into dozens of long, cyclic ones, and every path becomes an
OR'd sub-query in the generated SQL. The query behind a real production
slow-down carried 49 security paths and 243 joins under Bidirectional;
giving each lookup a single direction dropped it to 4 paths and single-digit
joins — the same rows, a fraction of the work. Access happened to stay correct
there, but the extra paths can also silently over-grant — so the risk is cost
and correctness. See Correct but expensive.
When Bidirectional/re-entry is right — the two real cases
They aren't never correct — they exist for two specific, rare shapes. The tell that separates both from a junction: a junction is two distinct properties, so you give each its own single direction; these cases are one shared property that genuinely has to be walked both ways — there is nothing to split. Both are checkable with the PathFinder tool — if you think you have one, confirm it there before shipping, because it's easy to think you do.
Case 1 — one FK shared by two collections, opposite directions → Bidirectional (↕)
projects.organization → organizations, where you want mutual visibility:
- Org members may see the org's projects. Protecting
projects, PathFinder walks ↑projects.organizationto the org, then to the org's members. - Project members may see the parent org. Protecting
organizations, PathFinder walks ↓projects.organizationinto the org's projects, then to their members.
The same projects.organization FK sits on projects's path (↑) and
organizations's path (↓). Give it a single direction and one of the two rules
silently loses its path; only Bidirectional serves both. One FK, two collections,
nothing to split — a real ↕, not a junction. The path never revisits a table, so
plain Bidirectional is enough (no re-entry).
Case 2 — see siblings under a shared parent → BidirectionalWithReentry (↻)
Same projects.organization → organizations, but now membership lives only on the
leaf (projects.user, or a projectMembers role) and you want a user to see
every project in an org they already have a project in, not just their own:
projects.user → TargetFilteredByEntity (↑ reaches my own projects)
projects.organization → BidirectionalWithReentry (↻)
The sibling path is sibling project → ↑ to the org → back ↓ to one of my projects → ↑ to me. It revisits the projects table — a cycle — so plain Bidirectional
(↕) is not enough: it blocks the revisit and you still see only your own.
BidirectionalWithReentry (↻) relaxes the cycle guard so the down-again step is
allowed. (Confirmed with the PathFinder tool: projects.organization set to ↑ or
↕ yields one path — your own projects; only ↻ adds the sibling path.)
It makes every sibling under the shared parent visible, including rows owned by
other users. That's the feature ("anyone with a project in the org sees all its
projects") and exactly the over-reach Bidirectional is warned about — use it only
when "shares a parent → shared visibility" is genuinely your access model. If you have
real parent-level membership (an orgMembers junction), route through that with
single directions; you don't need re-entry.
If neither of these is exactly your case, you want single directions.
Recognizing Case 1 from a 403 — the three-level shape
Case 1's example is easy because both audiences sit one hop from the shared FK. The shape that actually bites in real schemas is a role junction two levels below the record it must gate. Read this as the PathFinder walk, record → user, one lookup per hop:
player
→ ↑ players.club → club
→ ↓ teams.club → team
→ ↓ teamMembers.team → teamMembers row carrying role TeamLeaderEdit
→ ↑ teamMembers.user → user
A team leader's write role lives on teamMembers, but the records it must
authorize (players, via their club lookup) hang off the club — two
hops above the junction. For the leader's POST /players to succeed, the
role-carrying path must descend from the club through teams into
teamMembers, which needs teams.club walked ↓. But teams.club is
also a plain record-to-parent lookup: club admins protecting teams need it
walked ↑ (team → ↑ teams.club → club → ↓ clubMembers.club → clubMembers row carrying ClubAdmin → ↑ user). One FK, two audiences, opposite
directions — Case 1, even though no single collection "looks" mutual the
way projects ↔ organizations does.
The trap is the diagnosis, not the concept. The symptom is a write 403
(rule 1: the role-carrying path is severed) on a
collection two hops away from the edge that's wrong — nothing points you
at teams.club. And the mechanical fix from the direction table — "a
record's lookup to its parent is ↑" — is exactly what severs it. So:
- Before narrowing a ↕ edge to a single direction, list the consumers of each direction separately: which junction × role × method combinations resolve through ↑, and which through ↓. If both directions have a real consumer, it's Case 1 — keep ↕ and document why.
- When a write 403s and the method's rule names a junction role, trace where the junction's role path must descend from the protected record. Every edge on that descent needs ↓ (or ↕ if it also serves an ↑ audience).
Junction collections with a role
A common pattern is a junction collection that binds a user to a resource
with a role — e.g. tournamentMembers { user, tournament, role }. The
PathFinder needs three things from this junction:
- A way into the junction starting from the current user.
- A way out of the junction toward the resource being protected.
- A role lookup so the access rules'
roleNamescan be enforced for the chosen HTTP method.
The setup is:
tournamentMembers:
tournament → EntityFilteredByTarget (↓ step down from the tournament into the junction rows that reference it)
user → TargetFilteredByEntity (↑ step up from the junction to the caller's user row)
role → TargetFilteredByEntity (↑ the gating role, reached by stepping up like the user)
Read the path as PathFinder builds it — starting at the tournament being
protected, down into tournamentMembers (so tournament is
EntityFilteredByTarget), then up to the user and the role (so both
are TargetFilteredByEntity).
With this configuration, a user with role TournamentOrganizer on a
specific tournamentMembers row gains the access granted by the matching
access rule on tournaments for that one tournament.
The role here is evaluated against the value recorded on the junction row, not against the caller's token. The access rule names which role unlocks the method; the data decides whether the caller holds it on that particular record. See Roles that live in the data.
The role property must be TargetFilteredByEntity for PathFinder to
recognise it as the gating role on the junction — getting this wrong is a
common cause of "why does my path say no roles required?"
Worked example: system-wide create, per-record edit
A common real-world shape is: anyone may read; only certain users may create; a user may edit only the records they're attached to; an admin may do everything. Expressing this needs two membership tiers and — the crucial part — two distinct roles, each named only in its own method's access rule.
Model it as an organization that owns projects (the same shape as a GitHub organization owning repositories):
Collections
organizations— the root each resource belongs to (one row per tenant).organizationMembers { organization, user, role }— grants an organization-wide capability. A row with roleOrgCreatormeans "may create projects anywhere in this organization."projects— the protected resource, with a required security-policy lookuporganization → organizations.projectMembers { project, user, role }— grants a per-record capability. A row with roleProjectEditormeans "may edit this one project."
Lookup directions (see the table above)
projects.organization → TargetFilteredByEntity (↑ project up to its org)
organizationMembers.organization → EntityFilteredByTarget (↓ org down into its membership rows)
organizationMembers.user → TargetFilteredByEntity (↑)
organizationMembers.role → TargetFilteredByEntity (↑)
projectMembers.project → EntityFilteredByTarget (↓ project down into its membership rows)
projectMembers.user → TargetFilteredByEntity (↑)
projectMembers.role → TargetFilteredByEntity (↑)
Access rules on projects
[
{ "method": "GET", "roleNames": ["_AUTHENTICATED_USER"] },
{ "method": "POST", "roleNames": ["_AUTHENTICATED_USER", "OrgCreator", "OrgAdmin"] },
{ "method": "PATCH", "roleNames": ["_AUTHENTICATED_USER", "ProjectEditor", "OrgAdmin"] },
{ "method": "DELETE", "roleNames": ["_AUTHENTICATED_USER", "ProjectEditor", "OrgAdmin"] }
]
Why it works
- Read —
GETis satisfied at the token gate (_AUTHENTICATED_USER); any logged-in user reads. - Create —
POSTsendsorganization: { id }, and because that lookup is required it can't be omitted (see Creating rows (POST)). The caller'sorganizationMembers{OrgCreator}path reaches that organization carryingOrgCreator, which thePOSTrule names → create allowed. This is an org-wide capability: it works for any project in the org. - Edit —
PATCHrequires a path to the project carrying a role thePATCHrule names (ProjectEditororOrgAdmin). The caller'sOrgCreatorrow carriesOrgCreator, which is not in thePATCHrule, so it doesn't help. The only path that reaches the project withProjectEditorisprojectMembers{ProjectEditor} → project, which exists only for projects the user belongs to → per-record edit. - Admin — an
organizationMembers{OrgAdmin}row carriesOrgAdmin, named in every write rule, so it resolves for every project in the org.
Use one role for both create and edit — or list the create-role in the
PATCH rule — and the org-wide organizationMembers path satisfies edit too,
so every creator can edit every project in the org. Keep two roles, and
keep each role only in the rule for its own method. OrgCreator is org-wide,
but only for the method it appears in (POST); it stays out of editing
precisely because it is absent from the PATCH rule.
Access Evaluation
When a request is made:
- The system identifies security policy properties on the collection
- It traces relationship paths from the user to the requested record
- Access is granted if a valid path exists where the user has the required role
How Many Lookup Paths Must Resolve
When a record can be reached through more than one security-policy lookup, what must resolve splits into two independent checks:
- Reaching the existing record — for
GET,PATCH,PUT, andDELETEalike, the caller needs at least one security-policy path to the record to resolve. The paths are combined withOR, so a single valid path is enough; other lookups may be empty or point to rows the caller cannot see. - Lookups you write — a write additionally re-checks every security-policy lookup you actually send in the request body. Each supplied lookup must resolve to a target the caller can reach through a path the method's rule accepts — one carrying a role the rule names, or a roleless path — see the four rules below. Lookups you leave out of the body are not checked.
So the asymmetry is not "reads need one path, writes need all" — reaching the record is the same any-path check for both. The difference is that a write can re-point the record at a new parent, so each lookup present in the body is validated on the way in. Omit a lookup and it isn't enforced; include one pointing somewhere the caller can't reach and the write is denied.
How writes are actually checked — the four rules
Reads are about reach — any one resolving path is enough. The body-lookup check on writes is about role-matching: each path to a lookup's target is judged by the role it carries against the roles named in the method's access rule. Four rules decide every outcome:
- A path carrying a role the method's rule doesn't name is discarded.
Reaching the target is not enough — the role on the path must appear in the
rule for this method. A caller whose
clubMembers{TeamLeader}path reaches the club still gets403on aPOST /playerswhose rule namesTeamLeaderEdit: the path exists, but for this method it doesn't count. This is the flip side of the create/edit role split — a role is only as good as the rules that name it, and a mechanical direction change that severs the role-carrying path breaks writes even when plenty of other paths still reach the target — the classic shape is the three-level trap. - A roleless path satisfies any write rule. A path with no role lookup on
it (plain ownership like
createdBy↑) has no role to mismatch, so every method's rule accepts it. This is what lets a creator edit their own record with zero junction rows — and it makes a roleless path a wildcard: once one exists, no role gating on that lookup can exclude the callers it covers. Audit roleless edges as deliberately as role-carrying ones. - Lookups you omit are never checked. On create this has a sharp
consequence: unless the gating lookup is required, any caller who
clears the token gate can
POSTa record with the lookup left out — an orphan row that no security path can ever reach, invisible to every caller including its creator. Mark gating lookups required; see Creating rows (POST). _AUTHENTICATED_USERin a rule is the token gate only. It lets any logged-in caller attempt the request; rules 1–3 still apply unchanged to every lookup in the body. It never relaxes the role-match.
These checks run against the final item, after any functions: a function that pins a missing or corrected lookup into the incoming item does not help — the request is denied exactly as if the client had sent that value itself. Access comes from the caller's paths, never from a function's edits.
When a GET succeeds but a write on the same record 403s, the record's own
path is fine — it's the same any-path check GET uses — so the failure is in
a body lookup. Diagnose in this order: which body lookups carry a security
policy → for each, which paths reach its target → which role each path
carries → is that role named in this method's rule (or is the path
roleless). Occasionally the target really is unreachable (e.g. re-pointing a
gating lookup like organization at a row the caller isn't a member of) —
the same walk exposes that too. The error body will tell you none of this;
the PathFinder tool will.
Creating rows (POST)
A new row has no relationships yet — they arrive in the request body. So a
POST is gated by the lookup values you send: for each security-policy
lookup in the body whose target is itself reachable from the caller, the
create only succeeds if the caller has a valid path to that target (and,
where the path carries a role, a role the POST access rule allows).
Two consequences catch people out:
- A security-policy lookup is only enforced on create if its value is actually sent. Leave the lookup out and there is nothing for the policy to check against — the create falls back to the token gate alone. If a lookup is what gates who may create here, mark it required so it can't be bypassed by simply omitting it.
_AUTHENTICATED_USERonPOSTdoes not bypass the data check. Clearing the gate only lets the caller attempt the create; a supplied gating lookup is still enforced against the data. To let a junction role decide who may create — e.g. only aTournamentOrganizerof a tournament may add to it — name that role on thePOSTaccess rule and require the gating lookup. (This is rule 4 of the write checks, applied to create.)- A
POSTbody carries at most two security-policy-checked lookups. A third gated lookup in one body fails with a genericOperation failed. Split the create or restructure the model so fewer gated lookups travel together — this bites nested requests too.
PathFinder Tool
The Developer Portal includes a PathFinder tool to visualize and debug security policies.
Using PathFinder
- Go to Security → Security Policy
- Select a user to impersonate
- View the access matrix showing:
- Collections with security policies
- Valid access paths for the selected user
- HTTP methods allowed through each path
Reading the Access Matrix
| Indicator | Meaning |
|---|---|
| Green | Access allowed |
| Red | Access denied |
| Path shown | The relationship chain granting access |
Example: Project-Based Access
Consider a project management system:
Collections:
users— Application usersprojects— ProjectsprojectMembers— Links users to projects with arolefieldtasks— Tasks with aprojectlookup field
Security Policy Setup:
- Enable security policy on
tasks.project - Enable security policy on
projectMembers.userandprojectMembers.project
Result:
- Users can only see tasks in projects where they are members
- Access is determined by the
projectMembersjoin collection - Different member roles can have different permissions
Combining with Access Rules
Security policies work alongside access rules:
{
"access": [
{ "method": "GET", "roleNames": ["_AUTHENTICATED_USER"] },
{ "method": "POST", "roleNames": ["manager", "project_lead"] },
{ "method": "DELETE", "roleNames": ["manager"] }
]
}
A user needs:
- A matching role from the access rules AND
- A valid security policy path to the record
Global Roles and the Security Policy
A global (token) role is checked at the token gate: it can grant a method, but it does not bypass the security-policy data filter. Once the gate is passed the policy still applies, and it is satisfied only by a relationship path the caller can actually reach. A user who holds a role globally but has no path to the record clears the gate and is then filtered out — the request fails closed.
This is deliberate: a global role says who you are, not which rows you may touch.
Earlier versions let a global role short-circuit the security policy entirely. That override was removed because it was more confusing than useful — a global role now opens the door but never bypasses the row filter.
To grant genuinely unrestricted access, model it in the data, not as a plain global role:
- Give the admin a membership row carrying an admin role on the gating
relationship (e.g. an
OrgAdminrow on the organization, named in the write rules), so their path resolves for every record under it. - For service accounts and internal/system callers that must skip the policy altogether, use a token with the role/security-policy skip flags, or set the per-collection Skip security policy option — these are the only true bypasses.
Mixing global roles with path roles
Whether mixing global roles with a security policy is safe depends on one thing: does your security path carry a role?
- No role on the path (the junction has only its
userand parent lookups, norole) — access is decided purely by reachability, and your write rules just use built-ins like_AUTHENTICATED_USER. Global roles never collide with anything the filter checks, so mixing them in is harmless. - A role on the path — the same name is now evaluated in both layers: at the gate against the caller's token roles, and in the filter against the role stored on the junction row. Granting that name globally satisfies only the gate; it confers no row access, because the filter still wants a junction row. So don't hand out a path-role globally expecting it to unlock records — grant it on the junction instead.
Common Pitfalls
Security policies fail in quiet ways. The ones below account for most "why isn't this working?" — and "why is this slow?" — reports.
A single wrong edge breaks the whole path
A path is only usable if every edge along it is set to a compatible direction (see Security Policy on Lookup Properties). A junction is especially easy to get wrong because its two sides must point in opposite directions — the edge toward the parent resource and the edge toward the user/role are not the same direction.
If one edge points the wrong way, PathFinder never reaches the user and the whole path is discarded. There is no partial result and no message naming the bad edge:
- On reads, a discarded path means no filter is generated at all. The
collection is then restricted only by its access rules — and with a
permissive rule like
_AUTHENTICATED_USER, every logged-in user sees every row. A policy that "isn't gating" is almost always a broken path, not a loose one. This is the dangerous failure mode: it fails open, not closed. - On writes, a discarded path just yields a generic
403or empty result. The error never says "the path broke at edge X."
Always confirm with the PathFinder tool that a path actually resolves to the user before trusting a policy.
Errors are opaque
A misrouted path, an unsatisfied required lookup on create, or a missing role
on a junction all surface as a plain 400/403 (or, inside a function, a
failed write) — never as "security path didn't resolve." When a request is
denied unexpectedly, reach for the PathFinder rather than the error body.
Correct but expensive: the Bidirectional path explosion
Insidious because access usually still works — nothing obvious is denied and the same rows come back, just slower. (Those extra paths can also silently over-grant, so it isn't always harmless; but even when access is exactly right, the cost alone makes it a bug.)
Bidirectional/↻ let PathFinder traverse a lookup both ways, which on an
interconnected schema opens cycles and multiplies paths. Each path is an OR'd
sub-query with its own joins, so the SQL balloons — dozens of paths, hundreds of
joins — to filter the same rows a few short paths would.
A functional test won't reliably catch it: impersonate the user, query, and rows come back — usually the right ones — just slowly. The signals that expose it are the path count and the join/CPU cost of the query.
The rule: access working is necessary but not sufficient. After any security
policy change, check the paths (PathFinder tool). Any
unnecessary Bidirectional, any cycle, or a collection with more than a
handful of paths is a defect even if access tests pass — a correct-but-explosive
policy is still a bug. Fix it by giving each lookup a single direction
(Prefer a single direction).
Best Practices
- Default to a single direction —
Bidirectional/↻is almost always wrong; reach for it only for a genuinely two-sided lookup, never to "make a path resolve" (why) - Keep paths short — Simpler paths are easier to understand and debug
- Check the path count, not just access — access working doesn't mean the policy is cheap; any
Bidirectional, cycle, or many-path collection is a smell (Correct but expensive) - Use the PathFinder — Verify access works as expected before deploying
- Test with real users — Use the Current User Context control in the sidebar to test as different users. This global setting applies across Data Explorer, REST Explorer, and Function Testing.
- Document your model — Record why each security policy exists