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,40 @@
1
+ module Ribbon::Intercom
2
+ module Client::SDK::Adapters
3
+ class MockAdapter < LocalAdapter
4
+ attr_reader :store
5
+
6
+ def connect(service)
7
+ if service.is_a?(Class) && service < Service
8
+ service = service.new(store: Service::Channel::Stores::MockStore.new)
9
+ end
10
+
11
+ unless service.is_a?(Service)
12
+ raise ArgumentError, "Expected a service, got: #{service.inspect}"
13
+ end
14
+
15
+ unless service.store.is_a?(Service::Channel::Stores::MockStore)
16
+ raise ArgumentError, "Expected service to have a MockStore, got: #{service.store.inspect}"
17
+ end
18
+
19
+ super(service)
20
+ @store = service.store
21
+ end
22
+
23
+ def with_permissions(*perms, &block)
24
+ channel = store.open_channel(name: 'mock channel', may: perms)
25
+ secret = channel.rotate_secret!
26
+ with_channel(channel.token, secret, &block)
27
+ ensure
28
+ channel.close
29
+ end
30
+
31
+ def with_channel(token, secret)
32
+ token_prv, secret_prv = self.channel_token, self.channel_secret
33
+ self.channel_token, self.channel_secret = token, secret
34
+ yield
35
+ ensure
36
+ self.channel_token, self.channel_secret = token_prv, secret_prv
37
+ end
38
+ end # MockAdapter
39
+ end # Client::SDK::Adapters
40
+ end # Ribbon::Intercom
@@ -0,0 +1,66 @@
1
+ module Ribbon::Intercom
2
+ module Errors
3
+ class Error < StandardError; end
4
+
5
+ ##
6
+ # Packet errors
7
+ class PacketError < Error; end
8
+ class InvalidEncodingError < PacketError; end
9
+
10
+ #############
11
+ # Http Errors
12
+ class HttpError < Error; end
13
+
14
+ ##
15
+ # Request Errors
16
+
17
+ # 400 Error
18
+ class RequestError < HttpError; end
19
+ class UnsafeArgumentError < RequestError; end
20
+ class InvalidSubjectSignatureError < RequestError; end
21
+
22
+ # 401 Error
23
+ class AuthenticationError < RequestError; end
24
+
25
+ # 403 Error
26
+ class ForbiddenError < RequestError; end
27
+ class InsufficientPermissionsError < ForbiddenError; end
28
+
29
+ # 404 Not Found
30
+ class NotFoundError < RequestError; end
31
+ class InvalidMethodError < NotFoundError; end
32
+
33
+ # 405 Method Not Allowed
34
+ class MethodNotAllowedError < RequestError; end
35
+
36
+ ##
37
+ # Server Errors
38
+
39
+ # 500 Error
40
+ class ServerError < HttpError; end
41
+ class UnsafeResponseError < ServerError; end
42
+
43
+ # General Errors
44
+ class UnsafeValueError < Error; end
45
+
46
+ # Intercom Errors
47
+ class ServiceNotDefinedError < Error; end
48
+
49
+ # Service Errors
50
+ class NoPermissionsError < Error; end
51
+ class MissingStoreError < Error; end
52
+
53
+ # Channel Store Errors
54
+ class ChannelStoreError < Error; end
55
+ class InvalidStoreParamsError < ChannelStoreError; end
56
+ class InvalidChannelError < ChannelStoreError; end
57
+
58
+ # SDK Errors
59
+ class RequestFailureError < Error; end
60
+
61
+ # Channel Errors
62
+ class ChannelNameMissingError < Error; end
63
+ class ChannelTokenMissingError < Error; end
64
+ class ChannelSecretMissingError < Error; end
65
+ end # Errors
66
+ end # Ribbon::Intercom
@@ -0,0 +1,121 @@
1
+ require 'base64'
2
+
3
+ module Ribbon::Intercom
4
+ class Package
5
+ include Utils::Mixins::MockSafe
6
+
7
+ class << self
8
+ ##
9
+ # Package up any non-basic objects that include Packageable::Mixin.
10
+ def package(subject)
11
+ Utils.walk(subject) { |subject, context|
12
+ if Utils.basic_type?(subject)
13
+ subject
14
+ elsif context == :hash_key
15
+ # Hash keys must be basic types.
16
+ raise Errors::UnsafeResponseError, subject.inspect
17
+ elsif subject.is_a?(Packageable::Mixin)
18
+ _package_obj(subject, package(subject.package_data))
19
+ elsif subject.is_a?(Class) && subject < Packageable::Mixin
20
+ _package_obj(subject)
21
+ else
22
+ raise Errors::UnsafeResponseError, subject.inspect
23
+ end
24
+ }
25
+ end
26
+
27
+ def _package_obj(subject, data=nil)
28
+ new(encode_subject(subject), data)
29
+ end
30
+
31
+ def encode_subject(subject)
32
+ Marshal.dump(subject)
33
+ end
34
+
35
+ def decode_subject(encoded_subject)
36
+ Marshal.load(encoded_subject)
37
+ end
38
+
39
+ ##
40
+ # Walks the object and initializes all packages (i.e., sets them up to be
41
+ # accessed by the end-user on the client).
42
+ def init_packages(object, sdk)
43
+ Utils.walk(object) { |object|
44
+ object.send(:_init, sdk) if object.is_a?(Package)
45
+ object
46
+ }
47
+ end
48
+ end # Class Methods
49
+
50
+ attr_reader :sdk
51
+
52
+ def initialize(subject_data=nil, data=nil)
53
+ @_subject_data = subject_data
54
+ @_data = data
55
+ end
56
+
57
+ ##
58
+ # Begin a method chain (must be completed with `#end`).
59
+ #
60
+ # When `#end` is called on the method chain, the chain will be resolved
61
+ # remotely. This allows the chain of methods to be executed in one round-trip.
62
+ def begin
63
+ Utils::MethodChain.begin { |methods|
64
+ queue = Packet::MethodQueue.new
65
+ methods.each { |meth, *args| queue.enqueue(meth, *args) }
66
+ _send_method_queue(queue)
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def method_missing(meth, *args, &block)
73
+ if @_data && @_data.key?(meth)
74
+ @_data[meth]
75
+ elsif sdk
76
+ _call(meth, *args)
77
+ else
78
+ super
79
+ end
80
+ end
81
+
82
+ ##
83
+ # Call a method on the subject remotely.
84
+ def _call(method_name, *args)
85
+ _send_method_queue(Packet::MethodQueue.new.enqueue(method_name, *args))
86
+ end
87
+
88
+ ##
89
+ # Initializes the package on the client-side.
90
+ def _init(sdk)
91
+ @sdk = sdk
92
+ self.class.init_packages(@_data, sdk)
93
+ mock_safe! if sdk.mock_safe?
94
+ end
95
+
96
+ def _send_method_queue(method_queue)
97
+ _process_response(
98
+ sdk.send_packet(
99
+ subject: @_subject_data,
100
+ method_queue: method_queue
101
+ )
102
+ )
103
+ end
104
+
105
+ def marshal_dump
106
+ [@_subject_data, @_data]
107
+ end
108
+
109
+ def marshal_load(array)
110
+ @_subject_data = array[0]
111
+ @_data = array[1]
112
+ end
113
+
114
+ def _process_response(packet)
115
+ @_subject_data = packet.subject
116
+ @_data = self.class.init_packages(packet.package_data, sdk)
117
+
118
+ packet.retval
119
+ end
120
+ end # Package
121
+ end # Ribbon::Intercom
@@ -0,0 +1,6 @@
1
+ module Ribbon::Intercom
2
+ class Packageable
3
+ autoload(:Mixin, 'ribbon/intercom/packageable/mixin')
4
+ include Mixin
5
+ end # Packageable
6
+ end # Ribbon::Intercom
@@ -0,0 +1,29 @@
1
+ require 'set'
2
+
3
+ module Ribbon::Intercom
4
+ class Packageable
5
+ module Mixin
6
+ class << self
7
+ def included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def package_with(*args)
14
+ args.map { |m| _package_with_methods << m.to_sym }
15
+ end
16
+
17
+ def _package_with_methods
18
+ @__package_with_methods ||= [].to_set
19
+ end
20
+ end # ClassMethods
21
+
22
+ ##
23
+ # Returns the package data for the instance as a hash.
24
+ def package_data
25
+ self.class._package_with_methods.map { |meth| [meth, public_send(meth)] }.to_h
26
+ end
27
+ end # Mixin
28
+ end # Service::Subject
29
+ end # Ribbon::Intercom
@@ -0,0 +1,52 @@
1
+ require 'ostruct'
2
+ require 'base64'
3
+
4
+ module Ribbon::Intercom
5
+ ##
6
+ # Represents a collection of data to be passed between the service and client.
7
+ class Packet < OpenStruct
8
+ autoload(:MethodQueue, 'ribbon/intercom/packet/method_queue')
9
+
10
+ class << self
11
+ def decode(encoded_packet)
12
+ hash = Marshal.load(Base64.strict_decode64(encoded_packet))
13
+ raise Errors::InvalidEncodingError, hash.inspect unless hash.is_a?(Hash)
14
+ new(hash)
15
+ end
16
+ end # Class Methods
17
+
18
+ def initialize(params={})
19
+ error = params.delete(:error)
20
+ super(params)
21
+ self.error = error if error
22
+ end
23
+
24
+ ##
25
+ # Encode (marshal) the error before saving it. This allows the error to be
26
+ # decoded on the client when requested, rather than decoded at the same time
27
+ # the packet is decoded, which could cause problems if the error class doesn't
28
+ # exist on the client.
29
+ def error=(err)
30
+ self._encoded_error = Marshal.dump(err)
31
+ end
32
+
33
+ ##
34
+ # Decode the error, which may not exist on the client.
35
+ # If the error class doesn't exist on the client, raise a ServerError.
36
+ def error
37
+ error? && Marshal.load(_encoded_error)
38
+ rescue
39
+ Errors::ServerError.new('unknown server error')
40
+ end
41
+
42
+ ##
43
+ # Whether the packet contains an error
44
+ def error?
45
+ !!_encoded_error
46
+ end
47
+
48
+ def encode
49
+ Base64.strict_encode64(Marshal.dump(to_h))
50
+ end
51
+ end # Packet
52
+ end # Ribbon::Intercom
@@ -0,0 +1,28 @@
1
+ module Ribbon::Intercom
2
+ class Packet
3
+ class MethodQueue
4
+ ##
5
+ # Enqueue a method with it's arguments.
6
+ # Supports method chaining.
7
+ def enqueue(name, *args)
8
+ self.tap { _queue << [name, *Utils.sanitize(args)] }
9
+ end
10
+
11
+ ##
12
+ # Iterate through the methods and arguments.
13
+ def each(&block)
14
+ _queue.each(&block)
15
+ end
16
+
17
+ def empty?
18
+ _queue.empty?
19
+ end
20
+
21
+ private
22
+
23
+ def _queue
24
+ @__queue ||= []
25
+ end
26
+ end # MethodQueue
27
+ end # Packet
28
+ end # Ribbon::Intercom
@@ -0,0 +1,14 @@
1
+ require 'ribbon/intercom'
2
+ require 'rails'
3
+
4
+ module Ribbon
5
+ module Intercom
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :intercom
8
+
9
+ rake_tasks do
10
+ Intercom.load_tasks
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,273 @@
1
+ require 'base64'
2
+ require 'yaml'
3
+ require 'set'
4
+
5
+ module Ribbon::Intercom
6
+ class Service
7
+ autoload(:Channel, 'ribbon/intercom/service/channel')
8
+
9
+ # Used to signify an empty body
10
+ class EmptyResponse; end
11
+
12
+ class << self
13
+ def instance
14
+ @instance ||= new(store: _load_store)
15
+ end
16
+
17
+ def mock
18
+ Client::MockSDK.new(self)
19
+ end
20
+
21
+ def store(store_name, params={})
22
+ @_store_name = store_name
23
+ @_store_params = params
24
+ end
25
+
26
+ # The call method is needed here because Rails checks to see if a mounted
27
+ # Rack app can respond_to?(:call). Without it, the Service will not mount
28
+ def call(env)
29
+ instance.call(env)
30
+ end
31
+
32
+ def method_missing(meth, *args, &block)
33
+ instance.public_send(meth, *args, &block)
34
+ end
35
+
36
+ def _load_store
37
+ raise "Store name missing" unless (store_name = @_store_name.to_s)
38
+
39
+ store = Utils.classify(store_name) + "Store"
40
+ Intercom::Service::Channel::Stores.const_get(store).new(@_store_params)
41
+ end
42
+ end # Class methods
43
+
44
+ attr_reader :request
45
+ attr_reader :channel
46
+ attr_reader :subject
47
+ attr_reader :request_packet
48
+ attr_reader :env
49
+
50
+ def initialize(opts={})
51
+ @_opts = opts.dup
52
+ end
53
+
54
+ def store
55
+ @store ||= @_opts[:store] or raise Errors::MissingStoreError
56
+ end
57
+
58
+ def open_channel(params={})
59
+ # Accept either an array of permissions or a string
60
+ store.open_channel(params).tap { |channel|
61
+ channel.may(Utils.method_identifier(self, :rotate_secret))
62
+ }
63
+ end
64
+
65
+ def lookup_channel(token)
66
+ store.lookup_channel(token)
67
+ end
68
+
69
+ ##
70
+ # Check that the channel has sufficient permissions to call the method.
71
+ #
72
+ # The `send` method is forbidden because it breaks the encapsulation guaranteed
73
+ # by intercom (i.e., private methods can't be called).
74
+ #
75
+ # In addition to the permissions granted to the channel, all channels have
76
+ # implicit permission to call public methods on basic types.
77
+ def sufficient_permissions?(base, intercom_method)
78
+ intercom_method != :send && (
79
+ Utils.basic_type?(base) ||
80
+ channel.may?(Utils.method_identifier(base, intercom_method))
81
+ )
82
+ end
83
+
84
+ def call(env)
85
+ dup.call!(env)
86
+ end
87
+
88
+ def call!(env)
89
+ @env = env
90
+
91
+ response = catch(:response) {
92
+ begin
93
+ _process_request
94
+ rescue Exception => error
95
+ _respond_with_error!(error)
96
+ end
97
+ }
98
+
99
+ response.finish
100
+ end
101
+
102
+ def rotate_secret
103
+ channel.rotate_secret!
104
+ end
105
+
106
+ private
107
+
108
+ def _process_request
109
+ _init_request
110
+ _authenticate_request!
111
+ _load_request_packet
112
+ _load_subject
113
+ response_packet = _process_methods
114
+ _respond_with_packet(response_packet)
115
+ end
116
+
117
+ def _init_request
118
+ @request = Rack::Request.new(env)
119
+
120
+ unless request.put?
121
+ _error!(Errors::MethodNotAllowedError, 'only PUT allowed')
122
+ end
123
+ end
124
+
125
+ def _authenticate_request!
126
+ unless _request_authenticated?
127
+ _error!(Errors::AuthenticationError, "invalid channel credentials")
128
+ end
129
+ end
130
+
131
+ def _load_request_packet
132
+ @request_packet = Packet.decode(request.body.read)
133
+ end
134
+
135
+ def _load_subject
136
+ if (encoded_subject=request_packet.subject) && !encoded_subject.empty?
137
+ @subject = Package.decode_subject(encoded_subject)
138
+ _error!(Errors::InvalidSubjectSignatureError) unless @subject
139
+ else
140
+ @subject = self
141
+ end
142
+ end
143
+
144
+ ##
145
+ # Calls requested methods and returns a response packet for the client.
146
+ def _process_methods
147
+ retval = _call_methods
148
+ _prepare_response_packet(retval)
149
+ end
150
+
151
+ ##
152
+ # Call all the methods in the method queue.
153
+ def _call_methods
154
+ method_queue = _load_method_queue
155
+
156
+ intercom_method = nil
157
+ base = subject
158
+ method_queue.each { |meth, *args|
159
+ intercom_method = meth
160
+ _sufficient_permissions!(base, meth)
161
+ base = base.public_send(meth, *args)
162
+ }
163
+
164
+ Package.package(base)
165
+ rescue NoMethodError => error
166
+ if error.name == intercom_method
167
+ _error!(Errors::InvalidMethodError, intercom_method)
168
+ else
169
+ raise
170
+ end
171
+ end
172
+
173
+ def _load_method_queue
174
+ request_packet.method_queue.tap { |mq|
175
+ raise "No method queue given" unless mq
176
+ raise "Expected MethodQueue, got: #{mq.inspect}" unless mq.is_a?(Packet::MethodQueue)
177
+ raise "Empty MethodQueue" if mq.empty?
178
+ }
179
+ end
180
+
181
+ ##
182
+ # Creates a successful response Packet to be returned to the client.
183
+ def _prepare_response_packet(retval)
184
+ Packet.new.tap { |packet|
185
+ unless self == subject # Order matters here! See: issue#52
186
+ # Need to send subject back in case it was modified by the methods.
187
+ packet.subject = Package.encode_subject(subject)
188
+
189
+ if subject.is_a?(Packageable::Mixin)
190
+ # Need to send the package data back in case it changed, too.
191
+ packet.package_data = Package.package(subject.package_data)
192
+ end
193
+ end
194
+
195
+ packet.retval = retval
196
+ }
197
+ end
198
+
199
+ def _respond_with_packet(packet, status=200)
200
+ _respond!(status, {}, packet.encode)
201
+ end
202
+
203
+ def _request_authenticated?
204
+ auth = Rack::Auth::Basic::Request.new(env)
205
+
206
+ if auth.provided? && auth.basic?
207
+ token = auth.credentials[0]
208
+ secret = auth.credentials[1]
209
+
210
+ # Check if the request is authenticated
211
+ @channel = lookup_channel(token)
212
+ channel && channel.valid_secret?(secret)
213
+ end
214
+ end
215
+
216
+ def _sufficient_permissions!(base, intercom_method)
217
+ unless sufficient_permissions?(base, intercom_method)
218
+ required = Utils.method_identifier(base, intercom_method)
219
+ _error!(Errors::InsufficientPermissionsError, required)
220
+ end
221
+ end
222
+
223
+ def _response(status, headers={}, body=EmptyResponse)
224
+ body = body == EmptyResponse ? [] : [body]
225
+ headers = headers.merge("Content-Type" => "text/plain", "Transfer-Encoding" => "gzip")
226
+ Rack::Response.new(body, status, headers)
227
+ end
228
+
229
+ def _respond!(status, headers={}, body=EmptyResponse)
230
+ throw :response, _response(status, headers, body)
231
+ end
232
+
233
+ def _respond_with_error!(error, status=500)
234
+ _respond_with_packet(Packet.new(error: error), status)
235
+ end
236
+
237
+ def _error!(klass, message=nil)
238
+ error = message ? klass.new(message) : klass.new
239
+ _respond_with_error!(error, _error_to_http_code(error))
240
+ end
241
+
242
+ def _error_to_http_code(error)
243
+ case error
244
+ when Errors::MethodNotAllowedError
245
+ 405
246
+ when Errors::NotFoundError
247
+ 404
248
+ when Errors::ForbiddenError
249
+ 403
250
+ when Errors::AuthenticationError
251
+ 401
252
+ when Errors::RequestError
253
+ 400
254
+ when Errors::ServerError
255
+ 500
256
+ else
257
+ 500
258
+ end
259
+ end
260
+
261
+ ##
262
+ # Decodes the arguments.
263
+ #
264
+ # It's very important that this happens *after* channel authentication is
265
+ # performed. Since `args` comes from the client it could contain malicious
266
+ # marshalled data.
267
+ def _decode_args(args)
268
+ Utils.sanitize(Marshal.load(Base64.strict_decode64(args))).tap { |args|
269
+ raise Errors::UnsafeValueError unless args.is_a?(Array)
270
+ }
271
+ end
272
+ end # Service
273
+ end # Ribbon::Intercom