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:
- 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.
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:
- 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 (↓).
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 (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
GETsucceeds but aPATCH/PUTon 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 checkGETuses. 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_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.
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— Projects with acreatedByfield pointing to usersprojectMembers— 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 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
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 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