Skip to content

Commit 9c83900

Browse files
authored
Encryption casting with encrypts before serialize (rails#52650)
* Encryption casting with `encrypts` before `serialize` A demonstration of how we can simplify encryption serialization and deserialization if we call `encrypts` before `serialize`. Calling cast_type.deserialize before decrypting fixes the issue where binary data cannot be decrypted on PostgreSQL. We would need to update the docs to recommend that `encrypts` is called before `serialize` to take advantage of this and possibly raise an error if they are called the other way round. * Nest encrypted attribute types within serialized types Make the order in which `serializes :foo` and `encrypts :foo` are called irrelevant by always nesting the encrypted attribute type inside the serialized type. This required switching from `DelegateClass` to `SimpleDelegator` so that object we are delegating to can be replaced. To ensure that the serialized type survives YAML serialization (there's a test for this), we need to implement init_with to call __setobj__. * Update docs and changelog * Switch back to DelegateClass(ActiveModel::Type::Value) Thanks to @rafaelfranca for the suggestion.
1 parent 984b10b commit 9c83900

File tree

8 files changed

+64
-45
lines changed

8 files changed

+64
-45
lines changed

activerecord/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* Deserialize database values before decryption
2+
3+
PostgreSQL binary values (`ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Bytea`)
4+
need to be deserialized before they are decrypted.
5+
6+
Additionally ensure that the order of serialization/deserialization is consistent
7+
for `serialize :foo` and `encrypts :foo` whichever order they are declared in.
8+
9+
*Donal McBreen*
10+
111
* Infer default `:inverse_of` option for `delegated_type` definitions.
212

313
```ruby

activerecord/lib/active_record/encryption/encryptable_record.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,17 @@ def encrypt_attribute(name, key_provider: nil, key: nil, deterministic: false, s
8888
scheme = scheme_for key_provider: key_provider, key: key, deterministic: deterministic, support_unencrypted_data: support_unencrypted_data, \
8989
downcase: downcase, ignore_case: ignore_case, previous: previous, compress: compress, compressor: compressor, **context_properties
9090

91-
ActiveRecord::Encryption::EncryptedAttributeType.new(scheme: scheme, cast_type: cast_type, default: columns_hash[name.to_s]&.default)
91+
type_options = { scheme: scheme, default: columns_hash[name.to_s]&.default }
92+
93+
if cast_type.serialized?
94+
cast_type.tap do |serialized_type|
95+
serialized_type.replace_serialized_subtype do |current_subtype|
96+
ActiveRecord::Encryption::EncryptedAttributeType.new(cast_type: current_subtype, **type_options)
97+
end
98+
end
99+
else
100+
ActiveRecord::Encryption::EncryptedAttributeType.new(cast_type: cast_type, **type_options)
101+
end
92102
end
93103

94104
preserve_original_encrypted(name) if ignore_case

activerecord/lib/active_record/encryption/encrypted_attribute_type.rb

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def cast(value)
3333
end
3434

3535
def deserialize(value)
36-
cast_type.deserialize decrypt(value)
36+
decrypt(cast_type.deserialize(value))
3737
end
3838

3939
def serialize(value)
@@ -81,7 +81,7 @@ def previous_type?
8181
@previous_type
8282
end
8383

84-
def decrypt_as_text(value)
84+
def decrypt(value)
8585
with_context do
8686
unless value.nil?
8787
if @default && @default == value
@@ -99,10 +99,6 @@ def decrypt_as_text(value)
9999
end
100100
end
101101

102-
def decrypt(value)
103-
text_to_database_type decrypt_as_text(value)
104-
end
105-
106102
def try_to_deserialize_with_previous_encrypted_types(value)
107103
previous_types.each.with_index do |type, index|
108104
break type.deserialize(value)
@@ -128,12 +124,11 @@ def serialize_with_oldest(value)
128124
end
129125

130126
def serialize_with_current(value)
131-
casted_value = cast_type.serialize(value)
132-
casted_value = casted_value&.downcase if downcase?
133-
encrypt(casted_value.to_s) unless casted_value.nil?
127+
value = value&.downcase if downcase?
128+
cast_type.serialize(encrypt(value.to_s)) unless value.nil?
134129
end
135130

136-
def encrypt_as_text(value)
131+
def encrypt(value)
137132
with_context do
138133
if encryptor.binary? && !cast_type.binary?
139134
raise Errors::Encoding, "Binary encoded data can only be stored in binary columns"
@@ -143,10 +138,6 @@ def encrypt_as_text(value)
143138
end
144139
end
145140

146-
def encrypt(value)
147-
text_to_database_type encrypt_as_text(value)
148-
end
149-
150141
def encryptor
151142
ActiveRecord::Encryption.encryptor
152143
end
@@ -162,14 +153,6 @@ def decryption_options
162153
def clean_text_scheme
163154
@clean_text_scheme ||= ActiveRecord::Encryption::Scheme.new(downcase: downcase?, encryptor: ActiveRecord::Encryption::NullEncryptor.new)
164155
end
165-
166-
def text_to_database_type(value)
167-
if value && cast_type.binary?
168-
ActiveModel::Type::Binary::Data.new(value)
169-
else
170-
value
171-
end
172-
end
173156
end
174157
end
175158
end

activerecord/lib/active_record/type/serialized.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ def initialize(subtype, coder)
1515
super(subtype)
1616
end
1717

18+
def init_with(coder) # :nodoc:
19+
# Ensures YAML deserialization calls __setobj__
20+
@subtype = coder["subtype"]
21+
@coder = coder["coder"]
22+
__setobj__(subtype)
23+
end
24+
1825
def deserialize(value)
1926
if default_value?(value)
2027
value
@@ -57,6 +64,12 @@ def serialized? # :nodoc:
5764
true
5865
end
5966

67+
def replace_serialized_subtype(&block) # :nodoc:
68+
@subtype = block.call(subtype)
69+
__setobj__(@subtype)
70+
self
71+
end
72+
6073
private
6174
def default_value?(value)
6275
value == coder.load(nil)

activerecord/test/cases/encryption/encryptable_record_message_pack_serialized_test.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,22 @@
55
require "models/book_encrypted"
66
require "active_record/encryption/message_pack_message_serializer"
77

8-
class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::EncryptionTestCase
8+
class ActiveRecord::Encryption::EncryptableRecordMessagePackSerializedTest < ActiveRecord::EncryptionTestCase
99
fixtures :encrypted_books
1010

1111
test "binary data can be serialized with message pack" do
1212
all_bytes = (0..255).map(&:chr).join
13-
assert_equal all_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: all_bytes).logo
13+
book = EncryptedBookWithBinaryMessagePackSerialized.create!(logo: all_bytes)
14+
assert_encrypted_attribute(book, :logo, all_bytes)
1415
end
1516

1617
test "binary data can be encrypted uncompressed and serialized with message pack" do
18+
# Strings below 140 bytes are not compressed
1719
low_bytes = (0..127).map(&:chr).join
1820
high_bytes = (128..255).map(&:chr).join
19-
assert_equal low_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: low_bytes).logo
20-
assert_equal high_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: high_bytes).logo
21+
22+
assert_encrypted_attribute(EncryptedBookWithBinaryMessagePackSerialized.create!(logo: low_bytes), :logo, low_bytes)
23+
assert_encrypted_attribute(EncryptedBookWithBinaryMessagePackSerialized.create!(logo: high_bytes), :logo, high_bytes)
2124
end
2225

2326
test "text columns cannot be serialized with message pack" do

activerecord/test/cases/encryption/encryptable_record_test.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::Encryption
9292
assert_encrypted_attribute(traffic_light, :state, states)
9393
end
9494

95+
test "encrypts serialized attributes where encrypts is declared first" do
96+
states = ["green", "red"]
97+
traffic_light = EncryptedFirstTrafficLight.create!(state: states, long_state: states)
98+
assert_encrypted_attribute(traffic_light, :state, states)
99+
end
100+
95101
test "encrypts store attributes with accessors" do
96102
traffic_light = EncryptedTrafficLightWithStoreState.create!(color: "red", long_state: ["green", "red"])
97103
assert_equal "red", traffic_light.color
@@ -404,13 +410,13 @@ def name
404410
test "binary data can be encrypted uncompressed" do
405411
low_bytes = (0..127).map(&:chr).join
406412
high_bytes = (128..255).map(&:chr).join
407-
assert_equal low_bytes, EncryptedBookWithBinary.create!(logo: low_bytes).logo
408-
assert_equal high_bytes, EncryptedBookWithBinary.create!(logo: high_bytes).logo
413+
assert_encrypted_attribute EncryptedBookWithBinary.create!(logo: low_bytes), :logo, low_bytes
414+
assert_encrypted_attribute EncryptedBookWithBinary.create!(logo: high_bytes), :logo, high_bytes
409415
end
410416

411417
test "serialized binary data can be encrypted" do
412418
json_bytes = (32..127).map(&:chr)
413-
assert_equal json_bytes, EncryptedBookWithSerializedBinary.create!(logo: json_bytes).logo
419+
assert_encrypted_attribute EncryptedBookWithSerializedBinary.create!(logo: json_bytes), :logo, json_bytes
414420
end
415421

416422
test "can compress data with custom compressor" do

activerecord/test/models/traffic_light_encrypted.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ class EncryptedTrafficLight < TrafficLight
77
encrypts :state
88
end
99

10+
class EncryptedFirstTrafficLight < ActiveRecord::Base
11+
self.table_name = "traffic_lights"
12+
13+
encrypts :state
14+
serialize :state, type: Array
15+
serialize :long_state, type: Array
16+
end
17+
1018
class EncryptedTrafficLightWithStoreState < TrafficLight
1119
store :state, accessors: %i[ color ], coder: ActiveRecord::Coders::JSON
1220
encrypts :state

guides/source/active_record_encryption.md

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,21 +147,7 @@ To encrypt Action Text fixtures, you should place them in `fixtures/action_text/
147147

148148
`active_record.encryption` will serialize values using the underlying type before encrypting them, but, unless using a custom `message_serializer`, *they must be serializable as strings*. Structured types like `serialized` are supported out of the box.
149149

150-
If you need to support a custom type, the recommended way is using a [serialized attribute](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html). The declaration of the serialized attribute should go **before** the encryption declaration:
151-
152-
```ruby
153-
# CORRECT
154-
class Article < ApplicationRecord
155-
serialize :title, type: Title
156-
encrypts :title
157-
end
158-
159-
# INCORRECT
160-
class Article < ApplicationRecord
161-
encrypts :title
162-
serialize :title, type: Title
163-
end
164-
```
150+
If you need to support a custom type, the recommended way is using a [serialized attribute](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html).
165151

166152
### Ignoring Case
167153

0 commit comments

Comments
 (0)