Skip to content

Commit f15e576

Browse files
Merge pull request rails#46755 from jonathanhefner/messages-rotation_coordinator-transitional
Add `Message{Encryptors,Verifiers}#transitional`
2 parents 2d30ecd + 38a056c commit f15e576

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)