Skip to content

Commit

Permalink
Encrypt cache values via Solid Cache config
Browse files Browse the repository at this point in the history
Allows you to enable encryption of cache values with:

```yaml
\# config/solid_cache.yml
production:
  encrypt: true
```
or
```ruby
\# application.rb
config.solid_cache.encrypt = true
```

Requires Active Record Encryption to be configured already.

Solid Cache by default uses a custom encryptor and message serializer
that are optimised for it.

Firstly it disabled compression with the encryptor
`ActiveRecord::Encryption::Encryptor.new(compress: false)` - the cache
already compresses the data.

Secondly it uses `ActiveRecord::Encryption::MessagePackMessageSerializer.new`
as the serializer. This serializer can only be used for binary columns,
but can store about 40% more data than the standard serializer.

Or allow custom context properties to be set:

```ruby
\# application.rb
config.solid_cache.encryption_context_properties = {
  encryptor: ActiveRecord::Encryption::Encryptor.new,
  message_serializer: ActiveRecord::Encryption::MessageSerializer.new
}
```
  • Loading branch information
djmb committed Aug 13, 2024
1 parent 8eaf08b commit 86fb61c
Show file tree
Hide file tree
Showing 17 changed files with 239 additions and 28 deletions.
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ There are three options that can be set on the engine:

- `executor` - the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html#executor) used to wrap asynchronous operations, defaults to the app executor
- `connects_to` - a custom connects to value for the abstract `SolidCache::Record` active record model. Required for sharding and/or using a separate cache database to the main app. This will overwrite any value set in `config/solid_cache.yml`
- `size_estimate_samples` - if `max_size` is set on the cache, the number of the samples used to estimates the size
- `size_estimate_samples` - if `max_size` is set on the cache, the number of the samples used to estimate the size.
- `encrypted` - whether cache values should be encrypted (see [Enabling encryption](#enabling-encryption))
- `encryption_context_properties` - custom encryption context properties

These can be set in your Rails configuration:

Expand Down Expand Up @@ -239,12 +241,35 @@ production:

### Enabling encryption

Add this to an initializer:
To encrypt the cache values, you can add set the encrypt property.

```yaml
# config/solid_cache.yml
production:
encrypt: true
```
or
```ruby
ActiveSupport.on_load(:solid_cache_entry) do
encrypts :value
end
# application.rb
config.solid_cache.encrypt = true
```

You will need to set up your application to (use Active Record Encryption)[https://guides.rubyonrails.org/active_record_encryption.html].

Solid Cache by default uses a custom encryptor and message serializer that are optimised for it.

Firstly it disabled compression with the encryptor `ActiveRecord::Encryption::Encryptor.new(compress: false)` - the cache already compresses the data.
Secondly it uses `ActiveRecord::Encryption::MessagePackMessageSerializer.new` as the serializer. This serializer can only be used for binary columns,
but can store about 40% more data than the standard serializer.

You can choose your own context properties instead if you prefer:

```ruby
# application.rb
config.solid_cache.encryption_context_properties = {
encryptor: ActiveRecord::Encryption::Encryptor.new,
message_serializer: ActiveRecord::Encryption::MessageSerializer.new
}
```

### Index size limits
Expand Down
6 changes: 6 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ def run_without_aborting(*tasks)
end

def configs
<<<<<<< HEAD
[ :default, :connects_to, :database, :no_database, :shards, :unprepared_statements ]
=======
[ :default, :connects_to, :database, :encrypted, :encrypted_custom, :no_database, :shards, :unprepared_statements ]
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
end

task :test do
Expand All @@ -42,3 +46,5 @@ configs.each do |config|
end
end
end

task default: [:test]
2 changes: 1 addition & 1 deletion app/models/solid_cache/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module SolidCache
class Entry < Record
include Expiration, Size
include Encryption, Expiration, Size

# The estimated cost of an extra row in bytes, including fixed size columns, overhead, indexes and free space
# Based on experimentation on SQLite, MySQL and Postgresql.
Expand Down
15 changes: 15 additions & 0 deletions app/models/solid_cache/entry/encryption.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module SolidCache
class Entry
module Encryption
extend ActiveSupport::Concern

included do
if SolidCache.configuration.encrypt?
encrypts :value, **SolidCache.configuration.encryption_context_properties
end
end
end
end
end
59 changes: 59 additions & 0 deletions gemfiles/rails_main.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,15 @@ GEM
thor (>= 0.14.0)
ast (2.4.2)
base64 (0.2.0)
<<<<<<< HEAD
bigdecimal (3.1.8)
builder (3.3.0)
concurrent-ruby (1.3.4)
=======
bigdecimal (3.1.7)
builder (3.2.4)
concurrent-ruby (1.2.3)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
connection_pool (2.4.1)
crass (1.0.6)
debug (1.9.2)
Expand All @@ -90,6 +96,7 @@ GEM
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
<<<<<<< HEAD
minitest (5.24.1)
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
Expand All @@ -103,13 +110,33 @@ GEM
racc (~> 1.4)
parallel (1.26.2)
parser (3.3.4.2)
=======
minitest (5.21.2)
mocha (2.2.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
mysql2 (0.5.6)
nokogiri (1.16.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux)
racc (~> 1.4)
parallel (1.24.0)
parser (3.3.1.0)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
ast (~> 2.4.1)
racc
pg (1.5.7)
psych (5.1.2)
stringio
<<<<<<< HEAD
racc (1.8.1)
rack (3.1.7)
=======
racc (1.7.3)
rack (3.0.10)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
Expand All @@ -126,6 +153,7 @@ GEM
nokogiri (~> 1.14)
rainbow (3.1.1)
rake (13.2.1)
<<<<<<< HEAD
rdoc (6.7.0)
psych (>= 4.0.0)
regexp_parser (2.9.2)
Expand All @@ -134,6 +162,15 @@ GEM
rexml (3.3.5)
strscan
rubocop (1.65.1)
=======
rdoc (6.6.3.1)
psych (>= 4.0.0)
regexp_parser (2.9.0)
reline (0.5.4)
io-console (~> 0.5)
rexml (3.2.6)
rubocop (1.63.4)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
Expand All @@ -144,19 +181,34 @@ GEM
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
<<<<<<< HEAD
rubocop-ast (1.32.0)
parser (>= 3.3.1.0)
rubocop-md (1.2.2)
rubocop (>= 1.0)
rubocop-minitest (0.35.1)
=======
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-md (1.2.2)
rubocop (>= 1.0)
rubocop-minitest (0.35.0)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-packaging (0.5.2)
rubocop (>= 1.33, < 2.0)
<<<<<<< HEAD
rubocop-performance (1.21.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.25.1)
=======
rubocop-performance (1.21.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.24.1)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
Expand All @@ -171,11 +223,18 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
<<<<<<< HEAD
sqlite3 (2.0.3-arm64-darwin)
sqlite3 (2.0.3-x86_64-darwin)
sqlite3 (2.0.3-x86_64-linux-gnu)
stringio (3.1.1)
strscan (3.1.0)
=======
sqlite3 (2.0.1-arm64-darwin)
sqlite3 (2.0.1-x86_64-darwin)
sqlite3 (2.0.1-x86_64-linux-gnu)
stringio (3.1.0)
>>>>>>> e7305dc (Encrypt cache values via Solid Cache config)
thor (1.3.1)
timeout (0.4.1)
tzinfo (2.0.6)
Expand Down
22 changes: 20 additions & 2 deletions lib/solid_cache/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

module SolidCache
class Configuration
attr_reader :store_options, :connects_to, :executor, :size_estimate_samples
attr_reader :store_options, :connects_to, :executor, :size_estimate_samples, :encrypt, :encryption_context_properties

def initialize(store_options: {}, database: nil, databases: nil, connects_to: nil, executor: nil, size_estimate_samples: 10_000)
def initialize(store_options: {}, database: nil, databases: nil, connects_to: nil, executor: nil, encrypt: false, encryption_context_properties: nil, size_estimate_samples: 10_000)
@store_options = store_options
@size_estimate_samples = size_estimate_samples
@executor = executor
@encrypt = encrypt
@encryption_context_properties = encryption_context_properties
@encryption_context_properties ||= default_encryption_context_properties if encrypt?
set_connects_to(database: database, databases: databases, connects_to: connects_to)
end

Expand All @@ -19,6 +22,10 @@ def shard_keys
sharded? ? connects_to[:shards].keys : []
end

def encrypt?
encrypt.present?
end

private
def set_connects_to(database:, databases:, connects_to:)
if [database, databases, connects_to].compact.size > 1
Expand All @@ -37,5 +44,16 @@ def set_connects_to(database:, databases:, connects_to:)
nil
end
end

def default_encryption_context_properties
require "active_record/encryption/message_pack_message_serializer"

{
# No need to compress, the cache does that already
encryptor: ActiveRecord::Encryption::Encryptor.new(compress: false),
# Binary column only serializer that is 40% more efficient than the default MessageSerializer
message_serializer: ActiveRecord::Encryption::MessagePackMessageSerializer.new
}
end
end
end
2 changes: 2 additions & 0 deletions lib/solid_cache/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class Engine < ::Rails::Engine

options[:connects_to] = config.solid_cache.connects_to if config.solid_cache.connects_to
options[:size_estimate_samples] = config.solid_cache.size_estimate_samples if config.solid_cache.size_estimate_samples
options[:encrypt] = config.solid_cache.encrypt if config.solid_cache.encrypt
options[:encryption_context_properties] = config.solid_cache.encryption_context_properties if config.solid_cache.encryption_context_properties

SolidCache.configuration = SolidCache::Configuration.new(**options)

Expand Down
15 changes: 8 additions & 7 deletions test/dummy/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ class Application < Rails::Application

config.cache_store = :solid_cache_store

# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
config.active_record.encryption.key_provider = ActiveRecord::Encryption::EnvelopeEncryptionKeyProvider.new

if ENV["SOLID_CACHE_CONFIG"] == "config/solid_cache_encrypted_custom.yml"
config.solid_cache.encryption_context_properties = {
encryptor: ActiveRecord::Encryption::Encryptor.new,
message_serializer: ActiveRecord::Encryption::MessageSerializer.new
}
end

initializer :custom_solid_cache_yml, before: :solid_cache do |app|
app.paths.add "config/solid_cache", with: ENV["SOLID_CACHE_CONFIG_PATH"]
Expand Down
1 change: 1 addition & 0 deletions test/dummy/config/credentials.yml.enc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
KXs4HRH7tZqQZmVrP872Vy2OLbDJ3OoirCMzMrsRxDqraDWMLPB+XdNfVD9RhEDiKw05LNXPAGCIVi82dY/I4TJA2B8tz0cEX0DrfBphZ4Azo0gxjVva5xxfMcaQxa8Zv5lNtn+MXgXVOnO8FedKhrBxNFManCxaaBjZKlui9UTS/y3DeLvPjgFhSgZ6YcPQVoAU3kI08liXWUsleDFBEbaw8vJ0lKT20i5iKi/VgOyGBSju/sHW6EYUsQO6peOD8lWackcficA4I6tQ04Ce4GeYIP7KkA/tlTCwkC3oaS9Km+04J3zGfUS/9zh9apHw+C0zuoAr59+dmYNuKg3DhuTWjbI7iXjViUzGbnHRnv+qJXON4Nb3wwAjsmNgPGPqrObruzaQ10kuRCFnMgyy2GbXq/DJlZ2ZScgNItd/Z1Gcw0K0RXw6LDV+pkg8CnTYCfNJfA5NAbD/GNFv8NC9N5sJqDJoEhUfVwncbcUfX13HNqiAvO2XkxdIMzlMOVQijkT2CltmhVWYB1SRCLQv6kbd+iu/sSGcu4ropsVcxgonKkch5JsSjIXwEWCOpaDVg3Jm8RiJWP+LwjhfEC5/OSHOmFGKfIwHGSmLNvazgfx6odHnqW3ZPyTewcgYhK83ok1BS4GwX1UjDs1ZqXD3DX/W63pknqdx--lfZoS+MYEht37XUa--ICZ1nYvIPt+EI+k2Ti4pTA==
1 change: 1 addition & 0 deletions test/dummy/config/master.key
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
068c2c12595c52b8593721734c34dfed
13 changes: 13 additions & 0 deletions test/dummy/config/solid_cache_encrypted.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
default: &default
databases: [primary_shard_one, primary_shard_two, secondary_shard_one, secondary_shard_two]
encrypt: true

store_options:
max_age: 3600
max_size:

development:
<<: *default

test:
<<: *default
13 changes: 13 additions & 0 deletions test/dummy/config/solid_cache_encrypted_custom.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
default: &default
databases: [primary_shard_one, primary_shard_two, secondary_shard_one, secondary_shard_two]
encrypt: true

store_options:
max_age: 3600
max_size:

development:
<<: *default

test:
<<: *default
17 changes: 13 additions & 4 deletions test/models/solid_cache/record_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@

module SolidCache
class RecordTest < ActiveSupport::TestCase
SINGLE_DB_CONFIGS = [ "config/solid_cache_no_database.yml", "config/solid_cache_database.yml", "config/solid_cache_unprepared_statements.yml" ]
MULTI_DB_CONFIGS = [
"config/solid_cache_connects_to.yml",
"config/solid_cache_encrypted.yml",
"config/solid_cache_encrypted_custom.yml",
"config/solid_cache_shards.yml",
nil
]

test "each_shard" do
shards = SolidCache::Record.each_shard.map { SolidCache::Record.current_shard }
case ENV["SOLID_CACHE_CONFIG"]
when "config/solid_cache_no_database.yml", "config/solid_cache_database.yml", "config/solid_cache_unprepared_statements.yml"
when *SINGLE_DB_CONFIGS
assert_equal [ :default ], shards
when "config/solid_cache_connects_to.yml", "config/solid_cache_shards.yml", nil
when *MULTI_DB_CONFIGS
assert_equal [ :primary_shard_one, :primary_shard_two, :secondary_shard_one, :secondary_shard_two ], shards
else
raise "Unknown SOLID_CACHE_CONFIG: #{ENV["SOLID_CACHE_CONFIG"]}"
Expand All @@ -19,9 +28,9 @@ class RecordTest < ActiveSupport::TestCase
test "each_shard uses the default role" do
role = ActiveRecord::Base.connected_to(role: :reading) { SolidCache::Record.each_shard.map { SolidCache::Record.current_role } }
case ENV["SOLID_CACHE_CONFIG"]
when "config/solid_cache_no_database.yml", "config/solid_cache_database.yml", "config/solid_cache_unprepared_statements.yml"
when *SINGLE_DB_CONFIGS
assert_equal [ :reading ], role
when "config/solid_cache_connects_to.yml", "config/solid_cache_shards.yml", nil
when *MULTI_DB_CONFIGS
assert_equal [ :writing, :writing, :writing, :writing ], role
else
raise "Unknown SOLID_CACHE_CONFIG: #{ENV["SOLID_CACHE_CONFIG"]}"
Expand Down
6 changes: 6 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,10 @@ def second_shard_key
def single_database?
[ "config/solid_cache_database.yml", "config/solid_cache_no_database.yml", "config/solid_cache_unprepared_statements.yml" ].include?(ENV["SOLID_CACHE_CONFIG"])
end

def shard_keys(cache, shard)
namespaced_keys = 100.times.map { |i| @cache.send(:normalize_key, "key#{i}", {}) }
shard_keys = cache.send(:connections).assign(namespaced_keys)[shard]
shard_keys.map { |key| key.delete_prefix("#{@namespace}:") }
end
end
Loading

0 comments on commit 86fb61c

Please sign in to comment.