protector 0.1.1 → 0.2.1

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,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