Saga Documentation 0.9.610-1

Permission Evaluation

Table of Contents

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:

  1. Route permissions (/routes/...) - evaluated as a simple allow/deny check against the request path and HTTP method.
  2. 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:

  1. If any deny rule matches the path and action, access is denied immediately.
  2. If at least one allow rule matches, access is granted.
  3. 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:

  1. Model-level - can the caller interact with this model at all?
  2. Document-level - which documents can the caller see/modify? (filters)
  3. 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:

  • email is readable on all user documents
  • username is readable only on documents where public_profile is true

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_data is false - username visible (allow matches, deny doesn't)
  • Documents where test_data is true - username stripped (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: username is stripped because deny wins

For a user {"username": "AdminBob", "suspended": false}:

  • The allow filter matches
  • The deny filter does not match
  • Result: username is 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}
]
  • hash and salt are always hidden
  • email is 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:

  1. 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.
  2. Document filtering - all matching allow permission filters are combined (OR). Documents outside the combined filter are not returned.
  3. Field restriction - when permissions specify individual fields (not *), only those fields are returned.
  4. 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).
    • _id and __v are 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:

  1. 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.
  2. Document filtering - for update and delete operations, the document filter is applied so only permitted documents are modified.
  3. Role assignment check - if the write payload modifies a roles field (directly or via $set, $push, $addToSet, $pull, $pullAll), each role ID is checked against /roles/{id}/assign write permission. This prevents privilege escalation through role assignment.