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
@@ -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
- return if protected_set.nil? || protected_set.empty?
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
- walk_for_protected_ref!(stage, protected_set, collection_name, "pipeline[#{idx}]")
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
- return if node.start_with?("$$")
461
- # Path may be dotted (`$ssn.area`). The protectedFields list
462
- # is a set of top-level column names per Parse Server's CLP
463
- # schema, so we compare against the first segment.
464
- head = node.sub(/\A\$/, "").split(".").first
465
- return if head.nil? || head.empty?
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 head == "_id"
470
- if protected_set.include?(head)
471
- raise Parse::CLPScope::Denied.new(
472
- class_name, :read,
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 — we don't need to gate them
488
- # because the post-fetch redact would still strip a key
489
- # literally named "ssn". The bypass is the VALUE-side
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)