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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -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
- headers.merge!({ Parse::Protocol::SESSION_TOKEN => session_token })
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
- headers.merge!({ Parse::Protocol::REVOCABLE_SESSION => "1" })
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!({ Parse::Protocol::SESSION_TOKEN => opts[:session_token] })
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!({ Parse::Protocol::REVOCABLE_SESSION => "1" })
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
- headers.merge!({ Parse::Protocol::REVOCABLE_SESSION => "1" })
204
- response = request :post, LOGIN_PATH, body: body, headers: headers, opts: opts
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
- headers.merge!({ Parse::Protocol::SESSION_TOKEN => session_token })
258
- opts.merge!({ use_master_key: false, session_token: session_token })
259
- request :post, LOGOUT_PATH, headers: headers, opts: opts
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
- results = Parse::MongoDB.aggregate(collection_name, pipeline, master: true)
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