parse-stack-next 5.4.1 → 5.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +489 -0
- data/Gemfile.lock +1 -1
- data/README.md +61 -9
- data/docs/atlas_vector_search_guide.md +318 -19
- data/lib/parse/acl_scope.rb +11 -0
- data/lib/parse/agent/mcp_rack_app.rb +53 -14
- data/lib/parse/agent/mcp_server.rb +19 -0
- data/lib/parse/api/path_segment.rb +31 -0
- data/lib/parse/api/users.rb +13 -0
- data/lib/parse/cache/redis.rb +55 -11
- data/lib/parse/client/caching.rb +12 -3
- data/lib/parse/client/logging.rb +9 -0
- data/lib/parse/client.rb +37 -3
- data/lib/parse/embeddings/batch_embedder.rb +188 -0
- data/lib/parse/embeddings/cache.rb +374 -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/properties.rb +42 -5
- data/lib/parse/model/core/vector_searchable.rb +157 -8
- data/lib/parse/mongodb.rb +12 -0
- data/lib/parse/pipeline_security.rb +81 -15
- data/lib/parse/query/constraint.rb +22 -0
- data/lib/parse/query/constraints.rb +271 -250
- data/lib/parse/query.rb +284 -43
- 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/stack.rb +12 -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
|
|
@@ -1928,11 +1965,21 @@ module Parse
|
|
|
1928
1965
|
# roles, injects the three-layer ACL simulation
|
|
1929
1966
|
# (top-level `$match`, `$lookup` rewriter, post-fetch
|
|
1930
1967
|
# redactor) via {Parse::MongoDB.aggregate}.
|
|
1968
|
+
# * an active `Parse.with_session` block — the fiber-local ambient
|
|
1969
|
+
# session token scopes the read the same way an explicit
|
|
1970
|
+
# `session_token=` would (see {#mongo_direct_auth_kwargs}).
|
|
1931
1971
|
#
|
|
1932
1972
|
# Raises a clear {MongoDirectRequired} otherwise.
|
|
1933
1973
|
# @!visibility private
|
|
1934
1974
|
def assert_mongo_direct_routable!
|
|
1935
1975
|
has_session = @session_token.is_a?(String) && !@session_token.empty?
|
|
1976
|
+
# An active `Parse.with_session` block scopes the read even on a
|
|
1977
|
+
# non-master client (client_mode, or a user-scoped client with no
|
|
1978
|
+
# master key), where `server_mode_master` is false. Without this the
|
|
1979
|
+
# query would raise instead of running scoped — and on a master
|
|
1980
|
+
# client the ambient is what `mongo_direct_auth_kwargs` forwards so
|
|
1981
|
+
# the read is scoped rather than silently master.
|
|
1982
|
+
has_ambient_session = !ambient_session_token.nil?
|
|
1936
1983
|
# Mirror the request-layer auth resolution in Parse::Client#request:
|
|
1937
1984
|
# when the process is in "server mode" — Parse.client_mode == false
|
|
1938
1985
|
# AND the resolved Parse::Client has a master_key — and the caller
|
|
@@ -1948,7 +1995,7 @@ module Parse
|
|
|
1948
1995
|
false
|
|
1949
1996
|
end
|
|
1950
1997
|
server_mode_master = (use_master_key != false) && !Parse.client_mode && client_has_master_key
|
|
1951
|
-
unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session
|
|
1998
|
+
unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session || has_ambient_session
|
|
1952
1999
|
raise MongoDirectRequired,
|
|
1953
2000
|
"[Parse::Query] This query uses a constraint that can only run " \
|
|
1954
2001
|
"via mongo-direct. Mongo-direct bypasses Parse Server's enforcement, " \
|
|
@@ -1982,6 +2029,12 @@ module Parse
|
|
|
1982
2029
|
# double-inject).
|
|
1983
2030
|
# * `session_token` is set → forward `session_token:` so
|
|
1984
2031
|
# Parse::ACLScope runs the full three-layer simulation.
|
|
2032
|
+
# * Otherwise, the fiber-local ambient session set by
|
|
2033
|
+
# `Parse.with_session` is forwarded as `session_token:` (unless
|
|
2034
|
+
# the caller explicitly requested `use_master_key: true`), so a
|
|
2035
|
+
# query that auto-routes to mongo-direct inside a `with_session`
|
|
2036
|
+
# block is scoped to that user — matching what the REST path does
|
|
2037
|
+
# in {Parse::Client#request}.
|
|
1985
2038
|
# * Otherwise (master-key path) → forward `master: true`.
|
|
1986
2039
|
# @!visibility private
|
|
1987
2040
|
def mongo_direct_auth_kwargs
|
|
@@ -1999,11 +2052,32 @@ module Parse
|
|
|
1999
2052
|
{ acl_role: @acl_role }
|
|
2000
2053
|
elsif @session_token.is_a?(String) && !@session_token.empty?
|
|
2001
2054
|
{ session_token: @session_token }
|
|
2055
|
+
elsif use_master_key != true && (ambient = ambient_session_token)
|
|
2056
|
+
# No explicit per-query scope, but a `Parse.with_session` block is
|
|
2057
|
+
# active. Mirror Parse::Client#request's precedence (ambient
|
|
2058
|
+
# session wins over the server-mode master default) so the read is
|
|
2059
|
+
# scoped to that user instead of silently running as master with
|
|
2060
|
+
# no ACL/CLP enforcement. An explicit `use_master_key: true` is a
|
|
2061
|
+
# deliberate admin call and skips the ambient, exactly as the REST
|
|
2062
|
+
# path does.
|
|
2063
|
+
{ session_token: ambient }
|
|
2002
2064
|
else
|
|
2003
2065
|
{ master: true }
|
|
2004
2066
|
end
|
|
2005
2067
|
end
|
|
2006
2068
|
|
|
2069
|
+
# The fiber-local ambient session token set by `Parse.with_session`,
|
|
2070
|
+
# or nil. A whitespace-only ambient is treated as absent so it cannot
|
|
2071
|
+
# block the master fallback and then fail a later presence check —
|
|
2072
|
+
# the same guard {Parse::Client#request} applies.
|
|
2073
|
+
# @return [String, nil]
|
|
2074
|
+
# @!visibility private
|
|
2075
|
+
def ambient_session_token
|
|
2076
|
+
return nil unless Parse.respond_to?(:current_session_token)
|
|
2077
|
+
ambient = Parse.current_session_token
|
|
2078
|
+
ambient if ambient.is_a?(String) && !ambient.strip.empty?
|
|
2079
|
+
end
|
|
2080
|
+
|
|
2007
2081
|
# Check if this query contains constraints that require aggregation pipeline processing
|
|
2008
2082
|
# @return [Boolean] true if aggregation pipeline is required
|
|
2009
2083
|
def requires_aggregation_pipeline?
|
|
@@ -3464,15 +3538,60 @@ module Parse
|
|
|
3464
3538
|
complete_pipeline << { "$limit" => @limit }
|
|
3465
3539
|
end
|
|
3466
3540
|
|
|
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
3541
|
# Optimize pipeline by merging consecutive $match stages
|
|
3474
3542
|
complete_pipeline = deduplicate_consecutive_match_stages(complete_pipeline)
|
|
3475
3543
|
|
|
3544
|
+
# Auto-detect whether this aggregation must run via the direct-MongoDB
|
|
3545
|
+
# path instead of Parse Server's REST /aggregate endpoint. Three
|
|
3546
|
+
# independent triggers, each of which REST /aggregate cannot serve:
|
|
3547
|
+
#
|
|
3548
|
+
# * $inQuery / $notInQuery → $lookup stages (the original trigger).
|
|
3549
|
+
# * An SDK-injected ACL $match on the internal _rperm / _wperm columns
|
|
3550
|
+
# (readable_by / publicly_readable / writable_by and friends). Parse
|
|
3551
|
+
# Server's REST aggregate rejects a $match on those columns.
|
|
3552
|
+
# * A scoped query (session_token / scope_to_user / scope_to_role).
|
|
3553
|
+
# REST /aggregate is master-key-only and enforces NEITHER ACL NOR
|
|
3554
|
+
# CLP, so a scoped aggregate sent over REST silently runs unscoped
|
|
3555
|
+
# as the master key — leaking sums/min/max/distinct over rows the
|
|
3556
|
+
# caller cannot read. This is the same enforcement asymmetry the
|
|
3557
|
+
# #distinct / #count / #results auto-routes already guard against;
|
|
3558
|
+
# the scalar terminals (sum/average/min/max/count_distinct) all
|
|
3559
|
+
# funnel through here, so routing them here fixes every one.
|
|
3560
|
+
#
|
|
3561
|
+
# `allow_internal_fields` is forwarded for internal-field pipelines: the
|
|
3562
|
+
# caller-supplied `pipeline` arg was validated above (line ~3343) with
|
|
3563
|
+
# the internal-fields denylist active, so any _rperm/_wperm reference in
|
|
3564
|
+
# the merged pipeline is provably SDK-injected, never user input.
|
|
3565
|
+
uses_internal_fields = pipeline_uses_internal_fields?(complete_pipeline)
|
|
3566
|
+
scoped = distinct_query_is_scoped?
|
|
3567
|
+
mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
3568
|
+
use_mongo_direct = mongo_direct
|
|
3569
|
+
|
|
3570
|
+
if scoped
|
|
3571
|
+
# A scoped aggregation (session_token / scope_to_user / scope_to_role)
|
|
3572
|
+
# must NEVER reach Parse Server's REST /aggregate endpoint — it is
|
|
3573
|
+
# master-key-only and enforces NEITHER ACL NOR CLP, so it would run
|
|
3574
|
+
# unscoped as the master key. This holds even when the caller
|
|
3575
|
+
# explicitly passes `mongo_direct: false`: an explicit false cannot
|
|
3576
|
+
# opt a scoped query out of ACL/CLP enforcement. Promote to mongo-
|
|
3577
|
+
# direct, or fail closed when direct Mongo is unavailable (refuse
|
|
3578
|
+
# rather than leak unscoped rows).
|
|
3579
|
+
if mongo_ready
|
|
3580
|
+
use_mongo_direct = true
|
|
3581
|
+
else
|
|
3582
|
+
raise_scoped_aggregation_requires_mongo_direct!
|
|
3583
|
+
end
|
|
3584
|
+
elsif use_mongo_direct.nil?
|
|
3585
|
+
# Unscoped auto-routing: $inQuery/$notInQuery → $lookup pipelines and
|
|
3586
|
+
# SDK-injected internal-field ($rperm/_wperm) pipelines can't be served
|
|
3587
|
+
# by REST /aggregate, so prefer mongo-direct when available. An unscoped
|
|
3588
|
+
# internal-field pipeline keeps the REST fallback (a master-key
|
|
3589
|
+
# correctness edge, not an enforcement bypass).
|
|
3590
|
+
if (lookup_stages && lookup_stages.any?) || uses_internal_fields
|
|
3591
|
+
use_mongo_direct = true if mongo_ready
|
|
3592
|
+
end
|
|
3593
|
+
end
|
|
3594
|
+
|
|
3476
3595
|
# When the pipeline is bound for direct MongoDB, translate every stage
|
|
3477
3596
|
# through the direct-MongoDB field rewriter so user-supplied stages
|
|
3478
3597
|
# (which use logical Parse field names like `$author`) reach the
|
|
@@ -3484,6 +3603,7 @@ module Parse
|
|
|
3484
3603
|
end
|
|
3485
3604
|
|
|
3486
3605
|
Aggregation.new(self, complete_pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
|
|
3606
|
+
allow_internal_fields: uses_internal_fields,
|
|
3487
3607
|
raw_values: raw_values, raw_field_names: raw_field_names)
|
|
3488
3608
|
end
|
|
3489
3609
|
|
|
@@ -3550,17 +3670,44 @@ module Parse
|
|
|
3550
3670
|
# Build pipeline from current query constraints
|
|
3551
3671
|
pipeline, has_lookup_stages = build_query_aggregate_pipeline
|
|
3552
3672
|
|
|
3673
|
+
# `allow_internal_fields` is computed from the SDK-built portion ONLY
|
|
3674
|
+
# (before appending caller stages): build_query_aggregate_pipeline emits
|
|
3675
|
+
# the _rperm/_wperm $match for readable_by/etc., but `additional_stages`
|
|
3676
|
+
# is caller-supplied and NOT validated here, so we must not sanction an
|
|
3677
|
+
# internal-field reference the caller smuggled in. A scoped query still
|
|
3678
|
+
# routes to mongo-direct regardless (so ACL/CLP enforcement runs).
|
|
3679
|
+
uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
|
|
3680
|
+
|
|
3553
3681
|
# Append any additional stages
|
|
3554
3682
|
pipeline.concat(additional_stages) if additional_stages.any?
|
|
3555
3683
|
|
|
3556
|
-
#
|
|
3684
|
+
# Same routing contract as #aggregate: $lookup subqueries, an SDK ACL
|
|
3685
|
+
# $match, or a scoped query each require the direct-MongoDB path (REST
|
|
3686
|
+
# /aggregate cannot express _rperm/_wperm and is master-key-only/
|
|
3687
|
+
# unenforced). A scoped query fails closed when mongo-direct is
|
|
3688
|
+
# unavailable rather than silently running unscoped as master.
|
|
3689
|
+
scoped = distinct_query_is_scoped?
|
|
3690
|
+
mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
3557
3691
|
use_mongo_direct = mongo_direct
|
|
3558
|
-
|
|
3559
|
-
|
|
3692
|
+
|
|
3693
|
+
if scoped
|
|
3694
|
+
# A scoped aggregation must never reach REST /aggregate (master-key-
|
|
3695
|
+
# only, unenforced) — not even when the caller explicitly passes
|
|
3696
|
+
# mongo_direct: false. Promote to mongo-direct, or fail closed.
|
|
3697
|
+
if mongo_ready
|
|
3698
|
+
use_mongo_direct = true
|
|
3699
|
+
else
|
|
3700
|
+
raise_scoped_aggregation_requires_mongo_direct!
|
|
3701
|
+
end
|
|
3702
|
+
elsif use_mongo_direct.nil?
|
|
3703
|
+
if has_lookup_stages || uses_internal_fields
|
|
3704
|
+
use_mongo_direct = true if mongo_ready
|
|
3705
|
+
end
|
|
3560
3706
|
end
|
|
3561
3707
|
|
|
3562
3708
|
# Create Aggregation directly to avoid double-applying constraints
|
|
3563
|
-
Aggregation.new(self, pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false
|
|
3709
|
+
Aggregation.new(self, pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
|
|
3710
|
+
allow_internal_fields: uses_internal_fields)
|
|
3564
3711
|
end
|
|
3565
3712
|
|
|
3566
3713
|
private
|
|
@@ -3607,6 +3754,16 @@ module Parse
|
|
|
3607
3754
|
end
|
|
3608
3755
|
end
|
|
3609
3756
|
|
|
3757
|
+
# Fold in SDK-built aggregation-pipeline marker stages (the _rperm/_wperm
|
|
3758
|
+
# $match emitted by readable_by/publicly_readable/etc., plus set-equality
|
|
3759
|
+
# and empty_or_nil markers). `compile_where` strips these markers, so
|
|
3760
|
+
# without this extraction an ACL filter on `aggregate_from_query` would
|
|
3761
|
+
# be silently dropped — the same omission that affected `Query#count`.
|
|
3762
|
+
markers = compile_markers
|
|
3763
|
+
if markers.key?("__aggregation_pipeline")
|
|
3764
|
+
markers["__aggregation_pipeline"].each { |stage| pipeline << stage }
|
|
3765
|
+
end
|
|
3766
|
+
|
|
3610
3767
|
# Add $sort stage from order constraints
|
|
3611
3768
|
unless @order.empty?
|
|
3612
3769
|
sort_stage = {}
|
|
@@ -3702,19 +3859,35 @@ module Parse
|
|
|
3702
3859
|
# Parse Server blocks these for security - must use MongoDB direct
|
|
3703
3860
|
use_mongo_direct = false
|
|
3704
3861
|
|
|
3862
|
+
# When the SDK-built pipeline references internal ACL columns
|
|
3863
|
+
# (_rperm/_wperm via readable_by/writable_by/publicly_readable and
|
|
3864
|
+
# friends, or _acl), the mongo-direct sink must be told these
|
|
3865
|
+
# references are sanctioned so the PipelineSecurity internal-fields
|
|
3866
|
+
# denylist lets them through. The pipeline here is built entirely
|
|
3867
|
+
# from SDK constraint translation (no caller-supplied stages), so
|
|
3868
|
+
# this is safe — same posture as results_direct/count_direct.
|
|
3869
|
+
uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
|
|
3870
|
+
scoped = distinct_query_is_scoped?
|
|
3871
|
+
|
|
3705
3872
|
# Check for explicit mongo_direct preference first
|
|
3706
3873
|
if defined?(@acl_query_mongo_direct) && !@acl_query_mongo_direct.nil?
|
|
3707
3874
|
use_mongo_direct = @acl_query_mongo_direct
|
|
3708
3875
|
elsif defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
3709
|
-
# Auto-detect based on pipeline contents
|
|
3710
|
-
if has_lookup_stages ||
|
|
3876
|
+
# Auto-detect based on pipeline contents and query scope
|
|
3877
|
+
if scoped || has_lookup_stages || uses_internal_fields
|
|
3711
3878
|
use_mongo_direct = true
|
|
3712
3879
|
end
|
|
3880
|
+
elsif scoped
|
|
3881
|
+
# Same fail-closed contract as #aggregate / #aggregate_from_query:
|
|
3882
|
+
# a scoped pipeline must not fall back to REST /aggregate, which
|
|
3883
|
+
# would drop the scope and return rows the caller cannot read.
|
|
3884
|
+
raise_scoped_aggregation_requires_mongo_direct!
|
|
3713
3885
|
end
|
|
3714
3886
|
|
|
3715
3887
|
# Create Aggregation directly to avoid double-applying constraints
|
|
3716
3888
|
# The aggregate() method would redundantly add where constraints again
|
|
3717
|
-
Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct
|
|
3889
|
+
Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct,
|
|
3890
|
+
allow_internal_fields: uses_internal_fields)
|
|
3718
3891
|
end
|
|
3719
3892
|
|
|
3720
3893
|
# Check if the pipeline references internal Parse fields that require MongoDB direct access
|
|
@@ -5454,23 +5627,38 @@ module Parse
|
|
|
5454
5627
|
# Strings are used as-is (user IDs or "role:RoleName" format).
|
|
5455
5628
|
# Use "public" for public access, "none" or [] for no read permissions.
|
|
5456
5629
|
#
|
|
5457
|
-
# @param permission [Parse::User, Parse::Role, String, Array]
|
|
5630
|
+
# @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
|
|
5631
|
+
# the permission to check. A `Parse::User` (or User pointer) expands to
|
|
5632
|
+
# the user's objectId plus every role they inherit; a `Parse::Role` (or
|
|
5633
|
+
# role name String / `:ACL.readable_by_role` form) expands up the role
|
|
5634
|
+
# hierarchy. `"public"` / `:public` / `:everyone` / `:world` map to the
|
|
5635
|
+
# `"*"` wildcard. `"none"` / `:none` / `[]` / `nil` match objects with no
|
|
5636
|
+
# read permissions (explicit empty `_rperm`).
|
|
5458
5637
|
# @param mongo_direct [Boolean] if true, forces MongoDB direct query. If nil (default),
|
|
5459
5638
|
# auto-detects based on query complexity. Set to false to force Parse Server aggregation.
|
|
5639
|
+
# @param strict [Boolean] when false (default), the match is **inclusive**:
|
|
5640
|
+
# it ALSO returns publicly-readable rows (`_rperm` contains `"*"`) and
|
|
5641
|
+
# rows with a missing `_rperm` (public by absence), because those are
|
|
5642
|
+
# genuinely readable by the principal. This is access-simulation
|
|
5643
|
+
# semantics ("what can this principal read"). Pass `strict: true` for an
|
|
5644
|
+
# **exact** match — only rows whose `_rperm` literally contains one of
|
|
5645
|
+
# the resolved permissions, with no public/missing rows — which is what
|
|
5646
|
+
# an ownership or security audit wants ("which rows explicitly grant
|
|
5647
|
+
# this principal"). Equivalent to the `:ACL.readable_by_exact` operator.
|
|
5460
5648
|
# @return [Parse::Query] returns self for method chaining
|
|
5461
5649
|
# @note This uses MongoDB aggregation pipeline because Parse Server restricts
|
|
5462
5650
|
# direct queries on internal ACL fields (_rperm/_wperm).
|
|
5463
5651
|
# @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)
|
|
5652
|
+
# Song.query.readable_by("user123") # readable by user ID (+ public)
|
|
5653
|
+
# Song.query.readable_by("role:Admin") # readable by Admin role (+ public)
|
|
5654
|
+
# Song.query.readable_by(current_user) # by user object, roles expanded (+ public)
|
|
5655
|
+
# Song.query.readable_by(:public) # publicly readable objects
|
|
5656
|
+
# Song.query.readable_by("none") # objects with no read permissions
|
|
5657
|
+
# Song.query.readable_by([]) # objects with no read permissions (empty ACL)
|
|
5658
|
+
# Song.query.readable_by("role:Admin", strict: true) # ONLY rows that explicitly grant Admin
|
|
5659
|
+
def readable_by(permission, mongo_direct: nil, strict: false)
|
|
5472
5660
|
@acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
|
|
5473
|
-
where(:ACL.readable_by => permission)
|
|
5661
|
+
where((strict ? :ACL.readable_by_exact : :ACL.readable_by) => permission)
|
|
5474
5662
|
self
|
|
5475
5663
|
end
|
|
5476
5664
|
|
|
@@ -5478,14 +5666,16 @@ module Parse
|
|
|
5478
5666
|
#
|
|
5479
5667
|
# @param role_name [Parse::Role, String, Array] the role name(s) to check
|
|
5480
5668
|
# @param mongo_direct [Boolean] if true, forces MongoDB direct query.
|
|
5669
|
+
# @param strict [Boolean] when true, exact match only — no implicit public
|
|
5670
|
+
# `"*"` and no missing-`_rperm` rows. See {#readable_by}.
|
|
5481
5671
|
# @return [Parse::Query] returns self for method chaining
|
|
5482
5672
|
# @example
|
|
5483
5673
|
# Song.query.readable_by_role("Admin") # Objects readable by Admin role
|
|
5484
5674
|
# Song.query.readable_by_role(["Admin", "Editor"]) # Objects readable by Admin or Editor
|
|
5485
5675
|
# Song.query.readable_by_role(admin_role) # Objects readable by Role object
|
|
5486
|
-
def readable_by_role(role_name, mongo_direct: nil)
|
|
5676
|
+
def readable_by_role(role_name, mongo_direct: nil, strict: false)
|
|
5487
5677
|
@acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
|
|
5488
|
-
where(:ACL.readable_by_role => role_name)
|
|
5678
|
+
where((strict ? :ACL.readable_by_role_exact : :ACL.readable_by_role) => role_name)
|
|
5489
5679
|
self
|
|
5490
5680
|
end
|
|
5491
5681
|
|
|
@@ -5493,23 +5683,27 @@ module Parse
|
|
|
5493
5683
|
# Strings are used as-is (user IDs or "role:RoleName" format).
|
|
5494
5684
|
# Use "public" for public access, "none" or [] for no write permissions.
|
|
5495
5685
|
#
|
|
5496
|
-
# @param permission [Parse::User, Parse::Role, String, Array]
|
|
5686
|
+
# @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
|
|
5687
|
+
# the permission to check. See {#readable_by} for value coercion and
|
|
5688
|
+
# role expansion.
|
|
5497
5689
|
# @param mongo_direct [Boolean] if true, forces MongoDB direct query. If nil (default),
|
|
5498
5690
|
# auto-detects based on query complexity. Set to false to force Parse Server aggregation.
|
|
5691
|
+
# @param strict [Boolean] when true, exact match only — no implicit public
|
|
5692
|
+
# `"*"` and no missing-`_wperm` rows. See {#readable_by}.
|
|
5499
5693
|
# @return [Parse::Query] returns self for method chaining
|
|
5500
5694
|
# @note This uses MongoDB aggregation pipeline because Parse Server restricts
|
|
5501
5695
|
# direct queries on internal ACL fields (_rperm/_wperm).
|
|
5502
5696
|
# @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)
|
|
5697
|
+
# Song.query.writable_by("user123") # writable by user ID (+ public)
|
|
5698
|
+
# Song.query.writable_by("role:Admin") # writable by Admin role (+ public)
|
|
5699
|
+
# Song.query.writable_by(current_user) # by user object, roles expanded (+ public)
|
|
5700
|
+
# Song.query.writable_by(:public) # Publicly writable objects
|
|
5701
|
+
# Song.query.writable_by("none") # objects with no write permissions
|
|
5702
|
+
# Song.query.writable_by([]) # objects with no write permissions (empty ACL)
|
|
5703
|
+
# Song.query.writable_by("role:Admin", strict: true) # ONLY rows that explicitly grant Admin
|
|
5704
|
+
def writable_by(permission, mongo_direct: nil, strict: false)
|
|
5511
5705
|
@acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
|
|
5512
|
-
where(:ACL.writable_by => permission)
|
|
5706
|
+
where((strict ? :ACL.writable_by_exact : :ACL.writable_by) => permission)
|
|
5513
5707
|
self
|
|
5514
5708
|
end
|
|
5515
5709
|
|
|
@@ -5517,14 +5711,16 @@ module Parse
|
|
|
5517
5711
|
#
|
|
5518
5712
|
# @param role_name [Parse::Role, String, Array] the role name(s) to check
|
|
5519
5713
|
# @param mongo_direct [Boolean] if true, forces MongoDB direct query.
|
|
5714
|
+
# @param strict [Boolean] when true, exact match only — no implicit public
|
|
5715
|
+
# `"*"` and no missing-`_wperm` rows. See {#readable_by}.
|
|
5520
5716
|
# @return [Parse::Query] returns self for method chaining
|
|
5521
5717
|
# @example
|
|
5522
5718
|
# Song.query.writable_by_role("Admin") # Objects writable by Admin role
|
|
5523
5719
|
# Song.query.writable_by_role(["Admin", "Editor"]) # Objects writable by Admin or Editor
|
|
5524
5720
|
# Song.query.writable_by_role(admin_role) # Objects writable by Role object
|
|
5525
|
-
def writable_by_role(role_name, mongo_direct: nil)
|
|
5721
|
+
def writable_by_role(role_name, mongo_direct: nil, strict: false)
|
|
5526
5722
|
@acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
|
|
5527
|
-
where(:ACL.writable_by_role => role_name)
|
|
5723
|
+
where((strict ? :ACL.writable_by_role_exact : :ACL.writable_by_role) => role_name)
|
|
5528
5724
|
self
|
|
5529
5725
|
end
|
|
5530
5726
|
|
|
@@ -5599,6 +5795,38 @@ module Parse
|
|
|
5599
5795
|
|
|
5600
5796
|
alias_method :master_key_only, :private_acl
|
|
5601
5797
|
|
|
5798
|
+
# Find objects that are NOT readable by the given principal — i.e. hidden
|
|
5799
|
+
# from them. Excludes rows readable by the principal directly, via any role
|
|
5800
|
+
# they inherit, OR publicly (a public row is readable by everyone), and
|
|
5801
|
+
# excludes rows with a missing `_rperm` (public by absence).
|
|
5802
|
+
#
|
|
5803
|
+
# @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
|
|
5804
|
+
# the principal to hide from. See {#readable_by} for value coercion.
|
|
5805
|
+
# @param mongo_direct [Boolean] if true, forces MongoDB direct query.
|
|
5806
|
+
# @return [Parse::Query] returns self for method chaining
|
|
5807
|
+
# @example
|
|
5808
|
+
# Song.query.not_readable_by(current_user).results # hidden from this user
|
|
5809
|
+
def not_readable_by(permission, mongo_direct: nil)
|
|
5810
|
+
@acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
|
|
5811
|
+
where(:ACL.not_readable_by => permission)
|
|
5812
|
+
self
|
|
5813
|
+
end
|
|
5814
|
+
|
|
5815
|
+
# Find objects that are NOT writable by the given principal. See
|
|
5816
|
+
# {#not_readable_by} for the exclusion semantics (direct, role, public).
|
|
5817
|
+
#
|
|
5818
|
+
# @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
|
|
5819
|
+
# the principal to exclude. See {#readable_by} for value coercion.
|
|
5820
|
+
# @param mongo_direct [Boolean] if true, forces MongoDB direct query.
|
|
5821
|
+
# @return [Parse::Query] returns self for method chaining
|
|
5822
|
+
# @example
|
|
5823
|
+
# Song.query.not_writable_by("role:Admin").results
|
|
5824
|
+
def not_writable_by(permission, mongo_direct: nil)
|
|
5825
|
+
@acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
|
|
5826
|
+
where(:ACL.not_writable_by => permission)
|
|
5827
|
+
self
|
|
5828
|
+
end
|
|
5829
|
+
|
|
5602
5830
|
# Find objects that are NOT publicly readable.
|
|
5603
5831
|
# Matches objects where _rperm does NOT contain "*".
|
|
5604
5832
|
#
|
|
@@ -5728,8 +5956,19 @@ module Parse
|
|
|
5728
5956
|
# aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
|
|
5729
5957
|
# @param raw_field_names [Boolean] when true, passes +rawFieldNames: true+ to the Parse Server
|
|
5730
5958
|
# REST aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
|
|
5959
|
+
# @param allow_internal_fields [Boolean] when true, the mongo-direct path
|
|
5960
|
+
# forwards +allow_internal_fields: true+ to {Parse::MongoDB.aggregate} so
|
|
5961
|
+
# SDK-built ACL `$match` stages that legitimately reference +_rperm+ /
|
|
5962
|
+
# +_wperm+ (emitted by {Parse::Query#readable_by}, +#publicly_readable+,
|
|
5963
|
+
# and friends) pass the pipeline-security internal-fields denylist —
|
|
5964
|
+
# matching the parity already held by +results_direct+ / +count_direct+ /
|
|
5965
|
+
# +distinct_direct+. Set +true+ ONLY when this Aggregation's pipeline was
|
|
5966
|
+
# built entirely from SDK constraint translation (no caller-supplied
|
|
5967
|
+
# stages); the credential-field guard (`_hashed_password`, session tokens,
|
|
5968
|
+
# auth data) is what +allow_internal_fields+ relaxes, so it must never be
|
|
5969
|
+
# set on a pipeline that interpolates user input. Defaults to +false+.
|
|
5731
5970
|
def initialize(query, pipeline, verbose: nil, mongo_direct: false, max_time_ms: nil,
|
|
5732
|
-
raw_values: false, raw_field_names: false)
|
|
5971
|
+
raw_values: false, raw_field_names: false, allow_internal_fields: false)
|
|
5733
5972
|
@query = query
|
|
5734
5973
|
@pipeline = pipeline
|
|
5735
5974
|
@cached_response = nil
|
|
@@ -5737,6 +5976,7 @@ module Parse
|
|
|
5737
5976
|
@max_time_ms = max_time_ms
|
|
5738
5977
|
@raw_values = raw_values
|
|
5739
5978
|
@raw_field_names = raw_field_names
|
|
5979
|
+
@allow_internal_fields = allow_internal_fields
|
|
5740
5980
|
# Use provided verbose setting, or fall back to query's verbose_aggregate setting
|
|
5741
5981
|
@verbose = verbose.nil? ? @query.instance_variable_get(:@verbose_aggregate) : verbose
|
|
5742
5982
|
end
|
|
@@ -5789,7 +6029,8 @@ module Parse
|
|
|
5789
6029
|
# honors it on the mongo-direct path too (parity with results_direct /
|
|
5790
6030
|
# count_direct / distinct_direct).
|
|
5791
6031
|
hint = @query.instance_variable_get(:@hint)
|
|
5792
|
-
Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint,
|
|
6032
|
+
Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint,
|
|
6033
|
+
allow_internal_fields: @allow_internal_fields, **auth_kwargs)
|
|
5793
6034
|
end
|
|
5794
6035
|
|
|
5795
6036
|
# 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)
|