Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,40 @@ The options are:
- `trim_batch_size` - the batch size to use when deleting old records (default: `100`)
- `reconnect_attempts` - Supports a number of connection attempts or an array of
durations to wait between attempts. (Defaults to 1 retry attempt)
- `encrypt` - whether to encrypt message payloads with Active Record Encryption.
(Defaults to false)

### Enabling encryption

Solid Cable can encrypt stored message payloads with Active Record Encryption. Add
`encrypt: true` to the Solid Cable environment in `config/cable.yml`:

```yaml
production:
adapter: solid_cable
encrypt: true
connects_to:
database:
writing: cable
```

Your application must also be [configured to use Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html#setup).
Solid Cable uses the binary MessagePack serializer by default, so your application
must include the `msgpack` gem.

Since encryption context properties contain Ruby objects, they cannot be set in
`config/cable.yml`. Set them in an initializer instead:

```ruby
# config/initializers/solid_cable.rb
SolidCable.configuration.encryption_context_properties = {
encryptor: ActiveRecord::Encryption::Encryptor.new,
message_serializer: ActiveRecord::Encryption::MessageSerializer.new
}
```

Active Record Encryption does not support encrypted binary columns on PostgreSQL
with Rails 7. Solid Cable raises during boot for that unsupported combination.


## Trimming
Expand Down
2 changes: 2 additions & 0 deletions app/models/solid_cable/message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module SolidCable
class Message < SolidCable::Record
include Encryption

scope :trimmable, lambda {
where(created_at: ...::SolidCable.message_retention.ago)
}
Expand Down
15 changes: 15 additions & 0 deletions app/models/solid_cable/message/encryption.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module SolidCable
class Message
module Encryption
extend ActiveSupport::Concern

included do
if SolidCable.encrypt?
encrypts :payload, **SolidCable.encryption_context_properties, support_unencrypted_data: true
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/solid_cable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class << self
delegate :connects_to, :silence_polling?, :polling_interval,
:message_retention, :autotrim?, :trim_batch_size, :use_skip_locked,
:trim_chance, :reconnect_attempts, :writer_batch_size, :writer_batch_delay,
:encrypt?, :encryption_context_properties,
to: :configuration

def configuration
Expand Down
27 changes: 26 additions & 1 deletion lib/solid_cable/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ def initialize(**options)

attr_writer :connects_to, :silence_polling, :polling_interval,
:message_retention, :autotrim, :trim_batch_size, :use_skip_locked,
:trim_chance, :reconnect_attempts, :writer_batch_size, :writer_batch_delay
:trim_chance, :reconnect_attempts, :writer_batch_size, :writer_batch_delay,
:encrypt, :encryption_context_properties

def connects_to
@connects_to ||= options.connects_to.to_h.deep_transform_values(&:to_sym)
Expand Down Expand Up @@ -75,9 +76,33 @@ def writer_batch_delay
[ parse_duration(options.writer_batch_delay, default: 0.001.seconds), 0 ].max
end

def encrypt?
return @encrypt if defined?(@encrypt)

@encrypt = options.encrypt.present?
end

def encryption_context_properties
return @encryption_context_properties if defined?(@encryption_context_properties)

@encryption_context_properties = options.encryption_context_properties&.deep_symbolize_keys
@encryption_context_properties ||= default_encryption_context_properties if encrypt?
end

private
attr_reader :options

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

def parse_duration(duration, default:)
if duration.present?
*amount, units = duration.to_s.split(".")
Expand Down
8 changes: 8 additions & 0 deletions lib/solid_cable/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,13 @@
module SolidCable
class Engine < ::Rails::Engine
isolate_namespace SolidCable

config.after_initialize do
if SolidCable.encrypt? && Record.lease_connection.adapter_name == "PostgreSQL" && Rails::VERSION::MAJOR == 7
raise \
"Cannot enable encryption for Solid Cable: in Rails 7, Active Record Encryption does not support " \
"encrypting binary columns on PostgreSQL"
end
end
end
end
1 change: 1 addition & 0 deletions solid_cable.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ Gem::Specification.new do |spec|
spec.add_dependency "actioncable", rails_version
spec.add_dependency "railties", rails_version

spec.add_development_dependency "msgpack"
spec.add_development_dependency "minitest", "~> 5.0"
end
4 changes: 2 additions & 2 deletions test/config_stubs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
module ConfigStubs
extend ActiveSupport::Concern

def with_cable_config(**)
SolidCable.configure(**)
def with_cable_config(**options)
SolidCable.configure(**Rails.application.config_for("cable").to_h.deep_symbolize_keys, **options)
yield
SolidCable.reset_configuration!
end
Expand Down
4 changes: 4 additions & 0 deletions test/dummy/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class Application < Rails::Application
# For compatibility with applications that use this config
config.action_controller.include_all_helpers = false

config.active_record.encryption.primary_key = "test-primary-key-for-solid-cable"
config.active_record.encryption.deterministic_key = "test-deterministic-key-solid-cable"
config.active_record.encryption.key_derivation_salt = "test-key-derivation-salt-solid-cable"

# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
Expand Down
1 change: 1 addition & 0 deletions test/dummy/config/cable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ development:

test:
adapter: test
encrypt: <%= ENV["TARGET_DB"] != "postgres" || Rails::VERSION::MAJOR >= 8 %>

production:
adapter: redis
Expand Down
31 changes: 30 additions & 1 deletion test/lib/action_cable/subscription_adapter/solid_cable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,35 @@ class ActionCable::SubscriptionAdapter::SolidCableTest < ActionCable::TestCase
end
end

test "reads existing unencrypted payloads" do
skip "Encrypted binary columns are unsupported on PostgreSQL with Rails 7" unless SolidCable.encrypt?

legacy_channel = "legacy channel"
legacy_payload = "unencrypted payload"
legacy_channel_hash = SolidCable::Message.channel_hash_for(legacy_channel)

ActiveRecord::Encryption.without_encryption do
SolidCable::Message.insert({
channel: legacy_channel, payload: legacy_payload,
channel_hash: legacy_channel_hash, created_at: Time.current
})
end

assert_equal legacy_payload, SolidCable::Message.find_by!(channel_hash: legacy_channel_hash).payload
end

test "broadcast inserts encrypted payloads" do
skip "Encrypted binary columns are unsupported on PostgreSQL with Rails 7" unless SolidCable.encrypt?

@tx_adapter.broadcast("channel", "sensitive payload")
wait_for_messages("sensitive payload")

message = SolidCable::Message.order(:id).last

assert_equal "sensitive payload", message.payload
assert_not_includes message.payload_before_type_cast, "sensitive payload"
end

test "broadcast_after_unsubscribe" do
keep_queue = nil
subscribe_as_queue("channel") do |queue|
Expand Down Expand Up @@ -264,7 +293,7 @@ def next_message_in_queue(queue)

def wait_for_messages(*payloads)
Timeout.timeout(5, nil, "Failed to persist broadcasts") do
sleep 0.001 until SolidCable::Message.where(payload: payloads).count == payloads.size
sleep 0.001 until SolidCable::Message.order(id: :desc).limit(payloads.size).pluck(:payload).sort == payloads.sort
end
end
end
25 changes: 25 additions & 0 deletions test/solid_cable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,29 @@ class SolidCableTest < ActiveSupport::TestCase
assert_equal [ 0, 1, 2 ], SolidCable.reconnect_attempts
end
end

test "encryption is disabled by default" do
configuration = SolidCable::Configuration.new

assert_not configuration.encrypt?
end

test "encryption is enabled when configured" do
configuration = SolidCable::Configuration.new(encrypt: true)

assert configuration.encrypt?
properties = configuration.encryption_context_properties
assert_instance_of ActiveRecord::Encryption::MessagePackMessageSerializer,
properties[:message_serializer]
end

test "custom encryption context properties" do
encryptor = ActiveRecord::Encryption::Encryptor.new
configuration = SolidCable::Configuration.new(
encrypt: true,
encryption_context_properties: { "encryptor" => encryptor }
)

assert_same encryptor, configuration.encryption_context_properties[:encryptor]
end
end