parse-stack-next 5.7.6 → 5.8.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 +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
data/lib/parse/stack.rb
CHANGED
|
@@ -62,6 +62,25 @@ module Parse
|
|
|
62
62
|
# the first attempt and gone by the retry; the fiber-local state lives
|
|
63
63
|
# for the lifetime of the block).
|
|
64
64
|
#
|
|
65
|
+
# Mongo-direct reads follow the same rule: an explicit `master: true`
|
|
66
|
+
# passed to `Parse::MongoDB.aggregate`, a `Parse::Query` direct terminal,
|
|
67
|
+
# Atlas Search, or vector search runs in the public scope inside the
|
|
68
|
+
# block (or raises `ACLRequired` when `require_session_token` is on), and
|
|
69
|
+
# the `parse.mongodb.aggregate` notification payload carries
|
|
70
|
+
# `master_dropped: true`. `Parse::AtlasSearch.faceted_search` refuses to
|
|
71
|
+
# run inside the block. A LiveQuery admin connection opened inside the
|
|
72
|
+
# block sends no master key; one that already connected with it stays
|
|
73
|
+
# elevated, because Parse Server authorizes per connection.
|
|
74
|
+
#
|
|
75
|
+
# The SDK's own metadata reads keep the master key inside the block: the
|
|
76
|
+
# class schemas and role graph it reads to enforce a scope, index
|
|
77
|
+
# statistics, and the Atlas Search index listing. Those requests are
|
|
78
|
+
# marked individually; the block's state is never lifted while they run.
|
|
79
|
+
#
|
|
80
|
+
# This is a guard against accidental master-key use, not an isolation
|
|
81
|
+
# boundary. Code inside the block can call {Parse.with_master_key}, and
|
|
82
|
+
# `Parse::MongoDB.find` and `Parse::MongoDB.indexes` take no scope at all.
|
|
83
|
+
#
|
|
65
84
|
# @yield runs the block with master-key disabled
|
|
66
85
|
# @return [Object] the block's return value
|
|
67
86
|
# @example
|
|
@@ -115,9 +134,12 @@ module Parse
|
|
|
115
134
|
#
|
|
116
135
|
# The `token` argument may be a String, a {Parse::User} (its
|
|
117
136
|
# `session_token` is read), a {Parse::Session} (its `session_token` is
|
|
118
|
-
# read), or `nil`. Passing `nil
|
|
119
|
-
#
|
|
120
|
-
# session
|
|
137
|
+
# read), or `nil`. Passing `nil`, or a user or session that carries no
|
|
138
|
+
# token, runs the block ANONYMOUSLY: requests inside it send neither a
|
|
139
|
+
# session token nor the master key, and a client's bound token is not
|
|
140
|
+
# used either. This is useful for performing one anonymous call inside an
|
|
141
|
+
# otherwise session-scoped region. A call inside the block can still opt
|
|
142
|
+
# out with an explicit `session_token:` or `use_master_key: true`.
|
|
121
143
|
#
|
|
122
144
|
# Fiber-local, not thread-local: concurrent fibers (and threads, since
|
|
123
145
|
# each thread starts with its own root fiber) do not share state.
|
|
@@ -156,17 +178,37 @@ module Parse
|
|
|
156
178
|
"token is refused so the block cannot silently execute with master-key " \
|
|
157
179
|
"authority — pass a valid session token, or `nil` for no ambient session."
|
|
158
180
|
end
|
|
159
|
-
|
|
181
|
+
# `nil` (no token, or a user/session without one) installs the
|
|
182
|
+
# anonymous sentinel rather than clearing the slot. A cleared slot meant
|
|
183
|
+
# "no ambient", which the request layer resolves to the master key on a
|
|
184
|
+
# master-keyed client: the opposite of the documented anonymous block.
|
|
185
|
+
Fiber[SESSION_TOKEN_STATE_KEY] = resolved.nil? ? ANONYMOUS_SESSION : resolved
|
|
160
186
|
yield
|
|
161
187
|
ensure
|
|
162
188
|
Fiber[SESSION_TOKEN_STATE_KEY] = previous
|
|
163
189
|
end
|
|
164
190
|
|
|
191
|
+
# Fiber-state sentinel installed by `Parse.with_session(nil)`. The request
|
|
192
|
+
# layer treats it as "anonymous": no session token and no master key.
|
|
193
|
+
# @!visibility private
|
|
194
|
+
ANONYMOUS_SESSION = :__parse_anonymous_session__
|
|
195
|
+
|
|
165
196
|
# The ambient session token set by {.with_session} for the current
|
|
166
|
-
# fiber, or `nil` when not inside such a block
|
|
197
|
+
# fiber, or `nil` when not inside such a block or inside an anonymous
|
|
198
|
+
# `with_session(nil)` block (see {.anonymous_session?}).
|
|
167
199
|
# @return [String, nil]
|
|
168
200
|
def self.current_session_token
|
|
169
|
-
Fiber[SESSION_TOKEN_STATE_KEY]
|
|
201
|
+
value = Fiber[SESSION_TOKEN_STATE_KEY]
|
|
202
|
+
value == ANONYMOUS_SESSION ? nil : value
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Whether the current fiber is inside an anonymous `Parse.with_session(nil)`
|
|
206
|
+
# block (directly, or nested without a token-bearing block inside it).
|
|
207
|
+
# Requests there carry no session token and no master key unless the call
|
|
208
|
+
# passes `session_token:` or `use_master_key: true` explicitly.
|
|
209
|
+
# @return [Boolean]
|
|
210
|
+
def self.anonymous_session?
|
|
211
|
+
Fiber[SESSION_TOKEN_STATE_KEY] == ANONYMOUS_SESSION
|
|
170
212
|
end
|
|
171
213
|
|
|
172
214
|
# @!visibility private
|
|
@@ -776,10 +818,17 @@ module Parse
|
|
|
776
818
|
# - the name singularizes to a *different* string (i.e. looks plural),
|
|
777
819
|
# - the singular form does NOT already end in `s` (per design: classes
|
|
778
820
|
# whose name ends in `s` are not auto-aliased),
|
|
779
|
-
# - the singular constant
|
|
780
|
-
#
|
|
781
|
-
#
|
|
782
|
-
#
|
|
821
|
+
# - the singular constant resolves from the referencing module and
|
|
822
|
+
# is a `Parse::Object` subclass,
|
|
823
|
+
# - the plural is not already defined in the singular class's own
|
|
824
|
+
# namespace.
|
|
825
|
+
#
|
|
826
|
+
# The alias is installed in the namespace that defines the singular
|
|
827
|
+
# class (`Object` for a top-level `Post`, `Blog` for `Blog::Post`), never
|
|
828
|
+
# on the module where the lookup happened. A reference to `Posts` from
|
|
829
|
+
# inside an unrelated class or module therefore resolves, but does not
|
|
830
|
+
# add a `Posts` constant to that module. A frozen namespace is left
|
|
831
|
+
# alone (no `FrozenError`).
|
|
783
832
|
#
|
|
784
833
|
# @param mod [Module] the module/class on which `const_missing` fired.
|
|
785
834
|
# @param name [Symbol] the missing constant name.
|
|
@@ -795,15 +844,40 @@ module Parse
|
|
|
795
844
|
return nil unless mod.const_defined?(sym, true)
|
|
796
845
|
klass = mod.const_get(sym)
|
|
797
846
|
return nil unless klass.is_a?(Class) && klass < Parse::Object
|
|
798
|
-
|
|
799
|
-
|
|
847
|
+
home = __pluralized_alias_home(klass, sym)
|
|
848
|
+
return nil if home.nil?
|
|
849
|
+
if home.const_defined?(name, false)
|
|
850
|
+
existing = home.const_get(name, false)
|
|
851
|
+
return existing.equal?(klass) ? klass : nil
|
|
852
|
+
end
|
|
853
|
+
return nil if home.frozen?
|
|
854
|
+
home.const_set(name, klass)
|
|
800
855
|
klass
|
|
801
|
-
rescue NameError, LoadError
|
|
856
|
+
rescue NameError, LoadError, FrozenError
|
|
802
857
|
# const_get/const_defined? can raise on malformed names or autoload
|
|
803
858
|
# failures; never let alias resolution mask the original lookup.
|
|
804
859
|
nil
|
|
805
860
|
end
|
|
806
861
|
|
|
862
|
+
# @!visibility private
|
|
863
|
+
# The namespace that directly defines `klass` under the name `singular`,
|
|
864
|
+
# which is where its pluralized alias belongs. nil when it cannot be
|
|
865
|
+
# determined (anonymous class, or a constant that only resolves through
|
|
866
|
+
# an ancestor).
|
|
867
|
+
# @param klass [Class]
|
|
868
|
+
# @param singular [Symbol]
|
|
869
|
+
# @return [Module, nil]
|
|
870
|
+
def __pluralized_alias_home(klass, singular)
|
|
871
|
+
name = klass.name
|
|
872
|
+
return nil if name.nil?
|
|
873
|
+
parent_name = name.rpartition("::").first
|
|
874
|
+
home = parent_name.empty? ? ::Object : ::Object.const_get(parent_name, false)
|
|
875
|
+
return nil unless home.is_a?(Module) && home.const_defined?(singular, false)
|
|
876
|
+
home.const_get(singular, false).equal?(klass) ? home : nil
|
|
877
|
+
rescue NameError
|
|
878
|
+
nil
|
|
879
|
+
end
|
|
880
|
+
|
|
807
881
|
# Verify that every association target across the loaded {Parse::Object}
|
|
808
882
|
# subclasses resolves to a known Parse class. Covers `belongs_to` and
|
|
809
883
|
# `property … as:` pointer targets (via each class's `references`),
|
|
@@ -1048,4 +1122,7 @@ Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
|
|
|
1048
1122
|
# already defined. Gated at runtime on Parse.pluralized_aliases?.
|
|
1049
1123
|
require_relative "model/core/pluralized_aliases"
|
|
1050
1124
|
|
|
1051
|
-
|
|
1125
|
+
# Only hook into Rails when railties is loaded. A bare `Rails` module (for
|
|
1126
|
+
# example the one rails-html-sanitizer defines) has no `Rails::Railtie`, and
|
|
1127
|
+
# subclassing it would abort loading the gem.
|
|
1128
|
+
require_relative "stack/railtie" if defined?(::Rails::Railtie)
|
|
@@ -50,8 +50,14 @@ module Parse
|
|
|
50
50
|
def login_with_mfa(username, password, mfa_token)
|
|
51
51
|
raise MFA::RequiredError, "MFA token is required" if mfa_token.blank?
|
|
52
52
|
|
|
53
|
+
# `client.login_with_mfa` always goes out without the master key
|
|
54
|
+
# (see Parse::API::Users#login_with_mfa): with it, Parse Server
|
|
55
|
+
# would skip this verification and overwrite the enrolled secret.
|
|
53
56
|
response = client.login_with_mfa(username, password, mfa_token)
|
|
54
|
-
|
|
57
|
+
unless response.success?
|
|
58
|
+
raise MFA::VerificationError, response.error.to_s if MFA.invalid_token_response?(response)
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
55
61
|
|
|
56
62
|
# Self-fetch trust: an MFA login returns the authenticating
|
|
57
63
|
# user's own row, so authData here is legitimately theirs.
|
|
@@ -447,8 +453,14 @@ module Parse
|
|
|
447
453
|
# user = Parse::User.first
|
|
448
454
|
# user.login_with_mfa!("password123", "123456")
|
|
449
455
|
def login_with_mfa!(password, mfa_token = nil)
|
|
456
|
+
raise MFA::RequiredError, "MFA token is required for this account" if mfa_token.blank?
|
|
450
457
|
response = client.login_with_mfa(username.to_s, password.to_s, mfa_token)
|
|
451
|
-
|
|
458
|
+
unless response.success?
|
|
459
|
+
raise MFA::VerificationError, response.error.to_s if MFA.invalid_token_response?(response)
|
|
460
|
+
return false
|
|
461
|
+
end
|
|
462
|
+
# Self-fetch trust: the login response is this user's own row.
|
|
463
|
+
self.class.with_authdata_trust { apply_attributes!(response.result) }
|
|
452
464
|
session_token.present?
|
|
453
465
|
rescue Parse::Client::ResponseError => e
|
|
454
466
|
if e.message.include?("Missing additional authData")
|
|
@@ -62,6 +62,17 @@ module Parse
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Whether a failed login response is Parse Server rejecting the MFA code
|
|
66
|
+
# (as opposed to wrong credentials). Parse Server's MFA adapter throws a
|
|
67
|
+
# bare string, which reaches the client as code 141 with the adapter's
|
|
68
|
+
# message, e.g. "Invalid MFA token".
|
|
69
|
+
# @!visibility private
|
|
70
|
+
# @param response [Parse::Response]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def self.invalid_token_response?(response)
|
|
73
|
+
response.respond_to?(:error) && response.error.to_s.start_with?("Invalid MFA token", "Please enter the token")
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
# Error raised when MFA is required but not provided
|
|
66
77
|
class RequiredError < Parse::Error
|
|
67
78
|
def initialize(message = "MFA token is required for this account")
|
|
@@ -399,9 +399,10 @@ module Parse
|
|
|
399
399
|
# Build the native `$rankFusion` pipeline (without ACL/CLP
|
|
400
400
|
# stages). Public-ish via {.native_pipeline} for snapshot tests;
|
|
401
401
|
# the live path appends ACL enforcement in {#run_native}.
|
|
402
|
-
def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights:,
|
|
403
|
+
owner_vector_filter: nil, owner_match: nil)
|
|
404
|
+
vsel = vector_search_stage(vec, oversample, owner_filter: owner_vector_filter)
|
|
405
|
+
lsel = lexical_search_stage(lex, oversample, owner_match: owner_match)
|
|
405
406
|
stage = {
|
|
406
407
|
"input" => {
|
|
407
408
|
"pipelines" => { "vector" => vsel, "lexical" => lsel },
|
|
@@ -437,19 +438,44 @@ module Parse
|
|
|
437
438
|
# AFTER the ACL `$match`, so trimming to `k` there would
|
|
438
439
|
# reintroduce the underfill the window exists to prevent. The
|
|
439
440
|
# trim to `k` happens client-side once enforcement is done.
|
|
441
|
+
pointer_fields = resolve_pointer_fields!(collection_name, resolution)
|
|
440
442
|
native_pipeline_for(lex, vec, candidate_window, resolution,
|
|
441
443
|
k_constant: fusion[:k_constant] || DEFAULT_K_CONSTANT,
|
|
442
|
-
weights: fusion[:weights], limit: candidate_window
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
444
|
+
weights: fusion[:weights], limit: candidate_window,
|
|
445
|
+
pointer_fields: pointer_fields,
|
|
446
|
+
owner_vector_filter: owner_vector_filter(collection_name, vec, pointer_fields, resolution))
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:,
|
|
450
|
+
pointer_fields: nil, owner_vector_filter: nil)
|
|
451
|
+
scoped = !(resolution.nil? || resolution.master?)
|
|
452
|
+
# Ownership inside each `$rankFusion` input as well as after it:
|
|
453
|
+
# every input is capped at `oversample`, so a final `$match`
|
|
454
|
+
# alone only sees the fused top rows and can underfill when
|
|
455
|
+
# other users' rows dominate both branches. The lexical input
|
|
456
|
+
# filters before its `$limit`; the vector input uses the
|
|
457
|
+
# `$vectorSearch.filter` prefilter when the owner path is
|
|
458
|
+
# filter-indexed and served (see Parse::VectorSearch).
|
|
459
|
+
owner_match = if scoped && pointer_fields
|
|
460
|
+
Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id)
|
|
461
|
+
end
|
|
462
|
+
pipeline = [build_rank_fusion_stage(lex, vec, oversample, k_constant: k_constant, weights: weights,
|
|
463
|
+
owner_vector_filter: (owner_vector_filter if scoped),
|
|
464
|
+
owner_match: owner_match)]
|
|
447
465
|
# The fused RRF score is surfaced via `{ $meta: "score" }`
|
|
448
466
|
# (a numeric), not "scoreDetails" (a breakdown document).
|
|
449
467
|
pipeline << { "$addFields" => { "_hybrid_score" => { "$meta" => "score" } } }
|
|
450
468
|
unless resolution.nil? || resolution.master?
|
|
451
469
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
452
470
|
pipeline << acl_match if acl_match
|
|
471
|
+
# pointerFields / readUserFields ownership before `$limit`, so
|
|
472
|
+
# rows ranked above the caller's own rows cannot crowd them
|
|
473
|
+
# out of the page.
|
|
474
|
+
if pointer_fields
|
|
475
|
+
pipeline << {
|
|
476
|
+
"$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id),
|
|
477
|
+
}
|
|
478
|
+
end
|
|
453
479
|
end
|
|
454
480
|
pipeline << { "$sort" => { "_hybrid_score" => -1 } }
|
|
455
481
|
pipeline << { "$limit" => limit }
|
|
@@ -463,13 +489,39 @@ module Parse
|
|
|
463
489
|
protected_fields = Parse::CLPScope.protected_fields_for(
|
|
464
490
|
collection_name, resolution.permission_strings,
|
|
465
491
|
)
|
|
492
|
+
# Unconditional: `Parse::AtlasSearch` may already be partially
|
|
493
|
+
# defined by a submodule (session, protected_paths) without the
|
|
494
|
+
# main file loaded. `require_relative` is idempotent.
|
|
495
|
+
require_relative "../atlas_search"
|
|
496
|
+
Parse::AtlasSearch.send(:assert_search_fields_allowed!,
|
|
497
|
+
Array(lex[:fields]).map(&:to_s), protected_fields, resolution)
|
|
466
498
|
Parse::VectorSearch.validate_query_vector!(vec[:query_vector])
|
|
467
499
|
Parse::PipelineSecurity.validate_filter!(vec[:vector_filter]) if vec[:vector_filter]
|
|
468
500
|
Parse::PipelineSecurity.validate_filter!(vec[:filter]) if vec[:filter]
|
|
469
501
|
Parse::PipelineSecurity.validate_filter!(lex[:filter]) if lex[:filter]
|
|
502
|
+
# The client path gets these refusals from AtlasSearch.search and
|
|
503
|
+
# VectorSearch.search. The native path builds its own pipeline,
|
|
504
|
+
# so apply the same protected-field refusals here: the vector
|
|
505
|
+
# field, every filter's predicate keys, and `$expr` references.
|
|
506
|
+
Parse::VectorSearch.send(:assert_protected_fields_untouched!,
|
|
507
|
+
collection_name, vec[:field].to_s, vec[:filter],
|
|
508
|
+
vec[:vector_filter], protected_fields, resolution)
|
|
509
|
+
if lex[:filter] && !lex[:filter].empty? && !resolution.master?
|
|
510
|
+
Parse::PipelineSecurity.refuse_protected_field_references!(
|
|
511
|
+
[{ "$match" => lex[:filter] }], collection_name, resolution,
|
|
512
|
+
)
|
|
513
|
+
Parse::AtlasSearch::ProtectedPaths.assert_filter_allowed!(
|
|
514
|
+
lex[:filter], protected_fields, resolution,
|
|
515
|
+
collection_name: collection_name,
|
|
516
|
+
method_name: "Parse::VectorSearch::Hybrid.search",
|
|
517
|
+
)
|
|
518
|
+
end
|
|
470
519
|
|
|
520
|
+
owner_filter = owner_vector_filter(collection_name, vec, pointer_fields, resolution)
|
|
471
521
|
pipeline = native_pipeline_for(lex, vec, oversample, resolution,
|
|
472
|
-
k_constant: k_constant, weights: weights, limit: oversample
|
|
522
|
+
k_constant: k_constant, weights: weights, limit: oversample,
|
|
523
|
+
pointer_fields: pointer_fields,
|
|
524
|
+
owner_vector_filter: owner_filter)
|
|
473
525
|
rows = run_pipeline!(collection_name, pipeline,
|
|
474
526
|
authorizing_client: Parse::ACLScope.client_of(resolution))
|
|
475
527
|
|
|
@@ -493,6 +545,8 @@ module Parse
|
|
|
493
545
|
rows.select! { |doc| native_row_visible?(doc, perms_set) }
|
|
494
546
|
Parse::ACLScope.redact_results!(rows, resolution)
|
|
495
547
|
Parse::CLPScope.redact_protected_fields!(rows, protected_fields) if protected_fields.any?
|
|
548
|
+
# Defense in depth: the pointerFields `$match` already ran
|
|
549
|
+
# before `$limit`, so this should drop nothing.
|
|
496
550
|
if pointer_fields
|
|
497
551
|
rows = Parse::CLPScope.filter_by_pointer_fields(rows, pointer_fields, resolution.user_id)
|
|
498
552
|
end
|
|
@@ -512,7 +566,13 @@ module Parse
|
|
|
512
566
|
rows
|
|
513
567
|
rescue Parse::CLPScope::Denied
|
|
514
568
|
raise
|
|
515
|
-
rescue StandardError
|
|
569
|
+
rescue StandardError => e
|
|
570
|
+
# A refused owner prefilter means Atlas serves an index version
|
|
571
|
+
# without that filter path; stop pushing it down so the client
|
|
572
|
+
# fallback below (and later searches) run without it.
|
|
573
|
+
if owner_filter && Parse::VectorSearch.owner_prefilter_rejected?(e, owner_filter)
|
|
574
|
+
Parse::VectorSearch.owner_prefilter_unavailable!(collection_name, vector_index_name(vec))
|
|
575
|
+
end
|
|
516
576
|
# Native execution failed (e.g. a cluster that probed as
|
|
517
577
|
# supported but rejects this exact shape, or a transient error).
|
|
518
578
|
# Fall back to the client-side path rather than failing the
|
|
@@ -520,7 +580,23 @@ module Parse
|
|
|
520
580
|
nil
|
|
521
581
|
end
|
|
522
582
|
|
|
523
|
-
|
|
583
|
+
# The owner prefilter for the native vector input, or nil when it
|
|
584
|
+
# cannot be pushed down (master, no pointerFields, or the owner
|
|
585
|
+
# path not filter-indexed in the index Atlas is serving).
|
|
586
|
+
def owner_vector_filter(collection_name, vec, pointer_fields, resolution)
|
|
587
|
+
return nil if resolution.nil? || resolution.master? || pointer_fields.nil?
|
|
588
|
+
Parse::VectorSearch.send(:owner_vector_prefilter, collection_name, vector_index_name(vec),
|
|
589
|
+
pointer_fields, resolution)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# The vector index the native input searches: the branch's
|
|
593
|
+
# `index:` or {Parse::VectorSearch.default_index}, the same
|
|
594
|
+
# fallback {Parse::VectorSearch.search} uses on the client path.
|
|
595
|
+
def vector_index_name(vec)
|
|
596
|
+
vec[:index] || Parse::VectorSearch.default_index
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def vector_search_stage(vec, oversample, owner_filter: nil)
|
|
524
600
|
# Parity with Parse::VectorSearch: Atlas requires
|
|
525
601
|
# `numCandidates >= limit` and caps it at 10_000. The default
|
|
526
602
|
# (`oversample * MULTIPLIER`) can blow past 10_000 for a large
|
|
@@ -531,19 +607,23 @@ module Parse
|
|
|
531
607
|
num_candidates = (vec[:num_candidates] || oversample * Parse::VectorSearch::DEFAULT_NUM_CANDIDATES_MULTIPLIER).to_i
|
|
532
608
|
num_candidates = [[num_candidates, oversample].max, 10_000].min
|
|
533
609
|
stage = {
|
|
534
|
-
"index" => vec
|
|
610
|
+
"index" => vector_index_name(vec).to_s,
|
|
535
611
|
"path" => vec[:field].to_s,
|
|
536
612
|
"queryVector" => vec[:query_vector],
|
|
537
613
|
"numCandidates" => num_candidates,
|
|
538
614
|
"limit" => oversample,
|
|
539
615
|
}
|
|
540
|
-
|
|
616
|
+
caller_filter = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
|
|
617
|
+
prefilter = [caller_filter, owner_filter].compact
|
|
618
|
+
unless prefilter.empty?
|
|
619
|
+
stage["filter"] = prefilter.size == 1 ? prefilter.first : { "$and" => prefilter }
|
|
620
|
+
end
|
|
541
621
|
inner = [{ "$vectorSearch" => stage }]
|
|
542
622
|
inner << { "$match" => vec[:filter] } if vec[:filter]
|
|
543
623
|
inner
|
|
544
624
|
end
|
|
545
625
|
|
|
546
|
-
def lexical_search_stage(lex, oversample)
|
|
626
|
+
def lexical_search_stage(lex, oversample, owner_match: nil)
|
|
547
627
|
require_relative "../atlas_search" if defined?(Parse::AtlasSearch::SearchBuilder).nil?
|
|
548
628
|
builder = Parse::AtlasSearch::SearchBuilder.new(index_name: lex[:index])
|
|
549
629
|
fields = lex[:fields]
|
|
@@ -552,7 +632,9 @@ module Parse
|
|
|
552
632
|
else
|
|
553
633
|
Array(fields).each { |f| builder.text(query: lex[:query], path: f.to_s, fuzzy: lex[:fuzzy]) }
|
|
554
634
|
end
|
|
555
|
-
inner = [builder.build
|
|
635
|
+
inner = [builder.build]
|
|
636
|
+
inner << { "$match" => owner_match } if owner_match
|
|
637
|
+
inner << { "$limit" => oversample }
|
|
556
638
|
inner << { "$match" => lex[:filter] } if lex[:filter]
|
|
557
639
|
inner
|
|
558
640
|
end
|
|
@@ -672,27 +754,22 @@ module Parse
|
|
|
672
754
|
end
|
|
673
755
|
|
|
674
756
|
def assert_clp_find!(collection_name, resolution)
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
end
|
|
757
|
+
# Same CLP branch evaluation as Parse::MongoDB.aggregate (public,
|
|
758
|
+
# user, and role grants first; then pointerFields / readUserFields).
|
|
759
|
+
# Raises Parse::CLPScope::Denied when the scope cannot find at all.
|
|
760
|
+
Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
|
|
761
|
+
label: "hybrid-search")
|
|
762
|
+
nil
|
|
682
763
|
end
|
|
683
764
|
|
|
684
765
|
def resolve_pointer_fields!(collection_name, resolution)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
"but the current hybrid-search scope has no user_id.",
|
|
693
|
-
)
|
|
694
|
-
end
|
|
695
|
-
pointer_fields
|
|
766
|
+
# nil when a public, user, or role grant already permits every row
|
|
767
|
+
# (Parse Server ignores pointer permissions then); otherwise the
|
|
768
|
+
# pointerFields plus readUserFields the rows must match. The older
|
|
769
|
+
# permits? / pointer_fields_for pair missed readUserFields entirely
|
|
770
|
+
# and over-restricted a public grant that also listed pointerFields.
|
|
771
|
+
Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
|
|
772
|
+
label: "hybrid-search")
|
|
696
773
|
end
|
|
697
774
|
|
|
698
775
|
def validate_weights!(weights)
|