protector 0.1.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,8 +1,7 @@
1
1
  module Protector
2
2
  module Adapters
3
3
  module ActiveRecord
4
-
5
- # Pathces `ActiveRecord::Relation`
4
+ # Patches `ActiveRecord::Relation`
6
5
  module Relation
7
6
  extend ActiveSupport::Concern
8
7
 
@@ -10,9 +9,6 @@ module Protector
10
9
  include Protector::DSL::Base
11
10
 
12
11
  alias_method_chain :exec_queries, :protector
13
- alias_method_chain :eager_loading?, :protector
14
-
15
- attr_accessor :eager_loadable_when_protected
16
12
 
17
13
  # AR 3.2 workaround. Come on, guys... SQL parsing :(
18
14
  unless method_defined?(:references_values)
@@ -67,86 +63,49 @@ module Protector
67
63
  #
68
64
  # Patching includes:
69
65
  #
70
- # * turning `includes` into `preload`
66
+ # * turning `includes` (that are not referenced for eager loading) into `preload`
71
67
  # * delaying built-in preloading to the stage where selection is restricted
72
- # * merging current relation with restriction
68
+ # * merging current relation with restriction (of self and every eager association)
73
69
  def exec_queries_with_protector(*args)
70
+ return @records if loaded?
74
71
  return exec_queries_without_protector unless @protector_subject
75
72
 
76
73
  subject = @protector_subject
77
74
  relation = merge(protector_meta.relation).unrestrict!
78
-
79
75
  relation = protector_substitute_includes(relation)
80
76
 
81
- # We should explicitly allow/deny eager loading now that we know
82
- # if we can use it
83
- relation.eager_loadable_when_protected = relation.includes_values.any?
84
-
85
77
  # Preserve associations from internal loading. We are going to handle that
86
78
  # ourselves respecting security scopes FTW!
87
79
  associations, relation.preload_values = relation.preload_values, []
88
80
 
89
- @records = relation.send(:exec_queries).each{|r| r.restrict!(subject)}
81
+ @records = relation.send(:exec_queries).each{|record| record.restrict!(subject)}
90
82
 
91
83
  # Now we have @records restricted properly so let's preload associations!
92
- associations.each do |a|
93
- ::ActiveRecord::Associations::Preloader.new(@records, a).run
84
+ associations.each do |association|
85
+ ::ActiveRecord::Associations::Preloader.new(@records, association).run
94
86
  end
95
87
 
96
88
  @loaded = true
97
89
  @records
98
90
  end
99
91
 
100
- # Swaps `includes` with `preload` and adds JOINs to any table referenced
101
- # from `where` (or manually with `reference`)
92
+ # Swaps `includes` with `preload` whether it's not referenced or merges
93
+ # security scope of proper class otherwise
102
94
  def protector_substitute_includes(relation)
103
95
  subject = @protector_subject
104
96
 
105
- # Note that `includes_values` shares reference across relation diffs so
106
- # it can not be modified safely and should be copied instead
107
- includes, relation.includes_values = relation.includes_values, []
108
-
109
- # We can not allow join-based eager loading for scoped associations
110
- # since actual filtering can differ for host model and joined relation.
111
- # Therefore we turn all `includes` into `preloads`.
112
- includes.each do |iv|
113
- protector_expand_include(iv).each do |ive|
114
- # First-level associations can stay JOINed if restriction scope
115
- # is absent. Checking deep associations would make us to check
116
- # every parent. This should probably be done sometimes :\
117
- meta = ive[0].protector_meta.evaluate(ive[0], subject) unless ive[1].is_a?(Hash)
118
-
119
- # We leave unscoped restrictions as `includes`
120
- # but turn scoped ones into `preloads`
121
- if meta && !meta.scoped?
122
- relation.includes!(ive[1])
123
- else
124
- if relation.references_values.include?(ive[0].table_name)
125
- if relation.respond_to?(:joins!)
126
- relation.joins!(ive[1])
127
- else
128
- relation = relation.joins(ive[1])
129
- end
130
- end
131
-
132
- # AR 3.2 Y U NO HAVE BANG RELATION MODIFIERS
133
- relation.preload_values << ive[1]
134
- false
135
- end
97
+ if eager_loading?
98
+ protector_expand_inclusion(includes_values + eager_load_values).each do |klass, path|
99
+ relation = relation.merge(klass.protector_meta.evaluate(klass, subject).relation)
136
100
  end
101
+ else
102
+ relation.preload_values += includes_values
103
+ relation.includes_values = []
137
104
  end
138
105
 
139
106
  relation
140
107
  end
141
108
 
142
- # Checks whether current object can be eager loaded respecting
143
- # protector flags
144
- def eager_loading_with_protector?
145
- flag = eager_loading_without_protector?
146
- flag &&= !!@eager_loadable_when_protected unless @eager_loadable_when_protected.nil?
147
- flag
148
- end
149
-
150
109
  # Indexes `includes` format by actual entity class
151
110
  #
152
111
  # Turns `{foo: :bar}` into `[[Foo, :foo], [Bar, {foo: :bar}]`
@@ -155,13 +114,13 @@ module Protector
155
114
  # @param [Array] results Resulting set
156
115
  # @param [Array] base Association path ([:foo, :bar])
157
116
  # @param [Class] klass Base class
158
- def protector_expand_include(inclusion, results=[], base=[], klass=@klass)
117
+ def protector_expand_inclusion(inclusion, results=[], base=[], klass=@klass)
159
118
  if inclusion.is_a?(Hash)
160
- protector_expand_include_hash(inclusion, results, base, klass)
119
+ protector_expand_inclusion_hash(inclusion, results, base, klass)
161
120
  else
162
121
  Array(inclusion).each do |i|
163
122
  if i.is_a?(Hash)
164
- protector_expand_include_hash(i, results, base, klass)
123
+ protector_expand_inclusion_hash(i, results, base, klass)
165
124
  else
166
125
  results << [
167
126
  klass.reflect_on_association(i.to_sym).klass,
@@ -176,7 +135,7 @@ module Protector
176
135
 
177
136
  private
178
137
 
179
- def protector_expand_include_hash(inclusion, results=[], base=[], klass=@klass)
138
+ def protector_expand_inclusion_hash(inclusion, results=[], base=[], klass=@klass)
180
139
  inclusion.each do |key, value|
181
140
  model = klass.reflect_on_association(key.to_sym).klass
182
141
  value = [value] unless value.is_a?(Array)
@@ -184,7 +143,7 @@ module Protector
184
143
 
185
144
  value.each do |v|
186
145
  if v.is_a?(Hash)
187
- protector_expand_include_hash(v, results, nest)
146
+ protector_expand_inclusion_hash(v, results, nest)
188
147
  else
189
148
  results << [
190
149
  model.reflect_on_association(v.to_sym).klass,
@@ -0,0 +1,66 @@
1
+ module Protector
2
+ module Adapters
3
+ module Sequel
4
+ # Patches `Sequel::Dataset`
5
+ module Dataset extend ActiveSupport::Concern
6
+
7
+ # Wrapper for the Dataset `row_proc` adding restriction function
8
+ class Restrictor
9
+ attr_accessor :subject
10
+ attr_accessor :mutator
11
+
12
+ def initialize(subject, mutator)
13
+ @subject = subject
14
+ @mutator = mutator
15
+ end
16
+
17
+ # Mutate entity through `row_proc` if available and then protect
18
+ #
19
+ # @param entity [Object] Entity coming from Dataset
20
+ def call(entity)
21
+ entity = mutator.call(entity) if mutator
22
+ return entity if !entity.respond_to?(:restrict!)
23
+ entity.restrict!(@subject)
24
+ end
25
+ end
26
+
27
+ included do |klass|
28
+ include Protector::DSL::Base
29
+
30
+ alias_method_chain :each, :protector
31
+ end
32
+
33
+ # Gets {Protector::DSL::Meta::Box} of this dataset
34
+ def protector_meta
35
+ model.protector_meta.evaluate(model, @protector_subject)
36
+ end
37
+
38
+ # Substitutes `row_proc` with {Protector} and injects protection scope
39
+ def each_with_protector(*args, &block)
40
+ return each_without_protector(*args, &block) if !@protector_subject
41
+
42
+ relation = protector_defend_graph(clone, @protector_subject)
43
+ relation = relation.instance_eval(&protector_meta.scope_proc) if protector_meta.scoped?
44
+
45
+ relation.row_proc = Restrictor.new(@protector_subject, relation.row_proc)
46
+ relation.each_without_protector(*args, &block)
47
+ end
48
+
49
+ # Injects protection scope for every joined graph association
50
+ def protector_defend_graph(relation, subject)
51
+ return relation unless @opts[:eager_graph]
52
+
53
+ @opts[:eager_graph][:reflections].each do |association, reflection|
54
+ model = reflection[:cache][:class] if reflection[:cache].is_a?(Hash) && reflection[:cache][:class]
55
+ model = reflection[:class_name].constantize unless model
56
+ meta = model.protector_meta.evaluate(model, subject)
57
+
58
+ relation = relation.instance_eval(&meta.scope_proc) if meta.scoped?
59
+ end
60
+
61
+ relation
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,24 @@
1
+ module Protector
2
+ module Adapters
3
+ module Sequel
4
+ # Patches `Sequel::Model::Associations::EagerGraphLoader`
5
+ module EagerGraphLoader extend ActiveSupport::Concern
6
+
7
+ included do
8
+ alias_method_chain :initialize, :protector
9
+ end
10
+
11
+ def initialize_with_protector(dataset)
12
+ initialize_without_protector(dataset)
13
+
14
+ if dataset.protector_subject
15
+ @row_procs.each do |k,v|
16
+ @row_procs[k] = Dataset::Restrictor.new(dataset.protector_subject, v)
17
+ @ta_map[k][1] = @row_procs[k] if @ta_map.has_key?(k)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,99 @@
1
+ module Protector
2
+ module Adapters
3
+ module Sequel
4
+ # Patches `Sequel::Model`
5
+ module Model extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include Protector::DSL::Base
9
+ include Protector::DSL::Entry
10
+
11
+ # Drops {Protector::DSL::Meta::Box} cache when subject changes
12
+ def restrict!(*args)
13
+ @protector_meta = nil
14
+ super
15
+ end
16
+ end
17
+
18
+ module ClassMethods
19
+ # Gets default restricted `Dataset`
20
+ def restrict!(subject)
21
+ dataset.clone.restrict! subject
22
+ end
23
+ end
24
+
25
+ # Storage for {Protector::DSL::Meta::Box}
26
+ def protector_meta
27
+ @protector_meta ||= self.class.protector_meta.evaluate(
28
+ self.class,
29
+ @protector_subject,
30
+ self.class.columns,
31
+ self
32
+ )
33
+ end
34
+
35
+ # Checks if current model can be selected in the context of current subject
36
+ def visible?
37
+ protector_meta.relation.where(pk_hash).any?
38
+ end
39
+
40
+ # Checks if current model can be created in the context of current subject
41
+ def creatable?
42
+ fields = HashWithIndifferentAccess[keys.map{|x| [x.to_s, @values[x]]}]
43
+ protector_meta.creatable?(fields)
44
+ end
45
+
46
+ # Checks if current model can be updated in the context of current subject
47
+ def updatable?
48
+ fields = HashWithIndifferentAccess[changed_columns.map{|x| [x.to_s, @values[x]]}]
49
+ protector_meta.updatable?(fields)
50
+ end
51
+
52
+ # Checks if current model can be destroyed in the context of current subject
53
+ def destroyable?
54
+ protector_meta.destroyable?
55
+ end
56
+
57
+ # Basic security validations
58
+ def validate
59
+ super
60
+ return unless @protector_subject
61
+ method = new? ? :creatable? : :updatable?
62
+ errors.add(:base, I18n.t('protector.invalid')) unless __send__(method)
63
+ end
64
+
65
+ # Destroy availability check
66
+ def before_destroy
67
+ return false if @protector_subject && !destroyable?
68
+ super
69
+ end
70
+
71
+ # Security-checking attributes reader
72
+ #
73
+ # @param name [Symbol] Name of attribute to read
74
+ def [](name)
75
+ if (
76
+ !@protector_subject ||
77
+ name == self.class.primary_key ||
78
+ (self.class.primary_key.is_a?(Array) && self.class.primary_key.include?(name)) ||
79
+ protector_meta.readable?(name.to_s)
80
+ )
81
+ @values[name]
82
+ else
83
+ nil
84
+ end
85
+ end
86
+
87
+ # This is used whenever we fetch data
88
+ def _associated_dataset(*args)
89
+ super.restrict!(@protector_subject)
90
+ end
91
+
92
+ # This is used whenever we call counters and existance checkers
93
+ def _dataset(*args)
94
+ super.restrict!(@protector_subject)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,17 @@
1
+ require 'protector/adapters/sequel/model'
2
+ require 'protector/adapters/sequel/dataset'
3
+ require 'protector/adapters/sequel/eager_graph_loader'
4
+
5
+ module Protector
6
+ module Adapters
7
+ # Sequel adapter
8
+ module Sequel
9
+ # YIP YIP! Monkey-Patch the Sequel.
10
+ def self.activate!
11
+ ::Sequel::Model.send :include, Protector::Adapters::Sequel::Model
12
+ ::Sequel::Dataset.send :include, Protector::Adapters::Sequel::Dataset
13
+ ::Sequel::Model::Associations::EagerGraphLoader.send :include, Protector::Adapters::Sequel::EagerGraphLoader
14
+ end
15
+ end
16
+ end
17
+ end
data/lib/protector/dsl.rb CHANGED
@@ -5,7 +5,7 @@ module Protector
5
5
 
6
6
  # Single DSL evaluation result
7
7
  class Box
8
- attr_accessor :access, :relation, :destroyable
8
+ attr_accessor :access, :scope_proc, :relation, :destroyable
9
9
 
10
10
  # @param model [Class] The class of protected entity
11
11
  # @param fields [Array<String>] All the fields the model has
@@ -50,7 +50,8 @@ module Protector
50
50
  # scope { none }
51
51
  # end
52
52
  def scope(&block)
53
- @relation = @model.instance_eval(&block)
53
+ @scope_proc = block
54
+ @relation = @model.instance_eval(&block)
54
55
  end
55
56
 
56
57
  # Enables action for given fields.
@@ -198,6 +199,8 @@ module Protector
198
199
  # @param fields [Array<String>] All the fields the model has
199
200
  # @param entry [Object] An instance of the model
200
201
  def evaluate(model, subject, fields=[], entry=nil)
202
+ raise "Unprotected entity detected: use `restrict` method to protect it." unless subject
203
+
201
204
  Box.new(model, fields, subject, entry, blocks)
202
205
  end
203
206
  end
@@ -1,4 +1,4 @@
1
1
  module Protector
2
2
  # Gem version
3
- VERSION = "0.1.1"
3
+ VERSION = "0.2.1"
4
4
  end
data/lib/protector.rb CHANGED
@@ -4,7 +4,9 @@ require "i18n"
4
4
  require "protector/version"
5
5
  require "protector/dsl"
6
6
  require "protector/adapters/active_record"
7
+ require "protector/adapters/sequel"
7
8
 
8
9
  I18n.load_path << Dir[File.join File.expand_path(File.dirname(__FILE__)), '..', 'locales', '*.yml']
9
10
 
10
- Protector::Adapters::ActiveRecord.activate! if defined?(ActiveRecord)
11
+ Protector::Adapters::ActiveRecord.activate! if defined?(ActiveRecord)
12
+ Protector::Adapters::Sequel.activate! if defined?(Sequel)
@@ -23,7 +23,6 @@ end
23
23
  t.integer :number
24
24
  t.text :text
25
25
  t.belongs_to :dummy
26
- t.timestamps
27
26
  end
28
27
  end
29
28
 
@@ -0,0 +1,49 @@
1
+ ### Connection
2
+
3
+ DB = if RUBY_PLATFORM == 'java'
4
+ Jdbc::SQLite3.load_driver
5
+ Sequel.connect('jdbc:sqlite::memory:')
6
+ else
7
+ Sequel.sqlite
8
+ end
9
+
10
+ Sequel::Model.instance_eval do
11
+ def none
12
+ where('1 = 0')
13
+ end
14
+ end
15
+
16
+ ### Tables
17
+
18
+ [:dummies, :fluffies, :bobbies].each do |m|
19
+ DB.create_table m do
20
+ primary_key :id
21
+ String :string
22
+ Integer :number
23
+ Text :text
24
+ Integer :dummy_id
25
+ end
26
+ end
27
+
28
+ DB.create_table :loonies do
29
+ Integer :fluffy_id
30
+ String :string
31
+ end
32
+
33
+ ### Classes
34
+
35
+ class Dummy < Sequel::Model
36
+ one_to_many :fluffies
37
+ one_to_many :bobbies
38
+ end
39
+
40
+ class Fluffy < Sequel::Model
41
+ many_to_one :dummy
42
+ one_to_one :loony
43
+ end
44
+
45
+ class Bobby < Sequel::Model
46
+ end
47
+
48
+ class Loony < Sequel::Model
49
+ end
@@ -1,3 +1,5 @@
1
+ require 'ruby-prof'
2
+
1
3
  migrate
2
4
 
3
5
  seed do
@@ -2,7 +2,7 @@ class Perf
2
2
  def self.load(adapter)
3
3
  perf = Perf.new(adapter.camelize)
4
4
  base = Pathname.new(File.expand_path '../..', __FILE__)
5
- file = base.join(adapter+'.rb').to_s
5
+ file = base.join(adapter+'_perf.rb').to_s
6
6
  perf.instance_eval File.read(file), file
7
7
  perf.run!
8
8
  end
@@ -11,6 +11,7 @@ class Perf
11
11
  @blocks = {}
12
12
  @adapter = adapter
13
13
  @activated = false
14
+ @profiling = {}
14
15
  end
15
16
 
16
17
  def migrate
@@ -32,15 +33,22 @@ class Perf
32
33
  @activation = block
33
34
  end
34
35
 
35
- def benchmark(subject, &block)
36
+ def benchmark(subject, options={}, &block)
36
37
  @blocks[subject] = block
37
38
  end
38
39
 
40
+ def benchmark!(subject, options={min_percent: 4}, &block)
41
+ @profiling[subject] = options
42
+ benchmark(subject, &block)
43
+ end
44
+
39
45
  def activated?
40
46
  @activated
41
47
  end
42
48
 
43
49
  def run!
50
+ require 'ruby-prof' if @profiling.any?
51
+
44
52
  results = {}
45
53
 
46
54
  results[:off] = run_state('disabled', :red)
@@ -65,11 +73,21 @@ class Perf
65
73
 
66
74
  def run_state(state, color)
67
75
  data = {}
76
+ prof = @profiling
68
77
 
69
78
  print_block "Protector #{state.send color}" do
70
79
  @blocks.each do |s, b|
80
+ RubyProf.start if prof.include?(s)
81
+
71
82
  data[s] = Benchmark.realtime(&b)
72
83
  print_result s, data[s].to_s
84
+
85
+ if prof.include?(s)
86
+ result = RubyProf.stop
87
+
88
+ printer = RubyProf::FlatPrinter.new(result)
89
+ printer.print(STDOUT, prof[s])
90
+ end
73
91
  end
74
92
  end
75
93
 
@@ -0,0 +1,84 @@
1
+ migrate
2
+
3
+ seed do
4
+ 500.times do
5
+ d = Dummy.create string: 'zomgstring', number: [999,777].sample, text: 'zomgtext'
6
+
7
+ 2.times do
8
+ f = Fluffy.create string: 'zomgstring', number: [999,777].sample, text: 'zomgtext', dummy_id: d.id
9
+ b = Bobby.create string: 'zomgstring', number: [999,777].sample, text: 'zomgtext', dummy_id: d.id
10
+ l = Loony.create string: 'zomgstring', fluffy_id: f.id
11
+ end
12
+ end
13
+ end
14
+
15
+ activate do
16
+ Dummy.instance_eval do
17
+ protect do
18
+ scope { where }
19
+ can :view, :string
20
+ end
21
+ end
22
+
23
+ Fluffy.instance_eval do
24
+ protect do
25
+ scope { where }
26
+ can :view
27
+ end
28
+ end
29
+
30
+ # Define attributes methods
31
+ Dummy.first
32
+ end
33
+
34
+ benchmark 'Read from unprotected model (100k)' do
35
+ d = Dummy.first
36
+ 100_000.times { d.string }
37
+ end
38
+
39
+ benchmark 'Read open field (100k)' do
40
+ d = Dummy.first
41
+ d = d.restrict!('!') if activated?
42
+ 100_000.times { d.string }
43
+ end
44
+
45
+ benchmark 'Read nil field (100k)' do
46
+ d = Dummy.first
47
+ d = d.restrict!('!') if activated?
48
+ 100_000.times { d.text }
49
+ end
50
+
51
+ benchmark 'Check existance' do
52
+ scope = activated? ? Dummy.restrict!('!') : Dummy.where
53
+ 1000.times { scope.any? }
54
+ end
55
+
56
+ benchmark 'Count' do
57
+ scope = Dummy.limit(1)
58
+ scope = scope.restrict!('!') if activated?
59
+ 1000.times { scope.count }
60
+ end
61
+
62
+ benchmark 'Select one' do
63
+ scope = Dummy.limit(1)
64
+ scope = scope.restrict!('!') if activated?
65
+ 1000.times { scope.to_a }
66
+ end
67
+
68
+ benchmark 'Select many' do
69
+ scope = Dummy.where
70
+ scope = scope.restrict!('!') if activated?
71
+ 200.times { scope.to_a }
72
+ end
73
+
74
+ benchmark 'Select with eager loading' do
75
+ scope = Dummy.eager(:fluffies)
76
+ scope = scope.restrict!('!') if activated?
77
+ 200.times { scope.to_a }
78
+ end
79
+
80
+ benchmark 'Select with filtered eager loading' do
81
+ scope = Dummy.eager_graph(fluffies: :loony)
82
+ scope = scope.restrict!('!') if activated?
83
+ 200.times { scope.to_a }
84
+ end
@@ -7,6 +7,19 @@ if defined?(ActiveRecord)
7
7
  before(:all) do
8
8
  load 'migrations/active_record.rb'
9
9
 
10
+ module ProtectionCase
11
+ extend ActiveSupport::Concern
12
+
13
+ included do |klass|
14
+ protect do |x|
15
+ scope{ where('1=0') } if x == '-'
16
+ scope{ where(klass.table_name => {number: 999}) } if x == '+'
17
+
18
+ can :view, :dummy_id unless x == '-'
19
+ end
20
+ end
21
+ end
22
+
10
23
  [Dummy, Fluffy].each{|c| c.send :include, ProtectionCase}
11
24
 
12
25
  Dummy.create! string: 'zomgstring', number: 999, text: 'zomgtext'
@@ -127,7 +140,7 @@ if defined?(ActiveRecord)
127
140
 
128
141
  context "joined to filtered association" do
129
142
  it "scopes" do
130
- d = Dummy.restrict!('+').includes(:fluffies).where(fluffies: {number: 777})
143
+ d = Dummy.restrict!('+').includes(:fluffies).where(fluffies: {string: 'zomgstring'})
131
144
  d.length.should == 2
132
145
  d.first.fluffies.length.should == 1
133
146
  end
@@ -136,18 +149,18 @@ if defined?(ActiveRecord)
136
149
  context "joined to plain association" do
137
150
  it "scopes" do
138
151
  d = Dummy.restrict!('+').includes(:bobbies, :fluffies).where(
139
- bobbies: {number: 777}, fluffies: {number: 777}
152
+ bobbies: {string: 'zomgstring'}, fluffies: {string: 'zomgstring'}
140
153
  )
141
154
  d.length.should == 2
142
155
  d.first.fluffies.length.should == 1
143
- d.first.bobbies.length.should == 1
156
+ d.first.bobbies.length.should == 2
144
157
  end
145
158
  end
146
159
 
147
160
  context "with complex include" do
148
161
  it "scopes" do
149
162
  d = Dummy.restrict!('+').includes(fluffies: :loony).where(
150
- fluffies: {number: 777},
163
+ fluffies: {string: 'zomgstring'},
151
164
  loonies: {string: 'zomgstring'}
152
165
  )
153
166
  d.length.should == 2