ribbon-intercom 0.2.3 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ribbon/intercom/client/mock_sdk.rb +13 -0
  3. data/lib/ribbon/intercom/client/sdk/adapters/adapter/response.rb +38 -0
  4. data/lib/ribbon/intercom/client/sdk/adapters/adapter.rb +51 -0
  5. data/lib/ribbon/intercom/client/sdk/adapters/http_adapter/connection.rb +34 -0
  6. data/lib/ribbon/intercom/client/sdk/adapters/http_adapter.rb +26 -0
  7. data/lib/ribbon/intercom/client/sdk/adapters/local_adapter.rb +54 -0
  8. data/lib/ribbon/intercom/client/sdk/adapters/mock_adapter.rb +40 -0
  9. data/lib/ribbon/intercom/client/sdk/adapters.rb +10 -0
  10. data/lib/ribbon/intercom/client/sdk.rb +71 -26
  11. data/lib/ribbon/intercom/client.rb +35 -7
  12. data/lib/ribbon/intercom/errors.rb +34 -6
  13. data/lib/ribbon/intercom/package.rb +64 -0
  14. data/lib/ribbon/intercom/packageable/mixin.rb +35 -0
  15. data/lib/ribbon/intercom/packageable.rb +6 -0
  16. data/lib/ribbon/intercom/service/channel/stores/mock_store.rb +40 -0
  17. data/lib/ribbon/intercom/service/channel/stores/redis_store.rb +186 -0
  18. data/lib/ribbon/intercom/service/{channel_stores/base.rb → channel/stores/store.rb} +13 -5
  19. data/lib/ribbon/intercom/service/channel/stores.rb +9 -0
  20. data/lib/ribbon/intercom/service/channel.rb +106 -4
  21. data/lib/ribbon/intercom/service.rb +156 -95
  22. data/lib/ribbon/intercom/utils/mixins/mock_safe.rb +26 -0
  23. data/lib/ribbon/intercom/utils/mixins.rb +5 -0
  24. data/lib/ribbon/intercom/utils/signer.rb +71 -0
  25. data/lib/ribbon/intercom/utils.rb +40 -13
  26. data/lib/ribbon/intercom/version.rb +1 -1
  27. data/lib/ribbon/intercom.rb +10 -7
  28. data/lib/tasks/intercom.rake +3 -3
  29. metadata +20 -20
  30. data/lib/ribbon/intercom/client/sdk/connection.rb +0 -35
  31. data/lib/ribbon/intercom/client/sdk/response.rb +0 -59
  32. data/lib/ribbon/intercom/service/channel_stores/redis_store.rb +0 -60
@@ -0,0 +1,186 @@
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
+ if token_exists?(token) && (channel_data = @_redis.hgetall(_key_name(token)))
78
+ permissions = @_redis.smembers(_key_name(token, 'permissions'))
79
+
80
+ signing_keys = Hash[
81
+ @_redis.hgetall(_key_name(token, 'signing_keys')).map { |key_id, data|
82
+ [key_id.to_i, Marshal.load(Base64.strict_decode64(data))]
83
+ }
84
+ ]
85
+
86
+ Utils.symbolize_keys(
87
+ channel_data.merge(
88
+ may: permissions,
89
+ signing_keys: signing_keys
90
+ )
91
+ )
92
+ end
93
+ end
94
+
95
+ ##
96
+ # A redis mutex inspired by <https://github.com/leandromoreira/redlock-rb>
97
+ class Mutex
98
+ class LockUnobtainableError < StandardError; end
99
+
100
+ ##
101
+ # Lua script to send to Redis when releasing a lock.
102
+ # See: http://redis.io/commands/set
103
+ UNLOCK_SCRIPT = <<-LUA
104
+ if redis.call("get",KEYS[1]) == ARGV[1] then
105
+ return redis.call("del",KEYS[1])
106
+ else
107
+ return 0
108
+ end
109
+ LUA
110
+
111
+ DEFAULT_RETRY_COUNT = 3
112
+ DEFAULT_RETRY_DELAY = 200
113
+ CLOCK_DRIFT_FACTOR = 0.01
114
+
115
+ class << self
116
+ def time_in_ms
117
+ (Time.now.to_f * 1000).to_i
118
+ end
119
+ end # Class Methods
120
+
121
+ attr_reader :redis
122
+ attr_reader :name
123
+ attr_reader :retry_count
124
+ attr_reader :retry_delay
125
+
126
+ def initialize(redis, name, opts={})
127
+ @redis = redis
128
+ @name = name
129
+ @retry_count = opts[:retry_count] || DEFAULT_RETRY_COUNT
130
+ @retry_delay = opts[:retry_delay] || DEFAULT_RETRY_DELAY
131
+ end
132
+
133
+ def synchronize(ttl)
134
+ retry_count.times {
135
+ start_time = self.class.time_in_ms
136
+
137
+ if (handle=lock(ttl))
138
+ begin
139
+ time_elapsed = (self.class.time_in_ms - start_time).to_i
140
+ return yield(ttl - time_elapsed - _drift(ttl))
141
+ ensure
142
+ unlock(handle)
143
+ end
144
+ else
145
+ sleep(rand(retry_delay).to_f / 1000)
146
+ end
147
+ }
148
+
149
+ raise LockUnobtainableError, name
150
+ end
151
+
152
+ ##
153
+ # Acquire a lock (non-blocking).
154
+ def lock(ttl=1000)
155
+ handle = SecureRandom.uuid
156
+ redis.set(name, handle, nx: true, px: ttl) && handle
157
+ end
158
+
159
+ ##
160
+ # Release a lock.
161
+ def unlock(handle)
162
+ redis.eval(UNLOCK_SCRIPT, [name], [handle])
163
+ rescue
164
+ # Do nothing, unlocking is best effort.
165
+ end
166
+
167
+ private
168
+
169
+ ##
170
+ # Approximate clock drift.
171
+ def _drift(ttl)
172
+ # Add 2 milliseconds to the drift to account for Redis expires
173
+ # precision, which is 1 millisecond, plus 1 millisecond min drift
174
+ # for small TTLs.
175
+ (ttl * CLOCK_DRIFT_FACTOR).to_i + 2
176
+ end
177
+ end # Mutex
178
+
179
+ def _key_name(channel_or_token, *sub_names)
180
+ token = channel_or_token.is_a?(Channel) ? channel_or_token.token : channel_or_token
181
+ ['channel', token, *sub_names].join(':')
182
+ end
183
+ end # RedisStore
184
+ end # Channel::Stores
185
+ end # Service
186
+ end # Ribbon::Intercom
@@ -1,9 +1,9 @@
1
1
  module Ribbon::Intercom
2
2
  class Service
3
- module ChannelStores
4
- class Base
3
+ module Channel::Stores
4
+ class Store
5
5
  def open_channel(params={})
6
- raise NotImplementedError
6
+ Channel.new(self, params)
7
7
  end
8
8
 
9
9
  def token_exists?(token)
@@ -17,7 +17,15 @@ module Ribbon::Intercom
17
17
  def persist(channel)
18
18
  raise NotImplementedError
19
19
  end
20
- end # Base
21
- end # ChannelStores
20
+
21
+ def delete(channel)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def with_lock(channel, &block)
26
+ raise NotImplementedError
27
+ end
28
+ end # Store
29
+ end # Channel::Stores
22
30
  end # Service
23
31
  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
@@ -4,11 +4,14 @@ require 'bcrypt'
4
4
  module Ribbon::Intercom
5
5
  class Service
6
6
  class Channel
7
+ autoload(:Stores, 'ribbon/intercom/service/channel/stores')
8
+
7
9
  include BCrypt
8
10
 
9
11
  attr_reader :name
10
12
  attr_reader :store
11
13
  attr_reader :token
14
+ attr_reader :signing_keys
12
15
  attr_reader :secret_hash_crt
13
16
  attr_reader :secret_hash_prv
14
17
 
@@ -16,10 +19,18 @@ module Ribbon::Intercom
16
19
  @store = store
17
20
  @name = params[:name] or raise Errors::ChannelNameMissingError
18
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={})
19
28
  @secret_hash_crt = params[:secret_hash_crt]
20
29
  @secret_hash_prv = params[:secret_hash_prv]
30
+ @signing_keys = params[:signing_keys] || {}
21
31
 
22
- may(*((params[:may] || []) | [:rotate_secret]))
32
+ @_allowed_to = nil
33
+ may(*params.fetch(:may, []))
23
34
  end
24
35
 
25
36
  def rotate_secret
@@ -33,10 +44,23 @@ module Ribbon::Intercom
33
44
  rotate_secret.tap { save }
34
45
  end
35
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
+
36
60
  def may(*args)
37
61
  (@_allowed_to ||= Hash.new(false)).merge!(
38
62
  Hash[
39
- args.map { |perm| [perm.to_sym, true] }
63
+ args.map { |perm| [perm.to_s, true] }
40
64
  ]
41
65
  ).keys.to_set
42
66
  end
@@ -45,8 +69,8 @@ module Ribbon::Intercom
45
69
  may(*args).tap { save }
46
70
  end
47
71
 
48
- def may?(*args)
49
- args.all? { |perm| @_allowed_to[perm.to_sym] }
72
+ def may?(*perms)
73
+ perms.all? { |perm| _may?(perm) }
50
74
  end
51
75
 
52
76
  def permissions
@@ -65,6 +89,10 @@ module Ribbon::Intercom
65
89
  @__secret_prv ||= _to_bcrypt_pw(secret_hash_prv)
66
90
  end
67
91
 
92
+ def with_lock(&block)
93
+ store.with_lock(self, &block)
94
+ end
95
+
68
96
  def save
69
97
  # Loop until unique
70
98
  unless token
@@ -75,6 +103,10 @@ module Ribbon::Intercom
75
103
  store.persist(self)
76
104
  end
77
105
 
106
+ def close
107
+ store.delete(self)
108
+ end
109
+
78
110
  def ==(other)
79
111
  other.is_a?(Channel) &&
80
112
  name == other.name &&
@@ -87,6 +119,13 @@ module Ribbon::Intercom
87
119
 
88
120
  private
89
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
+
90
129
  def _to_bcrypt_pw(pw)
91
130
  Password.new(pw) if pw && !pw.empty?
92
131
  end
@@ -96,6 +135,69 @@ module Ribbon::Intercom
96
135
  raise Errors::ChannelTokenMissingError unless token
97
136
  raise Errors::ChannelSecretMissingError unless secret_hash_crt
98
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
99
201
  end # Service
100
202
  end # Channel
101
203
  end # Ribbon::Intercom