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 receivesession_token:ormaster: trueraises 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$matchstage 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
_rpermdoes not include any of the resolution's permission strings. -
.resolve!(options, method_name:) ⇒ Resolution
Resolve the auth-related kwargs (
:session_token,:master) offoptionsand 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
_rpermallow-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 |