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/api/users.rb
CHANGED
|
@@ -47,7 +47,14 @@ module Parse
|
|
|
47
47
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
48
48
|
# @return [Parse::Response]
|
|
49
49
|
def current_user(session_token, headers: {}, **opts)
|
|
50
|
-
|
|
50
|
+
# The token argument is the whole point of this call, so it is passed
|
|
51
|
+
# as the explicit per-call token: an ambient `Parse.with_session`
|
|
52
|
+
# token or a client-bound token must never replace it (that resolved
|
|
53
|
+
# the wrong user and poisoned the identity cache). A caller-supplied
|
|
54
|
+
# `session_token:` in opts is ignored for the same reason.
|
|
55
|
+
session_token = session_token.session_token if session_token.respond_to?(:session_token)
|
|
56
|
+
opts = opts.merge(session_token: session_token.to_s, use_master_key: false)
|
|
57
|
+
headers = headers.merge({ Parse::Protocol::SESSION_TOKEN => session_token.to_s })
|
|
51
58
|
response = request :get, "#{USER_PATH_PREFIX}/me", headers: headers, opts: opts
|
|
52
59
|
response.parse_class = Parse::Model::CLASS_USER
|
|
53
60
|
response
|
|
@@ -58,10 +65,23 @@ module Parse
|
|
|
58
65
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
59
66
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
60
67
|
# @return [Parse::Response]
|
|
68
|
+
#
|
|
69
|
+
# Sent WITHOUT the master key unless the caller passes
|
|
70
|
+
# `use_master_key: true`. Parse Server does not mint a session token for
|
|
71
|
+
# a master-key signup, so a master-keyed client used to get back a user
|
|
72
|
+
# with no `sessionToken` (and {Parse::User#upgrade_anonymous!} then
|
|
73
|
+
# failed). A master-key create also lets the request through `_User`
|
|
74
|
+
# create CLPs and authData checks that a real signup must pass.
|
|
61
75
|
def create_user(body, headers: {}, **opts)
|
|
62
|
-
|
|
76
|
+
opts = opts.merge(use_master_key: false) unless opts[:use_master_key] == true
|
|
77
|
+
headers = headers.merge({ Parse::Protocol::REVOCABLE_SESSION => "1" })
|
|
63
78
|
if opts[:session_token].present?
|
|
64
|
-
headers.merge
|
|
79
|
+
headers = headers.merge({ Parse::Protocol::SESSION_TOKEN => opts[:session_token] })
|
|
80
|
+
elsif opts[:use_master_key] != true
|
|
81
|
+
# No token given: send none, rather than letting an ambient
|
|
82
|
+
# `with_session` token or a client-bound token make the caller the
|
|
83
|
+
# `request.user` of someone else's signup.
|
|
84
|
+
opts = opts.merge(session_token: "")
|
|
65
85
|
end
|
|
66
86
|
response = request :post, USER_PATH_PREFIX, body: body, headers: headers, opts: opts
|
|
67
87
|
response.parse_class = Parse::Model::CLASS_USER
|
|
@@ -78,6 +98,10 @@ module Parse
|
|
|
78
98
|
id = Parse::API::PathSegment.object_id!(id)
|
|
79
99
|
response = request :put, "#{USER_PATH_PREFIX}/#{id}", body: body, headers: headers, opts: opts
|
|
80
100
|
response.parse_class = Parse::Model::CLASS_USER
|
|
101
|
+
# A password change revokes the user's other sessions server-side.
|
|
102
|
+
if response.success? && body.is_a?(Hash) && (body.key?(:password) || body.key?("password"))
|
|
103
|
+
invalidate_user_identity(id)
|
|
104
|
+
end
|
|
81
105
|
response
|
|
82
106
|
end
|
|
83
107
|
|
|
@@ -101,7 +125,9 @@ module Parse
|
|
|
101
125
|
# @return [Parse::Response]
|
|
102
126
|
def delete_user(id, headers: {}, **opts)
|
|
103
127
|
id = Parse::API::PathSegment.object_id!(id)
|
|
104
|
-
request :delete, "#{USER_PATH_PREFIX}/#{id}", headers: headers, opts: opts
|
|
128
|
+
response = request :delete, "#{USER_PATH_PREFIX}/#{id}", headers: headers, opts: opts
|
|
129
|
+
invalidate_user_identity(id) if response.success?
|
|
130
|
+
response
|
|
105
131
|
end
|
|
106
132
|
|
|
107
133
|
# Request a password reset for a registered email.
|
|
@@ -127,7 +153,7 @@ module Parse
|
|
|
127
153
|
rate_key = "pwreset:#{email}"
|
|
128
154
|
check_login_rate_limit!(rate_key)
|
|
129
155
|
body = { email: email }
|
|
130
|
-
response = request :post, REQUEST_PASSWORD_RESET, body: body, opts: opts, headers: headers
|
|
156
|
+
response = request :post, REQUEST_PASSWORD_RESET, body: body, opts: unauthenticated_opts(opts), headers: headers
|
|
131
157
|
# Always count the attempt as a "failure" for backoff purposes:
|
|
132
158
|
# the response body is intentionally indistinguishable across
|
|
133
159
|
# found/not-found emails, so we cannot reset the counter on
|
|
@@ -150,7 +176,7 @@ module Parse
|
|
|
150
176
|
rate_key = "emailverify:#{email}"
|
|
151
177
|
check_login_rate_limit!(rate_key)
|
|
152
178
|
body = { email: email }
|
|
153
|
-
response = request :post, VERIFICATION_EMAIL_REQUEST, body: body, opts: opts, headers: headers
|
|
179
|
+
response = request :post, VERIFICATION_EMAIL_REQUEST, body: body, opts: unauthenticated_opts(opts), headers: headers
|
|
154
180
|
# Indistinguishable found/not-found response, like password reset — count
|
|
155
181
|
# every attempt toward backoff so probing can't reset the counter.
|
|
156
182
|
track_login_attempt(rate_key, false)
|
|
@@ -164,11 +190,16 @@ module Parse
|
|
|
164
190
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
165
191
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
166
192
|
# @return [Parse::Response]
|
|
193
|
+
#
|
|
194
|
+
# Always sent without the master key and without any ambient or bound
|
|
195
|
+
# session token (see {#unauthenticated_opts}). A master-key login makes
|
|
196
|
+
# Parse Server skip the additional MFA check and SAVE the submitted
|
|
197
|
+
# authData, so it must never be sent from a master-keyed client.
|
|
167
198
|
def login(username, password, headers: {}, **opts)
|
|
168
199
|
check_login_rate_limit!(username)
|
|
169
200
|
body = { username: username, password: password }
|
|
170
|
-
headers.merge
|
|
171
|
-
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: opts
|
|
201
|
+
headers = headers.merge({ Parse::Protocol::REVOCABLE_SESSION => "1" })
|
|
202
|
+
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: unauthenticated_opts(opts)
|
|
172
203
|
response.parse_class = Parse::Model::CLASS_USER
|
|
173
204
|
track_login_attempt(username, response.success?)
|
|
174
205
|
response
|
|
@@ -200,8 +231,12 @@ module Parse
|
|
|
200
231
|
},
|
|
201
232
|
},
|
|
202
233
|
}
|
|
203
|
-
|
|
204
|
-
|
|
234
|
+
# Never with the master key: with it, Parse Server skips the MFA
|
|
235
|
+
# validation and stores the submitted `authData.mfa` over the
|
|
236
|
+
# account's enrolled TOTP secret, so any code "verifies" and MFA is
|
|
237
|
+
# silently broken for the account from then on.
|
|
238
|
+
headers = headers.merge({ Parse::Protocol::REVOCABLE_SESSION => "1" })
|
|
239
|
+
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: unauthenticated_opts(opts)
|
|
205
240
|
response.parse_class = Parse::Model::CLASS_USER
|
|
206
241
|
track_login_attempt(username, response.success?)
|
|
207
242
|
response
|
|
@@ -242,7 +277,7 @@ module Parse
|
|
|
242
277
|
def verify_password(username, password, headers: {}, **opts)
|
|
243
278
|
check_login_rate_limit!(username)
|
|
244
279
|
body = { username: username, password: password }
|
|
245
|
-
response = request :post, VERIFY_PASSWORD_PATH, body: body, headers: headers, opts: opts
|
|
280
|
+
response = request :post, VERIFY_PASSWORD_PATH, body: body, headers: headers, opts: unauthenticated_opts(opts)
|
|
246
281
|
response.parse_class = Parse::Model::CLASS_USER
|
|
247
282
|
track_login_attempt(username, response.success?)
|
|
248
283
|
response
|
|
@@ -254,9 +289,14 @@ module Parse
|
|
|
254
289
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
255
290
|
# @return [Parse::Response]
|
|
256
291
|
def logout(session_token, headers: {}, **opts)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
292
|
+
session_token = session_token.session_token if session_token.respond_to?(:session_token)
|
|
293
|
+
headers = headers.merge({ Parse::Protocol::SESSION_TOKEN => session_token })
|
|
294
|
+
opts = opts.merge({ use_master_key: false, session_token: session_token })
|
|
295
|
+
response = request :post, LOGOUT_PATH, headers: headers, opts: opts
|
|
296
|
+
# Forget the token in this client's identity plane so mongo-direct
|
|
297
|
+
# reads see the revocation now rather than when the entry expires.
|
|
298
|
+
invalidate_session_identity(session_token) if response.success?
|
|
299
|
+
response
|
|
260
300
|
end
|
|
261
301
|
|
|
262
302
|
# Signup a user given a username, password and, optionally, their email.
|
|
@@ -272,8 +312,42 @@ module Parse
|
|
|
272
312
|
create_user(body, **opts)
|
|
273
313
|
end
|
|
274
314
|
|
|
315
|
+
# Drop one session token from this client's identity plane.
|
|
316
|
+
# @!visibility private
|
|
317
|
+
# @param session_token [String]
|
|
318
|
+
def invalidate_session_identity(session_token)
|
|
319
|
+
return if session_token.nil? || session_token.to_s.empty?
|
|
320
|
+
authorization.invalidate(session_token) if respond_to?(:authorization)
|
|
321
|
+
rescue StandardError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Drop every identity entry that resolves to `user_id` from this
|
|
326
|
+
# client's identity plane, after an event that revokes the user's
|
|
327
|
+
# sessions (password change, account deletion, logout everywhere).
|
|
328
|
+
# @!visibility private
|
|
329
|
+
# @param user_id [String]
|
|
330
|
+
def invalidate_user_identity(user_id)
|
|
331
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
332
|
+
authorization.invalidate_user(user_id) if respond_to?(:authorization)
|
|
333
|
+
rescue StandardError
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
275
337
|
private
|
|
276
338
|
|
|
339
|
+
# Request options for an endpoint that authenticates by the request
|
|
340
|
+
# body itself (login, MFA login, verifyPassword, password reset,
|
|
341
|
+
# verification email). Forces the master key off, and passes an
|
|
342
|
+
# explicitly blank session token so neither the ambient
|
|
343
|
+
# `Parse.with_session` token nor a client-bound token is attached:
|
|
344
|
+
# {Parse::Client#request} treats an explicit blank token as "send no
|
|
345
|
+
# credential". A caller cannot opt back into the master key here.
|
|
346
|
+
# @!visibility private
|
|
347
|
+
def unauthenticated_opts(opts)
|
|
348
|
+
opts.merge(use_master_key: false, session_token: "")
|
|
349
|
+
end
|
|
350
|
+
|
|
277
351
|
# @!visibility private
|
|
278
352
|
# Thread-safe tracker for login rate limiting. Keys are usernames, values are
|
|
279
353
|
# { failures: Integer, locked_until: Time }.
|
|
@@ -65,7 +65,11 @@ module Parse
|
|
|
65
65
|
# `master: true` so the SDK's CLP layer skips this metadata
|
|
66
66
|
# pipeline. The mongo-side privilege check still applies
|
|
67
67
|
# (the underlying connection must hold `listSearchIndexes`).
|
|
68
|
-
|
|
68
|
+
# The metadata sentinel keeps that master mode inside a
|
|
69
|
+
# `Parse.without_master_key` block, which governs row access,
|
|
70
|
+
# without re-enabling the master key for anything else.
|
|
71
|
+
results = Parse::MongoDB.aggregate(collection_name, pipeline,
|
|
72
|
+
master: Parse::ACLScope::METADATA_MASTER)
|
|
69
73
|
cache_mutex.synchronize { cache_indexes(collection_name, results) }
|
|
70
74
|
results
|
|
71
75
|
rescue => e
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../clp_scope"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module AtlasSearch
|
|
8
|
+
# Shared "does this path touch a protected field" logic for every
|
|
9
|
+
# Atlas Search and vector search entry point.
|
|
10
|
+
#
|
|
11
|
+
# Stripping a protected field from the RESULT rows does not stop it
|
|
12
|
+
# from deciding which documents MATCH, how they RANK, or which rows a
|
|
13
|
+
# filter keeps. A scoped caller that can name a protected field in a
|
|
14
|
+
# `$search` path, a highlight, an autocomplete field, a sort, or a
|
|
15
|
+
# `$match` filter key can test guesses against its value. These
|
|
16
|
+
# helpers find every such reference so the entry points can refuse
|
|
17
|
+
# the call with {Parse::CLPScope::Denied}.
|
|
18
|
+
#
|
|
19
|
+
# Path comparison rules:
|
|
20
|
+
# * A String or Symbol path is reduced to its first dotted segment,
|
|
21
|
+
# with a `_p_` storage prefix removed (`_p_owner` counts as `owner`,
|
|
22
|
+
# `secret.sub` counts as `secret`). The storage columns
|
|
23
|
+
# `_created_at` / `_updated_at` / `_id` map to `createdAt` /
|
|
24
|
+
# `updatedAt` / `objectId`.
|
|
25
|
+
# * An Array path touches a protected field when any element does.
|
|
26
|
+
# * A `{ "value" => ..., "multi" => ... }` path object is judged by
|
|
27
|
+
# its `value`.
|
|
28
|
+
# * A `{ "wildcard" => ... }` path object, or any other non-String
|
|
29
|
+
# path, is treated as touching every field.
|
|
30
|
+
#
|
|
31
|
+
# Every check is a no-op for a master resolution, a nil resolution,
|
|
32
|
+
# or an empty protected set.
|
|
33
|
+
module ProtectedPaths
|
|
34
|
+
# Storage-form column names that differ from their Parse field name.
|
|
35
|
+
STORAGE_ALIASES = {
|
|
36
|
+
"_created_at" => "createdAt",
|
|
37
|
+
"_updated_at" => "updatedAt",
|
|
38
|
+
"_id" => "objectId",
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# Query operators whose value is a list of sub-filters.
|
|
42
|
+
LOGICAL_OPERATORS = %w[$and $or $nor].freeze
|
|
43
|
+
|
|
44
|
+
# Sentinel yielded for a reference that reaches every field
|
|
45
|
+
# (a wildcard path, a queryString query).
|
|
46
|
+
ALL_FIELDS = :__all_fields__
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true when the scope is subject to protectedFields.
|
|
51
|
+
def enforce?(resolution, protected_fields)
|
|
52
|
+
return false if resolution.nil?
|
|
53
|
+
return false if resolution.respond_to?(:master?) && resolution.master?
|
|
54
|
+
!(protected_fields.nil? || protected_fields.empty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The Parse field name a single String path addresses.
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def root_field(path)
|
|
60
|
+
head = path.to_s.split(".").first.to_s
|
|
61
|
+
return nil if head.empty?
|
|
62
|
+
head = STORAGE_ALIASES.fetch(head, head)
|
|
63
|
+
head = head.delete_prefix("_p_") if head.start_with?("_p_") && head.length > 3
|
|
64
|
+
head
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether `path` touches a protected field.
|
|
68
|
+
#
|
|
69
|
+
# @param path [String, Symbol, Array, Hash, Object] an Atlas path.
|
|
70
|
+
# @param protected_fields [Set<String>, Array<String>]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def touches?(path, protected_fields)
|
|
73
|
+
return false if protected_fields.nil? || protected_fields.empty?
|
|
74
|
+
case path
|
|
75
|
+
when String, Symbol
|
|
76
|
+
protected_fields.include?(root_field(path))
|
|
77
|
+
when Array
|
|
78
|
+
path.any? { |p| touches?(p, protected_fields) }
|
|
79
|
+
when Hash
|
|
80
|
+
value = path.key?("value") ? path["value"] : path[:value]
|
|
81
|
+
has_wildcard = path.key?("wildcard") || path.key?(:wildcard)
|
|
82
|
+
return true if has_wildcard || value.nil?
|
|
83
|
+
touches?(value, protected_fields)
|
|
84
|
+
else
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Refuse when any of `paths` touches a protected field.
|
|
90
|
+
#
|
|
91
|
+
# @param paths [Object] a single path or an Array of paths.
|
|
92
|
+
# @param what [String] used in the error message ("search path",
|
|
93
|
+
# "highlight path", ...).
|
|
94
|
+
# @raise [Parse::CLPScope::Denied]
|
|
95
|
+
def assert_paths_allowed!(paths, protected_fields, resolution, collection_name: nil,
|
|
96
|
+
method_name: "Parse::AtlasSearch.search",
|
|
97
|
+
what: "path")
|
|
98
|
+
return unless enforce?(resolution, protected_fields)
|
|
99
|
+
list = paths.is_a?(Array) ? paths : [paths]
|
|
100
|
+
hit = list.find { |p| touches?(p, protected_fields) }
|
|
101
|
+
return if hit.nil? && !list.empty?
|
|
102
|
+
raise_denied!(collection_name, method_name, what, hit.nil? ? ALL_FIELDS : hit)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Walk a `$search` / `$searchMeta` body (or a whole stage) and
|
|
106
|
+
# refuse any path, sort key, or query-string reference that touches
|
|
107
|
+
# a protected field. Operators nest freely (compound
|
|
108
|
+
# must/should/filter/mustNot, embeddedDocument, facet operators,
|
|
109
|
+
# score functions), so every `path` / `defaultPath` value at any
|
|
110
|
+
# depth is checked.
|
|
111
|
+
#
|
|
112
|
+
# @raise [Parse::CLPScope::Denied]
|
|
113
|
+
def assert_search_stage_allowed!(stage, protected_fields, resolution, collection_name: nil,
|
|
114
|
+
method_name: "Parse::AtlasSearch.search_with_stage")
|
|
115
|
+
return unless enforce?(resolution, protected_fields)
|
|
116
|
+
each_search_reference(stage) do |ref|
|
|
117
|
+
next unless ref == ALL_FIELDS || touches?(ref, protected_fields)
|
|
118
|
+
raise_denied!(collection_name, method_name, "$search path", ref)
|
|
119
|
+
end
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Yield every field reference in a `$search`-style body.
|
|
124
|
+
# @!visibility private
|
|
125
|
+
def each_search_reference(node, &block)
|
|
126
|
+
case node
|
|
127
|
+
when Array
|
|
128
|
+
node.each { |child| each_search_reference(child, &block) }
|
|
129
|
+
when Hash
|
|
130
|
+
node.each do |key, value|
|
|
131
|
+
case key.to_s
|
|
132
|
+
when "path", "defaultPath"
|
|
133
|
+
yield value
|
|
134
|
+
when "sort"
|
|
135
|
+
if value.is_a?(Hash)
|
|
136
|
+
value.each do |sort_key, dir|
|
|
137
|
+
# `{ score: { $meta: "searchScore" } }` sorts by relevance,
|
|
138
|
+
# not by a stored field.
|
|
139
|
+
next if dir.is_a?(Hash) && (dir.key?("$meta") || dir.key?(:$meta))
|
|
140
|
+
yield sort_key
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
each_search_reference(value, &block)
|
|
144
|
+
end
|
|
145
|
+
when "queryString"
|
|
146
|
+
# The Lucene query syntax can name any field (`ssn:123*`),
|
|
147
|
+
# so the query text reaches every field.
|
|
148
|
+
yield ALL_FIELDS
|
|
149
|
+
each_search_reference(value, &block)
|
|
150
|
+
when "moreLikeThis"
|
|
151
|
+
like = value.is_a?(Hash) ? (value["like"] || value[:like]) : nil
|
|
152
|
+
Array(like).each do |doc|
|
|
153
|
+
doc.is_a?(Hash) ? doc.each_key { |k| yield k } : yield(ALL_FIELDS)
|
|
154
|
+
end
|
|
155
|
+
each_search_reference(value, &block)
|
|
156
|
+
else
|
|
157
|
+
each_search_reference(value, &block)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Refuse a `$match` filter whose predicate KEYS (top level, and
|
|
165
|
+
# inside `$and` / `$or` / `$nor` / `$not`) or `$expr` field
|
|
166
|
+
# references touch a protected field. A filter decides which rows
|
|
167
|
+
# survive, so filtering on a protected field is an oracle on its
|
|
168
|
+
# value even when the field is stripped from the output.
|
|
169
|
+
#
|
|
170
|
+
# @raise [Parse::CLPScope::Denied]
|
|
171
|
+
def assert_filter_allowed!(filter, protected_fields, resolution, collection_name: nil,
|
|
172
|
+
method_name: "Parse::AtlasSearch.search")
|
|
173
|
+
return unless enforce?(resolution, protected_fields)
|
|
174
|
+
each_filter_reference(filter) do |ref|
|
|
175
|
+
next unless touches?(ref, protected_fields)
|
|
176
|
+
raise_denied!(collection_name, method_name, "filter field", ref)
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Yield every field name a `$match` filter predicates on.
|
|
182
|
+
# @!visibility private
|
|
183
|
+
def each_filter_reference(node, &block)
|
|
184
|
+
case node
|
|
185
|
+
when Array
|
|
186
|
+
node.each { |child| each_filter_reference(child, &block) }
|
|
187
|
+
when Hash
|
|
188
|
+
node.each do |key, value|
|
|
189
|
+
k = key.to_s
|
|
190
|
+
if LOGICAL_OPERATORS.include?(k) || k == "$not"
|
|
191
|
+
each_filter_reference(value, &block)
|
|
192
|
+
elsif k == "$expr"
|
|
193
|
+
each_expr_reference(value, &block)
|
|
194
|
+
elsif k.start_with?("$")
|
|
195
|
+
# Other top-level operators ($text, $comment, ...) do not
|
|
196
|
+
# name a stored field by key.
|
|
197
|
+
next
|
|
198
|
+
else
|
|
199
|
+
yield k
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Yield `$field` references inside an aggregation expression.
|
|
207
|
+
# `$$VAR` references are variables, not fields.
|
|
208
|
+
# @!visibility private
|
|
209
|
+
def each_expr_reference(node, &block)
|
|
210
|
+
case node
|
|
211
|
+
when String
|
|
212
|
+
yield node[1..] if node.start_with?("$") && !node.start_with?("$$") && node.length > 1
|
|
213
|
+
when Array
|
|
214
|
+
node.each { |child| each_expr_reference(child, &block) }
|
|
215
|
+
when Hash
|
|
216
|
+
node.each_value { |child| each_expr_reference(child, &block) }
|
|
217
|
+
end
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @!visibility private
|
|
222
|
+
def raise_denied!(collection_name, method_name, what, ref)
|
|
223
|
+
detail = if ref == ALL_FIELDS
|
|
224
|
+
"a #{what} that reaches every field would match on protectedFields " \
|
|
225
|
+
"for the current scope; name the fields to search explicitly."
|
|
226
|
+
else
|
|
227
|
+
field = ref.is_a?(String) || ref.is_a?(Symbol) ? root_field(ref) : nil
|
|
228
|
+
label = field ? "'#{ref}' touches protected field '#{field}'" : "#{ref.inspect} reaches every field"
|
|
229
|
+
"#{what} #{label} for the current scope; matching, ranking, or " \
|
|
230
|
+
"filtering on it would reveal its value."
|
|
231
|
+
end
|
|
232
|
+
raise Parse::CLPScope::Denied.new(collection_name, :find, "#{method_name} refused: #{detail}")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|