parse-stack-next 5.8.2 → 5.8.4
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 +323 -0
- data/README.md +20 -6
- data/bin/parse-console +9 -3
- data/docs/TEST_SERVER.md +1 -1
- data/docs/caching.md +7 -2
- data/docs/mcp_guide.md +34 -0
- data/lib/parse/agent/constraint_translator.rb +66 -2
- data/lib/parse/agent/mcp_rack_app.rb +90 -1
- data/lib/parse/agent/mcp_server.rb +24 -2
- data/lib/parse/agent/mcp_subscriptions.rb +32 -1
- data/lib/parse/agent/tools.rb +3 -3
- data/lib/parse/agent.rb +14 -3
- data/lib/parse/atlas_search/protected_paths.rb +163 -0
- data/lib/parse/atlas_search.rb +62 -0
- data/lib/parse/client.rb +148 -6
- data/lib/parse/clp_scope.rb +53 -2
- data/lib/parse/embeddings/cache.rb +29 -9
- data/lib/parse/graphql/type_generator.rb +184 -5
- data/lib/parse/live_query/client.rb +27 -9
- data/lib/parse/live_query/event.rb +55 -1
- data/lib/parse/live_query/event_queue.rb +6 -1
- data/lib/parse/live_query/health_monitor.rb +1 -1
- data/lib/parse/model/file.rb +37 -3
- data/lib/parse/mongodb.rb +19 -0
- data/lib/parse/pipeline_security.rb +242 -5
- data/lib/parse/query/constraints.rb +101 -8
- data/lib/parse/query/cursor.rb +161 -13
- data/lib/parse/query.rb +42 -0
- data/lib/parse/retrieval/agent_tool.rb +82 -0
- data/lib/parse/retrieval/retriever.rb +14 -2
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/rack_advisory.rb +53 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +106 -2
- data/lib/parse/two_factor_auth/user_extension.rb +41 -5
- data/lib/parse/two_factor_auth.rb +81 -4
- data/lib/parse/vector_search/hybrid.rb +21 -0
- data/lib/parse/vector_search.rb +15 -0
- data/lib/parse/webhooks/replay_protection.rb +5 -1
- data/lib/parse/webhooks.rb +23 -4
- data/parse-stack-next.gemspec +12 -5
- metadata +14 -7
|
@@ -576,22 +576,115 @@ module Parse
|
|
|
576
576
|
end
|
|
577
577
|
private :literal_char
|
|
578
578
|
|
|
579
|
+
# Shorthand classes the membership check understands, as ASCII sets.
|
|
580
|
+
# Characters outside ASCII never reach this check: {#literal_char}
|
|
581
|
+
# only yields ASCII punctuation, letters, and digits.
|
|
582
|
+
SHORTHAND_CLASSES = {
|
|
583
|
+
"d" => ->(c) { c.match?(/\A[0-9]\z/) },
|
|
584
|
+
"w" => ->(c) { c.match?(/\A[A-Za-z0-9_]\z/) },
|
|
585
|
+
"s" => ->(c) { c.match?(/\A[ \t\r\n\f\v]\z/) },
|
|
586
|
+
}.freeze
|
|
587
|
+
|
|
579
588
|
# @!visibility private
|
|
580
589
|
# Whether an atom (ignoring its quantifier) can match `ch` in either
|
|
581
590
|
# case. Anything that cannot be checked counts as a match.
|
|
591
|
+
#
|
|
592
|
+
# The pattern text is never compiled with Ruby's Regexp: the caller's
|
|
593
|
+
# pattern is meant for MongoDB's PCRE engine, and compiling attacker
|
|
594
|
+
# text with Onigmo exposes the regexp-compiler memory-safety bugs
|
|
595
|
+
# fixed in Ruby 3.3.13, 3.4.12, and 4.0.9 (GitLab CVE-2026-89078 and
|
|
596
|
+
# related). Membership is decided from the parsed atom instead: a
|
|
597
|
+
# literal, a `\<punct>` escape, `\d \w \s` and their negations, or
|
|
598
|
+
# a character class built from those and ranges.
|
|
582
599
|
def atom_matches?(item, ch)
|
|
583
600
|
atom = item[:atom]
|
|
584
|
-
return true
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
601
|
+
return true if atom == [:dot]
|
|
602
|
+
return true unless atom == [:char]
|
|
603
|
+
variants = [ch, ch.swapcase, ch.downcase(:fold), ch.upcase].uniq
|
|
604
|
+
text = item[:text].to_s
|
|
605
|
+
if text.start_with?("[")
|
|
606
|
+
class_matches?(text, variants)
|
|
607
|
+
else
|
|
608
|
+
single = single_matcher(text)
|
|
609
|
+
single.nil? || variants.any? { |c| single.call(c) }
|
|
610
|
+
end
|
|
592
611
|
end
|
|
593
612
|
private :atom_matches?
|
|
594
613
|
|
|
614
|
+
# @!visibility private
|
|
615
|
+
# A predicate for one non-class atom's text, or nil when it cannot be
|
|
616
|
+
# checked (the caller then counts it as a match).
|
|
617
|
+
def single_matcher(text)
|
|
618
|
+
if text.length == 1 && !"\\.[]()|?*+{}^$".include?(text)
|
|
619
|
+
->(c) { c == text }
|
|
620
|
+
elsif text.length == 2 && text.start_with?("\\")
|
|
621
|
+
code = text[1]
|
|
622
|
+
if SHORTHAND_CLASSES.key?(code)
|
|
623
|
+
SHORTHAND_CLASSES[code]
|
|
624
|
+
elsif SHORTHAND_CLASSES.key?(code.downcase)
|
|
625
|
+
->(c) { !SHORTHAND_CLASSES[code.downcase].call(c) }
|
|
626
|
+
elsif !code.match?(/[A-Za-z0-9]/)
|
|
627
|
+
->(c) { c == code }
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
private :single_matcher
|
|
632
|
+
|
|
633
|
+
# @!visibility private
|
|
634
|
+
# Whether a `[...]` class can match any of `variants`. Members are
|
|
635
|
+
# literals, `\<punct>` escapes, `\d \w \s` (and negations), and
|
|
636
|
+
# `a-z` ranges; any other construct (POSIX classes, `\p{...}`, nested
|
|
637
|
+
# sets) makes the class count as a match.
|
|
638
|
+
def class_matches?(text, variants)
|
|
639
|
+
body = text[1...-1].to_s
|
|
640
|
+
negated = body.start_with?("^")
|
|
641
|
+
body = body[1..] if negated
|
|
642
|
+
members = []
|
|
643
|
+
i = 0
|
|
644
|
+
while i < body.length
|
|
645
|
+
c = body[i]
|
|
646
|
+
if c == "\\"
|
|
647
|
+
code = body[i + 1]
|
|
648
|
+
return true if code.nil?
|
|
649
|
+
if SHORTHAND_CLASSES.key?(code.downcase)
|
|
650
|
+
members << single_matcher("\\#{code}")
|
|
651
|
+
i += 2
|
|
652
|
+
next
|
|
653
|
+
end
|
|
654
|
+
return true if code.match?(/[A-Za-z0-9]/)
|
|
655
|
+
lo = code
|
|
656
|
+
i += 2
|
|
657
|
+
elsif c == "["
|
|
658
|
+
return true
|
|
659
|
+
else
|
|
660
|
+
lo = c
|
|
661
|
+
i += 1
|
|
662
|
+
end
|
|
663
|
+
if body[i] == "-" && i + 1 < body.length
|
|
664
|
+
hi = body[i + 1]
|
|
665
|
+
return true if hi == "\\" || hi == "["
|
|
666
|
+
# Members are plain data, not closures: a lambda built inside
|
|
667
|
+
# this loop would close over the loop's variables and see only
|
|
668
|
+
# their last values.
|
|
669
|
+
members << [:range, lo, hi]
|
|
670
|
+
i += 2
|
|
671
|
+
else
|
|
672
|
+
members << [:literal, lo]
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
member = lambda do |v|
|
|
676
|
+
members.any? do |m|
|
|
677
|
+
case m
|
|
678
|
+
when Proc then m.call(v)
|
|
679
|
+
when Array
|
|
680
|
+
m[0] == :range ? (v >= m[1] && v <= m[2]) : v == m[1]
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
negated ? !variants.all?(&member) : variants.any?(&member)
|
|
685
|
+
end
|
|
686
|
+
private :class_matches?
|
|
687
|
+
|
|
595
688
|
# @!visibility private
|
|
596
689
|
# A repeated group body that cannot split its input more than one way
|
|
597
690
|
# per repeat, so repeating it is safe:
|
data/lib/parse/query/cursor.rb
CHANGED
|
@@ -197,10 +197,53 @@ module Parse
|
|
|
197
197
|
}
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
+
# Maximum JSON nesting accepted when parsing a serialized cursor.
|
|
201
|
+
MAX_JSON_NESTING = 20
|
|
202
|
+
|
|
203
|
+
# Envelope version for signed cursor tokens.
|
|
204
|
+
SIGNED_VERSION = 2
|
|
205
|
+
|
|
206
|
+
class << self
|
|
207
|
+
# Secret used to HMAC-sign serialized cursors. When set, {#serialize}
|
|
208
|
+
# emits a signed token and {.deserialize} refuses any token without a
|
|
209
|
+
# valid signature. A per-call `secret:` argument overrides it. The
|
|
210
|
+
# secret must be a String of at least {MIN_SECRET_BYTES} bytes;
|
|
211
|
+
# anything else raises ArgumentError, since a blank or short key is
|
|
212
|
+
# guessable and would make signed tokens forgeable.
|
|
213
|
+
#
|
|
214
|
+
# The length check cannot tell a random key from a long guessable one.
|
|
215
|
+
# Generate the secret from a cryptographic random source, store it with
|
|
216
|
+
# your other application secrets, and keep it out of source control:
|
|
217
|
+
#
|
|
218
|
+
# ruby -rsecurerandom -e 'puts SecureRandom.hex(32)' # 32 random bytes, 64 hex characters
|
|
219
|
+
#
|
|
220
|
+
# Parse::Cursor.signing_secret = ENV.fetch("PARSE_CURSOR_SECRET")
|
|
221
|
+
#
|
|
222
|
+
# Rotating the secret invalidates every token signed with the old one.
|
|
223
|
+
# @return [String, nil]
|
|
224
|
+
attr_reader :signing_secret
|
|
225
|
+
|
|
226
|
+
# @param value [String, nil]
|
|
227
|
+
def signing_secret=(value)
|
|
228
|
+
@signing_secret = value.nil? ? nil : validate_secret!(value)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Shortest signing secret accepted, in bytes (the HMAC-SHA256 block
|
|
233
|
+
# recommendation for a random key is at least 32 bytes).
|
|
234
|
+
MIN_SECRET_BYTES = 32
|
|
235
|
+
|
|
200
236
|
# Serialize the cursor state to a JSON string for persistence.
|
|
201
237
|
# Useful for background jobs that may be interrupted and resumed.
|
|
202
238
|
#
|
|
239
|
+
# The token contains the class name and the full query constraints.
|
|
240
|
+
# Without a secret it is plain JSON that anyone holding it can edit. When
|
|
241
|
+
# a secret is configured (via {.signing_secret} or `secret:`), the state
|
|
242
|
+
# is wrapped in an envelope with an HMAC-SHA256 signature that
|
|
243
|
+
# {.deserialize} verifies before rebuilding the query.
|
|
244
|
+
#
|
|
203
245
|
# @example Save cursor state for later
|
|
246
|
+
# Parse::Cursor.signing_secret = ENV["CURSOR_SECRET"]
|
|
204
247
|
# cursor = Song.cursor(limit: 100)
|
|
205
248
|
# cursor.next_page
|
|
206
249
|
# state = cursor.serialize
|
|
@@ -208,11 +251,12 @@ module Parse
|
|
|
208
251
|
#
|
|
209
252
|
# @example Resume in a background job
|
|
210
253
|
# state = redis.get("cursor:#{job_id}")
|
|
211
|
-
# cursor = Parse::Cursor.deserialize(state)
|
|
254
|
+
# cursor = Parse::Cursor.deserialize(state, expected_class: Song)
|
|
212
255
|
# cursor.each_page { |page| process(page) }
|
|
213
256
|
#
|
|
257
|
+
# @param secret [String, nil] signing secret; defaults to {.signing_secret}
|
|
214
258
|
# @return [String] JSON string containing cursor state
|
|
215
|
-
def serialize
|
|
259
|
+
def serialize(secret: self.class.signing_secret)
|
|
216
260
|
require "json"
|
|
217
261
|
state = {
|
|
218
262
|
class_name: @query.table,
|
|
@@ -228,27 +272,78 @@ module Parse
|
|
|
228
272
|
order_direction: @order_direction,
|
|
229
273
|
version: 1, # For future compatibility
|
|
230
274
|
}
|
|
231
|
-
JSON.generate(state)
|
|
275
|
+
payload = JSON.generate(state)
|
|
276
|
+
return payload if secret.nil?
|
|
277
|
+
|
|
278
|
+
JSON.generate(v: SIGNED_VERSION, state: payload, sig: self.class.sign(payload, secret))
|
|
232
279
|
end
|
|
233
280
|
|
|
234
281
|
# Alias for serialize
|
|
282
|
+
# @param secret [String, nil] signing secret; defaults to {.signing_secret}
|
|
235
283
|
# @return [String] JSON string containing cursor state
|
|
236
|
-
def to_json
|
|
237
|
-
serialize
|
|
284
|
+
def to_json(*_args, secret: self.class.signing_secret)
|
|
285
|
+
serialize(secret: secret)
|
|
238
286
|
end
|
|
239
287
|
|
|
240
288
|
# Deserialize a cursor from a previously serialized state.
|
|
241
289
|
#
|
|
290
|
+
# The state names the class to query and the constraints to apply, so a
|
|
291
|
+
# token from an untrusted source (a client, a URL parameter) can query any
|
|
292
|
+
# class with any constraints unless it is signed. Configure a secret and
|
|
293
|
+
# pass `expected_class:` before accepting a token from outside the
|
|
294
|
+
# process.
|
|
295
|
+
#
|
|
296
|
+
# - With a secret (from `secret:` or {.signing_secret}), the token must be
|
|
297
|
+
# a signed envelope whose HMAC matches; the comparison is constant-time.
|
|
298
|
+
# Unsigned or tampered tokens raise ArgumentError.
|
|
299
|
+
# - Without a secret, an unsigned token is accepted only as trusted
|
|
300
|
+
# persistence: state the process wrote itself and stored where clients
|
|
301
|
+
# cannot change it (a job queue, a server-side table). Pass
|
|
302
|
+
# `trusted: true` to say so. Without it, an unsigned token is still
|
|
303
|
+
# accepted in this release with a one-time warning; a future minor
|
|
304
|
+
# release will require `trusted: true`. Never use this mode on
|
|
305
|
+
# client-supplied tokens.
|
|
306
|
+
#
|
|
242
307
|
# @param json_string [String] the serialized cursor state
|
|
308
|
+
# @param secret [String, nil] signing secret; defaults to {.signing_secret}
|
|
309
|
+
# @param expected_class [Class, String, nil] refuse tokens for any other
|
|
310
|
+
# Parse class
|
|
311
|
+
# @param trusted [Boolean] the caller asserts an unsigned token came from
|
|
312
|
+
# trusted persistence; ignored when a secret is in effect
|
|
243
313
|
# @return [Parse::Cursor] a cursor restored to the saved state
|
|
244
|
-
# @raise [ArgumentError] if the JSON is invalid
|
|
314
|
+
# @raise [ArgumentError] if the JSON is invalid, missing required fields,
|
|
315
|
+
# fails signature verification, or names a different class
|
|
245
316
|
#
|
|
246
317
|
# @example Resume a cursor
|
|
247
|
-
# cursor = Parse::Cursor.deserialize(saved_state)
|
|
318
|
+
# cursor = Parse::Cursor.deserialize(saved_state, expected_class: Song)
|
|
248
319
|
# cursor.each_page { |page| process(page) }
|
|
249
|
-
def self.deserialize(json_string)
|
|
320
|
+
def self.deserialize(json_string, secret: signing_secret, expected_class: nil, trusted: false)
|
|
250
321
|
require "json"
|
|
251
|
-
|
|
322
|
+
require "active_support/security_utils"
|
|
323
|
+
raise ArgumentError, "Invalid cursor state: expected a String" unless json_string.is_a?(String)
|
|
324
|
+
secret = validate_secret!(secret) unless secret.nil?
|
|
325
|
+
|
|
326
|
+
state = parse_state(json_string)
|
|
327
|
+
signed = state.is_a?(Hash) && state.key?(:sig) && state.key?(:state)
|
|
328
|
+
|
|
329
|
+
if signed
|
|
330
|
+
if secret.nil?
|
|
331
|
+
raise ArgumentError, "Signed cursor state requires a signing secret to verify"
|
|
332
|
+
end
|
|
333
|
+
payload = state[:state]
|
|
334
|
+
sig = state[:sig]
|
|
335
|
+
unless payload.is_a?(String) && sig.is_a?(String) &&
|
|
336
|
+
ActiveSupport::SecurityUtils.secure_compare(sign(payload, secret), sig)
|
|
337
|
+
raise ArgumentError, "Invalid cursor state: signature mismatch"
|
|
338
|
+
end
|
|
339
|
+
state = parse_state(payload)
|
|
340
|
+
elsif secret
|
|
341
|
+
raise ArgumentError, "Invalid cursor state: unsigned cursor refused while a signing secret is configured"
|
|
342
|
+
else
|
|
343
|
+
warn_unsigned_once unless trusted
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
raise ArgumentError, "Invalid cursor state: expected a JSON object" unless state.is_a?(Hash)
|
|
252
347
|
|
|
253
348
|
# Validate required fields
|
|
254
349
|
required = [:class_name, :page_size, :order_field, :order_direction]
|
|
@@ -257,6 +352,13 @@ module Parse
|
|
|
257
352
|
raise ArgumentError, "Invalid cursor state: missing #{missing.join(", ")}"
|
|
258
353
|
end
|
|
259
354
|
|
|
355
|
+
if expected_class
|
|
356
|
+
expected_name = expected_class.respond_to?(:parse_class) ? expected_class.parse_class : expected_class.to_s
|
|
357
|
+
unless state[:class_name].to_s == expected_name
|
|
358
|
+
raise ArgumentError, "Cursor state is for class #{state[:class_name].to_s.inspect}, expected #{expected_name.inspect}"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
260
362
|
# Get the model class
|
|
261
363
|
klass = Parse::Model.find_class(state[:class_name])
|
|
262
364
|
unless klass
|
|
@@ -286,9 +388,53 @@ module Parse
|
|
|
286
388
|
|
|
287
389
|
# Alias for deserialize
|
|
288
390
|
# @param json_string [String] the serialized cursor state
|
|
391
|
+
# @param kwargs [Hash] `secret:`, `expected_class:`, and `trusted:`, as for {.deserialize}
|
|
289
392
|
# @return [Parse::Cursor] a cursor restored to the saved state
|
|
290
|
-
def self.from_json(json_string)
|
|
291
|
-
deserialize(json_string)
|
|
393
|
+
def self.from_json(json_string, **kwargs)
|
|
394
|
+
deserialize(json_string, **kwargs)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# HMAC-SHA256 of a serialized cursor payload, hex encoded.
|
|
398
|
+
# @!visibility private
|
|
399
|
+
def self.sign(payload, secret)
|
|
400
|
+
require "openssl"
|
|
401
|
+
OpenSSL::HMAC.hexdigest("SHA256", validate_secret!(secret), payload)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The secret, when it is a String of at least {MIN_SECRET_BYTES} bytes
|
|
405
|
+
# that is not only whitespace.
|
|
406
|
+
# @!visibility private
|
|
407
|
+
# @raise [ArgumentError]
|
|
408
|
+
def self.validate_secret!(secret)
|
|
409
|
+
unless secret.is_a?(String) && !secret.strip.empty? && secret.bytesize >= MIN_SECRET_BYTES
|
|
410
|
+
raise ArgumentError,
|
|
411
|
+
"Parse::Cursor signing secret must be a String of at least #{MIN_SECRET_BYTES} " \
|
|
412
|
+
"bytes (got #{secret.class}#{secret.is_a?(String) ? ", #{secret.bytesize} bytes" : ""})."
|
|
413
|
+
end
|
|
414
|
+
secret
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# @!visibility private
|
|
418
|
+
def self.parse_state(json)
|
|
419
|
+
JSON.parse(json, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
|
|
420
|
+
rescue JSON::ParserError => e
|
|
421
|
+
raise ArgumentError, "Invalid cursor state: #{e.message}"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# @!visibility private
|
|
425
|
+
def self.warn_unsigned_once
|
|
426
|
+
return if @warned_unsigned
|
|
427
|
+
@warned_unsigned = true
|
|
428
|
+
warn "[Parse::Cursor] Deserializing an unsigned cursor. Unsigned cursor state can " \
|
|
429
|
+
"name any class and constraints; never accept it from a client. Set " \
|
|
430
|
+
"Parse::Cursor.signing_secret to sign and verify cursors, or pass trusted: true " \
|
|
431
|
+
"for state the process stored itself. A future minor release will require one of them."
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Reset the one-time unsigned warning. Test-only.
|
|
435
|
+
# @!visibility private
|
|
436
|
+
def self.reset_unsigned_warning!
|
|
437
|
+
@warned_unsigned = false
|
|
292
438
|
end
|
|
293
439
|
|
|
294
440
|
private
|
|
@@ -307,8 +453,10 @@ module Parse
|
|
|
307
453
|
|
|
308
454
|
# Deserialize a value from JSON storage
|
|
309
455
|
def self.deserialize_value(value)
|
|
310
|
-
return value unless value.is_a?(Hash)
|
|
311
|
-
|
|
456
|
+
return value unless value.is_a?(Hash)
|
|
457
|
+
type = value["__type"] || value[:__type]
|
|
458
|
+
return value unless type == "Date"
|
|
459
|
+
DateTime.parse(value["iso"] || value[:iso])
|
|
312
460
|
end
|
|
313
461
|
|
|
314
462
|
# Set up the ordering for cursor pagination.
|
data/lib/parse/query.rb
CHANGED
|
@@ -4463,6 +4463,18 @@ module Parse
|
|
|
4463
4463
|
# every document silently satisfied the constraint.
|
|
4464
4464
|
return field_str if field_str.start_with?("_")
|
|
4465
4465
|
|
|
4466
|
+
# A name the model declares, either its Ruby property name or its
|
|
4467
|
+
# declared server name (`belongs_to :owner_ref, field: "OwnerRef"`
|
|
4468
|
+
# takes both `owner_ref` and `OwnerRef`), resolves to the declared
|
|
4469
|
+
# column whether or not a query's field alias scope is active. Left
|
|
4470
|
+
# to the formatter, an unscoped `OwnerRef` columnizes to `ownerRef`,
|
|
4471
|
+
# which no property declares, so it passed through as `OwnerRef`
|
|
4472
|
+
# instead of `_p_OwnerRef`, and a `$ne` / `$nin` / `$exists: false`
|
|
4473
|
+
# on that missing column matched every row. Undeclared names (the
|
|
4474
|
+
# pipeline-local aliases described below) are not affected.
|
|
4475
|
+
declared = declared_column_for_direct_mongodb(field_str)
|
|
4476
|
+
return declared if declared
|
|
4477
|
+
|
|
4466
4478
|
# Apply field formatting for regular fields
|
|
4467
4479
|
formatted = Query.format_field(field)
|
|
4468
4480
|
|
|
@@ -4525,6 +4537,36 @@ module Parse
|
|
|
4525
4537
|
end
|
|
4526
4538
|
end
|
|
4527
4539
|
|
|
4540
|
+
# The storage column for a name the query's model declares, by its Ruby
|
|
4541
|
+
# property name or its declared server name, or nil for any other name.
|
|
4542
|
+
# A pointer resolves to `_p_<server name>`. Built-in system fields and
|
|
4543
|
+
# server names that start with `_` are left to the default handling.
|
|
4544
|
+
#
|
|
4545
|
+
# @param field_str [String]
|
|
4546
|
+
# @return [String, nil]
|
|
4547
|
+
# @api private
|
|
4548
|
+
def declared_column_for_direct_mongodb(field_str)
|
|
4549
|
+
return nil if field_str.empty? || field_str.include?(".")
|
|
4550
|
+
klass = table_model_class
|
|
4551
|
+
return nil unless klass.respond_to?(:field_map) && klass.respond_to?(:fields)
|
|
4552
|
+
fmap = klass.field_map
|
|
4553
|
+
sym = field_str.to_sym
|
|
4554
|
+
local = if fmap.key?(sym)
|
|
4555
|
+
sym
|
|
4556
|
+
else
|
|
4557
|
+
fmap.find { |_k, remote| remote.to_s == field_str }&.first
|
|
4558
|
+
end
|
|
4559
|
+
return nil if local.nil?
|
|
4560
|
+
return nil if Query::FIELD_ALIAS_BUILTINS.include?(local.to_s)
|
|
4561
|
+
remote = fmap[local].to_s
|
|
4562
|
+
return nil if remote.empty? || remote.start_with?("_")
|
|
4563
|
+
return nil if %w[objectId createdAt updatedAt ACL].include?(remote)
|
|
4564
|
+
type = klass.fields[local] || klass.fields[remote.to_sym]
|
|
4565
|
+
type == :pointer ? "_p_#{remote}" : remote
|
|
4566
|
+
rescue NameError
|
|
4567
|
+
nil
|
|
4568
|
+
end
|
|
4569
|
+
|
|
4528
4570
|
# Convert a value for direct MongoDB execution.
|
|
4529
4571
|
# @param field [String] the field name (for context)
|
|
4530
4572
|
# @param value [Object] the value to convert
|
|
@@ -123,6 +123,10 @@ module Parse
|
|
|
123
123
|
|
|
124
124
|
klass = Parse::Agent::MetadataRegistry.resolve_searchable!(class_name)
|
|
125
125
|
cname = klass.parse_class
|
|
126
|
+
# The same class gate every other read tool runs: the per-agent
|
|
127
|
+
# `classes:` allowlist and the CLP `find` check for scoped agents.
|
|
128
|
+
# resolve_searchable! only consults the global hidden flag.
|
|
129
|
+
Parse::Agent::Tools.assert_class_accessible!(cname, agent: agent, op: :find)
|
|
126
130
|
|
|
127
131
|
unless query.is_a?(String) && !query.strip.empty?
|
|
128
132
|
raise Parse::Agent::ValidationError, "semantic_search: `query` must be a non-empty String."
|
|
@@ -155,6 +159,10 @@ module Parse
|
|
|
155
159
|
end
|
|
156
160
|
assert_filter_fields_allowed!(filter, allowed)
|
|
157
161
|
assert_filter_fields_allowed!(vector_filter, allowed)
|
|
162
|
+
# The operator's per-agent filter (`filters: { Class => {...} }`) is
|
|
163
|
+
# ANDed into the post-search match after the caller's filter has been
|
|
164
|
+
# checked, so it is unconditional, as on every other read tool.
|
|
165
|
+
filter = compose_per_agent_filter(agent, klass, cname, filter)
|
|
158
166
|
|
|
159
167
|
# Tenant scope (nil for unscoped classes / bypassed admins; raises
|
|
160
168
|
# AccessDenied for an un-bound agent on a scoped class).
|
|
@@ -271,6 +279,80 @@ module Parse
|
|
|
271
279
|
envelope
|
|
272
280
|
end
|
|
273
281
|
|
|
282
|
+
# @!visibility private
|
|
283
|
+
# The caller's post-search filter with the agent's per-agent filter for
|
|
284
|
+
# `cname` ANDed in. The per-agent filter is converted to storage form
|
|
285
|
+
# against the class's declared fields (see {.per_agent_storage_filter}).
|
|
286
|
+
# The caller's filter gets the pointer conversion
|
|
287
|
+
# Parse::Retrieval.retrieve would have applied, since retrieve converts
|
|
288
|
+
# only top-level keys and both sides end up nested under `$and`.
|
|
289
|
+
def compose_per_agent_filter(agent, klass, cname, filter)
|
|
290
|
+
per_agent = agent.respond_to?(:filter_for) ? agent.filter_for(cname) : nil
|
|
291
|
+
return filter if per_agent.nil? || per_agent.empty?
|
|
292
|
+
scoped = per_agent_storage_filter(klass, per_agent)
|
|
293
|
+
return scoped if filter.nil? || filter.empty?
|
|
294
|
+
caller_filter = Parse::Retrieval.send(:translate_pointer_filter_values, klass, filter)
|
|
295
|
+
{ "$and" => [scoped, caller_filter] }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Built-in columns a per-agent filter may name, with their stored names.
|
|
299
|
+
PER_AGENT_BUILTIN_COLUMNS = {
|
|
300
|
+
"objectId" => "_id", "createdAt" => "_created_at", "updatedAt" => "_updated_at",
|
|
301
|
+
}.freeze
|
|
302
|
+
|
|
303
|
+
# @!visibility private
|
|
304
|
+
# A REST-form per-agent filter rewritten for a post-search `$match`.
|
|
305
|
+
# Each field is resolved through the class's declared properties, by
|
|
306
|
+
# its Ruby name or its declared server name (`PublicState` stays
|
|
307
|
+
# `PublicState`), so it addresses the stored column exactly; pointer
|
|
308
|
+
# fields use their `_p_` column and storage-form values, and Date and
|
|
309
|
+
# pointer values are converted. A field the class does not declare is
|
|
310
|
+
# refused: guessing its column could aim a negative operator such as
|
|
311
|
+
# `$ne` at a column that does not exist, which matches every row.
|
|
312
|
+
def per_agent_storage_filter(klass, node)
|
|
313
|
+
case node
|
|
314
|
+
when Array
|
|
315
|
+
node.map { |child| per_agent_storage_filter(klass, child) }
|
|
316
|
+
when Hash
|
|
317
|
+
node.each_with_object({}) do |(key, value), out|
|
|
318
|
+
k = key.to_s
|
|
319
|
+
if %w[$and $or $nor].include?(k)
|
|
320
|
+
# A single Hash is one clause; Array(hash) would split it into
|
|
321
|
+
# key/value pairs.
|
|
322
|
+
clauses = value.is_a?(Hash) ? [value] : Array(value)
|
|
323
|
+
out[k] = per_agent_storage_filter(klass, clauses)
|
|
324
|
+
elsif k.start_with?("$")
|
|
325
|
+
raise Parse::Agent::ValidationError,
|
|
326
|
+
"semantic_search: per-agent filter operator #{k} is not supported on this tool."
|
|
327
|
+
else
|
|
328
|
+
column, pointer = per_agent_column(klass, k)
|
|
329
|
+
converted = klass.query.send(:convert_value_for_direct_mongodb, k, value)
|
|
330
|
+
out[pointer ? "_p_#{column}" : column] = converted
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
else
|
|
334
|
+
node
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# @!visibility private
|
|
339
|
+
# [stored column, pointer?] for a per-agent filter field.
|
|
340
|
+
def per_agent_column(klass, name)
|
|
341
|
+
return [PER_AGENT_BUILTIN_COLUMNS[name], false] if PER_AGENT_BUILTIN_COLUMNS.key?(name)
|
|
342
|
+
fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
343
|
+
local = if klass.fields.key?(name.to_sym)
|
|
344
|
+
name.to_sym
|
|
345
|
+
else
|
|
346
|
+
fmap.find { |_l, remote| remote.to_s == name }&.first
|
|
347
|
+
end
|
|
348
|
+
if local.nil?
|
|
349
|
+
raise Parse::Agent::ValidationError,
|
|
350
|
+
"semantic_search: per-agent filter field #{name.inspect} is not a declared " \
|
|
351
|
+
"property of #{klass.parse_class}."
|
|
352
|
+
end
|
|
353
|
+
[(fmap[local] || local).to_s, klass.fields[local] == :pointer]
|
|
354
|
+
end
|
|
355
|
+
|
|
274
356
|
# @!visibility private
|
|
275
357
|
# A profile's hybrid settings with the lexical branch restricted to the
|
|
276
358
|
# text sources the agent may read. Without this the lexical search runs
|
|
@@ -264,9 +264,12 @@ module Parse
|
|
|
264
264
|
# contract. `merge_filters` is last-wins, so ordering the tenant
|
|
265
265
|
# constraint LAST guarantees its key survives any caller collision:
|
|
266
266
|
# callers can narrow the result set but never escape their tenant.
|
|
267
|
-
|
|
267
|
+
# `filter` may hold an operator-imposed constraint (the agent tool's
|
|
268
|
+
# per-agent filter), so a hybrid config's own lexical filter is ANDed
|
|
269
|
+
# with it rather than merged over it.
|
|
270
|
+
lexical[:filter] = merge_filters(and_filters(filter, lexical[:filter]), tenant_filter_hash(klass, tenant_scope))
|
|
268
271
|
vector[:field] ||= field unless field.nil?
|
|
269
|
-
vector[:filter] =
|
|
272
|
+
vector[:filter] = and_filters(vector[:filter], filter)
|
|
270
273
|
vector[:vector_filter] = merge_filters(vector[:vector_filter], merged_vector_filter)
|
|
271
274
|
|
|
272
275
|
klass.hybrid_search(
|
|
@@ -391,6 +394,15 @@ module Parse
|
|
|
391
394
|
{ wire_name(klass, field) => value }
|
|
392
395
|
end
|
|
393
396
|
|
|
397
|
+
# @!visibility private
|
|
398
|
+
# AND non-empty filter hashes together. Returns nil when nothing is
|
|
399
|
+
# left to apply, and a single filter unwrapped.
|
|
400
|
+
def and_filters(*filters)
|
|
401
|
+
present = filters.reject { |f| f.nil? || (f.respond_to?(:empty?) && f.empty?) }
|
|
402
|
+
return nil if present.empty?
|
|
403
|
+
present.length == 1 ? present.first : { "$and" => present }
|
|
404
|
+
end
|
|
405
|
+
|
|
394
406
|
# @!visibility private
|
|
395
407
|
# Shallow-merge non-empty filter hashes (left-to-right; later keys
|
|
396
408
|
# win). Returns nil when nothing is left to apply.
|
|
@@ -8,5 +8,5 @@ Parse.setup app_id: ENV["PARSE_SERVER_APPLICATION_ID"],
|
|
|
8
8
|
server_url: "https://localhost:1337/parse"
|
|
9
9
|
# optional
|
|
10
10
|
# logging: false,
|
|
11
|
-
# cache:
|
|
11
|
+
# cache: Parse::Cache::Redis.new(url: ENV['REDIS_URL']),
|
|
12
12
|
# expires: 1 # cache ttl 1 second
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Stack
|
|
6
|
+
# Load-time check for Rack releases affected by the 2026-04 Rack
|
|
7
|
+
# advisories. The fixes are per branch (2.2.23, 3.1.21, 3.2.6, with no
|
|
8
|
+
# fixed 3.0 release). The gemspec floor covers the 2.2 line; RubyGems
|
|
9
|
+
# limits a requirement string to 255 characters, which is too short to
|
|
10
|
+
# exclude each vulnerable 3.x release by name, so a vulnerable 3.x
|
|
11
|
+
# release is reported here instead of being refused at install time.
|
|
12
|
+
module RackAdvisory
|
|
13
|
+
# Lowest fixed release per Rack 3.x minor line; nil means no fix exists
|
|
14
|
+
# on that line.
|
|
15
|
+
FIXED_3X = { "3.0" => nil, "3.1" => "3.1.21", "3.2" => "3.2.6" }.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Whether a Rack version is on a 3.x line with a known-vulnerable
|
|
20
|
+
# release below its fix.
|
|
21
|
+
# @param version [String, Gem::Version]
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def vulnerable?(version)
|
|
24
|
+
v = Gem::Version.new(version.to_s)
|
|
25
|
+
return false unless v.segments.first == 3
|
|
26
|
+
line = v.segments.first(2).join(".")
|
|
27
|
+
return false unless FIXED_3X.key?(line)
|
|
28
|
+
fixed = FIXED_3X[line]
|
|
29
|
+
fixed.nil? || v < Gem::Version.new(fixed)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Warn once when the loaded Rack release is vulnerable.
|
|
33
|
+
# @param version [String, Gem::Version, nil] defaults to the activated
|
|
34
|
+
# rack gem's version.
|
|
35
|
+
# @return [Boolean] true when a warning was printed
|
|
36
|
+
def check!(version = nil)
|
|
37
|
+
version ||= Gem.loaded_specs["rack"]&.version
|
|
38
|
+
return false if version.nil? || @warned
|
|
39
|
+
return false unless vulnerable?(version)
|
|
40
|
+
@warned = true
|
|
41
|
+
warn "[parse-stack-next] Rack #{version} is affected by the 2026-04 Rack " \
|
|
42
|
+
"security advisories. Upgrade to Rack 3.2.6 or later (or 3.1.21 or later " \
|
|
43
|
+
"on the 3.1 line; the 3.0 line has no fixed release)."
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @!visibility private
|
|
48
|
+
def reset!
|
|
49
|
+
@warned = false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|