diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index b5eb3da8ef..c27a3e4423 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -5,6 +5,9 @@ on: [push, pull_request] jobs: build: + # disable rubocop on legacy branches + if: false + runs-on: ubuntu-latest env: CI: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d3d10fc77..1220dddc12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,10 @@ name: Run Mongoid Tests - pull_request jobs: build: + # skip github workflows on this branch; we'll rely solely on + # evergreen for CI runs + if: false + name: "${{matrix.ruby}} db:${{matrix.mongodb}} rails:${{matrix.rails}} fle:${{matrix.fle}} ${{matrix.topology}}" env: diff --git a/.mod/drivers-evergreen-tools b/.mod/drivers-evergreen-tools index 14cc285a38..890a93bdb8 160000 --- a/.mod/drivers-evergreen-tools +++ b/.mod/drivers-evergreen-tools @@ -1 +1 @@ -Subproject commit 14cc285a383f79eb2d0aec95e611192da9aa9dad +Subproject commit 890a93bdb88c595907813a9bfd0a56156d9f9f5b diff --git a/gemfiles/standard.rb b/gemfiles/standard.rb index fab9ee0ffa..acdde48a7e 100644 --- a/gemfiles/standard.rb +++ b/gemfiles/standard.rb @@ -50,6 +50,6 @@ def standard_dependencies end if ENV['FLE'] == 'helper' - gem 'libmongocrypt-helper', '~> 1.14.0' + gem 'libmongocrypt-helper', '~> 1.20.0' end end diff --git a/lib/config/locales/en.yml b/lib/config/locales/en.yml index a5c56921c7..83edf665d3 100644 --- a/lib/config/locales/en.yml +++ b/lib/config/locales/en.yml @@ -140,6 +140,32 @@ en: A collation option is only supported if the query is executed on a MongoDB server with version >= 3.4." resolution: "Remove the collation option from the query." + in_memory_regexp_timeout: + message: "Evaluating this query in memory exceeded the %{limit} second + limit that applies to conditions containing a regular expression." + summary: "Mongoid evaluates some conditions in the calling thread + rather than sending them to the server: queries against an embedded + association, removing documents from an association by condition, + and Document#_matches?. A regular expression in such a condition + runs locally. Mongoid limits the total time one in-memory + evaluation may spend matching, because both the cost of a single + match and the number of matches performed grow with the condition, + and a pattern built from end-user input can be made to consume an + unbounded amount of CPU. The limit is cumulative over the whole + evaluation rather than per match. Where the Ruby in use offers no + per-Regexp timeout the limit is enforced as wall clock over the + whole in-memory evaluation, so there it can be exceeded by a large + scan even when the pattern itself is cheap." + resolution: "Check whether the pattern in this query comes from user + input; if it does, validate it before querying. If the query is + legitimate and simply large, raise + Mongoid::Config.in_memory_regexp_time_limit, or set it to nil to + remove the limit entirely. The limit in force is the smaller of + that setting and any global Regexp.timeout the application has set, + so check both. On JRuby, and on MRI before 3.2, there is no + per-Regexp timeout, so what the limit bounds there is the elapsed + time of the whole in-memory evaluation rather than the time spent + matching." invalid_around_callback: message: "An around callback must contain a yield in its definition." summary: "The block needs to be yielded to for around callbacks to function as intended." @@ -515,6 +541,25 @@ en: environment, Mongoid cannot load its configuration." resolution: "Please ensure an environment is set in one of the listed locations. The environment must be explicitly set." + no_encryption_schema: + message: "The encryption schema of client %{client} does not cover + %{namespace}." + summary: "%{klass} declares encrypted fields, but the namespace it + resolved to, %{namespace}, is not part of the automatic encryption + schema of client %{client}. The encryption schema is built once, + when the client is created, and is keyed by namespace, so a + database name that is only known later - a callable :database + option, Model.with(database:), Mongoid.override_database - is not + in it. Routing the model to a client that has no + auto_encryption_options has the same effect. Mongoid refuses the + operation because the driver would store the declared fields in + plaintext without reporting an error." + resolution: "Give the model a namespace that is known when the + client is created, and a client configured with + auto_encryption_options. If the model has to live in a database + chosen at runtime, configure one client per database, each with + its own auto_encryption_options, and select between them with + Model.with(client:) instead of switching the database." no_map_reduce_output: message: "No output location was specified for the map/reduce operation." diff --git a/lib/mongoid/association/depending.rb b/lib/mongoid/association/depending.rb index 49e1b21098..be10ce383a 100644 --- a/lib/mongoid/association/depending.rb +++ b/lib/mongoid/association/depending.rb @@ -12,11 +12,15 @@ module Depending included do class_attribute :dependents + # Note the leading underscore: without it, ActiveSupport's + # class_attribute would generate a helper method for this attribute + # that collides with one it generates for :dependents. + # # @api private - class_attribute :dependents_owner + class_attribute :_dependents_owner self.dependents = [] - self.dependents_owner = self + self._dependents_owner = self end class_methods do @@ -57,9 +61,9 @@ def _all_dependents def self.define_dependency!(association) validate!(association) association.inverse_class.tap do |klass| - if klass.dependents_owner != klass + if klass._dependents_owner != klass klass.dependents = [] - klass.dependents_owner = klass + klass._dependents_owner = klass end if association.dependent && !klass.dependents.include?(association) diff --git a/lib/mongoid/association/nested/many.rb b/lib/mongoid/association/nested/many.rb index 640b002cbd..f9406bdeb7 100644 --- a/lib/mongoid/association/nested/many.rb +++ b/lib/mongoid/association/nested/many.rb @@ -174,7 +174,7 @@ def update_document(doc, attrs) # @param [ Hash ] attrs The single document attributes to process. def update_nested_relation(parent, id, attrs) first = existing.first - converted = first ? convert_id(first.class, id) : id + converted = convert_id(first ? first.class : association.klass, id) if existing.where(_id: converted).exists? # document exists in association @@ -186,19 +186,48 @@ def update_nested_relation(parent, id, attrs) end elsif association.embedded? raise Errors::DocumentNotFound.new(association.klass, id) - elsif association.is_a?(Association::Referenced::HasAndBelongsToMany) || Mongoid.allow_reparenting_via_nested_attributes? - Mongoid::Warnings.warn_reparenting_via_nested_attributes if Mongoid.allow_reparenting_via_nested_attributes? + elsif destroyable?(attrs) + # A destroy of a document that is not in the association is + # ignored, rather than reaching for it outside the association. + nil + elsif Mongoid.allow_reparenting_via_nested_attributes? + Mongoid::Warnings.warn_reparenting_via_nested_attributes # push existing document to association doc = association.klass.unscoped.find(converted) + # find returns nil instead of raising when + # Mongoid.raise_not_found_error is false; a missing document + # must still be an error here, consistent with the other + # not-found branches. + raise Errors::DocumentNotFound.new(association.klass, id) if doc.nil? + update_document(doc, attrs) - existing.push(doc) unless destroyable?(attrs) + existing.push(doc) else - raise Errors::DocumentNotFound.new(association.klass, { _id: id, association.foreign_key => parent.id }) + raise Errors::DocumentNotFound.new(association.klass, not_found_params(parent, id)) end parent.children_may_have_changed! end + + # The params to report when an id in the nested attributes could not + # be resolved within the association. + # + # @api private + # + # @param [ Document ] parent The parent document. + # @param [ String | BSON::ObjectId ] id of the related document. + # + # @return [ Hash | Object ] The params for the not found error. + def not_found_params(parent, id) + if association.is_a?(Association::Referenced::HasAndBelongsToMany) + # A many-to-many association has no foreign key on the child, so + # the id is only ever resolved within the association itself. + id + else + { _id: id, association.foreign_key => parent.id } + end + end end end end diff --git a/lib/mongoid/association/nested/nested_buildable.rb b/lib/mongoid/association/nested/nested_buildable.rb index 81d6b292b6..79327eeda5 100644 --- a/lib/mongoid/association/nested/nested_buildable.rb +++ b/lib/mongoid/association/nested/nested_buildable.rb @@ -58,12 +58,26 @@ def update_only? # @example Convert the id. # builder.convert_id(Person, "4d371b444835d98b8b000010") # + # Ids arriving from a form are always scalars. A Hash or an Array + # here means the parameters were crafted, and letting one through + # would turn the id into a query operator, so they are rejected. + # # @param [ Class ] klass The class we're trying to convert for. # @param [ String ] id The id, usually coming from the form. # # @return [ BSON::ObjectId | String | Object ] The converted id. + # + # @raise [ Errors::DocumentNotFound ] if the id is not a scalar, or + # cannot be converted to the type the class uses for its ids. + # The BSON::Error rescue is defensive: a value that reaches + # BSON::ObjectId.mongoize and raises there must not surface as an + # unhandled BSON error in the caller. def convert_id(klass, id) + raise Errors::DocumentNotFound.new(klass, id) if id.is_a?(::Hash) || id.is_a?(::Array) + klass.using_object_ids? ? BSON::ObjectId.mongoize(id) : id + rescue BSON::Error + raise Errors::DocumentNotFound.new(klass, id) end private diff --git a/lib/mongoid/association/referenced/has_many/proxy.rb b/lib/mongoid/association/referenced/has_many/proxy.rb index eb2dca117f..bc8b721bd5 100644 --- a/lib/mongoid/association/referenced/has_many/proxy.rb +++ b/lib/mongoid/association/referenced/has_many/proxy.rb @@ -534,12 +534,111 @@ def persistable? # @return [ Integer ] The number of documents deleted. def remove_all(conditions = nil, method = :delete_all) selector = conditions || {} - removed = klass.send(method, selector.merge!(criteria.selector)) + selector.merge!(criteria.selector) + + # Scanning before the delete means scanning only what is already in + # memory, so it is only done where there is a pattern whose cost has + # to be bounded. Everywhere else the delete comes first and the scan + # sees the survivors, which is what this association has always + # done. + # + # The limit is read once and carried into whichever branch is taken. + # Letting the branch below ask again would read it a second time, + # across a server round trip, and a limit that had become positive + # in the meantime would put a deadline on the delete_if -- a query, + # and an unbind of every match -- which is the arrangement this + # branch exists to avoid. + limit = Matcher::RegexpBudget.limit_for(selector) + return remove_all_bounded(selector, method, limit) if limit + + removed = klass.send(method, selector) + + # No scope around this. The scan runs after the delete, so the + # association this loads comes back holding only what the delete did + # not match, and there is no pattern here to bound in any case. + # Opening a scope would save _matches? from deciding once per + # document -- 0.48us against 1.74us for the match itself -- but a + # scope with nothing to bound suppresses that decision for + # everything nested inside it, and loading the association runs + # find callbacks. A pattern in a selector one of those evaluates + # would go unguarded, which costs more than the saving is worth on a + # path that has just made two round trips. _target.delete_if do |doc| doc._matches?(selector).tap do |b| unbind_one(doc) if b end end + + removed + end + + # Deletes all related documents matching a selector that carries a + # regular expression, with the whole scan under one regexp budget. + # + # The scan runs before anything is deleted, so a budget that runs out + # leaves both the database and the association untouched rather than + # reporting a failure for a delete that has already happened. It also + # means the budget is closed by the time the association is mutated, + # so where the budget is enforced with Timeout there is no window for + # the exception to land in the middle of an unbind. + # + # @param [ Hash ] selector The selector to delete with. + # @param [ Symbol ] method The deletion method to call. + # @param [ Float ] limit The seconds the scan may spend, as read by + # the caller when it chose this path. + # + # @return [ Integer ] The number of documents deleted. + def remove_all_bounded(selector, method, limit) + # Only what the association already holds. Iterating it instead + # would load the whole association before the delete, and for a + # broad pattern that is every document the association has: /.*/ is + # both the cheapest pattern an attacker can supply and the one that + # matches everything, so bounding the cost of the matching would + # have been paid for with an unbounded amount of memory. Nothing is + # missed by not loading, because the server evaluates the same + # selector for the delete itself. + # + # It also means the scan runs no query, so no deadline of ours can + # land on one. Where the budget is enforced with Timeout, covering + # a query would report a slow network as a regexp timeout and could + # deliver the asynchronous exception inside the driver's socket + # read. + documents = _target.in_memory + + matching = Matcher::RegexpBudget.open_with(limit) do + documents.select { |doc| doc._matches?(selector) } + end + + removed = klass.send(method, selector) + + # The ids are already known to be in memory, so the loaded and added + # documents can be dropped directly. Enumerable#delete would look + # each one up by id and stop at the first hash that has it, which + # removes the wrong instance when both hashes hold one for the same + # id. Enumerable#delete_if drops it from both, as this does, but + # only after load_all!, which is the load this scan exists to avoid. + # + # Which documents get unbound has always depended on which ones + # happened to be in memory, and scanning only what is in memory + # keeps that. It matters for has_and_belongs_to_many, whose + # unbind_one pulls the id out of the foreign key array on _base and + # marks it dirty; the scan used to run after the delete, against an + # association that a cold proxy had just reloaded as holding only + # the survivors, so nothing matched and nothing was unbound. + # + # TODO: unbinding should not depend on what was in memory. Every + # matched document leaves the association, so every one of them + # should be unbound, which would also stop _base's foreign key + # array from keeping ids that no longer resolve. That is a + # behaviour change for has_and_belongs_to_many and belongs in a + # ticket of its own, with a release note; it should not ride along + # on this one. + matching.each do |doc| + unbind_one(doc) + _target._loaded.delete(doc._id) + _target._added.delete(doc._id) + end + removed end diff --git a/lib/mongoid/association/relatable.rb b/lib/mongoid/association/relatable.rb index fc485c4d94..ffa36491b7 100644 --- a/lib/mongoid/association/relatable.rb +++ b/lib/mongoid/association/relatable.rb @@ -172,6 +172,23 @@ def relation_class end alias :klass :relation_class + # The class of the association target, or nil when the named class is + # not defined. + # + # An association may name a class that never gets defined. The + # association is then unusable, but its owner still has to be. Callers + # that walk every association of every model, rather than following the + # one the application asked for, use this instead of relation_class. + # + # @return [ Class | nil ] The association objects' class. + # + # @api private + def try_relation_class + relation_class + rescue NameError + nil + end + # The class name of the object owning this association. # # @return [ String ] The owning objects' class name. diff --git a/lib/mongoid/collection_configurable.rb b/lib/mongoid/collection_configurable.rb index d5d45876a6..b1a122f66e 100644 --- a/lib/mongoid/collection_configurable.rb +++ b/lib/mongoid/collection_configurable.rb @@ -22,6 +22,14 @@ module ClassMethods # @raise [ Errors::CreateCollectionFailure ] If collection creation failed. # @raise [ Errors::DropCollectionFailure ] If an attempt to drop collection failed. def create_collection(force: false) + Threaded.with_collection_management do + perform_create_collection(force: force) + end + end + + private + + def perform_create_collection(force:) if collection_name.empty? # This is most probably an anonymous class, we ignore them. return diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index 7de48736b6..654a3341d3 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -110,20 +110,22 @@ module Config # to `:global_thread_pool`. option :global_executor_concurrency, default: nil - # When this flag is true, it will be possible to change the parent of a - # record in a "has_many" association by passing the child record's id in the - # nested attributes for another parent record. + # When this flag is true, it will be possible to add a record to a + # "has_many" or "has_and_belongs_to_many" association by passing that + # record's id in the nested attributes for another parent record, even + # when the record does not already belong to that association. For a + # "has_many" association this moves the record to the new parent. # - # When this flag is false, attempting to change the parent of a record in a - # "has-many" association via nested attributes will raise an error. + # When this flag is false, an id in nested attributes is only resolved + # within the association itself, and anything else raises an error. # - # The default is `true`. Note that allowing reparenting via nested attributes + # The default is `false`. Note that allowing reparenting via nested attributes # is a potential security risk, since it could allow a malicious user to move # records that they do not own to a parent record that they do own. # - # This option will default to `false` in Mongoid 9.1, and will be removed - # in Mongoid 10. - option :allow_reparenting_via_nested_attributes, default: true + # This option will be removed in Mongoid 10, and the only behavior will be + # as if this option were set to false. + option :allow_reparenting_via_nested_attributes, default: false # When this flag is true, any documents in associations with `autosave: true` # will be saved even if they have not been changed. When this flag is false, @@ -205,6 +207,51 @@ module Config # See https://jira.mongodb.org/browse/MONGOID-5785 for more details. option :allow_scopes_to_unset_default_scope, default: false + # The maximum number of seconds that evaluating a single query in memory + # may spend executing regular expressions. Queries against an embedded + # association are evaluated in the calling thread, so a pattern built from + # user input runs locally and can otherwise consume unbounded CPU. The + # limit is cumulative over the whole query, since cost grows with the + # number of documents and conditions as well as with the pattern. + # + # Set to nil to remove the limit. On Ruby 3.2 and later the remaining + # budget is compiled into the pattern, so the limit counts only the time + # spent matching. Earlier Rubies have no per-Regexp timeout, so the query + # is bounded with Timeout instead and the limit is wall clock over the + # whole in-memory evaluation. + # + # See https://jira.mongodb.org/browse/MONGOID-5981 for details. + option :in_memory_regexp_time_limit, default: 5.0 + + # When false (default), query operators are restricted when building a + # selector, so that user-supplied input reaching the query builder cannot + # make MongoDB execute arbitrary JavaScript. Two rules are enforced: + # + # - An operator at the top level of an expression must appear in + # +Criteria::Queryable::Selectable::ALLOWED_QUERY_OPERATORS+. + # - +$where+, +$function+, and +$accumulator+ are rejected at any depth, + # including inside +$expr+ and the logical operators. + # + # This applies to every query method that accepts an expression, including + # +where+, +find_by+, +and+, +or+, +nor+, +not+, +any_of+, and +none_of+. + # It also governs the string form of +where+, e.g. + # +where("this.name == 'admin'")+, which the server evaluates as +$where+. + # Applications relying on that form must set this option to true. + # + # The APIs that request JavaScript explicitly, +Criteria#for_js+ and + # +js_query+, are unaffected: there the developer has asked for it. + # + # Set to true to restore the unrestricted pass-through behavior. + # + # Note that this option is deliberately not tied to +load_defaults+: an + # application that has opted into older defaults still gets the guard, and + # must set this option explicitly to turn it off. + # + # See https://jira.mongodb.org/browse/MONGOID-5939, + # https://jira.mongodb.org/browse/MONGOID-5993, + # https://jira.mongodb.org/browse/MONGOID-5994 for details. + option :allow_unsafe_query_operators, default: false + # Returns the Config singleton, for use in the configure DSL. # # @return [ self ] The Config singleton. diff --git a/lib/mongoid/config/encryption.rb b/lib/mongoid/config/encryption.rb index 93b927a4b7..dd429e1c03 100644 --- a/lib/mongoid/config/encryption.rb +++ b/lib/mongoid/config/encryption.rb @@ -21,17 +21,25 @@ module Encryption # # @return [ Hash ] The encryption schema map. def encryption_schema_map(default_database, models = ::Mongoid.models) - visited = Set.new models.each_with_object({}) do |model, map| - next if visited.include?(model) - visited << model next if model.embedded? - next unless model.encrypted? + next unless model.requires_encryption_schema? database = model.storage_options.fetch(:database) { default_database } + # A callable database name cannot be resolved here: the documented + # multi-tenant idiom has no correct value while the client is being + # built. Interpolating the callable would produce a key that never + # matches any namespace, so leave the model out of the map. Writes + # are refused later, by PersistenceContext, rather than silently + # going out unencrypted. + next if database.respond_to?(:call) + key = "#{database}.#{model.collection_name}" - props = metadata_for(model).merge(properties_for(model, visited)) - map[key] = props unless props.empty? + props = metadata_for(model).merge(properties_for(model, [ model ])) + # The root of a collection schema describes the document, so it is + # always an object. Saying so matters when a nested schema carries + # encryptMetadata: mongocryptd rejects the schema otherwise. + map[key] = { 'bsonType' => 'object' }.merge(props) unless props.empty? end end @@ -100,11 +108,12 @@ def metadata_for(model) # are marked as encrypted. # # @param [ Mongoid::Document ] model The model to generate the properties for. - # @param [ Set ] visited The set of models that have already been visited. + # @param [ Array ] path The models the walk is already + # inside of, outermost first. # # @return [ Hash ] The encryption properties. - def properties_for(model, visited) - result = properties_for_fields(model).merge(properties_for_relations(model, visited)) + def properties_for(model, path) + result = properties_for_fields(model).merge(properties_for_relations(model, path)) if result.empty? {} else @@ -141,20 +150,29 @@ def properties_for_fields(model) # are configured to be encrypted. # # @param [ Mongoid::Document ] model The model to generate the properties for. - # @param [ Set ] visited The set of models that have already been visited. + # @param [ Array ] path The models the walk is already + # inside of, outermost first. # # @return [ Hash ] The encryption properties. - def properties_for_relations(model, visited) + def properties_for_relations(model, path) model.relations.each_with_object({}) do |(name, relation), props| - next if visited.include?(relation.relation_class) + # relation_class constantizes, and a polymorphic embedded_in has no + # class to resolve, so the relation type has to be checked first. next unless relation.is_a?(Association::Embedded::EmbedsOne) - next unless relation.relation_class.encrypted? - visited << relation.relation_class - metadata_for( - relation.relation_class - ).merge( - properties_for(relation.relation_class, visited) + klass = relation.try_relation_class + # An association target does not have to be a Mongoid document, and + # the class it names does not have to exist. + next unless klass.respond_to?(:requires_encryption_schema?) + # Stop at a model the walk is already inside of, or a self-embedding + # model never terminates. The path covers the current branch only: + # a model embedded by two parents, or twice by one parent, has to be + # emitted at every place it appears. + next if path.include?(klass) + next unless klass.requires_encryption_schema? + + metadata_for(klass).merge( + properties_for(klass, path + [ klass ]) ).tap do |properties| props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty? end diff --git a/lib/mongoid/contextual/aggregable/memory.rb b/lib/mongoid/contextual/aggregable/memory.rb index ae4aacffd2..a0b65ea3b0 100644 --- a/lib/mongoid/contextual/aggregable/memory.rb +++ b/lib/mongoid/contextual/aggregable/memory.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true # rubocop:todo all +require 'mongoid/field_readable' + module Mongoid module Contextual module Aggregable # Contains behavior for aggregating values in memory. module Memory + include FieldReadable # Get all the aggregate values for the provided field. # Provided for interface consistency with Aggregable::Mongo. @@ -30,7 +33,7 @@ def aggregates(field) # # @return [ Numeric ] The average. def avg(field) - total = count { |doc| !doc.send(field).nil? } + total = count { |doc| !read_field_value(doc, field).nil? } return nil unless total > 0 total = total.to_f if total.is_a?(Integer) @@ -116,7 +119,7 @@ def sum(field = nil) def aggregate_by(field, method) return nil unless any? - map { |doc| doc.public_send(field) }.compact.public_send(method) + map { |doc| read_field_value(doc, field) }.compact.public_send(method) end end end diff --git a/lib/mongoid/contextual/memory.rb b/lib/mongoid/contextual/memory.rb index 6f9d2e8083..bce05625de 100644 --- a/lib/mongoid/contextual/memory.rb +++ b/lib/mongoid/contextual/memory.rb @@ -3,6 +3,7 @@ require "mongoid/contextual/aggregable/memory" require "mongoid/association/eager_loadable" +require "mongoid/field_readable" module Mongoid module Contextual @@ -17,6 +18,7 @@ class Memory include Association::EagerLoadable include Queryable include Positional + include FieldReadable # @attribute [r] root The root document. # @attribute [r] path The atomic path. @@ -172,10 +174,20 @@ def first! # @param [ Criteria ] criteria The criteria. def initialize(criteria) @criteria, @klass = criteria, criteria.klass - @documents = criteria.documents.select do |doc| - @root ||= doc._root - @collection ||= root.collection - doc._matches?(criteria.selector) + + # Resolving the collection can build a Mongo::Client the first time it + # runs, so it happens before the budget opens. Where the budget is + # enforced with Timeout the exception is asynchronous, and landing in + # the middle of that would leave a half-built client behind. + if (first = criteria.documents.first) + @root = first._root + @collection = @root.collection + end + + # One regexp budget covers the whole scan, so that the limit bounds the + # query rather than each document individually. + @documents = Matcher::RegexpBudget.open(criteria.selector) do + criteria.documents.select { |doc| doc._matches?(criteria.selector) } end apply_sorting apply_options @@ -733,11 +745,10 @@ def retrieve_value_at_path(document, field_path) # _translations hash so that we can get the specified translation in # the remaining if field&.localized? - document.send("#{segment}_translations") + document.public_send("#{segment}_translations") end end - meth = klass.aliased_associations[segment] || segment - res.nil? ? document.try(meth) : res + res.nil? ? read_field_value(document, segment) : res elsif document.is_a?(Hash) # TODO: Remove the indifferent access when implementing MONGOID-5410. document.key?(segment.to_s) ? diff --git a/lib/mongoid/criteria/queryable/mergeable.rb b/lib/mongoid/criteria/queryable/mergeable.rb index 15a317fdd9..4ba47604db 100644 --- a/lib/mongoid/criteria/queryable/mergeable.rb +++ b/lib/mongoid/criteria/queryable/mergeable.rb @@ -326,6 +326,10 @@ def __multi__(criteria, operator) end end end + # Every query method that takes a user-supplied expression normalizes + # it here, so this is where the operator guard is enforced. See + # Selectable#_mongoid_validate_operators! for what it does not cover. + _mongoid_validate_operators!(result) result end diff --git a/lib/mongoid/criteria/queryable/selectable.rb b/lib/mongoid/criteria/queryable/selectable.rb index 2d308a09bd..09059cdf48 100644 --- a/lib/mongoid/criteria/queryable/selectable.rb +++ b/lib/mongoid/criteria/queryable/selectable.rb @@ -823,6 +823,12 @@ def where(*criteria) # only ever specify one criterion to #where. @criterion = criterion if criterion.is_a?(String) + unless Mongoid.allow_unsafe_query_operators? + raise Errors::InvalidQuery, + "String criteria are not allowed because they compile to the '$where' operator, " \ + 'which is not allowed in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \ + 'to permit all operators.' + end js_query(criterion) else expr_query(criterion) @@ -832,6 +838,19 @@ def where(*criteria) private + # Operators permitted at the top level of a query expression without + # opt-in. Excludes $where (JS execution) and other operators not needed + # for ordinary application queries. + ALLOWED_QUERY_OPERATORS = %w[ + $and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue + ].freeze + + # Operators that execute server-side JavaScript. These are rejected at + # any depth, not just at the top level: the allowlist above permits + # $expr and the logical operators, and their values are arbitrary + # nested expressions that can carry $function or $where. + JAVASCRIPT_QUERY_OPERATORS = %w[$where $function $accumulator].freeze + # Adds the specified expression to the query. # # Criterion must be a hash in one of the following forms: @@ -857,6 +876,8 @@ def expr_query(criterion) raise Errors::InvalidQuery, "Expression must be a Hash: #{Errors::InvalidQuery.truncate_expr(criterion)}" end + # The operator guard is applied by _mongoid_expand_keys, which every + # query method that accepts a user-supplied expression passes through. normalized = _mongoid_expand_keys(criterion) clone.tap do |query| normalized.each do |field, value| @@ -872,6 +893,94 @@ def expr_query(criterion) end end + # Enforces the operator rules governed by the + # +allow_unsafe_query_operators+ configuration option against a + # normalized query expression. + # + # Two rules apply, and both are skipped when the option is true: + # + # - An operator at the top level of the expression must appear in + # ALLOWED_QUERY_OPERATORS. + # - An operator in JAVASCRIPT_QUERY_OPERATORS is rejected at any depth. + # + # This is called from #_mongoid_expand_keys rather than from the + # individual query methods, because that is the one point every query + # method taking a user-supplied expression passes through on its way to + # the selector. + # + # It deliberately does not cover the APIs that ask for JavaScript + # outright, such as #js_query and Criteria#for_js: there the developer + # has chosen server-side JavaScript, so there is nothing to guard + # against. The same goes for the low-level Storable methods + # (#add_field_expression, #add_operator_expression), which write to the + # selector directly. + # + # @param [ Hash ] expr A normalized query expression. + # + # @raise [ Errors::InvalidQuery ] If a disallowed operator is present. + # + # @api private + def _mongoid_validate_operators!(expr) + return if Mongoid.allow_unsafe_query_operators? + + expr.each_key do |field| + field_s = field.to_s + next unless field_s.start_with?('$') + next if ALLOWED_QUERY_OPERATORS.include?(field_s) + + raise Errors::InvalidQuery, + "Operator '#{field_s}' is not allowed in a query expression. " \ + 'Set Mongoid.allow_unsafe_query_operators = true to permit all operators.' + end + + _mongoid_validate_no_javascript!(expr) + end + + # Walks a query expression looking for operators that execute + # server-side JavaScript, descending through both hashes and arrays so + # that nested forms such as {'$expr' => {'$function' => ...}} and + # {'$or' => [ {'$where' => ...} ]} are caught. + # + # The walk does not distinguish operator position from value position, + # so it also rejects queries where a JavaScript operator name appears as + # data rather than as an operator. Server 5.0+ permits $-prefixed field + # names in stored documents, which makes this reachable: + # + # Doc.where(payload: { '$eq' => { '$function' => 'abc' } }) + # + # Here the $eq marks its argument as a literal value to compare, so the + # server never evaluates it, but the guard raises anyway. The only + # workaround today is the global allow_unsafe_query_operators flag. + # + # TODO: discuss whether to track operator position (skipping the subtree + # under $eq, $ne, $in, $nin, and $elemMatch values) in a future + # iteration. It removes the false positive but adds exactly the kind of + # state that a real bypass could hide in, so it was left out for now. + # + # @param [ Object ] object A fragment of a query expression. + # + # @raise [ Errors::InvalidQuery ] If a JavaScript operator is present. + # + # @api private + def _mongoid_validate_no_javascript!(object) + case object + when Hash + object.each do |key, value| + key_s = key.to_s + if JAVASCRIPT_QUERY_OPERATORS.include?(key_s) + raise Errors::InvalidQuery, + "Operator '#{key_s}' executes server-side JavaScript and is not allowed " \ + 'anywhere in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \ + 'to permit all operators.' + end + + _mongoid_validate_no_javascript!(value) + end + when Array + object.each { |value| _mongoid_validate_no_javascript!(value) } + end + end + # Force the values of the criterion to be evolved. # # @api private diff --git a/lib/mongoid/encryptable.rb b/lib/mongoid/encryptable.rb index e4b18d08f8..33132d9204 100644 --- a/lib/mongoid/encryptable.rb +++ b/lib/mongoid/encryptable.rb @@ -37,6 +37,52 @@ def encrypted? !encrypt_metadata.empty? || fields.any? { |_, field| field.is_a?(Mongoid::Fields::Encrypted) } end + # Whether an encryption schema has to be generated for this model. + # + # True when the model declares encryption itself, and also when any model + # reachable through its embeds_one relations does. A model in the second + # group has no encrypted field of its own, but its collection still needs + # a schema, otherwise the embedded fields are written in plaintext. + # + # The answer is memoized, since this runs on the persistence path. + # Declaring encryption on a model after it has already been persisted is + # not supported. + # + # @return [ true | false ] Whether the model needs an encryption schema. + # + # @api private + def requires_encryption_schema? + return @requires_encryption_schema if defined?(@requires_encryption_schema) + + @requires_encryption_schema = encrypted? || embeds_encrypted?([ self ]) + end + + # Whether any model reachable through this model's embeds_one relations + # declares encryption. + # + # embeds_many is not considered: libmongocrypt cannot express per-field + # encryption under array items, so those fields are never mapped. + # + # @param [ Array ] path The models the walk is already inside of. + # A model embedding itself terminates here. + # + # @return [ true | false ] Whether an embedded model is encrypted. + # + # @api private + def embeds_encrypted?(path) + relations.each_value.any? do |relation| + next false unless relation.is_a?(Association::Embedded::EmbedsOne) + + klass = relation.try_relation_class + # An association target does not have to be a Mongoid document, and + # the class it names does not have to exist. + next false unless klass.respond_to?(:encrypted?) + next false if path.include?(klass) + + klass.encrypted? || klass.embeds_encrypted?(path + [ klass ]) + end + end + # Override the key_id for the model. # # This method is solely for testing purposes and should not be used in diff --git a/lib/mongoid/errors.rb b/lib/mongoid/errors.rb index cb0e38a745..9553242fff 100644 --- a/lib/mongoid/errors.rb +++ b/lib/mongoid/errors.rb @@ -12,6 +12,7 @@ require "mongoid/errors/empty_config_file" require "mongoid/errors/immutable_attribute" require "mongoid/errors/in_memory_collation_not_supported" +require "mongoid/errors/in_memory_regexp_timeout" require "mongoid/errors/invalid_auto_encryption_configuration" require "mongoid/errors/invalid_around_callback" require "mongoid/errors/invalid_async_query_executor" @@ -52,6 +53,7 @@ require "mongoid/errors/nested_attributes_metadata_not_found" require "mongoid/errors/no_default_client" require "mongoid/errors/no_environment" +require "mongoid/errors/no_encryption_schema" require "mongoid/errors/no_map_reduce_output" require "mongoid/errors/no_metadata" require "mongoid/errors/no_parent" diff --git a/lib/mongoid/errors/in_memory_regexp_timeout.rb b/lib/mongoid/errors/in_memory_regexp_timeout.rb new file mode 100644 index 0000000000..5b02ccf006 --- /dev/null +++ b/lib/mongoid/errors/in_memory_regexp_timeout.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mongoid + module Errors + # This error is raised when evaluating a query in memory exceeds + # Mongoid::Config.in_memory_regexp_time_limit. + # + # What the limit bounds depends on the Ruby in use. Where per-Regexp + # timeouts are available it is the time spent executing regular + # expressions; elsewhere it is the elapsed time of the whole in-memory + # evaluation. The message is worded to hold either way. + class InMemoryRegexpTimeout < MongoidError + # Create the new error. + # + # @example Create the new in-memory regexp timeout error. + # InMemoryRegexpTimeout.new(5.0) + # + # @param [ Float ] limit The limit that was exceeded, in seconds. Not + # always the configured one: a global Regexp.timeout stricter than the + # configuration takes its place. + def initialize(limit) + super(compose_message('in_memory_regexp_timeout', limit: limit)) + end + end + end +end diff --git a/lib/mongoid/errors/no_encryption_schema.rb b/lib/mongoid/errors/no_encryption_schema.rb new file mode 100644 index 0000000000..3abd77c339 --- /dev/null +++ b/lib/mongoid/errors/no_encryption_schema.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mongoid + module Errors + # This error is raised when a model declares encrypted fields, but the + # namespace it is about to be persisted to is not covered by the automatic + # encryption schema of the client in use. Without a schema the driver has + # nothing to encrypt with, and the fields would be stored in plaintext. + class NoEncryptionSchema < MongoidError + # Create the new error. + # + # @example Create the error. + # NoEncryptionSchema.new(Band, 'music.bands', :default) + # + # @param [ Class ] klass The model class. + # @param [ String ] namespace The namespace the model resolved to. + # @param [ String | Symbol ] client The name of the client in use. + def initialize(klass, namespace, client) + super( + compose_message( + 'no_encryption_schema', + { klass: klass, namespace: namespace, client: client } + ) + ) + end + end + end +end diff --git a/lib/mongoid/field_readable.rb b/lib/mongoid/field_readable.rb new file mode 100644 index 0000000000..45a327ffcb --- /dev/null +++ b/lib/mongoid/field_readable.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# rubocop:todo all + +module Mongoid + # Reads the value of a field name from a document without dispatching the + # name as an arbitrary method. + # + # Field names may come from application input, where a name like +destroy+ + # or +attributes+ would delete the document or disclose its contents. + # Declared field and association names are validated when they are declared + # (see Mongoid.destructive_fields), so a name that resolves to one of them + # is safe to send to a document. Any other name is read from the attributes + # hash, which is what the database-backed query contexts do. + # + # @api private + module FieldReadable + private + + # Read the value of the given field name from the given document. + # + # @param [ Document ] document The document to read from. + # @param [ String | Symbol ] name The name of the field. + # + # @return [ Object | nil ] The value of the field, or nil when the name + # is neither a declared field nor present in the attributes. + def read_field_value(document, name) + name = name.to_s + # A blank name cannot name a field. Return nil, which is what reading + # one has always done. + return nil if name.blank? + + if (meth = readable_method_for(document.class, name)) + document.public_send(meth) + else + document.attributes[document.class.database_field_name(name)] + end + end + + # Resolve the given name to a method that is declared by the given class, + # and therefore safe to send to one of its instances. + # + # @param [ Class ] klass The document class. + # @param [ String ] name The name of the field. + # + # @return [ String | nil ] The method to send, or nil when the name is + # not declared by the class. + def readable_method_for(klass, name) + # Fields, associations, and field aliases each define a reader of their + # own name. Note that associations must be resolved before aliases: a + # belongs_to aliases its own name to its foreign key, and reading + # `band` must give the document, not the id. + return name if klass.relations.key?(name) || + klass.fields.key?(name) || + klass.aliased_fields.key?(name) + + # An association may also be named by its `store_as`, which has no + # reader of its own, or by its ids accessor, which does. + if (assoc = klass.relations[klass.aliased_associations[name]]) + return (assoc.store_as == name) ? assoc.name.to_s : name + end + + # Localized fields also get a _translations reader, which does not + # appear in the fields hash. + base = name.delete_suffix(Fields::TRANSLATIONS_SFX) + return nil if base == name + + name if klass.fields[klass.database_field_name(base)]&.localized? + end + end +end diff --git a/lib/mongoid/indexable.rb b/lib/mongoid/indexable.rb index 8eb6b7e1b3..0d03982c8d 100644 --- a/lib/mongoid/indexable.rb +++ b/lib/mongoid/indexable.rb @@ -27,18 +27,9 @@ module ClassMethods def create_indexes return unless index_specifications - default_options = {background: Config.background_indexing} - - index_specifications.each do |spec| - key, options = spec.key, default_options.merge(spec.options) - if database = options[:database] - with(database: database) do |klass| - klass.collection.indexes(session: _session).create_one(key, options.except(:database)) - end - else - collection.indexes(session: _session).create_one(key, options) - end - end and true + Threaded.with_collection_management do + perform_create_indexes + end end # Send the actual index removal comments to the MongoDB driver, @@ -49,21 +40,9 @@ def create_indexes # # @return [ true ] If the operation succeeded. def remove_indexes - indexed_database_names.each do |database| - with(database: database) do |klass| - begin - klass.collection.indexes(session: _session).each do |spec| - unless spec["name"] == "_id_" - klass.collection.indexes(session: _session).drop_one(spec["key"]) - logger.info( - "MONGOID: Removed index '#{spec["name"]}' on collection " + - "'#{klass.collection.name}' in database '#{database}'." - ) - end - end - rescue Mongo::Error::OperationFailure; end - end - end and true + Threaded.with_collection_management do + perform_remove_indexes + end end # Add the default indexes to the root document if they do not already @@ -118,6 +97,39 @@ def index_specification(index_hash, index_name = nil) private + def perform_create_indexes + default_options = {background: Config.background_indexing} + + index_specifications.each do |spec| + key, options = spec.key, default_options.merge(spec.options) + if database = options[:database] + with(database: database) do |klass| + klass.collection.indexes(session: _session).create_one(key, options.except(:database)) + end + else + collection.indexes(session: _session).create_one(key, options) + end + end and true + end + + def perform_remove_indexes + indexed_database_names.each do |database| + with(database: database) do |klass| + begin + klass.collection.indexes(session: _session).each do |spec| + unless spec["name"] == "_id_" + klass.collection.indexes(session: _session).drop_one(spec["key"]) + logger.info( + "MONGOID: Removed index '#{spec["name"]}' on collection " + + "'#{klass.collection.name}' in database '#{database}'." + ) + end + end + rescue Mongo::Error::OperationFailure; end + end + end and true + end + # Get the names of all databases for this model that have index # definitions. # diff --git a/lib/mongoid/matchable.rb b/lib/mongoid/matchable.rb index 5b1273f78f..f5d62b142f 100644 --- a/lib/mongoid/matchable.rb +++ b/lib/mongoid/matchable.rb @@ -18,7 +18,12 @@ module Matchable # # @return [ true | false ] True if matches, false if not. def _matches?(selector) - Matcher::Expression.matches?(self, selector) + # Opens a regexp budget for this document only. Callers that match many + # documents against one selector open a budget of their own first, and + # this one joins it rather than giving every document a fresh limit. + Matcher::RegexpBudget.open(selector) do + Matcher::Expression.matches?(self, selector) + end end end end diff --git a/lib/mongoid/matcher.rb b/lib/mongoid/matcher.rb index 9b2a0c1cf9..48278d06b1 100644 --- a/lib/mongoid/matcher.rb +++ b/lib/mongoid/matcher.rb @@ -142,6 +142,7 @@ module Matcher require 'mongoid/matcher/not' require 'mongoid/matcher/or' require 'mongoid/matcher/regex' +require 'mongoid/matcher/regexp_budget' require 'mongoid/matcher/size' require 'mongoid/matcher/type' require 'mongoid/matcher/expression_operator' diff --git a/lib/mongoid/matcher/eq_impl_with_regexp.rb b/lib/mongoid/matcher/eq_impl_with_regexp.rb index 77af1d90b6..e0a15ed275 100644 --- a/lib/mongoid/matcher/eq_impl_with_regexp.rb +++ b/lib/mongoid/matcher/eq_impl_with_regexp.rb @@ -1,13 +1,10 @@ -# rubocop:todo all module Mongoid module Matcher - # This is an internal equality implementation that performs exact # comparisons and regular expression matches. # # @api private module EqImplWithRegexp - # Returns whether a value satisfies an $eq (or similar) expression, # performing a regular expression match if the condition is a regular # expression. @@ -19,14 +16,12 @@ module EqImplWithRegexp # @return [ true | false ] Whether the value matches. # # @api private - module_function def matches?(original_operator, value, condition) + module_function def matches?(_original_operator, value, condition) case condition - when Regexp - value =~ condition - when ::BSON::Regexp::Raw - value =~ condition.compile + when Regexp, ::BSON::Regexp::Raw + value.respond_to?(:=~) && RegexpBudget.match?(value, condition) else - if value.kind_of?(Time) && condition.kind_of?(Time) + if value.is_a?(Time) && condition.is_a?(Time) EqImpl.time_eq?(value, condition) else value == condition diff --git a/lib/mongoid/matcher/regex.rb b/lib/mongoid/matcher/regex.rb index 3c8f8b33b0..83645448a5 100644 --- a/lib/mongoid/matcher/regex.rb +++ b/lib/mongoid/matcher/regex.rb @@ -1,14 +1,11 @@ -# rubocop:todo all module Mongoid module Matcher - # In-memory matcher for $regex expression. # # @see https://www.mongodb.com/docs/manual/reference/operator/query/regex/ # # @api private module Regex - # Returns whether a value satisfies a $regex expression. # # @param [ true | false ] exists Not used. @@ -18,25 +15,24 @@ module Regex # @return [ true | false ] Whether the value matches. # # @api private - module_function def matches?(exists, value, condition) - condition = case condition - when Regexp - condition - when BSON::Regexp::Raw - condition.compile - else + module_function def matches?(_exists, value, condition) + unless condition.is_a?(Regexp) || condition.is_a?(BSON::Regexp::Raw) # Note that strings must have been converted to a regular expression # instance already (with $options taken into account, if provided). raise Errors::InvalidQuery, "$regex requires a regular expression argument: #{Errors::InvalidQuery.truncate_expr(condition)}" end + # The condition is compiled by RegexpBudget rather than here, so that + # the budget's timeout can be baked into the pattern. case value when Array + # Object#=~ is gone as of Ruby 3.2, so an element that cannot be + # matched against has to be rejected rather than passed to =~. value.any? do |v| - v =~ condition + v.respond_to?(:=~) && RegexpBudget.match?(v, condition) end when String - value =~ condition + RegexpBudget.match?(value, condition) else false end @@ -52,7 +48,7 @@ module Regex # # @api private module_function def matches_array_or_scalar?(value, condition) - if Array === value + if value.is_a?(Array) value.any? do |v| matches?(true, v, condition) end diff --git a/lib/mongoid/matcher/regexp_budget.rb b/lib/mongoid/matcher/regexp_budget.rb new file mode 100644 index 0000000000..76516bd486 --- /dev/null +++ b/lib/mongoid/matcher/regexp_budget.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require 'timeout' + +module Mongoid + module Matcher + # Bounds the time spent executing regular expressions while evaluating a + # single in-memory match operation. + # + # A query condition can carry an application-supplied pattern, and the + # in-memory matcher compiles and runs that pattern in the caller's thread. + # Both the cost of one match and the number of matches performed are under + # the control of whoever supplied the condition, so the limit is cumulative + # over an entire operation rather than per match. + # + # The budget is held in thread- or fiber-local storage, so concurrent + # queries are accounted for independently. + # + # @api private + module RegexpBudget + # Whether a per-Regexp timeout can be relied on to reach Regexp.new. + # + # MRI added them in 3.2. JRuby 10.0.6 defines Regexp::TimeoutError, + # reports Ruby 3.4, and does honour a timeout that reaches it, but its + # Regexp.new accepts the keyword only for the first couple of calls + # through a given call site and raises ArgumentError from then on. + # Because that breakage is per call site, no load-time probe can predict + # it: a probe at its own call site reports a capability that the call in + # Budget#compile does not have. So non-MRI engines are excluded outright + # and use the Timeout fallback, which does interrupt a Joni match already + # under way. Worth revisiting if JRuby fixes the keyword handling. + PER_REGEXP_TIMEOUT = + if RUBY_ENGINE == 'ruby' && defined?(::Regexp::TimeoutError) + true + else + false + end + + # The exception raised by a per-Regexp timeout. Tied to the constant + # rather than to the probe, so that a timeout set some other way (an + # application assigning Regexp.timeout, say) is still translated. On + # Rubies with no such constant, a class that is never raised stands in. + TIMEOUT_ERROR = defined?(::Regexp::TimeoutError) ? ::Regexp::TimeoutError : Class.new(StandardError) + + # Raised by Timeout on Rubies without per-Regexp timeouts, and converted + # immediately. It is private to this module so that an application's own + # Timeout, firing inside our block, is never mistaken for ours. + class TimedOut < StandardError; end + + # The marker stored in thread-local storage when a scope is open but has + # no budget to enforce. Threaded.has? reports a nil-valued variable as + # absent, so an open-but-unbounded scope has to store a real marker; a + # nested call checks Threaded.has? and joins the enclosing scope rather + # than deciding again for itself. + NO_BUDGET = Object.new + + # The state of one open budget: what is left of the limit, and the + # patterns compiled under it. + # + # @api private + class Budget + # @return [ Float ] The limit this budget started with. + attr_reader :limit + + # @return [ Float ] The seconds left before the budget is spent. + attr_reader :remaining + + # @param [ Float ] limit The seconds this budget may spend. + def initialize(limit) + @limit = limit + @remaining = limit + @cache = {} + # A per-Regexp timeout bounds one match; the cumulative budget bounds + # the operation. So the timeout is fixed for the life of the scope + # rather than following the drawdown, which is what lets a pattern be + # compiled once instead of once per match. It means a match that + # starts with almost nothing left can still run for a whole limit, so + # an operation can overshoot by at most one limit -- bounded, which is + # the point, and far cheaper than recompiling. + # + # An application that has set a stricter global Regexp.timeout keeps + # it: baking in a larger value would leave it less protected than it + # asked to be. A timeout set on one individual pattern is a different + # matter, and is not preserved -- Budget#compile rebuilds the pattern + # from its source and flags, neither of which carries one, so this + # value takes its place. Only trusted code can supply such a pattern: + # a condition decoded from JSON or BSON arrives as a string or a + # BSON::Regexp::Raw, with no timeout of its own. + @timeout = [ limit, ::Regexp.timeout ].compact.min if PER_REGEXP_TIMEOUT + end + + # Draws the elapsed time down from the budget. + # + # @param [ Float ] elapsed The seconds to charge. + def charge(elapsed) + @remaining -= elapsed + end + + # @return [ true | false ] Whether the budget is spent. + def exhausted? + @remaining <= 0 + end + + # Returns the condition as a Regexp which, where the Ruby in use + # supports it, gives up once its timeout is spent. + # + # The condition is taken uncompiled so that the cache can answer before + # any compiling happens. A BSON::Regexp::Raw memoizes its own compile, + # but FieldExpression builds a fresh one for every $regex it evaluates, + # so that memo is worth nothing across documents and the source would + # otherwise be compiled once per document. + # + # @param [ Regexp | BSON::Regexp::Raw ] condition The condition. + # + # @return [ Regexp ] The compiled pattern. + def compile(condition) + @cache[cache_key(condition)] ||= bake(RegexpBudget.coerce(condition)) + end + + private + + # BSON::Regexp::Raw aliases eql? to == but leaves hash alone, so two + # equal instances hash differently and cannot key the cache. What they + # are equal by can. Anything else keys on itself and is left to coerce + # to reject. + def cache_key(condition) + case condition + when BSON::Regexp::Raw then [ condition.pattern, condition.options ] + else condition + end + end + + # Rebuilds the pattern with the budget's timeout, where the Ruby in use + # has them. + def bake(regexp) + return regexp unless PER_REGEXP_TIMEOUT + + ::Regexp.new(regexp.source, regexp.options, timeout: @timeout) + end + end + + class << self + # Opens a budget scope for the duration of the block. + # + # Nested calls join the enclosing budget instead of starting a new one, + # which is what lets a scan over many documents share a single limit. + # It also keeps the recursion in Expression.matches? (through + # $elemMatch, $and, $or and $nor) from resetting the budget. + # + # No budget is opened for a selector that carries no regular + # expression. There would be nothing for it to bound, and on the + # Timeout path it would put a deadline on in-memory work that has + # nothing to do with regular expressions. + # + # The scope covers everything nested inside the block, a selector other + # than this one included: a nested call joins the scope rather than + # deciding for itself, which is what keeps a scan from walking the + # selector once per document. Where the scope has nothing to bound, that + # means nested selectors are not bounded either -- so do not open one + # around work that can run application code. Loading documents runs find + # callbacks, and a query in one of those brings its own selector. + # + # Where a selector does carry one, the Timeout path still measures the + # whole scope rather than the matching alone, so a long scan can trip + # the limit with a cheap pattern. That imprecision is accepted: the + # alternative is a Timeout around each individual match, which was + # measured at about nine seconds per million matches, and the limit + # exists to bound a scan of exactly that size. The error message is + # worded to hold either way, and Rubies with per-Regexp timeouts -- + # every supported MRI from 3.2 on -- do not take this path at all. + # + # Code inside the block that mutates state should be wrapped in + # .protect, since on Rubies without a per-Regexp timeout the budget is + # enforced with an asynchronous exception that can land anywhere. + # + # @param [ Hash ] selector The selector about to be evaluated. + # + # @return [ Object ] The value of the block. + def open(selector, &block) + # The key is present (holding NO_BUDGET) where an enclosing scope + # found nothing to bound, so that a nested call does not scan the + # selector again. Deciding before this check, in a default argument + # say, would walk the selector once per document on a scan. + return yield if Threaded.has?(Threaded::REGEXP_BUDGET_KEY) + + open_with(limit_for(selector), &block) + end + + # Opens a budget scope for a limit the caller has already decided on. + # + # A caller that rearranges its work around the decision -- loading + # documents up front so that nothing is mutated before the scan + # finishes, say -- has to make it before it can act on it, and must not + # then make it a second time. Asking .limit_for and letting .open ask + # again reads the configured limit twice, and the two reads can differ: + # a limit that becomes positive in between would establish a budget in + # the branch that was chosen for not needing one, and on the Timeout + # path that arms a deadline over work the branch never made + # interruptible. + # + # See .open for what the scope does and does not bound, and for the + # note about mutating state inside it. + # + # @param [ Float | nil ] limit The seconds the scope may spend, or nil + # for a scope with nothing to bound. + # + # @return [ Object ] The value of the block. + def open_with(limit, &block) + return yield if Threaded.has?(Threaded::REGEXP_BUDGET_KEY) + + budget = Budget.new(limit) if limit&.positive? + + begin + # Set inside the begin so that an asynchronous exception from an + # enclosing timeout cannot leave the key behind on a pooled thread. + Threaded.set(Threaded::REGEXP_BUDGET_KEY, budget || NO_BUDGET) + + if budget.nil? || PER_REGEXP_TIMEOUT + yield + else + begin + Timeout.timeout(budget.limit, TimedOut, &block) + rescue TimedOut + raise timeout_error(budget) + end + end + ensure + Threaded.delete(Threaded::REGEXP_BUDGET_KEY) + end + end + + # The limit a scope evaluating this selector would be bounded by. + # + # A caller that has to rearrange its work to make the scan + # interruptible -- loading documents up front so that nothing is + # mutated before the scan finishes, say -- can ask this first and skip + # the rearrangement, and whatever it costs, when there is no pattern to + # bound. It then passes what it got to .open_with, so that the decision + # it acted on is the one the scope is opened with. Callers with nothing + # to rearrange should just call .open, which asks this itself. + # + # @param [ Hash ] selector The selector about to be evaluated. + # + # @return [ Float | nil ] The limit, or nil where there is nothing to + # bound. + def limit_for(selector) + # nil.to_f is 0.0, so an unset limit and a limit of zero or less are + # the same thing here: no limit. Zero is a common way to spell + # "disabled", and taking it literally would mean a budget that is + # spent before the first match and a query that can never run. + limit = Mongoid::Config.in_memory_regexp_time_limit.to_f + return nil unless limit.positive? + + limit if contains_regexp?(selector) + end + + # Matches a value against a regular expression condition, charging the + # time it takes against the open budget. + # + # @param [ Object ] value The value to match. + # @param [ Regexp | BSON::Regexp::Raw ] condition The condition. + # + # @raise [ Errors::InMemoryRegexpTimeout ] if the budget is exhausted. + # + # @return [ Integer | nil ] The offset of the match, or nil. + def match?(value, condition) + budget = current + return value =~ coerce(condition) unless budget + + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pattern = nil + begin + raise timeout_error(budget) if budget.exhausted? + + # Compiling is charged too. It is not free, and for a pattern with + # very many branches it costs far more than running the pattern + # does, so leaving it out would leave a way to spend unbounded time + # without the budget ever noticing. + pattern = budget.compile(condition) + value =~ pattern + rescue TIMEOUT_ERROR + # Name the limit that actually fired, which is not always the + # budget's. A baked pattern carries it: the smaller of the budget's + # limit and any global Regexp.timeout the application has set. + # Where nothing was baked -- an engine that raises this error but + # will not take a per-Regexp timeout, which is JRuby -- the global + # is the only thing that can have fired, and the pattern reports + # nil. Naming the budget's limit in either case would state a time + # that was never spent and send the reader after a setting that is + # not the one in the way. + # + # Both readers arrived together with Regexp::TimeoutError, so every + # engine that can reach this rescue at all has them. + raise timeout_error(budget, pattern&.timeout || ::Regexp.timeout || budget.limit) + ensure + budget.charge(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) + end + end + + # Runs the block without letting a scope timeout tear it in half. + # + # Where the budget is enforced with Timeout, the exception is raised + # asynchronously and can arrive at any point. Wrapping a mutation in + # this holds the exception back until the block has finished, so the + # interruption is deferred rather than given up. + # + # @return [ Object ] The value of the block. + def protect(&block) + Thread.handle_interrupt(TimedOut => :never, &block) + end + + # The time left in the open budget, or nil when no budget is open. + # + # @return [ Float | nil ] The remaining seconds. + def remaining + current&.remaining + end + + # Returns the condition as a Regexp, without a timeout. + # + # @param [ Regexp | BSON::Regexp::Raw ] condition The condition. + # + # @return [ Regexp ] The pattern. + def coerce(condition) + case condition + when ::Regexp then condition + when BSON::Regexp::Raw then condition.compile + else raise ArgumentError, "Not a regular expression: #{condition.inspect}" + end + end + + private + + # The budget for the open scope, if there is one. + # + # A scope with nothing to bound leaves NO_BUDGET in storage, which reads + # as no budget. + # + # @return [ Budget | nil ] The open budget. + def current + budget = Threaded.get(Threaded::REGEXP_BUDGET_KEY) + budget unless budget.equal?(NO_BUDGET) + end + + # Whether evaluating the selector could run a regular expression. + # + # A string under $regex counts: FieldExpression turns it into a pattern + # at match time. + def contains_regexp?(object) + case object + when ::Regexp, BSON::Regexp::Raw + true + when Hash + object.any? do |k, v| + k.to_s == '$regex' || contains_regexp?(v) + end + when Array + object.any? { |v| contains_regexp?(v) } + else + false + end + end + + # Builds the error with the scope timeout held back. + # + # Composing the message goes through I18n, which reads locale files the + # first time it runs. An asynchronous TimedOut landing in the middle of + # that is caught by I18n and reraised as a locale-loading failure, so + # the real error never surfaces. + # + # @param [ Budget ] budget The open budget. + # @param [ Float ] limit The limit that was exceeded. Defaults to the + # budget's own, which is the right one to name everywhere the budget + # itself ran out. + def timeout_error(budget, limit = budget.limit) + protect do + Errors::InMemoryRegexpTimeout.new(limit) + end + end + end + end + end +end diff --git a/lib/mongoid/persistence_context.rb b/lib/mongoid/persistence_context.rb index 89d7f49e64..1211095427 100644 --- a/lib/mongoid/persistence_context.rb +++ b/lib/mongoid/persistence_context.rb @@ -126,6 +126,8 @@ def client client = client.with(options) unless options.empty? + verify_encryption_schema!(client) + client end end @@ -199,6 +201,39 @@ def __evaluate__(name) name.respond_to?(:call) ? name.call.to_sym : name.to_sym end + # Refuse to use a client that would store a model's encrypted fields in + # plaintext. + # + # The automatic encryption schema is keyed by namespace and is built once, + # when the client is created. The driver looks the target namespace up in + # that schema, and when it is absent it asks the server for a schema + # instead; a collection without a validator then yields no encryption at + # all, and no error. Checking the namespace here is what turns that silent + # downgrade into a failure. + # + # @param [ Mongo::Client ] client The client this context resolved to. + # + # @raise [ Errors::NoEncryptionSchema ] if the model needs an encryption + # schema and the client's schema does not cover the target namespace. + def verify_encryption_schema!(client) + klass = @object.is_a?(Class) ? @object : @object.class + # A model whose encrypted fields all live on embedded models declares no + # encryption of its own, and still needs a schema for its collection. + return unless klass.respond_to?(:requires_encryption_schema?) && klass.requires_encryption_schema? + # An embedded document is persisted as part of its parent, so the + # parent's context is what governs the namespace. + return if klass.embedded? + # Collection and index management sends no document data, so it does not + # need an encryption-capable client. + return if Threaded.managing_collection? + + schema_map = client.options.dig(:auto_encryption_options, :schema_map) + namespace = "#{client.database.name}.#{collection_name}" + return if schema_map&.key?(namespace) + + raise Errors::NoEncryptionSchema.new(klass, namespace, client_name) + end + def client_options @client_options ||= begin opts = options.select do |k, v| diff --git a/lib/mongoid/search_indexable.rb b/lib/mongoid/search_indexable.rb index 1342539223..a6938eaeff 100644 --- a/lib/mongoid/search_indexable.rb +++ b/lib/mongoid/search_indexable.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +# rubocop:todo all module Mongoid # Encapsulates behavior around managing search indexes. This feature @@ -65,7 +66,9 @@ module ClassMethods def create_search_indexes return if search_index_specs.empty? - collection.search_indexes.create_many(search_index_specs) + Threaded.with_collection_management do + collection.search_indexes.create_many(search_index_specs) + end end # Waits for the named search indexes to be created. @@ -96,7 +99,9 @@ def wait_for_search_indexes(names, interval: 5) # @option options [ Hash ] :aggregate The options hash to pass to the # aggregate command (optional) def search_indexes(options = {}) - collection.search_indexes(options) + Threaded.with_collection_management do + collection.search_indexes(options) + end end # Removes the search index specified by the given name or id. Either @@ -105,12 +110,14 @@ def search_indexes(options = {}) # @param [ String | nil ] name the name of the index to remove # @param [ String | nil ] id the id of the index to remove def remove_search_index(name: nil, id: nil) - logger.info( - "MONGOID: Removing search index '#{name || id}' " \ - "on collection '#{collection.name}'." - ) + Threaded.with_collection_management do + logger.info( + "MONGOID: Removing search index '#{name || id}' " \ + "on collection '#{collection.name}'." + ) - collection.search_indexes.drop_one(name: name, id: id) + collection.search_indexes.drop_one(name: name, id: id) + end end # Request the removal of all registered search indexes. Note diff --git a/lib/mongoid/tasks/database.rb b/lib/mongoid/tasks/database.rb index aa3edbcc79..3e7656357c 100644 --- a/lib/mongoid/tasks/database.rb +++ b/lib/mongoid/tasks/database.rb @@ -84,6 +84,12 @@ def create_search_indexes(models = ::Mongoid.models, wait: true) # # @return [ Array ] The list of undefined indexes by model. def undefined_indexes(models = ::Mongoid.models) + Threaded.with_collection_management do + undefined_indexes_for(models) + end + end + + def undefined_indexes_for(models) undefined_by_model = {} models.each do |model| @@ -116,15 +122,17 @@ def undefined_indexes(models = ::Mongoid.models) # # @return [ Hash{Class => Array(Hash)}] The list of indexes that were removed by model. def remove_undefined_indexes(models = ::Mongoid.models) - undefined_indexes(models).each do |model, indexes| - indexes.each do |index| - key = index['key'].symbolize_keys - collection = model.collection - collection.indexes(session: model.send(:_session)).drop_one(key) - logger.info( - "MONGOID: Removed index '#{index['name']}' on collection " + - "'#{collection.name}' in database '#{collection.database.name}'." - ) + Threaded.with_collection_management do + undefined_indexes(models).each do |model, indexes| + indexes.each do |index| + key = index['key'].symbolize_keys + collection = model.collection + collection.indexes(session: model.send(:_session)).drop_one(key) + logger.info( + "MONGOID: Removed index '#{index['name']}' on collection " + + "'#{collection.name}' in database '#{collection.database.name}'." + ) + end end end end @@ -170,6 +178,12 @@ def remove_search_indexes(models = ::Mongoid.models) # # @return [ Array ] The sharded models def shard_collections(models = ::Mongoid.models) + Threaded.with_collection_management do + shard_collections_for(models) + end + end + + def shard_collections_for(models) models.map do |model| next if model.shard_config.nil? diff --git a/lib/mongoid/threaded.rb b/lib/mongoid/threaded.rb index bb3c00de6b..dcd8148adc 100644 --- a/lib/mongoid/threaded.rb +++ b/lib/mongoid/threaded.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +# rubocop:todo all require 'mongoid/threaded/lifecycle' @@ -25,6 +26,9 @@ module Threaded hash[key] = "[mongoid]:#{key}-stack" end + # The name of the stack tracking collection and index management. + COLLECTION_MANAGEMENT = :collection_management + # The key for the current thread's sessions. SESSIONS_KEY = '[mongoid]:sessions' @@ -35,6 +39,9 @@ module Threaded # executed on documents. EXECUTE_CALLBACKS = '[mongoid]:execute-callbacks' + # The key for the time left in the current in-memory regexp budget. + REGEXP_BUDGET_KEY = 'regexp-budget' + extend self # Queries the thread-local variable with the given name. If a block is @@ -152,6 +159,36 @@ def executing?(name) !stack(name).empty? end + # Execute the block as collection or index management. + # + # Creating, dropping and inspecting collections and indexes sends no + # document data, so these operations are exempt from the encryption schema + # check that PersistenceContext applies to reads and writes. Without the + # exemption, tasks such as db:mongoid:create_collections would need an + # encryption-capable client to run. + # + # @example Create a collection. + # Threaded.with_collection_management { model.create_collection } + # + # @return [ Object ] The result of the block. + def with_collection_management + begin_execution(COLLECTION_MANAGEMENT) + yield + ensure + exit_execution(COLLECTION_MANAGEMENT) + end + + # Is collection or index management being executed? + # + # @example Is a collection being managed? + # Threaded.managing_collection? + # + # @return [ true | false ] Whether collection or index management is in + # progress on the current thread. + def managing_collection? + executing?(COLLECTION_MANAGEMENT) + end + # Exit from a named thread local stack. # # @example Exit from the stack. diff --git a/lib/mongoid/version.rb b/lib/mongoid/version.rb index ffb5c85662..a7dbe34de3 100644 --- a/lib/mongoid/version.rb +++ b/lib/mongoid/version.rb @@ -5,5 +5,5 @@ module Mongoid # # Note that this file is automatically updated via `rake candidate:create`. # Manual changes to this file will be overwritten by that rake task. - VERSION = '9.0.11' + VERSION = '9.0.12' end diff --git a/product.yml b/product.yml index 7f78f9a576..f380ef38ad 100644 --- a/product.yml +++ b/product.yml @@ -4,5 +4,5 @@ description: a Ruby ODM for MongoDB package: mongoid jira: https://jira.mongodb.org/projects/MONGOID version: - number: 9.0.11 + number: 9.0.12 file: lib/mongoid/version.rb diff --git a/spec/integration/app_spec.rb b/spec/integration/app_spec.rb index 356fc01dc9..cb224abdbd 100644 --- a/spec/integration/app_spec.rb +++ b/spec/integration/app_spec.rb @@ -459,6 +459,13 @@ def adjust_app_gemfile(rails_version: SpecConfig.instance.rails_version) end gemfile_lines << "gem 'mongoid', path: '#{File.expand_path(BASE)}'\n" + # json 3.0 removed the quirks_mode and max_nesting keywords that + # ActiveSupport::JSON (every 7.x) still passes to JSON.generate and + # JSON.parse, so a freshly resolved bundle picks a 3.x json and the app + # fails to encode any response with ArgumentError: unknown keyword: + # quirks_mode. Cap json at 2.x, which still accepts those keywords. + gemfile_lines << "gem 'json', '< 3'\n" + # Rails 6.0 and 6.1 need logger gem on Ruby 2.7+ due to stdlib changes if rails_version && rails_version.to_f < 7.0 && RUBY_VERSION >= '2.7' gemfile_lines << "gem 'logger'\n" diff --git a/spec/integration/associations/has_and_belongs_to_many_spec.rb b/spec/integration/associations/has_and_belongs_to_many_spec.rb index 45b281b18c..b05e02a1b8 100644 --- a/spec/integration/associations/has_and_belongs_to_many_spec.rb +++ b/spec/integration/associations/has_and_belongs_to_many_spec.rb @@ -87,8 +87,23 @@ class Color }) end - it 'does not raise on save' do - expect { image_block.save! }.not_to raise_error + # The nested attributes are processed before the attachment_ids + # assignment is applied, so at that point the id is not yet in the + # association and resolving it requires a collection-wide lookup. + context 'when allow_reparenting_via_nested_attributes is false' do + config_override :allow_reparenting_via_nested_attributes, false + + it 'raises a document not found error' do + expect { image_block.save! }.to raise_error(Mongoid::Errors::DocumentNotFound) + end + end + + context 'when allow_reparenting_via_nested_attributes is true' do + config_override :allow_reparenting_via_nested_attributes, true + + it 'does not raise on save' do + expect { image_block.save! }.not_to raise_error + end end end diff --git a/spec/integration/dots_and_dollars_spec.rb b/spec/integration/dots_and_dollars_spec.rb index ead9261694..3c54417ee6 100644 --- a/spec/integration/dots_and_dollars_spec.rb +++ b/spec/integration/dots_and_dollars_spec.rb @@ -268,10 +268,20 @@ class DADMUser DADMUser.where("$_amount": 0).first end - it "raise an error" do + it 'raises an error' do expect do queried - end.to raise_error(Mongo::Error::OperationFailure) + end.to raise_error(Mongoid::Errors::InvalidQuery) + end + + context 'when allow_unsafe_query_operators is true' do + config_override :allow_unsafe_query_operators, true + + it 'raises an error from the server' do + expect do + queried + end.to raise_error(Mongo::Error::OperationFailure) + end end end end diff --git a/spec/integration/encryption_spec.rb b/spec/integration/encryption_spec.rb index ee4b956a1e..bae1476045 100644 --- a/spec/integration/encryption_spec.rb +++ b/spec/integration/encryption_spec.rb @@ -34,19 +34,24 @@ around do |example| Mongoid.default_client[Crypt::Patient.collection_name].drop Mongoid.default_client[Crypt::Car.collection_name].drop + Mongoid.default_client[Crypt::Folder.collection_name].drop existing_key_id = Crypt::Patient.encrypt_metadata[:key_id] Crypt::Patient.set_key_id(data_key_id) Crypt::Car.set_key_id(data_key_id) + Crypt::Note.set_key_id(data_key_id) Mongoid::Config.send(:clients=, config) Mongoid::Clients.with_name(:key_vault)[key_vault_collection].drop Crypt::Patient.store_in(client: :encrypted) Crypt::Car.store_in(client: :encrypted, database: Crypt::Car.storage_options[:database]) + Crypt::Folder.store_in(client: :encrypted) example.run Crypt::Patient.reset_storage_options! + Crypt::Folder.reset_storage_options! Crypt::Patient.set_key_id(existing_key_id) Crypt::Car.set_key_id(existing_key_id) + Crypt::Note.set_key_id(existing_key_id) end it 'encrypts and decrypts fields' do @@ -86,6 +91,17 @@ end end + # Nothing about the parent document says it needs a schema: it has no + # encrypted field of its own and no encrypt_with. Its collection still needs + # one, or the embedded field goes out in the clear. + it 'stores an embedded field encrypted when the parent has no encrypted field' do + folder = Crypt::Folder.create!(note: Crypt::Note.new(text: 'SECRET')) + unencrypted_client[Crypt::Folder.collection.name].find(_id: folder.id).first.tap do |doc| + expect(doc['note']['text']).to be_a(BSON::Binary) + expect(doc['note']['text'].type).to eq(:ciphertext) + end + end + it 'stores data encrypted in the non-default database' do car = Crypt::Car.create!(vin: 'VA1234') unencrypted_client @@ -96,5 +112,138 @@ end end + # The encryption schema map is keyed by namespace and built once, when the + # client is constructed. Whenever the namespace an encrypted model actually + # writes to is not in that map, the driver encrypts nothing and reports no + # error, so the field lands in the clear. Mongoid fails closed instead. + describe 'when the target namespace is not in the encryption schema map' do + let(:scratch_databases) do + %w[vehicles_dynamic vehicles_tenant_b vehicles_tenant_c tenant_a] + end + + around do |example| + original_options = { + Crypt::DynamicCar => Crypt::DynamicCar.storage_options, + Crypt::TenantCar => Crypt::TenantCar.storage_options + } + existing_key_ids = original_options.keys.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] } + original_options.each_key do |model| + model.set_key_id(data_key_id) + model.store_in(client: :encrypted) + end + clean_scratch_data + # The schema map is generated when the client is built, so the client has + # to be built after the storage options above are in place. + Mongoid::Clients.clear + + example.run + + clean_scratch_data + original_options.each { |model, options| model.storage_options = options } + existing_key_ids.each { |model, key_id| model.set_key_id(key_id) } + Mongoid::Clients.clear + end + + def clean_scratch_data + scratch_databases.each { |database| unencrypted_client.use(database).database.drop } + # Crypt::Car writes into the shared 'vehicles' database, which other + # examples rely on, so remove only the documents these examples create. + unencrypted_client + .use(Crypt::Car.storage_options[:database])[Crypt::Car.collection_name.to_s] + .delete_many(vin: { '$in' => %w[CLIENT-1 CLIENT-2] }) + end + + # Reads with a client that has no automatic encryption, to see what really + # landed on disk. + def documents_with_plaintext_vin(database, collection, vin) + unencrypted_client.use(database)[collection].find(vin: vin).to_a + end + + context 'when the database name is a callable' do + it 'does not store the field in plaintext', :aggregate_failures do + expect { Crypt::DynamicCar.create!(vin: 'DYNAMIC-1') } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + documents_with_plaintext_vin('vehicles_dynamic', Crypt::DynamicCar.collection_name.to_s, 'DYNAMIC-1') + ).to be_empty + end + end + + context 'when the database name is a callable resolved per tenant' do + around do |example| + Thread.current[:tenant_database] = 'tenant_a' + example.run + Thread.current[:tenant_database] = nil + end + + it 'does not store the field in plaintext', :aggregate_failures do + expect { Crypt::TenantCar.create!(vin: 'TENANT-1') } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + documents_with_plaintext_vin('tenant_a', Crypt::TenantCar.collection_name.to_s, 'TENANT-1') + ).to be_empty + end + end + + context 'when the database is overridden for the block' do + it 'does not store the field in plaintext', :aggregate_failures do + expect { Crypt::Car.with(database: 'vehicles_tenant_b') { |car| car.create!(vin: 'BLOCK-1') } } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + documents_with_plaintext_vin('vehicles_tenant_b', Crypt::Car.collection_name.to_s, 'BLOCK-1') + ).to be_empty + end + end + + context 'when the database is overridden globally' do + persistence_context_override :database, 'vehicles_tenant_c' + it 'does not store the field in plaintext', :aggregate_failures do + expect { Crypt::Car.create!(vin: 'GLOBAL-1') } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + documents_with_plaintext_vin('vehicles_tenant_c', Crypt::Car.collection_name.to_s, 'GLOBAL-1') + ).to be_empty + end + end + + context 'when the model is routed to a client without automatic encryption' do + it 'does not store the field in plaintext', :aggregate_failures do + expect { Crypt::Car.with(client: :default) { |car| car.create!(vin: 'CLIENT-1') } } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-1') + ).to be_empty + end + end + + context 'when the parent has no encrypted field of its own' do + it 'does not store the embedded field in plaintext', :aggregate_failures do + expect { Crypt::Folder.with(client: :default) { |folder| folder.create!(note: Crypt::Note.new(text: 'EMBEDDED-1')) } } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + unencrypted_client[Crypt::Folder.collection_name.to_s].find('note.text' => 'EMBEDDED-1').to_a + ).to be_empty + end + end + + context 'when the client is overridden globally to one without automatic encryption' do + persistence_context_override :client, :default + + it 'does not store the field in plaintext', :aggregate_failures do + expect { Crypt::Car.create!(vin: 'CLIENT-2') } + .to raise_error(Mongoid::Errors::NoEncryptionSchema) + + expect( + documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-2') + ).to be_empty + end + end + end end diff --git a/spec/integration/matcher_operator_data/regex.yml b/spec/integration/matcher_operator_data/regex.yml index 242c93d0cb..abd0ff2ada 100644 --- a/spec/integration/matcher_operator_data/regex.yml +++ b/spec/integration/matcher_operator_data/regex.yml @@ -135,6 +135,27 @@ $regex: bar matches: false +# Object#=~ is gone as of Ruby 3.2, so an element that cannot be matched +# against has to be skipped rather than passed to =~. +- name: array field value - element that cannot be matched against + document: + title: + - 42 + - foo + query: + title: + $regex: foo + matches: true + +- name: array field value - no element can be matched against + document: + title: + - 42 + query: + title: + $regex: foo + matches: false + - name: true field value document: title: true diff --git a/spec/integration/matcher_regexp_timeout_spec.rb b/spec/integration/matcher_regexp_timeout_spec.rb new file mode 100644 index 0000000000..9d5608dec0 --- /dev/null +++ b/spec/integration/matcher_regexp_timeout_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'benchmark' +require 'spec_helper' + +describe 'in-memory regexp time limit' do + # See the comment in spec/mongoid/matcher/regexp_budget_spec.rb: costly to + # match but bounded on every supported Ruby. A pattern relying on nested + # quantifiers would hang before Ruby 3.2, where the examples that have no + # interrupt available could not stop it. + let(:slow_pattern) { "(?:#{(1..300).map { |i| "a#{i}" }.join('|')})Z" } + let(:slow_street) { 'a' * 5_000 } + + let(:person) do + # Address derives its _id from the street, so the streets have to differ or + # the embedded association collapses to a single document. + Person.new(addresses: Array.new(30) { |i| Address.new(street: "#{slow_street}#{i}") }) + end + + let(:one_address) { Person.new(addresses: [ Address.new(street: slow_street) ]) } + + # What one match of the fixture costs on the machine running the suite. It + # varies by more than an order of magnitude between implementations - around + # 6ms on MRI, 110ms on JRuby - so the examples below calibrate against a + # measurement instead of hard-coding a threshold that only holds on one. + let(:single_match_cost) do + regexp = Regexp.new(slow_pattern) + 3.times { slow_street =~ regexp } + Benchmark.realtime { slow_street =~ regexp } + end + + # Comfortably more than one match, comfortably less than twenty. The examples + # only need the ordering to hold, so the margin either side is wide. + let(:calibrated_limit) { single_match_cost * 5 } + + context 'when the limit is disabled' do + config_override :in_memory_regexp_time_limit, nil + + it 'evaluates the query without a bound' do + expect(person.addresses.where(street: { '$regex' => slow_pattern }).to_a).to eq([]) + end + end + + context 'when a limit is configured' do + config_override :in_memory_regexp_time_limit, nil + + before { Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit } + + it 'leaves ordinary queries alone' do + person = Person.new(addresses: [ Address.new(street: 'Clarkson') ]) + expect(person.addresses.where(street: { '$regex' => '\AClark' }).to_a.size).to eq(1) + end + + it 'does not raise for a single document' do + # The counterpart to the example below, and what makes it meaningful: one + # document stays under the limit, so a scan that raises can only have got + # there by accumulating cost across documents. + expect do + one_address.addresses.where(street: { '$regex' => slow_pattern }).to_a + end.not_to raise_error + end + + it 'raises for a scan over many documents under that same limit' do + expect do + person.addresses.where(street: { '$regex' => slow_pattern }).to_a + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'raises when $in multiplies the cost within a single document' do + conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) } + + expect do + one_address.addresses.where(street: { '$in' => conditions }).to_a + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'raises through the public _matches? API' do + # 20 conditions rather than one, because a single match is deliberately + # kept well under the limit by the calibration above. + conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) } + + expect do + person.addresses.first._matches?('street' => { '$in' => conditions }) + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + end + + context 'when the pattern is pathological' do + config_override :in_memory_regexp_time_limit, 0.2 + + # The ticket's Case 1, verbatim. It backtracks exponentially and is the + # reason the guard exists, so every entry point that evaluates a selector + # in memory has to bound it. The subject is kept short enough that the + # match still finishes on its own in a few seconds, so an entry point that + # fails to bound it shows up as a failing example rather than a hung run. + let(:evil_pattern) { '^(a+)+\1?$' } + let(:evil_subject) { "#{'a' * 28}X" } + + it 'bounds a scan over an embedded association' do + person = Person.new(addresses: [ Address.new(street: evil_subject) ]) + + expect do + person.addresses.where(street: { '$regex' => evil_pattern }).to_a + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'bounds the public _matches? API' do + expect do + Address.new(street: evil_subject)._matches?('street' => { '$regex' => evil_pattern }) + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'bounds removal from a referenced association' do + owner = Person.create! + owner.posts.create!(title: evil_subject) + + expect do + owner.posts.delete_all(title: { '$regex' => evil_pattern }) + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'deletes nothing when removal is bounded' do + # The scan runs before the delete, so the error means what a caller would + # take it to mean: the documents are still there. + owner = Person.create! + owner.posts.create!(title: evil_subject) + + expect do + owner.posts.delete_all(title: { '$regex' => evil_pattern }) + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + + expect(Post.count).to eq(1) + expect(owner.reload.posts.size).to eq(1) + end + end + + context 'when the association being removed from has not been loaded' do + config_override :in_memory_regexp_time_limit, 0.2 + + let!(:owner) do + person = Person.create! + person.posts.create!(title: 'Testing') + Person.find(person._id) + end + + # The query that loads the association, which removal has to run before it + # has anything to match against. + let(:load_query) { owner.posts._target._unloaded } + + # A stall standing in for a slow network. It is longer than the limit, and + # none of it is time spent on regular expressions. + before do + allow(load_query).to receive(:each).and_wrap_original do |original, *args, &block| + sleep(0.3) + original.call(*args, &block) + end + end + + it 'does not load it, so no query can be counted against the limit' do + # On the Timeout path a deadline covering the fetch failed a slow query + # with an error about regular expressions, and the asynchronous exception + # could land inside the driver's socket read, leaving the connection with + # unconsumed bytes. Nothing is fetched now: the scan sees only what the + # association already holds. + stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) + + expect do + owner.posts.delete_all(title: { '$regex' => '\Azzz' }) + end.not_to raise_error + + expect(load_query).not_to have_received(:each) + end + + it 'runs no pattern against documents it never loaded' do + # The counterpart to the referenced-association examples below, where the + # documents are in memory and the pattern is run against every one of + # them. Here there is nothing to run it against, and the server applies + # the same selector for the delete, so nothing is missed by not looking. + expect(Mongoid::Matcher::RegexpBudget).not_to receive(:match?) + + owner.posts.delete_all(title: { '$regex' => '\Azzz' }) + end + + it 'still removes the documents that match' do + expect(owner.posts.delete_all(title: { '$regex' => '\ATest' })).to eq(1) + expect(Post.count).to eq(0) + expect(owner.posts.size).to eq(0) + end + end + + context 'when removing documents from a referenced association' do + config_override :in_memory_regexp_time_limit, nil + + let!(:owner) { Person.create! } + + before do + 30.times { owner.posts.create!(title: slow_street) } + Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit + end + + it 'raises rather than scanning every loaded document' do + expect do + owner.posts.delete_all(title: { '$regex' => slow_pattern }) + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'leaves the documents in place' do + expect do + owner.posts.delete_all(title: { '$regex' => slow_pattern }) + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + + expect(Post.count).to eq(30) + end + end +end diff --git a/spec/integration/query_operator_guard_spec.rb b/spec/integration/query_operator_guard_spec.rb new file mode 100644 index 0000000000..eb25cedfc4 --- /dev/null +++ b/spec/integration/query_operator_guard_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'query operator injection guard' do + let(:js) { 'this.name == "admin"' } + + let(:nested_function) do + { '$expr' => { '$function' => { 'body' => 'function() { return true; }', 'args' => [], 'lang' => 'js' } } } + end + + let(:band) { Band.create!(name: 'Depeche Mode') } + + context 'with default configuration' do + it 'rejects unsafe operators without any opt-out' do + expect(Mongoid.allow_unsafe_query_operators).to be false + end + + context 'when querying with where' do + it 'rejects a top-level $where' do + expect { Band.where('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery) + end + + it 'rejects a $function nested in $expr' do + expect { Band.where(nested_function).first }.to raise_error(Mongoid::Errors::InvalidQuery) + end + + it 'permits an ordinary query' do + expect(Band.where(name: band.name).first).to eq(band) + end + end + + context 'when querying with find_by' do + it 'rejects a top-level $where' do + expect { Band.find_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery) + end + + it 'rejects a $function nested in $expr' do + expect { Band.find_by(nested_function) }.to raise_error(Mongoid::Errors::InvalidQuery) + end + end + + context 'when querying with find_or_create_by' do + it 'rejects a top-level $where' do + expect { Band.find_or_create_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery) + end + end + + context 'when querying with find_or_initialize_by' do + it 'rejects a top-level $where' do + expect { Band.find_or_initialize_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery) + end + end + + context 'when querying an association with find_or_create_by' do + it 'rejects a top-level $where' do + expect do + band.records.find_or_create_by('$where' => js) + end.to raise_error(Mongoid::Errors::InvalidQuery) + end + end + + context 'when querying with a logical operator' do + it 'rejects a $where smuggled through or' do + expect { Band.or('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery) + end + + it 'rejects a $where smuggled through any_of' do + expect { Band.any_of('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery) + end + end + end + + context 'when allow_unsafe_query_operators is true' do + config_override :allow_unsafe_query_operators, true + + it 'permits a top-level $where' do + expect(Band.where('$where' => "this.name == '#{band.name}'").first).to eq(band) + end + + it 'permits a $function nested in $expr' do + expect { Band.where(nested_function).first }.not_to raise_error + end + + it 'permits a $where smuggled through or' do + expect { Band.or('$where' => js).first }.not_to raise_error + end + end +end diff --git a/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb b/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb index 18b2691943..1af662ddf6 100644 --- a/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +++ b/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb @@ -2267,6 +2267,13 @@ end end + # Whether the conditions carry a pattern decides which branch of remove_all + # runs, so anything that has to behave the same on both is exercised twice. + conditions_variants = [ + [ 'no regular expression', nil ], + [ 'a regular expression', { name: /Test/ } ] + ].freeze + %i[ delete_all destroy_all ].each do |method| describe "\##{method}" do context 'when the relation is not polymorphic' do @@ -2338,6 +2345,47 @@ expect(person.preferences).to eq([]) end end + + context 'when the base holds the foreign keys' do + # unbind_one pulls ids out of the base's foreign key array and marks + # the base dirty, so which documents get unbound is visible here. + # Only what the association already held is unbound, and that has to + # hold whether or not the conditions carry a regular expression: + # scanning under a regexp budget loads the association first, and + # that load must not turn into an unbind of its own. See the note in + # HasMany::Proxy#remove_all_bounded. + let!(:person) do + Person.create!.tap do |base| + base.preferences.create!(name: 'Testing') + base.preferences.create!(name: 'Test') + end + end + + let(:ids) { person.preference_ids.dup } + + conditions_variants.each do |description, conditions| + context "when the conditions carry #{description}" do + context 'when the association is already in memory' do + it 'unbinds the deleted documents' do + person.preferences.send(method, conditions) + expect(person.preference_ids).to eq([]) + expect(person).to be_changed + end + end + + context 'when the association has not been loaded' do + let(:cold) { Person.find(person._id) } + + it 'leaves the foreign keys alone' do + expect(ids.length).to eq(2) + cold.preferences.send(method, conditions) + expect(cold.preference_ids).to eq(ids) + expect(cold).not_to be_changed + end + end + end + end + end end end end diff --git a/spec/mongoid/association/referenced/has_many/proxy_spec.rb b/spec/mongoid/association/referenced/has_many/proxy_spec.rb index 1e9f0d1b21..da371fad09 100644 --- a/spec/mongoid/association/referenced/has_many/proxy_spec.rb +++ b/spec/mongoid/association/referenced/has_many/proxy_spec.rb @@ -1832,6 +1832,151 @@ def with_transaction_via(model, &block) expect(person.posts).to eq([]) end end + + context 'when a document is appended with an id that is already loaded' do + # Iterating the association moves an appended document out of _added + # without adding it to _loaded, so the scan for matches and the + # removal that follows see different instances for the same id. + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'Testing') } + let(:reloaded) { Person.find(person._id) } + + before do + reloaded.posts.to_a + reloaded.posts._target.push( + Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id) + ) + end + + shared_examples 'removes the document once' do + it 'sets the association locally' do + reloaded.posts.send(method, conditions) + expect(reloaded.posts).to eq([]) + end + + it 'deletes the documents from the database' do + reloaded.posts.send(method, conditions) + expect(Post.count).to eq(0) + end + end + + context 'when the conditions carry no regular expression' do + let(:conditions) { { title: 'Testing' } } + + it_behaves_like 'removes the document once' + end + + context 'when the conditions carry a regular expression' do + let(:conditions) { { title: /Testing/ } } + + it_behaves_like 'removes the document once' + end + end + + context 'when an appended document disagrees with the loaded one' do + # Only the appended instance matches, so the removal has to drop that + # id from both _loaded and _added. Removing by id and stopping at the + # first hash that has it would take the loaded instance instead and + # leave the unbound one behind. + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'Other') } + let(:reloaded) { Person.find(person._id) } + + before do + reloaded.posts.to_a + reloaded.posts._target.push( + Post.new(_id: post._id, title: 'Testing', person_id: reloaded._id) + ) + end + + it 'deletes nothing from the database' do + expect(reloaded.posts.send(method, { title: /Testing/ })).to eq(0) + expect(Post.where(title: 'Other').count).to eq(1) + end + + it 'leaves no unbound document in the association' do + reloaded.posts.send(method, { title: /Testing/ }) + expect(reloaded.posts._target.in_memory.map(&:person_id)).not_to include(nil) + end + end + + context 'when a nested selector is evaluated while the association loads' do + # Loading the association runs find callbacks, and application code in + # one of those can evaluate a selector of its own. A scope opened + # around the load would mark it as having nothing to bound, and that + # suppresses the decision for the nested selector too, leaving any + # pattern in it unguarded. + config_override :in_memory_regexp_time_limit, 5.0 + + let(:person) { Person.create! } + let(:reloaded) { Person.find(person._id) } + + before do + person.posts.create!(title: 'Testing') + person.posts.create!(title: 'Keep') + reloaded + end + + it 'leaves the nested scope free to establish its own budget' do + seen = [] + allow(Mongoid::Factory).to receive(:from_db).and_wrap_original do |original, *args| + Mongoid::Matcher::RegexpBudget.open('title' => /Testing/) do + seen << Mongoid::Matcher::RegexpBudget.remaining + end + original.call(*args) + end + + reloaded.posts.send(method, { title: 'Testing' }) + + expect(seen).not_to be_empty + expect(seen).not_to include(nil) + end + end + + if method == :delete_all + context 'when the association has not been loaded' do + let(:person) { Person.create! } + let(:reloaded) { Person.find(person._id) } + let(:subscriber) { Mrss::EventSubscriber.new } + + before do + person.posts.create!(title: 'Testing') + person.posts.create!(title: 'Test') + reloaded + end + + def commands_for(conditions) + Person.collection.client.subscribe(Mongo::Monitoring::COMMAND, subscriber) + reloaded.posts.delete_all(conditions) + subscriber.started_events.map(&:command_name) + ensure + Person.collection.client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber) + end + + context 'when the conditions carry no regular expression' do + # Nothing to bound, so the scan for matches stays where it was, + # after the delete, where the association it loads comes back + # empty. Moving it ahead of the delete would transfer the whole + # association for an operation that need send nothing over the + # wire. + it 'deletes before loading the association' do + expect(commands_for(nil)).to eq(%w[ delete find ]) + end + end + + context 'when the conditions carry a regular expression' do + # The scan has to run under a budget, and before anything is + # deleted, so that a budget which runs out leaves the database + # untouched. What it scans is what is already in memory: loading + # would trade the matching cost the budget exists to bound for an + # unbounded amount of memory, since /.*/ is both cheap to supply + # and matches every document in the association. + it 'deletes without loading the association' do + expect(commands_for({ title: /Test/ })).to eq(%w[ delete ]) + end + end + end + end end context 'when the association is polymorphic' do diff --git a/spec/mongoid/attributes/nested_spec.rb b/spec/mongoid/attributes/nested_spec.rb index 771e31ac04..b47a74104c 100644 --- a/spec/mongoid/attributes/nested_spec.rb +++ b/spec/mongoid/attributes/nested_spec.rb @@ -8,11 +8,38 @@ require_relative './nested_spec_models' describe Mongoid::Attributes::Nested do + # Expects the enclosing context to define `build_with_id`, taking an id + # value and returning the document built with it in nested attributes. + shared_examples 'rejects non-scalar ids' do |klass_name| + not_found = /not found for class #{klass_name}/ + + context 'when the id is an operator hash' do + it 'raises a document not found error' do + expect { build_with_id({ '$ne' => nil }) } + .to raise_error(Mongoid::Errors::DocumentNotFound, not_found) + end + end + + context 'when the id is an array' do + it 'raises a document not found error' do + expect { build_with_id([ BSON::ObjectId.new ]) } + .to raise_error(Mongoid::Errors::DocumentNotFound, not_found) + end + end + + context 'when the id is a malformed object id hash' do + it 'raises a document not found error' do + expect { build_with_id({ '$oid' => 'junk' }) } + .to raise_error(Mongoid::Errors::DocumentNotFound, not_found) + end + end + end describe ".accepts_nested_attributes_for" do context "when the autosave option is not defined" do + let(:person) do Person.new end @@ -202,6 +229,30 @@ end end end + + context 'when the id in the attributes is not a scalar' do + let(:person) { Person.create! } + + def build_with_id(id) + person.update!(posts_attributes: { '0' => { id: id, title: 'Crafted' } }) + end + + context 'when the association is empty' do + include_examples 'rejects non-scalar ids', 'Post' + end + + context 'when the association is not empty' do + before { person.posts.create!(title: 'Existing') } + + include_examples 'rejects non-scalar ids', 'Post' + end + + context 'when allow_reparenting_via_nested_attributes is true' do + config_override :allow_reparenting_via_nested_attributes, true + + include_examples 'rejects non-scalar ids', 'Post' + end + end end context 'when the relation is a has-and-belongs-to-many' do @@ -226,16 +277,100 @@ ) end - it "sets the nested attributes" do - expect(person.preferences.map(&:name)).to eq([preference.name]) + context 'when allow_reparenting_via_nested_attributes is false' do + config_override :allow_reparenting_via_nested_attributes, false + + it 'raises a document not found error' do + expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound, + /Document\(s\) not found for class Preference/) + end end - it "updates attributes of existing document which is added to relation" do - preference_name = 'updated preference' - person = Person.new( - preferences_attributes: { 0 => { id: preference.id, name: preference_name } } - ) - expect(person.preferences.map(&:name)).to eq([preference_name]) + context 'when allow_reparenting_via_nested_attributes is true' do + config_override :allow_reparenting_via_nested_attributes, true + + it 'sets the nested attributes' do + expect(person.preferences.map(&:name)).to eq([ preference.name ]) + end + + it 'updates attributes of existing document which is added to relation' do + preference_name = 'updated preference' + person = Person.new( + preferences_attributes: { 0 => { id: preference.id, name: preference_name } } + ) + expect(person.preferences.map(&:name)).to eq([ preference_name ]) + end + + context 'when the id does not correspond to an existing document' do + let(:person) do + Person.new( + preferences_attributes: { 0 => { id: BSON::ObjectId.new, name: 'Ghost' } } + ) + end + + context 'when raise_not_found_error is true' do + config_override :raise_not_found_error, true + + it 'raises a document not found error' do + expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound, + /Document\(s\) not found for class Preference/) + end + end + + context 'when raise_not_found_error is false' do + config_override :raise_not_found_error, false + + it 'raises a document not found error' do + expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound, + /Document\(s\) not found for class Preference/) + end + end + end + end + + context 'when _destroy is true for a document not in the relation' do + before do + Person.send(:undef_method, :preferences_attributes=) + Person.accepts_nested_attributes_for :preferences, allow_destroy: true + end + + let(:person) do + Person.new( + preferences_attributes: { 0 => { id: preference.id, _destroy: '1' } } + ) + end + + it 'does not raise UnknownAttribute' do + expect { person }.not_to raise_error + end + + it 'does not add the document to the relation' do + expect(person.preferences).to be_empty + end + end + end + + context 'when the id in the attributes is not a scalar' do + let(:target) { Person.create! } + + def build_with_id(id) + target.update!(preferences_attributes: { 0 => { id: id, name: 'Crafted' } }) + end + + context 'when the association is empty' do + include_examples 'rejects non-scalar ids', 'Preference' + end + + context 'when the association is not empty' do + before { target.preferences.create!(name: 'Existing') } + + include_examples 'rejects non-scalar ids', 'Preference' + end + + context 'when allow_reparenting_via_nested_attributes is true' do + config_override :allow_reparenting_via_nested_attributes, true + + include_examples 'rejects non-scalar ids', 'Preference' end end end @@ -3041,7 +3176,7 @@ class BandWithAllowDestroyedRecords < Band { '0' => { 'id' => BSON::ObjectId.new.to_s, 'title' => 'Rogue' } } end.to raise_error(Mongoid::Errors::DocumentNotFound, - /Document\(s\) not found for class Post/) + /Document not found for class Post/) end end end @@ -3076,7 +3211,7 @@ class BandWithAllowDestroyedRecords < Band person.posts_attributes = { 'foo' => { 'id' => 'test', 'title' => 'Test' } } end.to raise_error(Mongoid::Errors::DocumentNotFound, - /Document\(s\) not found for class Post/) + /Document not found for class Post/) end end end @@ -5025,4 +5160,63 @@ class BandWithAllowDestroyedRecords < Band end end end + + context 'when an id in nested attributes belongs to another parent' do + let(:victim) { Person.create! } + let(:attacker) { Person.create! } + + context 'when the association is a has-many' do + before do + Person.send(:undef_method, :posts_attributes=) + Person.accepts_nested_attributes_for :posts + end + + let!(:victim_post) { victim.posts.create!(title: 'Private') } + + it 'does not update the other parent\'s document' do + expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) } + .to raise_error(Mongoid::Errors::DocumentNotFound) + expect(victim_post.reload.title).to eq('Private') + end + + it 'does not add the other parent\'s document to the association' do + expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) } + .to raise_error(Mongoid::Errors::DocumentNotFound) + expect(attacker.reload.posts).to be_empty + end + + it 'does not update via a direct association assignment' do + expect { attacker.update!(posts: { '0' => { id: victim_post.id, title: 'Overwritten' } }) } + .to raise_error(Mongoid::Errors::DocumentNotFound) + expect(victim_post.reload.title).to eq('Private') + end + end + + context 'when the association is a has-and-belongs-to-many' do + before do + Person.send(:undef_method, :preferences_attributes=) + Person.accepts_nested_attributes_for :preferences + end + + let!(:victim_preference) { victim.preferences.create!(name: 'Private') } + + it 'does not update the other parent\'s document' do + expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) } + .to raise_error(Mongoid::Errors::DocumentNotFound) + expect(victim_preference.reload.name).to eq('Private') + end + + it 'does not add the other parent\'s document to the association' do + expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) } + .to raise_error(Mongoid::Errors::DocumentNotFound) + expect(attacker.reload.preferences).to be_empty + end + + it 'does not update via a direct association assignment' do + expect { attacker.update!(preferences: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) } + .to raise_error(Mongoid::Errors::DocumentNotFound) + expect(victim_preference.reload.name).to eq('Private') + end + end + end end diff --git a/spec/mongoid/config/defaults_spec.rb b/spec/mongoid/config/defaults_spec.rb index cbf8904e42..68dac94ec3 100644 --- a/spec/mongoid/config/defaults_spec.rb +++ b/spec/mongoid/config/defaults_spec.rb @@ -41,6 +41,18 @@ end end + shared_examples 'uses settings for 9.0' do + it 'uses settings for 9.0' do + expect(Mongoid.autosave_saves_unchanged_documents).to be true + end + end + + shared_examples 'never allows reparenting via nested attributes' do + it 'leaves allow_reparenting_via_nested_attributes false' do + expect(Mongoid.allow_reparenting_via_nested_attributes).to be false + end + end + context "when giving a valid version" do before do @@ -57,6 +69,8 @@ it_behaves_like "uses settings for 8.0" it_behaves_like "uses settings for 8.1" + it_behaves_like 'uses settings for 9.0' + it_behaves_like 'never allows reparenting via nested attributes' end context "when the given version is 8.1" do @@ -65,6 +79,8 @@ it_behaves_like "does not use settings for 8.0" it_behaves_like "uses settings for 8.1" + it_behaves_like 'uses settings for 9.0' + it_behaves_like 'never allows reparenting via nested attributes' end context "when the given version is 9.0" do @@ -73,6 +89,8 @@ it_behaves_like "does not use settings for 8.0" it_behaves_like "does not use settings for 8.1" + it_behaves_like 'uses settings for 9.0' + it_behaves_like 'never allows reparenting via nested attributes' end end diff --git a/spec/mongoid/config/encryption_spec.rb b/spec/mongoid/config/encryption_spec.rb index 66bf608300..4b5c0481c0 100644 --- a/spec/mongoid/config/encryption_spec.rb +++ b/spec/mongoid/config/encryption_spec.rb @@ -75,6 +75,19 @@ end end + # Models are registered in class load order, and Rails eager loads + # app/models alphabetically, so a child whose file name sorts before + # its parent's arrives first. + context 'when the embedded model comes before its parent' do + let(:models) do + [ Crypt::Insurance, Crypt::Patient ] + end + + it 'returns a map of encryption schemas' do + expect(encryption_schema_map).to eq(expected_schema_map) + end + end + context 'and fields do not have encryption options' do let(:models) do [Crypt::Car] @@ -109,6 +122,7 @@ let(:expected_schema_map) do { "mongoid_test.crypt_users" => { + 'bsonType' => 'object', "properties" => { "name" => { "encrypt" => { @@ -148,5 +162,219 @@ expect(encryption_schema_map).to eq({}) end end + + # A field left out of the schema map is written in plaintext, with no + # exception and nothing logged, so an omission here is silent data + # exposure rather than a broken feature. + context 'when the encrypted fields are on an embedded model' do + let(:token_properties) do + { + 'bsonType' => 'object', + 'properties' => { + 'value' => { + 'encrypt' => { + 'bsonType' => 'string', + 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + } + } + } + end + + context 'when two models embed the same encrypted model' do + let(:models) do + [ Crypt::Vault, Crypt::Chest ] + end + + it 'maps the embedded model for the first parent' do + expect(encryption_schema_map.dig('mongoid_test.crypt_vaults', 'properties', 'token')) + .to eq(token_properties) + end + + it 'maps the embedded model for the second parent' do + expect(encryption_schema_map.dig('mongoid_test.crypt_chests', 'properties', 'token')) + .to eq(token_properties) + end + end + + context 'when one model embeds the same encrypted model twice' do + let(:models) do + [ Crypt::Ledger ] + end + + it 'maps the first relation' do + expect(encryption_schema_map.dig('mongoid_test.crypt_ledgers', 'properties', 'primary_token')) + .to eq(token_properties) + end + + it 'maps the second relation' do + expect(encryption_schema_map.dig('mongoid_test.crypt_ledgers', 'properties', 'backup_token')) + .to eq(token_properties) + end + end + + # relation_class constantizes, and a polymorphic embedded_in has no class + # to resolve, so the walk has to look at the relation type first. + context 'when the embedded model is embedded polymorphically' do + let(:models) do + [ Crypt::Vault ] + end + + it 'does not raise' do + expect { encryption_schema_map }.not_to raise_error + end + end + + context 'when the embedded model comes before its parent' do + let(:models) do + [ Crypt::Token, Crypt::Vault ] + end + + it 'maps the embedded model' do + expect(encryption_schema_map.dig('mongoid_test.crypt_vaults', 'properties', 'token')) + .to eq(token_properties) + end + end + + context 'when the parent has no encrypted field of its own' do + let(:models) do + [ Crypt::Wallet, Crypt::Token ] + end + + it 'maps the embedded model' do + expect(encryption_schema_map.dig('mongoid_test.crypt_wallets', 'properties', 'token')) + .to eq(token_properties) + end + end + + context 'when the encrypted model is nested three levels deep' do + let(:account_properties) do + { + 'bsonType' => 'object', + 'properties' => { + 'credential' => { + 'bsonType' => 'object', + 'properties' => { + 'secret' => { + 'encrypt' => { + 'bsonType' => 'string', + 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + } + } + } + } + } + end + + context 'when the models are listed outermost first' do + let(:models) do + [ Crypt::Owner, Crypt::Account, Crypt::Credential ] + end + + it 'maps the whole subtree' do + expect(encryption_schema_map.dig('mongoid_test.crypt_owners', 'properties', 'account')) + .to eq(account_properties) + end + end + + context 'when the middle model comes first' do + let(:models) do + [ Crypt::Account, Crypt::Owner, Crypt::Credential ] + end + + it 'maps the whole subtree' do + expect(encryption_schema_map.dig('mongoid_test.crypt_owners', 'properties', 'account')) + .to eq(account_properties) + end + end + end + + # The walk has to keep stopping at a model it is already inside of, + # otherwise a self-embedding model recurses forever. + context 'when a model embeds itself' do + let(:models) do + [ Crypt::Article ] + end + + it 'terminates' do + expect { encryption_schema_map }.not_to raise_error + end + + it 'maps the embedded model without descending into the cycle' do + expect(encryption_schema_map.dig('mongoid_test.crypt_articles', 'properties', 'comment')).to eq( + 'bsonType' => 'object', + 'properties' => { + 'body' => { + 'encrypt' => { + 'bsonType' => 'string', + 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + } + } + ) + end + end + end + + context 'when an encrypted model has a callable database name' do + let(:models) do + [ Crypt::DynamicCar ] + end + + it 'does not build a key from the unresolved callable' do + expect(encryption_schema_map.keys.grep(/Proc/)).to be_empty + end + + it 'does not build a key that the model can never write to' do + expect(encryption_schema_map.keys).to all(satisfy { |key| key.count('.') == 1 }) + end + end + + context 'when an encrypted model has a callable database name that needs a tenant' do + let(:models) do + [ Crypt::TenantCar ] + end + + # Resolving the callable while the map is built is not a fix: the + # documented multi-tenant idiom has no correct value at client + # construction time. Generating the map must not depend on it. + it 'does not raise' do + expect { encryption_schema_map }.not_to raise_error + end + + it 'does not pin the map to whichever tenant happens to be current' do + Thread.current[:tenant_database] = 'tenant_a' + expect(encryption_schema_map.keys).not_to include('tenant_a.crypt_tenant_cars') + ensure + Thread.current[:tenant_database] = nil + end + end + + # The map is generated over every model in the application, and a model may + # name an embedded class that is never defined. Resolving that name raises, + # which would take down every client build. + context 'when a model names an embedded class that is not defined' do + let(:models) do + [ Crypt::Drawer, Crypt::Cabinet ] + end + + it 'does not raise' do + expect { encryption_schema_map }.not_to raise_error + end + + it 'leaves out the model with nothing to encrypt' do + expect(encryption_schema_map.keys).not_to include('mongoid_test.crypt_drawers') + end + + it 'still maps the encrypted fields of the model itself' do + expect(encryption_schema_map.dig('mongoid_test.crypt_cabinets', 'properties', 'label')).to eq( + 'encrypt' => { + 'bsonType' => 'string', + 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ) + end + end end end diff --git a/spec/mongoid/contextual/aggregable/memory_spec.rb b/spec/mongoid/contextual/aggregable/memory_spec.rb index 804bbce8d5..ceacb4e3c6 100644 --- a/spec/mongoid/contextual/aggregable/memory_spec.rb +++ b/spec/mongoid/contextual/aggregable/memory_spec.rb @@ -582,4 +582,95 @@ end end end + + context 'when the field name is a method name' do + let!(:depeche) do + Band.create!(name: 'Depeche Mode', likes: 1000) + end + + let(:criteria) do + Band.all.tap do |crit| + crit.documents = [ depeche ] + end + end + + it 'does not call the method for sum' do + expect(depeche).not_to receive(:delete) + expect(context.sum(:delete)).to eq(0) + end + + it 'does not call the method for min' do + expect(depeche).not_to receive(:delete) + expect(context.min(:delete)).to be_nil + end + + it 'does not call the method for max' do + expect(depeche).not_to receive(:delete) + expect(context.max(:delete)).to be_nil + end + + it 'does not call the method for avg' do + expect(depeche).not_to receive(:delete) + expect(context.avg(:delete)).to be_nil + end + + it 'does not call the method for aggregates' do + expect(depeche).not_to receive(:delete) + expect(context.aggregates(:delete)) + .to eq('count' => 0, 'avg' => nil, 'max' => nil, 'min' => nil, 'sum' => 0) + end + + it 'does not call a private method for avg' do + expect(depeche).not_to receive(:exit) + expect(context.avg(:exit)).to be_nil + end + end + + context 'when the field is not defined' do + let!(:depeche) do + Band.create!(name: 'Depeche Mode', likes: 1000) + end + + let(:criteria) do + Band.all.tap do |crit| + crit.documents = [ depeche ] + end + end + + it 'returns zero for sum' do + expect(context.sum(:not_a_field)).to eq(0) + end + + it 'returns nil for min' do + expect(context.min(:not_a_field)).to be_nil + end + + it 'returns nil for max' do + expect(context.max(:not_a_field)).to be_nil + end + + it 'returns nil for avg' do + expect(context.avg(:not_a_field)).to be_nil + end + end + + context 'when the field is the foreign key of a many to many association' do + let!(:preference) do + Preference.create!(name: 'nature') + end + + let!(:person) do + Person.new(preferences: [ preference ]) + end + + let(:criteria) do + Person.all.tap do |crit| + crit.documents = [ person ] + end + end + + it 'aggregates the stored ids' do + expect(context.max(:preference_ids)).to eq([ preference.id ]) + end + end end diff --git a/spec/mongoid/contextual/memory_spec.rb b/spec/mongoid/contextual/memory_spec.rb index d45038f502..f49cc86d92 100644 --- a/spec/mongoid/contextual/memory_spec.rb +++ b/spec/mongoid/contextual/memory_spec.rb @@ -588,6 +588,27 @@ expect(context.distinct("label.sales")).to eq([ BigDecimal("1E2") ]) end end + + context 'when the field name is a method name' do + let!(:person) do + Person.create!(ssn: 'secret-ssn') + end + + let!(:address) do + person.addresses.create!(street: 'hobrecht') + end + + let(:criteria) do + Address.all.tap do |crit| + crit.documents = [ address ] + end + end + + it 'does not call the method' do + expect(address).not_to receive(:destroy) + expect(context.distinct(:destroy)).to eq([ nil ]) + end + end end describe "#each" do @@ -1826,6 +1847,116 @@ ]) end end + + context 'when plucking a dynamic attribute' do + let(:criteria) do + Band.all.tap do |crit| + crit.documents = [ Band.create!(name: 'Depeche Mode', mood: 'dark') ] + end + end + + it 'returns the value from the attributes' do + expect(context.pluck(:mood)).to eq([ 'dark' ]) + end + end + + context 'when the field name is a method name' do + let!(:person) do + Person.create!(ssn: 'secret-ssn') + end + + let!(:address) do + person.addresses.create!(street: 'hobrecht') + end + + let(:criteria) do + Address.all.tap do |crit| + crit.documents = [ address ] + end + end + + it 'does not call the method' do + expect(address).not_to receive(:destroy) + expect(context.pluck(:destroy)).to eq([ nil ]) + end + + it 'does not return the attributes hash' do + expect(context.pluck(:attributes)).to eq([ nil ]) + end + + it 'does not traverse to the parent document' do + expect(context.pluck('_root.ssn')).to eq([ nil ]) + end + + it 'does not destroy the parent document' do + expect(context.pluck('_root.destroy')).to eq([ nil ]) + expect(Person.where(_id: person.id).count).to eq(1) + end + end + + context 'when plucking a belongs_to association' do + let!(:band) do + Band.create!(name: 'Depeche Mode') + end + + let!(:artist) do + Artist.create!(band: band) + end + + let(:criteria) do + Artist.all.tap do |crit| + crit.documents = [ artist ] + end + end + + it 'traverses to the associated document' do + expect(context.pluck('band.name')).to eq([ 'Depeche Mode' ]) + end + + it 'returns the document for the association name' do + expect(context.pluck(:band)).to eq([ band ]) + end + + it 'returns the id for the foreign key' do + expect(context.pluck(:band_id)).to eq([ band.id ]) + end + end + + context 'when plucking the foreign key of a many to many association' do + let!(:preference) do + Preference.create!(name: 'nature') + end + + let!(:person) do + Person.create!(preferences: [ preference ]) + end + + let(:criteria) do + Person.all.tap do |crit| + crit.documents = [ person ] + end + end + + it 'returns the stored ids' do + expect(context.pluck(:preference_ids)).to eq([ [ preference.id ] ]) + end + end + + context 'when the field path has an empty segment' do + let!(:band) do + Band.create!(name: 'Depeche Mode', label: Label.new(name: 'Mute')) + end + + let(:criteria) do + Band.all.tap do |crit| + crit.documents = [ band ] + end + end + + it 'returns nil' do + expect(context.pluck('label..name')).to eq([ nil ]) + end + end end describe "#pick" do @@ -1886,6 +2017,27 @@ expect(picked).to be_nil end end + + context 'when the field name is a method name' do + let!(:person) do + Person.create!(ssn: 'secret-ssn') + end + + let!(:address) do + person.addresses.create!(street: 'hobrecht') + end + + let(:criteria) do + Address.all.tap do |crit| + crit.documents = [ address ] + end + end + + it 'does not call the method' do + expect(address).not_to receive(:destroy) + expect(context.pick(:destroy)).to be_nil + end + end end describe "#tally" do @@ -2340,6 +2492,31 @@ ) end end + + context 'when the field name is a method name' do + let!(:person) do + Person.create!(ssn: 'secret-ssn') + end + + let!(:address) do + person.addresses.create!(street: 'hobrecht') + end + + let(:criteria) do + Address.all.tap do |crit| + crit.documents = [ address ] + end + end + + it 'does not call the method' do + expect(address).not_to receive(:destroy) + expect(context.tally(:destroy)).to eq(nil => 1) + end + + it 'does not disclose the parent document fields' do + expect(context.tally('_root.ssn')).to eq(nil => 1) + end + end end describe '#inc' do diff --git a/spec/mongoid/criteria/queryable/selectable_logical_spec.rb b/spec/mongoid/criteria/queryable/selectable_logical_spec.rb index 4ac7c4f73b..f5bbd65e4d 100644 --- a/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +++ b/spec/mongoid/criteria/queryable/selectable_logical_spec.rb @@ -1830,6 +1830,8 @@ end context 'when the following criteria uses string were form' do + # String criteria compile to $where, which requires the opt-in. + config_override :allow_unsafe_query_operators, true let(:selection) do query.not.where('hello world') diff --git a/spec/mongoid/criteria/queryable/selectable_where_spec.rb b/spec/mongoid/criteria/queryable/selectable_where_spec.rb index d5dbf1a29e..2963acd2a7 100644 --- a/spec/mongoid/criteria/queryable/selectable_where_spec.rb +++ b/spec/mongoid/criteria/queryable/selectable_where_spec.rb @@ -36,6 +36,8 @@ it_behaves_like 'requires a non-nil argument' context "when provided a string" do + # String criteria compile to $where, which requires the opt-in. + config_override :allow_unsafe_query_operators, true let(:selection) do query.where("this.value = 10") @@ -587,4 +589,202 @@ def self.evolve(object) end end end + + describe 'top-level operator injection guard' do + context 'when allow_unsafe_query_operators is true' do + config_override :allow_unsafe_query_operators, true + + context 'when passing $where' do + it 'does not raise' do + expect do + query.where('$where' => 'this.name == "admin"') + end.not_to raise_error + end + end + + context 'when passing a string criterion' do + it 'does not raise' do + expect do + query.where('this.name == "admin"') + end.not_to raise_error + end + end + + context 'when passing a string criterion to a negated query' do + it 'does not raise' do + expect do + query.not.where('this.name == "admin"') + end.not_to raise_error + end + end + + context 'when passing $function' do + it 'does not raise' do + expect do + query.where('$function' => { body: 'function() { return true; }', args: [], lang: 'js' }) + end.not_to raise_error + end + end + end + + context 'when allow_unsafe_query_operators is false' do + config_override :allow_unsafe_query_operators, false + + context 'when passing $where' do + it 'raises InvalidQuery' do + expect do + query.where('$where' => 'this.name == "admin"') + end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/) + end + end + + context 'when passing $function' do + it 'raises InvalidQuery' do + expect do + query.where('$function' => { body: 'function() { return true; }', args: [], lang: 'js' }) + end.to raise_error(Mongoid::Errors::InvalidQuery, /\$function/) + end + end + + context 'when the error mentions allow_unsafe_query_operators' do + it 'includes the config opt-in in the message' do + expect do + query.where('$where' => 'true') + end.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/) + end + end + + context 'when passing a string criterion' do + it 'raises InvalidQuery' do + expect do + query.where('this.name == "admin"') + end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/) + end + + it 'includes the config opt-in in the message' do + expect do + query.where('this.name == "admin"') + end.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/) + end + end + + context 'when passing a string criterion to a negated query' do + it 'raises InvalidQuery' do + expect do + query.not.where('this.name == "admin"') + end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/) + end + end + + %w[$and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue].each do |op| + context "when passing #{op} (allowlisted)" do + it 'does not raise' do + value = case op + when '$and', '$or', '$nor', '$not' then [ { 'x' => 1 } ] + when '$text' then { '$search' => 'hi' } + when '$expr' then { '$gt' => [ '$a', 1 ] } + when '$jsonSchema' then { 'required' => [ 'x' ] } + else true + end + expect { query.where(op => value) }.not_to raise_error + end + end + end + end + end + + describe 'nested operator injection guard' do + js = 'this.name == "admin"' + + function_expr = { + '$function' => { 'body' => 'function() { return true; }', 'args' => [], 'lang' => 'js' } + }.freeze + + accumulator_expr = { + '$accumulator' => { + 'init' => 'function() { return 0; }', + 'accumulate' => 'function() { return 0; }', + 'accumulateArgs' => [], + 'merge' => 'function() { return 0; }', + 'lang' => 'js' + } + }.freeze + + # Every path into the selector that does not route through #expr_query, + # plus the nested forms that a top-level key check cannot see. + unsafe_queries = { + 'and with a $where key' => ->(query) { query.and('$where' => js) }, + 'or with a $where key' => ->(query) { query.or('$where' => js) }, + 'nor with a $where key' => ->(query) { query.nor('$where' => js) }, + 'not with a $where key' => ->(query) { query.not('$where' => js) }, + 'any_of with a $where key' => ->(query) { query.any_of('$where' => js) }, + 'none_of with a $where key' => ->(query) { query.none_of('$where' => js) }, + 'elem_match with a nested $where' => ->(query) { query.elem_match(a: { '$where' => js }) }, + 'where with $where inside $or' => ->(query) { query.where('$or' => [ { '$where' => js } ]) }, + 'where with $function inside $expr' => ->(query) { query.where('$expr' => function_expr) }, + 'where with $accumulator inside $expr' => ->(query) { query.where('$expr' => accumulator_expr) }, + 'where with $where three levels deep' => lambda { |query| + query.where('$and' => [ { '$or' => [ { '$where' => js } ] } ]) + }, + 'where with a symbol $where key inside $or' => ->(query) { query.where('$or' => [ { :$where => js } ]) } + }.freeze + + context 'when allow_unsafe_query_operators is false' do + config_override :allow_unsafe_query_operators, false + + unsafe_queries.each do |description, builder| + context "when querying with #{description}" do + it 'raises InvalidQuery' do + expect do + builder.call(query) + end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where|\$function|\$accumulator/) + end + end + end + + it 'includes the config opt-in in the message' do + expect do + query.or('$where' => js) + end.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/) + end + + context 'when the nested expression is safe' do + it 'permits $or with field expressions' do + expect do + query.where('$or' => [ { 'a' => 1 }, { 'b' => { '$gt' => 2 } } ]) + end.not_to raise_error + end + + it 'permits $expr with aggregation operators' do + expect do + query.where('$expr' => { '$gt' => [ '$a', '$b' ] }) + end.not_to raise_error + end + + it 'permits a field whose name resembles a javascript operator' do + expect do + query.where('where' => js) + end.not_to raise_error + end + + it 'permits a value that resembles a javascript operator' do + expect do + query.where('name' => '$where') + end.not_to raise_error + end + end + end + + context 'when allow_unsafe_query_operators is true' do + config_override :allow_unsafe_query_operators, true + + unsafe_queries.each do |description, builder| + context "when querying with #{description}" do + it 'does not raise' do + expect { builder.call(query) }.not_to raise_error + end + end + end + end + end end diff --git a/spec/mongoid/criteria_spec.rb b/spec/mongoid/criteria_spec.rb index 581cfe456f..49feef2b50 100644 --- a/spec/mongoid/criteria_spec.rb +++ b/spec/mongoid/criteria_spec.rb @@ -2432,6 +2432,8 @@ def self.ages; self; end end context "when the criteria is not embedded" do + # String criteria compile to $where, which requires the opt-in. + config_override :allow_unsafe_query_operators, true let(:criteria) do Band.where("this.name == 'Depeche Mode'") diff --git a/spec/mongoid/encryptable_spec.rb b/spec/mongoid/encryptable_spec.rb new file mode 100644 index 0000000000..1b700c0ee1 --- /dev/null +++ b/spec/mongoid/encryptable_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/crypt/models' + +describe Mongoid::Encryptable do + describe '.requires_encryption_schema?' do + context 'when the model declares encrypt_with' do + it 'returns true' do + expect(Crypt::Vault.requires_encryption_schema?).to be true + end + end + + context 'when the model has an encrypted field' do + it 'returns true' do + expect(Crypt::User.requires_encryption_schema?).to be true + end + end + + context 'when the model embeds an encrypted model' do + it 'returns true' do + expect(Crypt::Wallet.requires_encryption_schema?).to be true + end + end + + context 'when the encrypted model is nested three levels deep' do + it 'returns true' do + expect(Crypt::Owner.requires_encryption_schema?).to be true + end + end + + context 'when the model declares no encryption anywhere' do + it 'returns false' do + expect(Person.requires_encryption_schema?).to be false + end + end + + # An association target does not have to be a Mongoid document. Truck + # embeds a plain Ruby class. + context 'when an embedded association target is not a document' do + it 'returns false' do + expect(Truck.requires_encryption_schema?).to be false + end + end + + context 'when the model embeds itself' do + it 'terminates' do + expect(Crypt::Comment.requires_encryption_schema?).to be true + end + end + + # Every model is asked this question, including ones naming an embedded + # class that is never defined. Such an association cannot be used, so + # nothing is embedded through it and nothing needs encrypting. + context 'when an embedded association names a class that is not defined' do + it 'returns false' do + expect(Crypt::Drawer.requires_encryption_schema?).to be false + end + end + end +end diff --git a/spec/mongoid/matcher/regexp_budget_spec.rb b/spec/mongoid/matcher/regexp_budget_spec.rb new file mode 100644 index 0000000000..6dd5d95919 --- /dev/null +++ b/spec/mongoid/matcher/regexp_budget_spec.rb @@ -0,0 +1,570 @@ +# frozen_string_literal: true + +require 'benchmark' +require 'spec_helper' + +describe Mongoid::Matcher::RegexpBudget do + # Costly but bounded on every supported Ruby: unanchored, so every branch is + # tried at every position, and with no nested quantifier there is nothing to + # backtrack exponentially. What it costs still varies a lot by engine, about + # 6ms a match on MRI against 110ms on JRuby, so anything asserting on that + # cost calibrates rather than hard-coding it. + # + # A pattern built out of nested quantifiers would be wrong here. Those are + # merely slow only where Ruby memoizes matching, which arrived in 3.2; on + # 2.7 through 3.1 they backtrack exponentially and never finish. + let(:slow_pattern) { BSON::Regexp::Raw.new("(?:#{(1..300).map { |i| "a#{i}" }.join('|')})Z") } + let(:slow_subject) { 'a' * 5_000 } + + # Backtracks exponentially: the backreference disables memoization, so this + # runs orders of magnitude longer than the limit and has to be interrupted. + # + # The subject is kept short enough that the match still finishes on its own, + # in roughly 4.5 seconds against a 0.2 second limit. That way a Ruby whose + # engine does not check for interrupts mid-match fails these examples + # visibly instead of hanging the run. + let(:catastrophic_pattern) { BSON::Regexp::Raw.new('^(a+)+\1?$') } + let(:catastrophic_subject) { "#{'a' * 28}X" } + + let(:cheap_pattern) { BSON::Regexp::Raw.new('\Aabc\z') } + + # A budget is only opened for a selector that carries a pattern, so anything + # exercising one has to hand .open something to bound. + let(:regexp_selector) { { 'name' => /\Aabc\z/ } } + + describe '.open' do + context 'when no limit is configured' do + config_override :in_memory_regexp_time_limit, nil + + it 'does not establish a budget' do + described_class.open(regexp_selector) do + expect(described_class.remaining).to be_nil + end + end + end + + context 'when the configured limit is zero' do + config_override :in_memory_regexp_time_limit, 0 + + # Zero is a common way to spell "disabled". Taken literally it would mean + # a budget spent before the first match, so every in-memory query + # carrying a pattern would raise rather than run. + it 'does not establish a budget' do + described_class.open(regexp_selector) do + expect(described_class.remaining).to be_nil + end + end + + it 'still performs matches' do + result = described_class.open(regexp_selector) do + described_class.match?('abc', cheap_pattern) + end + + expect(result).to eq(0) + end + end + + context 'when a limit is configured' do + config_override :in_memory_regexp_time_limit, 5.0 + + it 'establishes the budget for the duration of the block' do + described_class.open(regexp_selector) do + expect(described_class.remaining).to be_within(0.01).of(5.0) + end + end + + it 'returns the value of the block' do + expect(described_class.open(regexp_selector) { :result }).to eq(:result) + end + + it 'clears the budget when the block returns' do + described_class.open(regexp_selector) { nil } + expect(described_class.remaining).to be_nil + end + + it 'clears the budget when the block raises' do + expect { described_class.open(regexp_selector) { raise 'boom' } }.to raise_error('boom') + expect(described_class.remaining).to be_nil + end + + it 'joins the enclosing budget rather than starting a new one' do + described_class.open(regexp_selector) do + described_class.match?(slow_subject, slow_pattern) + spent = 5.0 - described_class.remaining + expect(spent).to be > 0 + + described_class.open(regexp_selector) do + expect(described_class.remaining).to be_within(0.01).of(5.0 - spent) + end + end + end + + context 'when the selector carries no regular expression' do + it 'does not establish a budget' do + described_class.open('name' => 'abc') do + expect(described_class.remaining).to be_nil + end + end + + it 'does not put a deadline on the block' do + # The limit bounds regular expressions, not in-memory work at large. + # On the Timeout path a scope with nothing to bound used to fail any + # slow scan -- an embedded association of any size, say -- with an + # error about regular expressions. + stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) + Mongoid::Config.in_memory_regexp_time_limit = 0.2 + + expect do + described_class.open('name' => 'abc') { sleep 0.3 } + end.not_to raise_error + end + + it 'keeps a nested scope from scanning the selector again' do + described_class.open('name' => 'abc') do + expect(Mongoid::Threaded.has?(Mongoid::Threaded::REGEXP_BUDGET_KEY)).to be(true) + end + end + end + + context 'when the selector carries a string $regex' do + # FieldExpression turns it into a pattern at match time, so it needs + # bounding just as much as a Regexp written out in the selector does. + it 'establishes a budget' do + described_class.open('name' => { '$regex' => 'abc' }) do + expect(described_class.remaining).to be_within(0.01).of(5.0) + end + end + end + + context 'when the selector nests a regular expression' do + it 'establishes a budget' do + described_class.open('$or' => [ { 'name' => 'abc' }, { 'name' => /abc/ } ]) do + expect(described_class.remaining).to be_within(0.01).of(5.0) + end + end + end + end + end + + describe '.limit_for' do + context 'when a limit is configured' do + config_override :in_memory_regexp_time_limit, 5.0 + + it 'is the limit for a selector carrying a regular expression' do + expect(described_class.limit_for(regexp_selector)).to eq(5.0) + end + + it 'is the limit for a selector carrying a string $regex' do + expect(described_class.limit_for('name' => { '$regex' => 'abc' })).to eq(5.0) + end + + it 'is the limit for a selector nesting a regular expression' do + expect(described_class.limit_for('$or' => [ { 'name' => 'abc' }, { 'name' => /abc/ } ])).to eq(5.0) + end + + it 'is nil for a selector carrying no regular expression' do + expect(described_class.limit_for('name' => 'abc')).to be_nil + end + + it 'is nil for an empty selector' do + expect(described_class.limit_for({})).to be_nil + end + end + + context 'when no limit is configured' do + config_override :in_memory_regexp_time_limit, nil + + # Nothing would be bounded, so a caller that only rearranges its work to + # make room for a budget has no reason to. + it 'is nil even for a selector carrying a regular expression' do + expect(described_class.limit_for(regexp_selector)).to be_nil + end + end + + context 'when the configured limit is zero' do + config_override :in_memory_regexp_time_limit, 0 + + it 'is nil even for a selector carrying a regular expression' do + expect(described_class.limit_for(regexp_selector)).to be_nil + end + end + end + + describe '.open_with' do + config_override :in_memory_regexp_time_limit, 5.0 + + it 'establishes a budget for the limit it is given, not the configured one' do + described_class.open_with(2.0) do + expect(described_class.remaining).to be_within(0.01).of(2.0) + end + end + + it 'returns the value of the block' do + expect(described_class.open_with(2.0) { :result }).to eq(:result) + end + + it 'clears the budget when the block returns' do + described_class.open_with(2.0) { nil } + expect(described_class.remaining).to be_nil + end + + context 'when given no limit' do + it 'does not establish a budget' do + described_class.open_with(nil) do + expect(described_class.remaining).to be_nil + end + end + + # A caller that read the limit, found nothing to bound and arranged its + # work accordingly must not have a deadline imposed on it by a later read + # of the same setting. On the Timeout path that deadline would cover + # whatever the caller went on to do, which here is a query and a mutation + # of every match. + it 'does not put a deadline on the block' do + stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) + Mongoid::Config.in_memory_regexp_time_limit = 0.2 + + expect do + described_class.open_with(nil) { sleep 0.3 } + end.not_to raise_error + end + + it 'leaves an enclosing budget alone' do + described_class.open(regexp_selector) do + described_class.open_with(nil) do + expect(described_class.remaining).to be_within(0.01).of(5.0) + end + end + end + end + end + + describe '.match?' do + context 'when no budget is open' do + it 'performs the match' do + expect(described_class.match?('abc', cheap_pattern)).to eq(0) + end + + it 'returns nil when the value does not match' do + expect(described_class.match?('xyz', cheap_pattern)).to be_nil + end + + it "leaves an application's own Regexp timeout alone" do + # With nothing of ours to blame it on, translating the error would name + # a limit that is not set and advise unsetting it. + skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT + + pattern = Regexp.new(catastrophic_pattern.pattern, timeout: 0.2) + + expect do + described_class.match?(catastrophic_subject, pattern) + end.to raise_error(Regexp::TimeoutError) + end + end + + context 'when a budget is open' do + config_override :in_memory_regexp_time_limit, 5.0 + + it 'performs the match' do + described_class.open(regexp_selector) do + expect(described_class.match?('abc', cheap_pattern)).to eq(0) + end + end + + it 'accepts an already compiled Regexp' do + described_class.open(regexp_selector) do + expect(described_class.match?('abc', /\Aabc\z/)).to eq(0) + end + end + + it 'preserves the options of the condition' do + described_class.open(regexp_selector) do + expect(described_class.match?('ABC', BSON::Regexp::Raw.new('\Aabc\z', 'i'))).to eq(0) + end + end + + it 'preserves the encoding of the condition' do + # The condition is rebuilt to carry the timeout, so its source, options + # and encoding all have to survive the round trip. + described_class.open(regexp_selector) do + expect(described_class.match?('é', /\Aé+\z/u)).to eq(0) + end + end + + it 'charges the elapsed time against the budget' do + described_class.open(regexp_selector) do + before = described_class.remaining + described_class.match?(slow_subject, slow_pattern) + expect(described_class.remaining).to be < before + end + end + + it 'compiles a pattern once for the scope rather than once per match' do + skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT + + # An already compiled condition, so that the only Regexp.new in play is + # the one baking in the timeout. + allow(Regexp).to receive(:new).and_call_original + + described_class.open(regexp_selector) do + 10.times { described_class.match?('abc', /\Aabc\z/) } + end + + expect(Regexp).to have_received(:new).once + end + end + + context 'when the patterns are expensive to compile but cheap to run' do + config_override :in_memory_regexp_time_limit, nil + + # Thousands of alternations cost far more to compile than to run against + # a subject that fails at the first character. Charging only the match + # would leave a selector full of these -- a long $or, say -- able to + # spend as much time as it liked without the budget noticing. + let(:costly_to_compile) do + Array.new(200) do |n| + BSON::Regexp::Raw.new("(?:#{(1..2_000).map { |i| "b#{n}x#{i}" }.join('|')})Z") + end + end + + before do + # Building the fixtures is not free either, so it happens before the + # clock starts rather than inside the block being measured. + pattern = costly_to_compile.first.pattern + cost = Benchmark.realtime { Regexp.new(pattern) } + Mongoid::Config.in_memory_regexp_time_limit = cost * 5 + end + + it 'charges compilation against the budget' do + skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT + + expect do + described_class.open(regexp_selector) do + costly_to_compile.each { |pattern| described_class.match?('x', pattern) } + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + end + + context 'when the application has set a stricter Regexp timeout' do + config_override :in_memory_regexp_time_limit, 5.0 + + around do |example| + skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT + + was = Regexp.timeout + Regexp.timeout = 0.2 + begin + example.run + ensure + Regexp.timeout = was + end + end + + it 'does not loosen it to the budget limit' do + # Baking the whole limit in would override the global and leave the + # application less protected than it configured itself to be. + elapsed = Benchmark.realtime do + expect do + described_class.open(regexp_selector) do + described_class.match?(catastrophic_subject, catastrophic_pattern) + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + expect(elapsed).to be < 1.0 + end + + it 'names the limit that fired rather than the configured one' do + # Naming the configured 5.0 would state a time that was never spent, + # and send the reader after a setting that is not the one in the way. + expect do + described_class.open(regexp_selector) do + described_class.match?(catastrophic_subject, catastrophic_pattern) + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the 0\.2 second limit/) + end + end + + context 'when a global Regexp timeout fires with nothing baked' do + config_override :in_memory_regexp_time_limit, 5.0 + + # The counterpart to the context above, for the engine that raises + # Regexp::TimeoutError but will not take a per-Regexp timeout: JRuby. The + # pattern carries nothing there, so it reports nil and the global is the + # only thing that can have fired. + around do |example| + skip 'no Regexp timeouts at all' unless defined?(Regexp::TimeoutError) + + was = Regexp.timeout + Regexp.timeout = 0.2 + begin + example.run + ensure + Regexp.timeout = was + end + end + + before { stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) } + + it 'names the global limit rather than the budget limit' do + expect do + described_class.open_with(5.0) do + described_class.match?(catastrophic_subject, catastrophic_pattern) + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the 0\.2 second limit/) + end + end + + context 'when the accumulated cost exceeds the limit' do + config_override :in_memory_regexp_time_limit, nil + + # Calibrated rather than hard-coded: one match of the fixture costs about + # 6ms on MRI and 110ms on JRuby, so a fixed limit would be either + # unreachable on one or tripped by a single match on the other. + before do + regexp = Regexp.new(slow_pattern.pattern) + 3.times { slow_subject =~ regexp } + cost = Benchmark.realtime { slow_subject =~ regexp } + Mongoid::Config.in_memory_regexp_time_limit = cost * 5 + end + + it 'raises once the budget is spent' do + expect do + described_class.open(regexp_selector) do + 100.times { described_class.match?(slow_subject, slow_pattern) } + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'names the configured limit' do + # The counterpart to the stricter-Regexp.timeout example above: where + # the budget itself is what ran out, its own limit is the one to name. + limit = Regexp.escape(Mongoid::Config.in_memory_regexp_time_limit.to_s) + + expect do + described_class.open(regexp_selector) do + 100.times { described_class.match?(slow_subject, slow_pattern) } + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout, /exceeded the #{limit} second limit/) + end + + it 'does not raise for a single match under that same limit' do + expect do + described_class.open(regexp_selector) { described_class.match?(slow_subject, slow_pattern) } + end.not_to raise_error + end + + it 'does not raise when no single match and no accumulation exceeds the limit' do + expect do + described_class.open(regexp_selector) do + 100.times { described_class.match?('abc', cheap_pattern) } + end + end.not_to raise_error + end + end + end + + context 'when a single match runs far longer than the limit' do + config_override :in_memory_regexp_time_limit, 0.2 + + context 'when the Ruby in use supports per-Regexp timeouts' do + # Keyed to the capability, not the version: JRuby 10 reports Ruby 3.4 + # but cannot be given a per-Regexp timeout, so it runs the Timeout path. + before do + skip 'per-Regexp timeouts unavailable' unless described_class::PER_REGEXP_TIMEOUT + end + + it 'interrupts the match' do + expect do + described_class.open(regexp_selector) do + described_class.match?(catastrophic_subject, catastrophic_pattern) + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + end + + context 'when the Ruby in use has no per-Regexp timeouts' do + before do + stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false) + end + + it 'interrupts the match with Timeout' do + expect do + described_class.open(regexp_selector) do + described_class.match?(catastrophic_subject, catastrophic_pattern) + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + end + + it 'interrupts an unprotected block part way through' do + completed = false + + expect do + described_class.open(regexp_selector) do + sleep 0.3 + completed = true + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + + expect(completed).to be(false) + end + + it 'lets a protected block finish before delivering the interruption' do + # remove_all mutates association state inside its scope. Deferring the + # exception, rather than forgoing it, is what lets that scope stay + # interruptible without unbind_one being torn in half. + completed = false + + expect do + described_class.open(regexp_selector) do + described_class.protect do + sleep 0.3 + completed = true + end + end + end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout) + + expect(completed).to be(true) + end + end + end + + describe Mongoid::Matcher::RegexpBudget::Budget do + describe '#compile' do + let(:budget) { described_class.new(5.0) } + + it 'returns a pattern matching the condition' do + expect(budget.compile(BSON::Regexp::Raw.new('\Aabc\z'))).to match('abc') + end + + it 'reuses the pattern compiled for an equal condition' do + # FieldExpression builds a fresh BSON::Regexp::Raw for every $regex it + # evaluates, so its own memo is worth nothing from one document to the + # next and the source would be compiled once per document. Raw does not + # override hash, either, so the cache cannot be keyed on the condition + # itself. + first = budget.compile(BSON::Regexp::Raw.new('abc', 'i')) + second = budget.compile(BSON::Regexp::Raw.new('abc', 'i')) + + expect(second).to equal(first) + end + + it 'does not confuse conditions differing only in their options' do + insensitive = budget.compile(BSON::Regexp::Raw.new('abc', 'i')) + sensitive = budget.compile(BSON::Regexp::Raw.new('abc')) + + expect(insensitive).to match('ABC') + expect(sensitive).not_to match('ABC') + end + + it 'reuses the pattern compiled for an equal Regexp' do + expect(budget.compile(/abc/i)).to equal(budget.compile(/abc/i)) + end + + it 'raises for a condition that is not a regular expression' do + expect { budget.compile('abc') }.to raise_error(ArgumentError, /Not a regular expression/) + end + end + end +end diff --git a/spec/mongoid/tasks/database_spec.rb b/spec/mongoid/tasks/database_spec.rb index 3758282040..52af7f74bd 100644 --- a/spec/mongoid/tasks/database_spec.rb +++ b/spec/mongoid/tasks/database_spec.rb @@ -73,6 +73,24 @@ class Note end describe '.create_collections' do + # Creating a collection sends no document data, so it must not require a + # client that can encrypt, even for a model that declares encrypted + # fields. Otherwise this task cannot run without KMS credentials. + context 'when a model declares encrypted fields' do + before do + require 'support/crypt/models' + end + + after do + Mongoid.default_client[Crypt::Patient.collection_name].drop + end + + it 'creates the collection using a client without automatic encryption' do + expect { Mongoid::Tasks::Database.create_collections([ Crypt::Patient ]) } + .not_to raise_error + end + end + context 'collection_options are specified' do let(:models) do [DatabaseSpec::Measurement] diff --git a/spec/support/crypt/models.rb b/spec/support/crypt/models.rb index b3e7c67cdc..67af8f2c15 100644 --- a/spec/support/crypt/models.rb +++ b/spec/support/crypt/models.rb @@ -47,4 +47,161 @@ class Car field :vin, type: String, encrypt: true field :make, type: String end + + # An encrypted model whose database name is a callable resolving to a + # constant. The callable form of :database is supported by store_in, but the + # encryption schema map is keyed by namespace and built once, so this model's + # namespace is not known when the map is generated. + class DynamicCar + include Mongoid::Document + + store_in database: -> { 'vehicles_dynamic' } + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==', deterministic: true + + field :vin, type: String, encrypt: true + end + + # An encrypted model using the documented multi-tenant idiom, where the + # database name is only known once a tenant is selected. There is no correct + # value to resolve at client construction time. + class TenantCar + include Mongoid::Document + + # The fallback keeps this model usable by tasks that iterate every model, + # such as Mongoid::Tasks::Database.create_collections. Mongoid raises + # NoMethodError when a callable :database resolves to nil. + store_in database: -> { Thread.current[:tenant_database] || 'vehicles_no_tenant' } + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==', deterministic: true + + field :vin, type: String, encrypt: true + end + + # An encrypted model that is embedded by more than one parent, and more than + # once by the same parent. + class Token + include Mongoid::Document + + field :value, type: String, encrypt: { deterministic: true } + + embedded_in :tokenized, polymorphic: true + end + + # A parent whose encrypted data lives entirely in an embedded model: it has + # no encrypted field of its own and no encrypt_with. + class Wallet + include Mongoid::Document + + embeds_one :token, class_name: 'Crypt::Token', as: :tokenized + end + + # Two parents embedding the same encrypted model. + class Vault + include Mongoid::Document + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==' + + embeds_one :token, class_name: 'Crypt::Token', as: :tokenized + end + + class Chest + include Mongoid::Document + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==' + + embeds_one :token, class_name: 'Crypt::Token', as: :tokenized + end + + # A parent embedding the same encrypted model through two relations. + class Ledger + include Mongoid::Document + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==' + + embeds_one :primary_token, class_name: 'Crypt::Token', as: :tokenized + embeds_one :backup_token, class_name: 'Crypt::Token', as: :tokenized + end + + # Three levels of nesting, where the middle level has no encrypted field of + # its own. + class Owner + include Mongoid::Document + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==' + + embeds_one :account, class_name: 'Crypt::Account' + end + + class Account + include Mongoid::Document + + embedded_in :owner, class_name: 'Crypt::Owner' + embeds_one :credential, class_name: 'Crypt::Credential' + end + + class Credential + include Mongoid::Document + + field :secret, type: String, encrypt: { deterministic: true } + + embedded_in :account, class_name: 'Crypt::Account' + end + + # A model that embeds itself. The walk over embedded relations has to stop + # here, or generating the map never terminates. + class Comment + include Mongoid::Document + + field :body, type: String, encrypt: { deterministic: true } + + embedded_in :commentable, polymorphic: true + embeds_one :reply, class_name: 'Crypt::Comment', as: :commentable + end + + class Article + include Mongoid::Document + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==' + + embeds_one :comment, class_name: 'Crypt::Comment', as: :commentable + end + + # A parent with no encrypted field of its own: the encrypted field lives on + # the embedded model. Used by the integration specs, so the embedded model + # carries a key id. + class Folder + include Mongoid::Document + + embeds_one :note, class_name: 'Crypt::Note' + end + + class Note + include Mongoid::Document + + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==', deterministic: true + + field :text, type: String, encrypt: true + + embedded_in :folder, class_name: 'Crypt::Folder' + end + + # A model naming an embedded class that is never defined. The association + # cannot be used, so nothing is ever embedded through it, but every model in + # the application is walked when the schema map is generated. + class Drawer + include Mongoid::Document + + embeds_one :missing_note, class_name: 'Crypt::MissingNote' + end + + # The same, on a model that is encrypted itself, so the walk descends into + # its relations. + class Cabinet + include Mongoid::Document + + field :label, type: String, encrypt: { deterministic: true } + + embeds_one :missing_note, class_name: 'Crypt::MissingNote' + end end