flipper-cloud 0.17.2 → 0.20.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7fede3c397bbdeac69fbbc03de325433ba10255d1c6c776bfb2ba14a4dad136
4
- data.tar.gz: f43c172ab7c249397aab32008c6239827956dc2971ad219078f8a0d43a75f146
3
+ metadata.gz: ad733b77b848c6b7b0f1acbe1b9b6c95f87a71e0075dee518c3cfea654b46742
4
+ data.tar.gz: f030f6785ccd8482c660fd9f3e9ece0a81c4b5f455b8c8559ab39b6dc025d29e
5
5
  SHA512:
6
- metadata.gz: 83ed632dcbebf3ff0ac131a9778735b1de56aa372210b209ada815c2bb55f7bf13c1c05c9f683042ed23ca977bdd4adc1857bf0e82e2caa0e4cefa797d20413f
7
- data.tar.gz: f58c5686b43aa3a2ad0c728136db8f212ba42fec6fde838e4bbb6e3ad270beb82f1b44c11a014d27689e314deaa50aef40c9567f767b7e472e473b7c7ab7cc7d
6
+ metadata.gz: 747bc35cfd3642780198a4219cf3b8d91b63aa008efaf445900763b1a8fc61229c4dae9da8affe2b0e607ee86bb1774b6a37abd1ab338c8766dca52baf5ba436
7
+ data.tar.gz: 3d4d1e9b03701f21719e132bcf11cc8d0b9f1d134fea56a14d19df95b9ea6c472eff0fc6daf87d4a9888741b950e3ba21d611b174959c6d707d83a316c77668d
@@ -0,0 +1,17 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> FLIPPER_CLOUD_SYNC_METHOD=webhook bundle exec rackup examples/ui/basic.ru -p 9999
3
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> FLIPPER_CLOUD_SYNC_METHOD=webhook bundle exec shotgun examples/ui/basic.ru -p 9999
4
+ # http://localhost:9999/
5
+ # http://localhost:9999/webhooks
6
+
7
+ require 'pathname'
8
+ root_path = Pathname(__FILE__).dirname.join('..').expand_path
9
+ lib_path = root_path.join('lib')
10
+ $:.unshift(lib_path)
11
+
12
+ require 'flipper/cloud'
13
+ Flipper.configure do |config|
14
+ config.default { Flipper::Cloud.new }
15
+ end
16
+
17
+ run Flipper::Cloud.app
@@ -10,7 +10,6 @@ Gem::Specification.new do |gem|
10
10
  gem.authors = ['John Nunemaker']
11
11
  gem.email = ['nunemaker@gmail.com']
12
12
  gem.summary = 'FeatureFlipper.com adapter for Flipper'
13
- gem.description = 'FeatureFlipper.com adapter for Flipper'
14
13
  gem.license = 'MIT'
15
14
  gem.homepage = 'https://github.com/jnunemaker/flipper'
16
15
 
@@ -18,7 +17,7 @@ Gem::Specification.new do |gem|
18
17
  'lib/flipper/version.rb',
19
18
  ]
20
19
  gem.files = `git ls-files`.split("\n").select(&flipper_cloud_files) + extra_files
21
- gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_cloud_files) # rubocop:disable Layout/LineLength
20
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n").select(&flipper_cloud_files)
22
21
  gem.name = 'flipper-cloud'
23
22
  gem.require_paths = ['lib']
24
23
  gem.version = Flipper::VERSION
@@ -1,5 +1,9 @@
1
1
  require "flipper"
2
+ require "flipper/middleware/setup_env"
3
+ require "flipper/middleware/memoizer"
2
4
  require "flipper/cloud/configuration"
5
+ require "flipper/cloud/dsl"
6
+ require "flipper/cloud/middleware"
3
7
 
4
8
  module Flipper
5
9
  module Cloud
@@ -10,10 +14,27 @@ module Flipper
10
14
  # options - The Hash of options. See Flipper::Cloud::Configuration.
11
15
  # block - The block that configuration will be yielded to allowing you to
12
16
  # customize this cloud instance and its adapter.
13
- def self.new(token, options = {})
14
- configuration = Configuration.new(options.merge(token: token))
17
+ def self.new(token = nil, options = {})
18
+ options = options.merge(token: token) if token
19
+ configuration = Configuration.new(options)
15
20
  yield configuration if block_given?
16
- Flipper.new(configuration.adapter, instrumenter: configuration.instrumenter)
21
+ DSL.new(configuration)
22
+ end
23
+
24
+ def self.app(flipper = nil, options = {})
25
+ env_key = options.fetch(:env_key, 'flipper')
26
+ memoizer_options = options.fetch(:memoizer_options, {})
27
+
28
+ app = ->(_) { [404, { 'Content-Type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] }
29
+ builder = Rack::Builder.new
30
+ yield builder if block_given?
31
+ builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key
32
+ builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key)
33
+ builder.use Flipper::Cloud::Middleware, env_key: env_key
34
+ builder.run app
35
+ klass = self
36
+ builder.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output
37
+ builder
17
38
  end
18
39
  end
19
40
  end
@@ -1,19 +1,22 @@
1
1
  require "flipper/adapters/http"
2
2
  require "flipper/adapters/memory"
3
+ require "flipper/adapters/dual_write"
3
4
  require "flipper/adapters/sync"
4
5
 
5
6
  module Flipper
6
7
  module Cloud
7
8
  class Configuration
8
- # The default url should be the one, the only, the website.
9
- DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
9
+ # The set of valid ways that syncing can happpen.
10
+ VALID_SYNC_METHODS = Set[
11
+ :poll,
12
+ :webhook,
13
+ ].freeze
10
14
 
11
15
  # Public: The token corresponding to an environment on flippercloud.io.
12
16
  attr_accessor :token
13
17
 
14
- # Public: The url for http adapter (default: Flipper::Cloud::DEFAULT_URL).
15
- # Really should only be customized for development work. Feel free
16
- # to forget you ever saw this.
18
+ # Public: The url for http adapter. Really should only be customized for
19
+ # development work. Feel free to forget you ever saw this.
17
20
  attr_reader :url
18
21
 
19
22
  # Public: net/http read timeout for all http requests (default: 5).
@@ -53,18 +56,32 @@ module Flipper
53
56
  # the local in sync with cloud (default: 10).
54
57
  attr_accessor :sync_interval
55
58
 
59
+ # Public: The method to be used for synchronizing your local flipper
60
+ # adapter with cloud. (default: :poll, can also be :webhook).
61
+ attr_reader :sync_method
62
+
63
+ # Public: The secret used to verify if syncs in the middleware should
64
+ # occur or not.
65
+ attr_accessor :sync_secret
66
+
56
67
  def initialize(options = {})
57
- @token = options.fetch(:token)
68
+ @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
69
+
70
+ if @token.nil?
71
+ raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token used to validate webhooks (e.g. Flipper::Cloud.new('token'))."
72
+ end
73
+
58
74
  @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
59
- @read_timeout = options.fetch(:read_timeout, 5)
60
- @open_timeout = options.fetch(:open_timeout, 5)
61
- @write_timeout = options.fetch(:write_timeout, 5)
62
- @sync_interval = options.fetch(:sync_interval, 10)
75
+ @read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
76
+ @open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
77
+ @write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
78
+ @sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
79
+ @sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
63
80
  @local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
64
81
  @debug_output = options[:debug_output]
65
82
  @adapter_block = ->(adapter) { adapter }
66
-
67
- self.url = options.fetch(:url, DEFAULT_URL)
83
+ self.sync_method = options.fetch(:sync_method) { ENV.fetch("FLIPPER_CLOUD_SYNC_METHOD", :poll).to_sym }
84
+ self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", "https://www.flippercloud.io/adapter".freeze) }
68
85
  end
69
86
 
70
87
  # Public: Read or customize the http adapter. Calling without a block will
@@ -82,34 +99,61 @@ module Flipper
82
99
  if block_given?
83
100
  @adapter_block = block
84
101
  else
85
- @adapter_block.call sync_adapter
102
+ @adapter_block.call app_adapter
86
103
  end
87
104
  end
88
105
 
89
106
  # Public: Set url for the http adapter.
90
107
  attr_writer :url
91
108
 
109
+ def sync
110
+ Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
111
+ instrumenter: instrumenter,
112
+ interval: sync_interval,
113
+ }).call
114
+ end
115
+
116
+ def sync_method=(new_sync_method)
117
+ new_sync_method = new_sync_method.to_sym
118
+
119
+ unless VALID_SYNC_METHODS.include?(new_sync_method)
120
+ raise ArgumentError, "Unsupported sync_method. Valid options are (#{VALID_SYNC_METHODS.to_a.join(', ')})"
121
+ end
122
+
123
+ if new_sync_method == :webhook && sync_secret.nil?
124
+ raise ArgumentError, "Flipper::Cloud sync_secret is missing. Please set FLIPPER_CLOUD_SYNC_SECRET or provide the sync_secret used to validate webhooks."
125
+ end
126
+
127
+ @sync_method = new_sync_method
128
+ end
129
+
92
130
  private
93
131
 
132
+ def app_adapter
133
+ sync_method == :webhook ? dual_write_adapter : sync_adapter
134
+ end
135
+
136
+ def dual_write_adapter
137
+ Flipper::Adapters::DualWrite.new(local_adapter, http_adapter)
138
+ end
139
+
94
140
  def sync_adapter
95
- sync_options = {
141
+ Flipper::Adapters::Sync.new(local_adapter, http_adapter, {
96
142
  instrumenter: instrumenter,
97
143
  interval: sync_interval,
98
- }
99
- Flipper::Adapters::Sync.new(local_adapter, http_adapter, sync_options)
144
+ })
100
145
  end
101
146
 
102
147
  def http_adapter
103
- http_options = {
148
+ Flipper::Adapters::Http.new({
104
149
  url: @url,
105
150
  read_timeout: @read_timeout,
106
151
  open_timeout: @open_timeout,
107
152
  debug_output: @debug_output,
108
153
  headers: {
109
- "Feature-Flipper-Token" => @token,
154
+ "Flipper-Cloud-Token" => @token,
110
155
  },
111
- }
112
- Flipper::Adapters::Http.new(http_options)
156
+ })
113
157
  end
114
158
  end
115
159
  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,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,48 @@
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
+
11
+ def initialize(app, options = {})
12
+ @app = app
13
+ @env_key = options.fetch(:env_key, 'flipper')
14
+ end
15
+
16
+ def call(env)
17
+ dup.call!(env)
18
+ end
19
+
20
+ def call!(env)
21
+ request = Rack::Request.new(env)
22
+ if request.post? && request.path_info.match(WEBHOOK_PATH)
23
+ status = 200
24
+ headers = {
25
+ "Content-Type" => "application/json",
26
+ }
27
+ body = "{}"
28
+ payload = request.body.read
29
+ signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"]
30
+ flipper = env.fetch(@env_key)
31
+
32
+ begin
33
+ message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
34
+ if message_verifier.verify(payload, signature)
35
+ flipper.sync
36
+ end
37
+ rescue MessageVerifier::InvalidSignature
38
+ status = 400
39
+ end
40
+
41
+ [status, headers, [body]]
42
+ else
43
+ @app.call(env)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,3 +1,3 @@
1
1
  module Flipper
2
- VERSION = '0.17.2'.freeze
2
+ VERSION = '0.20.0.beta2'.freeze
3
3
  end
@@ -12,6 +12,13 @@ RSpec.describe Flipper::Cloud::Configuration do
12
12
  expect(instance.token).to eq(required_options[:token])
13
13
  end
14
14
 
15
+ it "can set token from ENV var" do
16
+ with_modified_env "FLIPPER_CLOUD_TOKEN" => "from_env" do
17
+ instance = described_class.new(required_options.reject { |k, v| k == :token })
18
+ expect(instance.token).to eq("from_env")
19
+ end
20
+ end
21
+
15
22
  it "can set instrumenter" do
16
23
  instrumenter = Object.new
17
24
  instance = described_class.new(required_options.merge(instrumenter: instrumenter))
@@ -23,21 +30,49 @@ RSpec.describe Flipper::Cloud::Configuration do
23
30
  expect(instance.read_timeout).to eq(5)
24
31
  end
25
32
 
33
+ it "can set read_timeout from ENV var" do
34
+ with_modified_env "FLIPPER_CLOUD_READ_TIMEOUT" => "9" do
35
+ instance = described_class.new(required_options.reject { |k, v| k == :read_timeout })
36
+ expect(instance.read_timeout).to eq(9)
37
+ end
38
+ end
39
+
26
40
  it "can set open_timeout" do
27
41
  instance = described_class.new(required_options.merge(open_timeout: 5))
28
42
  expect(instance.open_timeout).to eq(5)
29
43
  end
30
44
 
45
+ it "can set open_timeout from ENV var" do
46
+ with_modified_env "FLIPPER_CLOUD_OPEN_TIMEOUT" => "9" do
47
+ instance = described_class.new(required_options.reject { |k, v| k == :open_timeout })
48
+ expect(instance.open_timeout).to eq(9)
49
+ end
50
+ end
51
+
31
52
  it "can set write_timeout" do
32
53
  instance = described_class.new(required_options.merge(write_timeout: 5))
33
54
  expect(instance.write_timeout).to eq(5)
34
55
  end
35
56
 
57
+ it "can set write_timeout from ENV var" do
58
+ with_modified_env "FLIPPER_CLOUD_WRITE_TIMEOUT" => "9" do
59
+ instance = described_class.new(required_options.reject { |k, v| k == :write_timeout })
60
+ expect(instance.write_timeout).to eq(9)
61
+ end
62
+ end
63
+
36
64
  it "can set sync_interval" do
37
65
  instance = described_class.new(required_options.merge(sync_interval: 1))
38
66
  expect(instance.sync_interval).to eq(1)
39
67
  end
40
68
 
69
+ it "can set sync_interval from ENV var" do
70
+ with_modified_env "FLIPPER_CLOUD_SYNC_INTERVAL" => "5" do
71
+ instance = described_class.new(required_options.reject { |k, v| k == :sync_interval })
72
+ expect(instance.sync_interval).to eq(5)
73
+ end
74
+ end
75
+
41
76
  it "passes sync_interval into sync adapter" do
42
77
  # The initial sync of http to local invokes this web request.
43
78
  stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
@@ -70,7 +105,12 @@ RSpec.describe Flipper::Cloud::Configuration do
70
105
  expect(instance.adapter).to be_instance_of(Flipper::Adapters::Instrumented)
71
106
  end
72
107
 
73
- it "can override url" do
108
+ it "defaults url" do
109
+ instance = described_class.new(required_options.reject { |k, v| k == :url })
110
+ expect(instance.url).to eq("https://www.flippercloud.io/adapter")
111
+ end
112
+
113
+ it "can override url using options" do
74
114
  options = required_options.merge(url: "http://localhost:5000/adapter")
75
115
  instance = described_class.new(options)
76
116
  expect(instance.url).to eq("http://localhost:5000/adapter")
@@ -79,4 +119,157 @@ RSpec.describe Flipper::Cloud::Configuration do
79
119
  instance.url = "http://localhost:5000/adapter"
80
120
  expect(instance.url).to eq("http://localhost:5000/adapter")
81
121
  end
122
+
123
+ it "can override URL using ENV var" do
124
+ with_modified_env "FLIPPER_CLOUD_URL" => "https://example.com" do
125
+ instance = described_class.new(required_options.reject { |k, v| k == :url })
126
+ expect(instance.url).to eq("https://example.com")
127
+ end
128
+ end
129
+
130
+ it "defaults to sync_method to poll" do
131
+ memory_adapter = Flipper::Adapters::Memory.new
132
+ instance = described_class.new(required_options)
133
+
134
+ expect(instance.sync_method).to eq(:poll)
135
+ end
136
+
137
+ it "can use webhook for sync_method" do
138
+ memory_adapter = Flipper::Adapters::Memory.new
139
+ instance = described_class.new(required_options.merge({
140
+ sync_secret: "secret",
141
+ sync_method: :webhook,
142
+ local_adapter: memory_adapter,
143
+ }))
144
+
145
+ expect(instance.sync_method).to eq(:webhook)
146
+ expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite)
147
+ end
148
+
149
+ it "raises ArgumentError for invalid sync_method" do
150
+ expect {
151
+ described_class.new(required_options.merge(sync_method: :foo))
152
+ }.to raise_error(ArgumentError, "Unsupported sync_method. Valid options are (poll, webhook)")
153
+ end
154
+
155
+ it "can use ENV var for sync_method" do
156
+ with_modified_env "FLIPPER_CLOUD_SYNC_METHOD" => "webhook" do
157
+ instance = described_class.new(required_options.merge({
158
+ sync_secret: "secret",
159
+ }))
160
+
161
+ expect(instance.sync_method).to eq(:webhook)
162
+ end
163
+ end
164
+
165
+ it "can use string sync_method instead of symbol" do
166
+ memory_adapter = Flipper::Adapters::Memory.new
167
+ instance = described_class.new(required_options.merge({
168
+ sync_secret: "secret",
169
+ sync_method: "webhook",
170
+ local_adapter: memory_adapter,
171
+ }))
172
+
173
+ expect(instance.sync_method).to eq(:webhook)
174
+ expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite)
175
+ end
176
+
177
+ it "can set sync_secret" do
178
+ instance = described_class.new(required_options.merge(sync_secret: "from_config"))
179
+ expect(instance.sync_secret).to eq("from_config")
180
+ end
181
+
182
+ it "can override sync_secret using ENV var" do
183
+ with_modified_env "FLIPPER_CLOUD_SYNC_SECRET" => "from_env" do
184
+ instance = described_class.new(required_options.reject { |k, v| k == :sync_secret })
185
+ expect(instance.sync_secret).to eq("from_env")
186
+ end
187
+ end
188
+
189
+ it "can sync with cloud" do
190
+ body = JSON.generate({
191
+ "features": [
192
+ {
193
+ "key": "search",
194
+ "state": "on",
195
+ "gates": [
196
+ {
197
+ "key": "boolean",
198
+ "name": "boolean",
199
+ "value": true
200
+ },
201
+ {
202
+ "key": "groups",
203
+ "name": "group",
204
+ "value": []
205
+ },
206
+ {
207
+ "key": "actors",
208
+ "name": "actor",
209
+ "value": []
210
+ },
211
+ {
212
+ "key": "percentage_of_actors",
213
+ "name": "percentage_of_actors",
214
+ "value": 0
215
+ },
216
+ {
217
+ "key": "percentage_of_time",
218
+ "name": "percentage_of_time",
219
+ "value": 0
220
+ }
221
+ ]
222
+ },
223
+ {
224
+ "key": "history",
225
+ "state": "off",
226
+ "gates": [
227
+ {
228
+ "key": "boolean",
229
+ "name": "boolean",
230
+ "value": false
231
+ },
232
+ {
233
+ "key": "groups",
234
+ "name": "group",
235
+ "value": []
236
+ },
237
+ {
238
+ "key": "actors",
239
+ "name": "actor",
240
+ "value": []
241
+ },
242
+ {
243
+ "key": "percentage_of_actors",
244
+ "name": "percentage_of_actors",
245
+ "value": 0
246
+ },
247
+ {
248
+ "key": "percentage_of_time",
249
+ "name": "percentage_of_time",
250
+ "value": 0
251
+ }
252
+ ]
253
+ }
254
+ ]
255
+ })
256
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
257
+ with({
258
+ headers: {
259
+ 'Flipper-Cloud-Token'=>'asdf',
260
+ },
261
+ }).to_return(status: 200, body: body, headers: {})
262
+ instance = described_class.new(required_options)
263
+ instance.sync
264
+
265
+ # Check that remote was fetched.
266
+ expect(stub).to have_been_requested
267
+
268
+ # Check that local adapter really did sync.
269
+ local_adapter = instance.adapter.instance_variable_get("@local")
270
+ all = local_adapter.get_all
271
+ expect(all.keys).to eq(["search", "history"])
272
+ expect(all["search"][:boolean]).to eq("true")
273
+ expect(all["history"][:boolean]).to eq(nil)
274
+ end
82
275
  end
@@ -0,0 +1,87 @@
1
+ require 'helper'
2
+ require 'flipper/cloud/configuration'
3
+ require 'flipper/cloud/dsl'
4
+ require 'flipper/adapters/operation_logger'
5
+ require 'flipper/adapters/instrumented'
6
+
7
+ RSpec.describe Flipper::Cloud::DSL do
8
+ it 'delegates everything to flipper instance' do
9
+ cloud_configuration = Flipper::Cloud::Configuration.new({
10
+ token: "asdf",
11
+ sync_secret: "tasty",
12
+ sync_method: :webhook,
13
+ })
14
+ dsl = described_class.new(cloud_configuration)
15
+ expect(dsl.features).to eq(Set.new)
16
+ expect(dsl.enabled?(:foo)).to be(false)
17
+ end
18
+
19
+ it 'delegates sync to cloud configuration' do
20
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
21
+ with({
22
+ headers: {
23
+ 'Flipper-Cloud-Token'=>'asdf',
24
+ },
25
+ }).to_return(status: 200, body: '{"features": {}}', headers: {})
26
+ cloud_configuration = Flipper::Cloud::Configuration.new({
27
+ token: "asdf",
28
+ sync_secret: "tasty",
29
+ sync_method: :webhook,
30
+ })
31
+ dsl = described_class.new(cloud_configuration)
32
+ dsl.sync
33
+ expect(stub).to have_been_requested
34
+ end
35
+
36
+ it 'delegates sync_secret to cloud configuration' do
37
+ cloud_configuration = Flipper::Cloud::Configuration.new({
38
+ token: "asdf",
39
+ sync_secret: "tasty",
40
+ sync_method: :webhook,
41
+ })
42
+ dsl = described_class.new(cloud_configuration)
43
+ expect(dsl.sync_secret).to eq("tasty")
44
+ end
45
+
46
+ context "when sync_method is webhook" do
47
+ let(:local_adapter) do
48
+ Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
49
+ end
50
+
51
+ let(:cloud_configuration) do
52
+ cloud_configuration = Flipper::Cloud::Configuration.new({
53
+ token: "asdf",
54
+ sync_secret: "tasty",
55
+ sync_method: :webhook,
56
+ local_adapter: local_adapter
57
+ })
58
+ end
59
+
60
+ subject do
61
+ described_class.new(cloud_configuration)
62
+ end
63
+
64
+ it "sends reads to local adapter" do
65
+ subject.features
66
+ subject.enabled?(:foo)
67
+ expect(local_adapter.count(:features)).to be(1)
68
+ expect(local_adapter.count(:get)).to be(1)
69
+ end
70
+
71
+ it "sends writes to cloud and local" do
72
+ add_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features").
73
+ with({headers: {'Flipper-Cloud-Token'=>'asdf'}}).
74
+ to_return(status: 200, body: '{}', headers: {})
75
+ enable_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features/foo/boolean").
76
+ with(headers: {'Flipper-Cloud-Token'=>'asdf'}).
77
+ to_return(status: 200, body: '{}', headers: {})
78
+
79
+ subject.enable(:foo)
80
+
81
+ expect(local_adapter.count(:add)).to be(1)
82
+ expect(local_adapter.count(:enable)).to be(1)
83
+ expect(add_stub).to have_been_requested
84
+ expect(enable_stub).to have_been_requested
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,105 @@
1
+ require 'helper'
2
+ require 'flipper/cloud/message_verifier'
3
+
4
+ RSpec.describe Flipper::Cloud::MessageVerifier do
5
+ let(:payload) { "some payload" }
6
+ let(:secret) { "secret" }
7
+ let(:timestamp) { Time.now }
8
+
9
+ describe "#generate" do
10
+ it "generates signature that can be verified" do
11
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
12
+ signature = message_verifier.generate(payload, timestamp)
13
+ header = generate_header(timestamp: timestamp, signature: signature)
14
+ expect(message_verifier.verify(payload, header)).to be(true)
15
+ end
16
+ end
17
+
18
+ describe "#header" do
19
+ it "generates a header in valid format" do
20
+ version = "v1"
21
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
22
+ signature = message_verifier.generate(payload, timestamp)
23
+ header = message_verifier.header(signature, timestamp)
24
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
25
+ end
26
+ end
27
+
28
+ describe ".header" do
29
+ it "generates a header in valid format" do
30
+ version = "v1"
31
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
32
+ signature = message_verifier.generate(payload, timestamp)
33
+
34
+ header = Flipper::Cloud::MessageVerifier.header(signature, timestamp, version)
35
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
36
+ end
37
+ end
38
+
39
+ describe "#verify" do
40
+ it "raises a InvalidSignature when the header does not have the expected format" do
41
+ header = "i'm not even a real signature header"
42
+ expect {
43
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
44
+ message_verifier.verify(payload, header)
45
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "Unable to extract timestamp and signatures from header")
46
+ end
47
+
48
+ it "raises a InvalidSignature when there are no signatures with the expected version" do
49
+ header = generate_header(version: "v0")
50
+ expect {
51
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
52
+ message_verifier.verify(payload, header)
53
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /No signatures found with expected version/)
54
+ end
55
+
56
+ it "raises a InvalidSignature when there are no valid signatures for the payload" do
57
+ header = generate_header(signature: "bad_signature")
58
+ expect {
59
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
60
+ message_verifier.verify(payload, header)
61
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "No signatures found matching the expected signature for payload")
62
+ end
63
+
64
+ it "raises a InvalidSignature when the timestamp is not within the tolerance" do
65
+ header = generate_header(timestamp: Time.now - 15)
66
+ expect {
67
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
68
+ message_verifier.verify(payload, header, tolerance: 10)
69
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /Timestamp outside the tolerance zone/)
70
+ end
71
+
72
+ it "returns true when the header contains a valid signature and the timestamp is within the tolerance" do
73
+ header = generate_header
74
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
75
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
76
+ end
77
+
78
+ it "returns true when the header contains at least one valid signature" do
79
+ header = generate_header + ",v1=bad_signature"
80
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
81
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
82
+ end
83
+
84
+ it "returns true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do
85
+ header = generate_header(timestamp: Time.at(12_345))
86
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
87
+ expect(message_verifier.verify(payload, header)).to be(true)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def generate_header(options = {})
94
+ options[:secret] ||= secret
95
+ options[:version] ||= "v1"
96
+
97
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: options[:secret], version: options[:version])
98
+
99
+ options[:timestamp] ||= timestamp
100
+ options[:payload] ||= payload
101
+ options[:signature] ||= message_verifier.generate(options[:payload], options[:timestamp])
102
+
103
+ Flipper::Cloud::MessageVerifier.header(options[:signature], options[:timestamp], options[:version])
104
+ end
105
+ end
@@ -0,0 +1,173 @@
1
+ require 'securerandom'
2
+ require 'helper'
3
+ require 'flipper/cloud'
4
+ require 'flipper/cloud/middleware'
5
+ require 'flipper/adapters/operation_logger'
6
+
7
+ RSpec.describe Flipper::Cloud::Middleware do
8
+ let(:flipper) {
9
+ Flipper::Cloud.new("regular") do |config|
10
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
11
+ config.sync_secret = "regular_tasty"
12
+ config.sync_method = :webhook
13
+ end
14
+ }
15
+
16
+ let(:env_flipper) {
17
+ Flipper::Cloud.new("env") do |config|
18
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
19
+ config.sync_secret = "env_tasty"
20
+ config.sync_method = :webhook
21
+ end
22
+ }
23
+
24
+ let(:app) { Flipper::Cloud.app(flipper) }
25
+ let(:response_body) { JSON.generate({features: {}}) }
26
+ let(:request_body) {
27
+ JSON.generate({
28
+ "environment_id" => 1,
29
+ "webhook_id" => 1,
30
+ "delivery_id" => SecureRandom.uuid,
31
+ "action" => "sync",
32
+ })
33
+ }
34
+ let(:timestamp) { Time.now }
35
+ let(:signature) {
36
+ Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp)
37
+ }
38
+ let(:signature_header_value) {
39
+ Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
40
+ }
41
+
42
+ context 'when initializing middleware with flipper instance' do
43
+ let(:app) { Flipper::Cloud.app(flipper) }
44
+
45
+ it 'uses instance to sync' do
46
+ stub = stub_request_for_token('regular')
47
+ env = {
48
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
49
+ }
50
+ post '/webhooks', request_body, env
51
+
52
+ expect(last_response.status).to eq(200)
53
+ expect(stub).to have_been_requested
54
+ end
55
+ end
56
+
57
+ context 'when signature is invalid' do
58
+ let(:app) { Flipper::Cloud.app(flipper) }
59
+ let(:signature) {
60
+ Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
61
+ }
62
+
63
+ it 'uses instance to sync' do
64
+ stub = stub_request_for_token('regular')
65
+ env = {
66
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
67
+ }
68
+ post '/webhooks', request_body, env
69
+
70
+ expect(last_response.status).to eq(400)
71
+ expect(stub).not_to have_been_requested
72
+ end
73
+ end
74
+
75
+ context 'when initialized with flipper instance and flipper instance in env' do
76
+ let(:app) { Flipper::Cloud.app(flipper) }
77
+ let(:signature) {
78
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
79
+ }
80
+
81
+ it 'uses env instance to sync' do
82
+ stub = stub_request_for_token('env')
83
+ env = {
84
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
85
+ 'flipper' => env_flipper,
86
+ }
87
+ post '/webhooks', request_body, env
88
+
89
+ expect(last_response.status).to eq(200)
90
+ expect(stub).to have_been_requested
91
+ end
92
+ end
93
+
94
+ context 'when initialized without flipper instance but flipper instance in env' do
95
+ let(:app) { Flipper::Cloud.app }
96
+ let(:signature) {
97
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
98
+ }
99
+
100
+ it 'uses env instance to sync' do
101
+ stub = stub_request_for_token('env')
102
+ env = {
103
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
104
+ 'flipper' => env_flipper,
105
+ }
106
+ post '/webhooks', request_body, env
107
+
108
+ expect(last_response.status).to eq(200)
109
+ expect(stub).to have_been_requested
110
+ end
111
+ end
112
+
113
+ context 'when initialized with env_key' do
114
+ let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
115
+ let(:signature) {
116
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
117
+ }
118
+
119
+ it 'uses provided env key instead of default' do
120
+ stub = stub_request_for_token('env')
121
+ env = {
122
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
123
+ 'flipper' => flipper,
124
+ 'flipper_cloud' => env_flipper,
125
+ }
126
+ post '/webhooks', request_body, env
127
+
128
+ expect(last_response.status).to eq(200)
129
+ expect(stub).to have_been_requested
130
+ end
131
+ end
132
+
133
+ context 'when initializing lazily with a block' do
134
+ let(:app) { Flipper::Cloud.app(-> { flipper }) }
135
+
136
+ it 'works' do
137
+ stub = stub_request_for_token('regular')
138
+ env = {
139
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
140
+ }
141
+ post '/webhooks', request_body, env
142
+
143
+ expect(last_response.status).to eq(200)
144
+ expect(stub).to have_been_requested
145
+ end
146
+ end
147
+
148
+ describe 'Request method unsupported' do
149
+ it 'skips middleware' do
150
+ get '/webhooks'
151
+ expect(last_response.status).to eq(404)
152
+ expect(last_response.content_type).to eq("application/json")
153
+ expect(last_response.body).to eq("{}")
154
+ end
155
+ end
156
+
157
+ describe 'Inspecting the built Rack app' do
158
+ it 'returns a String' do
159
+ expect(Flipper::Cloud.app(flipper).inspect).to be_a(String)
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def stub_request_for_token(token)
166
+ stub_request(:get, "https://www.flippercloud.io/adapter/features").
167
+ with({
168
+ headers: {
169
+ 'Flipper-Cloud-Token' => token,
170
+ },
171
+ }).to_return(status: 200, body: response_body, headers: {})
172
+ end
173
+ end
@@ -20,7 +20,11 @@ RSpec.describe Flipper::Cloud do
20
20
  end
21
21
 
22
22
  it 'returns Flipper::DSL instance' do
23
- expect(@instance).to be_instance_of(Flipper::DSL)
23
+ expect(@instance).to be_instance_of(Flipper::Cloud::DSL)
24
+ end
25
+
26
+ it 'can read the cloud configuration' do
27
+ expect(@instance.cloud_configuration).to be_instance_of(Flipper::Cloud::Configuration)
24
28
  end
25
29
 
26
30
  it 'configures instance to use http adapter' do
@@ -36,7 +40,7 @@ RSpec.describe Flipper::Cloud do
36
40
 
37
41
  it 'sets correct token header' do
38
42
  headers = @http_client.instance_variable_get('@headers')
39
- expect(headers['Feature-Flipper-Token']).to eq(token)
43
+ expect(headers['Flipper-Cloud-Token']).to eq(token)
40
44
  end
41
45
 
42
46
  it 'uses noop instrumenter' do
@@ -63,6 +67,12 @@ RSpec.describe Flipper::Cloud do
63
67
  end
64
68
  end
65
69
 
70
+ it 'can initialize with no token explicitly provided' do
71
+ with_modified_env "FLIPPER_CLOUD_TOKEN" => "asdf" do
72
+ expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL)
73
+ end
74
+ end
75
+
66
76
  it 'can set instrumenter' do
67
77
  instrumenter = Flipper::Instrumenters::Memory.new
68
78
  instance = described_class.new('asdf', instrumenter: instrumenter)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipper-cloud
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.2
4
+ version: 0.20.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-01-04 00:00:00.000000000 Z
11
+ date: 2020-12-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flipper
@@ -16,21 +16,22 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.17.2
19
+ version: 0.20.0.beta2
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.17.2
27
- description: FeatureFlipper.com adapter for Flipper
26
+ version: 0.20.0.beta2
27
+ description:
28
28
  email:
29
29
  - nunemaker@gmail.com
30
30
  executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
+ - examples/cloud/app.ru
34
35
  - examples/cloud/basic.rb
35
36
  - examples/cloud/cached_in_memory.rb
36
37
  - examples/cloud/import.rb
@@ -39,8 +40,14 @@ files:
39
40
  - lib/flipper-cloud.rb
40
41
  - lib/flipper/cloud.rb
41
42
  - lib/flipper/cloud/configuration.rb
43
+ - lib/flipper/cloud/dsl.rb
44
+ - lib/flipper/cloud/message_verifier.rb
45
+ - lib/flipper/cloud/middleware.rb
42
46
  - lib/flipper/version.rb
43
47
  - spec/flipper/cloud/configuration_spec.rb
48
+ - spec/flipper/cloud/dsl_spec.rb
49
+ - spec/flipper/cloud/message_verifier_spec.rb
50
+ - spec/flipper/cloud/middleware_spec.rb
44
51
  - spec/flipper/cloud_spec.rb
45
52
  homepage: https://github.com/jnunemaker/flipper
46
53
  licenses:
@@ -58,9 +65,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
58
65
  version: '0'
59
66
  required_rubygems_version: !ruby/object:Gem::Requirement
60
67
  requirements:
61
- - - ">="
68
+ - - ">"
62
69
  - !ruby/object:Gem::Version
63
- version: '0'
70
+ version: 1.3.1
64
71
  requirements: []
65
72
  rubygems_version: 3.0.3
66
73
  signing_key:
@@ -68,4 +75,7 @@ specification_version: 4
68
75
  summary: FeatureFlipper.com adapter for Flipper
69
76
  test_files:
70
77
  - spec/flipper/cloud/configuration_spec.rb
78
+ - spec/flipper/cloud/dsl_spec.rb
79
+ - spec/flipper/cloud/message_verifier_spec.rb
80
+ - spec/flipper/cloud/middleware_spec.rb
71
81
  - spec/flipper/cloud_spec.rb