From 34b1aa65b9fbf0abe3e786e894d589e05daa8a1b Mon Sep 17 00:00:00 2001 From: Nick Pezza Date: Sat, 15 Aug 2026 13:59:41 -0400 Subject: [PATCH] Add encryption support Fixes #69 --- README.md | 34 +++++++++++++++++++ app/models/solid_cable/message.rb | 2 ++ app/models/solid_cable/message/encryption.rb | 15 ++++++++ lib/solid_cable.rb | 1 + lib/solid_cable/configuration.rb | 27 ++++++++++++++- lib/solid_cable/engine.rb | 8 +++++ solid_cable.gemspec | 1 + test/config_stubs.rb | 4 +-- test/dummy/config/application.rb | 4 +++ test/dummy/config/cable.yml | 1 + .../subscription_adapter/solid_cable_test.rb | 31 ++++++++++++++++- test/solid_cable_test.rb | 25 ++++++++++++++ 12 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 app/models/solid_cable/message/encryption.rb diff --git a/README.md b/README.md index 32ef8d9..d5dc919 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/models/solid_cable/message.rb b/app/models/solid_cable/message.rb index 4a16d70..c41285c 100644 --- a/app/models/solid_cable/message.rb +++ b/app/models/solid_cable/message.rb @@ -2,6 +2,8 @@ module SolidCable class Message < SolidCable::Record + include Encryption + scope :trimmable, lambda { where(created_at: ...::SolidCable.message_retention.ago) } diff --git a/app/models/solid_cable/message/encryption.rb b/app/models/solid_cable/message/encryption.rb new file mode 100644 index 0000000..e0495d7 --- /dev/null +++ b/app/models/solid_cable/message/encryption.rb @@ -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 diff --git a/lib/solid_cable.rb b/lib/solid_cable.rb index 03c434f..46737ef 100644 --- a/lib/solid_cable.rb +++ b/lib/solid_cable.rb @@ -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 diff --git a/lib/solid_cable/configuration.rb b/lib/solid_cable/configuration.rb index f29922a..9d36b8d 100644 --- a/lib/solid_cable/configuration.rb +++ b/lib/solid_cable/configuration.rb @@ -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) @@ -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(".") diff --git a/lib/solid_cable/engine.rb b/lib/solid_cable/engine.rb index 7ac26af..300d5ac 100644 --- a/lib/solid_cable/engine.rb +++ b/lib/solid_cable/engine.rb @@ -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 diff --git a/solid_cable.gemspec b/solid_cable.gemspec index b8949ed..3a23b0b 100644 --- a/solid_cable.gemspec +++ b/solid_cable.gemspec @@ -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 diff --git a/test/config_stubs.rb b/test/config_stubs.rb index 5e4607f..915f81b 100644 --- a/test/config_stubs.rb +++ b/test/config_stubs.rb @@ -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 diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 6e61f6b..c41b0f7 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -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. diff --git a/test/dummy/config/cable.yml b/test/dummy/config/cable.yml index 98367f8..3ecfbc5 100644 --- a/test/dummy/config/cable.yml +++ b/test/dummy/config/cable.yml @@ -3,6 +3,7 @@ development: test: adapter: test + encrypt: <%= ENV["TARGET_DB"] != "postgres" || Rails::VERSION::MAJOR >= 8 %> production: adapter: redis diff --git a/test/lib/action_cable/subscription_adapter/solid_cable_test.rb b/test/lib/action_cable/subscription_adapter/solid_cable_test.rb index 87e7899..4b11763 100644 --- a/test/lib/action_cable/subscription_adapter/solid_cable_test.rb +++ b/test/lib/action_cable/subscription_adapter/solid_cable_test.rb @@ -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| @@ -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 diff --git a/test/solid_cable_test.rb b/test/solid_cable_test.rb index a47fd11..ff2447b 100644 --- a/test/solid_cable_test.rb +++ b/test/solid_cable_test.rb @@ -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