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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +489 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +219 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +366 -113
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +6 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +6 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +136 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +120 -5
- data/parse-stack-next.gemspec +6 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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;
|
|
1156
|
+
# are 2-4 deep; 10 leaves headroom for unusual hierarchies without
|
|
949
1157
|
# encouraging runaway $graphLookup fan-out on pathological inputs.
|
|
950
|
-
|
|
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
|
|
957
|
-
#
|
|
958
|
-
#
|
|
959
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
1599
|
+
"_id" => 0,
|
|
1600
|
+
"ids" => { "$setUnion" => ["$ids", [role_id]] },
|
|
1366
1601
|
} },
|
|
1367
1602
|
{ "$lookup" => {
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1603
|
+
"from" => "_Join:users:_Role",
|
|
1604
|
+
"localField" => "ids",
|
|
1605
|
+
"foreignField" => "owningId",
|
|
1606
|
+
"as" => "subscriptions",
|
|
1372
1607
|
} },
|
|
1373
1608
|
{ "$project" => {
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1420
|
-
raise "role-graph pipeline shape regression: $graphLookup.
|
|
1421
|
-
|
|
1422
|
-
raise "role-graph pipeline shape regression: $graphLookup.
|
|
1423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1441
|
-
raise "role-graph pipeline shape regression: $graphLookup.
|
|
1442
|
-
|
|
1443
|
-
raise "role-graph pipeline shape regression: $graphLookup.
|
|
1444
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1788
|
+
pipeline = Parse::LookupRewriter.auto_rewrite(
|
|
1789
|
+
pipeline, class_name: collection_name, enabled: rewrite_lookups,
|
|
1790
|
+
)
|
|
1562
1791
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
1931
|
-
|
|
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:
|
|
2242
|
+
ops: (accesses["ops"] || accesses[:ops]).to_i,
|
|
2013
2243
|
since: accesses["since"] || accesses[:since],
|
|
2014
2244
|
}
|
|
2015
2245
|
end
|