parse-stack-next 5.6.0 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
data/lib/parse/mongodb.rb CHANGED
@@ -85,6 +85,11 @@ module Parse
85
85
  # $accumulator, which all execute server-side JavaScript.
86
86
  class DeniedOperator < StandardError; end
87
87
 
88
+ # Raised when a mongo-direct query is authorized by one Parse client but
89
+ # this process's global MongoDB connection belongs to another. See
90
+ # {Parse::MongoDB.verify_client!}.
91
+ class ClientMismatch < StandardError; end
92
+
88
93
  # Error raised when an index mutation primitive is invoked but the
89
94
  # writer connection has not been configured via {.configure_writer}.
90
95
  class WriterNotConfigured < StandardError; end
@@ -269,9 +274,193 @@ module Parse
269
274
  @enabled = enabled
270
275
  @database = database || extract_database_from_uri(resolved)
271
276
  @client = nil # Reset client on reconfigure
277
+ # Bind this connection to whichever Parse application is configured
278
+ # now. See {.verify_client!} for why.
279
+ BINDING_MUTEX.synchronize do
280
+ @bound_app_scope = current_app_scope
281
+ @observed_app_scopes = nil
282
+ note_observed_scope_unlocked(@bound_app_scope)
283
+ end
272
284
  warn_if_writeable_role! if verify_role && enabled
273
285
  end
274
286
 
287
+ # @return [String, nil] the application scope this global connection is
288
+ # bound to, captured at {.configure} time. See {.app_scope_for}.
289
+ attr_reader :bound_app_scope
290
+
291
+ # Identify a Parse application by BOTH its application id and its server
292
+ # url.
293
+ #
294
+ # The application id alone is not enough. Parse application ids are not
295
+ # globally unique: the same id is routinely reused across a staging and
296
+ # a production deployment of the same app, which is exactly the pair
297
+ # most likely to be configured in one developer process. Comparing ids
298
+ # only would let a staging client authorize a read of the production
299
+ # database and call it a match, which is the failure this guard exists
300
+ # to prevent rather than a case it may ignore.
301
+ #
302
+ # Mirrors {Parse::Cache::Keyspace}'s app scope, which derives the same
303
+ # pair for the same reason.
304
+ #
305
+ # @param client [Object, nil]
306
+ # @return [String, nil] a stable scope string, or nil when the client
307
+ # cannot be identified.
308
+ def app_scope_for(client)
309
+ return nil if client.nil?
310
+ return nil unless client.respond_to?(:application_id)
311
+ app_id = client.application_id
312
+ return nil if app_id.nil? || app_id.to_s.empty?
313
+ server = client.respond_to?(:server_url) ? client.server_url.to_s : ""
314
+ "#{app_id}\u0000#{server}"
315
+ end
316
+
317
+ # @!visibility private
318
+ # Human-readable form for error messages. The scope string joins with a
319
+ # NUL byte, which would render as garbage in a message.
320
+ def describe_scope(scope)
321
+ return "unknown" if scope.nil?
322
+ app_id, server = scope.split("\u0000", 2)
323
+ server.to_s.empty? ? app_id.inspect : "#{app_id.inspect} at #{server.inspect}"
324
+ end
325
+
326
+ # Refuse a mongo-direct query issued by a client that is not the one
327
+ # this connection was configured for.
328
+ #
329
+ # The connection is process-global: one URI, one database, one driver
330
+ # client, chosen by whoever called {.configure}. Authorization, since
331
+ # 5.7, is per-client: `client.authorization` resolves a session token
332
+ # against that client's own Parse application. Those two facts are safe
333
+ # in isolation and dangerous together. A secondary client would resolve
334
+ # its token correctly, against its own application, produce a correct
335
+ # `_rperm` allow-set for a user of that application, and then run the
336
+ # resulting pipeline against the OTHER application's database. The user
337
+ # ids and role names would be matched against rows they have nothing to
338
+ # do with, and any collision is a cross-application read.
339
+ #
340
+ # Failing closed is the only safe answer, because the alternative is
341
+ # silent and looks like a working query. The connection becomes
342
+ # client-owned in 6.0, at which point this guard is unnecessary.
343
+ #
344
+ # A caller of `nil` is UNIDENTIFIED, and is never silently upgraded to
345
+ # the default client. Substituting a default there made the
346
+ # fail-closed branch below unreachable: a call site that forgot to
347
+ # forward its client was presented as the default and passed against a
348
+ # default-bound database, which is exactly the omission this exists to
349
+ # catch.
350
+ #
351
+ # @param client [Parse::Client, nil] the client that authorized the
352
+ # call, or nil when the caller cannot say.
353
+ # @raise [Parse::MongoDB::ClientMismatch]
354
+ def verify_client!(client)
355
+ caller_scope = app_scope_for(client)
356
+ bound = binding_scope!(caller_scope)
357
+ return if bound.nil?
358
+
359
+ if caller_scope.nil?
360
+ # An unidentified caller. Where only one application has ever been
361
+ # seen this is unambiguous, which covers every single-application
362
+ # deployment. Once a SECOND has been seen it is a real ambiguity,
363
+ # and allowing it meant any call site that forgot to forward its
364
+ # client silently read whichever database happened to be bound.
365
+ # Completeness of that plumbing should not be the only thing
366
+ # standing between two applications, so this fails closed.
367
+ return if observed_scopes.size <= 1
368
+ raise ClientMismatch,
369
+ "Parse::MongoDB received a direct query with no identifiable client, in a " \
370
+ "process where more than one Parse application has been seen " \
371
+ "(#{observed_scopes.map { |sc| describe_scope(sc) }.join(", ")}). " \
372
+ "The connection is bound to #{describe_scope(bound)}. Refusing rather than " \
373
+ "guessing: pass client: through this call path so the read can be checked."
374
+ end
375
+
376
+ return if caller_scope == bound
377
+
378
+ raise ClientMismatch,
379
+ "Parse::MongoDB is bound to #{describe_scope(bound)} but this query was " \
380
+ "authorized by a client for #{describe_scope(caller_scope)}. The MongoDB " \
381
+ "connection is process-global while authorization is per-client, so running " \
382
+ "this would resolve one application's identity and then read the other " \
383
+ "application's database. Note that a matching application id is not enough: " \
384
+ "the same id is commonly reused across staging and production, so the server " \
385
+ "url is compared too. Configure a separate process for each application, " \
386
+ "or route this query through REST instead of mongo-direct."
387
+ end
388
+
389
+ # @!visibility private
390
+ # The currently configured default Parse application's scope, or nil
391
+ # when no client exists yet. Never raises: {.configure} must work in a
392
+ # process that sets up Mongo before Parse.
393
+ def current_app_scope
394
+ app_scope_for(default_client_or_nil)
395
+ end
396
+
397
+ # @!visibility private
398
+ # Guards the binding and the observed-scope set. Both are read and
399
+ # written from request threads, and a lost update to either weakens a
400
+ # security check rather than merely losing a cache entry.
401
+ BINDING_MUTEX = Mutex.new
402
+
403
+ # @!visibility private
404
+ # The application this connection belongs to, establishing it on first
405
+ # use when `configure` ran before a Parse client existed.
406
+ #
407
+ # **Ownership decides the binding, never whoever calls first.** An
408
+ # earlier version used `@bound_app_scope ||= caller_scope`, so a process
409
+ # that configured MongoDB before Parse, installed default client A, and
410
+ # then made its first direct request explicitly as B would bind the
411
+ # connection to B and read A's database without complaint. The binding
412
+ # now always comes from the DEFAULT client, whose configuration is what
413
+ # produced this connection. `caller_scope` is a fallback only for a
414
+ # process with no default client at all, where there is nothing else to
415
+ # go on and nothing to conflict with.
416
+ def binding_scope!(caller_scope)
417
+ # Read outside the lock: this can construct nothing and must not run
418
+ # arbitrary client code while holding a mutex a request path takes.
419
+ owner_scope = current_app_scope
420
+
421
+ BINDING_MUTEX.synchronize do
422
+ if @bound_app_scope.nil?
423
+ @bound_app_scope = owner_scope || caller_scope
424
+ # Record the owner as well, so a process where only a SECOND
425
+ # application ever identifies itself still counts as two.
426
+ note_observed_scope_unlocked(@bound_app_scope)
427
+ end
428
+ # Observe the current owner on EVERY call, not only while unbound.
429
+ # A process that bound to explicit B before any default client
430
+ # existed, then installed default A afterwards, would otherwise
431
+ # never see A at all: the observed set held only B, so unidentified
432
+ # callers looked unambiguous and were allowed straight through to
433
+ # B's database.
434
+ note_observed_scope_unlocked(owner_scope)
435
+ note_observed_scope_unlocked(caller_scope)
436
+ @bound_app_scope
437
+ end
438
+ end
439
+
440
+ # @!visibility private
441
+ # @return [Array<String>] application scopes this guard has seen.
442
+ def observed_scopes
443
+ BINDING_MUTEX.synchronize { (@observed_app_scopes || []).dup }
444
+ end
445
+
446
+ # @!visibility private
447
+ # Caller must hold {BINDING_MUTEX}.
448
+ def note_observed_scope_unlocked(scope)
449
+ return if scope.nil?
450
+ @observed_app_scopes ||= []
451
+ @observed_app_scopes << scope unless @observed_app_scopes.include?(scope)
452
+ end
453
+
454
+ # @!visibility private
455
+ # The default Parse client, or nil when none is configured. Never
456
+ # raises, and never constructs one as a side effect of asking.
457
+ def default_client_or_nil
458
+ return nil unless Parse::Client.client?
459
+ Parse.client
460
+ rescue StandardError
461
+ nil
462
+ end
463
+
275
464
  # @return [String, nil] the first env-var URI found, in
276
465
  # {ENV_URI_KEYS} priority order, or nil if none is set.
277
466
  def resolve_uri_from_env
@@ -383,6 +572,8 @@ module Parse
383
572
  @client = nil
384
573
  @enabled = false
385
574
  @uri = nil
575
+ @bound_app_scope = nil
576
+ @observed_app_scopes = nil
386
577
  @database = nil
387
578
  remove_instance_variable(:@gem_available) if defined?(@gem_available)
388
579
  reset_writer!
@@ -391,7 +582,24 @@ module Parse
391
582
  # Get a MongoDB collection
392
583
  # @param name [String] the collection name
393
584
  # @return [Mongo::Collection]
394
- def collection(name)
585
+ def collection(name, authorizing_client: nil)
586
+ # The last chokepoint before the database. {.aggregate} verifies the
587
+ # binding too, but not every scoped read goes through it: Atlas
588
+ # Search runs its own `$search` pipelines and hybrid vector search
589
+ # runs its own, both reaching the driver through here.
590
+ #
591
+ # `authorizing_client:` must be the client that AUTHORIZED the read.
592
+ # An earlier version compared `Parse.client` unconditionally, which
593
+ # was worse than no check at all: a caller that resolved a token
594
+ # against client B and then asked for a collection had its default
595
+ # client A compared against A's own binding, so the guard reported
596
+ # success on exactly the case it exists to catch.
597
+ # NOT `authorizing_client || default_client_or_nil`. Substituting the
598
+ # default presented a forgotten `client:` as the default client, so
599
+ # it passed against a default-bound database and the fail-closed
600
+ # branch could never fire. A missing client is an unidentified
601
+ # caller, and {.verify_client!} decides what that means.
602
+ verify_client!(authorizing_client)
395
603
  client[name]
396
604
  end
397
605
 
@@ -535,13 +743,13 @@ module Parse
535
743
  # identically-specified index was already present.
536
744
  # @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection]
537
745
  def create_index(collection_name, keys, name: nil, unique: false, sparse: false,
538
- partial_filter: nil, expire_after: nil, allow_system_classes: false)
746
+ partial_filter: nil, expire_after: nil, allow_system_classes: false)
539
747
  assert_mutations_allowed!
540
748
  assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
541
749
  spec_keys = normalize_index_keys(keys)
542
750
  existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
543
751
  if index_matches?(existing, spec_keys, name: name, unique: unique, sparse: sparse,
544
- partial_filter: partial_filter, expire_after: expire_after)
752
+ partial_filter: partial_filter, expire_after: expire_after)
545
753
  audit_writer_event(:create_index_skipped, collection_name, keys: spec_keys, name: name)
546
754
  return :exists
547
755
  end
@@ -778,10 +986,10 @@ module Parse
778
986
  # min_pool_size: 0 — keep idle pool drained when not in use.
779
987
  # The writer should be a rare-use connection.
780
988
  ::Mongo::Client.new(@writer_uri, min_pool_size: 0, max_pool_size: 2,
781
- server_selection_timeout: 10,
782
- socket_timeout: 10,
783
- connect_timeout: 5,
784
- monitoring: false)
989
+ server_selection_timeout: 10,
990
+ socket_timeout: 10,
991
+ connect_timeout: 5,
992
+ monitoring: false)
785
993
  rescue => e
786
994
  raise ConnectionError, "Failed to connect writer client: #{e.message}"
787
995
  end
@@ -945,20 +1153,27 @@ module Parse
945
1153
 
946
1154
  # @!visibility private
947
1155
  # Default BFS depth for role-graph expansion. Real-world role graphs
948
- # are 2-4 deep; 6 leaves headroom for unusual hierarchies without
1156
+ # are 2-4 deep; 10 leaves headroom for unusual hierarchies without
949
1157
  # encouraging runaway $graphLookup fan-out on pathological inputs.
950
- ROLE_GRAPH_DEFAULT_DEPTH = 6
1158
+ # Matches the `max_depth:` default on {Parse::Role.all_for_user}, so
1159
+ # the opt-in mongo fast path accepts the same depth the slow path
1160
+ # walks instead of raising ArgumentError.
1161
+ ROLE_GRAPH_DEFAULT_DEPTH = 10
951
1162
 
952
1163
  # @!visibility private
953
1164
  # Hard ceiling on accepted `max_depth:` for the role-graph helpers.
954
1165
  # Anything above raises `ArgumentError` — the helpers do not silently
955
1166
  # clamp because a caller passing 100 is a bug worth surfacing.
956
- # Lowered from 20 to 6 (matches DEFAULT_DEPTH) to prevent the helper
957
- # from being used as a `$graphLookup` DoS amplifier on pathological
958
- # role hierarchies. Real-world Parse `_Role` graphs are 2-4 deep;
959
- # callers needing more should examine why their hierarchy is so
1167
+ # Lowered from 20 to 6 to prevent the helper from being used as a
1168
+ # `$graphLookup` DoS amplifier on pathological role hierarchies, then
1169
+ # raised to 10 to match both DEFAULT_DEPTH and the `max_depth:`
1170
+ # default on {Parse::Role.all_for_user}: a ceiling below that default
1171
+ # made the opt-in fast path raise ArgumentError for any caller who
1172
+ # did not override it. Runaway traversal is separately bounded by
1173
+ # {ROLE_GRAPH_MAX_TIME_MS}. Real-world Parse `_Role` graphs are 2-4
1174
+ # deep; callers needing more should examine why their hierarchy is so
960
1175
  # deep before raising this ceiling.
961
- ROLE_GRAPH_MAX_DEPTH = 6
1176
+ ROLE_GRAPH_MAX_DEPTH = 10
962
1177
 
963
1178
  # @!visibility private
964
1179
  # Hardcoded `maxTimeMS` budget for the role-graph aggregations. Both
@@ -1046,8 +1261,15 @@ module Parse
1046
1261
  # supplied, or when both are supplied.
1047
1262
  # @raise [Parse::CLPScope::Denied] when `as:` is supplied and the
1048
1263
  # scope cannot `find` on `_Role`.
1049
- def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
1050
- authorize_role_graph_call!(:role_names_for_user, master: master, as: as)
1264
+ def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil,
1265
+ client: nil)
1266
+ # Public entry point: resolve an omitted client to the default ONCE,
1267
+ # here, and use the same value for the authorization and for the
1268
+ # collection below. Passing the raw `client:` down meant an ordinary
1269
+ # default-client call reached the sink as unidentified and was
1270
+ # rejected once a second application had been observed.
1271
+ client ||= default_client_or_nil
1272
+ authorize_role_graph_call!(:role_names_for_user, master: master, as: as, client: client)
1051
1273
  validate_role_graph_id!(user_id, "user_id")
1052
1274
  depth = validate_role_graph_depth!(max_depth)
1053
1275
  return Set.new if depth <= 0
@@ -1064,7 +1286,9 @@ module Parse
1064
1286
  "parse.mongodb.role_graph",
1065
1287
  direction: :forward, target_id: user_id, depth: depth,
1066
1288
  ) do |payload|
1067
- docs = collection("_Join:users:_Role").aggregate(
1289
+ # The role graph is an authorization input, so this read is
1290
+ # checked against the client that asked for it like any other.
1291
+ docs = collection("_Join:users:_Role", authorizing_client: client).aggregate(
1068
1292
  pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
1069
1293
  ).to_a
1070
1294
  names = Array(docs.first && docs.first["names"])
@@ -1120,9 +1344,13 @@ module Parse
1120
1344
  # supplied, or when both are supplied.
1121
1345
  # @raise [Parse::CLPScope::Denied] when `as:` is supplied and the
1122
1346
  # scope cannot `find` on `_Role`.
1123
- def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
1347
+ def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil,
1348
+ client: nil)
1349
+ # See {.role_names_for_user}: resolve the omission once at the entry
1350
+ # point rather than letting nil travel to the collection lookup.
1351
+ client ||= default_client_or_nil
1124
1352
  resolution = authorize_role_graph_call!(
1125
- :users_in_role_subtree, master: master, as: as,
1353
+ :users_in_role_subtree, master: master, as: as, client: client,
1126
1354
  )
1127
1355
  validate_role_graph_id!(role_id, "role_id")
1128
1356
  depth = validate_role_graph_depth!(max_depth)
@@ -1150,7 +1378,7 @@ module Parse
1150
1378
  "parse.mongodb.role_graph",
1151
1379
  direction: :reverse, target_id: role_id, depth: depth,
1152
1380
  ) do |payload|
1153
- docs = collection("_Join:roles:_Role").aggregate(
1381
+ docs = collection("_Join:roles:_Role", authorizing_client: client).aggregate(
1154
1382
  pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
1155
1383
  ).to_a
1156
1384
  ids = Array(docs.first && docs.first["user_ids"])
@@ -1188,10 +1416,10 @@ module Parse
1188
1416
  def master_key_available?
1189
1417
  return false unless defined?(Parse) && Parse.respond_to?(:client)
1190
1418
  c = begin
1191
- Parse.client
1192
- rescue StandardError
1193
- nil
1194
- end
1419
+ Parse.client
1420
+ rescue StandardError
1421
+ nil
1422
+ end
1195
1423
  return false if c.nil?
1196
1424
  key = c.respond_to?(:master_key) ? c.master_key : nil
1197
1425
  key.is_a?(String) && !key.empty?
@@ -1227,7 +1455,7 @@ module Parse
1227
1455
  # are provided.
1228
1456
  # @raise [Parse::CLPScope::Denied] when the resolved scope cannot
1229
1457
  # `find` on `_Role`.
1230
- def authorize_role_graph_call!(method_name, master:, as:)
1458
+ def authorize_role_graph_call!(method_name, master:, as:, client: nil)
1231
1459
  if master == true && !as.nil?
1232
1460
  raise ArgumentError,
1233
1461
  "Parse::MongoDB.#{method_name}: pass exactly one of " \
@@ -1237,8 +1465,9 @@ module Parse
1237
1465
 
1238
1466
  if master == true
1239
1467
  return Parse::ACLScope::Resolution.new(
1240
- mode: :master, permission_strings: nil, user_id: nil, session: nil,
1241
- )
1468
+ mode: :master, permission_strings: nil, user_id: nil, session: nil,
1469
+ client: client,
1470
+ )
1242
1471
  end
1243
1472
 
1244
1473
  if as.nil?
@@ -1249,7 +1478,13 @@ module Parse
1249
1478
  "to run under the caller's scope (subject to `_Role` CLP)."
1250
1479
  end
1251
1480
 
1252
- resolution = Parse::ACLScope.resolve!({ acl_user: as }, method_name: method_name)
1481
+ # The `as:` user's permissions must be computed against the SAME
1482
+ # client whose connection will run the traversal. Resolving on the
1483
+ # default while reading through another is how one application's
1484
+ # role names end up gating another application's rows.
1485
+ resolution = Parse::ACLScope.resolve!(
1486
+ { acl_user: as, client: client }.compact, method_name: method_name,
1487
+ )
1253
1488
  unless resolution.master?
1254
1489
  perms = resolution.permission_strings
1255
1490
  unless Parse::CLPScope.permits?(Parse::Model::CLASS_ROLE, :find, perms)
@@ -1295,26 +1530,26 @@ module Parse
1295
1530
  pipeline = [
1296
1531
  { "$match" => { "relatedId" => user_id } },
1297
1532
  { "$graphLookup" => {
1298
- "from" => "_Join:roles:_Role",
1299
- "startWith" => "$owningId",
1300
- "connectFromField" => "owningId",
1301
- "connectToField" => "relatedId",
1302
- "as" => "parent_chain",
1303
- "maxDepth" => graph_depth,
1533
+ "from" => "_Join:roles:_Role",
1534
+ "startWith" => "$owningId",
1535
+ "connectFromField" => "owningId",
1536
+ "connectToField" => "relatedId",
1537
+ "as" => "parent_chain",
1538
+ "maxDepth" => graph_depth,
1304
1539
  } },
1305
1540
  { "$project" => {
1306
- "_id" => 0,
1307
- "role_ids" => {
1308
- "$setUnion" => [["$owningId"], "$parent_chain.owningId"],
1309
- },
1541
+ "_id" => 0,
1542
+ "role_ids" => {
1543
+ "$setUnion" => [["$owningId"], "$parent_chain.owningId"],
1544
+ },
1310
1545
  } },
1311
1546
  { "$unwind" => "$role_ids" },
1312
1547
  { "$group" => { "_id" => nil, "ids" => { "$addToSet" => "$role_ids" } } },
1313
1548
  { "$lookup" => {
1314
- "from" => "_Role",
1315
- "localField" => "ids",
1316
- "foreignField" => "_id",
1317
- "as" => "roles",
1549
+ "from" => "_Role",
1550
+ "localField" => "ids",
1551
+ "foreignField" => "_id",
1552
+ "as" => "roles",
1318
1553
  } },
1319
1554
  { "$project" => { "_id" => 0, "names" => "$roles.name" } },
1320
1555
  ]
@@ -1345,34 +1580,34 @@ module Parse
1345
1580
  pipeline = [
1346
1581
  { "$match" => { "owningId" => role_id } },
1347
1582
  { "$graphLookup" => {
1348
- "from" => "_Join:roles:_Role",
1349
- "startWith" => "$relatedId",
1350
- "connectFromField" => "relatedId",
1351
- "connectToField" => "owningId",
1352
- "as" => "descendant_chain",
1353
- "maxDepth" => graph_depth,
1583
+ "from" => "_Join:roles:_Role",
1584
+ "startWith" => "$relatedId",
1585
+ "connectFromField" => "relatedId",
1586
+ "connectToField" => "owningId",
1587
+ "as" => "descendant_chain",
1588
+ "maxDepth" => graph_depth,
1354
1589
  } },
1355
1590
  { "$project" => {
1356
- "_id" => 0,
1357
- "role_ids" => {
1358
- "$setUnion" => [["$relatedId"], "$descendant_chain.relatedId"],
1359
- },
1591
+ "_id" => 0,
1592
+ "role_ids" => {
1593
+ "$setUnion" => [["$relatedId"], "$descendant_chain.relatedId"],
1594
+ },
1360
1595
  } },
1361
1596
  { "$unwind" => "$role_ids" },
1362
1597
  { "$group" => { "_id" => nil, "ids" => { "$addToSet" => "$role_ids" } } },
1363
1598
  { "$project" => {
1364
- "_id" => 0,
1365
- "ids" => { "$setUnion" => ["$ids", [role_id]] },
1599
+ "_id" => 0,
1600
+ "ids" => { "$setUnion" => ["$ids", [role_id]] },
1366
1601
  } },
1367
1602
  { "$lookup" => {
1368
- "from" => "_Join:users:_Role",
1369
- "localField" => "ids",
1370
- "foreignField" => "owningId",
1371
- "as" => "subscriptions",
1603
+ "from" => "_Join:users:_Role",
1604
+ "localField" => "ids",
1605
+ "foreignField" => "owningId",
1606
+ "as" => "subscriptions",
1372
1607
  } },
1373
1608
  { "$project" => {
1374
- "_id" => 0,
1375
- "user_id_candidates" => "$subscriptions.relatedId",
1609
+ "_id" => 0,
1610
+ "user_id_candidates" => "$subscriptions.relatedId",
1376
1611
  } },
1377
1612
  # Filter tombstoned _User rows AND project only `_id` server-side
1378
1613
  # via pipeline-form $lookup (3.6+). Without this, a role with N
@@ -1385,17 +1620,17 @@ module Parse
1385
1620
  # `_rperm` match is folded into the sub-pipeline filter so the
1386
1621
  # join honors row-level ACL.
1387
1622
  { "$lookup" => {
1388
- "from" => "_User",
1389
- "let" => { "ids" => "$user_id_candidates" },
1390
- "pipeline" => [
1391
- { "$match" => user_match },
1392
- { "$project" => { "_id" => 1 } },
1393
- ],
1394
- "as" => "active_users",
1623
+ "from" => "_User",
1624
+ "let" => { "ids" => "$user_id_candidates" },
1625
+ "pipeline" => [
1626
+ { "$match" => user_match },
1627
+ { "$project" => { "_id" => 1 } },
1628
+ ],
1629
+ "as" => "active_users",
1395
1630
  } },
1396
1631
  { "$project" => {
1397
- "_id" => 0,
1398
- "user_ids" => "$active_users._id",
1632
+ "_id" => 0,
1633
+ "user_ids" => "$active_users._id",
1399
1634
  } },
1400
1635
  ]
1401
1636
  # Defense-in-depth shape assertions (see comment in
@@ -1411,47 +1646,32 @@ module Parse
1411
1646
  # `startWith`. These fields drive the BFS direction in MongoDB; a
1412
1647
  # caller value here would be a query-injection primitive.
1413
1648
  def assert_user_role_names_pipeline_shape!(pipeline, user_id, graph_depth)
1414
- raise "role-graph pipeline shape regression: $match.relatedId must equal user_id" \
1415
- unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
1416
- pipeline[0]["$match"]["relatedId"] == user_id
1649
+ raise "role-graph pipeline shape regression: $match.relatedId must equal user_id" unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
1650
+ pipeline[0]["$match"]["relatedId"] == user_id
1417
1651
  gl = pipeline[1] && pipeline[1]["$graphLookup"]
1418
- raise "role-graph pipeline shape regression: missing $graphLookup stage" \
1419
- unless gl.is_a?(Hash)
1420
- raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" \
1421
- unless gl["from"] == "_Join:roles:_Role"
1422
- raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" \
1423
- unless gl["connectFromField"] == "owningId"
1424
- raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" \
1425
- unless gl["connectToField"] == "relatedId"
1426
- raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" \
1427
- unless gl["startWith"] == "$owningId"
1428
- raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" \
1429
- unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
1652
+ raise "role-graph pipeline shape regression: missing $graphLookup stage" unless gl.is_a?(Hash)
1653
+ raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" unless gl["from"] == "_Join:roles:_Role"
1654
+ raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" unless gl["connectFromField"] == "owningId"
1655
+ raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" unless gl["connectToField"] == "relatedId"
1656
+ raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" unless gl["startWith"] == "$owningId"
1657
+ raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
1430
1658
  end
1431
1659
 
1432
1660
  # @!visibility private
1433
1661
  # Hardcoded-shape assertion for build_role_subtree_users_pipeline.
1434
1662
  def assert_role_subtree_users_pipeline_shape!(pipeline, role_id, graph_depth)
1435
- raise "role-graph pipeline shape regression: $match.owningId must equal role_id" \
1436
- unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
1437
- pipeline[0]["$match"]["owningId"] == role_id
1663
+ raise "role-graph pipeline shape regression: $match.owningId must equal role_id" unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
1664
+ pipeline[0]["$match"]["owningId"] == role_id
1438
1665
  gl = pipeline[1] && pipeline[1]["$graphLookup"]
1439
- raise "role-graph pipeline shape regression: missing $graphLookup stage" \
1440
- unless gl.is_a?(Hash)
1441
- raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" \
1442
- unless gl["from"] == "_Join:roles:_Role"
1443
- raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" \
1444
- unless gl["connectFromField"] == "relatedId"
1445
- raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" \
1446
- unless gl["connectToField"] == "owningId"
1447
- raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" \
1448
- unless gl["startWith"] == "$relatedId"
1449
- raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" \
1450
- unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
1666
+ raise "role-graph pipeline shape regression: missing $graphLookup stage" unless gl.is_a?(Hash)
1667
+ raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" unless gl["from"] == "_Join:roles:_Role"
1668
+ raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" unless gl["connectFromField"] == "relatedId"
1669
+ raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" unless gl["connectToField"] == "owningId"
1670
+ raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" unless gl["startWith"] == "$relatedId"
1671
+ raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
1451
1672
  # Final _User $lookup carries the hardcoded foreign collection.
1452
1673
  user_lookup = pipeline.find { |s| s.dig("$lookup", "from") == "_User" }
1453
- raise "role-graph pipeline shape regression: missing _User $lookup stage" \
1454
- unless user_lookup.is_a?(Hash)
1674
+ raise "role-graph pipeline shape regression: missing _User $lookup stage" unless user_lookup.is_a?(Hash)
1455
1675
  end
1456
1676
 
1457
1677
  # Execute an aggregation pipeline directly on MongoDB
@@ -1499,7 +1719,7 @@ module Parse
1499
1719
  # @raise [Parse::ACLScope::ACLRequired] when neither
1500
1720
  # `session_token:` nor `master: true` is supplied and
1501
1721
  # {Parse::ACLScope.require_session_token} is enabled.
1502
- def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil, hint: nil)
1722
+ def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, client: nil, read_preference: nil, hint: nil)
1503
1723
  # AS::N envelope. Payload is intentionally metadata-only —
1504
1724
  # `stage_count`, `stage_types`, `collection`, `scope`,
1505
1725
  # `result_count`, `max_time_ms`, `read_preference`. Pipeline
@@ -1522,151 +1742,164 @@ module Parse
1522
1742
  result_count: nil,
1523
1743
  }
1524
1744
  ActiveSupport::Notifications.instrument("parse.mongodb.aggregate", instrument_payload) do |payload|
1525
- # Resolve auth kwargs into a Parse::ACLScope::Resolution. The
1526
- # call MUTATES the temporary kwargs hash (popping the auth
1527
- # entries) before the resolution; we package them into a hash
1528
- # here only so the shared helper can stay path-agnostic. The
1529
- # hash is local and discarded after the call.
1530
- auth_kwargs = {
1531
- session_token: session_token,
1532
- master: master,
1533
- acl_user: acl_user,
1534
- acl_role: acl_role,
1535
- }.compact
1536
- resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)
1537
- payload[:scope] = __scope_label(resolution)
1538
-
1539
- # Validate BEFORE rewrite so the security denylist is applied to the
1540
- # caller's original pipeline (which an attacker controls), not to
1541
- # the gem-rewritten form (which it doesn't). Matches the ordering
1542
- # used by Parse::Query#aggregate and Parse::Agent::Tools.aggregate.
1543
- assert_no_denied_operators!(pipeline, allow_internal_fields: allow_internal_fields)
1544
-
1545
- # Wave-3 TRACK-CLP-4: refuse any caller-supplied `$<field>`
1546
- # reference that names a protectedField for the queried class
1547
- # in the current scope. The post-fetch redact strips by NAME,
1548
- # so a pipeline can launder a protected value through a
1549
- # `$project: { renamed: "$ssn" }` (and similar) clauses and
1550
- # bypass the strip silently. Catching the reference here at
1551
- # parse-time refuses the join with `Parse::CLPScope::Denied`
1552
- # so the bypass surfaces as an explicit error rather than a
1553
- # quiet exfiltration. Master mode short-circuits inside the
1554
- # scanner (no protected set on master).
1555
- Parse::PipelineSecurity.refuse_protected_field_references!(
1556
- pipeline, collection_name, resolution,
1557
- )
1745
+ # Resolve auth kwargs into a Parse::ACLScope::Resolution. The
1746
+ # call MUTATES the temporary kwargs hash (popping the auth
1747
+ # entries) before the resolution; we package them into a hash
1748
+ # here only so the shared helper can stay path-agnostic. The
1749
+ # hash is local and discarded after the call.
1750
+ auth_kwargs = {
1751
+ session_token: session_token,
1752
+ master: master,
1753
+ acl_user: acl_user,
1754
+ acl_role: acl_role,
1755
+ # The client whose authorization context resolves this call. Nil
1756
+ # falls back to Parse.client at the ACLScope boundary. It is carried
1757
+ # onto the Resolution so verify_client! below can compare it against
1758
+ # the application this process-global connection is bound to.
1759
+ client: client,
1760
+ }.compact
1761
+ resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)
1762
+ # The resolution above is client-scoped; the connection below is not.
1763
+ # Refuse the combination rather than reading another application's
1764
+ # database with this application's permission strings.
1765
+ verify_client!(resolution.client)
1766
+ payload[:scope] = __scope_label(resolution)
1767
+
1768
+ # Validate BEFORE rewrite so the security denylist is applied to the
1769
+ # caller's original pipeline (which an attacker controls), not to
1770
+ # the gem-rewritten form (which it doesn't). Matches the ordering
1771
+ # used by Parse::Query#aggregate and Parse::Agent::Tools.aggregate.
1772
+ assert_no_denied_operators!(pipeline, allow_internal_fields: allow_internal_fields)
1773
+
1774
+ # Wave-3 TRACK-CLP-4: refuse any caller-supplied `$<field>`
1775
+ # reference that names a protectedField for the queried class
1776
+ # in the current scope. The post-fetch redact strips by NAME,
1777
+ # so a pipeline can launder a protected value through a
1778
+ # `$project: { renamed: "$ssn" }` (and similar) clauses and
1779
+ # bypass the strip silently. Catching the reference here at
1780
+ # parse-time refuses the join with `Parse::CLPScope::Denied`
1781
+ # so the bypass surfaces as an explicit error rather than a
1782
+ # quiet exfiltration. Master mode short-circuits inside the
1783
+ # scanner (no protected set on master).
1784
+ Parse::PipelineSecurity.refuse_protected_field_references!(
1785
+ pipeline, collection_name, resolution,
1786
+ )
1558
1787
 
1559
- pipeline = Parse::LookupRewriter.auto_rewrite(
1560
- pipeline, class_name: collection_name, enabled: rewrite_lookups,
1561
- )
1788
+ pipeline = Parse::LookupRewriter.auto_rewrite(
1789
+ pipeline, class_name: collection_name, enabled: rewrite_lookups,
1790
+ )
1562
1791
 
1563
- # Three-layer ACL simulation on the mongo-direct path:
1564
- #
1565
- # 1. Top-level $match: filter the queried collection's rows by
1566
- # the session's _rperm allow-set. Mirrors Parse Server's
1567
- # REST find behavior.
1568
- # 2. Pipeline rewriter: every $lookup / $unionWith / $graphLookup /
1569
- # $facet sub-pipeline gets the same _rperm filter embedded
1570
- # so joined rows from other collections are filtered at the
1571
- # database. Without this, includes/joins would silently leak
1572
- # rows the requesting session has no permission to read.
1573
- # 3. Post-fetch redaction: walk the returned documents and
1574
- # scrub any embedded sub-documents whose stored _rperm
1575
- # doesn't match the perms set. Catches cases the rewriter
1576
- # can't reach (e.g., :object columns embedding raw pointer
1577
- # hashes, or caller-supplied $lookup stages that escaped
1578
- # rewriting because of unusual shapes).
1579
- #
1580
- # The security validator already ran on the caller's original
1581
- # pipeline above; the injected stages reference `_rperm` but
1582
- # are SDK-generated (not attacker-controlled), so no
1583
- # re-validation is needed before they're handed to MongoDB.
1584
- if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
1585
- pipeline = prepend_or_fold_acl_match(pipeline, acl_stage)
1586
- end
1587
- pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
1588
-
1589
- # Class-Level Permissions boundary check. Parse Server's REST
1590
- # aggregate endpoint runs master-key-only and does NOT enforce
1591
- # CLP; the mongo-direct path bypasses Parse Server entirely so
1592
- # the SDK is the only enforcement layer. Refuse the call when
1593
- # the resolved scope can't `find` on the collection. Master-
1594
- # key (resolution.master? / nil permission_strings) bypasses.
1595
- perms_for_clp = resolution&.permission_strings
1596
- unless resolution.nil? || resolution.master?
1597
- unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp)
1598
- raise Parse::CLPScope::Denied.new(
1599
- collection_name, :find,
1600
- "CLP refuses find on '#{collection_name}' for the current scope.",
1601
- )
1792
+ # Three-layer ACL simulation on the mongo-direct path:
1793
+ #
1794
+ # 1. Top-level $match: filter the queried collection's rows by
1795
+ # the session's _rperm allow-set. Mirrors Parse Server's
1796
+ # REST find behavior.
1797
+ # 2. Pipeline rewriter: every $lookup / $unionWith / $graphLookup /
1798
+ # $facet sub-pipeline gets the same _rperm filter embedded
1799
+ # so joined rows from other collections are filtered at the
1800
+ # database. Without this, includes/joins would silently leak
1801
+ # rows the requesting session has no permission to read.
1802
+ # 3. Post-fetch redaction: walk the returned documents and
1803
+ # scrub any embedded sub-documents whose stored _rperm
1804
+ # doesn't match the perms set. Catches cases the rewriter
1805
+ # can't reach (e.g., :object columns embedding raw pointer
1806
+ # hashes, or caller-supplied $lookup stages that escaped
1807
+ # rewriting because of unusual shapes).
1808
+ #
1809
+ # The security validator already ran on the caller's original
1810
+ # pipeline above; the injected stages reference `_rperm` but
1811
+ # are SDK-generated (not attacker-controlled), so no
1812
+ # re-validation is needed before they're handed to MongoDB.
1813
+ if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
1814
+ pipeline = prepend_or_fold_acl_match(pipeline, acl_stage)
1815
+ end
1816
+ pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
1817
+
1818
+ # Class-Level Permissions boundary check. Parse Server's REST
1819
+ # aggregate endpoint runs master-key-only and does NOT enforce
1820
+ # CLP; the mongo-direct path bypasses Parse Server entirely so
1821
+ # the SDK is the only enforcement layer. Refuse the call when
1822
+ # the resolved scope can't `find` on the collection. Master-
1823
+ # key (resolution.master? / nil permission_strings) bypasses.
1824
+ perms_for_clp = resolution&.permission_strings
1825
+ unless resolution.nil? || resolution.master?
1826
+ unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp)
1827
+ raise Parse::CLPScope::Denied.new(
1828
+ collection_name, :find,
1829
+ "CLP refuses find on '#{collection_name}' for the current scope.",
1830
+ )
1831
+ end
1602
1832
  end
1603
- end
1604
1833
 
1605
- # Resolve the pointerFields constraint (if any) BEFORE running
1606
- # the query — we apply the filter post-fetch but want to fail
1607
- # loudly when the scope can't satisfy the constraint at all
1608
- # (acl_role-only / public agents have no user_id to match).
1609
- pointer_fields = nil
1610
- unless resolution.nil? || resolution.master?
1611
- pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
1612
- if pointer_fields && resolution.user_id.nil?
1613
- raise Parse::CLPScope::Denied.new(
1614
- collection_name, :find,
1615
- "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
1616
- "but the current scope has no user_id.",
1617
- )
1834
+ # Resolve the pointerFields constraint (if any) BEFORE running
1835
+ # the query — we apply the filter post-fetch but want to fail
1836
+ # loudly when the scope can't satisfy the constraint at all
1837
+ # (acl_role-only / public agents have no user_id to match).
1838
+ pointer_fields = nil
1839
+ unless resolution.nil? || resolution.master?
1840
+ pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
1841
+ if pointer_fields && resolution.user_id.nil?
1842
+ raise Parse::CLPScope::Denied.new(
1843
+ collection_name, :find,
1844
+ "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
1845
+ "but the current scope has no user_id.",
1846
+ )
1847
+ end
1618
1848
  end
1619
- end
1620
1849
 
1621
- agg_opts = {}
1622
- agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1623
- # Forced index hint (Query#hint). Mirrors Parse Server's REST `hint`
1624
- # on the mongo-direct path so a bad plan diagnosed with `explain` can
1625
- # be corrected here too. Accepts an index name (String) or a key
1626
- # pattern (Hash).
1627
- agg_opts[:hint] = hint unless hint.nil?
1628
- coll = collection(collection_name)
1629
- if (mode = normalize_read_preference(read_preference))
1630
- coll = coll.with(read: { mode: mode })
1631
- end
1632
- results = coll.aggregate(pipeline, agg_opts).to_a
1633
- Parse::ACLScope.redact_results!(results, resolution)
1634
-
1635
- # Post-fetch pointerFields filter: drop rows where none of the
1636
- # named pointer fields references the requesting user. Skipped
1637
- # for master-key and when the CLP has no pointerFields entry.
1638
- if pointer_fields
1639
- results = Parse::CLPScope.filter_by_pointer_fields(
1640
- results, pointer_fields, resolution.user_id,
1641
- )
1642
- end
1850
+ agg_opts = {}
1851
+ agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1852
+ # Forced index hint (Query#hint). Mirrors Parse Server's REST `hint`
1853
+ # on the mongo-direct path so a bad plan diagnosed with `explain` can
1854
+ # be corrected here too. Accepts an index name (String) or a key
1855
+ # pattern (Hash).
1856
+ agg_opts[:hint] = hint unless hint.nil?
1857
+ # The SAME client this call already verified against. Passing nothing
1858
+ # here made the collection lookup an unidentified caller, so once a
1859
+ # second application had been observed a perfectly legitimate
1860
+ # aggregate for the bound application was refused.
1861
+ coll = collection(collection_name, authorizing_client: Parse::ACLScope.client_of(resolution))
1862
+ if (mode = normalize_read_preference(read_preference))
1863
+ coll = coll.with(read: { mode: mode })
1864
+ end
1865
+ results = coll.aggregate(pipeline, agg_opts).to_a
1866
+ Parse::ACLScope.redact_results!(results, resolution)
1867
+
1868
+ # Post-fetch pointerFields filter: drop rows where none of the
1869
+ # named pointer fields references the requesting user. Skipped
1870
+ # for master-key and when the CLP has no pointerFields entry.
1871
+ if pointer_fields
1872
+ results = Parse::CLPScope.filter_by_pointer_fields(
1873
+ results, pointer_fields, resolution.user_id,
1874
+ )
1875
+ end
1643
1876
 
1644
- # Protected fields stripping. Resolve the field set per the
1645
- # session's claim composition and walk-delete from every
1646
- # row + embedded sub-document. Top-level $project would also
1647
- # work but doesn't reach inside `$lookup`-included sub-docs,
1648
- # so the post-walker is the defense-in-depth layer.
1649
- unless resolution.nil? || resolution.master?
1650
- strip_set = Parse::CLPScope.protected_fields_for(
1651
- collection_name, perms_for_clp,
1652
- )
1653
- Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
1654
-
1655
- # Process-level floor: recursively strip Parse-internal credential
1656
- # columns (_hashed_password, _session_token, _auth_data_*, _rperm,
1657
- # ...) from every row AND every embedded sub-document. The
1658
- # protectedFields strip above is keyed on the OUTER class, and the
1659
- # ACL sub-doc walk only DROPS ACL-failing sub-docs — neither covers
1660
- # a foreign class (e.g. _User / _Session) pulled in via $lookup /
1661
- # $graphLookup / $unionWith under an arbitrary alias. Runs last, for
1662
- # scoped (non-master) callers only; master is unredacted by design.
1663
- results.each do |row|
1664
- Parse::PipelineSecurity.redact_internal_fields_deep!(row)
1877
+ # Protected fields stripping. Resolve the field set per the
1878
+ # session's claim composition and walk-delete from every
1879
+ # row + embedded sub-document. Top-level $project would also
1880
+ # work but doesn't reach inside `$lookup`-included sub-docs,
1881
+ # so the post-walker is the defense-in-depth layer.
1882
+ unless resolution.nil? || resolution.master?
1883
+ strip_set = Parse::CLPScope.protected_fields_for(
1884
+ collection_name, perms_for_clp,
1885
+ )
1886
+ Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
1887
+
1888
+ # Process-level floor: recursively strip Parse-internal credential
1889
+ # columns (_hashed_password, _session_token, _auth_data_*, _rperm,
1890
+ # ...) from every row AND every embedded sub-document. The
1891
+ # protectedFields strip above is keyed on the OUTER class, and the
1892
+ # ACL sub-doc walk only DROPS ACL-failing sub-docs — neither covers
1893
+ # a foreign class (e.g. _User / _Session) pulled in via $lookup /
1894
+ # $graphLookup / $unionWith under an arbitrary alias. Runs last, for
1895
+ # scoped (non-master) callers only; master is unredacted by design.
1896
+ results.each do |row|
1897
+ Parse::PipelineSecurity.redact_internal_fields_deep!(row)
1898
+ end
1665
1899
  end
1666
- end
1667
1900
 
1668
- payload[:result_count] = results.size
1669
- results
1901
+ payload[:result_count] = results.size
1902
+ results
1670
1903
  end
1671
1904
  rescue => e
1672
1905
  raise_if_timeout!(e, collection_name, max_time_ms)
@@ -1699,15 +1932,9 @@ module Parse
1699
1932
  # either type, else follow the `$geoNear` key's type (string stage
1700
1933
  # → "query", symbol stage → :query). The Mongo driver normalizes
1701
1934
  # either way, but keeping one style avoids a duplicate query key.
1702
- q_key =
1703
- if geo.key?("query") then "query"
1704
- elsif geo.key?(:query) then :query
1705
- elsif geo_key.is_a?(String) then "query"
1706
- else :query
1707
- end
1935
+ q_key = if geo.key?("query") then "query" elsif geo.key?(:query) then :query elsif geo_key.is_a?(String) then "query" else :query end
1708
1936
  existing = geo[q_key]
1709
- geo[q_key] =
1710
- if existing.is_a?(Hash) && !existing.empty?
1937
+ geo[q_key] = if existing.is_a?(Hash) && !existing.empty?
1711
1938
  # `existing` is still the caller's own `$geoNear.query` hash
1712
1939
  # (the outer `.dup` above is shallow). Embed a copy, not the
1713
1940
  # original, so the folded pipeline and the caller's pipeline
@@ -1825,6 +2052,7 @@ module Parse
1825
2052
  master: nil,
1826
2053
  acl_user: nil,
1827
2054
  acl_role: nil,
2055
+ client: nil,
1828
2056
  read_preference: nil)
1829
2057
  stage = { :$geoNear => {
1830
2058
  near: geojson_point_for(near),
@@ -1851,6 +2079,7 @@ module Parse
1851
2079
  master: master,
1852
2080
  acl_user: acl_user,
1853
2081
  acl_role: acl_role,
2082
+ client: client,
1854
2083
  read_preference: read_preference)
1855
2084
  end
1856
2085
 
@@ -1870,6 +2099,8 @@ module Parse
1870
2099
  # @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
1871
2100
  def find(collection_name, filter = {}, **options)
1872
2101
  max_time_ms = options.delete(:max_time_ms)
2102
+ # Consumed like the other auth kwargs so it never reaches the driver.
2103
+ find_client = options.delete(:client)
1873
2104
  # Metadata-only AS::N payload: collection, presence-of-filter
1874
2105
  # (NOT body), projection keys (column names, not values), limit,
1875
2106
  # max_time_ms, result_count. Filter / projection bodies are
@@ -1881,8 +2112,7 @@ module Parse
1881
2112
  # both event names must treat `payload[:scope]` as optional.
1882
2113
  # `result_count` is seeded nil so subscribers see a stable key
1883
2114
  # set even on the raise path.
1884
- projection_keys =
1885
- if options[:projection].is_a?(Hash)
2115
+ projection_keys = if options[:projection].is_a?(Hash)
1886
2116
  options[:projection].keys.map(&:to_s)
1887
2117
  end
1888
2118
  instrument_payload = {
@@ -1894,41 +2124,41 @@ module Parse
1894
2124
  result_count: nil,
1895
2125
  }
1896
2126
  ActiveSupport::Notifications.instrument("parse.mongodb.find", instrument_payload) do |payload|
1897
- allow_internal_fields = options.delete(:allow_internal_fields) || false
1898
- assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
1899
- cursor = collection(collection_name).find(filter)
1900
- explicit_limit = options.key?(:limit)
1901
- applied_default_limit = false
1902
-
1903
- if explicit_limit
1904
- cursor = cursor.limit(options[:limit]) if options[:limit] > 0
1905
- else
1906
- # Apply the hard default BEFORE to_a so we never materialize an
1907
- # unbounded result set. Fetch one extra row so we can detect when
1908
- # callers hit the cap and warn them.
1909
- cursor = cursor.limit(DEFAULT_FIND_LIMIT + 1)
1910
- applied_default_limit = true
1911
- end
2127
+ allow_internal_fields = options.delete(:allow_internal_fields) || false
2128
+ assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
2129
+ cursor = collection(collection_name, authorizing_client: find_client).find(filter)
2130
+ explicit_limit = options.key?(:limit)
2131
+ applied_default_limit = false
2132
+
2133
+ if explicit_limit
2134
+ cursor = cursor.limit(options[:limit]) if options[:limit] > 0
2135
+ else
2136
+ # Apply the hard default BEFORE to_a so we never materialize an
2137
+ # unbounded result set. Fetch one extra row so we can detect when
2138
+ # callers hit the cap and warn them.
2139
+ cursor = cursor.limit(DEFAULT_FIND_LIMIT + 1)
2140
+ applied_default_limit = true
2141
+ end
1912
2142
 
1913
- cursor = cursor.skip(options[:skip]) if options[:skip]
1914
- cursor = cursor.sort(options[:sort]) if options[:sort]
1915
- cursor = cursor.projection(options[:projection]) if options[:projection]
1916
- cursor = cursor.hint(options[:hint]) unless options[:hint].nil?
1917
- cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
1918
- results = cursor.to_a
1919
-
1920
- if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
1921
- # Trim the sentinel row and warn — the caller asked for everything
1922
- # but the result set is larger than the safety cap.
1923
- results = results.first(DEFAULT_FIND_LIMIT)
1924
- warn "[Parse::MongoDB.find] on '#{collection_name}' truncated to " \
1925
- "#{DEFAULT_FIND_LIMIT} rows (no :limit specified). Pass an " \
1926
- "explicit :limit to control the size, or :limit => 0 for " \
1927
- "unbounded behavior."
1928
- end
2143
+ cursor = cursor.skip(options[:skip]) if options[:skip]
2144
+ cursor = cursor.sort(options[:sort]) if options[:sort]
2145
+ cursor = cursor.projection(options[:projection]) if options[:projection]
2146
+ cursor = cursor.hint(options[:hint]) unless options[:hint].nil?
2147
+ cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
2148
+ results = cursor.to_a
2149
+
2150
+ if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
2151
+ # Trim the sentinel row and warn — the caller asked for everything
2152
+ # but the result set is larger than the safety cap.
2153
+ results = results.first(DEFAULT_FIND_LIMIT)
2154
+ warn "[Parse::MongoDB.find] on '#{collection_name}' truncated to " \
2155
+ "#{DEFAULT_FIND_LIMIT} rows (no :limit specified). Pass an " \
2156
+ "explicit :limit to control the size, or :limit => 0 for " \
2157
+ "unbounded behavior."
2158
+ end
1929
2159
 
1930
- payload[:result_count] = results.size
1931
- results
2160
+ payload[:result_count] = results.size
2161
+ results
1932
2162
  end
1933
2163
  rescue => e
1934
2164
  raise_if_timeout!(e, collection_name, max_time_ms)
@@ -2009,7 +2239,7 @@ module Parse
2009
2239
  next unless name
2010
2240
  accesses = row["accesses"] || row[:accesses] || {}
2011
2241
  h[name] = {
2012
- ops: (accesses["ops"] || accesses[:ops]).to_i,
2242
+ ops: (accesses["ops"] || accesses[:ops]).to_i,
2013
2243
  since: accesses["since"] || accesses[:since],
2014
2244
  }
2015
2245
  end