parse-stack-next 5.7.5 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +290 -17
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
|
@@ -439,55 +439,347 @@ module Parse
|
|
|
439
439
|
# caller hasn't otherwise needed it.
|
|
440
440
|
require_relative "clp_scope" unless defined?(Parse::CLPScope)
|
|
441
441
|
|
|
442
|
+
require_relative "atlas_search/protected_paths" unless defined?(Parse::AtlasSearch::ProtectedPaths)
|
|
443
|
+
|
|
442
444
|
protected_set = Parse::CLPScope.protected_fields_for(collection_name, perms)
|
|
443
|
-
|
|
445
|
+
sets = Hash.new do |memo, klass|
|
|
446
|
+
memo[klass] = Parse::CLPScope.protected_fields_for(klass, perms)
|
|
447
|
+
end
|
|
448
|
+
sets[collection_name.to_s] = protected_set || Set.new
|
|
444
449
|
|
|
445
450
|
pipeline.each_with_index do |stage, idx|
|
|
446
|
-
|
|
451
|
+
walk_stage_refs!(stage, collection_name.to_s, sets, "pipeline[#{idx}]")
|
|
452
|
+
refuse_protected_stage_keys!(stage, collection_name.to_s, sets, "pipeline[#{idx}]")
|
|
447
453
|
end
|
|
448
454
|
nil
|
|
449
455
|
end
|
|
450
456
|
|
|
457
|
+
# Whether no stage of `pipeline` can change a row's identity
|
|
458
|
+
# (`_id` / `objectId`).
|
|
459
|
+
#
|
|
460
|
+
# The `_User` self exemption (a user sees their own `email`) is decided
|
|
461
|
+
# from the identity of the row that comes OUT of the pipeline. A stage
|
|
462
|
+
# such as `{ "$set" => { "_id" => "<caller id>" } }` would otherwise let
|
|
463
|
+
# another user's row claim the exemption. Only stages that cannot write
|
|
464
|
+
# an identity key count as preserving: filters, sorts, paging,
|
|
465
|
+
# `$unset`, `$project` that only includes or excludes `_id`, and
|
|
466
|
+
# `$addFields` / `$set` / `$lookup` / `$graphLookup` / `$unwind` /
|
|
467
|
+
# `$geoNear` that write no identity key. Every other stage (`$group`,
|
|
468
|
+
# `$replaceRoot`, `$facet`, `$unionWith`, ...) and any unknown shape
|
|
469
|
+
# returns false, so the exemption is withheld and the field stripped.
|
|
470
|
+
#
|
|
471
|
+
# @param pipeline [Array<Hash>, nil]
|
|
472
|
+
# @return [Boolean]
|
|
473
|
+
def identity_preserving?(pipeline)
|
|
474
|
+
return true if pipeline.nil?
|
|
475
|
+
return false unless pipeline.is_a?(Array)
|
|
476
|
+
pipeline.all? { |stage| identity_preserving_stage?(stage) }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# @!visibility private
|
|
480
|
+
IDENTITY_KEYS = %w[_id objectId].freeze
|
|
481
|
+
|
|
482
|
+
# @!visibility private
|
|
483
|
+
def identity_preserving_stage?(stage)
|
|
484
|
+
return false unless stage.is_a?(Hash) && stage.size == 1
|
|
485
|
+
op, body = stage.first
|
|
486
|
+
case op.to_s
|
|
487
|
+
when "$match", "$sort", "$limit", "$skip", "$sample", "$unset"
|
|
488
|
+
true
|
|
489
|
+
when "$count"
|
|
490
|
+
!identity_key?(body)
|
|
491
|
+
when "$project"
|
|
492
|
+
body.is_a?(Hash) &&
|
|
493
|
+
body.all? { |k, v| !identity_key?(k) || [0, 1, true, false].include?(v) }
|
|
494
|
+
when "$addFields", "$set"
|
|
495
|
+
body.is_a?(Hash) && body.keys.none? { |k| identity_key?(k) }
|
|
496
|
+
when "$lookup", "$graphLookup"
|
|
497
|
+
body.is_a?(Hash) && !identity_key?(body["as"] || body[:as])
|
|
498
|
+
when "$unwind"
|
|
499
|
+
body.is_a?(String) ||
|
|
500
|
+
(body.is_a?(Hash) && !identity_key?(body["includeArrayIndex"] || body[:includeArrayIndex]))
|
|
501
|
+
when "$geoNear"
|
|
502
|
+
body.is_a?(Hash) &&
|
|
503
|
+
%w[distanceField includeLocs].none? { |k| identity_key?(body[k] || body[k.to_sym]) }
|
|
504
|
+
else
|
|
505
|
+
false
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
private_class_method :identity_preserving_stage?
|
|
510
|
+
|
|
511
|
+
# @!visibility private
|
|
512
|
+
def identity_key?(key)
|
|
513
|
+
return false if key.nil?
|
|
514
|
+
IDENTITY_KEYS.include?(key.to_s.split(".").first.to_s)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private_class_method :identity_key?
|
|
518
|
+
|
|
519
|
+
# Walk one stage for `$<field>` value references, judging each against
|
|
520
|
+
# the protected set of the class whose documents the expression sees.
|
|
521
|
+
#
|
|
522
|
+
# Most stages see the documents of `class_name`. A join is different:
|
|
523
|
+
# its `let` / `startWith` expressions read the OUTER document, while its
|
|
524
|
+
# sub-pipeline (and a `$graphLookup` search) reads the JOINED class, so
|
|
525
|
+
# those are checked against the joined class's set. The outer set is
|
|
526
|
+
# kept as well (the union of both): a joined class whose CLP cannot be
|
|
527
|
+
# resolved reports nothing protected, and the conservative reading of a
|
|
528
|
+
# name both classes share is to refuse it. A `$facet` branch sees the
|
|
529
|
+
# same documents as the stage it sits in.
|
|
530
|
+
#
|
|
531
|
+
# A `$graphLookup` into a class with protected fields for the scope is
|
|
532
|
+
# refused outright: it returns whole joined documents and offers no
|
|
533
|
+
# sub-pipeline in which the SDK could strip them.
|
|
534
|
+
#
|
|
535
|
+
# @!visibility private
|
|
536
|
+
def walk_stage_refs!(stage, class_name, sets, path, extra = nil)
|
|
537
|
+
return unless stage.is_a?(Hash)
|
|
538
|
+
own = sets[class_name]
|
|
539
|
+
own = own | extra if extra && !extra.empty?
|
|
540
|
+
stage.each do |op, body|
|
|
541
|
+
op_s = op.to_s
|
|
542
|
+
case op_s
|
|
543
|
+
when "$lookup", "$unionWith", "$graphLookup"
|
|
544
|
+
walk_join_refs!(op_s, body, class_name, own, sets, "#{path}.#{op_s}")
|
|
545
|
+
when "$facet"
|
|
546
|
+
next unless body.is_a?(Hash)
|
|
547
|
+
body.each do |name, branch|
|
|
548
|
+
Array(branch).each_with_index do |sub, i|
|
|
549
|
+
walk_stage_refs!(sub, class_name, sets, "#{path}.$facet.#{name}[#{i}]", extra)
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
when "$replaceRoot", "$replaceWith"
|
|
553
|
+
next if own.nil? || own.empty?
|
|
554
|
+
new_root = op_s == "$replaceRoot" && body.is_a?(Hash) ? (body["newRoot"] || body[:newRoot]) : body
|
|
555
|
+
if rehome_root?(new_root)
|
|
556
|
+
# `$replaceRoot: { newRoot: "$$ROOT" }`, `$replaceWith: "$$ROOT"`,
|
|
557
|
+
# or a `$mergeObjects` of `$$ROOT` in that position keeps every
|
|
558
|
+
# field at the TOP level of the output, where the strip removes
|
|
559
|
+
# protected ones. Only the other operands need checking.
|
|
560
|
+
walk_rehome_operands!(new_root, own, class_name, "#{path}.#{op_s}")
|
|
561
|
+
else
|
|
562
|
+
walk_for_protected_ref!(body, own, class_name, "#{path}.#{op_s}")
|
|
563
|
+
end
|
|
564
|
+
else
|
|
565
|
+
next if own.nil? || own.empty?
|
|
566
|
+
walk_for_protected_ref!(body, own, class_name, "#{path}.#{op_s}")
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
nil
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
private_class_method :walk_stage_refs!
|
|
573
|
+
|
|
574
|
+
# @!visibility private
|
|
575
|
+
def walk_join_refs!(op, body, class_name, own, sets, path)
|
|
576
|
+
spec = body
|
|
577
|
+
spec = { "coll" => body } if op == "$unionWith" && body.is_a?(String)
|
|
578
|
+
return unless spec.is_a?(Hash)
|
|
579
|
+
foreign = (spec["from"] || spec[:from] || spec["coll"] || spec[:coll]).to_s
|
|
580
|
+
foreign_set = foreign.empty? ? Set.new : sets[foreign]
|
|
581
|
+
|
|
582
|
+
outer_keys = op == "$graphLookup" ? %w[startWith] : %w[let]
|
|
583
|
+
outer_keys.each do |key|
|
|
584
|
+
value = spec[key] || spec[key.to_sym]
|
|
585
|
+
next if value.nil? || own.nil? || own.empty?
|
|
586
|
+
walk_for_protected_ref!(value, own, class_name, "#{path}.#{key}")
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
return if foreign.empty?
|
|
590
|
+
|
|
591
|
+
if op == "$graphLookup"
|
|
592
|
+
unless foreign_set.empty?
|
|
593
|
+
raise Parse::CLPScope::Denied.new(
|
|
594
|
+
foreign, :find,
|
|
595
|
+
"#{path} reads class #{foreign}, which has protected fields for the " \
|
|
596
|
+
"current scope. $graphLookup returns whole joined documents with no " \
|
|
597
|
+
"sub-pipeline to strip them in; use $lookup instead.",
|
|
598
|
+
)
|
|
599
|
+
end
|
|
600
|
+
restrict = spec["restrictSearchWithMatch"] || spec[:restrictSearchWithMatch]
|
|
601
|
+
walk_for_protected_ref!(restrict, foreign_set | own, foreign, "#{path}.restrictSearchWithMatch") if restrict
|
|
602
|
+
return
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
sub = spec["pipeline"] || spec[:pipeline]
|
|
606
|
+
return unless sub.is_a?(Array)
|
|
607
|
+
sub.each_with_index do |sub_stage, i|
|
|
608
|
+
walk_stage_refs!(sub_stage, foreign, sets, "#{path}.pipeline[#{i}]", own)
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
private_class_method :walk_join_refs!
|
|
613
|
+
|
|
614
|
+
# Refuse a stage that filters, sorts, or joins on a protected field by
|
|
615
|
+
# naming it as a KEY rather than through a `$<field>` value reference.
|
|
616
|
+
#
|
|
617
|
+
# Parse Server refuses a REST find whose `where` names a protected field
|
|
618
|
+
# (at any depth of `$and` / `$or` / `$nor`, dotted or not) with error 119,
|
|
619
|
+
# because which rows match, and in what order, reveals the field's
|
|
620
|
+
# value even though the output strips it. The mongo-direct path must do
|
|
621
|
+
# the same. Checked here:
|
|
622
|
+
#
|
|
623
|
+
# * `$match` predicate keys, including under `$and` / `$or` / `$nor` /
|
|
624
|
+
# `$not`, dotted paths (`secret.sub`), and the `_p_<field>` storage
|
|
625
|
+
# form of a pointer;
|
|
626
|
+
# * `$sort` keys (a `$meta` sort is not a field);
|
|
627
|
+
# * `$geoNear.query` keys and `$geoNear.key`;
|
|
628
|
+
# * `$facet` branches, against the same class;
|
|
629
|
+
# * `$lookup` `localField` (this class) and `foreignField` plus the
|
|
630
|
+
# sub-pipeline (the joined class, with ITS protected set), and the
|
|
631
|
+
# same for `$unionWith` and `$graphLookup`, so an `$inQuery` style
|
|
632
|
+
# join cannot probe the joined class's protected fields either.
|
|
633
|
+
#
|
|
634
|
+
# @!visibility private
|
|
635
|
+
def refuse_protected_stage_keys!(stage, class_name, sets, path)
|
|
636
|
+
return unless stage.is_a?(Hash)
|
|
637
|
+
paths = Parse::AtlasSearch::ProtectedPaths
|
|
638
|
+
own = sets[class_name]
|
|
639
|
+
stage.each do |op, body|
|
|
640
|
+
case op.to_s
|
|
641
|
+
when "$match"
|
|
642
|
+
paths.each_filter_reference(body) do |ref|
|
|
643
|
+
raise_protected_key!(class_name, ref, "#{path}.$match") if paths.touches?(ref, own)
|
|
644
|
+
end
|
|
645
|
+
when "$sort"
|
|
646
|
+
next unless body.is_a?(Hash)
|
|
647
|
+
body.each do |key, dir|
|
|
648
|
+
next if dir.is_a?(Hash) && (dir.key?("$meta") || dir.key?(:$meta))
|
|
649
|
+
raise_protected_key!(class_name, key, "#{path}.$sort") if paths.touches?(key.to_s, own)
|
|
650
|
+
end
|
|
651
|
+
when "$geoNear"
|
|
652
|
+
next unless body.is_a?(Hash)
|
|
653
|
+
query = body["query"] || body[:query]
|
|
654
|
+
paths.each_filter_reference(query) do |ref|
|
|
655
|
+
raise_protected_key!(class_name, ref, "#{path}.$geoNear.query") if paths.touches?(ref, own)
|
|
656
|
+
end
|
|
657
|
+
key = body["key"] || body[:key]
|
|
658
|
+
raise_protected_key!(class_name, key, "#{path}.$geoNear.key") if key && paths.touches?(key.to_s, own)
|
|
659
|
+
when "$facet"
|
|
660
|
+
next unless body.is_a?(Hash)
|
|
661
|
+
body.each do |name, branch|
|
|
662
|
+
Array(branch).each_with_index do |sub, i|
|
|
663
|
+
refuse_protected_stage_keys!(sub, class_name, sets, "#{path}.$facet.#{name}[#{i}]")
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
when "$lookup", "$graphLookup", "$unionWith"
|
|
667
|
+
refuse_protected_join_keys!(op.to_s, body, class_name, sets, path)
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
nil
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
private_class_method :refuse_protected_stage_keys!
|
|
674
|
+
|
|
675
|
+
# @!visibility private
|
|
676
|
+
def refuse_protected_join_keys!(op, body, class_name, sets, path)
|
|
677
|
+
paths = Parse::AtlasSearch::ProtectedPaths
|
|
678
|
+
spec = body
|
|
679
|
+
spec = { "coll" => body } if op == "$unionWith" && body.is_a?(String)
|
|
680
|
+
return unless spec.is_a?(Hash)
|
|
681
|
+
foreign = (spec["from"] || spec[:from] || spec["coll"] || spec[:coll]).to_s
|
|
682
|
+
foreign_set = foreign.empty? ? Set.new : sets[foreign]
|
|
683
|
+
own = sets[class_name]
|
|
684
|
+
|
|
685
|
+
local_keys = case op
|
|
686
|
+
when "$lookup" then [spec["localField"] || spec[:localField]]
|
|
687
|
+
else []
|
|
688
|
+
end
|
|
689
|
+
local_keys.compact.each do |k|
|
|
690
|
+
raise_protected_key!(class_name, k, "#{path}.#{op}.localField") if paths.touches?(k.to_s, own)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
foreign_keys = case op
|
|
694
|
+
when "$lookup" then [spec["foreignField"] || spec[:foreignField]]
|
|
695
|
+
when "$graphLookup"
|
|
696
|
+
[spec["connectToField"] || spec[:connectToField],
|
|
697
|
+
spec["connectFromField"] || spec[:connectFromField]]
|
|
698
|
+
else []
|
|
699
|
+
end
|
|
700
|
+
foreign_keys.compact.each do |k|
|
|
701
|
+
raise_protected_key!(foreign, k, "#{path}.#{op}") if paths.touches?(k.to_s, foreign_set)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
restrict = spec["restrictSearchWithMatch"] || spec[:restrictSearchWithMatch]
|
|
705
|
+
paths.each_filter_reference(restrict) do |ref|
|
|
706
|
+
raise_protected_key!(foreign, ref, "#{path}.#{op}.restrictSearchWithMatch") if paths.touches?(ref, foreign_set)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
sub = spec["pipeline"] || spec[:pipeline]
|
|
710
|
+
return if foreign.empty? || !sub.is_a?(Array)
|
|
711
|
+
sub.each_with_index do |sub_stage, i|
|
|
712
|
+
refuse_protected_stage_keys!(sub_stage, foreign, sets, "#{path}.#{op}.pipeline[#{i}]")
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
private_class_method :refuse_protected_join_keys!
|
|
717
|
+
|
|
718
|
+
# @!visibility private
|
|
719
|
+
def raise_protected_key!(class_name, ref, path)
|
|
720
|
+
field = Parse::AtlasSearch::ProtectedPaths.root_field(ref)
|
|
721
|
+
raise Parse::CLPScope::Denied.new(
|
|
722
|
+
class_name, :find,
|
|
723
|
+
"This user is not allowed to query #{field} on class #{class_name}: " \
|
|
724
|
+
"#{path} filters, sorts, or joins on protected field '#{ref}', which " \
|
|
725
|
+
"would reveal its value through which rows match or how they order.",
|
|
726
|
+
)
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
private_class_method :raise_protected_key!
|
|
730
|
+
|
|
451
731
|
# @!visibility private
|
|
452
732
|
def walk_for_protected_ref!(node, protected_set, class_name, path)
|
|
733
|
+
return if protected_set.nil? || protected_set.empty?
|
|
734
|
+
paths = Parse::AtlasSearch::ProtectedPaths
|
|
453
735
|
case node
|
|
454
736
|
when String
|
|
455
|
-
# Field-reference syntax is `$<path>` — variable refs start
|
|
456
|
-
# with `$$` (e.g. `$$ROOT`, `$$<userVarFromLet>`) and aren't
|
|
457
|
-
# field references; skip them.
|
|
458
737
|
return if node.empty?
|
|
459
738
|
return unless node.start_with?("$")
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
739
|
+
if node.start_with?("$$")
|
|
740
|
+
# `$$ROOT` / `$$CURRENT` name the whole document. Copying it into
|
|
741
|
+
# a value (`{ snapshot: "$$ROOT" }`, `$mergeObjects`, `$push`, a
|
|
742
|
+
# `let` binding) nests every protected field one level down,
|
|
743
|
+
# where the top-level strip does not reach. A dotted form is a
|
|
744
|
+
# field path like `$<field>`. Other `$$` names are variables.
|
|
745
|
+
var, rest = node[2..].split(".", 2)
|
|
746
|
+
return unless %w[ROOT CURRENT].include?(var)
|
|
747
|
+
if rest.nil? || rest.empty?
|
|
748
|
+
raise Parse::CLPScope::Denied.new(
|
|
749
|
+
class_name, :read,
|
|
750
|
+
"Pipeline at #{path} copies the whole document with '#{node}'. " \
|
|
751
|
+
"Class #{class_name} has protected fields for the current scope, " \
|
|
752
|
+
"and a nested copy would carry them past the top-level strip.",
|
|
753
|
+
)
|
|
754
|
+
end
|
|
755
|
+
raise_protected_ref!(class_name, path, node, paths.root_field(rest)) if paths.touches?(rest, protected_set)
|
|
756
|
+
return
|
|
757
|
+
end
|
|
758
|
+
ref = node[1..]
|
|
466
759
|
# `$_id` is the canonical primary-key reference; never on the
|
|
467
760
|
# protected list and would otherwise short-circuit common
|
|
468
761
|
# aggregations like `{$group: {_id: "$_id"}}`.
|
|
469
|
-
return if
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
"Pipeline at #{path} references protectedField '#{head}' " \
|
|
474
|
-
"via field-reference '#{node}'. ProtectedFields cannot be " \
|
|
475
|
-
"laundered through a $project/$addFields/$group rename — " \
|
|
476
|
-
"the post-fetch strip walks by name and would miss the " \
|
|
477
|
-
"renamed value, leaking the protected column.",
|
|
478
|
-
)
|
|
479
|
-
end
|
|
762
|
+
return if ref.split(".").first == "_id"
|
|
763
|
+
# Dotted (`$ssn.area`) and storage (`$_p_owner`) forms are compared
|
|
764
|
+
# by their top-level Parse field, as Parse Server protects columns.
|
|
765
|
+
raise_protected_ref!(class_name, path, node, paths.root_field(ref)) if paths.touches?(ref, protected_set)
|
|
480
766
|
when Array
|
|
481
767
|
node.each_with_index do |child, i|
|
|
482
768
|
walk_for_protected_ref!(child, protected_set, class_name, "#{path}[#{i}]")
|
|
483
769
|
end
|
|
484
770
|
when Hash
|
|
485
771
|
node.each do |key, value|
|
|
772
|
+
# `$getField` reads a field by NAME, so `{ $getField: "ssn" }`
|
|
773
|
+
# reaches a protected column without a `$ssn` reference. It reads
|
|
774
|
+
# the current document unless `input` names something else.
|
|
775
|
+
# `$setField` / `$unsetField` take a name the same way and are held
|
|
776
|
+
# to the same literal-name rule.
|
|
777
|
+
check_field_name_operator!(key.to_s, value, protected_set, class_name, "#{path}.#{key}") if
|
|
778
|
+
FIELD_NAME_OPERATORS.include?(key.to_s)
|
|
486
779
|
# Recurse into every value. Hash keys are field NAMES in
|
|
487
|
-
# most contexts, not references
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
# field-reference string.
|
|
780
|
+
# most contexts, not references; the post-fetch redact
|
|
781
|
+
# would still strip a key literally named "ssn". The bypass
|
|
782
|
+
# is the VALUE-side field-reference string.
|
|
491
783
|
walk_for_protected_ref!(value, protected_set, class_name, "#{path}.#{key}")
|
|
492
784
|
end
|
|
493
785
|
end
|
|
@@ -496,6 +788,103 @@ module Parse
|
|
|
496
788
|
|
|
497
789
|
private_class_method :walk_for_protected_ref!
|
|
498
790
|
|
|
791
|
+
# @!visibility private
|
|
792
|
+
WHOLE_DOCUMENT_VARS = %w[$$ROOT $$CURRENT].freeze
|
|
793
|
+
|
|
794
|
+
# Whether `value` is the whole current document, or a `$mergeObjects`
|
|
795
|
+
# whose operands include it, in a re-home position.
|
|
796
|
+
# @!visibility private
|
|
797
|
+
def rehome_root?(value)
|
|
798
|
+
return true if WHOLE_DOCUMENT_VARS.include?(value)
|
|
799
|
+
return false unless value.is_a?(Hash) && value.size == 1
|
|
800
|
+
operands = value["$mergeObjects"] || value[:$mergeObjects]
|
|
801
|
+
operands.is_a?(Array) && operands.any? { |o| WHOLE_DOCUMENT_VARS.include?(o) }
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
private_class_method :rehome_root?
|
|
805
|
+
|
|
806
|
+
# @!visibility private
|
|
807
|
+
def walk_rehome_operands!(value, protected_set, class_name, path)
|
|
808
|
+
return unless value.is_a?(Hash)
|
|
809
|
+
operands = value["$mergeObjects"] || value[:$mergeObjects]
|
|
810
|
+
Array(operands).each_with_index do |operand, i|
|
|
811
|
+
next if WHOLE_DOCUMENT_VARS.include?(operand)
|
|
812
|
+
walk_for_protected_ref!(operand, protected_set, class_name, "#{path}.$mergeObjects[#{i}]")
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
private_class_method :walk_rehome_operands!
|
|
817
|
+
|
|
818
|
+
# Expression operators that take a field NAME rather than a `$field`
|
|
819
|
+
# reference.
|
|
820
|
+
# @!visibility private
|
|
821
|
+
FIELD_NAME_OPERATORS = %w[$getField $setField $unsetField].freeze
|
|
822
|
+
|
|
823
|
+
# Check the field-name argument of `$getField`, `$setField`, or
|
|
824
|
+
# `$unsetField`. The name must be fixed when the pipeline is written: a
|
|
825
|
+
# plain string that does not start with `$`, or `{ $literal: "<name>" }`.
|
|
826
|
+
# Anything else is computed at run time. `{ $getField: "$selector" }`
|
|
827
|
+
# takes the name from the document's own `selector` field, so a caller
|
|
828
|
+
# who first sets `selector` to a protected field's name reads that field
|
|
829
|
+
# under another name. A fixed `$getField` name that reads a protected
|
|
830
|
+
# field of the current document is refused as well.
|
|
831
|
+
# @!visibility private
|
|
832
|
+
def check_field_name_operator!(op, spec, protected_set, class_name, path)
|
|
833
|
+
field, input = if spec.is_a?(Hash)
|
|
834
|
+
[spec.key?("field") ? spec["field"] : spec[:field], spec.key?("input") ? spec["input"] : spec[:input]]
|
|
835
|
+
else
|
|
836
|
+
[spec, nil]
|
|
837
|
+
end
|
|
838
|
+
name = literal_field_name(field)
|
|
839
|
+
if name.nil?
|
|
840
|
+
raise Parse::CLPScope::Denied.new(
|
|
841
|
+
class_name, :read,
|
|
842
|
+
"Pipeline at #{path} uses #{op} with a field name computed at run time " \
|
|
843
|
+
"(#{field.inspect}). Class #{class_name} has protected fields for the current " \
|
|
844
|
+
"scope, so the field name must be a plain string that does not start with " \
|
|
845
|
+
"'$', or a $literal string.",
|
|
846
|
+
)
|
|
847
|
+
end
|
|
848
|
+
return unless op == "$getField"
|
|
849
|
+
return unless input.nil? || WHOLE_DOCUMENT_VARS.include?(input)
|
|
850
|
+
return unless protected_set.include?(name) || protected_set.include?(name.delete_prefix("_p_"))
|
|
851
|
+
raise_protected_ref!(class_name, path, "$getField(#{name})", name.delete_prefix("_p_"))
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
private_class_method :check_field_name_operator!
|
|
855
|
+
|
|
856
|
+
# The fixed field name a `$getField` / `$setField` / `$unsetField`
|
|
857
|
+
# argument names, or nil when the name is computed at run time. A bare
|
|
858
|
+
# string starting with `$` is an expression (a field path or variable),
|
|
859
|
+
# not a name.
|
|
860
|
+
# @!visibility private
|
|
861
|
+
def literal_field_name(field)
|
|
862
|
+
if field.is_a?(String) || field.is_a?(Symbol)
|
|
863
|
+
name = field.to_s
|
|
864
|
+
return name.start_with?("$") ? nil : name
|
|
865
|
+
end
|
|
866
|
+
return nil unless field.is_a?(Hash) && field.size == 1
|
|
867
|
+
literal = field.key?("$literal") ? field["$literal"] : field[:$literal]
|
|
868
|
+
literal.is_a?(String) || literal.is_a?(Symbol) ? literal.to_s : nil
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
private_class_method :literal_field_name
|
|
872
|
+
|
|
873
|
+
# @!visibility private
|
|
874
|
+
def raise_protected_ref!(class_name, path, node, field)
|
|
875
|
+
raise Parse::CLPScope::Denied.new(
|
|
876
|
+
class_name, :read,
|
|
877
|
+
"Pipeline at #{path} references protectedField '#{field}' " \
|
|
878
|
+
"via field-reference '#{node}'. ProtectedFields cannot be " \
|
|
879
|
+
"laundered through a $project/$addFields/$group rename: " \
|
|
880
|
+
"the post-fetch strip walks by name and would miss the " \
|
|
881
|
+
"renamed value, leaking the protected column.",
|
|
882
|
+
)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
private_class_method :raise_protected_ref!
|
|
886
|
+
|
|
887
|
+
|
|
499
888
|
# @!visibility private
|
|
500
889
|
def validate_stage!(stage, idx)
|
|
501
890
|
unless stage.is_a?(Hash)
|
|
@@ -67,6 +67,7 @@ module Parse
|
|
|
67
67
|
|
|
68
68
|
# Creates a new constraint given an operation and value.
|
|
69
69
|
def create(operation, value)
|
|
70
|
+
operation = translate_foreign_operation(operation)
|
|
70
71
|
#default to a generic equality constraint if not passed an operation
|
|
71
72
|
unless operation.is_a?(Parse::Operation) && operation.valid?
|
|
72
73
|
return self.new(operation, value)
|
|
@@ -74,6 +75,35 @@ module Parse
|
|
|
74
75
|
operation.constraint(value)
|
|
75
76
|
end
|
|
76
77
|
|
|
78
|
+
# MongoDB operator strings that a Mongoid query key may carry, mapped to
|
|
79
|
+
# the equivalent Parse operator.
|
|
80
|
+
# @!visibility private
|
|
81
|
+
MONGOID_OPERATOR_MAP = {
|
|
82
|
+
"$gt" => :gt, "$gte" => :gte, "$lt" => :lt, "$lte" => :lte,
|
|
83
|
+
"$ne" => :ne, "$in" => :in, "$nin" => :nin, "$all" => :all,
|
|
84
|
+
"$exists" => :exists, "$elemMatch" => :elem_match,
|
|
85
|
+
"$near" => :near, "$size" => :size,
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# When Mongoid is loaded before Parse, `:plays.gt` returns Mongoid's
|
|
89
|
+
# own query key, because Parse does not replace another library's
|
|
90
|
+
# `Symbol` methods. Translate such a key into the equivalent
|
|
91
|
+
# {Parse::Operation} so `where(:plays.gt => 10)` keeps working.
|
|
92
|
+
# Anything else is returned unchanged.
|
|
93
|
+
# @!visibility private
|
|
94
|
+
def translate_foreign_operation(operation)
|
|
95
|
+
return operation if operation.is_a?(Parse::Operation)
|
|
96
|
+
return operation unless defined?(::Mongoid::Criteria::Queryable::Key) &&
|
|
97
|
+
operation.is_a?(::Mongoid::Criteria::Queryable::Key)
|
|
98
|
+
op = MONGOID_OPERATOR_MAP[operation.operator.to_s]
|
|
99
|
+
if op.nil?
|
|
100
|
+
raise ArgumentError, "Unsupported Mongoid query key #{operation.operator.inspect} " \
|
|
101
|
+
"for field #{operation.name.inspect}. Use " \
|
|
102
|
+
"Parse::Operation.new(:field, :operator) instead."
|
|
103
|
+
end
|
|
104
|
+
Parse::Operation.new(operation.name, op)
|
|
105
|
+
end
|
|
106
|
+
|
|
77
107
|
# Set the keyword for this Constraint. Subclasses should use this method.
|
|
78
108
|
# @param keyword [Symbol]
|
|
79
109
|
# @return (see key)
|