Skip to main content

Security Policies

Security policies provide row-level access control based on data relationships. They allow you to restrict access to records based on how they relate to the current user.

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.

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

A role can also be enforced inside the data filter. 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.

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 (↓).

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 (carrying the required role where that path gates one). 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.

Tip: If a GET succeeds but a PATCH/PUT on the same record returns a 403/404, suspect a lookup in your write body pointing at a target the caller can't reach — not the record's own path, which is the same any-path check GET uses. A common case is re-pointing a gating lookup (e.g. system / organization) to a row the caller isn't a member of. Use the PathFinder to check which path is failing.

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.

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 with a createdBy field pointing to users
  • 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 two below account for most "why isn't this working?" 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 trigger, 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.

Best Practices

  • Keep paths short — Simpler paths are easier to understand and debug
  • 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