Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce compressor option to ActiveRecord::Encryption::Encryptor #51735

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 20 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
* `ActiveRecord::Encryption::Encryptor` now supports a `:compressor` option to customize the compression algorithm used.

```ruby
module ZstdCompressor
def self.deflate(data)
heka1024 marked this conversation as resolved.
Show resolved Hide resolved
Zstd.compress(data)
end

def self.inflate(data)
Zstd.decompress(data)
end
end

class User
encrypts :name, encryptor: ActiveRecord::Encryption::Encryptor.new(compressor: ZstdCompressor)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API doesn't feel right. Having to initialize a new Encryptor in your app to set the compressor feels cumbersome and un-Rails like. I have two suggestions:

  1. I think if we want a compressor option for every model then Rails should do the initialization for the app. The AR code would check if the compressor option is set and then Rails would do the initialization for the application. Then it's a much cleaner API because it becomes:
encrypts :name, compressor: ZstdCompressor
  1. Rails should provide a config option so that applications can set all encryptors to use the same custom compressor.
config.active_record_encryption.compressor = ZstdCompressor

Copy link
Contributor Author

@heka1024 heka1024 May 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eileencodes Thank you for review! Your talk at the 2023 Rails conference inspired me to contribute to Rails, and I'm glad I got a review 😄

  1. This API seems nicer than the earlier one. I'll implement it. Additionally, I think we should accept the compress option in the encrypts method, as introduced in Allow encryption without compression #50876. I'll handle that in this PR. So, we can use like this :
encrypts :title, compress: false
encrypts :content, compressor: ZstdCompressor
  1. I've already implemented this feature in Config. Should I just add it to the CHANGELOG?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@heka1024 New config parameters require a little more documentation work than other features. Specifically, you'll need to update guides/source/configuring.md.

There is a linting tool you should run to make sure configuring.md changes are correct, run tools/railspect configuration .

end
```

*heka1024*

* Add public method for checking if a table is ignored by the schema cache.

Previously, an application would need to reimplement `ignored_table?` from the schema cache class to check if a table was set to be ignored. This adds a public method to support this and updates the schema cache to use that directly.
Expand Down
4 changes: 3 additions & 1 deletion activerecord/lib/active_record/encryption/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ module Encryption
class Config
attr_accessor :primary_key, :deterministic_key, :store_key_references, :key_derivation_salt, :hash_digest_class,
:support_unencrypted_data, :encrypt_fixtures, :validate_column_size, :add_to_filter_parameters,
:excluded_from_filter_parameters, :extend_queries, :previous_schemes, :forced_encoding_for_deterministic_encryption
:excluded_from_filter_parameters, :extend_queries, :previous_schemes, :forced_encoding_for_deterministic_encryption,
:compressor

def initialize
set_defaults
Expand Down Expand Up @@ -55,6 +56,7 @@ def set_defaults
self.previous_schemes = []
self.forced_encoding_for_deterministic_encryption = Encoding::UTF_8
self.hash_digest_class = OpenSSL::Digest::SHA1
self.compressor = Zlib

# TODO: Setting to false for now as the implementation is a bit experimental
self.extend_queries = false
Expand Down
11 changes: 8 additions & 3 deletions activerecord/lib/active_record/encryption/encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ class Encryptor
#
# * <tt>:compress</tt> - Boolean indicating whether records should be compressed before encryption.
# Defaults to +true+.
def initialize(compress: true)
# * <tt>:compressor</tt> - The compressor to use.
# 1. If compressor is provided, it will be used.
# 2. If not, it will use ActiveRecord::Encryption.config.compressor which default value is +Zlib+.
# If you want to use a custom compressor, it must respond to +deflate+ and +inflate+.
def initialize(compress: true, compressor: nil)
@compress = compress
@compressor = compressor || ActiveRecord::Encryption.config.compressor
end

# Encrypts +clean_text+ and returns the encrypted result
Expand Down Expand Up @@ -135,7 +140,7 @@ def compress?
end

def compress(data)
Zlib::Deflate.deflate(data).tap do |compressed_data|
@compressor.deflate(data).tap do |compressed_data|
compressed_data.force_encoding(data.encoding)
end
end
Expand All @@ -149,7 +154,7 @@ def uncompress_if_needed(data, compressed)
end

def uncompress(data)
Zlib::Inflate.inflate(data).tap do |uncompressed_data|
@compressor.inflate(data).tap do |uncompressed_data|
uncompressed_data.force_encoding(data.encoding)
end
end
Expand Down
16 changes: 16 additions & 0 deletions activerecord/test/cases/encryption/encryptor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ class ActiveRecord::Encryption::EncryptorTest < ActiveRecord::EncryptionTestCase
assert_equal Encoding::ISO_8859_1, decrypted_text.encoding
end

test "accept a custom compressor" do
compressor = Module.new do
def self.deflate(data)
"compressed #{data}"
end

def self.inflate(data)
data.sub(/\Acompressed /, "")
end
end
@encryptor = ActiveRecord::Encryption::Encryptor.new(compressor: compressor)
content = SecureRandom.hex(5.kilobytes)

assert_encrypt_text content
end

private
def assert_encrypt_text(clean_text)
encrypted_text = @encryptor.encrypt(clean_text)
Expand Down
40 changes: 40 additions & 0 deletions guides/source/active_record_encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,42 @@ And you can disable this behavior and preserve the encoding in all cases with:
config.active_record.encryption.forced_encoding_for_deterministic_encryption = nil
```

### Compression

The library compresses encrypted payloads by default. This can save up to 30% of the storage space for larger payloads. You can disable compression by settings `compress: false` to encrypted attributes:

```ruby
class Article < ApplicationRecord
encrypts :content, encryptor: ActiveRecord::Encryption::Encryptor.new(compress: false)
end
```

Also, you can configure the algorithm used for the compression. The default is `Zlib`. You can implement your own compressor by creating a class or module that responds to `def deflate(data)` and `def inflate(data)`.

```ruby
require "zstd-ruby"

module ZstdCompressor
def self.deflate(data)
Zstd.compress(data)
end

def self.inflate(data)
Zstd.decompress(data)
end
end

class User
encrypts :name, encryptor: ActiveRecord::Encryption::Encryptor.new(compressor: ZstdCompressor)
end
```

You can configure the compressor globally:

```ruby
config.active_record.encryption.compressor = ZstdCompressor
```

## Key Management

Key providers implement key management strategies. You can configure key providers globally, or on a per attribute basis.
Expand Down Expand Up @@ -497,6 +533,10 @@ The digest algorithm used to derive keys. `OpenSSL::Digest::SHA256` by default.
Supports decrypting data encrypted non-deterministically with a digest class SHA1. Default is false, which
means it will only support the digest algorithm configured in `config.active_record.encryption.hash_digest_class`.

#### `config.active_record.encryption.compressor`

The compressor used to compress encrypted payloads. It should respond to `deflate` and `inflate`. Default is `Zlib`. You can find more information about compressors in the [Compression](#compression) section.

### Encryption Contexts

An encryption context defines the encryption components that are used in a given moment. There is a default encryption context based on your global configuration, but you can configure a custom context for a given attribute or when running a specific block of code.
Expand Down