Permission Evaluation
Table of Contents
- Overview
- Route Permission Evaluation
- Model Permission Evaluation
- Evaluation Examples
- Enforcement Mechanics
Overview
This document describes how SAGA evaluates permissions when multiple rules interact. For the basics of permission structure, roles, and HTTP resources, see Access Control.
Permission evaluation has two distinct layers:
- Route permissions (
/routes/...) - evaluated as a simple allow/deny check against the request path and HTTP method. - Model permissions (
/models/...) - evaluated with field-level granularity, optional document filters, and per-document field redaction.
The core principle across both layers: deny wins over allow. When both an allow and a deny rule match, the deny takes effect.
Route Permission Evaluation
Path Matching
A permission path is split on / and compared segment by segment against the request path. A * segment in a non-trailing position matches any single path segment. A trailing * matches the parent path and all deeper subpaths.
| Permission path | Request path | Match? |
|---|---|---|
/routes/bots |
/routes/bots |
Yes (exact match) |
/routes/bots |
/routes/bots/123 |
No (exact match only, no trailing wildcard) |
/routes/bots/* |
/routes/bots/123 |
Yes (trailing wildcard) |
/routes/bots/* |
/routes/bots/123/properties |
Yes (trailing wildcard matches all depth) |
/routes/bots/* |
/routes/bots |
Yes (trailing wildcard matches parent) |
/routes/bots |
/routes/users |
No (different root segment) |
/routes/users/*/properties |
/routes/users/123/properties |
Yes (middle * matches one segment) |
/routes/users/*/properties |
/routes/users/123/properties/foo |
No (no trailing wildcard) |
/routes/users/123 |
/routes/users/456 |
No (specific ID mismatch) |
/* |
/routes/anything |
Yes (global wildcard) |
A permission without a trailing * matches only the exact path. The permission /routes/bots with action read grants access to /routes/bots only.
A permission with a trailing * matches the parent path and all deeper paths. The permission /routes/bots/* with action read grants access to /routes/bots, /routes/bots/123, /routes/bots/123/properties, etc.
The same rules apply to deny permissions — a deny without a trailing * blocks only the exact path, while a deny with a trailing * blocks the parent path and all subpaths.
Allow and Deny Precedence
Permissions are evaluated in order. The result follows these rules:
- If any deny rule matches the path and action, access is denied immediately.
- If at least one allow rule matches, access is granted.
- If no rules match at all, access is denied (default deny).
[
{"path": "/routes/bots/*", "action": "*", "allow": true},
{"path": "/routes/bots/SECRET_ID", "action": "*", "allow": false}
]
GET /routes/bots- allowed (trailing wildcard in first rule matches the parent path)GET /routes/bots/123- allowed (first rule matches via trailing wildcard, second doesn't)GET /routes/bots/SECRET_ID- denied (second rule matches and deny wins)
Non-Overlapping Permissions
When a permission path does not overlap with the request path at the first segment, it is skipped entirely. A /routes/bots permission has no effect on /routes/users requests.
Model Permission Evaluation
Model permissions control which documents and fields a caller can access. They operate at three levels:
- Model-level - can the caller interact with this model at all?
- Document-level - which documents can the caller see/modify? (filters)
- Field-level - which fields on those documents are visible/writable?
Document Filters
When a permission includes a filter, only documents matching that MongoDB query are accessible. Multiple allow permissions with different filters combine with OR semantics (union):
[
{"path": "/models/bots/*", "action": "read", "allow": true, "filter": {"tags": "npc"}},
{"path": "/models/bots/*", "action": "read", "allow": true, "filter": {"tags": "enemy"}}
]
This grants read access to bots tagged npc OR enemy. The effective filter becomes:
{"$or": [{"tags": "npc"}, {"tags": "enemy"}]}
If any matching permission has no filter, access is unrestricted (all documents visible):
[
{"path": "/models/bots/*", "action": "read", "allow": true, "filter": {"tags": "npc"}},
{"path": "/models/bots/*", "action": "read", "allow": true}
]
The second permission has no filter, so the filter from the first is irrelevant - all bots are readable.
If no permissions match the model at all, no documents are accessible.
Field-Level Access
When permissions specify individual fields instead of *, only those fields are returned in reads or allowed in writes.
[
{"path": "/models/users/username", "action": "read", "allow": true},
{"path": "/models/users/email", "action": "read", "allow": true}
]
Reads on this model will only return _id, username, and email. The _id field is always included.
For writes, attempting to set a field not covered by any write permission is rejected with an error.
Field-Level Filters
Filters can be combined with field-level permissions to make individual fields conditionally visible depending on the document's contents.
[
{"path": "/models/users/email", "action": "read", "allow": true},
{"path": "/models/users/username", "action": "read", "allow": true, "filter": {"public_profile": true}}
]
This means:
emailis readable on all user documentsusernameis readable only on documents wherepublic_profileistrue
For a user with {"email": "a@test.com", "username": "alice", "public_profile": true}, both fields are returned.
For a user with {"email": "b@test.com", "username": "bob", "public_profile": false}, only email is returned. The username field is stripped from the response.
Deny Rules with Filters
Deny rules (allow: false) with filters create conditional field redaction. The field is stripped only from documents matching the deny filter.
[
{"path": "/models/users/username", "action": "read", "allow": true},
{"path": "/models/users/username", "action": "read", "allow": false, "filter": {"test_data": true}}
]
- Documents where
test_dataisfalse-usernamevisible (allow matches, deny doesn't) - Documents where
test_dataistrue-usernamestripped (deny matches and wins)
When both an allow filter and a deny filter match the same document, deny wins:
[
{"path": "/models/users/email", "action": "read", "allow": true},
{"path": "/models/users/username", "action": "read", "allow": true, "filter": {"username": {"$regex": "^Admin"}}},
{"path": "/models/users/username", "action": "read", "allow": false, "filter": {"suspended": true}}
]
For a user {"username": "AdminAlice", "suspended": true}:
- The allow filter matches (username starts with "Admin")
- The deny filter also matches (suspended is true)
- Result:
usernameis stripped because deny wins
For a user {"username": "AdminBob", "suspended": false}:
- The allow filter matches
- The deny filter does not match
- Result:
usernameis visible
Wildcard Allow with Deny Rules
A wildcard allow (/models/users/*) combined with field-specific deny rules creates a "show everything except" pattern.
[
{"path": "/models/users/*", "action": "read", "allow": true},
{"path": "/models/users/hash", "action": "read", "allow": false},
{"path": "/models/users/salt", "action": "read", "allow": false}
]
All fields are readable except hash and salt, which are always stripped.
This also works with deny filters for conditional redaction:
[
{"path": "/models/users/*", "action": "read", "allow": true},
{"path": "/models/users/email", "action": "read", "allow": false, "filter": {"email_private": true}}
]
All fields are readable. The email field is additionally stripped from documents where email_private is true.
Evaluation Examples
Self-Only Access
A user can only read and write their own user document:
[
{"path": "/models/users/*", "action": "*", "allow": true, "filter": {"_id": "auth_id"}}
]
auth_id is replaced with the authenticated user's ID at evaluation time. All read queries are scoped to {"_id": "USER_ID"}, and all writes/deletes target only the user's own document.
Conditional Field Visibility
Show location only for users who have opted in to location sharing:
[
{"path": "/models/users/*", "action": "read", "allow": true},
{"path": "/models/users/location", "action": "read", "allow": false, "filter": {"share_location": {"$ne": true}}}
]
Users with share_location: true have their location visible. All others have it stripped.
Deny Overrides Allow
A role grants broad access, but a second role restricts a specific resource:
[
{"path": "/models/bots/*", "action": "read", "allow": true},
{"path": "/models/bots/internal_state", "action": "read", "allow": false}
]
Scripts can read all bot fields except internal_state.
Wildcard with Selective Deny
Grant full read access but redact sensitive fields from non-owned documents:
[
{"path": "/models/users/*", "action": "read", "allow": true},
{"path": "/models/users/email", "action": "read", "allow": false, "filter": {"_id": {"$ne": "auth_id"}}},
{"path": "/models/users/hash", "action": "read", "allow": false},
{"path": "/models/users/salt", "action": "read", "allow": false}
]
hashandsaltare always hiddenemailis hidden on other users' documents but visible on the caller's own document- All other fields are visible on all documents
Multiple Filters Combine as OR
Two permissions with different document filters grant access to the union of matching documents:
[
{"path": "/models/bots/*", "action": "write", "allow": true, "filter": {"owner": "auth_id"}},
{"path": "/models/bots/*", "action": "write", "allow": true, "filter": {"team": "engineering"}}
]
The caller can write to bots they own or bots on the engineering team. The effective filter becomes:
{"$or": [{"owner": "USER_ID"}, {"team": "engineering"}]}
Enforcement Mechanics
Understanding the enforcement order helps when designing complex permission rules.
Read Enforcement
When a read operation is performed:
- Permission check - does the caller have any matching allow permission for this model and action? If no permissions match at all, the operation is rejected.
- Document filtering - all matching allow permission filters are combined (OR). Documents outside the combined filter are not returned.
- Field restriction - when permissions specify individual fields (not
*), only those fields are returned. - Per-document field redaction - field-level rules with filters are evaluated against each returned document:
- If any deny rule matches: the field is removed from the document.
- If any allow rule matches and no deny matches: the field is kept.
- If no rules match the field: it is removed (unless a wildcard allow exists, in which case it is kept).
_idand__vare always preserved.
Deny filters can reference fields that are not otherwise readable. For example, a deny filter {"suspended": true} works correctly even if suspended itself is not in the readable field set, because the filter is evaluated against the complete document before any fields are removed.
Write Enforcement
When a write operation is performed:
- Field validation - every field in the write payload is checked against the writable field set. If any field lacks write permission, the operation is rejected.
- Document filtering - for update and delete operations, the document filter is applied so only permitted documents are modified.
- Role assignment check - if the write payload modifies a
rolesfield (directly or via$set,$push,$addToSet,$pull,$pullAll), each role ID is checked against/roles/{id}/assignwrite permission. This prevents privilege escalation through role assignment.