flipper 0.28.3 → 1.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Changelog.md +23 -0
  3. data/Gemfile +5 -3
  4. data/docs/images/flipper_cloud.png +0 -0
  5. data/examples/cloud/app.ru +12 -0
  6. data/examples/cloud/basic.rb +22 -0
  7. data/examples/cloud/cloud_setup.rb +4 -0
  8. data/examples/cloud/forked.rb +31 -0
  9. data/examples/cloud/import.rb +17 -0
  10. data/examples/cloud/threaded.rb +36 -0
  11. data/examples/dsl.rb +0 -14
  12. data/flipper-cloud.gemspec +19 -0
  13. data/flipper.gemspec +3 -2
  14. data/lib/flipper/cloud/configuration.rb +189 -0
  15. data/lib/flipper/cloud/dsl.rb +27 -0
  16. data/lib/flipper/cloud/instrumenter.rb +48 -0
  17. data/lib/flipper/cloud/message_verifier.rb +95 -0
  18. data/lib/flipper/cloud/middleware.rb +63 -0
  19. data/lib/flipper/cloud/routes.rb +14 -0
  20. data/lib/flipper/cloud.rb +53 -0
  21. data/lib/flipper/dsl.rb +0 -46
  22. data/lib/flipper/{railtie.rb → engine.rb} +19 -3
  23. data/lib/flipper/metadata.rb +5 -1
  24. data/lib/flipper/spec/shared_adapter_specs.rb +43 -43
  25. data/lib/flipper/test/shared_adapter_test.rb +43 -43
  26. data/lib/flipper/version.rb +1 -1
  27. data/lib/flipper.rb +3 -5
  28. data/spec/flipper/adapters/dual_write_spec.rb +2 -2
  29. data/spec/flipper/adapters/instrumented_spec.rb +1 -1
  30. data/spec/flipper/adapters/memoizable_spec.rb +6 -6
  31. data/spec/flipper/adapters/operation_logger_spec.rb +2 -2
  32. data/spec/flipper/adapters/read_only_spec.rb +6 -6
  33. data/spec/flipper/cloud/configuration_spec.rb +269 -0
  34. data/spec/flipper/cloud/dsl_spec.rb +82 -0
  35. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  36. data/spec/flipper/cloud/middleware_spec.rb +289 -0
  37. data/spec/flipper/cloud_spec.rb +180 -0
  38. data/spec/flipper/dsl_spec.rb +0 -75
  39. data/spec/flipper/engine_spec.rb +190 -0
  40. data/spec/flipper_integration_spec.rb +12 -12
  41. data/spec/flipper_spec.rb +0 -30
  42. data/spec/spec_helper.rb +0 -12
  43. data/spec/support/climate_control.rb +7 -0
  44. metadata +54 -10
  45. data/spec/flipper/railtie_spec.rb +0 -109
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32360e70231522c5c5b41e5e9e889fd0d4df13c59ba223dd5416433d904954e5
4
- data.tar.gz: 5ec60d72b7b00d4d4bb5458d5746a4df4b89f3656fa9d4c7bfeddb27caae9c81
3
+ metadata.gz: 157649a8da61471fe5d0a9f88f739f72521a3d78d922650ab40cf36a5c5a6e22
4
+ data.tar.gz: 5ab348ba8ac11acc776324997f03ee95ee883e1544452ec1199657cac2ecc53c
5
5
  SHA512:
6
- metadata.gz: 12e61dc74aa548fe5b266c1d909e650190416d8350adfd12c608a84b16b5ede9cce4b753d74c5fed472506bfb0dd9a3e0e768bcb193f6f751408f9550513774e
7
- data.tar.gz: a2c0dc70d0fd50848fe9aedc2ed9f8f396dd51f5b604badc1477efd0249104e9fa7276d5161ac95e25992d8b1765eadd491e9a1a1d79640c6644e9fdd69442f2
6
+ metadata.gz: 36eacd774551b1b91d75c7db4978929820918253c05351068471b5f1c9221f0e0333b511715d4baf11191744476b79497d79ab074c2f9c344eaea2002f07a7fa
7
+ data.tar.gz: dfa622c2db1a7a9befce00f2e6ce0e12dcff8f32cfb2c8e0d24aa3d9b7daaa83157e2bc2162fe8e444a433dfbec369c6ec66f317f4b3d431a8118be4a60fc3ab
data/Changelog.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## Unreleased
6
+
7
+ ### Additions/Changes
8
+
9
+ * ui, api: Allow Rack 3 (https://github.com/jnunemaker/flipper/pull/670)
10
+ * cloud: The `flipper-cloud` gem has been merged into the `flipper` and no longer needs to be added separately. Configure cloud by setting the `FLIPPER_CLOUD_TOKEN` environment variable. (https://github.com/jnunemaker/flipper/pull/743)
11
+ ```diff
12
+ # Gemfile
13
+ gem 'flipper'
14
+ - gem 'flipper-cloud'
15
+ ```
16
+
17
+ ### Breaking Changes
18
+
19
+ * Removed `bool`, `actors`, `time`, `actor`, `percentage_of_actors`, and `percentage_of_time` methods on `Flipper` and `Flipper::DSL`. They are rarely if ever used and conflict with some upcoming features. If you are using them, you can migrate via a search and replace like so:
20
+ * Change `Flipper.bool` => `Flipper::Types::Boolean.new`
21
+ * Change `Flipper.boolean` => `Flipper::Types::Boolean.new`
22
+ * Change `Flipper.actor` => `Flipper::Types::Actor.new`
23
+ * Change `Flipper.percentage_of_actors` => `Flipper::Types::PercentageOfActors.new`
24
+ * Change `Flipper.actors` => `Flipper::Types::PercentageOfActors.new`
25
+ * Change `Flipper.percentage_of_time` => `Flipper::Types::PercentageOfTime.new`
26
+ * Change `Flipper.time` => `Flipper::Types::PercentageOfTime.new`
27
+
5
28
  ## 0.28.3
6
29
 
7
30
  * Updated cloud config to ensure that poll adapter ONLY syncs from cloud to local adapter (and never back to cloud). Shouldn't affect anyone other than making things more safe if an incorrect response is received from the cloud poll endpoint. (https://github.com/jnunemaker/flipper/pull/740)
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