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.
- checksums.yaml +7 -0
- data/lib/ribbon/intercom.rb +42 -0
- data/lib/ribbon/intercom/client.rb +61 -0
- data/lib/ribbon/intercom/client/mock_sdk.rb +13 -0
- data/lib/ribbon/intercom/client/sdk.rb +99 -0
- data/lib/ribbon/intercom/client/sdk/adapters.rb +10 -0
- data/lib/ribbon/intercom/client/sdk/adapters/adapter.rb +77 -0
- data/lib/ribbon/intercom/client/sdk/adapters/adapter/response.rb +13 -0
- data/lib/ribbon/intercom/client/sdk/adapters/http_adapter.rb +32 -0
- data/lib/ribbon/intercom/client/sdk/adapters/http_adapter/connection.rb +34 -0
- data/lib/ribbon/intercom/client/sdk/adapters/local_adapter.rb +55 -0
- data/lib/ribbon/intercom/client/sdk/adapters/mock_adapter.rb +40 -0
- data/lib/ribbon/intercom/errors.rb +66 -0
- data/lib/ribbon/intercom/package.rb +121 -0
- data/lib/ribbon/intercom/packageable.rb +6 -0
- data/lib/ribbon/intercom/packageable/mixin.rb +29 -0
- data/lib/ribbon/intercom/packet.rb +52 -0
- data/lib/ribbon/intercom/packet/method_queue.rb +28 -0
- data/lib/ribbon/intercom/railtie.rb +14 -0
- data/lib/ribbon/intercom/service.rb +273 -0
- data/lib/ribbon/intercom/service/channel.rb +203 -0
- data/lib/ribbon/intercom/service/channel/stores.rb +9 -0
- data/lib/ribbon/intercom/service/channel/stores/mock_store.rb +40 -0
- data/lib/ribbon/intercom/service/channel/stores/redis_store.rb +196 -0
- data/lib/ribbon/intercom/service/channel/stores/store.rb +31 -0
- data/lib/ribbon/intercom/utils.rb +72 -0
- data/lib/ribbon/intercom/utils/method_chain.rb +38 -0
- data/lib/ribbon/intercom/utils/mixins.rb +5 -0
- data/lib/ribbon/intercom/utils/mixins/mock_safe.rb +26 -0
- data/lib/ribbon/intercom/utils/signer.rb +71 -0
- data/lib/ribbon/intercom/version.rb +5 -0
- data/lib/tasks/intercom.rake +24 -0
- 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
|