parse-stack-next 5.4.1 → 5.5.0

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
@@ -1252,14 +1252,32 @@ module Parse
1252
1252
  pipeline, has_lookup_stages = build_aggregation_pipeline
1253
1253
  pipeline << { "$count" => "count" }
1254
1254
 
1255
- # Auto-detect if MongoDB direct is needed
1255
+ # Auto-detect if MongoDB direct is needed. Mirror the routing in
1256
+ # #execute_aggregation_pipeline: a pipeline that references internal
1257
+ # ACL columns (_rperm/_wperm via readable_by/publicly_readable and
1258
+ # friends) MUST run mongo-direct — Parse Server's REST aggregate
1259
+ # endpoint cannot express a $match on those columns — and the
1260
+ # mongo-direct sink must be told the references are sanctioned so
1261
+ # the PipelineSecurity internal-fields denylist lets them through.
1262
+ uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
1263
+ scoped = distinct_query_is_scoped?
1256
1264
  use_mongo_direct = false
1257
- if has_lookup_stages && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
1265
+ if defined?(@acl_query_mongo_direct) && !@acl_query_mongo_direct.nil?
1266
+ use_mongo_direct = @acl_query_mongo_direct
1267
+ elsif (scoped || has_lookup_stages || uses_internal_fields) &&
1268
+ defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
1258
1269
  use_mongo_direct = true
1270
+ elsif scoped
1271
+ # Same fail-closed contract as #aggregate / #aggregate_from_query:
1272
+ # a scoped count must not fall back to REST /aggregate, which
1273
+ # would drop the scope and count rows the caller cannot read.
1274
+ raise_scoped_aggregation_requires_mongo_direct!
1259
1275
  end
1260
1276
 
1261
1277
  # Execute aggregation
1262
- aggregation = Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct)
1278
+ aggregation = Aggregation.new(self, pipeline, verbose: @verbose_aggregate,
1279
+ mongo_direct: use_mongo_direct,
1280
+ allow_internal_fields: uses_internal_fields)
1263
1281
  response = aggregation.execute!
1264
1282
 
1265
1283
  # Extract count from aggregation result
@@ -1803,6 +1821,25 @@ module Parse
1803
1821
  false
1804
1822
  end
1805
1823
 
1824
+ # Fail closed for a scoped aggregation that would otherwise fall back
1825
+ # to REST /aggregate. That endpoint is master-key-only and enforces
1826
+ # neither ACL nor CLP, so letting a scoped query through would silently
1827
+ # run it unscoped as the master key. Every aggregation terminal that
1828
+ # routes a scoped query (aggregate, aggregate_from_query, count,
1829
+ # execute_aggregation_pipeline) raises through here.
1830
+ # @raise [MongoDirectRequired]
1831
+ # @api private
1832
+ def raise_scoped_aggregation_requires_mongo_direct!
1833
+ raise MongoDirectRequired,
1834
+ "[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."
1841
+ end
1842
+
1806
1843
  # Scope a query to a specific user's row-level ACL when it auto-routes
1807
1844
  # through mongo-direct. The SDK records the user, computes the
1808
1845
  # effective `_rperm` allow-set (user objectId + `"*"` + every role
@@ -3464,15 +3501,51 @@ module Parse
3464
3501
  complete_pipeline << { "$limit" => @limit }
3465
3502
  end
3466
3503
 
3467
- # Auto-detect if mongo_direct is needed (when $inQuery constraints are present and MongoDB is available)
3468
- use_mongo_direct = mongo_direct
3469
- if use_mongo_direct.nil? && lookup_stages && lookup_stages.any? && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3470
- use_mongo_direct = true
3471
- end
3472
-
3473
3504
  # Optimize pipeline by merging consecutive $match stages
3474
3505
  complete_pipeline = deduplicate_consecutive_match_stages(complete_pipeline)
3475
3506
 
3507
+ # Auto-detect whether this aggregation must run via the direct-MongoDB
3508
+ # path instead of Parse Server's REST /aggregate endpoint. Three
3509
+ # independent triggers, each of which REST /aggregate cannot serve:
3510
+ #
3511
+ # * $inQuery / $notInQuery → $lookup stages (the original trigger).
3512
+ # * An SDK-injected ACL $match on the internal _rperm / _wperm columns
3513
+ # (readable_by / publicly_readable / writable_by and friends). Parse
3514
+ # Server's REST aggregate rejects a $match on those columns.
3515
+ # * A scoped query (session_token / scope_to_user / scope_to_role).
3516
+ # REST /aggregate is master-key-only and enforces NEITHER ACL NOR
3517
+ # CLP, so a scoped aggregate sent over REST silently runs unscoped
3518
+ # as the master key — leaking sums/min/max/distinct over rows the
3519
+ # caller cannot read. This is the same enforcement asymmetry the
3520
+ # #distinct / #count / #results auto-routes already guard against;
3521
+ # the scalar terminals (sum/average/min/max/count_distinct) all
3522
+ # funnel through here, so routing them here fixes every one.
3523
+ #
3524
+ # `allow_internal_fields` is forwarded for internal-field pipelines: the
3525
+ # caller-supplied `pipeline` arg was validated above (line ~3343) with
3526
+ # the internal-fields denylist active, so any _rperm/_wperm reference in
3527
+ # the merged pipeline is provably SDK-injected, never user input.
3528
+ uses_internal_fields = pipeline_uses_internal_fields?(complete_pipeline)
3529
+ scoped = distinct_query_is_scoped?
3530
+ 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?
3534
+ 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
+ end
3547
+ end
3548
+
3476
3549
  # When the pipeline is bound for direct MongoDB, translate every stage
3477
3550
  # through the direct-MongoDB field rewriter so user-supplied stages
3478
3551
  # (which use logical Parse field names like `$author`) reach the
@@ -3484,6 +3557,7 @@ module Parse
3484
3557
  end
3485
3558
 
3486
3559
  Aggregation.new(self, complete_pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
3560
+ allow_internal_fields: uses_internal_fields,
3487
3561
  raw_values: raw_values, raw_field_names: raw_field_names)
3488
3562
  end
3489
3563
 
@@ -3550,17 +3624,40 @@ module Parse
3550
3624
  # Build pipeline from current query constraints
3551
3625
  pipeline, has_lookup_stages = build_query_aggregate_pipeline
3552
3626
 
3627
+ # `allow_internal_fields` is computed from the SDK-built portion ONLY
3628
+ # (before appending caller stages): build_query_aggregate_pipeline emits
3629
+ # the _rperm/_wperm $match for readable_by/etc., but `additional_stages`
3630
+ # is caller-supplied and NOT validated here, so we must not sanction an
3631
+ # internal-field reference the caller smuggled in. A scoped query still
3632
+ # routes to mongo-direct regardless (so ACL/CLP enforcement runs).
3633
+ uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
3634
+
3553
3635
  # Append any additional stages
3554
3636
  pipeline.concat(additional_stages) if additional_stages.any?
3555
3637
 
3556
- # Auto-detect if mongo_direct is needed (when $inQuery constraints are present and MongoDB is available)
3638
+ # Same routing contract as #aggregate: $lookup subqueries, an SDK ACL
3639
+ # $match, or a scoped query each require the direct-MongoDB path (REST
3640
+ # /aggregate cannot express _rperm/_wperm and is master-key-only/
3641
+ # unenforced). A scoped query fails closed when mongo-direct is
3642
+ # unavailable rather than silently running unscoped as master.
3643
+ scoped = distinct_query_is_scoped?
3557
3644
  use_mongo_direct = mongo_direct
3558
- if use_mongo_direct.nil? && has_lookup_stages && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3559
- use_mongo_direct = true
3645
+ if use_mongo_direct.nil?
3646
+ mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3647
+ if has_lookup_stages
3648
+ 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
+ end
3560
3656
  end
3561
3657
 
3562
3658
  # Create Aggregation directly to avoid double-applying constraints
3563
- Aggregation.new(self, pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false)
3659
+ Aggregation.new(self, pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
3660
+ allow_internal_fields: uses_internal_fields)
3564
3661
  end
3565
3662
 
3566
3663
  private
@@ -3607,6 +3704,16 @@ module Parse
3607
3704
  end
3608
3705
  end
3609
3706
 
3707
+ # Fold in SDK-built aggregation-pipeline marker stages (the _rperm/_wperm
3708
+ # $match emitted by readable_by/publicly_readable/etc., plus set-equality
3709
+ # and empty_or_nil markers). `compile_where` strips these markers, so
3710
+ # without this extraction an ACL filter on `aggregate_from_query` would
3711
+ # be silently dropped — the same omission that affected `Query#count`.
3712
+ markers = compile_markers
3713
+ if markers.key?("__aggregation_pipeline")
3714
+ markers["__aggregation_pipeline"].each { |stage| pipeline << stage }
3715
+ end
3716
+
3610
3717
  # Add $sort stage from order constraints
3611
3718
  unless @order.empty?
3612
3719
  sort_stage = {}
@@ -3702,19 +3809,35 @@ module Parse
3702
3809
  # Parse Server blocks these for security - must use MongoDB direct
3703
3810
  use_mongo_direct = false
3704
3811
 
3812
+ # When the SDK-built pipeline references internal ACL columns
3813
+ # (_rperm/_wperm via readable_by/writable_by/publicly_readable and
3814
+ # friends, or _acl), the mongo-direct sink must be told these
3815
+ # references are sanctioned so the PipelineSecurity internal-fields
3816
+ # denylist lets them through. The pipeline here is built entirely
3817
+ # from SDK constraint translation (no caller-supplied stages), so
3818
+ # this is safe — same posture as results_direct/count_direct.
3819
+ uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
3820
+ scoped = distinct_query_is_scoped?
3821
+
3705
3822
  # Check for explicit mongo_direct preference first
3706
3823
  if defined?(@acl_query_mongo_direct) && !@acl_query_mongo_direct.nil?
3707
3824
  use_mongo_direct = @acl_query_mongo_direct
3708
3825
  elsif defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3709
- # Auto-detect based on pipeline contents
3710
- if has_lookup_stages || pipeline_uses_internal_fields?(pipeline)
3826
+ # Auto-detect based on pipeline contents and query scope
3827
+ if scoped || has_lookup_stages || uses_internal_fields
3711
3828
  use_mongo_direct = true
3712
3829
  end
3830
+ elsif scoped
3831
+ # Same fail-closed contract as #aggregate / #aggregate_from_query:
3832
+ # a scoped pipeline must not fall back to REST /aggregate, which
3833
+ # would drop the scope and return rows the caller cannot read.
3834
+ raise_scoped_aggregation_requires_mongo_direct!
3713
3835
  end
3714
3836
 
3715
3837
  # Create Aggregation directly to avoid double-applying constraints
3716
3838
  # The aggregate() method would redundantly add where constraints again
3717
- Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct)
3839
+ Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct,
3840
+ allow_internal_fields: uses_internal_fields)
3718
3841
  end
3719
3842
 
3720
3843
  # Check if the pipeline references internal Parse fields that require MongoDB direct access
@@ -5454,23 +5577,38 @@ module Parse
5454
5577
  # Strings are used as-is (user IDs or "role:RoleName" format).
5455
5578
  # Use "public" for public access, "none" or [] for no read permissions.
5456
5579
  #
5457
- # @param permission [Parse::User, Parse::Role, String, Array] the permission to check
5580
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5581
+ # the permission to check. A `Parse::User` (or User pointer) expands to
5582
+ # the user's objectId plus every role they inherit; a `Parse::Role` (or
5583
+ # role name String / `:ACL.readable_by_role` form) expands up the role
5584
+ # hierarchy. `"public"` / `:public` / `:everyone` / `:world` map to the
5585
+ # `"*"` wildcard. `"none"` / `:none` / `[]` / `nil` match objects with no
5586
+ # read permissions (explicit empty `_rperm`).
5458
5587
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query. If nil (default),
5459
5588
  # auto-detects based on query complexity. Set to false to force Parse Server aggregation.
5589
+ # @param strict [Boolean] when false (default), the match is **inclusive**:
5590
+ # it ALSO returns publicly-readable rows (`_rperm` contains `"*"`) and
5591
+ # rows with a missing `_rperm` (public by absence), because those are
5592
+ # genuinely readable by the principal. This is access-simulation
5593
+ # semantics ("what can this principal read"). Pass `strict: true` for an
5594
+ # **exact** match — only rows whose `_rperm` literally contains one of
5595
+ # the resolved permissions, with no public/missing rows — which is what
5596
+ # an ownership or security audit wants ("which rows explicitly grant
5597
+ # this principal"). Equivalent to the `:ACL.readable_by_exact` operator.
5460
5598
  # @return [Parse::Query] returns self for method chaining
5461
5599
  # @note This uses MongoDB aggregation pipeline because Parse Server restricts
5462
5600
  # direct queries on internal ACL fields (_rperm/_wperm).
5463
5601
  # @example
5464
- # Song.query.readable_by("user123") # Objects readable by user ID
5465
- # Song.query.readable_by("role:Admin") # Objects readable by Admin role
5466
- # Song.query.readable_by(current_user) # Objects readable by user object
5467
- # Song.query.readable_by("public") # Publicly readable objects
5468
- # Song.query.readable_by("none") # Objects with no read permissions
5469
- # Song.query.readable_by([]) # Objects with no read permissions (empty ACL)
5470
- # Song.query.readable_by([], mongo_direct: true) # Force MongoDB direct query
5471
- def readable_by(permission, mongo_direct: nil)
5602
+ # Song.query.readable_by("user123") # readable by user ID (+ public)
5603
+ # Song.query.readable_by("role:Admin") # readable by Admin role (+ public)
5604
+ # Song.query.readable_by(current_user) # by user object, roles expanded (+ public)
5605
+ # Song.query.readable_by(:public) # publicly readable objects
5606
+ # Song.query.readable_by("none") # objects with no read permissions
5607
+ # Song.query.readable_by([]) # objects with no read permissions (empty ACL)
5608
+ # Song.query.readable_by("role:Admin", strict: true) # ONLY rows that explicitly grant Admin
5609
+ def readable_by(permission, mongo_direct: nil, strict: false)
5472
5610
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5473
- where(:ACL.readable_by => permission)
5611
+ where((strict ? :ACL.readable_by_exact : :ACL.readable_by) => permission)
5474
5612
  self
5475
5613
  end
5476
5614
 
@@ -5478,14 +5616,16 @@ module Parse
5478
5616
  #
5479
5617
  # @param role_name [Parse::Role, String, Array] the role name(s) to check
5480
5618
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5619
+ # @param strict [Boolean] when true, exact match only — no implicit public
5620
+ # `"*"` and no missing-`_rperm` rows. See {#readable_by}.
5481
5621
  # @return [Parse::Query] returns self for method chaining
5482
5622
  # @example
5483
5623
  # Song.query.readable_by_role("Admin") # Objects readable by Admin role
5484
5624
  # Song.query.readable_by_role(["Admin", "Editor"]) # Objects readable by Admin or Editor
5485
5625
  # Song.query.readable_by_role(admin_role) # Objects readable by Role object
5486
- def readable_by_role(role_name, mongo_direct: nil)
5626
+ def readable_by_role(role_name, mongo_direct: nil, strict: false)
5487
5627
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5488
- where(:ACL.readable_by_role => role_name)
5628
+ where((strict ? :ACL.readable_by_role_exact : :ACL.readable_by_role) => role_name)
5489
5629
  self
5490
5630
  end
5491
5631
 
@@ -5493,23 +5633,27 @@ module Parse
5493
5633
  # Strings are used as-is (user IDs or "role:RoleName" format).
5494
5634
  # Use "public" for public access, "none" or [] for no write permissions.
5495
5635
  #
5496
- # @param permission [Parse::User, Parse::Role, String, Array] the permission to check
5636
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5637
+ # the permission to check. See {#readable_by} for value coercion and
5638
+ # role expansion.
5497
5639
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query. If nil (default),
5498
5640
  # auto-detects based on query complexity. Set to false to force Parse Server aggregation.
5641
+ # @param strict [Boolean] when true, exact match only — no implicit public
5642
+ # `"*"` and no missing-`_wperm` rows. See {#readable_by}.
5499
5643
  # @return [Parse::Query] returns self for method chaining
5500
5644
  # @note This uses MongoDB aggregation pipeline because Parse Server restricts
5501
5645
  # direct queries on internal ACL fields (_rperm/_wperm).
5502
5646
  # @example
5503
- # Song.query.writable_by("user123") # Objects writable by user ID
5504
- # Song.query.writable_by("role:Admin") # Objects writable by Admin role
5505
- # Song.query.writable_by(current_user) # Objects writable by user object
5506
- # Song.query.writable_by("public") # Publicly writable objects
5507
- # Song.query.writable_by("none") # Objects with no write permissions
5508
- # Song.query.writable_by([]) # Objects with no write permissions (empty ACL)
5509
- # Song.query.writable_by([], mongo_direct: true) # Force MongoDB direct query
5510
- def writable_by(permission, mongo_direct: nil)
5647
+ # Song.query.writable_by("user123") # writable by user ID (+ public)
5648
+ # Song.query.writable_by("role:Admin") # writable by Admin role (+ public)
5649
+ # Song.query.writable_by(current_user) # by user object, roles expanded (+ public)
5650
+ # Song.query.writable_by(:public) # Publicly writable objects
5651
+ # Song.query.writable_by("none") # objects with no write permissions
5652
+ # Song.query.writable_by([]) # objects with no write permissions (empty ACL)
5653
+ # Song.query.writable_by("role:Admin", strict: true) # ONLY rows that explicitly grant Admin
5654
+ def writable_by(permission, mongo_direct: nil, strict: false)
5511
5655
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5512
- where(:ACL.writable_by => permission)
5656
+ where((strict ? :ACL.writable_by_exact : :ACL.writable_by) => permission)
5513
5657
  self
5514
5658
  end
5515
5659
 
@@ -5517,14 +5661,16 @@ module Parse
5517
5661
  #
5518
5662
  # @param role_name [Parse::Role, String, Array] the role name(s) to check
5519
5663
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5664
+ # @param strict [Boolean] when true, exact match only — no implicit public
5665
+ # `"*"` and no missing-`_wperm` rows. See {#readable_by}.
5520
5666
  # @return [Parse::Query] returns self for method chaining
5521
5667
  # @example
5522
5668
  # Song.query.writable_by_role("Admin") # Objects writable by Admin role
5523
5669
  # Song.query.writable_by_role(["Admin", "Editor"]) # Objects writable by Admin or Editor
5524
5670
  # Song.query.writable_by_role(admin_role) # Objects writable by Role object
5525
- def writable_by_role(role_name, mongo_direct: nil)
5671
+ def writable_by_role(role_name, mongo_direct: nil, strict: false)
5526
5672
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5527
- where(:ACL.writable_by_role => role_name)
5673
+ where((strict ? :ACL.writable_by_role_exact : :ACL.writable_by_role) => role_name)
5528
5674
  self
5529
5675
  end
5530
5676
 
@@ -5599,6 +5745,38 @@ module Parse
5599
5745
 
5600
5746
  alias_method :master_key_only, :private_acl
5601
5747
 
5748
+ # Find objects that are NOT readable by the given principal — i.e. hidden
5749
+ # from them. Excludes rows readable by the principal directly, via any role
5750
+ # they inherit, OR publicly (a public row is readable by everyone), and
5751
+ # excludes rows with a missing `_rperm` (public by absence).
5752
+ #
5753
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5754
+ # the principal to hide from. See {#readable_by} for value coercion.
5755
+ # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5756
+ # @return [Parse::Query] returns self for method chaining
5757
+ # @example
5758
+ # Song.query.not_readable_by(current_user).results # hidden from this user
5759
+ def not_readable_by(permission, mongo_direct: nil)
5760
+ @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5761
+ where(:ACL.not_readable_by => permission)
5762
+ self
5763
+ end
5764
+
5765
+ # Find objects that are NOT writable by the given principal. See
5766
+ # {#not_readable_by} for the exclusion semantics (direct, role, public).
5767
+ #
5768
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5769
+ # the principal to exclude. See {#readable_by} for value coercion.
5770
+ # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5771
+ # @return [Parse::Query] returns self for method chaining
5772
+ # @example
5773
+ # Song.query.not_writable_by("role:Admin").results
5774
+ def not_writable_by(permission, mongo_direct: nil)
5775
+ @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5776
+ where(:ACL.not_writable_by => permission)
5777
+ self
5778
+ end
5779
+
5602
5780
  # Find objects that are NOT publicly readable.
5603
5781
  # Matches objects where _rperm does NOT contain "*".
5604
5782
  #
@@ -5728,8 +5906,19 @@ module Parse
5728
5906
  # aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5729
5907
  # @param raw_field_names [Boolean] when true, passes +rawFieldNames: true+ to the Parse Server
5730
5908
  # REST aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5909
+ # @param allow_internal_fields [Boolean] when true, the mongo-direct path
5910
+ # forwards +allow_internal_fields: true+ to {Parse::MongoDB.aggregate} so
5911
+ # SDK-built ACL `$match` stages that legitimately reference +_rperm+ /
5912
+ # +_wperm+ (emitted by {Parse::Query#readable_by}, +#publicly_readable+,
5913
+ # and friends) pass the pipeline-security internal-fields denylist —
5914
+ # matching the parity already held by +results_direct+ / +count_direct+ /
5915
+ # +distinct_direct+. Set +true+ ONLY when this Aggregation's pipeline was
5916
+ # built entirely from SDK constraint translation (no caller-supplied
5917
+ # stages); the credential-field guard (`_hashed_password`, session tokens,
5918
+ # auth data) is what +allow_internal_fields+ relaxes, so it must never be
5919
+ # set on a pipeline that interpolates user input. Defaults to +false+.
5731
5920
  def initialize(query, pipeline, verbose: nil, mongo_direct: false, max_time_ms: nil,
5732
- raw_values: false, raw_field_names: false)
5921
+ raw_values: false, raw_field_names: false, allow_internal_fields: false)
5733
5922
  @query = query
5734
5923
  @pipeline = pipeline
5735
5924
  @cached_response = nil
@@ -5737,6 +5926,7 @@ module Parse
5737
5926
  @max_time_ms = max_time_ms
5738
5927
  @raw_values = raw_values
5739
5928
  @raw_field_names = raw_field_names
5929
+ @allow_internal_fields = allow_internal_fields
5740
5930
  # Use provided verbose setting, or fall back to query's verbose_aggregate setting
5741
5931
  @verbose = verbose.nil? ? @query.instance_variable_get(:@verbose_aggregate) : verbose
5742
5932
  end
@@ -5789,7 +5979,8 @@ module Parse
5789
5979
  # honors it on the mongo-direct path too (parity with results_direct /
5790
5980
  # count_direct / distinct_direct).
5791
5981
  hint = @query.instance_variable_get(:@hint)
5792
- Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint, **auth_kwargs)
5982
+ Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint,
5983
+ allow_internal_fields: @allow_internal_fields, **auth_kwargs)
5793
5984
  end
5794
5985
 
5795
5986
  # Returns processed results from the aggregation.
@@ -108,20 +108,27 @@ module Parse
108
108
  score_quantize = (agent.permissions != :admin)
109
109
  vector_field = Parse::Agent::MetadataRegistry.searchable_field(cname)
110
110
 
111
- chunks = Parse::Retrieval.retrieve(
112
- query: query,
113
- klass: klass,
114
- field: vector_field,
115
- text_field: resolved_text_field,
116
- k: clamp_k(k),
117
- filter: filter,
118
- vector_filter: vector_filter,
119
- chunker: build_chunker(chunk_size, chunk_overlap, chunk_by, max_chunks_per_document),
120
- tenant_scope: scope,
121
- score_quantize: score_quantize,
122
- source_transform: source_projector(agent, cname, scope),
123
- **agent.acl_scope_kwargs,
124
- )
111
+ # with_precharged: the cap was charged above with per-tenant
112
+ # identity (or deliberately skipped for trusted admin agents) —
113
+ # suppress the generic query-embed charge inside
114
+ # find_similar/embed_query_text! so the query isn't double-billed
115
+ # (or admin queries billed to the shared default bucket).
116
+ chunks = Parse::Embeddings::SpendCap.with_precharged do
117
+ Parse::Retrieval.retrieve(
118
+ query: query,
119
+ klass: klass,
120
+ field: vector_field,
121
+ text_field: resolved_text_field,
122
+ k: clamp_k(k),
123
+ filter: filter,
124
+ vector_filter: vector_filter,
125
+ chunker: build_chunker(chunk_size, chunk_overlap, chunk_by, max_chunks_per_document),
126
+ tenant_scope: scope,
127
+ score_quantize: score_quantize,
128
+ source_transform: source_projector(agent, cname, scope),
129
+ **agent.acl_scope_kwargs,
130
+ )
131
+ end
125
132
 
126
133
  # Token budget (B4): trim the score-ordered chunk list before
127
134
  # building the envelope so `documents` only carries parents whose
@@ -70,6 +70,84 @@ module Parse
70
70
  obj
71
71
  end
72
72
 
73
+ # Translate Parse pointer VALUES in a caller-supplied filter into
74
+ # their MongoDB storage form so they actually match raw documents.
75
+ # `{ owner: <Parse::Pointer User/abc> }` becomes
76
+ # `{ "_p_owner" => "_User$abc" }` — pointer columns are stored under
77
+ # a `_p_` prefix with `"<className>$<objectId>"` string values, so a
78
+ # Parse-side pointer (a `{__type: "Pointer", ...}` hash on the wire,
79
+ # or a `Parse::Pointer` / `Parse::Object` instance from Ruby
80
+ # callers) in a `$match` / `$vectorSearch.filter` would otherwise
81
+ # never match anything.
82
+ #
83
+ # Recognized pointer values:
84
+ # * `Parse::Pointer` / `Parse::Object` instances,
85
+ # * `{ "__type" => "Pointer", "className" => ..., "objectId" => ... }`
86
+ # hashes (symbol or string keys).
87
+ #
88
+ # Translation applies to direct values, and to pointer values inside
89
+ # one level of operator hashes (`{ owner: { "$in" => [ptr, ptr] } }`,
90
+ # `$eq` / `$ne` / `$nin`). Non-pointer values and unrecognized keys
91
+ # pass through untouched, so the call is idempotent.
92
+ #
93
+ # SECURITY ORDERING: run this AFTER {.assert_no_underscore_keys!} /
94
+ # the agent filter-field allowlist (callers may not name `_p_*`
95
+ # columns directly) and BEFORE the tenant-scope fold.
96
+ #
97
+ # @param klass [Class] the Parse::Object subclass (for field_map
98
+ # wire-name resolution).
99
+ # @param filter [Hash, nil] caller filter.
100
+ # @return [Hash, nil] translated copy (or the input when nothing
101
+ # needed translation / input was nil).
102
+ def translate_pointer_filter_values(klass, filter)
103
+ return filter unless filter.is_a?(Hash)
104
+ out = {}
105
+ filter.each do |key, value|
106
+ if (storage = pointer_storage_value(value))
107
+ out["_p_#{wire_name(klass, key)}"] = storage
108
+ elsif value.is_a?(Hash) && value.keys.any? { |op| op.to_s.start_with?("$") }
109
+ translated = value.transform_values do |opval|
110
+ if (s = pointer_storage_value(opval))
111
+ s
112
+ elsif opval.is_a?(Array)
113
+ opval.map { |el| pointer_storage_value(el) || el }
114
+ else
115
+ opval
116
+ end
117
+ end
118
+ if translated == value
119
+ out[key] = value
120
+ else
121
+ out["_p_#{wire_name(klass, key)}"] = translated
122
+ end
123
+ else
124
+ out[key] = value
125
+ end
126
+ end
127
+ out
128
+ end
129
+
130
+ # @!visibility private
131
+ # `"<className>$<objectId>"` storage string for a pointer-shaped
132
+ # value, or nil when the value is not a pointer.
133
+ def pointer_storage_value(value)
134
+ if defined?(Parse::Pointer) && value.is_a?(Parse::Pointer)
135
+ cname = value.parse_class
136
+ oid = value.id
137
+ return nil if cname.to_s.empty? || oid.to_s.empty?
138
+ return "#{cname}$#{oid}"
139
+ end
140
+ if value.is_a?(Hash)
141
+ type = value["__type"] || value[:__type]
142
+ return nil unless type.to_s == "Pointer"
143
+ cname = value["className"] || value[:className]
144
+ oid = value["objectId"] || value[:objectId]
145
+ return nil if cname.to_s.empty? || oid.to_s.empty?
146
+ return "#{cname}$#{oid}"
147
+ end
148
+ nil
149
+ end
150
+
73
151
  # Retrieve and chunk documents semantically similar to `query`.
74
152
  #
75
153
  # @param query [String] natural-language query.
@@ -136,6 +214,12 @@ module Parse
136
214
  end
137
215
 
138
216
  resolved_text_field = (text_field || infer_text_field!(klass)).to_sym
217
+ # Pointer-value translation runs BEFORE the tenant-scope fold (the
218
+ # fold's conflict check must see final storage-form keys) and after
219
+ # any caller-side underscore-key gate (the agent tool walks the raw
220
+ # filter before calling retrieve).
221
+ filter = translate_pointer_filter_values(klass, filter)
222
+ vector_filter = translate_pointer_filter_values(klass, vector_filter)
139
223
  merged_vector_filter = fold_tenant_scope(klass, vector_filter, tenant_scope)
140
224
  chunker ||= default_chunker
141
225
  text_wire = wire_name(klass, resolved_text_field)
@@ -67,7 +67,7 @@ module Parse
67
67
  def plan
68
68
  coll = collection_name
69
69
  existing, available = fetch_existing_indexes(coll)
70
- declared = @model_class.mongo_search_index_declarations
70
+ declared = @model_class.mongo_search_index_declarations.map { |d| effective_declaration(d) }
71
71
 
72
72
  existing_by_name = existing.each_with_object({}) do |idx, h|
73
73
  name = (idx["name"] || idx[:name]).to_s
@@ -189,6 +189,53 @@ module Parse
189
189
 
190
190
  private
191
191
 
192
+ # Augment a `vectorSearch` declaration with the model's registered
193
+ # `agent_tenant_scope` field as a `type: "filter"` path when the
194
+ # declaration doesn't already carry it. Tenant-scoped retrieval
195
+ # folds `{ <scope field> => <value> }` into `$vectorSearch.filter`
196
+ # (see Parse::Retrieval.retrieve) — Atlas rejects a pre-filter on
197
+ # any path not declared `type: "filter"` in the index, so an index
198
+ # created without the scope path fails every scoped query at
199
+ # runtime. Auto-including it here means `apply!` creates correct
200
+ # indexes by default, and pre-existing indexes lacking the path
201
+ # surface as `drifted:` in the plan instead of failing silently.
202
+ #
203
+ # Lexical (`type: "search"`) declarations pass through untouched.
204
+ def effective_declaration(decl)
205
+ return decl unless decl[:type] == "vectorSearch"
206
+ scope_path = tenant_scope_filter_path
207
+ return decl if scope_path.nil?
208
+ defn = decl[:definition]
209
+ return decl unless defn.is_a?(Hash)
210
+ fields_key = defn.key?("fields") ? "fields" : :fields
211
+ fields = defn[fields_key]
212
+ return decl unless fields.is_a?(Array)
213
+ covered = fields.any? do |f|
214
+ next false unless f.is_a?(Hash)
215
+ (f["type"] || f[:type]).to_s == "filter" &&
216
+ (f["path"] || f[:path]).to_s == scope_path
217
+ end
218
+ return decl if covered
219
+ augmented = defn.dup
220
+ augmented[fields_key] = fields + [{ "type" => "filter", "path" => scope_path }]
221
+ decl.merge(definition: augmented)
222
+ end
223
+
224
+ # Wire/storage path of the model's registered tenant-scope field,
225
+ # or nil when no `agent_tenant_scope` is declared (or the agent
226
+ # layer isn't loaded). Mirrors the wire-name resolution
227
+ # Parse::Retrieval uses when folding the scope into the filter.
228
+ def tenant_scope_filter_path
229
+ return nil unless defined?(Parse::Agent::MetadataRegistry)
230
+ rule = Parse::Agent::MetadataRegistry.tenant_scope_rule(collection_name)
231
+ return nil unless rule
232
+ sym = rule[:field].to_sym
233
+ fmap = @model_class.respond_to?(:field_map) ? @model_class.field_map : {}
234
+ (fmap[sym] || sym.to_s.columnize).to_s
235
+ rescue StandardError
236
+ nil
237
+ end
238
+
192
239
  # Read existing search indexes via the IndexManager's cached path.
193
240
  # Returns `[indexes, available]`. `available` is false when Atlas
194
241
  # isn't reachable (e.g. running against a vanilla Mongo without
@@ -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.4.1"
9
+ VERSION = "5.5.0"
10
10
  end
11
11
  end