parse-stack-next 5.5.0 → 5.5.2

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.
data/lib/parse/query.rb CHANGED
@@ -1811,13 +1811,25 @@ module Parse
1811
1811
  # Whether this query carries a non-master-key auth scope. Used by
1812
1812
  # `#distinct` (and group_by aggregations) to decide whether to
1813
1813
  # auto-promote the REST aggregate path to mongo-direct so the SDK's
1814
- # ACLScope / CLPScope enforcement actually runs.
1814
+ # ACLScope / CLPScope enforcement actually runs. Also detects the
1815
+ # fiber-local ambient session set by Parse.with_session so that
1816
+ # aggregations inside a with_session block are treated as scoped —
1817
+ # consistent with how Parse::Client#request already scopes REST calls.
1815
1818
  # @return [Boolean]
1816
1819
  # @api private
1817
1820
  def distinct_query_is_scoped?
1818
1821
  return true if @session_token.is_a?(String) && !@session_token.empty?
1819
1822
  return true if @acl_user
1820
1823
  return true if @acl_role
1824
+ # An ambient Parse.with_session counts as scope ONLY when the query did
1825
+ # not explicitly request master-key mode — mirroring Parse::Client#request,
1826
+ # where an explicit use_master_key: true is a deliberate admin call that
1827
+ # skips the ambient session. Otherwise an admin aggregation inside a
1828
+ # with_session block would be wrongly forced to mongo-direct / fail-closed.
1829
+ unless use_master_key == true
1830
+ ambient = ambient_session_token
1831
+ return true if ambient.is_a?(String) && !ambient.empty?
1832
+ end
1821
1833
  false
1822
1834
  end
1823
1835
 
@@ -1832,12 +1844,13 @@ module Parse
1832
1844
  def raise_scoped_aggregation_requires_mongo_direct!
1833
1845
  raise MongoDirectRequired,
1834
1846
  "[Parse::Query] This scoped aggregation (session_token / " \
1835
- "scope_to_user / scope_to_role) requires mongo-direct so the " \
1836
- "SDK can enforce ACL/CLP. Parse Server's REST /aggregate " \
1837
- "endpoint is master-key-only and enforces neither, so routing " \
1838
- "it there would silently run unscoped as the master key. " \
1839
- "Enable mongo-direct via Parse::MongoDB.configure(...), or " \
1840
- "rewrite without the aggregation terminal."
1847
+ "scope_to_user / scope_to_role, or an active Parse.with_session " \
1848
+ "block) requires mongo-direct so the SDK can enforce ACL/CLP. " \
1849
+ "Parse Server's REST /aggregate endpoint is master-key-only and " \
1850
+ "enforces neither, so routing it there would silently run unscoped " \
1851
+ "as the master key. Enable mongo-direct via " \
1852
+ "Parse::MongoDB.configure(...), or rewrite without the " \
1853
+ "aggregation terminal."
1841
1854
  end
1842
1855
 
1843
1856
  # Scope a query to a specific user's row-level ACL when it auto-routes
@@ -1965,11 +1978,21 @@ module Parse
1965
1978
  # roles, injects the three-layer ACL simulation
1966
1979
  # (top-level `$match`, `$lookup` rewriter, post-fetch
1967
1980
  # redactor) via {Parse::MongoDB.aggregate}.
1981
+ # * an active `Parse.with_session` block — the fiber-local ambient
1982
+ # session token scopes the read the same way an explicit
1983
+ # `session_token=` would (see {#mongo_direct_auth_kwargs}).
1968
1984
  #
1969
1985
  # Raises a clear {MongoDirectRequired} otherwise.
1970
1986
  # @!visibility private
1971
1987
  def assert_mongo_direct_routable!
1972
1988
  has_session = @session_token.is_a?(String) && !@session_token.empty?
1989
+ # An active `Parse.with_session` block scopes the read even on a
1990
+ # non-master client (client_mode, or a user-scoped client with no
1991
+ # master key), where `server_mode_master` is false. Without this the
1992
+ # query would raise instead of running scoped — and on a master
1993
+ # client the ambient is what `mongo_direct_auth_kwargs` forwards so
1994
+ # the read is scoped rather than silently master.
1995
+ has_ambient_session = !ambient_session_token.nil?
1973
1996
  # Mirror the request-layer auth resolution in Parse::Client#request:
1974
1997
  # when the process is in "server mode" — Parse.client_mode == false
1975
1998
  # AND the resolved Parse::Client has a master_key — and the caller
@@ -1985,7 +2008,7 @@ module Parse
1985
2008
  false
1986
2009
  end
1987
2010
  server_mode_master = (use_master_key != false) && !Parse.client_mode && client_has_master_key
1988
- unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session
2011
+ unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session || has_ambient_session
1989
2012
  raise MongoDirectRequired,
1990
2013
  "[Parse::Query] This query uses a constraint that can only run " \
1991
2014
  "via mongo-direct. Mongo-direct bypasses Parse Server's enforcement, " \
@@ -2019,6 +2042,12 @@ module Parse
2019
2042
  # double-inject).
2020
2043
  # * `session_token` is set → forward `session_token:` so
2021
2044
  # Parse::ACLScope runs the full three-layer simulation.
2045
+ # * Otherwise, the fiber-local ambient session set by
2046
+ # `Parse.with_session` is forwarded as `session_token:` (unless
2047
+ # the caller explicitly requested `use_master_key: true`), so a
2048
+ # query that auto-routes to mongo-direct inside a `with_session`
2049
+ # block is scoped to that user — matching what the REST path does
2050
+ # in {Parse::Client#request}.
2022
2051
  # * Otherwise (master-key path) → forward `master: true`.
2023
2052
  # @!visibility private
2024
2053
  def mongo_direct_auth_kwargs
@@ -2036,11 +2065,32 @@ module Parse
2036
2065
  { acl_role: @acl_role }
2037
2066
  elsif @session_token.is_a?(String) && !@session_token.empty?
2038
2067
  { session_token: @session_token }
2068
+ elsif use_master_key != true && (ambient = ambient_session_token)
2069
+ # No explicit per-query scope, but a `Parse.with_session` block is
2070
+ # active. Mirror Parse::Client#request's precedence (ambient
2071
+ # session wins over the server-mode master default) so the read is
2072
+ # scoped to that user instead of silently running as master with
2073
+ # no ACL/CLP enforcement. An explicit `use_master_key: true` is a
2074
+ # deliberate admin call and skips the ambient, exactly as the REST
2075
+ # path does.
2076
+ { session_token: ambient }
2039
2077
  else
2040
2078
  { master: true }
2041
2079
  end
2042
2080
  end
2043
2081
 
2082
+ # The fiber-local ambient session token set by `Parse.with_session`,
2083
+ # or nil. A whitespace-only ambient is treated as absent so it cannot
2084
+ # block the master fallback and then fail a later presence check —
2085
+ # the same guard {Parse::Client#request} applies.
2086
+ # @return [String, nil]
2087
+ # @!visibility private
2088
+ def ambient_session_token
2089
+ return nil unless Parse.respond_to?(:current_session_token)
2090
+ ambient = Parse.current_session_token
2091
+ ambient if ambient.is_a?(String) && !ambient.strip.empty?
2092
+ end
2093
+
2044
2094
  # Check if this query contains constraints that require aggregation pipeline processing
2045
2095
  # @return [Boolean] true if aggregation pipeline is required
2046
2096
  def requires_aggregation_pipeline?
@@ -3527,22 +3577,31 @@ module Parse
3527
3577
  # the merged pipeline is provably SDK-injected, never user input.
3528
3578
  uses_internal_fields = pipeline_uses_internal_fields?(complete_pipeline)
3529
3579
  scoped = distinct_query_is_scoped?
3580
+ mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3530
3581
  use_mongo_direct = mongo_direct
3531
- if use_mongo_direct.nil?
3532
- mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3533
- if lookup_stages && lookup_stages.any?
3582
+
3583
+ if scoped
3584
+ # A scoped aggregation (session_token / scope_to_user / scope_to_role)
3585
+ # must NEVER reach Parse Server's REST /aggregate endpoint — it is
3586
+ # master-key-only and enforces NEITHER ACL NOR CLP, so it would run
3587
+ # unscoped as the master key. This holds even when the caller
3588
+ # explicitly passes `mongo_direct: false`: an explicit false cannot
3589
+ # opt a scoped query out of ACL/CLP enforcement. Promote to mongo-
3590
+ # direct, or fail closed when direct Mongo is unavailable (refuse
3591
+ # rather than leak unscoped rows).
3592
+ if mongo_ready
3593
+ use_mongo_direct = true
3594
+ else
3595
+ raise_scoped_aggregation_requires_mongo_direct!
3596
+ end
3597
+ elsif use_mongo_direct.nil?
3598
+ # Unscoped auto-routing: $inQuery/$notInQuery → $lookup pipelines and
3599
+ # SDK-injected internal-field ($rperm/_wperm) pipelines can't be served
3600
+ # by REST /aggregate, so prefer mongo-direct when available. An unscoped
3601
+ # internal-field pipeline keeps the REST fallback (a master-key
3602
+ # correctness edge, not an enforcement bypass).
3603
+ if (lookup_stages && lookup_stages.any?) || uses_internal_fields
3534
3604
  use_mongo_direct = true if mongo_ready
3535
- elsif scoped || uses_internal_fields
3536
- if mongo_ready
3537
- use_mongo_direct = true
3538
- elsif scoped
3539
- # Fail closed: a scoped aggregation cannot fall back to REST
3540
- # /aggregate without silently bypassing ACL/CLP (master-key-only
3541
- # endpoint). Refuse rather than leak unscoped results. Unscoped
3542
- # internal-field pipelines keep the REST fallback (a master-key
3543
- # correctness edge, not an enforcement bypass).
3544
- raise_scoped_aggregation_requires_mongo_direct!
3545
- end
3546
3605
  end
3547
3606
  end
3548
3607
 
@@ -3641,17 +3700,21 @@ module Parse
3641
3700
  # unenforced). A scoped query fails closed when mongo-direct is
3642
3701
  # unavailable rather than silently running unscoped as master.
3643
3702
  scoped = distinct_query_is_scoped?
3703
+ mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3644
3704
  use_mongo_direct = mongo_direct
3645
- if use_mongo_direct.nil?
3646
- mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3647
- if has_lookup_stages
3705
+
3706
+ if scoped
3707
+ # A scoped aggregation must never reach REST /aggregate (master-key-
3708
+ # only, unenforced) — not even when the caller explicitly passes
3709
+ # mongo_direct: false. Promote to mongo-direct, or fail closed.
3710
+ if mongo_ready
3711
+ use_mongo_direct = true
3712
+ else
3713
+ raise_scoped_aggregation_requires_mongo_direct!
3714
+ end
3715
+ elsif use_mongo_direct.nil?
3716
+ if has_lookup_stages || uses_internal_fields
3648
3717
  use_mongo_direct = true if mongo_ready
3649
- elsif scoped || uses_internal_fields
3650
- if mongo_ready
3651
- use_mongo_direct = true
3652
- elsif scoped
3653
- raise_scoped_aggregation_requires_mongo_direct!
3654
- end
3655
3718
  end
3656
3719
  end
3657
3720
 
@@ -5946,13 +6009,21 @@ module Parse
5946
6009
  if @mongo_direct && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
5947
6010
  @cached_response = execute_direct!
5948
6011
  else
6012
+ # REST /aggregate is master-key-only. An ambient Parse.with_session
6013
+ # block would suppress the master key via session_token, causing a
6014
+ # 401/403. Force use_master_key unless the caller explicitly disabled
6015
+ # it (use_master_key: false is a deliberate client-mode decision).
6016
+ # `.dup` keeps the master-key flip local to this call even if `_opts`
6017
+ # ever returns a shared/memoized hash.
6018
+ rest_opts = @query.send(:_opts).dup
6019
+ rest_opts[:use_master_key] = true unless rest_opts[:use_master_key] == false
5949
6020
  @cached_response = @query.client.aggregate_pipeline(
5950
6021
  @query.instance_variable_get(:@table),
5951
6022
  @pipeline,
5952
6023
  headers: {},
5953
6024
  raw_values: @raw_values,
5954
6025
  raw_field_names: @raw_field_names,
5955
- **@query.send(:_opts),
6026
+ **rest_opts,
5956
6027
  )
5957
6028
  end
5958
6029
 
@@ -6338,16 +6409,24 @@ module Parse
6338
6409
  # @return [Array<Hash>] raw aggregation results
6339
6410
  def raw(operation, aggregation_expr)
6340
6411
  formatted_group_field = @query.send(:format_aggregation_field, @group_field)
6341
- pipeline = build_pipeline(formatted_group_field, aggregation_expr)
6342
6412
 
6343
- response = @query.client.aggregate_pipeline(
6344
- @query.instance_variable_get(:@table),
6345
- pipeline,
6346
- headers: {},
6347
- **@query.send(:_opts),
6348
- )
6413
+ # Build the same pipeline the count/sum/etc. terminals use, then delegate
6414
+ # to Query#aggregate. That central path handles scoped-query routing
6415
+ # (session_token / acl_user / acl_role / ambient Parse.with_session →
6416
+ # auto-promote to mongo-direct, or fail closed when unavailable) so a
6417
+ # scoped `raw` is never sent to the master-key-only REST /aggregate
6418
+ # endpoint, and it returns the raw Array<Hash> rows this method documents.
6419
+ # `$match` from the query's where constraints is added by Query#aggregate.
6420
+ pipeline = []
6421
+ pipeline << { "$unwind" => "$#{formatted_group_field}" } if @flatten_arrays
6422
+ pipeline << { "$group" => { "_id" => "$#{formatted_group_field}", "count" => aggregation_expr } }
6423
+ add_fields = size_addfields_stage
6424
+ pipeline << add_fields if add_fields
6425
+ sort = sort_stage
6426
+ pipeline << sort if sort
6427
+ pipeline << { "$project" => { "_id" => 0, "objectId" => "$_id", "count" => 1 } }
6349
6428
 
6350
- response.result || []
6429
+ @query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate)).raw || []
6351
6430
  end
6352
6431
 
6353
6432
  # Count the number of items in each group.
@@ -6491,8 +6570,12 @@ module Parse
6491
6570
  # already does this auto-promotion (lib/parse/agent/tools.rb), this
6492
6571
  # is the equivalent at the Query layer for direct SDK callers.
6493
6572
  use_mongo_direct = @mongo_direct
6494
- if !use_mongo_direct && query_is_scoped? && parse_mongodb_available?
6495
- use_mongo_direct = true
6573
+ if !use_mongo_direct && query_is_scoped?
6574
+ if parse_mongodb_available?
6575
+ use_mongo_direct = true
6576
+ else
6577
+ @query.send(:raise_scoped_aggregation_requires_mongo_direct!)
6578
+ end
6496
6579
  end
6497
6580
 
6498
6581
  if use_mongo_direct
@@ -6729,15 +6812,22 @@ module Parse
6729
6812
  end
6730
6813
 
6731
6814
  # Whether the parent query carries any non-master-key auth scope. A
6732
- # session_token, acl_user, or acl_role means the caller expects the
6733
- # results to be filtered by ACL — which only happens on the SDK's
6734
- # mongo-direct path. Used to decide whether to auto-promote the REST
6735
- # aggregation path to mongo-direct.
6815
+ # session_token, acl_user, acl_role, or an active Parse.with_session
6816
+ # ambient means the caller expects ACL-filtered results — which only
6817
+ # the SDK's mongo-direct path provides. Used to decide whether to
6818
+ # auto-promote the REST aggregation path to mongo-direct.
6736
6819
  def query_is_scoped?
6737
6820
  st = @query.session_token
6738
6821
  return true if st.is_a?(String) && !st.empty?
6739
6822
  return true if @query.instance_variable_get(:@acl_user)
6740
6823
  return true if @query.instance_variable_get(:@acl_role)
6824
+ # Ambient Parse.with_session counts as scope only when the query did not
6825
+ # explicitly set use_master_key: true (matches Parse::Client#request
6826
+ # precedence — an explicit master-key call skips the ambient session).
6827
+ unless @query.use_master_key == true
6828
+ ambient = @query.send(:ambient_session_token)
6829
+ return true if ambient.is_a?(String) && !ambient.empty?
6830
+ end
6741
6831
  false
6742
6832
  end
6743
6833
 
@@ -7178,14 +7268,19 @@ module Parse
7178
7268
  # Format the date field name
7179
7269
  formatted_date_field = @query.send(:format_aggregation_field, @date_field)
7180
7270
 
7181
- # Auto-promote scoped queries to mongo-direct. See the matching
7182
- # block in `GroupBy#execute_group_aggregation` for the full
7183
- # rationale REST `/aggregate` is master-key-only and unscoped, so
7184
- # session_token / acl_user / acl_role queries need the SDK's
7185
- # mongo-direct enforcement layers to actually filter results.
7271
+ # Auto-promote scoped queries to mongo-direct. REST `/aggregate` is
7272
+ # master-key-only and enforces neither ACL nor CLP — a scoped query
7273
+ # (session_token / acl_user / acl_role, or an active
7274
+ # Parse.with_session block) must use the SDK's enforcement layers.
7275
+ # Fail closed if mongo-direct is unavailable rather than silently
7276
+ # returning unscoped rows. Mirrors the scoped-query gate in Query#aggregate.
7186
7277
  use_mongo_direct = @mongo_direct
7187
- if !use_mongo_direct && query_is_scoped? && parse_mongodb_available?
7188
- use_mongo_direct = true
7278
+ if !use_mongo_direct && query_is_scoped?
7279
+ if parse_mongodb_available?
7280
+ use_mongo_direct = true
7281
+ else
7282
+ @query.send(:raise_scoped_aggregation_requires_mongo_direct!)
7283
+ end
7189
7284
  end
7190
7285
 
7191
7286
  if use_mongo_direct
@@ -7231,11 +7326,21 @@ module Parse
7231
7326
  puts "[VERBOSE AGGREGATE] Sending to: #{@query.instance_variable_get(:@table)}"
7232
7327
  end
7233
7328
 
7329
+ # Parse Server's REST /aggregate endpoint is master-key-only. An active
7330
+ # Parse.with_session block sets a fiber-local ambient session token that
7331
+ # Parse::Client#request picks up and uses in place of the master key,
7332
+ # causing a 401/403 on this endpoint. Force use_master_key: true so the
7333
+ # ambient session cannot suppress it — unless the caller explicitly set
7334
+ # use_master_key: false (deliberate client-mode / session-token intent).
7335
+ # `.dup` keeps the master-key flip local to this call (see Aggregation#execute!).
7336
+ rest_opts = @query.send(:_opts).dup
7337
+ rest_opts[:use_master_key] = true unless rest_opts[:use_master_key] == false
7338
+
7234
7339
  response = @query.client.aggregate_pipeline(
7235
7340
  @query.instance_variable_get(:@table),
7236
7341
  pipeline,
7237
7342
  headers: {},
7238
- **@query.send(:_opts),
7343
+ **rest_opts,
7239
7344
  )
7240
7345
 
7241
7346
  if @query.instance_variable_get(:@verbose_aggregate)
@@ -7262,6 +7367,18 @@ module Parse
7262
7367
  end
7263
7368
  result_hash
7264
7369
  else
7370
+ unless response.success?
7371
+ # Surface the failure (the result would otherwise be a silent `{}`)
7372
+ # through the configured logger rather than unconditional stderr.
7373
+ # Log the error code + message, not a full `inspect`, to avoid
7374
+ # echoing an unbounded server payload into logs.
7375
+ logger = Parse.respond_to?(:logger) ? Parse.logger : nil
7376
+ logger&.warn(
7377
+ "[Parse::GroupByDate] aggregate failed " \
7378
+ "(#{@query.instance_variable_get(:@table)} :#{@date_field} :#{@interval}): " \
7379
+ "code=#{response.code} #{response.error}"
7380
+ )
7381
+ end
7265
7382
  {}
7266
7383
  end
7267
7384
  end
@@ -7442,15 +7559,23 @@ module Parse
7442
7559
  { "$sort" => { field => dir } }
7443
7560
  end
7444
7561
 
7445
- # Mirror of {GroupBy#query_is_scoped?}. A session_token, acl_user, or
7446
- # acl_role on the parent query means the caller expects ACL filtering,
7447
- # which only the mongo-direct path provides — Parse Server REST
7448
- # `/aggregate` is master-key-only and unscoped.
7562
+ # Mirror of {GroupBy#query_is_scoped?}. A session_token, acl_user,
7563
+ # acl_role, or an active Parse.with_session ambient means the caller
7564
+ # expects ACL-filtered results — which only the mongo-direct path
7565
+ # provides. Parse Server REST `/aggregate` is master-key-only and
7566
+ # unscoped.
7449
7567
  def query_is_scoped?
7450
7568
  st = @query.session_token
7451
7569
  return true if st.is_a?(String) && !st.empty?
7452
7570
  return true if @query.instance_variable_get(:@acl_user)
7453
7571
  return true if @query.instance_variable_get(:@acl_role)
7572
+ # Ambient Parse.with_session counts as scope only when the query did not
7573
+ # explicitly set use_master_key: true (matches Parse::Client#request
7574
+ # precedence — an explicit master-key call skips the ambient session).
7575
+ unless @query.use_master_key == true
7576
+ ambient = @query.send(:ambient_session_token)
7577
+ return true if ambient.is_a?(String) && !ambient.empty?
7578
+ end
7454
7579
  false
7455
7580
  end
7456
7581
 
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.5.0"
9
+ VERSION = "5.5.2"
10
10
  end
11
11
  end
data/lib/parse/stack.rb CHANGED
@@ -582,8 +582,19 @@ module Parse
582
582
 
583
583
  # Optional dedicated Moneta store for the synchronize-create lock. When
584
584
  # nil, falls back to {Parse.cache}.
585
+ #
586
+ # SECURITY: if you pass a raw Moneta-Redis store, build it with
587
+ # +value_serializer: nil+. The lock release path reads the stored owner
588
+ # token back (+store[key]+) to compare-and-delete; with Moneta's default
589
+ # Marshal value serializer that read +Marshal.load+s bytes from Redis — an
590
+ # RCE vector on a shared/untrusted/MITM'd lock store. With
591
+ # +value_serializer: nil+ the owner token is a plain string and is never
592
+ # deserialized. Alternatively pass a {Parse::Cache::Redis} instance, which
593
+ # uses a raw-string acquire/release path and avoids Marshal entirely.
585
594
  # @example
586
- # Parse.synchronize_create_store = Moneta.new(:Redis, url: "redis://locks:6379/1")
595
+ # Parse.synchronize_create_store = Moneta.new(:Redis, url: "redis://locks:6379/1", value_serializer: nil)
596
+ # # or, preferred:
597
+ # Parse.synchronize_create_store = Parse::Cache::Redis.new(url: "redis://locks:6379/1")
587
598
  @synchronize_create_store = nil
588
599
 
589
600
  # Optional allowlist of {Parse::Object} subclasses that may use the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.5.0
4
+ version: 5.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin