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.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +65 -0
- data/lib/mongoid/association/depending.rb +8 -4
- data/lib/mongoid/association/embedded/batchable.rb +8 -0
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/relatable.rb +17 -0
- data/lib/mongoid/clients/factory.rb +2 -0
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/encryption.rb +171 -22
- data/lib/mongoid/config.rb +56 -9
- data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
- data/lib/mongoid/encryptable.rb +73 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
- data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
- data/lib/mongoid/errors.rb +3 -0
- data/lib/mongoid/field_readable.rb +70 -0
- data/lib/mongoid/indexable.rb +39 -27
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
- data/lib/mongoid/matcher/regex.rb +9 -13
- data/lib/mongoid/matcher/regexp_budget.rb +383 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistence_context.rb +77 -0
- data/lib/mongoid/search_indexable.rb +19 -8
- data/lib/mongoid/tasks/database.rb +33 -16
- data/lib/mongoid/threaded.rb +37 -0
- data/lib/mongoid/version.rb +1 -1
- data/lib/mongoid/warnings.rb +4 -0
- data/spec/integration/app_spec.rb +7 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
- data/spec/integration/atomic/modifiers_spec.rb +57 -0
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +298 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/attributes/nested_spec.rb +204 -10
- data/spec/mongoid/clients/factory_spec.rb +36 -0
- data/spec/mongoid/config/defaults_spec.rb +18 -0
- data/spec/mongoid/config/encryption_spec.rb +451 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
- data/spec/mongoid/criteria_spec.rb +2 -0
- data/spec/mongoid/encryptable_spec.rb +115 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +576 -0
- data/spec/mongoid/search_indexable_spec.rb +34 -1
- data/spec/mongoid/tasks/database_spec.rb +31 -0
- data/spec/support/crypt/models.rb +253 -0
- data/spec/support/models/modifier_models.rb +32 -0
- 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
|
-
|
|
68
|
+
Threaded.with_collection_management do
|
|
69
|
+
searchable = models.select { |m| m.search_index_specs.any? }
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
data/lib/mongoid/threaded.rb
CHANGED
|
@@ -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.
|
data/lib/mongoid/version.rb
CHANGED
data/lib/mongoid/warnings.rb
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
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
|
|
271
|
+
it 'raises an error' do
|
|
272
272
|
expect do
|
|
273
273
|
queried
|
|
274
|
-
end.to raise_error(
|
|
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
|