bows 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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