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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +344 -0
- data/Gemfile.lock +1 -1
- data/README.md +45 -6
- data/docs/atlas_vector_search_guide.md +314 -19
- data/lib/parse/api/users.rb +10 -0
- data/lib/parse/client.rb +19 -1
- data/lib/parse/embeddings/batch_embedder.rb +188 -0
- data/lib/parse/embeddings/cache.rb +322 -0
- data/lib/parse/embeddings/cohere.rb +31 -18
- data/lib/parse/embeddings/image_fetch.rb +347 -0
- data/lib/parse/embeddings/provider.rb +17 -11
- data/lib/parse/embeddings/spend_cap.rb +117 -3
- data/lib/parse/embeddings/voyage.rb +34 -25
- data/lib/parse/embeddings.rb +40 -3
- data/lib/parse/model/acl.rb +15 -11
- data/lib/parse/model/core/embed_managed.rb +243 -14
- data/lib/parse/model/core/vector_searchable.rb +157 -8
- data/lib/parse/query/constraint.rb +22 -0
- data/lib/parse/query/constraints.rb +271 -250
- data/lib/parse/query.rb +233 -42
- data/lib/parse/retrieval/agent_tool.rb +21 -14
- data/lib/parse/retrieval/retriever.rb +84 -0
- data/lib/parse/schema/search_index_migrator.rb +48 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +39 -1
- data/lib/parse/vector_search.rb +34 -0
- data/lib/parse/webhooks/payload.rb +7 -1
- data/lib/parse/webhooks.rb +107 -21
- metadata +4 -1
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
|
|
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,
|
|
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
|
-
#
|
|
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?
|
|
3559
|
-
|
|
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 ||
|
|
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]
|
|
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")
|
|
5465
|
-
# Song.query.readable_by("role:Admin")
|
|
5466
|
-
# Song.query.readable_by(current_user)
|
|
5467
|
-
# Song.query.readable_by(
|
|
5468
|
-
# Song.query.readable_by("none")
|
|
5469
|
-
# Song.query.readable_by([])
|
|
5470
|
-
# Song.query.readable_by(
|
|
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]
|
|
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")
|
|
5504
|
-
# Song.query.writable_by("role:Admin")
|
|
5505
|
-
# Song.query.writable_by(current_user)
|
|
5506
|
-
# Song.query.writable_by(
|
|
5507
|
-
# Song.query.writable_by("none")
|
|
5508
|
-
# Song.query.writable_by([])
|
|
5509
|
-
# Song.query.writable_by(
|
|
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,
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|