flipper-cloud 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebdf8b29d3d86554ff8dbd738fd5db46e2dbcd0ba9d38dd49034e0f8edaf53df
4
- data.tar.gz: fe909fb2d4f21488ff0ad6db407a3936bbb7ca7df0c37b039e75fe960e67f846
3
+ metadata.gz: d17e92045e80beb6c0c83aa2591b75585393d0e6e922b834379fcfa0f5dc9049
4
+ data.tar.gz: 6943dde37bab5cadf5e8e8a2db847aa6ddb0e310fd1b7bc9c5638835ae81cfe1
5
5
  SHA512:
6
- metadata.gz: 04c191a3745be5f3155e38607d90d01fbd780581544cb57dc7b0e0a8d7363447e8ef73451f30fc44d68ba1948aff1be46c64dab8dbb7e990545a873d85522d1a
7
- data.tar.gz: 8b5072a46b285d9240ee7d3b65f137acd6de37fb1c920cfe6f5f0f9fd794f7da73d36f8e407214a37825f3c60a519931c626d622c9136147ce5bbd6721883573
6
+ metadata.gz: cf7073018558fa53b8f7ada5d984b42066d0a5ae5cd81e343cd249cc2d1032ecd4c95a094c2f49027f1bf87de7ddc6c7f1a5804c5658d4700c09f0c0c7d74b8c
7
+ data.tar.gz: faa00d96aae37eff0559f88e8a913ff407526825b55eff2404c794b2a6aa7f1f9882d04022746468fc157c52f8cc4eafa2ffc500c11aaa213876b38d963d6cab
@@ -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
@@ -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 (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,62 @@ 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: {
154
+ "Flipper-Cloud-Token" => @token,
109
155
  "Feature-Flipper-Token" => @token,
110
156
  },
111
- }
112
- Flipper::Adapters::Http.new(http_options)
157
+ })
113
158
  end
114
159
  end
115
160
  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,51 @@
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
+ body = JSON.generate({
37
+ groups: Flipper.group_names.map { |name| {name: name}}
38
+ })
39
+ end
40
+ rescue MessageVerifier::InvalidSignature
41
+ status = 400
42
+ end
43
+
44
+ [status, headers, [body]]
45
+ else
46
+ @app.call(env)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,3 +1,3 @@
1
1
  module Flipper
2
- VERSION = '0.19.0'.freeze
2
+ VERSION = '0.20.0'.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,188 @@
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
+ Flipper.register(:admins) { |*args| false }
47
+ Flipper.register(:staff) { |*args| false }
48
+ Flipper.register(:basic) { |*args| false }
49
+ Flipper.register(:plus) { |*args| false }
50
+ Flipper.register(:premium) { |*args| false }
51
+
52
+ stub = stub_request_for_token('regular')
53
+ env = {
54
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
55
+ }
56
+ post '/webhooks', request_body, env
57
+
58
+ expect(last_response.status).to eq(200)
59
+ expect(JSON.parse(last_response.body)).to eq({
60
+ "groups" => [
61
+ {"name" => "admins"},
62
+ {"name" => "staff"},
63
+ {"name" => "basic"},
64
+ {"name" => "plus"},
65
+ {"name" => "premium"},
66
+ ],
67
+ })
68
+ expect(stub).to have_been_requested
69
+ end
70
+ end
71
+
72
+ context 'when signature is invalid' do
73
+ let(:app) { Flipper::Cloud.app(flipper) }
74
+ let(:signature) {
75
+ Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
76
+ }
77
+
78
+ it 'uses instance to sync' do
79
+ stub = stub_request_for_token('regular')
80
+ env = {
81
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
82
+ }
83
+ post '/webhooks', request_body, env
84
+
85
+ expect(last_response.status).to eq(400)
86
+ expect(stub).not_to have_been_requested
87
+ end
88
+ end
89
+
90
+ context 'when initialized with flipper instance and flipper instance in env' do
91
+ let(:app) { Flipper::Cloud.app(flipper) }
92
+ let(:signature) {
93
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
94
+ }
95
+
96
+ it 'uses env instance to sync' do
97
+ stub = stub_request_for_token('env')
98
+ env = {
99
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
100
+ 'flipper' => env_flipper,
101
+ }
102
+ post '/webhooks', request_body, env
103
+
104
+ expect(last_response.status).to eq(200)
105
+ expect(stub).to have_been_requested
106
+ end
107
+ end
108
+
109
+ context 'when initialized without flipper instance but flipper instance in env' do
110
+ let(:app) { Flipper::Cloud.app }
111
+ let(:signature) {
112
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
113
+ }
114
+
115
+ it 'uses env instance to sync' do
116
+ stub = stub_request_for_token('env')
117
+ env = {
118
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
119
+ 'flipper' => env_flipper,
120
+ }
121
+ post '/webhooks', request_body, env
122
+
123
+ expect(last_response.status).to eq(200)
124
+ expect(stub).to have_been_requested
125
+ end
126
+ end
127
+
128
+ context 'when initialized with env_key' do
129
+ let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
130
+ let(:signature) {
131
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
132
+ }
133
+
134
+ it 'uses provided env key instead of default' do
135
+ stub = stub_request_for_token('env')
136
+ env = {
137
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
138
+ 'flipper' => flipper,
139
+ 'flipper_cloud' => env_flipper,
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
+ context 'when initializing lazily with a block' do
149
+ let(:app) { Flipper::Cloud.app(-> { flipper }) }
150
+
151
+ it 'works' do
152
+ stub = stub_request_for_token('regular')
153
+ env = {
154
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
155
+ }
156
+ post '/webhooks', request_body, env
157
+
158
+ expect(last_response.status).to eq(200)
159
+ expect(stub).to have_been_requested
160
+ end
161
+ end
162
+
163
+ describe 'Request method unsupported' do
164
+ it 'skips middleware' do
165
+ get '/webhooks'
166
+ expect(last_response.status).to eq(404)
167
+ expect(last_response.content_type).to eq("application/json")
168
+ expect(last_response.body).to eq("{}")
169
+ end
170
+ end
171
+
172
+ describe 'Inspecting the built Rack app' do
173
+ it 'returns a String' do
174
+ expect(Flipper::Cloud.app(flipper).inspect).to be_a(String)
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def stub_request_for_token(token)
181
+ stub_request(:get, "https://www.flippercloud.io/adapter/features").
182
+ with({
183
+ headers: {
184
+ 'Flipper-Cloud-Token' => token,
185
+ },
186
+ }).to_return(status: 200, body: response_body, headers: {})
187
+ end
188
+ 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.19.0
4
+ version: 0.20.0
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-09-25 00:00:00.000000000 Z
11
+ date: 2020-12-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flipper
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.19.0
19
+ version: 0.20.0
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.19.0
26
+ version: 0.20.0
27
27
  description:
28
28
  email:
29
29
  - nunemaker@gmail.com
@@ -31,6 +31,7 @@ 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:
@@ -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