the_garage 2.0.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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +162 -0
- data/Rakefile +33 -0
- data/app/assets/javascripts/garage/application.js +16 -0
- data/app/assets/javascripts/garage/docs/console.js.coffee +90 -0
- data/app/assets/javascripts/garage/docs/jquery.colorbox.js +1026 -0
- data/app/assets/stylesheets/garage/application.css +14 -0
- data/app/assets/stylesheets/garage/colorbox.scss +62 -0
- data/app/assets/stylesheets/garage/style.scss +59 -0
- data/app/assets/stylesheets/vendor/bootstrap.min.css +9 -0
- data/app/controllers/garage/application_controller.rb +4 -0
- data/app/controllers/garage/docs/resources_controller.rb +103 -0
- data/app/controllers/garage/meta/docs_controller.rb +20 -0
- data/app/controllers/garage/meta/services_controller.rb +20 -0
- data/app/helpers/garage/application_helper.rb +4 -0
- data/app/helpers/garage/docs/resources_helper.rb +24 -0
- data/app/models/garage/hash_representer.rb +11 -0
- data/app/views/garage/docs/resources/_layout_navigation.html.haml +5 -0
- data/app/views/garage/docs/resources/_navigation.html.haml +6 -0
- data/app/views/garage/docs/resources/callback.html.haml +5 -0
- data/app/views/garage/docs/resources/console.html.haml +45 -0
- data/app/views/garage/docs/resources/index.html.haml +2 -0
- data/app/views/garage/docs/resources/show.html.haml +16 -0
- data/app/views/layouts/garage/application.html.haml +26 -0
- data/config/routes.rb +0 -0
- data/lib/garage/app_responder.rb +22 -0
- data/lib/garage/authorizable.rb +26 -0
- data/lib/garage/config.rb +76 -0
- data/lib/garage/controller_helper.rb +110 -0
- data/lib/garage/docs/anchor_building.rb +28 -0
- data/lib/garage/docs/application.rb +24 -0
- data/lib/garage/docs/config.rb +61 -0
- data/lib/garage/docs/console_link_building.rb +14 -0
- data/lib/garage/docs/document.rb +141 -0
- data/lib/garage/docs/engine.rb +35 -0
- data/lib/garage/docs/example.rb +26 -0
- data/lib/garage/docs/renderer.rb +17 -0
- data/lib/garage/docs/toc_renderer.rb +14 -0
- data/lib/garage/docs.rb +9 -0
- data/lib/garage/exceptions.rb +49 -0
- data/lib/garage/hypermedia_filter.rb +44 -0
- data/lib/garage/hypermedia_responder.rb +120 -0
- data/lib/garage/meta/engine.rb +16 -0
- data/lib/garage/meta/remote_service.rb +78 -0
- data/lib/garage/meta.rb +6 -0
- data/lib/garage/meta_resource.rb +17 -0
- data/lib/garage/nested_field_query.rb +183 -0
- data/lib/garage/optional_response_body_responder.rb +16 -0
- data/lib/garage/paginating_responder.rb +113 -0
- data/lib/garage/permission.rb +13 -0
- data/lib/garage/permissions.rb +75 -0
- data/lib/garage/representer.rb +214 -0
- data/lib/garage/resource_casting_responder.rb +13 -0
- data/lib/garage/restful_actions.rb +219 -0
- data/lib/garage/strategy/access_token.rb +57 -0
- data/lib/garage/strategy/auth_server.rb +200 -0
- data/lib/garage/strategy/no_authentication.rb +13 -0
- data/lib/garage/strategy/test.rb +44 -0
- data/lib/garage/strategy.rb +4 -0
- data/lib/garage/test/migrator.rb +31 -0
- data/lib/garage/token_scope.rb +134 -0
- data/lib/garage/utils.rb +28 -0
- data/lib/garage/version.rb +3 -0
- data/lib/garage.rb +23 -0
- metadata +275 -0
@@ -0,0 +1,13 @@
|
|
1
|
+
module Garage::ResourceCastingResponder
|
2
|
+
def initialize(*args)
|
3
|
+
super
|
4
|
+
@caster = Garage.configuration.cast_resource
|
5
|
+
end
|
6
|
+
|
7
|
+
def display(resource, given_options={})
|
8
|
+
if @caster
|
9
|
+
resource = @caster.call(resource)
|
10
|
+
end
|
11
|
+
super(resource, given_options)
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# Public: mixes in CRUD controller actions to your Action Controller
|
2
|
+
# classes to provide a simple RESTful actions that provides
|
3
|
+
# resource-based permissions with built-in integrations with
|
4
|
+
# Doorkeeper scopes.
|
5
|
+
#
|
6
|
+
# Examples
|
7
|
+
#
|
8
|
+
# class PostsController < ApiController
|
9
|
+
# include Garage::RestfulActions
|
10
|
+
#
|
11
|
+
# def require_resources
|
12
|
+
# @resources = Post.all
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# def require_resource
|
16
|
+
# @resource = Post.find(params[:id])
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
module Garage
|
20
|
+
module RestfulActions
|
21
|
+
extend ActiveSupport::Concern
|
22
|
+
|
23
|
+
included do
|
24
|
+
before_action :require_resource, :only => [:show, :update, :destroy]
|
25
|
+
before_action :require_resources, :only => [:index, :create]
|
26
|
+
before_action :require_action_permission_crud, :only => [:index, :create, :show, :update, :destroy], :if => -> (_) { verify_permission? }
|
27
|
+
end
|
28
|
+
|
29
|
+
module ClassMethods
|
30
|
+
def resource_class=(klass)
|
31
|
+
@resource_class = klass
|
32
|
+
end
|
33
|
+
|
34
|
+
def resource_class
|
35
|
+
@resource_class ||= name.sub(/Controller\z/, '').demodulize.singularize.constantize
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Public: List resources
|
40
|
+
# Renders `@resources` with options specified with `respond_with_resources_options`
|
41
|
+
# Requires `:read` permission on `resource_class` specified for `@resources`
|
42
|
+
def index
|
43
|
+
respond_with @resources, respond_with_resources_options
|
44
|
+
end
|
45
|
+
|
46
|
+
# Public: Get the resource
|
47
|
+
# Renders `@resource` with options specified with `respond_with_resource_options`
|
48
|
+
# Requries `:read` permission on `@resource`
|
49
|
+
def show
|
50
|
+
respond_with @resource, respond_with_resource_options
|
51
|
+
end
|
52
|
+
|
53
|
+
# Public: Create a new resource
|
54
|
+
# Calls `create_resource` in your controller to create a new resource
|
55
|
+
# Requires `:write` permission on `resource_class` specified for `@resources`
|
56
|
+
def create
|
57
|
+
@resource = create_resource
|
58
|
+
respond_with @resource, :location => location
|
59
|
+
end
|
60
|
+
|
61
|
+
# Public: Update the resource
|
62
|
+
# Calls `update_resource` in your controller to update `@resource`
|
63
|
+
# Requires `:write` permission on `@resource`
|
64
|
+
def update
|
65
|
+
@resource = update_resource
|
66
|
+
respond_with @resource, respond_with_resource_options
|
67
|
+
end
|
68
|
+
|
69
|
+
# Public: Delete the resource
|
70
|
+
# Calls `destroy_resource` in your controller to destroy `@resource`
|
71
|
+
# Requires `:write` permission on `@resource`
|
72
|
+
def destroy
|
73
|
+
@resource = destroy_resource
|
74
|
+
respond_with @resource, respond_with_resource_options
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Private: returns either `:read` or `:write`, depending on the current action name
|
80
|
+
def current_operation
|
81
|
+
if %w[create update destroy].include?(action_name)
|
82
|
+
:write
|
83
|
+
else
|
84
|
+
:read
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Private: Call this method to require additional permission on
|
89
|
+
# extra resource your controller handles. It will check if the
|
90
|
+
# current request user has permission to perform the operation
|
91
|
+
# (`:read` or `:write`) on the resource.
|
92
|
+
#
|
93
|
+
# Examples
|
94
|
+
#
|
95
|
+
# before_action :require_recipe
|
96
|
+
# def require_recipe
|
97
|
+
# @recipe = Recipe.find(params[:recipe_id])
|
98
|
+
# require_permission! @recipe, :read
|
99
|
+
# end
|
100
|
+
def require_permission!(resource, operation = nil)
|
101
|
+
operation ||= current_operation
|
102
|
+
resource.authorize!(current_resource_owner, operation)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Private: Call this method to require additional access on extra
|
106
|
+
# resource class your controller needs access to. It will check if
|
107
|
+
# the current request token has an access permission (scope) to
|
108
|
+
# perform the operation (`:read` or `:write`) on the resource
|
109
|
+
# class.
|
110
|
+
#
|
111
|
+
# Examples
|
112
|
+
#
|
113
|
+
# before_action :require_stream
|
114
|
+
# def require_stream
|
115
|
+
# require_access! PostStream, :read
|
116
|
+
# end
|
117
|
+
def require_access!(resource, operation = nil)
|
118
|
+
operation ||= current_operation
|
119
|
+
ability_from_token.access!(resource.resource_class, operation)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Private: Call this method to require additional access and
|
123
|
+
# permission on extra resource your controller performs operation
|
124
|
+
# on.
|
125
|
+
def require_access_and_permission!(resource, operation = nil)
|
126
|
+
require_permission!(resource, operation)
|
127
|
+
require_access!(resource, operation)
|
128
|
+
end
|
129
|
+
|
130
|
+
def require_action_permission_crud
|
131
|
+
if operated_resource
|
132
|
+
require_access_and_permission!(operated_resource, current_operation)
|
133
|
+
else
|
134
|
+
Rails.logger.debug "skipping permissions check since there's no @resource(s) set"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
alias :require_action_permission :require_action_permission_crud
|
139
|
+
|
140
|
+
# Private: Call this method if you need to *change* the target
|
141
|
+
# resource to provision access and permission.
|
142
|
+
#
|
143
|
+
# def require_resources
|
144
|
+
# @resources = Post.where(user_id: @user.id)
|
145
|
+
# end
|
146
|
+
#
|
147
|
+
# By default, in `index` and `create` actions, Garage will check
|
148
|
+
# `:read` and `:write` access respectively on the default
|
149
|
+
# `resource_class` of `@resources`, in this case Post class. If
|
150
|
+
# you need more fine grained control than that, you should specify
|
151
|
+
# the optional parameters here, such as:
|
152
|
+
#
|
153
|
+
# def require_resources
|
154
|
+
# @resources = Post.where(user_id: @user.id)
|
155
|
+
# protect_source_as PrivatePost, user: @user
|
156
|
+
# end
|
157
|
+
#
|
158
|
+
# This way, the token should require access scope to `PrivatePost`
|
159
|
+
# (instead of `Post`), and the authorized user should have a
|
160
|
+
# permission to operate the action on resources owned by `@user`
|
161
|
+
# (instead of public). The `:user` option will be passed as
|
162
|
+
# parameters to `build_permissions` class method.
|
163
|
+
def protect_resource_as(klass, args = {})
|
164
|
+
if klass.is_a?(Hash)
|
165
|
+
klass, args = self.class.resource_class, klass
|
166
|
+
end
|
167
|
+
@operated_resource = MetaResource.new(klass, args)
|
168
|
+
end
|
169
|
+
|
170
|
+
def operated_resource
|
171
|
+
if @operated_resource
|
172
|
+
@operated_resource
|
173
|
+
elsif @resources
|
174
|
+
MetaResource.new(self.class.resource_class)
|
175
|
+
else
|
176
|
+
@resource
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Override to set @resource
|
181
|
+
def require_resource
|
182
|
+
raise NotImplementedError, "#{self.class}#require_resource is not implemented"
|
183
|
+
end
|
184
|
+
|
185
|
+
# Override to set @resources
|
186
|
+
def require_resources
|
187
|
+
raise NotImplementedError, "#{self.class}#require_resources is not implemented"
|
188
|
+
end
|
189
|
+
|
190
|
+
# Override to create a new resource
|
191
|
+
def create_resource
|
192
|
+
raise NotImplementedError, "#{self.class}#create_resource is not implemented"
|
193
|
+
end
|
194
|
+
|
195
|
+
# Override to update @resource
|
196
|
+
def update_resource
|
197
|
+
raise NotImplementedError, "#{self.class}#update_resource is not implemented"
|
198
|
+
end
|
199
|
+
|
200
|
+
# Override to destroy @resource
|
201
|
+
def destroy_resource
|
202
|
+
raise NotImplementedError, "#{self.class}#destroy_resource is not implemented"
|
203
|
+
end
|
204
|
+
|
205
|
+
# Override this if you want to pass options to respond_with in index action
|
206
|
+
def respond_with_resources_options
|
207
|
+
{}
|
208
|
+
end
|
209
|
+
|
210
|
+
# Override this if you want to pass options to respond_with in show, update and destroy actions
|
211
|
+
def respond_with_resource_options
|
212
|
+
{}
|
213
|
+
end
|
214
|
+
|
215
|
+
def location
|
216
|
+
{ action: :show, id: @resource.id } if @resource.try(:respond_to?, :id)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Garage
|
2
|
+
module Strategy
|
3
|
+
class AccessToken
|
4
|
+
attr_reader :scope, :token, :token_type
|
5
|
+
|
6
|
+
def initialize(attrs)
|
7
|
+
@scope, @token, @token_type = attrs[:scope], attrs[:token], attrs[:token_type]
|
8
|
+
@application_id, @resource_owner_id = attrs[:application_id], attrs[:resource_owner_id]
|
9
|
+
@expired_at, @revoked_at = attrs[:expired_at], attrs[:revoked_at]
|
10
|
+
end
|
11
|
+
|
12
|
+
def application_id
|
13
|
+
@application_id.try(:to_i)
|
14
|
+
end
|
15
|
+
|
16
|
+
def resource_owner_id
|
17
|
+
@resource_owner_id.try(:to_i)
|
18
|
+
end
|
19
|
+
|
20
|
+
def expired_at
|
21
|
+
@expired_at.present? ? Time.zone.parse(@expired_at) : nil
|
22
|
+
rescue ArgumentError, TypeError
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def revoked_at
|
27
|
+
@revoked_at.present? ? Time.zone.parse(@revoked_at) : nil
|
28
|
+
rescue ArgumentError, TypeError
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def scopes
|
33
|
+
scope.try(:split, ' ')
|
34
|
+
end
|
35
|
+
|
36
|
+
def acceptable?(required_scopes)
|
37
|
+
accessible? && includes_scope?(required_scopes)
|
38
|
+
end
|
39
|
+
|
40
|
+
def accessible?
|
41
|
+
!expired? && !revoked?
|
42
|
+
end
|
43
|
+
|
44
|
+
def revoked?
|
45
|
+
!!revoked_at.try(:past?)
|
46
|
+
end
|
47
|
+
|
48
|
+
def expired?
|
49
|
+
!!expired_at.try(:past?)
|
50
|
+
end
|
51
|
+
|
52
|
+
def includes_scope?(required_scopes)
|
53
|
+
required_scopes.blank? || required_scopes.any? { |s| scopes.exists?(s) }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,200 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'net/http'
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module Garage
|
6
|
+
module Strategy
|
7
|
+
module AuthServer
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
before_action :verify_auth, if: -> (_) { verify_permission? }
|
12
|
+
end
|
13
|
+
|
14
|
+
def access_token
|
15
|
+
if defined?(@access_token)
|
16
|
+
@access_token
|
17
|
+
else
|
18
|
+
@access_token = AccessTokenFetcher.fetch(request)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def verify_permission?
|
23
|
+
true
|
24
|
+
end
|
25
|
+
|
26
|
+
module Cache
|
27
|
+
def self.with_cache(key)
|
28
|
+
return yield unless Garage.configuration.cache_acceess_token_validation?
|
29
|
+
|
30
|
+
cached_token = Rails.cache.read(key)
|
31
|
+
return cached_token if cached_token && !cached_token.expired?
|
32
|
+
|
33
|
+
token = yield
|
34
|
+
Rails.cache.write(key, token, expires_in: default_ttl) if token && token.accessible?
|
35
|
+
token
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.default_ttl
|
39
|
+
Garage.configuration.ttl_for_access_token_cache
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns an AccessToken from request object or returns nil if failed.
|
44
|
+
class AccessTokenFetcher
|
45
|
+
READ_TIMEOUT = 1
|
46
|
+
OPEN_TIMEOUT = 1
|
47
|
+
USER_AGENT = "Garage #{Garage::VERSION}"
|
48
|
+
|
49
|
+
def self.fetch(*args)
|
50
|
+
new(*args).fetch
|
51
|
+
end
|
52
|
+
|
53
|
+
def initialize(request)
|
54
|
+
@request = request
|
55
|
+
end
|
56
|
+
|
57
|
+
def fetch
|
58
|
+
if has_any_valid_credentials?
|
59
|
+
if has_cacheable_credentials?
|
60
|
+
fetch_with_cache
|
61
|
+
else
|
62
|
+
fetch_without_cache
|
63
|
+
end
|
64
|
+
else
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
rescue Timeout::Error
|
68
|
+
raise AuthBackendTimeout.new(OPEN_TIMEOUT, read_timeout)
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def get
|
74
|
+
raw = http_client.get(path_with_query, header)
|
75
|
+
Response.new(raw)
|
76
|
+
end
|
77
|
+
|
78
|
+
def header
|
79
|
+
{
|
80
|
+
'Authorization' => @request.authorization,
|
81
|
+
'Host' => Garage.configuration.auth_server_host,
|
82
|
+
'Resource-Owner-Id' => @request.headers['Resource-Owner-Id'],
|
83
|
+
'Scopes' => @request.headers['Scopes'],
|
84
|
+
'User-Agent' => USER_AGENT,
|
85
|
+
}.reject {|_, v| v.nil? }
|
86
|
+
end
|
87
|
+
|
88
|
+
def path_with_query
|
89
|
+
result = uri.path
|
90
|
+
result << "?" + query unless query.empty?
|
91
|
+
result
|
92
|
+
end
|
93
|
+
|
94
|
+
def query
|
95
|
+
@query ||= @request.params.slice(:access_token, :bearer_token).to_query
|
96
|
+
end
|
97
|
+
|
98
|
+
def uri
|
99
|
+
@uri ||= URI.parse(auth_server_url)
|
100
|
+
end
|
101
|
+
|
102
|
+
def http_client
|
103
|
+
client = Net::HTTP.new(uri.host, uri.port)
|
104
|
+
client.use_ssl = true if uri.scheme == 'https'
|
105
|
+
client.read_timeout = read_timeout
|
106
|
+
client.open_timeout = OPEN_TIMEOUT
|
107
|
+
client
|
108
|
+
end
|
109
|
+
|
110
|
+
def auth_server_url
|
111
|
+
Garage.configuration.auth_server_url or raise NoUrlError
|
112
|
+
end
|
113
|
+
|
114
|
+
def read_timeout
|
115
|
+
Garage.configuration.auth_server_timeout or READ_TIMEOUT
|
116
|
+
end
|
117
|
+
|
118
|
+
def has_any_valid_credentials?
|
119
|
+
@request.authorization.present? ||
|
120
|
+
@request.params[:access_token].present? ||
|
121
|
+
@request.params[:bearer_token].present?
|
122
|
+
end
|
123
|
+
|
124
|
+
# Cacheable requests are:
|
125
|
+
# - Bearer token request with `Authorization` header.
|
126
|
+
#
|
127
|
+
# We don't cache these requests because they are less requested:
|
128
|
+
# - Bearer token request with query parameter which has been deprecated.
|
129
|
+
# - Any other token type.
|
130
|
+
def has_cacheable_credentials?
|
131
|
+
bearer_token.present?
|
132
|
+
end
|
133
|
+
|
134
|
+
def bearer_token
|
135
|
+
@bearer_token ||= @request.authorization.try {|o| o.slice(/\ABearer\s+(.+)\z/, 1) }
|
136
|
+
end
|
137
|
+
|
138
|
+
def fetch_with_cache
|
139
|
+
Cache.with_cache("garage_gem/token_cache/#{Garage::VERSION}/#{bearer_token}") do
|
140
|
+
fetch_without_cache
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def fetch_without_cache
|
145
|
+
response = get
|
146
|
+
if response.valid?
|
147
|
+
Garage::Strategy::AccessToken.new(response.to_hash)
|
148
|
+
else
|
149
|
+
if response.status_code == 401
|
150
|
+
nil
|
151
|
+
else
|
152
|
+
raise AuthBackendError.new(response)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
class Response
|
159
|
+
def initialize(raw)
|
160
|
+
@raw = raw
|
161
|
+
end
|
162
|
+
|
163
|
+
def valid?
|
164
|
+
status_code == 200 && json? && parsed_body.is_a?(Hash)
|
165
|
+
end
|
166
|
+
|
167
|
+
def to_hash
|
168
|
+
parsed_body.symbolize_keys
|
169
|
+
end
|
170
|
+
|
171
|
+
def status_code
|
172
|
+
@raw.code.to_i
|
173
|
+
end
|
174
|
+
|
175
|
+
def body
|
176
|
+
@raw.body
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
def json?
|
182
|
+
parsed_body
|
183
|
+
true
|
184
|
+
rescue JSON::ParserError
|
185
|
+
false
|
186
|
+
end
|
187
|
+
|
188
|
+
def parsed_body
|
189
|
+
@parsed_body ||= JSON.parse(body)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
class NoUrlError < StandardError
|
194
|
+
def message
|
195
|
+
'You must set Garage.configuration.auth_server_url'
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|