parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
@@ -74,13 +74,32 @@ module Parse
74
74
  # agree; see {Parse::MongoDB.verify_client!}. Per-client
75
75
  # authorization plus a process-global MongoDB connection is safe only
76
76
  # when they belong to the same application.
77
- Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, :client, keyword_init: true) do
77
+ # @!attribute master_dropped
78
+ # @return [Boolean, nil] `true` when the caller asked for `master: true`
79
+ # inside {Parse.without_master_key} and the call was downgraded to
80
+ # the public scope. Reported on the `parse.mongodb.aggregate`
81
+ # notification so a master-key caller that sees only public rows
82
+ # inside the block can be diagnosed.
83
+ Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, :client,
84
+ :master_dropped, keyword_init: true) do
78
85
  def master?; mode == :master; end
79
86
  def session?; mode == :session; end
80
87
  def public?; mode == :public; end
81
88
  def strict_role?; strict_role == true; end
89
+ def master_dropped?; master_dropped == true; end
82
90
  end
83
91
 
92
+ # SDK-internal value for `master:` on calls that read metadata, not rows
93
+ # (index statistics, the Atlas Search index listing). It resolves to
94
+ # master mode even inside {Parse.without_master_key}, which governs row
95
+ # access. Unlike wrapping the call in {Parse.with_master_key}, it changes
96
+ # no fiber state, so notification subscribers and threads started during
97
+ # the call still see the caller's block.
98
+ # @!visibility private
99
+ METADATA_MASTER = Object.new.tap do |o|
100
+ def o.inspect = "#<Parse::ACLScope::METADATA_MASTER>"
101
+ end.freeze
102
+
84
103
  # The client a resolution was produced by, or nil when it cannot say.
85
104
  #
86
105
  # Deliberately tolerant of objects that are resolution-shaped without
@@ -125,6 +144,12 @@ module Parse
125
144
  # `master: true` are supplied — they are mutually exclusive.
126
145
  # @raise [ACLRequired] when neither is supplied and
127
146
  # {.require_session_token} is `true`.
147
+ #
148
+ # Inside a {Parse.without_master_key} block an explicit `master: true`
149
+ # is dropped and the call runs in the public scope, as a REST request
150
+ # does once the block strips its master key. With
151
+ # {.require_session_token} set, the dropped call raises {ACLRequired}
152
+ # instead. A nested {Parse.with_master_key} block restores master mode.
128
153
  def resolve!(options, method_name:)
129
154
  session_token = options.delete(:session_token)
130
155
  master = options.delete(:master)
@@ -140,6 +165,10 @@ module Parse
140
165
  # the legacy behavior.
141
166
  strict_role = options.delete(:strict_role) == true
142
167
 
168
+ # SDK metadata reads keep master mode whatever the block says.
169
+ metadata_master = master.equal?(METADATA_MASTER)
170
+ master = true if metadata_master
171
+
143
172
  provided = [session_token, master == true ? master : nil, acl_user, acl_role].compact
144
173
  if provided.length > 1
145
174
  raise ArgumentError,
@@ -147,6 +176,12 @@ module Parse
147
176
  "session_token:, master: true, acl_user:, or acl_role:. Pick one."
148
177
  end
149
178
 
179
+ # `Parse.without_master_key` strips the master key from every REST
180
+ # request in the block, an explicit `use_master_key: true` included.
181
+ # A direct read must not keep master authority the REST call loses.
182
+ master_dropped = master == true && !metadata_master && master_key_suppressed?
183
+ master = nil if master_dropped
184
+
150
185
  if acl_user
151
186
  # Pre-resolved User-pointer path used by
152
187
  # Parse::Query#scope_to_user. Mirrors the session-token path
@@ -186,6 +221,18 @@ module Parse
186
221
  client: authorization_client(options))
187
222
  end
188
223
 
224
+ if master_dropped
225
+ if @require_session_token == true
226
+ raise ACLRequired,
227
+ "Parse::#{method_name} was called with master: true inside " \
228
+ "Parse.without_master_key, which drops master mode, and " \
229
+ "Parse::ACLScope.require_session_token refuses the public " \
230
+ "fallback. Pass session_token:, or run the call inside " \
231
+ "Parse.with_master_key if it needs master authority."
232
+ end
233
+ return public_resolution(options, master_dropped: true)
234
+ end
235
+
189
236
  if @require_session_token == true
190
237
  raise ACLRequired,
191
238
  "Parse::#{method_name} requires session_token: or master: true. " \
@@ -196,14 +243,7 @@ module Parse
196
243
  end
197
244
 
198
245
  warn_no_acl_context_once!(method_name)
199
- anonymous = Parse::Authorization::Resolved.new(nil, Set.new)
200
- Resolution.new(
201
- mode: :public,
202
- permission_strings: anonymous.permission_strings,
203
- user_id: nil,
204
- session: anonymous,
205
- client: authorization_client(options),
206
- )
246
+ public_resolution(options)
207
247
  end
208
248
 
209
249
  # Compile the `_rperm` `$match` stage to prepend to a mongo-direct
@@ -845,8 +885,30 @@ module Parse
845
885
  @warned_malformed_rperm_classes = Set.new
846
886
  end
847
887
 
888
+ # Whether an explicit `master: true` must be dropped: true inside a
889
+ # {Parse.without_master_key} block that no nested
890
+ # {Parse.with_master_key} has re-enabled.
891
+ # @!visibility private
892
+ # @return [Boolean]
893
+ def master_key_suppressed?
894
+ Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
895
+ end
896
+
848
897
  private
849
898
 
899
+ # The anonymous public-scope resolution: `"*"` grants only.
900
+ def public_resolution(options, master_dropped: nil)
901
+ anonymous = Parse::Authorization::Resolved.new(nil, Set.new)
902
+ Resolution.new(
903
+ mode: :public,
904
+ permission_strings: anonymous.permission_strings,
905
+ user_id: nil,
906
+ session: anonymous,
907
+ client: authorization_client(options),
908
+ master_dropped: master_dropped,
909
+ )
910
+ end
911
+
850
912
  # Emit the once-per-process security banner the first time a
851
913
  # mongo-direct path runs without `session_token:` and without
852
914
  # `master: true`. Mirrors {Parse::AtlasSearch}'s warned-once
@@ -117,17 +117,16 @@ module Parse
117
117
  # worst-case backtracking cost on any one pattern bounded.
118
118
  MAX_REGEX_PATTERN_LENGTH = 256
119
119
 
120
- # Allowed $options flag characters. MongoDB accepts i (case
121
- # insensitive), m (multi-line), x (extended/whitespace-ignored),
122
- # s (dot-all). The dot-all `s` flag is intentionally omitted: it
123
- # makes `.` cross newlines, which extends the search frontier on
124
- # multi-line text fields and amplifies catastrophic-backtracking
125
- # cost for the worst patterns. `imx` covers every real use case
126
- # the agent surface needs.
127
- ALLOWED_REGEX_OPTIONS = "imx"
128
-
129
- # Heuristic for nested-quantifier ReDoS patterns (catastrophic
130
- # backtracking). Matches a quantifier (`+` or `*`) INSIDE a
120
+ # Allowed $options flag characters: the shared
121
+ # {Parse::RegexSecurity::ALLOWED_OPTIONS} list (`imsu`). The extended
122
+ # flag `x` is refused because it lets comments hide a quantifier from
123
+ # the pattern check.
124
+ ALLOWED_REGEX_OPTIONS = defined?(Parse::RegexSecurity::ALLOWED_OPTIONS) ? Parse::RegexSecurity::ALLOWED_OPTIONS : "imsu"
125
+
126
+ # Older heuristic for nested-quantifier ReDoS patterns, kept for
127
+ # callers that reference it. Patterns are now checked by
128
+ # {Parse::RegexSecurity.validate!}, which parses the pattern. This
129
+ # heuristic matches a quantifier (`+` or `*`) INSIDE a
131
130
  # parenthesized group that is itself followed by a quantifier
132
131
  # (`+`, `*`, or `?`) — the structural shape that drives
133
132
  # exponential time on adversarial inputs (`(a+)+`, `(a*)*`,
@@ -435,15 +434,17 @@ module Parse
435
434
  #
436
435
  # 1. $regex must be a String. No Hash/Array/Numeric values.
437
436
  # 2. Pattern length ≤ MAX_REGEX_PATTERN_LENGTH (256 chars).
438
- # 3. Pattern must not match the nested-quantifier heuristic
439
- # (REDOS_NESTED_QUANTIFIER_RE).
437
+ # 3. Pattern must pass Parse::RegexSecurity.validate!, which
438
+ # parses it and refuses repeated groups holding a quantifier,
439
+ # alternation, or backreference, inline comments, and extended
440
+ # mode.
440
441
  #
441
442
  # For $options:
442
443
  #
443
444
  # 1. Must be a String.
444
445
  # 2. Length ≤ 8 (defensive — real-world usage is 0-3 chars).
445
- # 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imx).
446
- # The `s` (dot-all) flag is intentionally rejected.
446
+ # 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imsu).
447
+ # The extended `x` flag is rejected.
447
448
  #
448
449
  # @raise [ConstraintSecurityError] on any rule violation.
449
450
  def assert_regex_operand_safe!(op, val)
@@ -465,12 +466,12 @@ module Parse
465
466
  reason: :regex_too_long,
466
467
  )
467
468
  end
468
- if REDOS_NESTED_QUANTIFIER_RE.match?(val)
469
+ unless Parse::RegexSecurity.safe?(val, max_length: MAX_REGEX_PATTERN_LENGTH)
469
470
  raise ConstraintSecurityError.new(
470
471
  "$regex pattern #{val.inspect} contains a nested quantifier " \
471
- "(`(...x+...)+` shape) that can trigger catastrophic " \
472
- "backtracking on MongoDB's PCRE engine. Rewrite the pattern " \
473
- "without nested quantifier groups.",
472
+ "or another backtracking-prone construct that can trigger " \
473
+ "catastrophic backtracking on MongoDB's PCRE engine. Rewrite " \
474
+ "the pattern without nested quantifier groups.",
474
475
  operator: op,
475
476
  reason: :regex_redos,
476
477
  )
@@ -495,8 +496,8 @@ module Parse
495
496
  raise ConstraintSecurityError.new(
496
497
  "$options contains disallowed flag(s) " \
497
498
  "#{unrecognized.uniq.inspect}. Allowed flags: " \
498
- "#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The dot-all " \
499
- "`s` flag is intentionally rejected.",
499
+ "#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The extended " \
500
+ "`x` flag is rejected.",
500
501
  operator: op,
501
502
  reason: :invalid_regex,
502
503
  )
@@ -176,7 +176,8 @@ module Parse
176
176
  tenant_id: tenant_id,
177
177
  classes: filter_descriptor(@class_filter_only, @class_filter_except),
178
178
  tools: tools_descriptor,
179
- methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s }),
179
+ methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s })
180
+ .merge(layers: method_filter_layers_descriptor),
180
181
  filters: per_agent_filters_summary,
181
182
  hidden_classes: Parse::Agent::MetadataRegistry.hidden_class_names,
182
183
  per_class: per_class_descriptor,
@@ -383,10 +384,15 @@ module Parse
383
384
  lines << " except: #{data[:tools][:except].inspect}" if data[:tools][:except]
384
385
  lines << " effective: #{data[:tools][:effective].inspect}"
385
386
 
386
- if data[:methods][:only] || data[:methods][:except]
387
+ inherited_layers = Array(data[:methods][:layers])[0...-1] if data[:methods][:only] || data[:methods][:except]
388
+ inherited_layers ||= Array(data[:methods][:layers])
389
+ if data[:methods][:only] || data[:methods][:except] || inherited_layers.any?
387
390
  lines << " methods:"
388
391
  lines << " only: #{data[:methods][:only].inspect}" if data[:methods][:only]
389
392
  lines << " except: #{data[:methods][:except].inspect}" if data[:methods][:except]
393
+ inherited_layers.each do |layer|
394
+ lines << " inherited: #{layer.compact.inspect}"
395
+ end
390
396
  end
391
397
 
392
398
  if data[:filters]
@@ -89,6 +89,23 @@ module Parse
89
89
  end
90
90
  end
91
91
 
92
+ # Raised when a session-token agent needs its resolved claim set (for
93
+ # the SDK-side CLP check, an ACL `$match`, or a sub-agent subset check)
94
+ # but the token could not be resolved. A session agent is never treated
95
+ # as master-key posture just because its token did not resolve: the
96
+ # check fails closed instead. A subclass of {AccessDenied}, so tool
97
+ # dispatch reports it as an `:access_denied` refusal
98
+ # (`kind: :unresolved_identity`).
99
+ class UnresolvedIdentity < AccessDenied
100
+ def initialize(message = nil)
101
+ super(nil, message || "This agent's session could not be resolved, so its " \
102
+ "permissions cannot be checked. The request was refused " \
103
+ "rather than run without them; retry once Parse Server is " \
104
+ "reachable, or rebuild the agent with a valid session token.",
105
+ kind: :unresolved_identity)
106
+ end
107
+ end
108
+
92
109
  # Authentication failure for MCP transport adapters. Custom auth blocks
93
110
  # passed to Parse::Agent::MCPRackApp should raise this (or a subclass) to
94
111
  # signal an unauthenticated/unauthorized request; the transport layer
@@ -229,7 +229,14 @@ module Parse
229
229
  # token.
230
230
  prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
231
231
  prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
232
- prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
232
+ # Snapshot the agent's OWN gate, not the effective one: a sub-agent
233
+ # with no gate of its own reads its parent's, and restoring that
234
+ # copy would pin it and stop the delegation.
235
+ prev_approval_gate = if agent.respond_to?(:approval_gate_override)
236
+ agent.approval_gate_override
237
+ elsif agent.respond_to?(:approval_gate)
238
+ agent.approval_gate
239
+ end
233
240
  prev_log_callback = agent.log_callback if agent.respond_to?(:log_callback)
234
241
 
235
242
  # Install the progress callback and cancellation token on the
@@ -115,6 +115,32 @@ module Parse
115
115
  # Loopback hosts that are safe to bind to without an API key.
116
116
  LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
117
117
 
118
+ # Shortest API key accepted for a non-loopback bind.
119
+ MIN_PUBLIC_API_KEY_LENGTH = 16
120
+
121
+ # The API key to enforce, or nil when none is configured. Surrounding
122
+ # whitespace (ASCII or Unicode) is stripped, and a blank result is no
123
+ # key.
124
+ # @param key [String, nil]
125
+ # @return [String, nil]
126
+ # Strip leading and trailing whitespace of any kind (ASCII or Unicode)
127
+ # with a linear scan from each end.
128
+ # @param str [String]
129
+ # @return [String]
130
+ def self.strip_unicode_space(str)
131
+ first = 0
132
+ last = str.length
133
+ first += 1 while first < last && str[first].match?(/[[:space:]]/)
134
+ last -= 1 while last > first && str[last - 1].match?(/[[:space:]]/)
135
+ str[first...last]
136
+ end
137
+
138
+ def self.normalize_api_key(key)
139
+ return nil if key.nil?
140
+ stripped = strip_unicode_space(key.to_s)
141
+ stripped.empty? ? nil : stripped
142
+ end
143
+
118
144
  def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly,
119
145
  session_token: nil, api_key: nil, rate_limiter: nil,
120
146
  pre_auth_rate_limiter: nil,
@@ -126,7 +152,15 @@ module Parse
126
152
  raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
127
153
  end
128
154
 
129
- effective_api_key = api_key || ENV["MCP_API_KEY"]
155
+ # One normalization for every check below: surrounding whitespace is
156
+ # stripped and a key that is empty after stripping counts as no key.
157
+ # A whitespace-only key used to pass the non-loopback bind check
158
+ # (`" ".to_s.empty?` is false) and then disable request auth
159
+ # (`" ".present?` is false), leaving a public endpoint open.
160
+ # An explicit blank `api_key:` does not hide a configured
161
+ # MCP_API_KEY.
162
+ effective_api_key = self.class.normalize_api_key(api_key) ||
163
+ self.class.normalize_api_key(ENV["MCP_API_KEY"])
130
164
 
131
165
  # NEW-MCP-1: a non-loopback bind without an API key is an unauthenticated
132
166
  # network-exposed JSON-RPC endpoint. Refuse to start. Operators who
@@ -134,12 +168,19 @@ module Parse
134
168
  # auth — should bind to localhost and let the proxy forward, or
135
169
  # set MCP_API_KEY explicitly even when "the proxy authenticates"
136
170
  # (defense in depth).
137
- if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.to_s.empty?
171
+ if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.nil?
138
172
  raise ArgumentError,
139
173
  "MCPServer refuses to bind non-loopback host #{host.inspect} without an api_key. " \
140
174
  "Set MCP_API_KEY in the environment, pass api_key: explicitly, or use a loopback " \
141
175
  "host (one of: #{LOOPBACK_HOSTS.join(", ")})."
142
176
  end
177
+ # A short key on a public bind is guessable. Warn rather than refuse
178
+ # in a patch release so existing deployments keep starting.
179
+ if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.length < MIN_PUBLIC_API_KEY_LENGTH
180
+ warn "[Parse::Agent::MCPServer:SECURITY] api_key for non-loopback host #{host.inspect} " \
181
+ "is shorter than #{MIN_PUBLIC_API_KEY_LENGTH} characters. Use a long random key " \
182
+ "(for example SecureRandom.hex(32))."
183
+ end
143
184
 
144
185
  @port = port
145
186
  @host = host
@@ -250,8 +291,8 @@ module Parse
250
291
 
251
292
  # Tool list endpoint (requires auth if API key is configured)
252
293
  @server.mount_proc("/tools") do |req, res|
253
- if @api_key.present?
254
- provided_key = req[MCP_API_KEY_HEADER].to_s
294
+ if @api_key
295
+ provided_key = self.class.normalize_api_key(req[MCP_API_KEY_HEADER]).to_s
255
296
  unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
256
297
  error_response(res, 401, "Unauthorized: invalid or missing API key")
257
298
  next
@@ -353,8 +394,8 @@ module Parse
353
394
  # counters on each returned agent are scoped to that single request
354
395
  # and discarded when it ends, eliminating cross-request leakage.
355
396
  def agent_factory(env)
356
- if @api_key.present?
357
- provided_key = env["HTTP_X_MCP_API_KEY"].to_s
397
+ if @api_key
398
+ provided_key = self.class.normalize_api_key(env["HTTP_X_MCP_API_KEY"]).to_s
358
399
  unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
359
400
  raise Parse::Agent::Unauthorized.new("invalid or missing API key", reason: :bad_api_key)
360
401
  end