bows 0.0.1

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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/lib/ribbon/intercom.rb +42 -0
  3. data/lib/ribbon/intercom/client.rb +61 -0
  4. data/lib/ribbon/intercom/client/mock_sdk.rb +13 -0
  5. data/lib/ribbon/intercom/client/sdk.rb +99 -0
  6. data/lib/ribbon/intercom/client/sdk/adapters.rb +10 -0
  7. data/lib/ribbon/intercom/client/sdk/adapters/adapter.rb +77 -0
  8. data/lib/ribbon/intercom/client/sdk/adapters/adapter/response.rb +13 -0
  9. data/lib/ribbon/intercom/client/sdk/adapters/http_adapter.rb +32 -0
  10. data/lib/ribbon/intercom/client/sdk/adapters/http_adapter/connection.rb +34 -0
  11. data/lib/ribbon/intercom/client/sdk/adapters/local_adapter.rb +55 -0
  12. data/lib/ribbon/intercom/client/sdk/adapters/mock_adapter.rb +40 -0
  13. data/lib/ribbon/intercom/errors.rb +66 -0
  14. data/lib/ribbon/intercom/package.rb +121 -0
  15. data/lib/ribbon/intercom/packageable.rb +6 -0
  16. data/lib/ribbon/intercom/packageable/mixin.rb +29 -0
  17. data/lib/ribbon/intercom/packet.rb +52 -0
  18. data/lib/ribbon/intercom/packet/method_queue.rb +28 -0
  19. data/lib/ribbon/intercom/railtie.rb +14 -0
  20. data/lib/ribbon/intercom/service.rb +273 -0
  21. data/lib/ribbon/intercom/service/channel.rb +203 -0
  22. data/lib/ribbon/intercom/service/channel/stores.rb +9 -0
  23. data/lib/ribbon/intercom/service/channel/stores/mock_store.rb +40 -0
  24. data/lib/ribbon/intercom/service/channel/stores/redis_store.rb +196 -0
  25. data/lib/ribbon/intercom/service/channel/stores/store.rb +31 -0
  26. data/lib/ribbon/intercom/utils.rb +72 -0
  27. data/lib/ribbon/intercom/utils/method_chain.rb +38 -0
  28. data/lib/ribbon/intercom/utils/mixins.rb +5 -0
  29. data/lib/ribbon/intercom/utils/mixins/mock_safe.rb +26 -0
  30. data/lib/ribbon/intercom/utils/signer.rb +71 -0
  31. data/lib/ribbon/intercom/version.rb +5 -0
  32. data/lib/tasks/intercom.rake +24 -0
  33. metadata +215 -0
@@ -0,0 +1,203 @@
1
+ require 'securerandom'
2
+ require 'bcrypt'
3
+
4
+ module Ribbon::Intercom
5
+ class Service
6
+ class Channel
7
+ autoload(:Stores, 'ribbon/intercom/service/channel/stores')
8
+
9
+ include BCrypt
10
+
11
+ attr_reader :name
12
+ attr_reader :store
13
+ attr_reader :token
14
+ attr_reader :signing_keys
15
+ attr_reader :secret_hash_crt
16
+ attr_reader :secret_hash_prv
17
+
18
+ def initialize(store, params={})
19
+ @store = store
20
+ @name = params[:name] or raise Errors::ChannelNameMissingError
21
+ @token = params[:token]
22
+ refresh(params)
23
+ end
24
+
25
+ ##
26
+ # Refreshes the channel. To be called by the store after obtaining a lock.
27
+ def refresh(params={})
28
+ @secret_hash_crt = params[:secret_hash_crt]
29
+ @secret_hash_prv = params[:secret_hash_prv]
30
+ @signing_keys = params[:signing_keys] || {}
31
+
32
+ @_allowed_to = nil
33
+ may(*params.fetch(:may, []))
34
+ end
35
+
36
+ def rotate_secret
37
+ SecureRandom.hex(16).tap { |secret|
38
+ @secret_hash_prv = secret_hash_crt
39
+ @secret_hash_crt = Password.create(secret)
40
+ }
41
+ end
42
+
43
+ def rotate_secret!
44
+ rotate_secret.tap { save }
45
+ end
46
+
47
+ def sign(data)
48
+ key_id, key = _signing_key
49
+ "\x01" + [key_id].pack('N') + Utils::Signer.new(key).sign(data)
50
+ end
51
+
52
+ def verify(signed_data)
53
+ key_id = signed_data.slice(1, 4).unpack('N').first
54
+
55
+ if (key=_retrieve_signing_key(key_id))
56
+ Utils::Signer.new(key).verify(signed_data[5..-1])
57
+ end
58
+ end
59
+
60
+ def may(*args)
61
+ (@_allowed_to ||= Hash.new(false)).merge!(
62
+ Hash[
63
+ args.map { |perm| [perm.to_s, true] }
64
+ ]
65
+ ).keys.to_set
66
+ end
67
+
68
+ def may!(*args)
69
+ may(*args).tap { save }
70
+ end
71
+
72
+ def may?(*perms)
73
+ perms.all? { |perm| _may?(perm) }
74
+ end
75
+
76
+ def permissions
77
+ @_allowed_to.keys.to_set
78
+ end
79
+
80
+ def valid_secret?(secret)
81
+ !!secret && (secret_crt == secret || secret_prv == secret)
82
+ end
83
+
84
+ def secret_crt
85
+ @__secret_crt ||= _to_bcrypt_pw(secret_hash_crt)
86
+ end
87
+
88
+ def secret_prv
89
+ @__secret_prv ||= _to_bcrypt_pw(secret_hash_prv)
90
+ end
91
+
92
+ def with_lock(&block)
93
+ store.with_lock(self, &block)
94
+ end
95
+
96
+ def save
97
+ # Loop until unique
98
+ unless token
99
+ loop { break unless store.token_exists?(@token = SecureRandom.hex(4)) }
100
+ end
101
+
102
+ _run_validations
103
+ store.persist(self)
104
+ end
105
+
106
+ def close
107
+ store.delete(self)
108
+ end
109
+
110
+ def ==(other)
111
+ other.is_a?(Channel) &&
112
+ name == other.name &&
113
+ store == other.store &&
114
+ token == other.token &&
115
+ secret_crt.to_s == other.secret_crt.to_s &&
116
+ secret_prv.to_s == other.secret_prv.to_s &&
117
+ permissions == other.permissions
118
+ end
119
+
120
+ private
121
+
122
+ def _may?(required_perm)
123
+ permissions.any? { |perm|
124
+ regex = /\A#{Regexp.escape(perm).gsub('\*', '.*')}\z/
125
+ regex.match(required_perm)
126
+ }
127
+ end
128
+
129
+ def _to_bcrypt_pw(pw)
130
+ Password.new(pw) if pw && !pw.empty?
131
+ end
132
+
133
+ def _run_validations
134
+ raise Errors::ChannelNameMissingError unless name
135
+ raise Errors::ChannelTokenMissingError unless token
136
+ raise Errors::ChannelSecretMissingError unless secret_hash_crt
137
+ end
138
+
139
+ def _latest_signing_key_id
140
+ ((signing_keys || {}).sort_by { |k, v| v[:timestamp] }.last || []).first
141
+ end
142
+
143
+ def _retrieve_signing_key(key_id)
144
+ (data=_retrieve_signing_key_data(key_id)) && data[:key]
145
+ end
146
+
147
+ def _retrieve_signing_key_data(key_id)
148
+ if key_id && (data=signing_keys[key_id])
149
+ data.merge(key_id: key_id)
150
+ else
151
+ {}
152
+ end
153
+ end
154
+
155
+ ##
156
+ # Returns whether the key is expired. Optionally, an offset may be specified
157
+ # to expire the key earlier or later.
158
+ def _signing_key_expired?(key_id)
159
+ _signing_key_ttl(key_id) <= 0
160
+ end
161
+
162
+ def _signing_key_ttl(key_id, ttl=3600)
163
+ timestamp = _retrieve_signing_key_data(key_id)[:timestamp]
164
+ (timestamp && (ttl - (Time.now.to_i - timestamp))).to_i
165
+ end
166
+
167
+ ##
168
+ # Retrieve the latest signing key or generate a new one if the latest has
169
+ # expired (older than 1 hour).
170
+ def _signing_key
171
+ latest_id = _latest_signing_key_id
172
+
173
+ # Refresh the key if the current key has less than 5 minutes to live.
174
+ if _signing_key_ttl(latest_id) < 300
175
+ with_lock {
176
+ latest_id = _latest_signing_key_id
177
+
178
+ if _signing_key_ttl(latest_id) < 300
179
+ latest_id = _add_signing_key
180
+ _delete_expired_signing_keys
181
+ save # Need to persist changes to signing keys.
182
+ end
183
+ }
184
+ end
185
+
186
+ [latest_id, _retrieve_signing_key(latest_id)]
187
+ end
188
+
189
+ def _add_signing_key
190
+ (_latest_signing_key_id.to_i + 1).tap { |key_id|
191
+ signing_keys[key_id] = {
192
+ key: Utils::Signer.random_key,
193
+ timestamp: Time.now.to_i
194
+ }
195
+ }
196
+ end
197
+
198
+ def _delete_expired_signing_keys
199
+ signing_keys.reject! { |key_id| _signing_key_expired?(key_id) }
200
+ end
201
+ end # Service
202
+ end # Channel
203
+ end # Ribbon::Intercom
@@ -0,0 +1,9 @@
1
+ module Ribbon::Intercom
2
+ class Service::Channel
3
+ module Stores
4
+ autoload(:Store, 'ribbon/intercom/service/channel/stores/store')
5
+ autoload(:RedisStore, 'ribbon/intercom/service/channel/stores/redis_store')
6
+ autoload(:MockStore, 'ribbon/intercom/service/channel/stores/mock_store')
7
+ end # Stores
8
+ end # Service::Channel
9
+ end # Ribbon::Intercom
@@ -0,0 +1,40 @@
1
+ module Ribbon::Intercom
2
+ class Service
3
+ module Channel::Stores
4
+ class MockStore < Store
5
+ def channels
6
+ @__channels ||= {}
7
+ end
8
+
9
+ def lock
10
+ @__lock ||= Mutex.new
11
+ end
12
+
13
+ def token_exists?(token)
14
+ channels.key?(token)
15
+ end
16
+
17
+ def lookup_channel(token)
18
+ channels[token]
19
+ end
20
+
21
+ def persist(channel)
22
+ raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
23
+ channels[channel.token] = channel
24
+ end
25
+
26
+ def delete(channel)
27
+ raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
28
+ channels.delete(channel.token)
29
+ nil
30
+ end
31
+
32
+ def with_lock(channel, &block)
33
+ # This is a global lock, not a per-channel lock, but this store is only
34
+ # for testing so let's KISS.
35
+ lock.synchronize(&block)
36
+ end
37
+ end # RedisStore
38
+ end # Channel::Stores
39
+ end # Service
40
+ end # Ribbon::Intercom
@@ -0,0 +1,196 @@
1
+ require 'redis'
2
+ require 'securerandom'
3
+ require 'base64'
4
+
5
+ module Ribbon::Intercom
6
+ class Service
7
+ module Channel::Stores
8
+ class RedisStore < Store
9
+ def initialize(params={})
10
+ if params[:url]
11
+ @_redis = Redis.new(url: params[:url])
12
+ elsif params[:redis]
13
+ @_redis = params[:redis]
14
+ else
15
+ raise Errors::InvalidStoreParamsError
16
+ end
17
+ end
18
+
19
+ def token_exists?(token)
20
+ @_redis.exists("channel:#{token}")
21
+ end
22
+
23
+ def lookup_channel(token)
24
+ if (data=_load_data(token))
25
+ Channel.new(self, data)
26
+ end
27
+ end
28
+
29
+ def persist(channel)
30
+ raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
31
+
32
+ channel.tap { |channel|
33
+ data_hash = {}
34
+ [:name, :token, :secret_hash_crt, :secret_hash_prv].each { |key|
35
+ value = channel.send(key)
36
+ data_hash[key] = value if value
37
+ }
38
+
39
+ # Save channel data as hash
40
+ @_redis.mapped_hmset(_key_name(channel), data_hash)
41
+
42
+ # Associate permissions set to its channel
43
+ channel.permissions.each { |p|
44
+ @_redis.sadd(_key_name(channel, 'permissions'), p)
45
+ }
46
+
47
+ # Save signing keys
48
+ key_hash = Hash[
49
+ channel.signing_keys.map { |key_id, data|
50
+ [key_id, Base64.strict_encode64(Marshal.dump(data))]
51
+ }
52
+ ]
53
+
54
+ @_redis.mapped_hmset(_key_name(channel, 'signing_keys'), key_hash) unless key_hash.empty?
55
+ }
56
+ end
57
+
58
+ def delete(channel)
59
+ raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
60
+
61
+ @_redis.del(_key_name(channel))
62
+ @_redis.del(_key_name(channel, 'permissions'))
63
+ nil
64
+ end
65
+
66
+ def with_lock(channel, ttl=1000)
67
+ Mutex.new(@_redis, _key_name(channel, 'lock')).synchronize(ttl) { |validity|
68
+ start_time = Mutex.time_in_ms
69
+ channel.refresh(_load_data(channel.token))
70
+ yield validity - (Mutex.time_in_ms - start_time)
71
+ }
72
+ end
73
+
74
+ private
75
+
76
+ def _load_data(token)
77
+ channel_data_future = nil
78
+ permissions_future = nil
79
+ signing_keys_future = nil
80
+
81
+ @_redis.pipelined {
82
+ channel_data_future = @_redis.hgetall(_key_name(token))
83
+ permissions_future = @_redis.smembers(_key_name(token, 'permissions'))
84
+ signing_keys_future = @_redis.hgetall(_key_name(token, 'signing_keys'))
85
+ }
86
+
87
+ channel_data = channel_data_future.value
88
+
89
+ if channel_data && !channel_data.empty?
90
+ permissions = permissions_future.value
91
+
92
+ signing_keys = signing_keys_future.value.map { |key_id, data|
93
+ [key_id.to_i, Marshal.load(Base64.strict_decode64(data))]
94
+ }.to_h
95
+
96
+ Utils.symbolize_keys(
97
+ channel_data.merge(
98
+ may: permissions,
99
+ signing_keys: signing_keys
100
+ )
101
+ )
102
+ end
103
+ end
104
+
105
+ ##
106
+ # A redis mutex inspired by <https://github.com/leandromoreira/redlock-rb>
107
+ class Mutex
108
+ class LockUnobtainableError < StandardError; end
109
+
110
+ ##
111
+ # Lua script to send to Redis when releasing a lock.
112
+ # See: http://redis.io/commands/set
113
+ UNLOCK_SCRIPT = <<-LUA
114
+ if redis.call("get",KEYS[1]) == ARGV[1] then
115
+ return redis.call("del",KEYS[1])
116
+ else
117
+ return 0
118
+ end
119
+ LUA
120
+
121
+ DEFAULT_RETRY_COUNT = 3
122
+ DEFAULT_RETRY_DELAY = 200
123
+ CLOCK_DRIFT_FACTOR = 0.01
124
+
125
+ class << self
126
+ def time_in_ms
127
+ (Time.now.to_f * 1000).to_i
128
+ end
129
+ end # Class Methods
130
+
131
+ attr_reader :redis
132
+ attr_reader :name
133
+ attr_reader :retry_count
134
+ attr_reader :retry_delay
135
+
136
+ def initialize(redis, name, opts={})
137
+ @redis = redis
138
+ @name = name
139
+ @retry_count = opts[:retry_count] || DEFAULT_RETRY_COUNT
140
+ @retry_delay = opts[:retry_delay] || DEFAULT_RETRY_DELAY
141
+ end
142
+
143
+ def synchronize(ttl)
144
+ retry_count.times {
145
+ start_time = self.class.time_in_ms
146
+
147
+ if (handle=lock(ttl))
148
+ begin
149
+ time_elapsed = (self.class.time_in_ms - start_time).to_i
150
+ return yield(ttl - time_elapsed - _drift(ttl))
151
+ ensure
152
+ unlock(handle)
153
+ end
154
+ else
155
+ sleep(rand(retry_delay).to_f / 1000)
156
+ end
157
+ }
158
+
159
+ raise LockUnobtainableError, name
160
+ end
161
+
162
+ ##
163
+ # Acquire a lock (non-blocking).
164
+ def lock(ttl=1000)
165
+ handle = SecureRandom.uuid
166
+ redis.set(name, handle, nx: true, px: ttl) && handle
167
+ end
168
+
169
+ ##
170
+ # Release a lock.
171
+ def unlock(handle)
172
+ redis.eval(UNLOCK_SCRIPT, [name], [handle])
173
+ rescue
174
+ # Do nothing, unlocking is best effort.
175
+ end
176
+
177
+ private
178
+
179
+ ##
180
+ # Approximate clock drift.
181
+ def _drift(ttl)
182
+ # Add 2 milliseconds to the drift to account for Redis expires
183
+ # precision, which is 1 millisecond, plus 1 millisecond min drift
184
+ # for small TTLs.
185
+ (ttl * CLOCK_DRIFT_FACTOR).to_i + 2
186
+ end
187
+ end # Mutex
188
+
189
+ def _key_name(channel_or_token, *sub_names)
190
+ token = channel_or_token.is_a?(Channel) ? channel_or_token.token : channel_or_token
191
+ ['channel', token, *sub_names].join(':')
192
+ end
193
+ end # RedisStore
194
+ end # Channel::Stores
195
+ end # Service
196
+ end # Ribbon::Intercom