Module: Parse::ACLScope
- Defined in:
- lib/parse/acl_scope.rb
Overview
Shared identity-resolution helper for query paths that simulate Parse Server’s row-level ACL enforcement client-side because they bypass Parse Server entirely.
The mongo-direct entry points (‘Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#results_direct`, `#count_direct`) talk to MongoDB through a connection authenticated by the URI configured in `Parse::MongoDB.configure`. From MongoDB’s perspective that connection has full access — ‘_rperm` is just another field, not a security boundary. The SDK is therefore the only layer enforcing the row-level ACL that Parse Server would apply on a REST find. ACLScope produces the inputs that injection needs: the `_rperm` permission-string set for a session (`[“*”, userObjectId, “role:Admin”, …]`), so callers can prepend a `$match` stage built via Parse::ACL.read_predicate.
Atlas Search uses the same pattern through ‘Parse::AtlasSearch::Session`; this module reuses that resolver (token → user_id → role expansion + caching) and adds a path-agnostic kwarg-popping front door so every mongo-direct entry point can speak the same auth vocabulary.
Defined Under Namespace
Classes: ACLRequired, Resolution
Class Attribute Summary collapse
-
.require_session_token ⇒ Boolean
When ‘true`, every call to ACLScope.resolve! that did NOT receive `session_token:` or `master: true` raises ACLRequired instead of falling through to the public-only banner-and-continue path.
Class Method Summary collapse
-
.match_stage_for(resolution) ⇒ Hash?
Compile the ‘_rperm` `$match` stage to prepend to a mongo-direct pipeline.
-
.redact_results!(documents, resolution) ⇒ Array<Hash>
Walk the result documents and redact every embedded sub-document whose stored ‘_rperm` does not include any of the resolution’s permission strings.
-
.resolve!(options, method_name:) ⇒ Resolution
Resolve the auth-related kwargs (‘:session_token`, `:master`) off `options` and return a Resolution describing which mode the call will run in.
-
.resolve_for_role(role, strict_role: false) ⇒ Resolution
Build a Resolution for a role-only scope: no user_id, just the role’s name plus every role it transitively inherits from (parent-role chain).
-
.resolve_for_user(user) ⇒ Resolution
Build a Resolution directly from a pre-resolved User pointer (or User instance).
-
.rewrite_pipeline(pipeline, resolution) ⇒ Array<Hash>
Walk an aggregation pipeline and rewrite every join-style stage so its sub-results are filtered against the resolution’s ‘_rperm` allow-set.
Class Attribute Details
.require_session_token ⇒ Boolean
When ‘true`, every call to resolve! that did NOT receive `session_token:` or `master: true` raises ACLRequired instead of falling through to the public-only banner-and-continue path. Mirror of `Parse::AtlasSearch.require_session_token`. Default is `false` to preserve backwards compatibility with mongo-direct callsites that pre-date the session-token kwarg.
82 83 84 |
# File 'lib/parse/acl_scope.rb', line 82 def require_session_token @require_session_token end |
Class Method Details
.match_stage_for(resolution) ⇒ Hash?
Compile the ‘_rperm` `$match` stage to prepend to a mongo-direct pipeline. Returns `nil` on the master path (no injection), for `nil` resolutions (defensive — should never happen in normal use), and for legacy (non-strict-role) resolutions with an empty/nil perm set. Strict-role resolutions FAIL CLOSED: even an empty perm set still emits a $match (`$in: []` plus the `$exists: false` branch) so the caller cannot accidentally see every row. The shape comes straight from Parse::ACL.read_predicate and matches what Parse::AtlasSearch injects on its `$search` pipelines.
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/parse/acl_scope.rb', line 191 def match_stage_for(resolution) return nil if resolution.nil? || resolution.master? perms = resolution. strict = resolution.respond_to?(:strict_role?) && resolution.strict_role? # Legacy (non-strict) behavior: an empty/nil perm set means # nothing to inject, fall through with no $match. Strict-role # mode FAIL-CLOSED: even an empty resolved-role set must still # produce a predicate so the caller doesn't accidentally see # every row. With `include_public: false` and empty perms, the # predicate becomes `{$or: [{_rperm: {$in: []}}, {_rperm: # {$exists: false}}]}` — only no-_rperm rows pass, which is # the conservative interpretation. return nil if !strict && (perms.nil? || perms.empty?) perms = [] if perms.nil? # `strict_role?` (defaults to `false`) suppresses the implicit # `"*"` append that Parse::ACL.read_predicate normally performs. # Used by role-scoped resolutions that opted into strict mode # so a service-account-style query for, say, `acl_role: # "scope:reporting"` does NOT see every public-readable row in # the queried class. { "$match" => Parse::ACL.read_predicate(perms, include_public: !strict) } end |
.redact_results!(documents, resolution) ⇒ Array<Hash>
Walk the result documents and redact every embedded sub-document whose stored ‘_rperm` does not include any of the resolution’s permission strings. This is the second enforcement layer — the pipeline rewriter catches what it can reach, this catches what leaked through (raw ‘:object` columns embedding pointer-shaped hashes, `$lookup` stages the rewriter couldn’t rewrite, etc.).
Redaction is in-place tree mutation. Each embedded sub-document carrying ‘_rperm` is either kept as-is, replaced with `nil` (when value is a scalar field), or removed from its containing Array (when value is an array element). Sub-documents without `_rperm` are treated as public-readable and pass through. The top-level documents are NOT redacted by this walk — the top-level `$match` injection already filtered those.
295 296 297 298 299 300 301 302 303 304 |
# File 'lib/parse/acl_scope.rb', line 295 def redact_results!(documents, resolution) return documents if documents.nil? || documents.empty? return documents if resolution.nil? || resolution.master? perms = resolution. return documents if perms.nil? || perms.empty? perms_set = perms.is_a?(Set) ? perms : perms.to_set documents.each { |doc| redact_subdocs!(doc, perms_set, top: true) } documents end |
.resolve!(options, method_name:) ⇒ Resolution
Resolve the auth-related kwargs (‘:session_token`, `:master`) off `options` and return a Resolution describing which mode the call will run in. **Mutates `options`** by `delete`-ing the auth kwargs so the caller can forward the remaining hash to its underlying transport without leaking them.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/parse/acl_scope.rb', line 100 def resolve!(, method_name:) session_token = .delete(:session_token) master = .delete(:master) acl_user = .delete(:acl_user) acl_role = .delete(:acl_role) # `strict_role:` is only meaningful for the `acl_role:` branch # below — it tells `resolve_for_role` to suppress the implicit # `"*"` grant in the resulting permission set. We `delete` it # unconditionally to avoid forwarding it to the underlying # transport, and silently ignore on the non-role paths # (session-token / acl_user / master / public) where it has no # meaning. Defaults to `false` so the auto-public grant remains # the legacy behavior. strict_role = .delete(:strict_role) == true provided = [session_token, master == true ? master : nil, acl_user, acl_role].compact if provided.length > 1 raise ArgumentError, "Parse::ACLScope.#{method_name}: cannot pass more than one of " \ "session_token:, master: true, acl_user:, or acl_role:. Pick one." end if acl_user # Pre-resolved User-pointer path used by # Parse::Query#scope_to_user. Mirrors the session-token path # but skips the /users/me round-trip; role expansion still # runs via Parse::Role.all_for_user. return resolve_for_user(acl_user) end if acl_role # Role-only path used by Parse::Query#scope_to_role. # Simulates "what would a user holding this role see" # without minting a session token or knowing a specific # user — useful for service-account-style queries (cron # jobs, internal reporting, agentic tooling) where the # caller wants role-grade access without a per-user # identity. Parent-role inheritance applies (passing # "scope:admin" includes any role "scope:admin" inherits # from). return resolve_for_role(acl_role, strict_role: strict_role) end if session_token require_atlas_session! resolved = Parse::AtlasSearch::Session.resolve(session_token) return Resolution.new( mode: :session, permission_strings: resolved., user_id: resolved.user_id, session: resolved, ) end if master == true return Resolution.new(mode: :master, permission_strings: nil, user_id: nil, session: nil) end if @require_session_token == true raise ACLRequired, "Parse::#{method_name} requires session_token: or master: true. " \ "Mongo-direct queries bypass Parse Server's ACL enforcement, so " \ "the SDK refuses to run them without an explicit identity or an " \ "explicit master-mode opt-in. Flip Parse::ACLScope.require_session_token " \ "= false to allow public-only fallback." end warn_no_acl_context_once!(method_name) require_atlas_session! anonymous = Parse::AtlasSearch::Session::Resolved.new(nil, Set.new) Resolution.new( mode: :public, permission_strings: anonymous., user_id: nil, session: anonymous, ) end |
.resolve_for_role(role, strict_role: false) ⇒ Resolution
Build a Resolution for a role-only scope: no user_id, just the role’s name plus every role it transitively inherits from (parent-role chain). Useful for service-account-style queries (“see as if a user with the ‘admin` role were asking”) without minting a session token or knowing a specific user.
The inheritance walk uses Role#all_parent_role_names, which is the same upward traversal Role.all_for_user uses to compose user permissions — so the perms set is consistent with what a real user holding the role would see.
Accepts either a Role instance or a role name String (with or without the ‘“role:”` prefix). A String input triggers a `_Role.find_by(name:)` lookup and raises ArgumentError when the role doesn’t exist.
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 |
# File 'lib/parse/acl_scope.rb', line 638 def resolve_for_role(role, strict_role: false) require_relative "model/classes/role" role_obj = case role when Parse::Role then role when String, Symbol name = role.to_s.sub(/\Arole:/, "") raise ArgumentError, "[Parse::ACLScope] role name must be non-empty." if name.empty? found = Parse::Role.first(name: name) raise ArgumentError, "[Parse::ACLScope] no _Role found with name #{name.inspect}." if found.nil? found else raise ArgumentError, "[Parse::ACLScope] resolve_for_role expects Parse::Role or String." end names = begin role_obj.all_parent_role_names(max_depth: 10) rescue StandardError Set.new([role_obj.name].compact) end # In strict mode the permission set omits the implicit `"*"` # so the resulting predicate only matches rows whose `_rperm` # contains one of the resolved role names (plus the standard # `_rperm: {$exists: false}` branch — see Resolution#strict_role # docs). In legacy mode `"*"` is included so role-scoped # callers also see every public-readable row. perms = strict_role ? [] : ["*"] names.each { |n| perms << "role:#{n}" if n && !n.empty? } perms.uniq! require_atlas_session! Resolution.new( mode: :session, permission_strings: perms, user_id: nil, session: Parse::AtlasSearch::Session::Resolved.new(nil, names), strict_role: strict_role, ) end |
.resolve_for_user(user) ⇒ Resolution
Build a Resolution directly from a pre-resolved User pointer (or User instance). Role-expansion runs through Role.all_for_user — same path the session-token resolver uses — but the token-to-user step is skipped because the caller already has the user. Used by Query#scope_to_user and any external code that wants to feed a User directly into the ACL simulation without going through a session token.
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 |
# File 'lib/parse/acl_scope.rb', line 557 def resolve_for_user(user) # SECURITY: className must be `_User` (or the legacy `User` # alias). Without this check, any duck-typed object exposing # `#id` — including a `Parse::Pointer` to a foreign class # such as `Order` or `AuditLog` — would be accepted, and its # raw `user.id` would land verbatim in `perms` below. Parse # objectIds are 10-char alphanumerics with no class # segregation, so a foreign-class pointer whose objectId # happened to equal a real `_User` objectId would simulate # that user for ACL purposes (id-collision impersonation). # The two acceptable shapes are a `Parse::User` instance or # a `Parse::Pointer` whose `parse_class` is `_User`/`User`. valid_user_class = user.is_a?(Parse::User) || (user.is_a?(Parse::Pointer) && [Parse::Model::CLASS_USER, "User"].include?(user.parse_class)) unless valid_user_class got_class = user.respond_to?(:parse_class) ? user.parse_class.inspect : "<no className>" raise ArgumentError, "Parse::ACLScope.resolve_for_user requires a Parse::User or a " \ "Pointer with className '_User'; got #{user.class}/#{got_class}. " \ "Refusing - non-_User pointer ids would land in the ACL " \ "permission_strings and grant cross-class id-collision " \ "impersonation." end unless user.respond_to?(:id) && user.id.is_a?(String) && !user.id.empty? raise ArgumentError, "Parse::ACLScope.resolve_for_user expects a Parse::User or " \ "User Pointer with a non-empty objectId." end role_names = begin require_relative "model/classes/role" Parse::Role.all_for_user(user, max_depth: 10) rescue StandardError Set.new end perms = ["*", user.id] role_names.each { |name| perms << "role:#{name}" if name && !name.empty? } perms.uniq! require_atlas_session! Resolution.new( mode: :session, permission_strings: perms, user_id: user.id, session: Parse::AtlasSearch::Session::Resolved.new(user.id, role_names), ) end |
.rewrite_pipeline(pipeline, resolution) ⇒ Array<Hash>
Walk an aggregation pipeline and rewrite every join-style stage so its sub-results are filtered against the resolution’s ‘_rperm` allow-set. Without this rewriting, a top-level `$match` injection only filters the queried collection’s rows; any rows pulled in via ‘$lookup`, `$unionWith`, or `$graphLookup` are visible to the requesting session regardless of their stored ACL — a silent SDK-side ACL bypass on included/joined data.
The rewriter handles:
* **`$lookup`** — both simple (`from`/`localField`/`foreignField`)
and pipeline forms. Simple form is upgraded to the
combined form (Mongo 5.0+) by appending an `_rperm` match
to its `pipeline`. Pipeline form prepends the same stage.
* **`$unionWith`** — the unioned collection's rows are
filtered by prepending an `_rperm` match to its `pipeline`
(constructing one if absent).
* **`$graphLookup`** — appends an `_rperm` match by way of
a `restrictSearchWithMatch` clause (MongoDB's documented
mechanism for filtering traversed rows).
* **`$facet`** — recursive: each facet branch is itself a
pipeline; rewrite every branch independently.
Returns a NEW Array; the input pipeline is not mutated. Master and nil-resolution pass through unchanged. Legacy (non-strict-role) empty-perms resolutions also pass through. Strict-role empty-perms FAIL CLOSED (same contract as match_stage_for): the ACL match is still injected so joined collections are filtered, not exposed.
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/parse/acl_scope.rb', line 248 def rewrite_pipeline(pipeline, resolution) return pipeline if pipeline.nil? || pipeline.empty? return pipeline if resolution.nil? || resolution.master? perms = resolution. strict = resolution.respond_to?(:strict_role?) && resolution.strict_role? # Same fail-closed contract as {.match_stage_for}: legacy mode # passes through unmodified when perms are empty, strict-role # mode still emits the conservative predicate. return pipeline if !strict && (perms.nil? || perms.empty?) perms = [] if perms.nil? # Mirror the `strict_role?` handling in {.match_stage_for} so # the predicate prepended to $lookup / $unionWith / $graphLookup # / $facet sub-pipelines also suppresses the implicit `"*"` # grant for strict-role resolutions. acl_match = { "$match" => Parse::ACL.read_predicate(perms, include_public: !strict) } # Pass `perms` alongside `acl_match` so every join-style stage # rewriter can fire {Parse::CLPScope.permits?} on its joined # target class. Without this gate, a scoped session that lacked # `find` on `_User` could still surface `_User` rows by reading # them through `$lookup.from: "_User"` inside an aggregation # rooted on a public class. The agent dispatcher already had # this gate; the rewriter is the shared SDK-level layer so the # mongo-direct path enforces it independent of whether an agent # made the call. pipeline.map { |stage| rewrite_stage(stage, acl_match, perms) } end |