Skip to content

Commit 38a056c

Browse files
Add Message{Encryptors,Verifiers}#transitional
This commit adds a `transitional` attribute to `ActiveSupport::MessageEncryptors` and `ActiveSupport::MessageVerifiers`. Setting `transitional = true` will swap the first two rotations when building a message encryptor / verifier. For example, with the following configuration, message verifiers will generate messages using `serializer: Marshal, url_safe: true`, and will able to verify messages that were generated using any of the three option sets: ```ruby verifiers = ActiveSupport::MessageVerifiers.new { ... } verifiers.rotate(serializer: JSON, url_safe: true) verifiers.rotate(serializer: Marshal, url_safe: true) verifiers.rotate(serializer: Marshal, url_safe: false) verifiers.transitional = true ``` This can be useful when performing a rolling deploy of an application, wherein servers that have not yet been updated must still be able to verify messages from updated servers. In particular, as it can be applied to the default rotations of `Rails.application.message_verifiers`.
1 parent 40b1c60 commit 38a056c

File tree

4 files changed

+87
-1
lines changed

4 files changed

+87
-1
lines changed

activesupport/lib/active_support/message_encryptors.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@
44

55
module ActiveSupport
66
class MessageEncryptors < Messages::RotationCoordinator
7+
##
8+
# :attr_accessor: transitional
9+
#
10+
# If true, the first two rotation option sets are swapped when building
11+
# message encryptors. For example, with the following configuration, message
12+
# encryptors will encrypt messages using <tt>serializer: Marshal, url_safe: true</tt>,
13+
# and will able to decrypt messages that were encrypted using any of the
14+
# three option sets:
15+
#
16+
# encryptors = ActiveSupport::MessageEncryptors.new { ... }
17+
# encryptors.rotate(serializer: JSON, url_safe: true)
18+
# encryptors.rotate(serializer: Marshal, url_safe: true)
19+
# encryptors.rotate(serializer: Marshal, url_safe: false)
20+
# encryptors.transitional = true
21+
#
22+
# This can be useful when performing a rolling deploy of an application,
23+
# wherein servers that have not yet been updated must still be able to
24+
# decrypt messages from updated servers. In such a scenario, first perform a
25+
# rolling deploy with the new rotation (e.g. <tt>serializer: JSON, url_safe: true</tt>)
26+
# as the first rotation and <tt>transitional = true</tt>. Then, after all
27+
# servers have been updated, perform a second rolling deploy with
28+
# <tt>transitional = false</tt>.
29+
730
##
831
# :method: initialize
932
# :call-seq: initialize(&secret_generator)

activesupport/lib/active_support/message_verifiers.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@
44

55
module ActiveSupport
66
class MessageVerifiers < Messages::RotationCoordinator
7+
##
8+
# :attr_accessor: transitional
9+
#
10+
# If true, the first two rotation option sets are swapped when building
11+
# message verifiers. For example, with the following configuration, message
12+
# verifiers will generate messages using <tt>serializer: Marshal, url_safe: true</tt>,
13+
# and will able to verify messages that were generated using any of the
14+
# three option sets:
15+
#
16+
# verifiers = ActiveSupport::MessageVerifiers.new { ... }
17+
# verifiers.rotate(serializer: JSON, url_safe: true)
18+
# verifiers.rotate(serializer: Marshal, url_safe: true)
19+
# verifiers.rotate(serializer: Marshal, url_safe: false)
20+
# verifiers.transitional = true
21+
#
22+
# This can be useful when performing a rolling deploy of an application,
23+
# wherein servers that have not yet been updated must still be able to
24+
# verify messages from updated servers. In such a scenario, first perform a
25+
# rolling deploy with the new rotation (e.g. <tt>serializer: JSON, url_safe: true</tt>)
26+
# as the first rotation and <tt>transitional = true</tt>. Then, after all
27+
# servers have been updated, perform a second rolling deploy with
28+
# <tt>transitional = false</tt>.
29+
730
##
831
# :method: initialize
932
# :call-seq: initialize(&secret_generator)

activesupport/lib/active_support/messages/rotation_coordinator.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
module ActiveSupport
66
module Messages
77
class RotationCoordinator # :nodoc:
8+
attr_accessor :transitional
9+
810
def initialize(&secret_generator)
911
raise ArgumentError, "A secret generator block is required" unless secret_generator
1012
@secret_generator = secret_generator
@@ -63,7 +65,14 @@ def changing_configuration!
6365

6466
def build_with_rotations(salt)
6567
raise "No options have been configured" if @rotate_options.empty?
66-
@rotate_options.map { |options| build(salt, **options, on_rotation: @on_rotation) }.reduce(&:fall_back_to)
68+
69+
if transitional
70+
rotate_options = [@rotate_options[1], @rotate_options[0], *@rotate_options[2..]].compact
71+
else
72+
rotate_options = @rotate_options
73+
end
74+
75+
rotate_options.map { |options| build(salt, **options, on_rotation: @on_rotation) }.reduce(&:fall_back_to)
6776
end
6877

6978
def build(salt, secret_generator:, secret_generator_options:, **options)

activesupport/test/rotation_coordinator_tests.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,37 @@ module RotationCoordinatorTests
3434
assert_nil roundtrip("message", codec, obsolete_codec)
3535
end
3636

37+
test "#transitional swaps the first two rotations when enabled" do
38+
coordinator = make_coordinator.rotate(digest: "SHA1")
39+
coordinator.rotate(digest: "MD5")
40+
coordinator.rotate(digest: "MD4")
41+
coordinator.transitional = true
42+
43+
codec = coordinator["salt"]
44+
sha1_codec = (make_coordinator.rotate(digest: "SHA1"))["salt"]
45+
md5_codec = (make_coordinator.rotate(digest: "MD5"))["salt"]
46+
md4_codec = (make_coordinator.rotate(digest: "MD4"))["salt"]
47+
48+
assert_equal "message", roundtrip("message", codec, md5_codec)
49+
assert_nil roundtrip("message", codec, sha1_codec)
50+
51+
assert_equal "message", roundtrip("message", sha1_codec, codec)
52+
assert_equal "message", roundtrip("message", md5_codec, codec)
53+
assert_equal "message", roundtrip("message", md4_codec, codec)
54+
end
55+
56+
test "#transitional works with a single rotation" do
57+
@coordinator.transitional = true
58+
59+
assert_nothing_raised do
60+
codec = @coordinator["salt"]
61+
assert_equal "message", roundtrip("message", codec)
62+
63+
different_codec = (make_coordinator.rotate(digest: "MD5"))["salt"]
64+
assert_nil roundtrip("message", different_codec, codec)
65+
end
66+
end
67+
3768
test "can clear rotations" do
3869
@coordinator.clear_rotations.rotate(digest: "MD5")
3970
codec = @coordinator["salt"]

0 commit comments

Comments
 (0)