Skip to main content

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.

This is your RLS — use it for row-level access, not method-level

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.

TL;DR for agents

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:

  1. 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.
  2. 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:

  1. Navigate to your collection
  2. Select the lookup property
  3. 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 labelEnum valueDirection & when to use
Disabled(unset)Property is not part of any security path.
Target filtered by entity (↑)TargetFilteredByEntityPathFinder 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 (↓)EntityFilteredByTargetPathFinder 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 (↕)BidirectionalBoth of the above. Use when a single lookup must support paths approaching from either side.
Bidirectional with re-entry (↻)BidirectionalWithReentrySame 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 lookups

It 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 Bidirectional has 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 itExample
a record's lookup to its parentTargetFilteredByEntitytasks.project, teams.club
a junction's resource/parent side (descend from the protected record)EntityFilteredByTargetprojectMembers.project, clubMembers.club
a junction's user side or role sideTargetFilteredByEntityprojectMembers.user, projectMembers.role
a link/join table's two sidesone ↓, one ↑ — never both the same, never both BidirectionaltournamentClubs.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.organization to the org, then to the org's members.
  • Project members may see the parent org. Protecting organizations, PathFinder walks projects.organization into 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.)

Re-entry deliberately over-grants — so mean it

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:

  1. A way into the junction starting from the current user.
  2. A way out of the junction toward the resource being protected.
  3. A role lookup so the access rules' roleNames can 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 role OrgCreator means "may create projects anywhere in this organization."
  • projects — the protected resource, with a required security-policy lookup organization → organizations.
  • projectMembers { project, user, role } — grants a per-record capability. A row with role ProjectEditor means "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

  • ReadGET is satisfied at the token gate (_AUTHENTICATED_USER); any logged-in user reads.
  • CreatePOST sends organization: { id }, and because that lookup is required it can't be omitted (see Creating rows (POST)). The caller's organizationMembers{OrgCreator} path reaches that organization carrying OrgCreator, which the POST rule names → create allowed. This is an org-wide capability: it works for any project in the org.
  • EditPATCH requires a path to the project carrying a role the PATCH rule names (ProjectEditor or OrgAdmin). The caller's OrgCreator row carries OrgCreator, which is not in the PATCH rule, so it doesn't help. The only path that reaches the project with ProjectEditor is projectMembers{ProjectEditor} → project, which exists only for projects the user belongs to → per-record edit.
  • Admin — an organizationMembers{OrgAdmin} row carries OrgAdmin, named in every write rule, so it resolves for every project in the org.
The split is the whole trick

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:

  1. The system identifies security policy properties on the collection
  2. It traces relationship paths from the user to the requested record
  3. 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, and DELETE alike, the caller needs at least one security-policy path to the record to resolve. The paths are combined with OR, 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:

  1. 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 gets 403 on a POST /players whose rule names TeamLeaderEdit: 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.
  2. 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.
  3. 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 POST a 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).
  4. _AUTHENTICATED_USER in 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_USER on POST does 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 a TournamentOrganizer of a tournament may add to it — name that role on the POST access rule and require the gating lookup. (This is rule 4 of the write checks, applied to create.)
  • A POST body carries at most two security-policy-checked lookups. A third gated lookup in one body fails with a generic Operation 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

  1. Go to SecuritySecurity Policy
  2. Select a user to impersonate
  3. 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

IndicatorMeaning
GreenAccess allowed
RedAccess denied
Path shownThe relationship chain granting access

Example: Project-Based Access

Consider a project management system:

Collections:

  • users — Application users
  • projects — Projects
  • projectMembers — Links users to projects with a role field
  • tasks — Tasks with a project lookup field

Security Policy Setup:

  1. Enable security policy on tasks.project
  2. Enable security policy on projectMembers.user and projectMembers.project

Result:

  • Users can only see tasks in projects where they are members
  • Access is determined by the projectMembers join 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:

  1. A matching role from the access rules AND
  2. 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.

note

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 OrgAdmin row 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 user and parent lookups, no role) — 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 403 or 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 directionBidirectional/ 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