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

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)


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.

Parameters:

Returns:

  • (Hash, nil)

    a ‘$match` pipeline stage, or `nil`.



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



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



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!(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)
  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.permission_strings,
      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.permission_strings,
    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.

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.



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.

Parameters:

Returns:



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.

Parameters:

Returns:



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