heimdallr 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,34 +1,65 @@
1
1
  module Heimdallr
2
+ # Heimdallr is attached to your models by including the module and defining the
3
+ # restrictions in your classes.
4
+ #
5
+ # class Article < ActiveRecord::Base
6
+ # include Heimdallr::Model
7
+ #
8
+ # restrict do |context|
9
+ # # ...
10
+ # end
11
+ # end
12
+ #
13
+ # @todo Improve description
2
14
  module Model
3
15
  extend ActiveSupport::Concern
4
16
 
17
+ # Class methods for {Heimdallr::Model}. See also +ActiveSupport::Concern+.
5
18
  module ClassMethods
6
- def restrict(&block)
7
- @restrictions = Evaluator.new(self, &block)
8
- end
9
-
10
- def restricted?
11
- !@restrictions.nil?
19
+ # @overload restrict
20
+ # Define restrictions for a model with a DSL. See {Model} overview
21
+ # for DSL documentation.
22
+ #
23
+ # @yield A passed block is executed in the context of a new {Evaluator}.
24
+ #
25
+ # @overload restrict(context, action=:view)
26
+ # Return a secure collection object for the current scope.
27
+ #
28
+ # @param [Object] context security context
29
+ # @param [Symbol] action kind of actions which will be performed
30
+ # @return [Proxy::Collection]
31
+ def restrict(context=nil, &block)
32
+ if block
33
+ @restrictions = Evaluator.new(self, &block)
34
+ else
35
+ Proxy::Collection.new(context, restrictions(context).request_scope)
36
+ end
12
37
  end
13
38
 
39
+ # Evaluate the restrictions for a given +context+.
40
+ #
41
+ # @return [Evaluator]
14
42
  def restrictions(context)
15
43
  @restrictions.evaluate(context)
16
44
  end
17
45
  end
18
46
 
19
- module InstanceMethods
20
- def to_proxy(context, action)
21
- if self.class.restricted?
22
- Proxy.new(context, action, self)
23
- else
24
- self
25
- end
26
- end
47
+ # Return a secure proxy object for this record.
48
+ #
49
+ # @return [Record::Proxy]
50
+ def restrict(context, action)
51
+ Record::Proxy.new(context, self)
52
+ end
27
53
 
28
- def validate_action(context, action)
29
- if self.class.restricted?
30
- self.class.restrictions(context).validate(action, self)
31
- end
54
+ # @api private
55
+ #
56
+ # An internal attribute to store the Heimdallr security validators for
57
+ # the context in which this object is currently being saved.
58
+ attr_accessor :heimdallr_validators
59
+
60
+ def self.included(klass)
61
+ klass.const_eval do
62
+ validates_with Heimdallr::Validator
32
63
  end
33
64
  end
34
65
  end
@@ -0,0 +1,28 @@
1
+ module Heimdallr
2
+ # A security-aware proxy for +ActiveRecord+ scopes. This class validates all the
3
+ # method calls and either forwards them to the encapsulated scope or raises
4
+ # an exception.
5
+ class Proxy::Collection
6
+ # Create a collection proxy.
7
+ # @param context security context
8
+ # @param object proxified scope
9
+ def initialize(context, scope)
10
+ @context, @scope = context, scope
11
+
12
+ @restrictions = @object.class.restrictions(context)
13
+ end
14
+
15
+ # Collections cannot be restricted twice.
16
+ #
17
+ # @raise [RuntimeError]
18
+ def restrict(context)
19
+ raise RuntimeError, "Collections cannot be restricted twice"
20
+ end
21
+
22
+ # Dummy method_missing.
23
+ # @todo Write some actual dispatching logic.
24
+ def method_missing(method, *args, &block)
25
+ @scope.send method, *args
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,236 @@
1
+ module Heimdallr
2
+ # A security-aware proxy for individual records. This class validates all the
3
+ # method calls and either forwards them to the encapsulated object or raises
4
+ # an exception.
5
+ #
6
+ # The #touch method call isn't considered a security threat and as such, it is
7
+ # forwarded to the underlying object directly.
8
+ class Proxy::Record
9
+ # Create a record proxy.
10
+ # @param context security context
11
+ # @param object proxified record
12
+ def initialize(context, record)
13
+ @context, @record = context, record
14
+
15
+ @restrictions = @record.class.restrictions(context)
16
+ end
17
+
18
+ # @method decrement(field, by=1)
19
+ # @macro [new] delegate
20
+ # Delegates to the corresponding method of underlying object.
21
+ delegate :decrement, :to => :@record
22
+
23
+ # @method increment(field, by=1)
24
+ # @macro delegate
25
+ delegate :increment, :to => :@record
26
+
27
+ # @method toggle(field)
28
+ # @macro delegate
29
+ delegate :toggle, :to => :@record
30
+
31
+ # @method touch(field)
32
+ # @macro delegate
33
+ # This method does not modify any fields except for the timestamp itself
34
+ # and thus is not considered as a potential security threat.
35
+ delegate :touch, :to => :@record
36
+
37
+ # A proxy for +attributes+ method which removes all attributes
38
+ # without +:view+ permission.
39
+ def attributes
40
+ @restrictions.filter_attributes(:view, @record.attributes)
41
+ end
42
+
43
+ # A proxy for +update_attributes+ method which removes all attributes
44
+ # without +:update+ permission and invokes +#save+.
45
+ #
46
+ # @raise [Heimdallr::PermissionError]
47
+ def update_attributes(attributes, options={})
48
+ @record.with_transaction_returning_status do
49
+ @record.assign_attributes(attributes, options)
50
+ self.save
51
+ end
52
+ end
53
+
54
+ # A proxy for +update_attributes!+ method which removes all attributes
55
+ # without +:update+ permission and invokes +#save!+.
56
+ #
57
+ # @raise [Heimdallr::PermissionError]
58
+ def update_attributes(attributes, options={})
59
+ @record.with_transaction_returning_status do
60
+ @record.assign_attributes(attributes, options)
61
+ self.save!
62
+ end
63
+ end
64
+
65
+ # A proxy for +save+ method which verifies all of the dirty attributes to
66
+ # be valid for current security context.
67
+ #
68
+ # @raise [Heimdallr::PermissionError]
69
+ def save(options={})
70
+ check_save_options options
71
+
72
+ check_attributes do
73
+ @record.save(options)
74
+ end
75
+ end
76
+
77
+ # A proxy for +save+ method which verifies all of the dirty attributes to
78
+ # be valid for current security context and mandates the current record
79
+ # to be valid.
80
+ #
81
+ # @raise [Heimdallr::PermissionError]
82
+ # @raise [ActiveRecord::RecordInvalid]
83
+ # @raise [ActiveRecord::RecordNotSaved]
84
+ def save!(options={})
85
+ check_save_options options
86
+
87
+ check_attributes do
88
+ @record.save!(options)
89
+ end
90
+ end
91
+
92
+ [:delete, :destroy].each do |method|
93
+ class_eval(<<-EOM, __FILE__, __LINE__)
94
+ def #{method}
95
+ scope = @restrictions.request_scope(:delete)
96
+ if scope.where({ @record.primary_key => @record.to_key }).count != 0
97
+ @record.#{method}
98
+ end
99
+ end
100
+ EOM
101
+ end
102
+
103
+ # Records cannot be restricted twice.
104
+ #
105
+ # @raise [RuntimeError]
106
+ def restrict(context)
107
+ raise RuntimeError, "Records cannot be restricted twice"
108
+ end
109
+
110
+ # A whitelisting dispatcher for attribute-related method calls.
111
+ # Every unknown method is first normalized (that is, stripped of its +?+ or +=+
112
+ # suffix). Then, if the normalized form is whitelisted, it is passed to the
113
+ # underlying object as-is. Otherwise, an exception is raised.
114
+ #
115
+ # If the underlying object is an instance of ActiveRecord, then all association
116
+ # accesses are resolved and proxified automatically.
117
+ #
118
+ # Note that only the attribute and collection getters and setters are
119
+ # dispatched through this method. Every other model method should be defined
120
+ # as an instance method of this class in order to work.
121
+ #
122
+ # @raise [Heimdallr::PermissionError] when a non-whitelisted method is accessed
123
+ # @raise [Heimdallr::InsecureOperationError] when an insecure association is about
124
+ # to be fetched
125
+ def method_missing(method, *args, &block)
126
+ suffix = method.to_s[-1]
127
+ if %w(? = !).include? suffix
128
+ normalized_method = method[0..-2].to_sym
129
+ else
130
+ normalized_method = method
131
+ suffix = nil
132
+ end
133
+
134
+ if defined?(ActiveRecord) && @record.is_a?(ActiveRecord::Base) &&
135
+ association = @record.class.reflect_on_association(method)
136
+ referenced = @record.send(method, *args)
137
+
138
+ if referenced.respond_to? :restrict
139
+ referenced.restrict(@context)
140
+ elsif Heimdallr.allow_insecure_associations
141
+ referenced
142
+ else
143
+ raise Heimdallr::InsecureOperationError,
144
+ "Attempt to fetch insecure association #{method}. Try #insecure."
145
+ end
146
+ elsif @record.respond_to? method
147
+ if [nil, '?'].include?(suffix) &&
148
+ @restrictions.allowed_fields[:view].include?(normalized_method)
149
+ # Reading an attribute
150
+ @record.send method, *args, &block
151
+ elsif suffix == '='
152
+ @record.send method, *args
153
+ else
154
+ raise Heimdallr::PermissionError,
155
+ "Non-whitelisted method #{method} is called for #{@record.inspect} on #{@action}."
156
+ end
157
+ else
158
+ super
159
+ end
160
+ end
161
+
162
+ # Return the underlying object.
163
+ #
164
+ # @return [ActiveRecord::Base]
165
+ def insecure
166
+ @record
167
+ end
168
+
169
+ # Describes the proxy and proxified object.
170
+ #
171
+ # @return [String]
172
+ def inspect
173
+ "#<Heimdallr::Proxy(#{@action}): #{@record.inspect}>"
174
+ end
175
+
176
+ # Return the associated security metadata. The returned hash will contain keys
177
+ # +:context+ and +:object+, corresponding to the parameters in
178
+ # {#initialize}.
179
+ #
180
+ # Such a name was deliberately selected for this method in order to reduce namespace
181
+ # pollution.
182
+ #
183
+ # @return [Hash]
184
+ def reflect_on_security
185
+ {
186
+ context: @context,
187
+ object: @record
188
+ }
189
+ end
190
+
191
+ protected
192
+
193
+ # Raises an exception if any of the changed attributes are not valid
194
+ # for the current security context.
195
+ #
196
+ # @raise [Heimdallr::PermissionError]
197
+ def check_attributes
198
+ @record.errors.clear
199
+
200
+ if @record.new_record?
201
+ action = :create
202
+ else
203
+ action = :update
204
+ end
205
+
206
+ fixtures = @restrictions.fixtures[action]
207
+ validators = @restrictions.validators[action]
208
+
209
+ @record.changed.each do |attribute|
210
+ value = @record.send attribute
211
+
212
+ if fixtures.has_key? attribute
213
+ if fixtures[attribute] != value
214
+ raise Heimdallr::PermissionError,
215
+ "Attribute #{attribute} value (#{value}) is not equal to a fixture (#{fixtures[attribute]})"
216
+ end
217
+ end
218
+ end
219
+
220
+ @record.heimdallr_validators = validators
221
+
222
+ yield
223
+ ensure
224
+ @record.heimdallr_validators = nil
225
+ end
226
+
227
+ # Raises an exception if any of the +options+ intended for use in +save+
228
+ # methods are potentially unsafe.
229
+ def check_save_options(options)
230
+ if options[:validate] == false
231
+ raise Heimdallr::InsecureOperationError,
232
+ "Saving while omitting validation would omit security validations too"
233
+ end
234
+ end
235
+ end
236
+ end
@@ -1,110 +1,250 @@
1
1
  module Heimdallr
2
+ # Heimdallr {Resource} is a boilerplate for simple creation of REST endpoints, most of which are
3
+ # quite similar and thus may share a lot of code.
4
+ #
5
+ # The minimal controller possible would be:
6
+ #
7
+ # class MiceController < ApplicationController
8
+ # include Heimdallr::Resource
9
+ #
10
+ # # Class Mouse must include Heimdallr::Model.
11
+ # resource_for :mouse
12
+ # end
13
+ #
14
+ # Resource is built with Convention over Configuration principle in mind; that is,
15
+ # instead of providing complex configuration syntax, Resource consists of a lot of small, easy
16
+ # to override methods. If some kind of default behavior is undesirable, then one can just override
17
+ # the relative method in the particular controller or, say, define a module if the changes are
18
+ # to be shared between several controllers. You are encouraged to explore the source of this class.
19
+ #
20
+ # Resource allows to perform efficient operations on collections of objects. The
21
+ # {#create}, {#update} and {#destroy} actions accept both a single object/ID or an array of
22
+ # objects/IDs. The cardinal _modus
23
+ #
24
+ # Resource expects a method named +security_context+ to be defined either in the controller itself
25
+ # or, more conveniently, in any of its ancestors, likely +ApplicationController+. This method can
26
+ # often be aliased to +current_user+.
27
+ #
28
+ # Resource only works with ActiveRecord.
29
+ #
30
+ # See also {Resource::ClassMethods}.
2
31
  module Resource
3
- extend ActiveSupport::Concern
32
+ # @group Actions
4
33
 
5
- module ClassMethods
6
- attr_reader :model
34
+ # +GET /resources+
35
+ #
36
+ # This action does nothing by itself, but it has a +load_all_resources+ filter attached.
37
+ def index
38
+ end
7
39
 
8
- def resource_for(model, options={})
9
- @model = model.to_s.capitalize.constantize
40
+ # +GET /resource/1+
41
+ #
42
+ # This action does nothing by itself, but it has a +load_one_resource+ filter attached.
43
+ def show
44
+ end
10
45
 
11
- before_filter :load_all_resources, only: [ :index ].concat(options[:all] || [])
12
- before_filter :load_one_resource, only: [ :show ].concat(options[:member] || [])
13
- before_filter :load_referenced_resources, only: [ :update, :destroy ].concat(options[:collection] || [])
14
- end
46
+ # +GET /resources/new+
47
+ #
48
+ # This action renders a JSON representation of fields whitelisted for creation.
49
+ # It does not include any fixtures or validations.
50
+ #
51
+ # @example
52
+ # { 'fields': [ 'topic', 'content' ] }
53
+ def new
54
+ render :json => {
55
+ :fields => model.restrictions(security_context).allowed_fields[:create]
56
+ }
15
57
  end
16
58
 
17
- module InstanceMethods
18
- def index
59
+ # +POST /resources+
60
+ #
61
+ # This action creates one or more records from the passed parameters.
62
+ # It can accept both arrays of attribute hashes and single attribute hashes.
63
+ #
64
+ # After the creation, it calls {#render_resources}.
65
+ #
66
+ # See also {#load_referenced_resources} and {#with_objects_from_params}.
67
+ def create
68
+ with_objects_from_params do |attributes, index|
69
+ scoped_model.restrict(security_context).
70
+ create!(attributes)
19
71
  end
20
72
 
21
- def show
22
- end
73
+ render_resources
74
+ end
75
+
76
+ # +GET /resources/1/edit+
77
+ #
78
+ # This action renders a JSON representation of fields whitelisted for updating.
79
+ # See also {#new}.
80
+ def edit
81
+ render :json => {
82
+ :fields => model.restrict(security_context).allowed_fields[:update]
83
+ }
84
+ end
23
85
 
24
- def new
25
- render :json => {
26
- :fields => model.restrictions(security_context).whitelist[:create]
27
- }
86
+ # +PUT /resources/1,2+
87
+ #
88
+ # This action updates one or more records from the passed parameters.
89
+ # It expects resource IDs to be passed comma-separated in <tt>params[:id]</tt>,
90
+ # and expects them to be in the order corresponding to the order of actual
91
+ # attribute hashes.
92
+ #
93
+ # After the updating, it calls {#render_resources}.
94
+ #
95
+ # See also {#load_referenced_resources} and {#with_objects_from_params}.
96
+ def update
97
+ with_objects_from_params do |attributes, index|
98
+ @resources[index].update_attributes! attributes
28
99
  end
29
100
 
30
- def create
31
- model.transaction do
32
- if params.has_key? model.name.underscore
33
- scoped_model.new.to_proxy(security_context, :create).
34
- update_attributes!(params[model.name.underscore])
35
- else
36
- @resources.each_with_index do |resource, index|
37
- scoped_model.new.to_proxy(security_context, :create).
38
- update_attributes!(params[model.name.underscore.pluralize][index])
39
- end
40
- end
41
- end
101
+ render_resources
102
+ end
42
103
 
43
- if block_given?
44
- yield
45
- else
46
- render_modified_resources
47
- end
104
+ # +DELETE /resources/1,2+
105
+ #
106
+ # This action destroys one or more records. It expects resource IDs to be passed
107
+ # comma-separated in <tt>params[:id]</tt>.
108
+ #
109
+ # See also {#load_referenced_resources}.
110
+ def destroy
111
+ model.transaction do
112
+ @resources.each &:destroy
48
113
  end
49
114
 
50
- def edit
51
- render :json => {
52
- :fields => model.restrictions(security_context).whitelist[:update]
53
- }
54
- end
115
+ render :json => {}, :status => :ok
116
+ end
55
117
 
56
- def update
57
- model.transaction do
58
- if params.has_key? model.name.underscore
59
- @resources.first.to_proxy(security_context, :update).
60
- update_attributes!(params[model.name.underscore])
61
- else
62
- @resources.each_with_index do |resource, index|
63
- resource.to_proxy(security_context, :update).
64
- update_attributes!(params[model.name.underscore.pluralize][index])
65
- end
66
- end
67
- end
118
+ protected
68
119
 
69
- if block_given?
70
- yield
71
- else
72
- render_modified_resources
73
- end
74
- end
120
+ # @group Configuration
75
121
 
76
- def destroy
77
- model.transaction do
78
- @resources.each &:destroy
79
- end
122
+ # Return the associated model class.
123
+ # @return [Class] associated model
124
+ def model
125
+ self.class.model
126
+ end
80
127
 
81
- render :json => {}, :status => :ok
82
- end
128
+ # Return the appropriately scoped model. By default this method
129
+ # delegates to +self.model.scoped+; you may override it for nested
130
+ # resources so that it would only return the nested set.
131
+ #
132
+ # For example, this code would not allow user to perform any actions
133
+ # with a transaction from a wrong account, raising RecordNotFound
134
+ # instead:
135
+ #
136
+ # # transactions_controller.rb
137
+ # class TransactionsController < ApplicationController
138
+ # include Heimdallr::Resource
139
+ #
140
+ # resource_for :transactions
141
+ #
142
+ # protected
143
+ #
144
+ # def scoped_model
145
+ # Account.find(params[:account_id]).transactions
146
+ # end
147
+ # end
148
+ #
149
+ # # routes.rb
150
+ # Foo::Application.routes.draw do
151
+ # resources :accounts do
152
+ # resources :transactions
153
+ # end
154
+ # end
155
+ #
156
+ # @return ActiveRecord scope
157
+ def scoped_model
158
+ self.model.scoped
159
+ end
83
160
 
84
- protected
161
+ # Loads all resources in the current scope to +@resources+.
162
+ #
163
+ # Is automatically applied to {#index}.
164
+ def load_all_resources
165
+ @resources = scoped_model
166
+ end
85
167
 
86
- def model
87
- self.class.model
88
- end
168
+ # Loads one resource from the current scope, referenced by <code>params[:id]</code>,
169
+ # to +@resource+.
170
+ #
171
+ # Is automatically applied to {#show}.
172
+ def load_one_resource
173
+ @resource = scoped_model.find(params[:id])
174
+ end
89
175
 
90
- def scoped_model
91
- self.model.scoped
92
- end
176
+ # Loads several resources from the current scope, referenced by <code>params[:id]</code>
177
+ # with a comma-separated string like "1,2,3", to +@resources+.
178
+ #
179
+ # Is automatically applied to {#update} and {#destroy}.
180
+ def load_referenced_resources
181
+ @resources = scoped_model.find(params[:id].split(','))
182
+ end
93
183
 
94
- def load_one_resource
95
- @resource = scoped_model.find(params[:id])
96
- end
184
+ # Render a modified collection in {#create}, {#update} and similar actions.
185
+ #
186
+ # By default, it invokes a template for +index+.
187
+ def render_resources
188
+ render :action => :index
189
+ end
97
190
 
98
- def load_all_resources
99
- @resources = scoped_model.scoped
191
+ # Fetch one or several objects passed in +params+ and yield them to a block,
192
+ # wrapping everything in a transaction.
193
+ #
194
+ # @yield [attributes, index]
195
+ # @yieldparam [Hash] attributes
196
+ # @yieldparam [Integer] index
197
+ def with_objects_from_params
198
+ model.transaction do
199
+ if params.has_key? model.name.underscore
200
+ yield params[model.name.underscore], 0
201
+ else
202
+ params[model.name.underscore.pluralize].
203
+ each_with_index do |attributes, index|
204
+ yield attributes, index
205
+ end
206
+ end
100
207
  end
208
+ end
101
209
 
102
- def load_referenced_resources
103
- @resources = scoped_model.find(params[:id].split(','))
104
- end
210
+ extend ActiveSupport::Concern
211
+
212
+ # Class methods for {Heimdallr::Resource}. See also +ActiveSupport::Concern+.
213
+ module ClassMethods
214
+ # Returns the attached model class.
215
+ # @return [Class]
216
+ attr_reader :model
217
+
218
+ # Attaches this resource to a model.
219
+ #
220
+ # Note that ActiveSupport +before_filter+ _replaces_ the list of actions for specified
221
+ # filter and not appends to it. For example, the following code will only run +filter_a+
222
+ # when +bar+ action is invoked:
223
+ #
224
+ # class FooController < ApplicationController
225
+ # before_filter :filter_a, :only => :foo
226
+ # before_filter :filter_a, :only => :bar
227
+ #
228
+ # def foo; end
229
+ # def bar; end
230
+ # end
231
+ #
232
+ # For convenience, you can pass additional actions to register with default filters in
233
+ # +options+. It is also possible to use +append_before_filter+.
234
+ #
235
+ # @param [Class] model An ActiveModel or ActiveRecord model class.
236
+ # @option options [Array<Symbol>] :index
237
+ # Additional actions to be covered by {Heimdallr::Resource#load_all_resources}.
238
+ # @option options [Array<Symbol>] :member
239
+ # Additional actions to be covered by {Heimdallr::Resource#load_one_resource}.
240
+ # @option options [Array<Symbol>] :collection
241
+ # Additional actions to be covered by {Heimdallr::Resource#load_referenced_resources}.
242
+ def resource_for(model, options={})
243
+ @model = model.to_s.camelize.constantize
105
244
 
106
- def render_modified_resources
107
- render :action => :index
245
+ before_filter :load_all_resources, only: [ :index ].concat(options[:all] || [])
246
+ before_filter :load_one_resource, only: [ :show ].concat(options[:member] || [])
247
+ before_filter :load_referenced_resources, only: [ :update, :destroy ].concat(options[:collection] || [])
108
248
  end
109
249
  end
110
250
  end