parse-stack-next 5.7.6 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +830 -0
  3. data/README.md +14 -4
  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/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +317 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +225 -8
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
data/lib/parse/agent.rb CHANGED
@@ -23,6 +23,8 @@ require_relative "agent/rate_limiter"
23
23
  require_relative "agent/cancellation_token"
24
24
  require_relative "agent/log_levels"
25
25
  require_relative "agent/approval_gate"
26
+ require_relative "agent/field_policy"
27
+ require_relative "agent/field_names"
26
28
  require_relative "agent/prompt_hardening"
27
29
  require_relative "agent/describe"
28
30
 
@@ -592,8 +594,10 @@ module Parse
592
594
  # Use provided port, or configured port, or default
593
595
  port ||= Parse.mcp_server_port || 3001
594
596
 
595
- @mcp_enabled = true
597
+ # Load the server before flagging MCP as enabled, so a failed load
598
+ # does not leave mcp_enabled? reporting true.
596
599
  require_relative "agent/mcp_server"
600
+ @mcp_enabled = true
597
601
  MCPServer.default_port = port
598
602
 
599
603
  # Pass remote API config if available
@@ -1559,6 +1563,7 @@ module Parse
1559
1563
  max_log_size: DEFAULT_MAX_LOG_SIZE,
1560
1564
  system_prompt: nil, system_prompt_suffix: nil, pricing: nil,
1561
1565
  tools: nil, methods: nil, classes: nil, filters: nil,
1566
+ fields: nil, field_names: nil,
1562
1567
  parent: nil, recursion_depth: nil,
1563
1568
  strict_tool_filter: nil, strict_class_filter: nil,
1564
1569
  master_atlas: nil,
@@ -2033,6 +2038,21 @@ module Parse
2033
2038
  @method_filter_only, @method_filter_except = normalize_method_filter(methods)
2034
2039
  @class_filter_only, @class_filter_except = normalize_class_filter(classes)
2035
2040
  @filters = normalize_query_filters(filters)
2041
+ # Per-agent field narrowing (see Parse::Agent::FieldPolicy). Stored as
2042
+ # layers so a sub-agent's policy INTERSECTS its parent's: every layer
2043
+ # that names a class must permit a field for the agent to see it.
2044
+ own_field_policy = normalize_field_policy(fields)
2045
+ @field_policy_layers = (parent ? parent.field_policy_layers : []) +
2046
+ (own_field_policy ? [own_field_policy] : [])
2047
+ @field_policy_layers.freeze
2048
+ # Data-field naming mode (see Parse::Agent::FieldNames). A sub-agent
2049
+ # inherits its parent's mode unless it sets one; naming never affects
2050
+ # the access restrictions inherited above.
2051
+ @field_names_mode = if field_names.nil?
2052
+ parent ? parent.field_names_mode : :default
2053
+ else
2054
+ Parse::AggregationResult.normalize_field_names!(field_names)
2055
+ end
2036
2056
 
2037
2057
  # Sub-agent class-filter inheritance. Unlike `tools:` (which overrides
2038
2058
  # outright), `classes:` clamps to the parent's effective set so a
@@ -3428,6 +3448,49 @@ module Parse
3428
3448
  # lookup re-expands the variants and accepts both forms symmetrically.
3429
3449
  #
3430
3450
  # @return [Hash, nil] frozen Hash or nil when no filters declared
3451
+ # Normalize the constructor's `fields:` kwarg: a Hash mapping class
3452
+ # identifiers (Class, String, Symbol, or :default) to an Array of field
3453
+ # names. Keys canonicalize like `filters:` so `Parse::User` matches both
3454
+ # "_User" and "User". Field names may be Ruby property names or wire
3455
+ # names; they are translated to wire form at lookup time.
3456
+ def normalize_field_policy(fields)
3457
+ return nil if fields.nil?
3458
+ unless fields.is_a?(Hash)
3459
+ raise ArgumentError,
3460
+ "fields: must be a Hash mapping class identifiers (or :default) " \
3461
+ "to Arrays of field names, got #{fields.class}"
3462
+ end
3463
+ result = {}
3464
+ fields.each do |key, names|
3465
+ unless names.is_a?(Array) && names.all? { |n| n.is_a?(String) || n.is_a?(Symbol) }
3466
+ raise ArgumentError,
3467
+ "fields[#{key.inspect}]: value must be an Array of field names, got #{names.inspect}"
3468
+ end
3469
+ field_policy_keys(key).each { |canon| result[canon] = names.map(&:to_s).freeze }
3470
+ end
3471
+ result.freeze
3472
+ end
3473
+
3474
+ # Canonical lookup names for a `fields:` key. Tools look policies up by
3475
+ # the class's Parse name (`_User`, or a custom `parse_class`), so a
3476
+ # String or Symbol key is resolved through Parse::Model.find_class to
3477
+ # that name as well as kept verbatim; otherwise `fields: { "User" => ... }`
3478
+ # would silently fail to narrow `_User`.
3479
+ def field_policy_keys(key)
3480
+ keys = canonical_filter_key(key)
3481
+ if key.is_a?(String) || key.is_a?(Symbol)
3482
+ klass = (Parse::Model.find_class(key.to_s) rescue nil)
3483
+ if klass.respond_to?(:parse_class)
3484
+ keys |= [klass.parse_class.to_s]
3485
+ if defined?(Parse::Agent::MetadataRegistry) &&
3486
+ Parse::Agent::MetadataRegistry.respond_to?(:hidden_name_variants_for)
3487
+ keys |= Parse::Agent::MetadataRegistry.hidden_name_variants_for(klass)
3488
+ end
3489
+ end
3490
+ end
3491
+ keys
3492
+ end
3493
+
3431
3494
  def normalize_query_filters(filters)
3432
3495
  return nil if filters.nil?
3433
3496
  unless filters.is_a?(Hash)
@@ -3567,6 +3630,35 @@ module Parse
3567
3630
  #
3568
3631
  # @param class_name [String, Symbol, Class]
3569
3632
  # @return [Boolean]
3633
+ # @return [Array<Hash>] the per-agent `fields:` policies in effect, own
3634
+ # policy last; a sub-agent carries its parent's layers first. Each
3635
+ # layer maps a canonical class name (or :default) to raw field names.
3636
+ attr_reader :field_policy_layers
3637
+
3638
+ # @return [Symbol] `:default` or `:server`; see {Parse::Agent::FieldNames}.
3639
+ attr_reader :field_names_mode
3640
+
3641
+ # Wire-format field names this agent narrows `class_name` to, or nil when
3642
+ # no `fields:` layer names the class (or a :default). Every layer that
3643
+ # applies must permit a field, so a sub-agent can never widen its
3644
+ # parent. The result is intersected with the class's `agent_fields`
3645
+ # ceiling by {Parse::Agent::MetadataRegistry.field_allowlist}.
3646
+ #
3647
+ # @param class_name [String, Class]
3648
+ # @return [Array<String>, nil]
3649
+ def field_narrowing_for(class_name)
3650
+ return nil if @field_policy_layers.nil? || @field_policy_layers.empty?
3651
+ cn = class_name.respond_to?(:parse_class) ? class_name.parse_class.to_s : class_name.to_s
3652
+ result = nil
3653
+ @field_policy_layers.each do |layer|
3654
+ names = layer[cn] || layer[:default]
3655
+ next if names.nil?
3656
+ wire = Parse::Agent::MetadataRegistry.wire_field_names(cn, names)
3657
+ result = result ? (result & wire) : wire
3658
+ end
3659
+ result
3660
+ end
3661
+
3570
3662
  def class_filter_permits?(class_name)
3571
3663
  return true if @class_filter_only.nil? && @class_filter_except.nil?
3572
3664
  candidates = class_name_variants_for(class_name)
@@ -26,7 +26,16 @@ module Parse
26
26
  batch_operations = Parse::BatchOperation.new batch_operations
27
27
  end
28
28
  response = request(:post, "batch", body: batch_operations.as_json)
29
- response.success? && response.batch? ? response.batch_responses : response
29
+ return response.batch_responses if response.success? && response.batch?
30
+ return response if response.error?
31
+ # A successful HTTP response whose body is not an array of results
32
+ # cannot be matched to the submitted requests. Report it as a failure
33
+ # rather than as a batch where every write landed.
34
+ Parse::Response.error_response(
35
+ Parse::Response::ERROR_INTERNAL,
36
+ "Malformed batch response: expected an array of results",
37
+ http_status: response.http_status,
38
+ ).tap { |r| r.request = response.request }
30
39
  end
31
40
  end
32
41
  end
@@ -8,11 +8,19 @@ module Parse
8
8
  # @!visibility private
9
9
  SCHEMAS_PATH = "schemas"
10
10
 
11
+ # Parse Server serves `/schemas` to the master key only. Every call
12
+ # here therefore asks for the master key explicitly (outside client
13
+ # mode), which also stops an ambient `Parse.with_session` token or a
14
+ # client-bound token from being attached in its place. With a session token attached the request was
15
+ # refused with 403, and callers that cache the class permissions (the
16
+ # CLP scope used by mongo-direct queries) then denied a direct query
17
+ # that the master-keyed client was entitled to run.
18
+
11
19
  # Get all the schemas for the application.
12
20
  # @param opts [Hash] additional options for the request.
13
21
  # @return [Parse::Response]
14
22
  def schemas(opts = {})
15
- request_opts = { cache: false }.merge(opts)
23
+ request_opts = { cache: false }.merge(schema_auth_opts).merge(opts)
16
24
  request :get, SCHEMAS_PATH, opts: request_opts
17
25
  end
18
26
 
@@ -21,7 +29,7 @@ module Parse
21
29
  # @return [Parse::Response]
22
30
  def schema(className)
23
31
  safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
24
- opts = { cache: false }
32
+ opts = { cache: false }.merge(schema_auth_opts)
25
33
  request :get, "#{SCHEMAS_PATH}/#{safe}", opts: opts
26
34
  end
27
35
 
@@ -32,7 +40,7 @@ module Parse
32
40
  # @return [Parse::Response]
33
41
  def create_schema(className, schema)
34
42
  safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
35
- request :post, "#{SCHEMAS_PATH}/#{safe}", body: schema
43
+ request :post, "#{SCHEMAS_PATH}/#{safe}", body: schema, opts: schema_auth_opts
36
44
  end
37
45
 
38
46
  # Update the schema for a collection.
@@ -42,7 +50,18 @@ module Parse
42
50
  # @return [Parse::Response]
43
51
  def update_schema(className, schema)
44
52
  safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
45
- request :put, "#{SCHEMAS_PATH}/#{safe}", body: schema
53
+ request :put, "#{SCHEMAS_PATH}/#{safe}", body: schema, opts: schema_auth_opts
54
+ end
55
+
56
+ private
57
+
58
+ # Ask for the master key explicitly, except under `Parse.client_mode`,
59
+ # whose contract is that the SDK never sends the master key on its own
60
+ # initiative. A client with no master key configured sends none either
61
+ # way.
62
+ # @!visibility private
63
+ def schema_auth_opts
64
+ { use_master_key: !Parse.client_mode }
46
65
  end
47
66
  end #Schema
48
67
  end #API
@@ -13,8 +13,12 @@ module Parse
13
13
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
14
14
  # @return [Parse::Response]
15
15
  def fetch_session(session_token, **opts)
16
- opts.merge!({ use_master_key: false, cache: false })
17
- headers = { Parse::Protocol::SESSION_TOKEN => session_token }
16
+ # The positional token is passed as the explicit per-call token so an
17
+ # ambient `Parse.with_session` token or a client-bound token cannot
18
+ # replace it and return another user's session.
19
+ session_token = session_token.session_token if session_token.respond_to?(:session_token)
20
+ opts = opts.merge({ use_master_key: false, cache: false, session_token: session_token.to_s })
21
+ headers = { Parse::Protocol::SESSION_TOKEN => session_token.to_s }
18
22
  response = request :get, "#{SESSION_PATH_PREFIX}/me", headers: headers, opts: opts
19
23
  response.parse_class = Parse::Model::CLASS_SESSION
20
24
  response
@@ -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 }.
@@ -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