heimdallr 0.0.1 → 0.0.2

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.
@@ -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