ribbon-intercom 0.2.3 → 0.3.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.
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