mongoid 9.1.1 → 9.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +31 -11
- data/lib/mongoid/association/accessors.rb +18 -4
- data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +10 -10
- data/lib/mongoid/association/eager_load/embedded_distributor.rb +6 -6
- data/lib/mongoid/association/eager_load/lookup_pipeline.rb +1 -1
- data/lib/mongoid/association/eager_loadable.rb +8 -1
- data/lib/mongoid/association/embedded/batchable.rb +17 -1
- data/lib/mongoid/association/nested/many.rb +1 -1
- data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
- data/lib/mongoid/association/relatable.rb +18 -0
- data/lib/mongoid/association.rb +8 -0
- data/lib/mongoid/atomic/modifiers.rb +100 -16
- data/lib/mongoid/clients/factory.rb +2 -0
- data/lib/mongoid/config/encryption.rb +148 -17
- data/lib/mongoid/document.rb +3 -0
- data/lib/mongoid/encryptable.rb +42 -15
- data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
- data/lib/mongoid/errors/no_encryption_schema.rb +5 -3
- data/lib/mongoid/errors.rb +1 -0
- data/lib/mongoid/fields.rb +7 -0
- data/lib/mongoid/persistable/creatable.rb +120 -14
- data/lib/mongoid/persistable.rb +15 -15
- data/lib/mongoid/persistence_context.rb +46 -4
- data/lib/mongoid/search_indexable.rb +5 -1
- data/lib/mongoid/tasks/database.rb +10 -7
- data/lib/mongoid/touchable.rb +28 -1
- data/lib/mongoid/version.rb +1 -1
- data/lib/mongoid/warnings.rb +4 -0
- data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +14 -0
- data/spec/integration/atomic/modifiers_spec.rb +283 -0
- data/spec/integration/encryption_spec.rb +149 -0
- data/spec/mongoid/association/accessors_spec.rb +35 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
- data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
- data/spec/mongoid/attributes/nested_spec.rb +29 -0
- data/spec/mongoid/clients/factory_spec.rb +36 -0
- data/spec/mongoid/config/encryption_spec.rb +223 -0
- data/spec/mongoid/criteria/includable_spec.rb +346 -0
- data/spec/mongoid/encryptable_spec.rb +54 -0
- data/spec/mongoid/fields_spec.rb +8 -0
- data/spec/mongoid/search_indexable_spec.rb +26 -0
- data/spec/mongoid/tasks/database_spec.rb +13 -0
- data/spec/mongoid/touchable_spec.rb +217 -0
- data/spec/mongoid/touchable_spec_models.rb +37 -0
- data/spec/support/crypt/models.rb +96 -0
- data/spec/support/models/modifier_models.rb +65 -0
- metadata +5 -2
|
@@ -20,7 +20,11 @@ module Mongoid
|
|
|
20
20
|
#
|
|
21
21
|
# @return [ Hash ] The encryption schema map.
|
|
22
22
|
def encryption_schema_map(default_database, models = ::Mongoid.models)
|
|
23
|
-
|
|
23
|
+
# Subclasses share their parent's collection but are not registered in
|
|
24
|
+
# Mongoid.models, so they have to be added to the walk explicitly.
|
|
25
|
+
classes = models.flat_map { |model| [ model ] + model.descendants }.uniq
|
|
26
|
+
|
|
27
|
+
classes.each_with_object({}) do |model, map|
|
|
24
28
|
next if model.embedded?
|
|
25
29
|
next unless model.requires_encryption_schema?
|
|
26
30
|
|
|
@@ -34,14 +38,33 @@ module Mongoid
|
|
|
34
38
|
next if database.respond_to?(:call)
|
|
35
39
|
|
|
36
40
|
key = "#{database}.#{model.collection_name}"
|
|
37
|
-
props = metadata_for(model).merge(properties_for(model, [ model ]))
|
|
41
|
+
props = metadata_for(model).merge(properties_for(model, [ model ], key))
|
|
42
|
+
next if props.empty?
|
|
43
|
+
|
|
38
44
|
# The root of a collection schema describes the document, so it is
|
|
39
45
|
# always an object. Saying so matters when a nested schema carries
|
|
40
46
|
# encryptMetadata: mongocryptd rejects the schema otherwise.
|
|
41
|
-
|
|
47
|
+
entry = { 'bsonType' => 'object' }.merge(props)
|
|
48
|
+
map[key] = map.key?(key) ? merge_schema_entries(map[key], entry, key) : entry
|
|
42
49
|
end
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
# Collect the paths of the fields encrypted by the schema generated for
|
|
53
|
+
# a single model.
|
|
54
|
+
#
|
|
55
|
+
# @param [ Mongoid::Document ] model The model.
|
|
56
|
+
#
|
|
57
|
+
# @return [ Array<Array<String>> ] The property paths, outermost first.
|
|
58
|
+
#
|
|
59
|
+
# @api private
|
|
60
|
+
def encrypted_paths_for(model)
|
|
61
|
+
return model.instance_variable_get(:@encrypted_paths_for) if
|
|
62
|
+
model.instance_variable_defined?(:@encrypted_paths_for)
|
|
63
|
+
|
|
64
|
+
properties = properties_for(model, [ model ])['properties'] || {}
|
|
65
|
+
model.instance_variable_set(:@encrypted_paths_for, collect_encrypted_paths(properties, []))
|
|
66
|
+
end
|
|
67
|
+
|
|
45
68
|
private
|
|
46
69
|
|
|
47
70
|
# The algorithm to use for the deterministic encryption.
|
|
@@ -109,10 +132,12 @@ module Mongoid
|
|
|
109
132
|
# @param [ Mongoid::Document ] model The model to generate the properties for.
|
|
110
133
|
# @param [ Array<Mongoid::Document> ] path The models the walk is already
|
|
111
134
|
# inside of, outermost first.
|
|
135
|
+
# @param [ String | nil ] namespace The namespace the schema is built
|
|
136
|
+
# for, used for error reporting.
|
|
112
137
|
#
|
|
113
138
|
# @return [ Hash ] The encryption properties.
|
|
114
|
-
def properties_for(model, path)
|
|
115
|
-
result = properties_for_fields(model).merge(properties_for_relations(model, path))
|
|
139
|
+
def properties_for(model, path, namespace = nil)
|
|
140
|
+
result = properties_for_fields(model).merge(properties_for_relations(model, path, namespace))
|
|
116
141
|
if result.empty?
|
|
117
142
|
{}
|
|
118
143
|
else
|
|
@@ -151,9 +176,11 @@ module Mongoid
|
|
|
151
176
|
# @param [ Mongoid::Document ] model The model to generate the properties for.
|
|
152
177
|
# @param [ Array<Mongoid::Document> ] path The models the walk is already
|
|
153
178
|
# inside of, outermost first.
|
|
179
|
+
# @param [ String | nil ] namespace The namespace the schema is built
|
|
180
|
+
# for, used for error reporting.
|
|
154
181
|
#
|
|
155
182
|
# @return [ Hash ] The encryption properties.
|
|
156
|
-
def properties_for_relations(model, path)
|
|
183
|
+
def properties_for_relations(model, path, namespace = nil)
|
|
157
184
|
model.relations.each_with_object({}) do |(name, relation), props|
|
|
158
185
|
# relation_class constantizes, and a polymorphic embedded_in has no
|
|
159
186
|
# class to resolve, so the relation type has to be checked first.
|
|
@@ -163,21 +190,125 @@ module Mongoid
|
|
|
163
190
|
# An association target does not have to be a Mongoid document, and
|
|
164
191
|
# the class it names does not have to exist.
|
|
165
192
|
next unless klass.respond_to?(:requires_encryption_schema?)
|
|
166
|
-
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
|
|
194
|
+
# Any subclass of the declared class can be assigned to the
|
|
195
|
+
# association, so its encrypted fields have to be mapped here too.
|
|
196
|
+
properties = ([ klass ] + klass.descendants).uniq.inject({}) do |merged, candidate|
|
|
197
|
+
# Stop at a model the walk is already inside of, or a self-embedding
|
|
198
|
+
# model never terminates. The path covers the current branch only:
|
|
199
|
+
# a model embedded by two parents, or twice by one parent, has to be
|
|
200
|
+
# emitted at every place it appears.
|
|
201
|
+
next merged if path.include?(candidate)
|
|
202
|
+
next merged unless candidate.respond_to?(:requires_encryption_schema?)
|
|
203
|
+
next merged unless candidate.requires_encryption_schema?
|
|
204
|
+
|
|
205
|
+
merge_schema_entries(
|
|
206
|
+
merged,
|
|
207
|
+
metadata_for(candidate).merge(
|
|
208
|
+
properties_for(candidate, path + [ candidate ], namespace)
|
|
209
|
+
),
|
|
210
|
+
namespace
|
|
211
|
+
)
|
|
177
212
|
end
|
|
213
|
+
props[name] = { 'bsonType' => 'object' }.merge(properties) unless properties.empty?
|
|
178
214
|
end
|
|
179
215
|
end
|
|
180
216
|
|
|
217
|
+
# Walk the schema properties and collect the path of every encrypted
|
|
218
|
+
# leaf.
|
|
219
|
+
#
|
|
220
|
+
# @param [ Hash ] properties The properties to walk.
|
|
221
|
+
# @param [ Array<String> ] path The properties the walk is already
|
|
222
|
+
# inside of.
|
|
223
|
+
#
|
|
224
|
+
# @return [ Array<Array<String>> ] The property paths.
|
|
225
|
+
def collect_encrypted_paths(properties, path)
|
|
226
|
+
properties.each_with_object([]) do |(name, schema), paths|
|
|
227
|
+
if schema.key?('encrypt')
|
|
228
|
+
paths << (path + [ name ])
|
|
229
|
+
elsif schema['properties']
|
|
230
|
+
paths.concat(collect_encrypted_paths(schema['properties'], path + [ name ]))
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Merge two schema objects covering the same model or namespace.
|
|
236
|
+
#
|
|
237
|
+
# Every model writing to one namespace contributes an entry to the
|
|
238
|
+
# schema map. Those entries have to agree on everything they have in
|
|
239
|
+
# common, or the schema would silently encrypt a field differently for
|
|
240
|
+
# one model than for another.
|
|
241
|
+
#
|
|
242
|
+
# @param [ Hash ] left The first schema.
|
|
243
|
+
# @param [ Hash ] right The second schema.
|
|
244
|
+
# @param [ String | nil ] namespace The namespace the schema belongs to.
|
|
245
|
+
#
|
|
246
|
+
# @return [ Hash ] The merged schema.
|
|
247
|
+
def merge_schema_entries(left, right, namespace = nil)
|
|
248
|
+
left.merge(right) do |key, left_value, right_value|
|
|
249
|
+
case key
|
|
250
|
+
when 'bsonType'
|
|
251
|
+
left_value
|
|
252
|
+
when 'encryptMetadata'
|
|
253
|
+
merge_encrypt_metadata(left_value, right_value, namespace)
|
|
254
|
+
when 'properties'
|
|
255
|
+
merge_properties(left_value, right_value, namespace)
|
|
256
|
+
else
|
|
257
|
+
raise_invalid_encryption_schema(namespace, key)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Merge two encryptMetadata objects.
|
|
263
|
+
#
|
|
264
|
+
# @param [ Hash ] left The first encryptMetadata object.
|
|
265
|
+
# @param [ Hash ] right The second encryptMetadata object.
|
|
266
|
+
# @param [ String | nil ] namespace The namespace the schema belongs to.
|
|
267
|
+
#
|
|
268
|
+
# @return [ Hash ] The merged encryptMetadata object.
|
|
269
|
+
def merge_encrypt_metadata(left, right, namespace)
|
|
270
|
+
left.merge(right) do |key, left_value, right_value|
|
|
271
|
+
unless left_value == right_value
|
|
272
|
+
raise_invalid_encryption_schema(namespace, "encryptMetadata.#{key}")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
left_value
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Merge two properties objects.
|
|
280
|
+
#
|
|
281
|
+
# @param [ Hash ] left The first properties object.
|
|
282
|
+
# @param [ Hash ] right The second properties object.
|
|
283
|
+
# @param [ String | nil ] namespace The namespace the schema belongs to.
|
|
284
|
+
#
|
|
285
|
+
# @return [ Hash ] The merged properties object.
|
|
286
|
+
def merge_properties(left, right, namespace)
|
|
287
|
+
left.merge(right) do |name, left_value, right_value|
|
|
288
|
+
if left_value.key?('encrypt') && right_value.key?('encrypt')
|
|
289
|
+
unless left_value['encrypt'] == right_value['encrypt']
|
|
290
|
+
raise_invalid_encryption_schema(namespace, "properties.#{name}")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
left_value
|
|
294
|
+
elsif left_value.key?('encrypt') || right_value.key?('encrypt')
|
|
295
|
+
raise_invalid_encryption_schema(namespace, "properties.#{name}")
|
|
296
|
+
else
|
|
297
|
+
merge_schema_entries(left_value, right_value, namespace)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Raise an error for a schema conflicting with another one.
|
|
303
|
+
#
|
|
304
|
+
# @param [ String | nil ] namespace The namespace the schema belongs to.
|
|
305
|
+
# @param [ String ] path The conflicting path in the schema.
|
|
306
|
+
#
|
|
307
|
+
# @raise [ Errors::InvalidEncryptionSchema ] Always raised.
|
|
308
|
+
def raise_invalid_encryption_schema(namespace, path)
|
|
309
|
+
raise Errors::InvalidEncryptionSchema.new(namespace, path)
|
|
310
|
+
end
|
|
311
|
+
|
|
181
312
|
# Get the BSON type identifier for the provided field according to the
|
|
182
313
|
# https://www.mongodb.com/docs/manual/reference/bson-types/#std-label-bson-types
|
|
183
314
|
#
|
data/lib/mongoid/document.rb
CHANGED
|
@@ -410,6 +410,8 @@ module Mongoid
|
|
|
410
410
|
def instantiate_document(attrs = nil, selected_fields = nil, options = {}, &block)
|
|
411
411
|
execute_callbacks = options.fetch(:execute_callbacks, Threaded.execute_callbacks?)
|
|
412
412
|
attributes = attrs.to_h
|
|
413
|
+
preloaded_fields = attributes.keys.select { |key| key.start_with?(Association::PRELOADED_PREFIX) }
|
|
414
|
+
preloaded = attributes.extract!(*preloaded_fields)
|
|
413
415
|
|
|
414
416
|
doc = allocate
|
|
415
417
|
doc.__selected_fields = selected_fields
|
|
@@ -417,6 +419,7 @@ module Mongoid
|
|
|
417
419
|
doc.instance_variable_set(:@attributes_before_type_cast, attributes.dup)
|
|
418
420
|
|
|
419
421
|
doc._handle_callbacks_after_instantiation(execute_callbacks, &block)
|
|
422
|
+
doc.load_preloaded_associations(preloaded)
|
|
420
423
|
|
|
421
424
|
doc.remember_storage_options!
|
|
422
425
|
doc
|
data/lib/mongoid/encryptable.rb
CHANGED
|
@@ -43,21 +43,29 @@ module Mongoid
|
|
|
43
43
|
# group has no encrypted field of its own, but its collection still needs
|
|
44
44
|
# a schema, otherwise the embedded fields are written in plaintext.
|
|
45
45
|
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# not
|
|
46
|
+
# This runs on the persistence path, so the answer is memoized. A true
|
|
47
|
+
# answer is always memoized. A false answer is memoized only when every
|
|
48
|
+
# embeds_one target in the walk resolved to a class; if one could not be
|
|
49
|
+
# resolved yet, the answer is recomputed next time, so a class loaded
|
|
50
|
+
# later is still detected. Declaring encryption on a model after it has
|
|
51
|
+
# already been persisted is not supported.
|
|
49
52
|
#
|
|
50
53
|
# @return [ true | false ] Whether the model needs an encryption schema.
|
|
51
54
|
#
|
|
52
55
|
# @api private
|
|
53
56
|
def requires_encryption_schema?
|
|
54
57
|
return @requires_encryption_schema if defined?(@requires_encryption_schema)
|
|
58
|
+
return @requires_encryption_schema = true if encrypted?
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
found, complete = embedded_encryption_scan([ self ])
|
|
61
|
+
@requires_encryption_schema = found if found || complete
|
|
62
|
+
found
|
|
57
63
|
end
|
|
58
64
|
|
|
59
|
-
#
|
|
60
|
-
# declares encryption.
|
|
65
|
+
# Walks the models reachable through this model's embeds_one relations,
|
|
66
|
+
# looking for one that declares encryption. Subclasses of an
|
|
67
|
+
# association target are included: any of them can be assigned to the
|
|
68
|
+
# association.
|
|
61
69
|
#
|
|
62
70
|
# embeds_many is not considered: libmongocrypt cannot express per-field
|
|
63
71
|
# encryption under array items, so those fields are never mapped.
|
|
@@ -65,21 +73,40 @@ module Mongoid
|
|
|
65
73
|
# @param [ Array<Class> ] path The models the walk is already inside of.
|
|
66
74
|
# A model embedding itself terminates here.
|
|
67
75
|
#
|
|
68
|
-
# @return [ true | false ]
|
|
76
|
+
# @return [ Array<true | false> ] A pair: whether an embedded model is
|
|
77
|
+
# encrypted, and whether every embeds_one target the walk needed
|
|
78
|
+
# resolved to a class. The second value is only meaningful when the
|
|
79
|
+
# first is false.
|
|
69
80
|
#
|
|
70
81
|
# @api private
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
def embedded_encryption_scan(path)
|
|
83
|
+
complete = true
|
|
84
|
+
|
|
85
|
+
relations.each_value do |relation|
|
|
86
|
+
next unless relation.is_a?(Association::Embedded::EmbedsOne)
|
|
74
87
|
|
|
75
88
|
klass = relation.try_relation_class
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
# The class an association names does not have to exist yet.
|
|
90
|
+
if klass.nil?
|
|
91
|
+
complete = false
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# An association target does not have to be a Mongoid document.
|
|
96
|
+
next unless klass.respond_to?(:encrypted?)
|
|
97
|
+
|
|
98
|
+
([ klass ] + klass.descendants).uniq.each do |candidate|
|
|
99
|
+
next if path.include?(candidate)
|
|
100
|
+
return [ true, true ] if candidate.encrypted?
|
|
80
101
|
|
|
81
|
-
|
|
102
|
+
found, nested_complete = candidate.embedded_encryption_scan(path + [ candidate ])
|
|
103
|
+
return [ true, true ] if found
|
|
104
|
+
|
|
105
|
+
complete &&= nested_complete
|
|
106
|
+
end
|
|
82
107
|
end
|
|
108
|
+
|
|
109
|
+
[ false, complete ]
|
|
83
110
|
end
|
|
84
111
|
|
|
85
112
|
# Override the key_id for the model.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mongoid
|
|
4
|
+
module Errors
|
|
5
|
+
# This error is raised when two models writing to the same namespace
|
|
6
|
+
# describe a field, or the collection metadata, differently. The schema map
|
|
7
|
+
# has one entry per namespace, so the two descriptions cannot both be
|
|
8
|
+
# honored.
|
|
9
|
+
class InvalidEncryptionSchema < MongoidError
|
|
10
|
+
# Create the new error.
|
|
11
|
+
#
|
|
12
|
+
# @example Create the error.
|
|
13
|
+
# InvalidEncryptionSchema.new('music.bands', 'properties.code')
|
|
14
|
+
#
|
|
15
|
+
# @param [ String | nil ] namespace The namespace being described.
|
|
16
|
+
# @param [ String ] path The conflicting path in the schema.
|
|
17
|
+
def initialize(namespace, path)
|
|
18
|
+
super(
|
|
19
|
+
compose_message(
|
|
20
|
+
'invalid_encryption_schema',
|
|
21
|
+
{ namespace: namespace || 'the collection', path: path }
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -15,11 +15,13 @@ module Mongoid
|
|
|
15
15
|
# @param [ Class ] klass The model class.
|
|
16
16
|
# @param [ String ] namespace The namespace the model resolved to.
|
|
17
17
|
# @param [ String | Symbol ] client The name of the client in use.
|
|
18
|
-
|
|
18
|
+
# @param [ String | nil ] field The encrypted field missing from the
|
|
19
|
+
# schema entry, if the namespace itself is covered.
|
|
20
|
+
def initialize(klass, namespace, client, field = nil)
|
|
19
21
|
super(
|
|
20
22
|
compose_message(
|
|
21
|
-
'no_encryption_schema',
|
|
22
|
-
{ klass: klass, namespace: namespace, client: client }
|
|
23
|
+
field ? 'no_encryption_schema_field' : 'no_encryption_schema',
|
|
24
|
+
{ klass: klass, namespace: namespace, client: client, field: field }
|
|
23
25
|
)
|
|
24
26
|
)
|
|
25
27
|
end
|
data/lib/mongoid/errors.rb
CHANGED
|
@@ -20,6 +20,7 @@ require 'mongoid/errors/invalid_collection'
|
|
|
20
20
|
require 'mongoid/errors/invalid_config_file'
|
|
21
21
|
require 'mongoid/errors/invalid_config_option'
|
|
22
22
|
require 'mongoid/errors/invalid_dependent_strategy'
|
|
23
|
+
require 'mongoid/errors/invalid_encryption_schema'
|
|
23
24
|
require 'mongoid/errors/invalid_field'
|
|
24
25
|
require 'mongoid/errors/invalid_field_option'
|
|
25
26
|
require 'mongoid/errors/invalid_field_type'
|
data/lib/mongoid/fields.rb
CHANGED
|
@@ -412,6 +412,13 @@ module Mongoid
|
|
|
412
412
|
return '' unless name.present?
|
|
413
413
|
|
|
414
414
|
key = name.to_s
|
|
415
|
+
|
|
416
|
+
# A key without a "." refers to an immediate field, so it resolves to
|
|
417
|
+
# its alias (or itself) with no splitting. Short-circuiting here avoids
|
|
418
|
+
# the split and dup on every read, which is a hot path. This mirrors the
|
|
419
|
+
# same optimization already applied to the matcher.
|
|
420
|
+
return aliased_fields[key]&.dup || key unless key.include?('.')
|
|
421
|
+
|
|
415
422
|
segment, remaining = key.split('.', 2)
|
|
416
423
|
|
|
417
424
|
# Don't get the alias for the field when a belongs_to association
|
|
@@ -39,13 +39,79 @@ module Mongoid
|
|
|
39
39
|
{ atomic_insert_modifier => { atomic_position => as_attributes } }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Determine whether merging the touch updates into the insert
|
|
43
|
+
# operations would produce an update whose operators target both a
|
|
44
|
+
# path and one of its ancestors or descendants. MongoDB rejects such
|
|
45
|
+
# an update with error 40. This can happen for embeds_many inserts,
|
|
46
|
+
# where the operations target the array (via $push) but the touches
|
|
47
|
+
# include a path inside that array (e.g. items.0.updated_at) when a
|
|
48
|
+
# sibling has a pending touch.
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
#
|
|
52
|
+
# @param [ Hash ] operations The atomic insert operations.
|
|
53
|
+
# @param [ Hash ] touches The touch updates to merge.
|
|
54
|
+
#
|
|
55
|
+
# @return [ true | false ] Whether any touch path conflicts with a
|
|
56
|
+
# path the insert operations target.
|
|
57
|
+
def conflicting_touch_paths?(operations, touches)
|
|
58
|
+
targeted = operations.each_value.flat_map do |doc|
|
|
59
|
+
doc.respond_to?(:keys) ? doc.keys.map(&:to_s) : []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
touches.each_key.any? do |touch_path|
|
|
63
|
+
path = touch_path.to_s
|
|
64
|
+
targeted.any? do |target|
|
|
65
|
+
path == target || path.start_with?("#{target}.") ||
|
|
66
|
+
target.start_with?("#{path}.")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Rewrite the indices in the touch updates to the positional operator,
|
|
72
|
+
# as the final update will do to the merged operations. The touches of
|
|
73
|
+
# the parent chain target the inserted element, so rewriting them is
|
|
74
|
+
# safe, and merging the rewritten keys into the operations lets the
|
|
75
|
+
# final update reuse them as-is. A pending touch on a different element
|
|
76
|
+
# of the same array, however, would be rewritten to the same positional
|
|
77
|
+
# path and silently touch the wrong document; in that case this method
|
|
78
|
+
# returns nil and the caller persists the touch updates in their own
|
|
79
|
+
# round-trip after the insert instead of merging them.
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
#
|
|
83
|
+
# @param [ Hash ] selector The update selector.
|
|
84
|
+
# @param [ Document ] parent The parent document being touched.
|
|
85
|
+
# @param [ Symbol, String, nil ] field The parent's custom touch field.
|
|
86
|
+
# @param [ Hash ] touches The touch updates to rewrite.
|
|
87
|
+
#
|
|
88
|
+
# @return [ Hash | nil ] The rewritten touch updates, or nil if any
|
|
89
|
+
# touch would be misdirected by the rewriting.
|
|
90
|
+
def rewritten_touch_updates(selector, parent, field, touches)
|
|
91
|
+
rewritten = positionally(selector, { '$set' => touches })['$set']
|
|
92
|
+
|
|
93
|
+
chain_paths = parent._touchable_chain_paths(field)
|
|
94
|
+
touches.each_key do |key|
|
|
95
|
+
path = key.to_s
|
|
96
|
+
next if rewritten.key?(path)
|
|
97
|
+
|
|
98
|
+
return nil unless chain_paths.include?(path)
|
|
99
|
+
end
|
|
100
|
+
rewritten
|
|
101
|
+
end
|
|
102
|
+
|
|
42
103
|
# Insert the embedded document.
|
|
43
104
|
#
|
|
44
105
|
# When the parent association is touchable (which is the default for
|
|
45
|
-
# +embedded_in+), the touch
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
106
|
+
# +embedded_in+), the touch updates are merged into the same
|
|
107
|
+
# +update_one+ call that performs the insert. This avoids a second
|
|
108
|
+
# round-trip that the +after_save+ touch callback would otherwise
|
|
109
|
+
# issue. The merge is skipped when a touch path would conflict with a
|
|
110
|
+
# path the insert targets (see +conflicting_touch_paths?+) or when
|
|
111
|
+
# rewriting the touch indices to the positional operator would
|
|
112
|
+
# misdirect a touch (see +rewritten_touch_updates+); such touches are
|
|
113
|
+
# persisted in their own round-trip right after the insert, so their
|
|
114
|
+
# persistence does not depend on the callback chain running.
|
|
49
115
|
#
|
|
50
116
|
# @api private
|
|
51
117
|
#
|
|
@@ -62,19 +128,59 @@ module Mongoid
|
|
|
62
128
|
selector = _parent.atomic_selector
|
|
63
129
|
operations = atomic_inserts
|
|
64
130
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
131
|
+
deferred_touches = merge_touch_updates(selector, operations)
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
_root.collection.find(selector).update_one(
|
|
135
|
+
positionally(selector, operations),
|
|
136
|
+
session: _session
|
|
137
|
+
)
|
|
138
|
+
_root.persist_atomic_operations('$set' => deferred_touches) if deferred_touches
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# If the insert failed, the after_save callback will not run to
|
|
141
|
+
# consume and clear the merged-touch flag, so clear it here.
|
|
142
|
+
Threaded.exit_touch_merged(self)
|
|
143
|
+
raise
|
|
71
144
|
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Merge the parent chain's pending touch updates into the insert
|
|
149
|
+
# operations when doing so would not produce a conflicting update
|
|
150
|
+
# (see +conflicting_touch_paths?+ and +rewritten_touch_updates+).
|
|
151
|
+
# Either way, marks the touch as merged so the after_save callback
|
|
152
|
+
# does not persist the updates a second time. Returns the touch
|
|
153
|
+
# updates that could not be merged; they are persisted in their own
|
|
154
|
+
# round-trip right after the insert, rather than relying on the
|
|
155
|
+
# after_save callback, so that an aborted callback chain cannot
|
|
156
|
+
# silently drop them.
|
|
157
|
+
#
|
|
158
|
+
# @api private
|
|
159
|
+
#
|
|
160
|
+
# @param [ Hash ] selector The update selector.
|
|
161
|
+
# @param [ Hash ] operations The atomic insert operations, modified
|
|
162
|
+
# in place when the touches are merged.
|
|
163
|
+
#
|
|
164
|
+
# @return [ Hash | nil ] The deferred touch updates, if any.
|
|
165
|
+
def merge_touch_updates(selector, operations)
|
|
166
|
+
return nil unless _touchable_parent?
|
|
72
167
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
168
|
+
field = _association&.inverse_association&.touch_field
|
|
169
|
+
touches = _parent._gather_touch_updates(Time.current, field)
|
|
170
|
+
return nil if touches.blank?
|
|
171
|
+
|
|
172
|
+
if conflicting_touch_paths?(operations, touches)
|
|
173
|
+
deferred_touches = touches
|
|
174
|
+
else
|
|
175
|
+
rewritten = rewritten_touch_updates(selector, _parent, field, touches)
|
|
176
|
+
if rewritten
|
|
177
|
+
operations['$set'] = (operations['$set'] || {}).merge(rewritten)
|
|
178
|
+
else
|
|
179
|
+
deferred_touches = touches
|
|
180
|
+
end
|
|
77
181
|
end
|
|
182
|
+
Threaded.begin_touch_merged(self)
|
|
183
|
+
deferred_touches
|
|
78
184
|
end
|
|
79
185
|
|
|
80
186
|
# Insert the root document.
|
data/lib/mongoid/persistable.rb
CHANGED
|
@@ -142,6 +142,21 @@ module Mongoid
|
|
|
142
142
|
raise Errors::Callback.new(self.class, method)
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Persist the atomic operations.
|
|
146
|
+
#
|
|
147
|
+
# @api private
|
|
148
|
+
#
|
|
149
|
+
# @example Persist the atomic operations.
|
|
150
|
+
# persist_atomic_operations(ops)
|
|
151
|
+
#
|
|
152
|
+
# @param [ Hash ] operations The atomic operations.
|
|
153
|
+
def persist_atomic_operations(operations)
|
|
154
|
+
return unless persisted? && operations && !operations.empty?
|
|
155
|
+
|
|
156
|
+
selector = atomic_selector
|
|
157
|
+
_root.collection.find(selector).update_one(positionally(selector, operations), session: _session)
|
|
158
|
+
end
|
|
159
|
+
|
|
145
160
|
private
|
|
146
161
|
|
|
147
162
|
# Are we executing an atomically block on the current document?
|
|
@@ -300,20 +315,5 @@ module Mongoid
|
|
|
300
315
|
persist_atomic_operations(operation)
|
|
301
316
|
end
|
|
302
317
|
end
|
|
303
|
-
|
|
304
|
-
# Persist the atomic operations.
|
|
305
|
-
#
|
|
306
|
-
# @api private
|
|
307
|
-
#
|
|
308
|
-
# @example Persist the atomic operations.
|
|
309
|
-
# persist_atomic_operations(ops)
|
|
310
|
-
#
|
|
311
|
-
# @param [ Hash ] operations The atomic operations.
|
|
312
|
-
def persist_atomic_operations(operations)
|
|
313
|
-
return unless persisted? && operations && !operations.empty?
|
|
314
|
-
|
|
315
|
-
selector = atomic_selector
|
|
316
|
-
_root.collection.find(selector).update_one(positionally(selector, operations), session: _session)
|
|
317
|
-
end
|
|
318
318
|
end
|
|
319
319
|
end
|
|
@@ -209,12 +209,15 @@ module Mongoid
|
|
|
209
209
|
# that schema, and when it is absent it asks the server for a schema
|
|
210
210
|
# instead; a collection without a validator then yields no encryption at
|
|
211
211
|
# all, and no error. Checking the namespace here is what turns that silent
|
|
212
|
-
# downgrade into a failure.
|
|
212
|
+
# downgrade into a failure. When the namespace is present, its entry is
|
|
213
|
+
# checked against the fields the model declares as encrypted too: a
|
|
214
|
+
# hand-written schema can cover the namespace and still leave out a field.
|
|
213
215
|
#
|
|
214
216
|
# @param [ Mongo::Client ] client The client this context resolved to.
|
|
215
217
|
#
|
|
216
218
|
# @raise [ Errors::NoEncryptionSchema ] if the model needs an encryption
|
|
217
|
-
# schema and the client's schema does not cover the target namespace
|
|
219
|
+
# schema and the client's schema does not cover the target namespace, or
|
|
220
|
+
# the namespace entry does not cover every encrypted field of the model.
|
|
218
221
|
def verify_encryption_schema!(client)
|
|
219
222
|
klass = @object.is_a?(Class) ? @object : @object.class
|
|
220
223
|
# A model whose encrypted fields all live on embedded models declares no
|
|
@@ -229,9 +232,48 @@ module Mongoid
|
|
|
229
232
|
|
|
230
233
|
schema_map = client.options.dig(:auto_encryption_options, :schema_map)
|
|
231
234
|
namespace = "#{client.database.name}.#{collection_name}"
|
|
232
|
-
|
|
235
|
+
entry = schema_map && schema_map[namespace]
|
|
236
|
+
raise Errors::NoEncryptionSchema.new(klass, namespace, client_name) unless entry
|
|
233
237
|
|
|
234
|
-
|
|
238
|
+
# The schema map may have been written by hand, with symbol keys rather
|
|
239
|
+
# than strings; both are tolerated at every level.
|
|
240
|
+
Mongoid.config.encrypted_paths_for(klass).each do |path|
|
|
241
|
+
next if encryption_schema_has_path?(entry, path)
|
|
242
|
+
|
|
243
|
+
raise Errors::NoEncryptionSchema.new(
|
|
244
|
+
klass, namespace, client_name, path.join('.')
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Whether a schema entry encrypts the property at the given path.
|
|
250
|
+
#
|
|
251
|
+
# @param [ Hash ] entry The namespace schema entry.
|
|
252
|
+
# @param [ Array<String> ] path The property path, outermost first.
|
|
253
|
+
#
|
|
254
|
+
# @return [ true | false ] Whether the leaf is marked as encrypted.
|
|
255
|
+
def encryption_schema_has_path?(entry, path)
|
|
256
|
+
node = entry
|
|
257
|
+
path.each do |name|
|
|
258
|
+
properties = schema_value(node, 'properties')
|
|
259
|
+
return false unless properties.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
node = schema_value(properties, name)
|
|
262
|
+
return false unless node.is_a?(Hash)
|
|
263
|
+
end
|
|
264
|
+
!schema_value(node, 'encrypt').nil?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Read a schema key that may be written as a string or a symbol.
|
|
268
|
+
#
|
|
269
|
+
# @param [ Hash ] hash The schema object.
|
|
270
|
+
# @param [ String ] key The key to read.
|
|
271
|
+
#
|
|
272
|
+
# @return [ Object ] The value, or nil when the key is absent.
|
|
273
|
+
def schema_value(hash, key)
|
|
274
|
+
return hash[key] if hash.key?(key)
|
|
275
|
+
|
|
276
|
+
hash[key.to_sym]
|
|
235
277
|
end
|
|
236
278
|
|
|
237
279
|
def client_options
|
|
@@ -421,7 +421,11 @@ module Mongoid
|
|
|
421
421
|
#
|
|
422
422
|
# @return [ Array<Hash> ] the raw index documents
|
|
423
423
|
def get_indexes(names)
|
|
424
|
-
|
|
424
|
+
# Reading index metadata sends no document data, so it is exempt from
|
|
425
|
+
# the encryption schema check, like the other index operations here.
|
|
426
|
+
Threaded.with_collection_management do
|
|
427
|
+
collection.search_indexes.select { |i| names.include?(i['name']) }
|
|
428
|
+
end
|
|
425
429
|
end
|
|
426
430
|
|
|
427
431
|
# Resolves the index name and vector path from the declared specs,
|