mongoid 9.0.11 → 9.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +65 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/embedded/batchable.rb +8 -0
  5. data/lib/mongoid/association/nested/many.rb +34 -5
  6. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  7. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  8. data/lib/mongoid/association/relatable.rb +17 -0
  9. data/lib/mongoid/clients/factory.rb +2 -0
  10. data/lib/mongoid/collection_configurable.rb +8 -0
  11. data/lib/mongoid/config/encryption.rb +171 -22
  12. data/lib/mongoid/config.rb +56 -9
  13. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  14. data/lib/mongoid/contextual/memory.rb +18 -7
  15. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  16. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  17. data/lib/mongoid/encryptable.rb +73 -0
  18. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  19. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  20. data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
  21. data/lib/mongoid/errors.rb +3 -0
  22. data/lib/mongoid/field_readable.rb +70 -0
  23. data/lib/mongoid/indexable.rb +39 -27
  24. data/lib/mongoid/matchable.rb +6 -1
  25. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
  26. data/lib/mongoid/matcher/regex.rb +9 -13
  27. data/lib/mongoid/matcher/regexp_budget.rb +383 -0
  28. data/lib/mongoid/matcher.rb +1 -0
  29. data/lib/mongoid/persistence_context.rb +77 -0
  30. data/lib/mongoid/search_indexable.rb +19 -8
  31. data/lib/mongoid/tasks/database.rb +33 -16
  32. data/lib/mongoid/threaded.rb +37 -0
  33. data/lib/mongoid/version.rb +1 -1
  34. data/lib/mongoid/warnings.rb +4 -0
  35. data/spec/integration/app_spec.rb +7 -0
  36. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  37. data/spec/integration/atomic/modifiers_spec.rb +57 -0
  38. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  39. data/spec/integration/encryption_spec.rb +298 -0
  40. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  41. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  42. data/spec/integration/query_operator_guard_spec.rb +89 -0
  43. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  44. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  45. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  46. data/spec/mongoid/clients/factory_spec.rb +36 -0
  47. data/spec/mongoid/config/defaults_spec.rb +18 -0
  48. data/spec/mongoid/config/encryption_spec.rb +451 -0
  49. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  50. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  51. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  52. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  53. data/spec/mongoid/criteria_spec.rb +2 -0
  54. data/spec/mongoid/encryptable_spec.rb +115 -0
  55. data/spec/mongoid/matcher/regexp_budget_spec.rb +576 -0
  56. data/spec/mongoid/search_indexable_spec.rb +34 -1
  57. data/spec/mongoid/tasks/database_spec.rb +31 -0
  58. data/spec/support/crypt/models.rb +253 -0
  59. data/spec/support/models/modifier_models.rb +32 -0
  60. metadata +17 -2
@@ -65,15 +65,18 @@ module Mongoid
65
65
  # @param [ true | false ] wait whether to wait for the indexes to be
66
66
  # built.
67
67
  def create_search_indexes(models = ::Mongoid.models, wait: true)
68
- searchable = models.select { |m| m.search_index_specs.any? }
68
+ Threaded.with_collection_management do
69
+ searchable = models.select { |m| m.search_index_specs.any? }
69
70
 
70
- # queue up the search index creation requests
71
- index_names_by_model = searchable.each_with_object({}) do |model, obj|
72
- logger.info("MONGOID: Creating search indexes on #{model}...")
73
- obj[model] = model.create_search_indexes
74
- end
71
+ # queue up the search index creation requests
72
+ index_names_by_model = searchable.each_with_object({}) do |model, obj|
73
+ logger.info("MONGOID: Creating search indexes on #{model}...")
74
+ obj[model] = model.create_search_indexes
75
+ end
75
76
 
76
- wait_for_search_indexes(index_names_by_model) if wait
77
+ wait_for_search_indexes(index_names_by_model) if wait
78
+ index_names_by_model
79
+ end
77
80
  end
78
81
 
79
82
  # Return the list of indexes by model that exist in the database but aren't
@@ -84,6 +87,12 @@ module Mongoid
84
87
  #
85
88
  # @return [ Array<Hash> ] The list of undefined indexes by model.
86
89
  def undefined_indexes(models = ::Mongoid.models)
90
+ Threaded.with_collection_management do
91
+ undefined_indexes_for(models)
92
+ end
93
+ end
94
+
95
+ def undefined_indexes_for(models)
87
96
  undefined_by_model = {}
88
97
 
89
98
  models.each do |model|
@@ -116,15 +125,17 @@ module Mongoid
116
125
  #
117
126
  # @return [ Hash{Class => Array(Hash)}] The list of indexes that were removed by model.
118
127
  def remove_undefined_indexes(models = ::Mongoid.models)
119
- undefined_indexes(models).each do |model, indexes|
120
- indexes.each do |index|
121
- key = index['key'].symbolize_keys
122
- collection = model.collection
123
- collection.indexes(session: model.send(:_session)).drop_one(key)
124
- logger.info(
125
- "MONGOID: Removed index '#{index['name']}' on collection " +
126
- "'#{collection.name}' in database '#{collection.database.name}'."
127
- )
128
+ Threaded.with_collection_management do
129
+ undefined_indexes(models).each do |model, indexes|
130
+ indexes.each do |index|
131
+ key = index['key'].symbolize_keys
132
+ collection = model.collection
133
+ collection.indexes(session: model.send(:_session)).drop_one(key)
134
+ logger.info(
135
+ "MONGOID: Removed index '#{index['name']}' on collection " +
136
+ "'#{collection.name}' in database '#{collection.database.name}'."
137
+ )
138
+ end
128
139
  end
129
140
  end
130
141
  end
@@ -170,6 +181,12 @@ module Mongoid
170
181
  #
171
182
  # @return [ Array<Class> ] The sharded models
172
183
  def shard_collections(models = ::Mongoid.models)
184
+ Threaded.with_collection_management do
185
+ shard_collections_for(models)
186
+ end
187
+ end
188
+
189
+ def shard_collections_for(models)
173
190
  models.map do |model|
174
191
  next if model.shard_config.nil?
175
192
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rubocop:todo all
2
3
 
3
4
  require 'mongoid/threaded/lifecycle'
4
5
 
@@ -25,6 +26,9 @@ module Mongoid
25
26
  hash[key] = "[mongoid]:#{key}-stack"
26
27
  end
27
28
 
29
+ # The name of the stack tracking collection and index management.
30
+ COLLECTION_MANAGEMENT = :collection_management
31
+
28
32
  # The key for the current thread's sessions.
29
33
  SESSIONS_KEY = '[mongoid]:sessions'
30
34
 
@@ -35,6 +39,9 @@ module Mongoid
35
39
  # executed on documents.
36
40
  EXECUTE_CALLBACKS = '[mongoid]:execute-callbacks'
37
41
 
42
+ # The key for the time left in the current in-memory regexp budget.
43
+ REGEXP_BUDGET_KEY = '[mongoid]:regexp-budget'
44
+
38
45
  extend self
39
46
 
40
47
  # Queries the thread-local variable with the given name. If a block is
@@ -152,6 +159,36 @@ module Mongoid
152
159
  !stack(name).empty?
153
160
  end
154
161
 
162
+ # Execute the block as collection or index management.
163
+ #
164
+ # Creating, dropping and inspecting collections and indexes sends no
165
+ # document data, so these operations are exempt from the encryption schema
166
+ # check that PersistenceContext applies to reads and writes. Without the
167
+ # exemption, tasks such as db:mongoid:create_collections would need an
168
+ # encryption-capable client to run.
169
+ #
170
+ # @example Create a collection.
171
+ # Threaded.with_collection_management { model.create_collection }
172
+ #
173
+ # @return [ Object ] The result of the block.
174
+ def with_collection_management
175
+ begin_execution(COLLECTION_MANAGEMENT)
176
+ yield
177
+ ensure
178
+ exit_execution(COLLECTION_MANAGEMENT)
179
+ end
180
+
181
+ # Is collection or index management being executed?
182
+ #
183
+ # @example Is a collection being managed?
184
+ # Threaded.managing_collection?
185
+ #
186
+ # @return [ true | false ] Whether collection or index management is in
187
+ # progress on the current thread.
188
+ def managing_collection?
189
+ executing?(COLLECTION_MANAGEMENT)
190
+ end
191
+
155
192
  # Exit from a named thread local stack.
156
193
  #
157
194
  # @example Exit from the stack.
@@ -5,5 +5,5 @@ module Mongoid
5
5
  #
6
6
  # Note that this file is automatically updated via `rake candidate:create`.
7
7
  # Manual changes to this file will be overwritten by that rake task.
8
- VERSION = '9.0.11'
8
+ VERSION = '9.0.13'
9
9
  end
@@ -37,5 +37,9 @@ module Mongoid
37
37
  warning :autosave_saves_unchanged_documents, "Autosave associations are currently configured to save documents even if they haven't changed. " \
38
38
  'This legacy behavior is deprecated. Set Mongoid.autosave_saves_unchanged_documents to false to ' \
39
39
  'skip saving unchanged documents in autosave associations.'
40
+ warning :bypass_auto_encryption,
41
+ 'auto_encryption_options includes bypass_auto_encryption: true. The driver ' \
42
+ 'will not encrypt anything for this client: fields declared with encrypt: true ' \
43
+ 'are written to the database unencrypted.'
40
44
  end
41
45
  end
@@ -459,6 +459,13 @@ describe 'Mongoid application tests' do
459
459
  end
460
460
  gemfile_lines << "gem 'mongoid', path: '#{File.expand_path(BASE)}'\n"
461
461
 
462
+ # json 3.0 removed the quirks_mode and max_nesting keywords that
463
+ # ActiveSupport::JSON (every 7.x) still passes to JSON.generate and
464
+ # JSON.parse, so a freshly resolved bundle picks a 3.x json and the app
465
+ # fails to encode any response with ArgumentError: unknown keyword:
466
+ # quirks_mode. Cap json at 2.x, which still accepts those keywords.
467
+ gemfile_lines << "gem 'json', '< 3'\n"
468
+
462
469
  # Rails 6.0 and 6.1 need logger gem on Ruby 2.7+ due to stdlib changes
463
470
  if rails_version && rails_version.to_f < 7.0 && RUBY_VERSION >= '2.7'
464
471
  gemfile_lines << "gem 'logger'\n"
@@ -87,8 +87,23 @@ describe 'has_and_belongs_to_many associations' do
87
87
  })
88
88
  end
89
89
 
90
- it 'does not raise on save' do
91
- expect { image_block.save! }.not_to raise_error
90
+ # The nested attributes are processed before the attachment_ids
91
+ # assignment is applied, so at that point the id is not yet in the
92
+ # association and resolving it requires a collection-wide lookup.
93
+ context 'when allow_reparenting_via_nested_attributes is false' do
94
+ config_override :allow_reparenting_via_nested_attributes, false
95
+
96
+ it 'raises a document not found error' do
97
+ expect { image_block.save! }.to raise_error(Mongoid::Errors::DocumentNotFound)
98
+ end
99
+ end
100
+
101
+ context 'when allow_reparenting_via_nested_attributes is true' do
102
+ config_override :allow_reparenting_via_nested_attributes, true
103
+
104
+ it 'does not raise on save' do
105
+ expect { image_block.save! }.not_to raise_error
106
+ end
92
107
  end
93
108
  end
94
109
 
@@ -114,4 +114,61 @@ describe Mongoid::Atomic::Modifiers do
114
114
  end
115
115
  end
116
116
 
117
+ context 'when assigning embeds_many attributes on a new embedded document' do
118
+ let(:cabinet) do
119
+ ModifierModels::Cabinet.create!
120
+ end
121
+
122
+ before do
123
+ cabinet.reload
124
+ end
125
+
126
+ context 'when the nested documents are assigned on the built document' do
127
+ before do
128
+ cabinet.build_drawer(matcher: 'all')
129
+ cabinet.drawer.assign_attributes(items: [ { name: 'a' } ])
130
+ end
131
+
132
+ it 'saves without conflicting $set paths' do
133
+ expect { cabinet.save! }.not_to raise_error
134
+
135
+ cabinet.reload
136
+ expect(cabinet.drawer.matcher).to eq('all')
137
+ expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
138
+ end
139
+ end
140
+
141
+ context 'when the nested documents are assigned via the parent' do
142
+ before do
143
+ cabinet.assign_attributes(drawer: { matcher: 'all', items: [ { name: 'a' } ] })
144
+ end
145
+
146
+ it 'saves without conflicting $set paths' do
147
+ expect { cabinet.save! }.not_to raise_error
148
+
149
+ cabinet.reload
150
+ expect(cabinet.drawer.matcher).to eq('all')
151
+ expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
152
+ end
153
+ end
154
+
155
+ context 'when an assigned child has its own embedded collection' do
156
+ let(:item) { ModifierModels::Item.new(name: 'a') }
157
+
158
+ before do
159
+ item.assign_attributes(widgets: [ { label: 'w' } ])
160
+ cabinet.build_drawer(matcher: 'all')
161
+ cabinet.drawer.assign_attributes(items: [ item ])
162
+ end
163
+
164
+ it 'does not write the child collection to the root document' do
165
+ expect { cabinet.save! }.not_to raise_error
166
+
167
+ stored = ModifierModels::Cabinet.collection.find(_id: cabinet.id).first
168
+ expect(stored.keys).to contain_exactly('_id', 'drawer')
169
+ expect(stored['drawer']['items'].first['widgets'].first['label']).to eq('w')
170
+ end
171
+ end
172
+ end
173
+
117
174
  end
@@ -268,10 +268,20 @@ describe "Dots and Dollars" do
268
268
  DADMUser.where("$_amount": 0).first
269
269
  end
270
270
 
271
- it "raise an error" do
271
+ it 'raises an error' do
272
272
  expect do
273
273
  queried
274
- end.to raise_error(Mongo::Error::OperationFailure)
274
+ end.to raise_error(Mongoid::Errors::InvalidQuery)
275
+ end
276
+
277
+ context 'when allow_unsafe_query_operators is true' do
278
+ config_override :allow_unsafe_query_operators, true
279
+
280
+ it 'raises an error from the server' do
281
+ expect do
282
+ queried
283
+ end.to raise_error(Mongo::Error::OperationFailure)
284
+ end
275
285
  end
276
286
  end
277
287
  end
@@ -34,19 +34,24 @@ describe 'Encryption' do
34
34
  around do |example|
35
35
  Mongoid.default_client[Crypt::Patient.collection_name].drop
36
36
  Mongoid.default_client[Crypt::Car.collection_name].drop
37
+ Mongoid.default_client[Crypt::Folder.collection_name].drop
37
38
  existing_key_id = Crypt::Patient.encrypt_metadata[:key_id]
38
39
  Crypt::Patient.set_key_id(data_key_id)
39
40
  Crypt::Car.set_key_id(data_key_id)
41
+ Crypt::Note.set_key_id(data_key_id)
40
42
  Mongoid::Config.send(:clients=, config)
41
43
  Mongoid::Clients.with_name(:key_vault)[key_vault_collection].drop
42
44
  Crypt::Patient.store_in(client: :encrypted)
43
45
  Crypt::Car.store_in(client: :encrypted, database: Crypt::Car.storage_options[:database])
46
+ Crypt::Folder.store_in(client: :encrypted)
44
47
 
45
48
  example.run
46
49
 
47
50
  Crypt::Patient.reset_storage_options!
51
+ Crypt::Folder.reset_storage_options!
48
52
  Crypt::Patient.set_key_id(existing_key_id)
49
53
  Crypt::Car.set_key_id(existing_key_id)
54
+ Crypt::Note.set_key_id(existing_key_id)
50
55
  end
51
56
 
52
57
  it 'encrypts and decrypts fields' do
@@ -86,6 +91,17 @@ describe 'Encryption' do
86
91
  end
87
92
  end
88
93
 
94
+ # Nothing about the parent document says it needs a schema: it has no
95
+ # encrypted field of its own and no encrypt_with. Its collection still needs
96
+ # one, or the embedded field goes out in the clear.
97
+ it 'stores an embedded field encrypted when the parent has no encrypted field' do
98
+ folder = Crypt::Folder.create!(note: Crypt::Note.new(text: 'SECRET'))
99
+ unencrypted_client[Crypt::Folder.collection.name].find(_id: folder.id).first.tap do |doc|
100
+ expect(doc['note']['text']).to be_a(BSON::Binary)
101
+ expect(doc['note']['text'].type).to eq(:ciphertext)
102
+ end
103
+ end
104
+
89
105
  it 'stores data encrypted in the non-default database' do
90
106
  car = Crypt::Car.create!(vin: 'VA1234')
91
107
  unencrypted_client
@@ -96,5 +112,287 @@ describe 'Encryption' do
96
112
  end
97
113
  end
98
114
 
115
+ # The encryption schema map is keyed by namespace and built once, when the
116
+ # client is constructed. Whenever the namespace an encrypted model actually
117
+ # writes to is not in that map, the driver encrypts nothing and reports no
118
+ # error, so the field lands in the clear. Mongoid fails closed instead.
119
+ describe 'when the target namespace is not in the encryption schema map' do
120
+ let(:scratch_databases) do
121
+ %w[vehicles_dynamic vehicles_tenant_b vehicles_tenant_c tenant_a]
122
+ end
123
+
124
+ around do |example|
125
+ original_options = {
126
+ Crypt::DynamicCar => Crypt::DynamicCar.storage_options,
127
+ Crypt::TenantCar => Crypt::TenantCar.storage_options
128
+ }
129
+ existing_key_ids = original_options.keys.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
130
+ original_options.each_key do |model|
131
+ model.set_key_id(data_key_id)
132
+ model.store_in(client: :encrypted)
133
+ end
134
+ clean_scratch_data
135
+ # The schema map is generated when the client is built, so the client has
136
+ # to be built after the storage options above are in place.
137
+ Mongoid::Clients.clear
138
+
139
+ example.run
140
+
141
+ clean_scratch_data
142
+ original_options.each { |model, options| model.storage_options = options }
143
+ existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
144
+ Mongoid::Clients.clear
145
+ end
146
+
147
+ def clean_scratch_data
148
+ scratch_databases.each { |database| unencrypted_client.use(database).database.drop }
149
+ # Crypt::Car writes into the shared 'vehicles' database, which other
150
+ # examples rely on, so remove only the documents these examples create.
151
+ unencrypted_client
152
+ .use(Crypt::Car.storage_options[:database])[Crypt::Car.collection_name.to_s]
153
+ .delete_many(vin: { '$in' => %w[CLIENT-1 CLIENT-2] })
154
+ end
155
+
156
+ # Reads with a client that has no automatic encryption, to see what really
157
+ # landed on disk.
158
+ def documents_with_plaintext_vin(database, collection, vin)
159
+ unencrypted_client.use(database)[collection].find(vin: vin).to_a
160
+ end
161
+
162
+ context 'when the database name is a callable' do
163
+ it 'does not store the field in plaintext', :aggregate_failures do
164
+ expect { Crypt::DynamicCar.create!(vin: 'DYNAMIC-1') }
165
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
166
+
167
+ expect(
168
+ documents_with_plaintext_vin('vehicles_dynamic', Crypt::DynamicCar.collection_name.to_s, 'DYNAMIC-1')
169
+ ).to be_empty
170
+ end
171
+ end
172
+
173
+ context 'when the database name is a callable resolved per tenant' do
174
+ around do |example|
175
+ Thread.current[:tenant_database] = 'tenant_a'
176
+ example.run
177
+ Thread.current[:tenant_database] = nil
178
+ end
179
+
180
+ it 'does not store the field in plaintext', :aggregate_failures do
181
+ expect { Crypt::TenantCar.create!(vin: 'TENANT-1') }
182
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
183
+
184
+ expect(
185
+ documents_with_plaintext_vin('tenant_a', Crypt::TenantCar.collection_name.to_s, 'TENANT-1')
186
+ ).to be_empty
187
+ end
188
+ end
189
+
190
+ context 'when the database is overridden for the block' do
191
+ it 'does not store the field in plaintext', :aggregate_failures do
192
+ expect { Crypt::Car.with(database: 'vehicles_tenant_b') { |car| car.create!(vin: 'BLOCK-1') } }
193
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
194
+
195
+ expect(
196
+ documents_with_plaintext_vin('vehicles_tenant_b', Crypt::Car.collection_name.to_s, 'BLOCK-1')
197
+ ).to be_empty
198
+ end
199
+ end
200
+
201
+ context 'when the database is overridden globally' do
202
+ persistence_context_override :database, 'vehicles_tenant_c'
203
+
204
+ it 'does not store the field in plaintext', :aggregate_failures do
205
+ expect { Crypt::Car.create!(vin: 'GLOBAL-1') }
206
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
207
+
208
+ expect(
209
+ documents_with_plaintext_vin('vehicles_tenant_c', Crypt::Car.collection_name.to_s, 'GLOBAL-1')
210
+ ).to be_empty
211
+ end
212
+ end
213
+
214
+ context 'when the model is routed to a client without automatic encryption' do
215
+ it 'does not store the field in plaintext', :aggregate_failures do
216
+ expect { Crypt::Car.with(client: :default) { |car| car.create!(vin: 'CLIENT-1') } }
217
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
218
+
219
+ expect(
220
+ documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-1')
221
+ ).to be_empty
222
+ end
223
+ end
224
+
225
+ context 'when the parent has no encrypted field of its own' do
226
+ it 'does not store the embedded field in plaintext', :aggregate_failures do
227
+ expect { Crypt::Folder.with(client: :default) { |folder| folder.create!(note: Crypt::Note.new(text: 'EMBEDDED-1')) } }
228
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
229
+
230
+ expect(
231
+ unencrypted_client[Crypt::Folder.collection_name.to_s].find('note.text' => 'EMBEDDED-1').to_a
232
+ ).to be_empty
233
+ end
234
+ end
235
+
236
+ context 'when the client is overridden globally to one without automatic encryption' do
237
+ persistence_context_override :client, :default
238
+
239
+ it 'does not store the field in plaintext', :aggregate_failures do
240
+ expect { Crypt::Car.create!(vin: 'CLIENT-2') }
241
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
242
+
243
+ expect(
244
+ documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-2')
245
+ ).to be_empty
246
+ end
247
+ end
248
+ end
249
+
250
+ # The schema map has one entry per namespace, and it has to cover every model
251
+ # that writes there: models sharing a collection, subclasses, and subclasses
252
+ # of embedded models. Documents are read back through a client without
253
+ # automatic encryption, to see what is actually stored.
254
+ describe 'when more than one model writes to a namespace' do
255
+ let(:root_models) do
256
+ [ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::Listing, Crypt::Box, Crypt::Locker ]
257
+ end
99
258
 
259
+ let(:keyed_models) do
260
+ [ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::PrivateListing, Crypt::Box, Crypt::SecretContent ]
261
+ end
262
+
263
+ around do |example|
264
+ original_options = root_models.to_h { |model| [ model, model.storage_options ] }
265
+ existing_key_ids = keyed_models.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
266
+ keyed_models.each { |model| model.set_key_id(data_key_id) }
267
+ root_models.each do |model|
268
+ unencrypted_client[model.collection_name.to_s].drop
269
+ model.store_in(client: :encrypted)
270
+ end
271
+ # The schema map is generated when the client is built, so the client has
272
+ # to be built after the key ids above are in place.
273
+ Mongoid::Clients.clear
274
+
275
+ example.run
276
+
277
+ original_options.each { |model, options| model.storage_options = options }
278
+ existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
279
+ Mongoid::Clients.clear
280
+ end
281
+
282
+ def stored_document(model, id)
283
+ unencrypted_client[model.collection_name.to_s].find(_id: id).first
284
+ end
285
+
286
+ def expect_ciphertext(value)
287
+ expect(value).to be_a(BSON::Binary).and(have_attributes(type: :ciphertext))
288
+ end
289
+
290
+ context 'when two models are stored in one collection' do
291
+ it 'stores the fields of both models encrypted', :aggregate_failures do
292
+ badge = Crypt::Badge.create!(serial: 'SERIAL-1')
293
+ scan = Crypt::BadgeScan.create!(holder: 'HOLDER-1')
294
+
295
+ expect_ciphertext(stored_document(Crypt::Badge, badge.id)['serial'])
296
+ expect_ciphertext(stored_document(Crypt::BadgeScan, scan.id)['holder'])
297
+ end
298
+ end
299
+
300
+ context 'when a subclass adds an encrypted field to an encrypted parent' do
301
+ it 'stores the fields of the parent and of the subclass encrypted', :aggregate_failures do
302
+ employee = Crypt::Employee.create!(ssn: 'SSN-1', salary: 'SALARY-1')
303
+
304
+ stored_document(Crypt::Member, employee.id).tap do |doc|
305
+ expect_ciphertext(doc['ssn'])
306
+ expect_ciphertext(doc['salary'])
307
+ end
308
+ end
309
+
310
+ it 'stores the fields of a sibling subclass encrypted' do
311
+ contractor = Crypt::Contractor.create!(ssn: 'SSN-2', rate: 'RATE-1')
312
+
313
+ expect_ciphertext(stored_document(Crypt::Member, contractor.id)['rate'])
314
+ end
315
+
316
+ it 'reads the fields back' do
317
+ employee = Crypt::Employee.create!(ssn: 'SSN-3', salary: 'SALARY-3')
318
+
319
+ expect(Crypt::Member.find(employee.id).salary).to eq('SALARY-3')
320
+ end
321
+ end
322
+
323
+ context 'when only a subclass has encrypted fields' do
324
+ it 'stores the fields of the subclass encrypted' do
325
+ listing = Crypt::PrivateListing.create!(title: 'TITLE-1', owner_ssn: 'SSN-4')
326
+
327
+ expect_ciphertext(stored_document(Crypt::Listing, listing.id)['owner_ssn'])
328
+ end
329
+ end
330
+
331
+ context 'when a subclass of an embeds_one target adds an encrypted field' do
332
+ it 'stores the fields of the target and of the subclass encrypted', :aggregate_failures do
333
+ box = Crypt::Box.create!(item: Crypt::SealedItem.new(label: 'LABEL-1', seal: 'SEAL-1'))
334
+
335
+ stored_document(Crypt::Box, box.id)['item'].tap do |item|
336
+ expect_ciphertext(item['label'])
337
+ expect_ciphertext(item['seal'])
338
+ end
339
+ end
340
+ end
341
+
342
+ context 'when only a subclass of an embeds_one target has encrypted fields' do
343
+ it 'stores the fields of the subclass encrypted' do
344
+ locker = Crypt::Locker.create!(content: Crypt::SecretContent.new(label: 'LABEL-2', code: 'CODE-1'))
345
+
346
+ expect_ciphertext(stored_document(Crypt::Locker, locker.id)['content']['code'])
347
+ end
348
+ end
349
+
350
+ # An entry for the namespace exists, but it does not cover every encrypted
351
+ # field of the model. The driver encrypts what the entry lists and writes
352
+ # the rest in plaintext.
353
+ context 'when the schema entry for the namespace misses an encrypted field' do
354
+ let(:config) do
355
+ super().merge(
356
+ partial: {
357
+ hosts: SpecConfig.instance.addresses,
358
+ database: database_id,
359
+ options: {
360
+ auto_encryption_options: {
361
+ key_vault_client: :key_vault,
362
+ kms_providers: kms_providers,
363
+ key_vault_namespace: key_vault_namespace,
364
+ extra_options: extra_options,
365
+ schema_map: partial_schema_map
366
+ }
367
+ }
368
+ }
369
+ )
370
+ end
371
+
372
+ let(:partial_schema_map) do
373
+ {
374
+ "#{database_id}.#{Crypt::Member.collection_name}" => {
375
+ 'bsonType' => 'object',
376
+ 'encryptMetadata' => {
377
+ 'keyId' => [ BSON::Binary.new(Base64.decode64(data_key_id), :uuid) ],
378
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
379
+ },
380
+ 'properties' => {
381
+ 'ssn' => { 'encrypt' => { 'bsonType' => 'string' } }
382
+ }
383
+ }
384
+ }
385
+ end
386
+
387
+ it 'does not store the field in plaintext', :aggregate_failures do
388
+ expect do
389
+ Crypt::Employee.with(client: :partial) { |klass| klass.create!(ssn: 'SSN-5', salary: 'PARTIAL-1') }
390
+ end.to raise_error(Mongoid::Errors::NoEncryptionSchema)
391
+
392
+ expect(
393
+ unencrypted_client[Crypt::Member.collection_name.to_s].find(salary: 'PARTIAL-1').to_a
394
+ ).to be_empty
395
+ end
396
+ end
397
+ end
100
398
  end
@@ -135,6 +135,27 @@
135
135
  $regex: bar
136
136
  matches: false
137
137
 
138
+ # Object#=~ is gone as of Ruby 3.2, so an element that cannot be matched
139
+ # against has to be skipped rather than passed to =~.
140
+ - name: array field value - element that cannot be matched against
141
+ document:
142
+ title:
143
+ - 42
144
+ - foo
145
+ query:
146
+ title:
147
+ $regex: foo
148
+ matches: true
149
+
150
+ - name: array field value - no element can be matched against
151
+ document:
152
+ title:
153
+ - 42
154
+ query:
155
+ title:
156
+ $regex: foo
157
+ matches: false
158
+
138
159
  - name: true field value
139
160
  document:
140
161
  title: true