flipper 0.28.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/examples.yml +2 -2
  3. data/Changelog.md +190 -165
  4. data/Gemfile +5 -3
  5. data/docs/images/flipper_cloud.png +0 -0
  6. data/examples/cloud/app.ru +12 -0
  7. data/examples/cloud/basic.rb +22 -0
  8. data/examples/cloud/cloud_setup.rb +4 -0
  9. data/examples/cloud/forked.rb +31 -0
  10. data/examples/cloud/import.rb +17 -0
  11. data/examples/cloud/threaded.rb +36 -0
  12. data/examples/dsl.rb +0 -14
  13. data/flipper-cloud.gemspec +19 -0
  14. data/flipper.gemspec +3 -2
  15. data/lib/flipper/cloud/configuration.rb +189 -0
  16. data/lib/flipper/cloud/dsl.rb +27 -0
  17. data/lib/flipper/cloud/instrumenter.rb +48 -0
  18. data/lib/flipper/cloud/message_verifier.rb +95 -0
  19. data/lib/flipper/cloud/middleware.rb +63 -0
  20. data/lib/flipper/cloud/routes.rb +14 -0
  21. data/lib/flipper/cloud.rb +53 -0
  22. data/lib/flipper/dsl.rb +0 -46
  23. data/lib/flipper/{railtie.rb → engine.rb} +19 -3
  24. data/lib/flipper/metadata.rb +5 -1
  25. data/lib/flipper/middleware/memoizer.rb +1 -1
  26. data/lib/flipper/spec/shared_adapter_specs.rb +43 -43
  27. data/lib/flipper/test/shared_adapter_test.rb +43 -43
  28. data/lib/flipper/version.rb +1 -1
  29. data/lib/flipper.rb +3 -5
  30. data/spec/flipper/adapters/dual_write_spec.rb +2 -2
  31. data/spec/flipper/adapters/instrumented_spec.rb +1 -1
  32. data/spec/flipper/adapters/memoizable_spec.rb +6 -6
  33. data/spec/flipper/adapters/operation_logger_spec.rb +2 -2
  34. data/spec/flipper/adapters/read_only_spec.rb +6 -6
  35. data/spec/flipper/cloud/configuration_spec.rb +269 -0
  36. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  37. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  38. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  39. data/spec/flipper/cloud_spec.rb +180 -0
  40. data/spec/flipper/dsl_spec.rb +0 -75
  41. data/spec/flipper/engine_spec.rb +190 -0
  42. data/spec/flipper_integration_spec.rb +12 -12
  43. data/spec/flipper_spec.rb +0 -30
  44. data/spec/spec_helper.rb +0 -12
  45. data/spec/support/climate_control.rb +7 -0
  46. metadata +54 -11
  47. data/.tool-versions +0 -1
  48. data/spec/flipper/railtie_spec.rb +0 -109
data/Gemfile CHANGED
@@ -7,15 +7,16 @@ Dir['flipper-*.gemspec'].each do |gemspec|
7
7
  end
8
8
 
9
9
  gem 'debug'
10
- gem 'rake', '~> 12.3.3'
10
+ gem 'rake'
11
11
  gem 'statsd-ruby', '~> 1.2.1'
12
12
  gem 'rspec', '~> 3.0'
13
13
  gem 'rack-test'
14
+ gem 'rackup'
14
15
  gem 'sqlite3', "~> #{ENV['SQLITE3_VERSION'] || '1.4.1'}"
15
- gem 'rails', "~> #{ENV['RAILS_VERSION'] || '7.0.0'}"
16
+ gem 'rails', "~> #{ENV['RAILS_VERSION'] || '7.0.4'}"
16
17
  gem 'minitest', '~> 5.18'
17
18
  gem 'minitest-documentation'
18
- gem 'webmock', '~> 3.0'
19
+ gem 'webmock'
19
20
  gem 'ice_age'
20
21
  gem 'redis-namespace'
21
22
  gem 'webrick'
@@ -23,6 +24,7 @@ gem 'stackprof'
23
24
  gem 'benchmark-ips'
24
25
  gem 'stackprof-webnav'
25
26
  gem 'flamegraph'
27
+ gem 'climate_control'
26
28
 
27
29
  group(:guard) do
28
30
  gem 'guard', '~> 2.15'
Binary file
@@ -0,0 +1,12 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> bundle exec rackup examples/cloud/app.ru -p 9999
3
+ # http://localhost:9999/
4
+
5
+ require 'bundler/setup'
6
+ require 'flipper/cloud'
7
+
8
+ Flipper.configure do |config|
9
+ config.default { Flipper::Cloud.new }
10
+ end
11
+
12
+ run Flipper::Cloud.app
@@ -0,0 +1,22 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/basic.rb
3
+
4
+ require_relative "./cloud_setup"
5
+ require 'bundler/setup'
6
+ require 'flipper/cloud'
7
+
8
+ Flipper[:stats].enable
9
+
10
+ if Flipper[:stats].enabled?
11
+ puts 'Enabled!'
12
+ else
13
+ puts 'Disabled!'
14
+ end
15
+
16
+ Flipper[:stats].disable
17
+
18
+ if Flipper[:stats].enabled?
19
+ puts 'Enabled!'
20
+ else
21
+ puts 'Disabled!'
22
+ end
@@ -0,0 +1,4 @@
1
+ if ENV["FLIPPER_CLOUD_TOKEN"].nil? || ENV["FLIPPER_CLOUD_TOKEN"].empty?
2
+ warn "FLIPPER_CLOUD_TOKEN missing so skipping cloud example."
3
+ exit
4
+ end
@@ -0,0 +1,31 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/threaded.rb
3
+
4
+ require_relative "./cloud_setup"
5
+ require 'bundler/setup'
6
+ require 'flipper/cloud'
7
+
8
+ pids = 5.times.map do |n|
9
+ fork {
10
+ # Check every second to see if the feature is enabled
11
+ threads = []
12
+ 5.times do
13
+ threads << Thread.new do
14
+ loop do
15
+ sleep rand
16
+
17
+ if Flipper[:stats].enabled?
18
+ puts "#{Process.pid} #{Time.now.to_i} Enabled!"
19
+ else
20
+ puts "#{Process.pid} #{Time.now.to_i} Disabled!"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ threads.map(&:join)
26
+ }
27
+ end
28
+
29
+ pids.each do |pid|
30
+ Process.waitpid pid, 0
31
+ end
@@ -0,0 +1,17 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/import.rb
3
+
4
+ require_relative "./cloud_setup"
5
+ require 'bundler/setup'
6
+ require 'flipper'
7
+ require 'flipper/cloud'
8
+
9
+ Flipper.enable(:test)
10
+ Flipper.enable(:search)
11
+ Flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker"))
12
+ Flipper.enable_percentage_of_time(:logging, 5)
13
+
14
+ cloud = Flipper::Cloud.new
15
+
16
+ # makes cloud identical to memory flipper
17
+ cloud.import(Flipper)
@@ -0,0 +1,36 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> bundle exec ruby examples/cloud/threaded.rb
3
+
4
+ require_relative "./cloud_setup"
5
+ require 'bundler/setup'
6
+ require 'flipper/cloud'
7
+ require "active_support/notifications"
8
+ require "active_support/isolated_execution_state"
9
+
10
+ ActiveSupport::Notifications.subscribe(/poller\.flipper/) do |*args|
11
+ p args: args
12
+ end
13
+
14
+ Flipper.configure do |config|
15
+ config.default {
16
+ Flipper::Cloud.new(local_adapter: config.adapter, instrumenter: ActiveSupport::Notifications)
17
+ }
18
+ end
19
+
20
+ # Check every second to see if the feature is enabled
21
+ threads = []
22
+ 10.times do
23
+ threads << Thread.new do
24
+ loop do
25
+ sleep rand
26
+
27
+ if Flipper[:stats].enabled?
28
+ puts "#{Time.now.to_i} Enabled!"
29
+ else
30
+ puts "#{Time.now.to_i} Disabled!"
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ threads.map(&:join)
data/examples/dsl.rb CHANGED
@@ -47,20 +47,6 @@ puts "stats.enabled?: #{stats.enabled?}"
47
47
  puts "stats.enabled? person: #{stats.enabled? person}"
48
48
  puts
49
49
 
50
- # get an instance of the percentage of time type set to 5
51
- puts Flipper.time(5).inspect
52
-
53
- # get an instance of the percentage of actors type set to 15
54
- puts Flipper.actors(15).inspect
55
-
56
- # get an instance of an actor using an object that responds to flipper_id
57
- responds_to_flipper_id = Struct.new(:flipper_id).new(10)
58
- puts Flipper.actor(responds_to_flipper_id).inspect
59
-
60
- # get an instance of an actor using an object
61
- actor = Struct.new(:flipper_id).new(22)
62
- puts Flipper.actor(actor).inspect
63
-
64
50
  # register a top level group
65
51
  admins = Flipper.register(:admins) { |actor|
66
52
  actor.respond_to?(:admin?) && actor.admin?
@@ -0,0 +1,19 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/flipper/version', __FILE__)
3
+ require File.expand_path('../lib/flipper/metadata', __FILE__)
4
+
5
+ Gem::Specification.new do |gem|
6
+ gem.authors = ['John Nunemaker']
7
+ gem.email = 'support@flippercloud.io'
8
+ gem.summary = '[DEPRECATED] This gem has been merged into the `flipper` gem'
9
+ gem.license = 'MIT'
10
+ gem.homepage = 'https://www.flippercloud.io'
11
+
12
+ gem.files = [ 'lib/flipper-cloud.rb', 'lib/flipper/version.rb' ]
13
+ gem.name = 'flipper-cloud'
14
+ gem.require_paths = ['lib']
15
+ gem.version = Flipper::VERSION
16
+ gem.metadata = Flipper::METADATA
17
+
18
+ gem.add_dependency 'flipper', "~> #{Flipper::VERSION}"
19
+ end
data/flipper.gemspec CHANGED
@@ -22,9 +22,9 @@ ignored_test_files.flatten!.uniq!
22
22
 
23
23
  Gem::Specification.new do |gem|
24
24
  gem.authors = ['John Nunemaker']
25
- gem.email = ['nunemaker@gmail.com']
25
+ gem.email = 'support@flippercloud.io'
26
26
  gem.summary = 'Feature flipper for ANYTHING'
27
- gem.homepage = 'https://github.com/jnunemaker/flipper'
27
+ gem.homepage = 'https://www.flippercloud.io/docs'
28
28
  gem.license = 'MIT'
29
29
 
30
30
  gem.files = `git ls-files`.split("\n") - ignored_files + ['lib/flipper/version.rb']
@@ -35,4 +35,5 @@ Gem::Specification.new do |gem|
35
35
  gem.metadata = Flipper::METADATA
36
36
 
37
37
  gem.add_dependency 'concurrent-ruby', '< 2'
38
+ gem.add_dependency 'brow', '~> 0.4.1'
38
39
  end
@@ -0,0 +1,189 @@
1
+ require "socket"
2
+ require "flipper/adapters/http"
3
+ require "flipper/adapters/poll"
4
+ require "flipper/poller"
5
+ require "flipper/adapters/memory"
6
+ require "flipper/adapters/dual_write"
7
+ require "flipper/adapters/sync/synchronizer"
8
+ require "flipper/cloud/instrumenter"
9
+ require "brow"
10
+
11
+ module Flipper
12
+ module Cloud
13
+ class Configuration
14
+ # The set of valid ways that syncing can happpen.
15
+ VALID_SYNC_METHODS = Set[
16
+ :poll,
17
+ :webhook,
18
+ ].freeze
19
+
20
+ DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
21
+
22
+ # Private: Keeps track of brow instances so they can be shared across
23
+ # threads.
24
+ def self.brow_instances
25
+ @brow_instances ||= Concurrent::Map.new
26
+ end
27
+
28
+ # Public: The token corresponding to an environment on flippercloud.io.
29
+ attr_accessor :token
30
+
31
+ # Public: The url for http adapter. Really should only be customized for
32
+ # development work. Feel free to forget you ever saw this.
33
+ attr_reader :url
34
+
35
+ # Public: net/http read timeout for all http requests (default: 5).
36
+ attr_accessor :read_timeout
37
+
38
+ # Public: net/http open timeout for all http requests (default: 5).
39
+ attr_accessor :open_timeout
40
+
41
+ # Public: net/http write timeout for all http requests (default: 5).
42
+ attr_accessor :write_timeout
43
+
44
+ # Public: IO stream to send debug output too. Off by default.
45
+ #
46
+ # # for example, this would send all http request information to STDOUT
47
+ # configuration = Flipper::Cloud::Configuration.new
48
+ # configuration.debug_output = STDOUT
49
+ attr_accessor :debug_output
50
+
51
+ # Public: Instrumenter to use for the Flipper instance returned by
52
+ # Flipper::Cloud.new (default: Flipper::Instrumenters::Noop).
53
+ #
54
+ # # for example, to use active support notifications you could do:
55
+ # configuration = Flipper::Cloud::Configuration.new
56
+ # configuration.instrumenter = ActiveSupport::Notifications
57
+ attr_accessor :instrumenter
58
+
59
+ # Public: Local adapter that all reads should go to in order to ensure
60
+ # latency is low and resiliency is high. This adapter is automatically
61
+ # kept in sync with cloud.
62
+ #
63
+ # # for example, to use active record you could do:
64
+ # configuration = Flipper::Cloud::Configuration.new
65
+ # configuration.local_adapter = Flipper::Adapters::ActiveRecord.new
66
+ attr_accessor :local_adapter
67
+
68
+ # Public: The Integer or Float number of seconds between attempts to bring
69
+ # the local in sync with cloud (default: 10).
70
+ attr_accessor :sync_interval
71
+
72
+ # Public: The secret used to verify if syncs in the middleware should
73
+ # occur or not.
74
+ attr_accessor :sync_secret
75
+
76
+ def initialize(options = {})
77
+ @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
78
+
79
+ if @token.nil?
80
+ raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token (e.g. Flipper::Cloud.new(token: 'token'))."
81
+ end
82
+
83
+ @read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
84
+ @open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
85
+ @write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
86
+ @sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
87
+ @sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
88
+ @local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
89
+ @debug_output = options[:debug_output]
90
+ @adapter_block = ->(adapter) { adapter }
91
+ self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_URL) }
92
+
93
+ instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
94
+
95
+ # This is alpha. Don't use this unless you are me. And you are not me.
96
+ cloud_instrument = options.fetch(:cloud_instrument) { ENV["FLIPPER_CLOUD_INSTRUMENT"] == "1" }
97
+ @instrumenter = if cloud_instrument
98
+ Instrumenter.new(brow: brow, instrumenter: instrumenter)
99
+ else
100
+ instrumenter
101
+ end
102
+ end
103
+
104
+ # Public: Read or customize the http adapter. Calling without a block will
105
+ # perform a read. Calling with a block yields the cloud adapter
106
+ # for customization.
107
+ #
108
+ # # for example, to instrument the http calls, you can wrap the http
109
+ # # adapter with the intsrumented adapter
110
+ # configuration = Flipper::Cloud::Configuration.new
111
+ # configuration.adapter do |adapter|
112
+ # Flipper::Adapters::Instrumented.new(adapter)
113
+ # end
114
+ #
115
+ def adapter(&block)
116
+ if block_given?
117
+ @adapter_block = block
118
+ else
119
+ @adapter_block.call app_adapter
120
+ end
121
+ end
122
+
123
+ # Public: Set url for the http adapter.
124
+ attr_writer :url
125
+
126
+ def sync
127
+ Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
128
+ instrumenter: instrumenter,
129
+ }).call
130
+ end
131
+
132
+ def brow
133
+ self.class.brow_instances.compute_if_absent(url + token) do
134
+ uri = URI.parse(url)
135
+ uri.path = "#{uri.path}/events".squeeze("/")
136
+
137
+ Brow::Client.new({
138
+ url: uri.to_s,
139
+ headers: {
140
+ "Accept" => "application/json",
141
+ "Content-Type" => "application/json",
142
+ "User-Agent" => "Flipper v#{VERSION} via Brow v#{Brow::VERSION}",
143
+ "Flipper-Cloud-Token" => @token,
144
+ }
145
+ })
146
+ end
147
+ end
148
+
149
+ # Public: The method that will be used to synchronize local adapter with
150
+ # cloud. (default: :poll, will be :webhook if sync_secret is set).
151
+ def sync_method
152
+ sync_secret ? :webhook : :poll
153
+ end
154
+
155
+ private
156
+
157
+ def app_adapter
158
+ read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
159
+ Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
160
+ end
161
+
162
+ def poller
163
+ Flipper::Poller.get(@url + @token, {
164
+ interval: sync_interval,
165
+ remote_adapter: http_adapter,
166
+ instrumenter: instrumenter,
167
+ }).tap(&:start)
168
+ end
169
+
170
+ def poll_adapter
171
+ Flipper::Adapters::Poll.new(poller, local_adapter)
172
+ end
173
+
174
+ def http_adapter
175
+ Flipper::Adapters::Http.new({
176
+ url: @url,
177
+ read_timeout: @read_timeout,
178
+ open_timeout: @open_timeout,
179
+ write_timeout: @write_timeout,
180
+ max_retries: 0, # we'll handle retries ourselves
181
+ debug_output: @debug_output,
182
+ headers: {
183
+ "Flipper-Cloud-Token" => @token,
184
+ },
185
+ })
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,27 @@
1
+ require 'forwardable'
2
+
3
+ module Flipper
4
+ module Cloud
5
+ class DSL < SimpleDelegator
6
+ attr_reader :cloud_configuration
7
+
8
+ def initialize(cloud_configuration)
9
+ @cloud_configuration = cloud_configuration
10
+ super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
11
+ end
12
+
13
+ def sync
14
+ @cloud_configuration.sync
15
+ end
16
+
17
+ def sync_secret
18
+ @cloud_configuration.sync_secret
19
+ end
20
+
21
+ def inspect
22
+ inspect_id = ::Kernel::format "%x", (object_id * 2)
23
+ %(#<#{self.class}:0x#{inspect_id} @cloud_configuration=#{cloud_configuration.inspect}, flipper=#{__getobj__.inspect}>)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ require "delegate"
2
+ require "flipper/instrumenters/noop"
3
+
4
+ module Flipper
5
+ module Cloud
6
+ class Instrumenter < SimpleDelegator
7
+ def initialize(options = {})
8
+ @brow = options.fetch(:brow)
9
+ @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
10
+ super @instrumenter
11
+ end
12
+
13
+ def instrument(name, payload = {}, &block)
14
+ result = @instrumenter.instrument(name, payload, &block)
15
+ push name, payload
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def push(name, payload)
22
+ return unless name == Flipper::Feature::InstrumentationName
23
+ return unless :enabled? == payload[:operation]
24
+
25
+ dimensions = {
26
+ "feature" => payload[:feature_name].to_s,
27
+ "result" => payload[:result].to_s,
28
+ }
29
+
30
+ if (thing = payload[:thing])
31
+ dimensions["flipper_id"] = thing.value.to_s
32
+ end
33
+
34
+ if (actors = payload[:actors])
35
+ dimensions["flipper_ids"] = actors.map { |actor| actor.value.to_s }
36
+ end
37
+
38
+ event = {
39
+ type: "enabled",
40
+ dimensions: dimensions,
41
+ measures: {},
42
+ ts: Time.now.utc,
43
+ }
44
+ @brow.push event
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,95 @@
1
+ require "openssl"
2
+ require "digest/sha2"
3
+
4
+ module Flipper
5
+ module Cloud
6
+ class MessageVerifier
7
+ class InvalidSignature < StandardError; end
8
+
9
+ DEFAULT_VERSION = "v1"
10
+
11
+ def self.header(signature, timestamp, version = DEFAULT_VERSION)
12
+ raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
13
+ raise ArgumentError, "signature should be a string" unless signature.is_a?(String)
14
+ "t=#{timestamp.to_i},#{version}=#{signature}"
15
+ end
16
+
17
+ def initialize(secret:, version: DEFAULT_VERSION)
18
+ @secret = secret
19
+ @version = version || DEFAULT_VERSION
20
+
21
+ raise ArgumentError, "secret should be a string" unless @secret.is_a?(String)
22
+ raise ArgumentError, "version should be a string" unless @version.is_a?(String)
23
+ end
24
+
25
+ def generate(payload, timestamp)
26
+ raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
27
+ raise ArgumentError, "payload should be a string" unless payload.is_a?(String)
28
+
29
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, "#{timestamp.to_i}.#{payload}")
30
+ end
31
+
32
+ def header(signature, timestamp)
33
+ self.class.header(signature, timestamp, @version)
34
+ end
35
+
36
+ # Public: Verifies the signature header for a given payload.
37
+ #
38
+ # Raises a InvalidSignature in the following cases:
39
+ # - the header does not match the expected format
40
+ # - no signatures found with the expected scheme
41
+ # - no signatures matching the expected signature
42
+ # - a tolerance is provided and the timestamp is not within the
43
+ # tolerance
44
+ #
45
+ # Returns true otherwise.
46
+ def verify(payload, header, tolerance: nil)
47
+ begin
48
+ timestamp, signatures = get_timestamp_and_signatures(header)
49
+ rescue StandardError
50
+ raise InvalidSignature, "Unable to extract timestamp and signatures from header"
51
+ end
52
+
53
+ if signatures.empty?
54
+ raise InvalidSignature, "No signatures found with expected version #{@version}"
55
+ end
56
+
57
+ expected_sig = generate(payload, timestamp)
58
+ unless signatures.any? { |s| secure_compare(expected_sig, s) }
59
+ raise InvalidSignature, "No signatures found matching the expected signature for payload"
60
+ end
61
+
62
+ if tolerance && timestamp < Time.now - tolerance
63
+ raise InvalidSignature, "Timestamp outside the tolerance zone (#{Time.at(timestamp)})"
64
+ end
65
+
66
+ true
67
+ end
68
+
69
+ private
70
+
71
+ # Extracts the timestamp and the signature(s) with the desired version
72
+ # from the header
73
+ def get_timestamp_and_signatures(header)
74
+ list_items = header.split(/,\s*/).map { |i| i.split("=", 2) }
75
+ timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1])
76
+ signatures = list_items.select { |i| i[0] == @version }.map { |i| i[1] }
77
+ [Time.at(timestamp), signatures]
78
+ end
79
+
80
+ # Private
81
+ def fixed_length_secure_compare(a, b)
82
+ raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
83
+ l = a.unpack "C#{a.bytesize}"
84
+ res = 0
85
+ b.each_byte { |byte| res |= byte ^ l.shift }
86
+ res == 0
87
+ end
88
+
89
+ # Private
90
+ def secure_compare(a, b)
91
+ fixed_length_secure_compare(::Digest::SHA256.digest(a), ::Digest::SHA256.digest(b)) && a == b
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipper/cloud/message_verifier"
4
+
5
+ module Flipper
6
+ module Cloud
7
+ class Middleware
8
+ # Internal: The path to match for webhook requests.
9
+ WEBHOOK_PATH = %r{\A/webhooks\/?\Z}
10
+ # Internal: The root path to match for requests.
11
+ ROOT_PATH = %r{\A/\Z}
12
+
13
+ def initialize(app, options = {})
14
+ @app = app
15
+ @env_key = options.fetch(:env_key, 'flipper')
16
+ end
17
+
18
+ def call(env)
19
+ dup.call!(env)
20
+ end
21
+
22
+ def call!(env)
23
+ request = Rack::Request.new(env)
24
+ if request.post? && (request.path_info.match(ROOT_PATH) || request.path_info.match(WEBHOOK_PATH))
25
+ status = 200
26
+ headers = {
27
+ "content-type" => "application/json",
28
+ }
29
+ body = "{}"
30
+ payload = request.body.read
31
+ signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"]
32
+ flipper = env.fetch(@env_key)
33
+
34
+ begin
35
+ message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
36
+ if message_verifier.verify(payload, signature)
37
+ begin
38
+ flipper.sync
39
+ body = JSON.generate({
40
+ groups: Flipper.group_names.map { |name| {name: name}}
41
+ })
42
+ rescue Flipper::Adapters::Http::Error => error
43
+ status = error.response.code.to_i == 402 ? 402 : 500
44
+ headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
45
+ headers["Flipper-Cloud-Response-Error-Message"] = error.message
46
+ rescue => error
47
+ status = 500
48
+ headers["Flipper-Cloud-Response-Error-Class"] = error.class.name
49
+ headers["Flipper-Cloud-Response-Error-Message"] = error.message
50
+ end
51
+ end
52
+ rescue MessageVerifier::InvalidSignature
53
+ status = 400
54
+ end
55
+
56
+ [status, headers, [body]]
57
+ else
58
+ @app.call(env)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ # Default routes loaded by Flipper::Cloud::Engine
2
+ Rails.application.routes.draw do
3
+ if ENV["FLIPPER_CLOUD_TOKEN"] && ENV["FLIPPER_CLOUD_SYNC_SECRET"]
4
+ require 'flipper/cloud'
5
+ config = Rails.application.config.flipper
6
+
7
+ cloud_app = Flipper::Cloud.app(nil,
8
+ env_key: config.env_key,
9
+ memoizer_options: { preload: config.preload }
10
+ )
11
+
12
+ mount cloud_app, at: config.cloud_path
13
+ end
14
+ end