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.

Identity and role resolution live in Authorization, which owns the token → user_id → role-expansion pipeline and its caches. This module adds a path-agnostic kwarg-popping front door on top, so every mongo-direct entry point speaks the same auth vocabulary. Atlas Search is a peer consumer of the same resolver, not its home: Parse::Query#results_direct runs no $search at all and must not depend on the Atlas Search namespace to decide who the caller is.

Defined Under Namespace

Classes: ACLRequired, Resolution

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.require_session_tokenBoolean

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.

Returns:

  • (Boolean)


110
111
112
# File 'lib/parse/acl_scope.rb', line 110

def require_session_token
  @require_session_token
end

Class Method Details

.client_of(resolution) ⇒ Parse::Client?

The client a resolution was produced by, or nil when it cannot say.

Deliberately tolerant of objects that are resolution-shaped without being a Resolution: test doubles and any caller-supplied stand-in. A resolution that cannot name its client IS an unidentified caller, which is a case MongoDB.verify_client! models explicitly (it allows the single-application case and refuses the ambiguous one). Crashing on the missing method instead would convert an unknown into a NoMethodError far from its cause.

Parameters:

Returns:



96
97
98
99
100
# File 'lib/parse/acl_scope.rb', line 96

def self.client_of(resolution)
  return nil if resolution.nil?
  return nil unless resolution.respond_to?(:client)
  resolution.client
end

.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.

Parameters:

Returns:

  • (Hash, nil)

    a $match pipeline stage, or nil.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/parse/acl_scope.rb', line 222

def match_stage_for(resolution)
  return nil if resolution.nil? || resolution.master?
  perms = resolution.permission_strings
  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.

Parameters:

Returns:

  • (Array<Hash>)

    the same Array, with embedded sub-docs redacted in place.



326
327
328
329
330
331
332
333
334
335
# File 'lib/parse/acl_scope.rb', line 326

def redact_results!(documents, resolution)
  return documents if documents.nil? || documents.empty?
  return documents if resolution.nil? || resolution.master?
  perms = resolution.permission_strings
  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.

Parameters:

  • options (Hash)

    kwargs Hash the caller will forward; :session_token and :master are removed in place.

  • method_name (Symbol)

    for error messages — typically the public entry-point name (:aggregate, :geo_near, :results_direct).

Returns:

Raises:

  • (ArgumentError)

    when both session_token: and master: true are supplied — they are mutually exclusive.

  • (ACLRequired)

    when neither is supplied and require_session_token is true.



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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/parse/acl_scope.rb', line 128

def resolve!(options, method_name:)
  session_token = options.delete(:session_token)
  master = options.delete(:master)
  acl_user = options.delete(:acl_user)
  acl_role = options.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 = options.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, client: authorization_client(options))
  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,
                                      client: authorization_client(options))
  end

  if session_token
    context = authorization_for(options)
    resolved = context.resolve(session_token)
    return Resolution.new(
             mode: :session,
             permission_strings: resolved.permission_strings,
             user_id: resolved.user_id,
             session: resolved,
             client: context.client,
           )
  end

  if master == true
    return Resolution.new(mode: :master, permission_strings: nil, user_id: nil, session: nil,
                          client: authorization_client(options))
  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)
  anonymous = Parse::Authorization::Resolved.new(nil, Set.new)
  Resolution.new(
    mode: :public,
    permission_strings: anonymous.permission_strings,
    user_id: nil,
    session: anonymous,
    client: authorization_client(options),
  )
end

.resolve_for_role(role, strict_role: false, client: nil) ⇒ 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.

Parameters:

  • role (Parse::Role, String)
  • strict_role (Boolean) (defaults to: false)

    when true, the returned Resolution signals downstream predicate construction to suppress the implicit "*" grant. The resolved permission set drops "*" (so a role-scoped query does NOT see every public-readable row in the queried class). Defaults to false for backwards compatibility — legacy callers that used acl_role: continue to see public rows as before. Note: even in strict mode, rows with no _rperm field continue to match because Parse Server treats them as public-default; see match_stage_for for the precise predicate shape.

Returns:

Raises:

  • (ArgumentError)

    when the role cannot be resolved.



680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
# File 'lib/parse/acl_scope.rb', line 680

def resolve_for_role(role, strict_role: false, client: nil)
  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 = role_lookup_by_name(name, client: client)
      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, client: client)
    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!

  Resolution.new(
    mode: :session,
    permission_strings: perms,
    user_id: nil,
    session: Parse::Authorization::Resolved.new(nil, names),
    strict_role: strict_role,
    client: client,
  )
end

.resolve_for_user(user, client: nil) ⇒ 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.

Parameters:

Returns:



596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
# File 'lib/parse/acl_scope.rb', line 596

def resolve_for_user(user, client: nil)
  # 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"
      # `client:` matters here for the same reason it does on the
      # session-token path: resolving the user against one application
      # and then walking the role graph on another would grant that
      # user the other application's roles by name.
      Parse::Role.all_for_user(user, max_depth: 10, client: client)
    rescue StandardError
      Set.new
    end

  perms = ["*", user.id]
  role_names.each { |name| perms << "role:#{name}" if name && !name.empty? }
  perms.uniq!

  Resolution.new(
    mode: :session,
    permission_strings: perms,
    user_id: user.id,
    session: Parse::Authorization::Resolved.new(user.id, role_names),
    client: client,
  )
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.

Parameters:

Returns:



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/parse/acl_scope.rb', line 279

def rewrite_pipeline(pipeline, resolution)
  return pipeline if pipeline.nil? || pipeline.empty?
  return pipeline if resolution.nil? || resolution.master?
  perms = resolution.permission_strings
  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