├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── .yardopts ├── Appraisals ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── AR_3.2.gemfile ├── AR_4.1.gemfile ├── AR_4.gemfile ├── AR_edge.gemfile ├── Rails_3.2.gemfile ├── Rails_4.gemfile └── Sequel.gemfile ├── lib ├── protector.rb └── protector │ ├── adapters │ ├── active_record.rb │ ├── active_record │ │ ├── association.rb │ │ ├── base.rb │ │ ├── collection_proxy.rb │ │ ├── preloader.rb │ │ ├── relation.rb │ │ ├── singular_association.rb │ │ ├── strong_parameters.rb │ │ └── validations.rb │ ├── sequel.rb │ └── sequel │ │ ├── dataset.rb │ │ ├── eager_graph_loader.rb │ │ └── model.rb │ ├── dsl.rb │ ├── engine.rb │ └── version.rb ├── locales ├── de.yml ├── en.yml └── ru.yml ├── migrations ├── active_record.rb └── sequel.rb ├── perf ├── active_record_perf.rb ├── perf_helpers │ └── boot.rb └── sequel_perf.rb ├── protector.gemspec └── spec ├── internal ├── config │ └── database.yml └── db │ └── schema.rb ├── lib └── protector │ ├── adapters │ ├── active_record_spec.rb │ └── sequel_spec.rb │ ├── dsl_spec.rb │ └── engine_spec.rb └── spec_helpers ├── adapters ├── active_record.rb └── sequel.rb ├── boot.rb ├── contexts └── paranoid.rb └── examples └── model.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | gemfiles/*.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | spec/internal/log 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --tty 2 | --color 3 | --format progress 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Excludes: 3 | - spec/** 4 | - perf/** 5 | - migrations/** 6 | 7 | LineLength: 8 | Enabled: false 9 | 10 | SpaceAroundEqualsInParameterDefault: 11 | Enabled: false 12 | 13 | Documentation: 14 | Enabled: false 15 | 16 | MethodLength: 17 | Enabled: false 18 | 19 | ClassLength: 20 | Enabled: false 21 | 22 | TrivialAccessors: 23 | ExactNameMatch: true 24 | 25 | ParameterLists: 26 | Max: 6 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.9.3 3 | - jruby-19mode 4 | - 2.0.0 5 | - 2.1.0 6 | 7 | matrix: 8 | allow_failures: 9 | - gemfile: gemfiles/AR_edge.gemfile 10 | 11 | gemfile: 12 | - gemfiles/AR_3.2.gemfile 13 | - gemfiles/AR_4.gemfile 14 | - gemfiles/AR_edge.gemfile 15 | - gemfiles/Rails_3.2.gemfile 16 | - gemfiles/Rails_4.gemfile 17 | - gemfiles/Sequel.gemfile 18 | 19 | script: bundle exec rspec 20 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "AR_3.2" do 2 | gem "activerecord", "3.2.9", require: "active_record" 3 | gem "activerecord-jdbcsqlite3-adapter", platform: :jruby, github: "jruby/activerecord-jdbc-adapter" 4 | end 5 | 6 | appraise "AR_4" do 7 | gem "activerecord", "4.0", require: "active_record" 8 | gem "activerecord-jdbcsqlite3-adapter", platform: :jruby, github: "jruby/activerecord-jdbc-adapter" 9 | end 10 | 11 | appraise "AR_4.1" do 12 | gem "activerecord", "4.1.0.rc1", require: "active_record" 13 | gem "activerecord-jdbcsqlite3-adapter", platform: :jruby, github: "jruby/activerecord-jdbc-adapter" 14 | end 15 | 16 | appraise "AR_edge" do 17 | gem "activerecord", require: "active_record", github: "rails/rails" 18 | gem "activemodel", github: "rails/rails" 19 | gem "activesupport", github: "rails/rails" 20 | gem "arel", github: "rails/arel" 21 | gem "activerecord-jdbcsqlite3-adapter", platform: :jruby, github: "jruby/activerecord-jdbc-adapter" 22 | end 23 | 24 | appraise "Rails_3.2" do 25 | gem "combustion", github: "pat/combustion", ref: "50a946b5a7ab3d9249f0e5fcebbb73488a91b1e5" 26 | gem "rails", "3.2.13" 27 | gem "strong_parameters" 28 | gem "activerecord-jdbcsqlite3-adapter", platform: :jruby, github: "jruby/activerecord-jdbc-adapter" 29 | end 30 | 31 | appraise "Rails_4" do 32 | gem "combustion", github: "pat/combustion", ref: "50a946b5a7ab3d9249f0e5fcebbb73488a91b1e5" 33 | gem "rails", "4.0.0" 34 | gem "activerecord-jdbcsqlite3-adapter", platform: :jruby, github: "jruby/activerecord-jdbc-adapter" 35 | end 36 | 37 | appraise "Sequel" do 38 | gem "sequel", "3.30.0" 39 | end 40 | 41 | # appraise "Mongoid" do 42 | # gem "mongoid", ">= 3.1.4" 43 | # end 44 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'rake' 5 | gem 'colored' 6 | gem 'pry' 7 | gem 'rspec' 8 | gem 'simplecov', require: false 9 | gem 'simplecov-summary' 10 | 11 | gem 'appraisal', github: 'thoughtbot/appraisal' 12 | 13 | gem 'sqlite3', platform: :ruby 14 | gem 'jdbc-sqlite3', platform: :jruby, require: 'jdbc/sqlite3' 15 | 16 | gem 'ruby-prof', platform: :ruby 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Boris Staal 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Protector 2 | 3 | [![Gem Version](https://badge.fury.io/rb/protector.png)](http://badge.fury.io/rb/protector) 4 | [![Build Status](https://travis-ci.org/inossidabile/protector.png?branch=master)](https://travis-ci.org/inossidabile/protector) 5 | [![Code Climate](https://codeclimate.com/github/inossidabile/protector.png)](https://codeclimate.com/github/inossidabile/protector) 6 | 7 | Protector is a Ruby ORM extension for managing security restrictions on a field level. The gem favors white-listing over black-listing (everything is disallowed by default), convention over configuration and is duck-type compatible with most of existing code. 8 | 9 | Currently Protector supports the following ORM adapters: 10 | 11 | * [ActiveRecord](http://guides.rubyonrails.org/active_record_querying.html) (>= 3.2.9) 12 | * [Sequel](http://sequel.rubyforge.org/) (>= 3.30.0) 13 | 14 | We are working hard to extend the list with: 15 | 16 | * [Mongoid](http://mongoid.org/en/mongoid/index.html) 17 | * [ROM](https://github.com/rom-rb/rom) 18 | 19 | ## Compatibility 20 | 21 | Protector is an extension and therefore hides deeply inside your ORM library making itself compatible to the most gems you use. Sometimes however, you might need additional integration to take the best from it: 22 | 23 | * [Protector and Strong Parameters](https://github.com/inossidabile/protector/wiki/Protector-and-Strong-Parameters) 24 | * [Protector and InheritedResources](https://github.com/inossidabile/protector/wiki/Protector-and-Inherited-Resources) 25 | * [Protector and CanCan](https://github.com/inossidabile/protector/wiki/Protector-and-CanCan) 26 | * [Protector and SimpleForm](https://github.com/inossidabile/protector/wiki/Protector-and-SimpleForm) 27 | 28 | ## Basics 29 | 30 | DSL of Protector is a Ruby block (or several) describing ACL separated into contexts (authorized user is a very typical example of a context). Each time the context of model changes, DSL blocks reevaluate internally to get an actual ACL that is then utilized internally to cut restricted actions. 31 | 32 | Protector follows nondestructive blocking strategy. It returns `nil` when the forbidden field is requested and only checks creation (modification) capability during persisting. Even more: the latter is implemented as a model validation so it will seamlessly integrate into your typical workflow. 33 | 34 | This example is based on ActiveRecord but the code is mostly identical for any supported adapter. 35 | 36 | ```ruby 37 | class Article < ActiveRecord::Base # Fields: title, text, user_id, hidden 38 | protect do |user| # `user` is a context of security 39 | 40 | if user.admin? 41 | scope { all } # Admins can retrieve anything 42 | 43 | can :read # ... and view anything 44 | can :create # ... and create anything 45 | can :update # ... and update anything 46 | can :destroy # ... and they can delete 47 | else 48 | scope { where(hidden: false) } # Non-admins can only read insecure data 49 | 50 | can :read # Allow to read any field 51 | if user.nil? # User is unknown and therefore not authenticated 52 | cannot :read, :text # Guests can't read the text 53 | end 54 | 55 | can :create, %w(title text) # Non-admins can't set `hidden` flag 56 | can :create, user_id: labmda{|x| # ... and should correctly fill 57 | x == user.id # ... the `user_id` association 58 | } 59 | 60 | # In this setup non-admins can not destroy or update existing records. 61 | end 62 | end 63 | end 64 | ``` 65 | 66 | Inside your model, you can have several `protect` calls that will get merged. Using this you can move basic rules to a separate module to keep code DRY. 67 | 68 | Now that we have ACL described we can enable it as easy as: 69 | 70 | ```ruby 71 | article.restrict!(current_user) # Assuming article is an instance of Article 72 | ``` 73 | 74 | If `current_user` is a guest we will get `nil` from `article.text`. At the same time we will get validation error if we pass any fields but title, text and user_id (equal to our own id) on creation. 75 | 76 | To make model unsafe again call: 77 | 78 | ```ruby 79 | article.unrestrict! 80 | ``` 81 | 82 | **Both methods are chainable!** 83 | 84 | ## Scopes 85 | 86 | Besides the `can` and `cannot` directives Protector also handles relations visibility. In the previous sample the following block is responsible to make hidden articles actually hide: 87 | 88 | ```ruby 89 | scope { where(hidden: false) } # Non-admins can only read unsecure data 90 | ```` 91 | 92 | Make sure to write the block content of the `scope` directive in the notation of your ORM library. 93 | 94 | To finally utilize this function use the same `restrict!` method on a level of Class or Relation. Like this: 95 | 96 | ```ruby 97 | Article.restrict!(current_user).where(...) 98 | # OR 99 | Article.where(...).restrict!(current_user) 100 | ``` 101 | 102 | Be aware that if you already made the database query the scope has no effect on the already fatched data. This is because Protector is working on two levels: first during retrieval (scops are applied here) and after that on the level of fields. So for example `find` and `restrict!` calls are not commutative: 103 | ```ruby 104 | # Should be used if you are using scops for visibility restriction 105 | Article.restrict!(current_user).find(3) 106 | 107 | # not equal! 108 | # Will select the record with id: 3 regardless of any scops and only restrict on the field level 109 | Article.find(3).restrict!(current_user) 110 | ``` 111 | 112 | Note also that you don't need to explicitly restrict models you get from a restricted scope – they born restricted. 113 | 114 | **Important**: unlike fields, scopes follow black-list approach by default. It means that you will NOT restrict selection in any way if no scope was set within protection block! This arguably is the best default strategy. But it's not the only one – see `paranoid` at the [list of available options](https://github.com/inossidabile/protector#options) for details. 115 | 116 | 117 | ## Self-aware conditions 118 | 119 | Sometimes an access decision depends on the object we restrict. `protect` block accepts second argument to fulfill these cases. Keep in mind however that it's not always accessible: we don't have any instance for the restriction of relation and therefore `nil` is passed. 120 | 121 | The following example extends Article to allow users edit their own posts: 122 | 123 | ```ruby 124 | class Article < ActiveRecord::Base # Fields: title, text, user_id, hidden 125 | protect do |user, article| 126 | if user 127 | if article.try(:user_id) == user.id # Checks belonging keeping possible nil in mind 128 | can :update, %w(title text) # Allow authors to modify posts 129 | end 130 | end 131 | end 132 | end 133 | ``` 134 | 135 | ## Associations 136 | 137 | Protector is aware of associations. All the associations retrieved from restricted instance will automatically be restricted to the same context. Therefore you don't have to do anything special – it will respect proper scopes out of the box: 138 | 139 | ```ruby 140 | foo.restrict!(current_user).bar # bar is automatically restricted by `current_user` 141 | ``` 142 | 143 | Remember however that auto-restriction is only enabled for reading. Passing a model (or an array of those) to an association will not auto-restrict it. You should handle it manually. 144 | 145 | The access to `belongs_to` kind of association depends on corresponding foreign key readability. 146 | 147 | ## Eager Loading 148 | 149 | Both of eager loading strategies (separate query and JOIN) are fully supported. 150 | 151 | ## Manual checks and custom actions 152 | 153 | Each restricted model responds to the following methods: 154 | 155 | * `visible?` – determines if the model is visible through restriction scope 156 | * `creatable?` – determines if you pass validation on creation with the fields you set 157 | * `updatable?` – determines if you pass validation on update with the fields you changed 158 | * `destroyable?` – determines if you can destroy the model 159 | 160 | In fact Protector does not limit you to `:read`, `:update` and `:create` actions. They are just used internally. You however can define any other to make custom roles and restrictions. All of them are able to work on a field level. 161 | 162 | ```ruby 163 | protect do 164 | can :drink, :field1 # Allows `drink` action with field1 165 | can :eat # Allows `eat` action with any field 166 | end 167 | ``` 168 | 169 | To check against custom actions use `can?` method: 170 | 171 | ```ruby 172 | model.can?(:drink, :field2) # Checks if model can drink field2 173 | model.can?(:drink) # Checks if model can drink any field 174 | ``` 175 | 176 | As you can see you don't have to use fields. You can use `can :foo` and `can? :foo`. While they will bound to fields internally it will work like you expect for empty sets. 177 | 178 | ## Global switch 179 | 180 | Sometimes for different reasons (like debug or whatever) you might want to run piece of code having Protector totally disabled. There is a way to do that: 181 | 182 | ```ruby 183 | Protector.insecurely do 184 | # anything here 185 | end 186 | ``` 187 | 188 | No matter what happens inside, all your entities will act unprotected. So use with **EXTREME** caution. 189 | 190 | Please note also that we are talking about "unprotected" and "disabled". It does not make `can?` to always return `true`. Instead `can?` would thrown an exception just like it does for any unprotected model. Any other approach makes logic incostitent, unpredictable and just dangerous. There are different possible strategies to isolate business logic from security domain in tests like direct `can?` mocking or forcing admin role to a test user. Use them whenever you want to abstract from security in a whole and `insecurely` when you want to mock a model to the basic security state. 191 | 192 | ## Ideology 193 | 194 | Protector is a successor to [Heimdallr](https://github.com/inossidabile/heimdallr). The latter being a proof-of-concept appeared to be way too paranoid and incompatible with the rest of the world. Protector re-implements same idea keeping the Ruby way: 195 | 196 | * it works inside of the model instead of wrapping it into a proxy: that's why it's compatible with every other extension you use 197 | * it secures persistence and not object properties: you can modify any properties you want but it's not going to let you save what you can not save 198 | * it respects the differentiation between business-logic layer and SQL layer: protection is validation so any method that skips validation will also avoid the security check 199 | 200 | **The last thing is really important to understand. No matter if you can read a field or not, methods like `.pluck` are still capable of reading any of your fields and if you tell your model to skip validation it will also skip an ACL check.** 201 | 202 | ## Installation 203 | 204 | Add this line to your application's Gemfile: 205 | 206 | gem 'protector' 207 | 208 | And then execute: 209 | 210 | $ bundle 211 | 212 | Or install it yourself as: 213 | 214 | $ gem install protector 215 | 216 | As long as you load Protector after an ORM library it is supposed to activate itself automatically. Otherwise you can enable required adapter manually: 217 | 218 | ```ruby 219 | Protector::Adapters::ActiveRecord.activate! 220 | ``` 221 | 222 | Where "ActiveRecord" is the adapter you are about to use. It can be "Sequel", "DataMapper", "Mongoid". 223 | 224 | ## Options 225 | 226 | Use `Protector.config.option = value` to assign an option. Available options are: 227 | 228 | * **paranoid**: makes scope management white-listed. If set to `true` will force Protector to return empty scope when no scope was given within a protection block. 229 | * **strong_parameters**: set to `false` to disable built-in [Strong Parameters integration](https://github.com/inossidabile/protector/wiki/Protector-and-Strong-Parameters). 230 | 231 | Protector features basic Rails integration so you can assign options using `config.protector.option = value` at your `config/*.rb`. 232 | 233 | ## Need help? 234 | 235 | * Use [StackOverflow](http://stackoverflow.com/questions/tagged/protector) Luke! Make sure to use tag `protector`. 236 | * You can get help at [irc.freenode.net](http://freenode.net) #protector.rb. 237 | 238 | ## Maintainers 239 | 240 | * Boris Staal, [@inossidabile](http://staal.io) 241 | 242 | ## License 243 | 244 | It is free software, and may be redistributed under the terms of MIT license. 245 | 246 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/inossidabile/protector/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 247 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | require 'appraisal' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task :default => :all 9 | 10 | desc 'Test the plugin under all supported Rails versions.' 11 | task :all do |t| 12 | exec('bundle exec appraisal rspec') 13 | end 14 | 15 | task :perf do 16 | require 'protector' 17 | 18 | Bundler.require 19 | 20 | %w(ActiveRecord DataMapper Mongoid Sequel).each do |a| 21 | if (a.constantize rescue nil) 22 | load "perf/perf_helpers/boot.rb" 23 | Perf.load a.underscore 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /gemfiles/AR_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "activerecord", "3.2.9", :require=>"active_record" 16 | gem "activerecord-jdbcsqlite3-adapter", :platform=>:jruby, :github=>"jruby/activerecord-jdbc-adapter" 17 | 18 | gemspec :path=>".././" 19 | -------------------------------------------------------------------------------- /gemfiles/AR_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "activerecord", "4.1.0.rc1", :require=>"active_record" 16 | gem "activerecord-jdbcsqlite3-adapter", :platform=>:jruby, :github=>"jruby/activerecord-jdbc-adapter" 17 | 18 | gemspec :path=>".././" 19 | -------------------------------------------------------------------------------- /gemfiles/AR_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "activerecord", "4.0", :require=>"active_record" 16 | gem "activerecord-jdbcsqlite3-adapter", :platform=>:jruby, :github=>"jruby/activerecord-jdbc-adapter" 17 | 18 | gemspec :path=>".././" 19 | -------------------------------------------------------------------------------- /gemfiles/AR_edge.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "activerecord", :require=>"active_record", :github=>"rails/rails" 16 | gem "activemodel", :github=>"rails/rails" 17 | gem "activesupport", :github=>"rails/rails" 18 | gem "arel", :github=>"rails/arel" 19 | gem "activerecord-jdbcsqlite3-adapter", :platform=>:jruby, :github=>"jruby/activerecord-jdbc-adapter" 20 | 21 | gemspec :path=>".././" 22 | -------------------------------------------------------------------------------- /gemfiles/Rails_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "combustion", :github=>"pat/combustion", :ref=>"50a946b5a7ab3d9249f0e5fcebbb73488a91b1e5" 16 | gem "rails", "3.2.13" 17 | gem "strong_parameters" 18 | gem "activerecord-jdbcsqlite3-adapter", :platform=>:jruby, :github=>"jruby/activerecord-jdbc-adapter" 19 | 20 | gemspec :path=>".././" 21 | -------------------------------------------------------------------------------- /gemfiles/Rails_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "combustion", :github=>"pat/combustion", :ref=>"50a946b5a7ab3d9249f0e5fcebbb73488a91b1e5" 16 | gem "rails", "4.0.0" 17 | gem "activerecord-jdbcsqlite3-adapter", :platform=>:jruby, :github=>"jruby/activerecord-jdbc-adapter" 18 | 19 | gemspec :path=>".././" 20 | -------------------------------------------------------------------------------- /gemfiles/Sequel.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "colored" 7 | gem "pry" 8 | gem "rspec" 9 | gem "simplecov", :require=>false 10 | gem "simplecov-summary" 11 | gem "appraisal", :github=>"thoughtbot/appraisal" 12 | gem "sqlite3", :platform=>:ruby 13 | gem "jdbc-sqlite3", :platform=>:jruby, :require=>"jdbc/sqlite3" 14 | gem "ruby-prof", :platform=>:ruby 15 | gem "sequel", "3.30.0" 16 | 17 | gemspec :path=>".././" 18 | -------------------------------------------------------------------------------- /lib/protector.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/all' 2 | require 'i18n' 3 | 4 | require 'protector/version' 5 | require 'protector/dsl' 6 | require 'protector/adapters/active_record' 7 | require 'protector/adapters/sequel' 8 | 9 | require 'protector/engine' if defined?(Rails) 10 | 11 | I18n.load_path += Dir[File.expand_path File.join('..', 'locales', '*.yml'), File.dirname(__FILE__)] 12 | 13 | module Protector 14 | class << self 15 | ADAPTERS = [ 16 | Protector::Adapters::ActiveRecord, 17 | Protector::Adapters::Sequel 18 | ] 19 | 20 | attr_accessor :config 21 | 22 | def paranoid= 23 | '`Protector.paranoid = ...` is deprecated! Please change it to `Protector.config.paranoid = ...`' 24 | end 25 | 26 | # Allows executing any code having Protector globally disabled 27 | def insecurely(&block) 28 | Thread.current[:protector_disabled_nesting] ||= 0 29 | Thread.current[:protector_disabled_nesting] += 1 30 | 31 | Thread.current[:protector_disabled] = true 32 | yield 33 | ensure 34 | Thread.current[:protector_disabled_nesting] -= 1 35 | 36 | if Thread.current[:protector_disabled_nesting] == 0 37 | Thread.current[:protector_disabled] = false 38 | end 39 | end 40 | 41 | def activate! 42 | ADAPTERS.each { |adapter| adapter.activate! } 43 | end 44 | end 45 | 46 | # Internal protector config holder 47 | class Config < ActiveSupport::OrderedOptions 48 | def paranoid? 49 | !!paranoid 50 | end 51 | 52 | def strong_parameters? 53 | strong_parameters.nil? || !!strong_parameters 54 | end 55 | end 56 | 57 | self.config = Config.new 58 | end 59 | 60 | Protector.activate! 61 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'protector/adapters/active_record/base' 2 | require 'protector/adapters/active_record/association' 3 | require 'protector/adapters/active_record/singular_association' 4 | require 'protector/adapters/active_record/relation' 5 | require 'protector/adapters/active_record/collection_proxy' 6 | require 'protector/adapters/active_record/preloader' 7 | require 'protector/adapters/active_record/strong_parameters' 8 | require 'protector/adapters/active_record/validations' 9 | 10 | module Protector 11 | module Adapters 12 | # ActiveRecord adapter 13 | module ActiveRecord 14 | # YIP YIP! Monkey-Patch the ActiveRecord. 15 | def self.activate! 16 | return false unless defined?(::ActiveRecord) 17 | 18 | ::ActiveRecord::Base.send :include, Protector::Adapters::ActiveRecord::Base 19 | ::ActiveRecord::Base.send :include, Protector::Adapters::ActiveRecord::Validations 20 | ::ActiveRecord::Relation.send :include, Protector::Adapters::ActiveRecord::Relation 21 | ::ActiveRecord::Associations::SingularAssociation.send :include, Protector::Adapters::ActiveRecord::Association 22 | ::ActiveRecord::Associations::SingularAssociation.send :include, Protector::Adapters::ActiveRecord::SingularAssociation 23 | ::ActiveRecord::Associations::CollectionAssociation.send :include, Protector::Adapters::ActiveRecord::Association 24 | ::ActiveRecord::Associations::Preloader.send :include, Protector::Adapters::ActiveRecord::Preloader 25 | ::ActiveRecord::Associations::Preloader::Association.send :include, Protector::Adapters::ActiveRecord::Preloader::Association 26 | ::ActiveRecord::Associations::CollectionProxy.send :include, Protector::Adapters::ActiveRecord::CollectionProxy 27 | end 28 | 29 | def self.modern? 30 | Gem::Version.new(::ActiveRecord::VERSION::STRING) >= Gem::Version.new('4.0.0') 31 | end 32 | 33 | def self.is?(instance) 34 | instance.is_a?(::ActiveRecord::Relation) || 35 | (instance.is_a?(Class) && instance < ActiveRecord::Base) 36 | end 37 | 38 | def self.null_proc 39 | # rubocop:disable IndentationWidth, EndAlignment 40 | @null_proc ||= if modern? 41 | proc { none } 42 | else 43 | proc { where('1=0') } 44 | end 45 | # rubocop:enable IndentationWidth, EndAlignment 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/association.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | # Patches `ActiveRecord::Associations::SingularAssociation` and `ActiveRecord::Associations::CollectionAssociation` 5 | module Association 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | include Protector::DSL::Base 10 | 11 | # AR 4 has renamed `scoped` to `scope` 12 | if method_defined?(:scope) 13 | alias_method_chain :scope, :protector 14 | else 15 | alias_method 'scope_without_protector', 'scoped' 16 | alias_method 'scoped', 'scope_with_protector' 17 | end 18 | 19 | alias_method_chain :build_record, :protector 20 | end 21 | 22 | # Wraps every association with current subject 23 | def scope_with_protector(*args) 24 | scope = scope_without_protector(*args) 25 | scope = scope.restrict!(protector_subject) if protector_subject? 26 | scope 27 | end 28 | 29 | # Forwards protection subject to the new instance 30 | def build_record_with_protector(*args) 31 | return build_record_without_protector(*args) unless protector_subject? 32 | build_record_without_protector(*args).restrict!(protector_subject) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/base.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | # Patches `ActiveRecord::Base` 5 | module Base 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | include Protector::DSL::Base 10 | include Protector::DSL::Entry 11 | 12 | before_destroy :protector_ensure_destroyable 13 | 14 | # We need this to make sure no ActiveRecord classes managed 15 | # to cache db scheme and create corresponding methods since 16 | # we want to modify the way they get created 17 | ObjectSpace.each_object(Class).each do |klass| 18 | klass.undefine_attribute_methods if klass < self 19 | end 20 | 21 | # Drops {Protector::DSL::Meta::Box} cache when subject changes 22 | def restrict!(*args) 23 | @protector_meta = nil 24 | super 25 | end 26 | 27 | if !Protector::Adapters::ActiveRecord.modern? 28 | def self.restrict!(*args) 29 | scoped.restrict!(*args) 30 | end 31 | else 32 | def self.restrict!(*args) 33 | all.restrict!(*args) 34 | end 35 | end 36 | 37 | def [](name) 38 | # rubocop:disable ParenthesesAroundCondition 39 | if ( 40 | !protector_subject? || 41 | name == self.class.primary_key || 42 | (self.class.primary_key.is_a?(Array) && self.class.primary_key.include?(name)) || 43 | protector_meta.readable?(name) 44 | ) 45 | read_attribute(name) 46 | else 47 | nil 48 | end 49 | # rubocop:enable ParenthesesAroundCondition 50 | end 51 | 52 | def association(*params) 53 | return super unless protector_subject? 54 | super.restrict!(protector_subject) 55 | end 56 | end 57 | 58 | module ClassMethods 59 | # Storage of {Protector::DSL::Meta} 60 | def protector_meta 61 | ensure_protector_meta!(Protector::Adapters::ActiveRecord) do 62 | column_names 63 | end 64 | end 65 | 66 | # Wraps every `.field` method with a check against {Protector::DSL::Meta::Box#readable?} 67 | def define_method_attribute(name) 68 | super 69 | 70 | # Show some <3 to composite primary keys 71 | unless primary_key == name || Array(primary_key).include?(name) 72 | generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 73 | alias_method #{"#{name}_unprotected".inspect}, #{name.inspect} 74 | 75 | def #{name} 76 | if !protector_subject? || protector_meta.readable?(#{name.inspect}) 77 | #{name}_unprotected 78 | else 79 | nil 80 | end 81 | end 82 | STR 83 | end 84 | end 85 | end 86 | 87 | # Gathers real changed values bypassing restrictions 88 | def protector_changed 89 | HashWithIndifferentAccess[changed.map { |field| [field, read_attribute(field)] }] 90 | end 91 | 92 | # Storage for {Protector::DSL::Meta::Box} 93 | def protector_meta(subject=protector_subject) 94 | @protector_meta ||= self.class.protector_meta.evaluate(subject, self) 95 | end 96 | 97 | # Checks if current model can be selected in the context of current subject 98 | def visible? 99 | return true unless protector_meta.scoped? 100 | 101 | protector_meta.relation.where( 102 | self.class.primary_key => id 103 | ).any? 104 | end 105 | 106 | # Checks if current model can be created in the context of current subject 107 | def creatable? 108 | protector_meta.creatable? protector_changed 109 | end 110 | 111 | # Checks if current model can be updated in the context of current subject 112 | def updatable? 113 | protector_meta.updatable? protector_changed 114 | end 115 | 116 | # Checks if current model can be destroyed in the context of current subject 117 | def destroyable? 118 | protector_meta.destroyable? 119 | end 120 | 121 | def can?(action, field=false) 122 | protector_meta.can?(action, field) 123 | end 124 | 125 | private 126 | def protector_ensure_destroyable 127 | return true unless protector_subject? 128 | destroyable? 129 | end 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/collection_proxy.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | # Patches `ActiveRecord::Associations::CollectionProxy` 5 | module CollectionProxy 6 | extend ActiveSupport::Concern 7 | delegate :protector_subject, :protector_subject?, :to => :@association 8 | 9 | def restrict!(*args) 10 | @association.restrict!(*args) 11 | self 12 | end 13 | end 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/preloader.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | # Patches `ActiveRecord::Associations::Preloader` 5 | module Preloader extend ActiveSupport::Concern 6 | 7 | # Patches `ActiveRecord::Associations::Preloader::Association` 8 | module Association extend ActiveSupport::Concern 9 | included do 10 | # AR 4 has renamed `scoped` to `scope` 11 | if method_defined?(:scope) 12 | alias_method_chain :scope, :protector 13 | else 14 | alias_method 'scope_without_protector', 'scoped' 15 | alias_method 'scoped', 'scope_with_protector' 16 | end 17 | end 18 | 19 | # Gets current subject of preloading association 20 | def protector_subject 21 | # Owners are always loaded from the single source 22 | # having same protector_subject 23 | owners.first.protector_subject 24 | end 25 | 26 | def protector_subject? 27 | owners.first.protector_subject? 28 | end 29 | 30 | # Restricts preloading association scope with subject of the owner 31 | def scope_with_protector(*args) 32 | return scope_without_protector unless protector_subject? 33 | 34 | @meta ||= klass.protector_meta.evaluate(protector_subject) 35 | 36 | scope_without_protector.merge(@meta.relation) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/relation.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | # Patches `ActiveRecord::Relation` 5 | module Relation 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | include Protector::DSL::Base 10 | 11 | alias_method_chain :exec_queries, :protector 12 | alias_method_chain :new, :protector 13 | alias_method_chain :create, :protector 14 | alias_method_chain :create!, :protector 15 | 16 | # AR 3.2 workaround. Come on, guys... SQL parsing :( 17 | unless method_defined?(:references_values) 18 | def references_values 19 | tables_in_string(to_sql) 20 | end 21 | end 22 | 23 | unless method_defined?(:includes!) 24 | def includes!(*args) 25 | self.includes_values += args 26 | self 27 | end 28 | end 29 | end 30 | 31 | def creatable? 32 | new.creatable? 33 | end 34 | 35 | def can?(action, field=false) 36 | protector_meta.can?(action, field) 37 | end 38 | 39 | # Gets {Protector::DSL::Meta::Box} of this relation 40 | def protector_meta(subject=protector_subject) 41 | @klass.protector_meta.evaluate(subject) 42 | end 43 | 44 | def protector_relation 45 | result = self.clone 46 | result = protector_meta.eval_scope_procs(result) if protector_meta.relation 47 | result 48 | end 49 | 50 | # @note Unscoped relation drops properties and therefore should be re-restricted 51 | def unscoped 52 | return super unless protector_subject? 53 | super.restrict!(protector_subject) 54 | end 55 | 56 | def except(*args) 57 | return super unless protector_subject? 58 | super.restrict!(protector_subject) 59 | end 60 | 61 | def only(*args) 62 | return super unless protector_subject? 63 | super.restrict!(protector_subject) 64 | end 65 | 66 | # @note This is here cause `NullRelation` can return `nil` from `count` 67 | def count(*args) 68 | super || 0 69 | end 70 | 71 | # @note This is here cause `NullRelation` can return `nil` from `sum` 72 | def sum(*args) 73 | super || 0 74 | end 75 | 76 | # Merges current relation with restriction and calls real `calculate` 77 | def calculate(*args) 78 | return super unless protector_subject? 79 | protector_relation.unrestrict!.calculate(*args) 80 | end 81 | 82 | # Merges current relation with restriction and calls real `exists?` 83 | def exists?(*args) 84 | return super unless protector_subject? 85 | protector_relation.unrestrict!.exists?(*args) 86 | end 87 | 88 | # Forwards protection subject to the new instance 89 | def new_with_protector(*args, &block) 90 | return new_without_protector(*args, &block) unless protector_subject? 91 | 92 | protector_permit_strong_params(args) 93 | 94 | unless block_given? 95 | new_without_protector(*args).restrict!(protector_subject) 96 | else 97 | new_without_protector(*args) do |instance| 98 | block.call instance.restrict!(protector_subject) 99 | end 100 | end 101 | end 102 | 103 | def create_with_protector(*args, &block) 104 | return create_without_protector(*args, &block) unless protector_subject? 105 | 106 | protector_permit_strong_params(args) 107 | 108 | create_without_protector(*args) do |instance| 109 | instance.restrict!(protector_subject) 110 | block.call(instance) if block 111 | end 112 | end 113 | 114 | def create_with_protector!(*args, &block) 115 | return create_without_protector!(*args, &block) unless protector_subject? 116 | 117 | protector_permit_strong_params(args) 118 | 119 | create_without_protector!(*args) do |instance| 120 | instance.restrict!(protector_subject) 121 | block.call(instance) if block 122 | end 123 | end 124 | 125 | # Patches current relation to fulfill restriction and call real `exec_queries` 126 | # 127 | # Patching includes: 128 | # 129 | # * turning `includes` (that are not referenced for eager loading) into `preload` 130 | # * delaying built-in preloading to the stage where selection is restricted 131 | # * merging current relation with restriction (of self and every eager association) 132 | def exec_queries_with_protector(*args) 133 | return @records if loaded? 134 | return exec_queries_without_protector unless protector_subject? 135 | 136 | subject = protector_subject 137 | relation = protector_relation.unrestrict! 138 | relation = protector_substitute_includes(subject, relation) 139 | 140 | # Preserve associations from internal loading. We are going to handle that 141 | # ourselves respecting security scopes FTW! 142 | associations, relation.preload_values = relation.preload_values, [] 143 | 144 | @records = relation.send(:exec_queries).each { |record| record.restrict!(subject) } 145 | 146 | # Now we have @records restricted properly so let's preload associations! 147 | associations.each do |association| 148 | if ::ActiveRecord::Associations::Preloader.method_defined? :preload 149 | ::ActiveRecord::Associations::Preloader.new.preload(@records, association) 150 | else 151 | ::ActiveRecord::Associations::Preloader.new(@records, association).run 152 | end 153 | end 154 | 155 | @loaded = true 156 | @records 157 | end 158 | 159 | # Swaps `includes` with `preload` if it's not referenced or merges 160 | # security scope of proper class otherwise 161 | def protector_substitute_includes(subject, relation) 162 | if relation.eager_loading? 163 | protector_expand_inclusion(relation.includes_values + relation.eager_load_values).each do |klass, path| 164 | # AR drops default_scope for eagerly loadable associations 165 | # https://github.com/inossidabile/protector/issues/3 166 | # and so should we 167 | meta = klass.protector_meta.evaluate(subject) 168 | 169 | if meta.scoped? 170 | unscoped = klass.unscoped 171 | 172 | # `unscoped` gets us a relation but Protector scope is supposed 173 | # to work with AR::Base. Some versions of AR have those uncompatible 174 | # so we have to workaround it :( 175 | unscoped.protector_mimic_base! 176 | 177 | # Finally we merge unscoped basic relation extended with protection scope 178 | relation = relation.merge meta.eval_scope_procs(unscoped) 179 | end 180 | end 181 | else 182 | relation.preload_values += includes_values 183 | relation.includes_values = [] 184 | end 185 | 186 | relation 187 | end 188 | 189 | # Makes instance of Relation duck-type compatible to AR::Base to allow proper 190 | # protection block execution with itself 191 | def protector_mimic_base! 192 | return unless Protector::Adapters::ActiveRecord.modern? 193 | 194 | class < a } } 253 | ] 254 | end 255 | end 256 | 257 | results << [model, base.reduce(key) { |a, n| { n => a } }] 258 | end 259 | end 260 | end 261 | end 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/singular_association.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | # Patches `ActiveRecord::Associations::SingularAssociation` 5 | module SingularAssociation 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | alias_method_chain :reader, :protector 10 | end 11 | 12 | # Reader has to be explicitly overrided for cases when the 13 | # loaded association is cached 14 | def reader_with_protector(*args) 15 | return reader_without_protector(*args) unless protector_subject? 16 | reader_without_protector(*args).try :restrict!, protector_subject 17 | end 18 | 19 | # Forwards protection subject to the new instance 20 | def build_record_with_protector(*args) 21 | return build_record_without_protector(*args) unless protector_subject? 22 | build_record_without_protector(*args).restrict!(protector_subject) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/strong_parameters.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module ActiveRecord 3 | module Adapters 4 | module StrongParameters 5 | def self.sanitize!(args, is_new, meta) 6 | return if args[0].permitted? 7 | if is_new 8 | args[0] = args[0].permit(*meta.access[:create].keys) if meta.access.include? :create 9 | else 10 | args[0] = args[0].permit(*meta.access[:update].keys) if meta.access.include? :update 11 | end 12 | end 13 | 14 | # strong_parameters integration 15 | def sanitize_for_mass_assignment(*args) 16 | # We check only for updation here since the creation will be handled by relation 17 | # (see Protector::Adapters::ActiveRecord::Relation#new_with_protector and 18 | # Protector::Adapters::ActiveRecord::Relation#create_with_protector) 19 | if Protector.config.strong_parameters? && args.first.respond_to?(:permit) \ 20 | && !new_record? && protector_subject? 21 | 22 | StrongParameters.sanitize! args, false, protector_meta 23 | end 24 | 25 | super 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/protector/adapters/active_record/validations.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module Adapters 3 | module ActiveRecord 4 | module Validations 5 | def valid?(*args) 6 | if protector_subject? 7 | state = Protector.insecurely{ super(*args) } 8 | method = new_record? ? :first_uncreatable_field : :first_unupdatable_field 9 | field = protector_meta.send(method, protector_changed) 10 | 11 | if field 12 | errors[:base] << I18n.t('protector.invalid', field: field) 13 | state = false 14 | end 15 | 16 | state 17 | else 18 | super(*args) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/protector/adapters/sequel.rb: -------------------------------------------------------------------------------- 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 | return false unless defined?(::Sequel) 12 | 13 | ::Sequel::Model.send :include, Protector::Adapters::Sequel::Model 14 | ::Sequel::Dataset.send :include, Protector::Adapters::Sequel::Dataset 15 | ::Sequel::Model::Associations::EagerGraphLoader.send :include, Protector::Adapters::Sequel::EagerGraphLoader 16 | end 17 | 18 | def self.is?(instance) 19 | instance.kind_of?(::Sequel::Dataset) || 20 | (instance.kind_of?(Class) && instance < ::Sequel::Model) 21 | end 22 | 23 | def self.null_proc 24 | @null_proc ||= proc { where('1=0') } 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/protector/adapters/sequel/dataset.rb: -------------------------------------------------------------------------------- 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 unless 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 | def creatable? 34 | model.new.restrict!(protector_subject).creatable? 35 | end 36 | 37 | def can?(action, field=false) 38 | protector_meta.can?(action, field) 39 | end 40 | 41 | # Gets {Protector::DSL::Meta::Box} of this dataset 42 | def protector_meta(subject=protector_subject) 43 | model.protector_meta.evaluate(subject) 44 | end 45 | 46 | # Substitutes `row_proc` with {Protector} and injects protection scope 47 | def each_with_protector(*args, &block) 48 | return each_without_protector(*args, &block) unless protector_subject? 49 | 50 | relation = protector_defend_graph(clone, protector_subject) 51 | relation = protector_meta.eval_scope_procs(relation) if protector_meta.scoped? 52 | 53 | relation.row_proc = Restrictor.new(protector_subject, relation.row_proc) 54 | relation.each_without_protector(*args, &block) 55 | end 56 | 57 | # Injects protection scope for every joined graph association 58 | def protector_defend_graph(relation, subject) 59 | return relation unless @opts[:eager_graph] 60 | 61 | @opts[:eager_graph][:reflections].each do |association, reflection| 62 | model = reflection[:cache][:class] if reflection[:cache].is_a?(Hash) && reflection[:cache][:class] 63 | model = reflection[:class_name].constantize unless model 64 | meta = model.protector_meta.evaluate(subject) 65 | 66 | relation = meta.eval_scope_procs(relation) if meta.scoped? 67 | end 68 | 69 | relation 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/protector/adapters/sequel/eager_graph_loader.rb: -------------------------------------------------------------------------------- 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.key?(k) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/protector/adapters/sequel/model.rb: -------------------------------------------------------------------------------- 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 | # Storage of {Protector::DSL::Meta} 20 | def protector_meta 21 | ensure_protector_meta!(Protector::Adapters::Sequel) do 22 | columns 23 | end 24 | end 25 | 26 | # Gets default restricted `Dataset` 27 | def restrict!(*args) 28 | dataset.clone.restrict!(*args) 29 | end 30 | end 31 | 32 | # Gathers real values of given fields bypassing restrictions 33 | def protector_changed(fields) 34 | HashWithIndifferentAccess[fields.map { |x| [x.to_s, @values[x]] }] 35 | end 36 | 37 | # Storage for {Protector::DSL::Meta::Box} 38 | def protector_meta(subject=protector_subject) 39 | @protector_meta ||= self.class.protector_meta.evaluate(subject, self) 40 | end 41 | 42 | # Checks if current model can be selected in the context of current subject 43 | def visible? 44 | return true unless protector_meta.scoped? 45 | protector_meta.relation.where(pk_hash).any? 46 | end 47 | 48 | # Checks if current model can be created in the context of current subject 49 | def creatable? 50 | protector_meta.creatable? protector_changed(keys) 51 | end 52 | 53 | # Checks if current model can be updated in the context of current subject 54 | def updatable? 55 | protector_meta.updatable? protector_changed(changed_columns) 56 | end 57 | 58 | # Checks if current model can be destroyed in the context of current subject 59 | def destroyable? 60 | protector_meta.destroyable? 61 | end 62 | 63 | def can?(action, field=false) 64 | protector_meta.can?(action, field) 65 | end 66 | 67 | # Basic security validations 68 | def validate 69 | super 70 | return unless protector_subject? 71 | 72 | # rubocop:disable IndentationWidth, EndAlignment 73 | field = if new? 74 | protector_meta.first_uncreatable_field protector_changed(keys) 75 | else 76 | protector_meta.first_unupdatable_field protector_changed(changed_columns) 77 | end 78 | # rubocop:enable IndentationWidth, EndAlignment 79 | 80 | errors.add :base, I18n.t('protector.invalid', field: field) if field 81 | end 82 | 83 | # Destroy availability check 84 | def before_destroy 85 | return false if protector_subject? && !destroyable? 86 | super 87 | end 88 | 89 | # Security-checking attributes reader 90 | # 91 | # @param name [Symbol] Name of attribute to read 92 | def [](name) 93 | # rubocop:disable ParenthesesAroundCondition 94 | if ( 95 | !protector_subject? || 96 | name == self.class.primary_key || 97 | (self.class.primary_key.is_a?(Array) && self.class.primary_key.include?(name)) || 98 | protector_meta.readable?(name.to_s) 99 | ) 100 | @values[name.to_sym] 101 | else 102 | nil 103 | end 104 | # rubocop:enable ParenthesesAroundCondition 105 | end 106 | 107 | # This is used whenever we fetch data 108 | def _associated_dataset(*args) 109 | return super unless protector_subject? 110 | super.restrict!(protector_subject) 111 | end 112 | 113 | # This is used whenever we call counters and existance checkers 114 | def _dataset(*args) 115 | return super unless protector_subject? 116 | super.restrict!(protector_subject) 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/protector/dsl.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | module DSL 3 | # DSL meta storage and evaluator 4 | class Meta 5 | 6 | # Single DSL evaluation result 7 | class Box 8 | attr_accessor :adapter, :access, :destroyable 9 | 10 | # @param model [Class] The class of protected entity 11 | # @param fields [Array] All the fields the model has 12 | # @param subject [Object] Restriction subject 13 | # @param entry [Object] An instance of the model 14 | # @param blocks [Array] An array of `protect` blocks 15 | def initialize(adapter, model, fields, subject, entry, blocks) 16 | @adapter = adapter 17 | @model = model 18 | @fields = fields 19 | @access = {} 20 | @scope_procs = [] 21 | @destroyable = false 22 | 23 | Protector.insecurely do 24 | blocks.each do |b| 25 | case b.arity 26 | when 2 27 | instance_exec(subject, entry, &b) 28 | when 1 29 | instance_exec(subject, &b) 30 | else 31 | instance_exec(&b) 32 | end 33 | end 34 | end 35 | end 36 | 37 | # Checks whether protection with given subject 38 | # has the selection scope defined 39 | def scoped? 40 | Protector.config.paranoid? || @scope_procs.length > 0 41 | end 42 | 43 | # @group Protection DSL 44 | 45 | # Activates the scope that selections will 46 | # be filtered with 47 | # 48 | # @yield Calls given model methods before the selection 49 | # 50 | # @example 51 | # protect do 52 | # # You can select nothing! 53 | # scope { none } 54 | # end 55 | def scope(&block) 56 | @scope_procs << block 57 | @relation = false 58 | end 59 | 60 | def scope_procs 61 | return [@adapter.null_proc] if @scope_procs.empty? && Protector.config.paranoid? 62 | @scope_procs 63 | end 64 | 65 | def relation 66 | return false unless scoped? 67 | 68 | @relation ||= eval_scope_procs @model 69 | end 70 | 71 | def eval_scope_procs(instance) 72 | scope_procs.reduce(instance) do |relation, scope_proc| 73 | relation.instance_eval(&scope_proc) 74 | end 75 | end 76 | 77 | # Enables action for given fields. 78 | # 79 | # Built-in possible actions are: `:read`, `:update`, `:create`. 80 | # You can pass any other actions you want to use with {#can?} afterwards. 81 | # 82 | # **The method enables action for every field if `fields` splat is empty.** 83 | # Use {#cannot} to exclude some of them afterwards. 84 | # 85 | # The list of fields can be given as a Hash. In this form you can pass `Range` 86 | # or `Proc` as a value. First will make Protector check against value inclusion. 87 | # The latter will make it evaluate given lambda (which is supposed to return true or false 88 | # determining if the value should validate or not). 89 | # 90 | # @param action [Symbol] Action to allow 91 | # @param fields [String, Hash, Array] Splat of fields to allow action with 92 | # 93 | # @see #can? 94 | # 95 | # @example 96 | # protect do 97 | # can :read # Can read any field 98 | # can :read, 'f1' # Can read `f1` field 99 | # can :read, %w(f2 f3) # Can read `f2`, `f3` fields 100 | # can :update, f1: 1..2 # Can update f1 field with values between 1 and 2 101 | # 102 | # # Can create f1 field with value equal to 'olo' 103 | # can :create, f1: lambda{|x| x == 'olo'} 104 | # end 105 | def can(action, *fields) 106 | action = deprecate_actions(action) 107 | 108 | return @destroyable = true if action == :destroy 109 | 110 | @access[action] = {} unless @access[action] 111 | 112 | if fields.length == 0 113 | @fields.each { |f| @access[action][f.to_s] = nil } 114 | else 115 | fields.each do |a| 116 | if a.is_a?(Array) 117 | a.each { |f| @access[action][f.to_s] = nil } 118 | elsif a.is_a?(Hash) 119 | @access[action].merge!(a.stringify_keys) 120 | else 121 | @access[action][a.to_s] = nil 122 | end 123 | end 124 | end 125 | end 126 | 127 | # Disables action for given fields. 128 | # 129 | # Works similar (but oppositely) to {#can}. 130 | # 131 | # @param action [Symbol] Action to disallow 132 | # @param fields [String, Hash, Array] Splat of fields to disallow action with 133 | # 134 | # @see #can 135 | # @see #can? 136 | def cannot(action, *fields) 137 | action = deprecate_actions(action) 138 | 139 | return @destroyable = false if action == :destroy 140 | 141 | return unless @access[action] 142 | 143 | if fields.length == 0 144 | @access.delete(action) 145 | else 146 | fields.each do |a| 147 | if a.is_a?(Array) 148 | a.each { |f| @access[action].delete(f.to_s) } 149 | else 150 | @access[action].delete(a.to_s) 151 | end 152 | end 153 | 154 | @access.delete(action) if @access[action].empty? 155 | end 156 | end 157 | 158 | # @endgroup 159 | 160 | # Checks whether given field of a model is readable in context of current subject 161 | def readable?(field) 162 | @access[:read] && @access[:read].key?(field.to_s) 163 | end 164 | 165 | # Checks whether you can create a model with given field in context of current subject 166 | def creatable?(fields=false) 167 | modifiable? :create, fields 168 | end 169 | 170 | def first_uncreatable_field(fields) 171 | first_unmodifiable_field :create, fields 172 | end 173 | 174 | # Checks whether you can update a model with given field in context of current subject 175 | def updatable?(fields=false) 176 | modifiable? :update, fields 177 | end 178 | 179 | def first_unupdatable_field(fields) 180 | first_unmodifiable_field :update, fields 181 | end 182 | 183 | # Checks whether you can destroy a model in context of current subject 184 | def destroyable? 185 | @destroyable 186 | end 187 | 188 | # Check whether you can perform custom action for given fields (or generally if no `field` given) 189 | # 190 | # @param [Symbol] action Action to check against 191 | # @param [String] field Field to check against 192 | def can?(action, field=false) 193 | return destroyable? if action == :destroy 194 | 195 | return false unless @access[action] 196 | return !@access[action].empty? unless field 197 | 198 | @access[action].key?(field.to_s) 199 | end 200 | 201 | def cannot?(*args) 202 | !can?(*args) 203 | end 204 | 205 | private 206 | 207 | def first_unmodifiable_field(part, fields) 208 | return (fields.keys.first || '-') unless @access[part] 209 | 210 | diff = fields.keys - @access[part].keys 211 | return diff.first if diff.length > 0 212 | 213 | fields.each do |k, v| 214 | case x = @access[part][k] 215 | when Enumerable 216 | return k unless x.include?(v) 217 | when Proc 218 | return k unless Protector.insecurely{ x.call(v) } 219 | else 220 | return k if !x.nil? && x != v 221 | end 222 | end 223 | 224 | false 225 | end 226 | 227 | def modifiable?(part, fields=false) 228 | return false unless @access[part] 229 | return false if fields && first_unmodifiable_field(part, fields) 230 | true 231 | end 232 | 233 | def deprecate_actions(action) 234 | if action == :view 235 | ActiveSupport::Deprecation.warn ":view rule has been deprecated and replaced with :read! "+ 236 | "Starting from version 1.0 :view will be treated as a custom rule." 237 | 238 | :read 239 | else 240 | action 241 | end 242 | end 243 | end 244 | 245 | def initialize(adapter, model, &fields_proc) 246 | @adapter = adapter 247 | @model = model 248 | @fields_proc = fields_proc 249 | end 250 | 251 | def fields 252 | @fields ||= @fields_proc.call 253 | end 254 | 255 | # Storage for `protect` blocks 256 | def blocks 257 | @blocks ||= [] 258 | end 259 | 260 | def blocks=(blocks) 261 | @blocks = blocks 262 | end 263 | 264 | # Register another protection block 265 | def <<(block) 266 | blocks << block 267 | end 268 | 269 | def inherit(model, &fields_proc) 270 | clone = self.class.new(@adapter, model, &fields_proc) 271 | clone.blocks = @blocks.clone unless @blocks.nil? 272 | clone 273 | end 274 | 275 | # Calculate protection at the context of subject 276 | # 277 | # @param subject [Object] Restriction subject 278 | # @param entry [Object] An instance of the model 279 | def evaluate(subject, entry=nil) 280 | Box.new(@adapter, @model, fields, subject, entry, blocks) 281 | end 282 | end 283 | 284 | module Base 285 | extend ActiveSupport::Concern 286 | 287 | # Property accessor that makes sure you don't use 288 | # subject on a non-protected model 289 | def protector_subject 290 | unless protector_subject? 291 | fail "Unprotected entity detected for '#{self.class}': use `restrict` method to protect it." 292 | end 293 | 294 | @protector_subject 295 | end 296 | 297 | # Assigns restriction subject 298 | # 299 | # @param [Object] subject Subject to restrict against 300 | def restrict!(subject=nil) 301 | @protector_subject = subject 302 | @protector_subject_set = true 303 | self 304 | end 305 | 306 | # Clears restriction subject 307 | def unrestrict! 308 | @protector_subject = nil 309 | @protector_subject_set = false 310 | self 311 | end 312 | 313 | # Checks if model was restricted 314 | def protector_subject? 315 | @protector_subject_set == true && !Thread.current[:protector_disabled] 316 | end 317 | end 318 | 319 | module Entry 320 | extend ActiveSupport::Concern 321 | 322 | module ClassMethods 323 | # Registers protection DSL block 324 | # @yield [subject, instance] Evaluates conditions described in terms of {Protector::DSL::Meta::Box}. 325 | # @yieldparam subject [Object] Subject that object was restricted with 326 | # @yieldparam instance [Object] Reference to the object being restricted (can be nil) 327 | def protect(&block) 328 | protector_meta << block 329 | end 330 | 331 | def ensure_protector_meta!(adapter, &column_names) 332 | @protector_meta ||= if superclass && superclass.respond_to?(:protector_meta) 333 | superclass.protector_meta.inherit(self, &column_names) 334 | else 335 | Protector::DSL::Meta.new(adapter, self, &column_names) 336 | end 337 | end 338 | end 339 | end 340 | end 341 | end 342 | -------------------------------------------------------------------------------- /lib/protector/engine.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | class Engine < ::Rails::Engine 3 | config.protector = ActiveSupport::OrderedOptions.new 4 | 5 | initializer 'protector.configuration' do |app| 6 | app.config.protector.each { |k, v| Protector.config[k] = v } 7 | 8 | if Protector::Adapters::ActiveRecord.modern? 9 | ::ActiveRecord::Base.send(:include, Protector::ActiveRecord::Adapters::StrongParameters) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/protector/version.rb: -------------------------------------------------------------------------------- 1 | module Protector 2 | # Gem version 3 | VERSION = '0.7.7' 4 | end 5 | -------------------------------------------------------------------------------- /locales/de.yml: -------------------------------------------------------------------------------- 1 | en: 2 | protector: 3 | invalid: "Zugriff auf '%{field}' verweigert" -------------------------------------------------------------------------------- /locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | protector: 3 | invalid: "Access denied to '%{field}'" -------------------------------------------------------------------------------- /locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | protector: 3 | invalid: "Нет прав доступа к полю '%{field}'" 4 | -------------------------------------------------------------------------------- /migrations/active_record.rb: -------------------------------------------------------------------------------- 1 | ### Connection 2 | 3 | ActiveRecord::Schema.verbose = false 4 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 5 | 6 | ActiveRecord::Base.instance_eval do 7 | unless method_defined?(:none) 8 | def none 9 | where('1 = 0') 10 | end 11 | end 12 | 13 | def every 14 | where(nil) 15 | end 16 | end 17 | 18 | ### Tables 19 | 20 | [:dummies, :fluffies, :bobbies].each do |m| 21 | ActiveRecord::Migration.create_table m do |t| 22 | t.string :string 23 | t.integer :number 24 | t.text :text 25 | t.belongs_to :dummy 26 | end 27 | end 28 | 29 | ActiveRecord::Migration.create_table(:loonies){|t| t.belongs_to :fluffy; t.string :string } 30 | 31 | ### Classes 32 | 33 | class Dummy < ActiveRecord::Base 34 | has_many :fluffies 35 | has_many :bobbies 36 | end 37 | 38 | class Fluffy < ActiveRecord::Base 39 | belongs_to :dummy 40 | has_one :loony 41 | end 42 | 43 | class Bobby < ActiveRecord::Base 44 | end 45 | 46 | class Loony < ActiveRecord::Base 47 | end 48 | 49 | class Rumba < ActiveRecord::Base 50 | end -------------------------------------------------------------------------------- /migrations/sequel.rb: -------------------------------------------------------------------------------- 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 50 | 51 | class Rumba < Sequel::Model 52 | end -------------------------------------------------------------------------------- /perf/active_record_perf.rb: -------------------------------------------------------------------------------- 1 | require 'ruby-prof' 2 | 3 | migrate 4 | 5 | seed do 6 | 500.times do 7 | d = Dummy.create! string: 'zomgstring', number: [999,777].sample, text: 'zomgtext' 8 | 9 | 2.times do 10 | f = Fluffy.create! string: 'zomgstring', number: [999,777].sample, text: 'zomgtext', dummy_id: d.id 11 | b = Bobby.create! string: 'zomgstring', number: [999,777].sample, text: 'zomgtext', dummy_id: d.id 12 | l = Loony.create! string: 'zomgstring', fluffy_id: f.id 13 | end 14 | end 15 | end 16 | 17 | activate do 18 | Dummy.instance_eval do 19 | protect do 20 | scope { every } 21 | can :read, :string 22 | end 23 | end 24 | 25 | Fluffy.instance_eval do 26 | protect do 27 | scope { every } 28 | can :read 29 | end 30 | end 31 | 32 | # Define attributes methods 33 | Dummy.first 34 | end 35 | 36 | benchmark 'Read from unprotected model (100k)' do 37 | d = Dummy.first 38 | 100_000.times { d.string } 39 | end 40 | 41 | benchmark 'Read open field (100k)' do 42 | d = Dummy.first 43 | d = d.restrict!('!') if activated? 44 | 100_000.times { d.string } 45 | end 46 | 47 | benchmark 'Read nil field (100k)' do 48 | d = Dummy.first 49 | d = d.restrict!('!') if activated? 50 | 100_000.times { d.text } 51 | end 52 | 53 | benchmark 'Check existance' do 54 | scope = activated? ? Dummy.restrict!('!') : Dummy.every 55 | 1000.times { scope.exists? } 56 | end 57 | 58 | benchmark 'Count' do 59 | scope = Dummy.limit(1) 60 | scope = scope.restrict!('!') if activated? 61 | 1000.times { scope.count } 62 | end 63 | 64 | benchmark 'Select one' do 65 | scope = Dummy.limit(1) 66 | scope = scope.restrict!('!') if activated? 67 | 1000.times { scope.to_a } 68 | end 69 | 70 | benchmark 'Select many' do 71 | scope = Dummy.every 72 | scope = scope.restrict!('!') if activated? 73 | 1000.times { scope.to_a } 74 | end 75 | 76 | benchmark 'Select with eager loading' do 77 | scope = Dummy.includes(:fluffies) 78 | scope = scope.restrict!('!') if activated? 79 | 1000.times { scope.to_a } 80 | end 81 | 82 | benchmark 'Select with filtered eager loading' do 83 | scope = Dummy.includes(:fluffies).where(fluffies: {number: 999}) 84 | scope = scope.restrict!('!') if activated? 85 | 1000.times { scope.to_a } 86 | end 87 | 88 | benchmark 'Select with mixed eager loading' do 89 | scope = Dummy.includes(:fluffies, :bobbies).where(fluffies: {number: 999}) 90 | scope = scope.restrict!('!') if activated? 91 | 1000.times { scope.to_a } 92 | end -------------------------------------------------------------------------------- /perf/perf_helpers/boot.rb: -------------------------------------------------------------------------------- 1 | class Perf 2 | def self.load(adapter) 3 | perf = Perf.new(adapter.camelize) 4 | base = Pathname.new(File.expand_path '../..', __FILE__) 5 | file = base.join(adapter+'_perf.rb').to_s 6 | perf.instance_eval File.read(file), file 7 | perf.run! 8 | end 9 | 10 | def initialize(adapter) 11 | @blocks = {} 12 | @adapter = adapter 13 | @activated = false 14 | @profiling = {} 15 | end 16 | 17 | def migrate 18 | puts 19 | print "Running with #{@adapter}: migrating... ".yellow 20 | 21 | load "migrations/#{@adapter.underscore}.rb" 22 | 23 | puts "Done.".yellow 24 | end 25 | 26 | def seed 27 | print "Seeding... ".yellow 28 | yield if block_given? 29 | puts "Done".yellow 30 | end 31 | 32 | def activate(&block) 33 | @activation = block 34 | end 35 | 36 | def benchmark(subject, options={}, &block) 37 | @blocks[subject] = block 38 | end 39 | 40 | def benchmark!(subject, options={min_percent: 4}, &block) 41 | @profiling[subject] = options 42 | benchmark(subject, &block) 43 | end 44 | 45 | def activated? 46 | @activated 47 | end 48 | 49 | def run! 50 | require 'ruby-prof' if @profiling.any? 51 | 52 | results = {} 53 | 54 | results[:off] = run_state('disabled', :red) 55 | 56 | Protector::Adapters.const_get(@adapter).activate! 57 | @activation.call 58 | @activated = true 59 | 60 | results[:on] = run_state('enabled', :green) 61 | 62 | print_block "Total".blue do 63 | results[:off].keys.each do |k| 64 | off = results[:off][k] 65 | on = results[:on][k] 66 | 67 | print_result k, sprintf("%8s / %8s (%s)", off, on, (on / off).round(2)) 68 | end 69 | end 70 | end 71 | 72 | private 73 | 74 | def run_state(state, color) 75 | data = {} 76 | prof = @profiling 77 | 78 | print_block "Protector #{state.send color}" do 79 | @blocks.each do |s, b| 80 | RubyProf.start if prof.include?(s) 81 | 82 | data[s] = Benchmark.realtime(&b) 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 91 | end 92 | end 93 | 94 | data 95 | end 96 | 97 | def print_result(title, time) 98 | print title.yellow 99 | print "..." 100 | puts sprintf("%#{100-title.length-3}s", time) 101 | end 102 | 103 | def print_block(title) 104 | puts 105 | puts title 106 | puts "-"*100 107 | 108 | yield 109 | 110 | puts "-"*100 111 | puts 112 | end 113 | end -------------------------------------------------------------------------------- /perf/sequel_perf.rb: -------------------------------------------------------------------------------- 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 :read, :string 20 | end 21 | end 22 | 23 | Fluffy.instance_eval do 24 | protect do 25 | scope { where } 26 | can :read 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 -------------------------------------------------------------------------------- /protector.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'protector/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "protector" 8 | spec.version = Protector::VERSION 9 | spec.authors = ["Boris Staal"] 10 | spec.email = ["boris@staal.io"] 11 | spec.description = %q{Comfortable (seriously) white-list security restrictions for models on a field level} 12 | spec.summary = %q{Protector is a successor to the Heimdallr gem: it hits the same goals keeping the Ruby way} 13 | spec.homepage = "https://github.com/inossidabile/protector" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "activesupport" 22 | spec.add_dependency "i18n" 23 | end 24 | -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3 3 | database: ":memory:" 4 | verbosity: quiet -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inossidabile/protector/ef43d0d8b00d92bbf33cdf64438e2e8e84b14c54/spec/internal/db/schema.rb -------------------------------------------------------------------------------- /spec/lib/protector/adapters/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helpers/boot' 2 | 3 | if defined?(ActiveRecord) 4 | load 'spec_helpers/adapters/active_record.rb' 5 | 6 | describe Protector::Adapters::ActiveRecord do 7 | before(:all) do 8 | load 'migrations/active_record.rb' 9 | 10 | module ProtectionCase 11 | extend ActiveSupport::Concern 12 | 13 | included do |klass| 14 | protect do |x| 15 | if x == '-' 16 | scope{ where('1=0') } 17 | elsif x == '+' 18 | scope{ where(klass.table_name => {number: 999}) } 19 | end 20 | 21 | can :read, :dummy_id unless x == '-' 22 | end 23 | end 24 | end 25 | 26 | [Dummy, Fluffy].each{|c| c.send :include, ProtectionCase} 27 | 28 | Dummy.create! string: 'zomgstring', number: 999, text: 'zomgtext' 29 | Dummy.create! string: 'zomgstring', number: 999, text: 'zomgtext' 30 | Dummy.create! string: 'zomgstring', number: 777, text: 'zomgtext' 31 | Dummy.create! string: 'zomgstring', number: 777, text: 'zomgtext' 32 | 33 | [Fluffy, Bobby].each do |m| 34 | m.create! string: 'zomgstring', number: 999, text: 'zomgtext', dummy_id: 1 35 | m.create! string: 'zomgstring', number: 777, text: 'zomgtext', dummy_id: 1 36 | m.create! string: 'zomgstring', number: 999, text: 'zomgtext', dummy_id: 2 37 | m.create! string: 'zomgstring', number: 777, text: 'zomgtext', dummy_id: 2 38 | end 39 | 40 | Fluffy.all.each{|f| Loony.create! fluffy_id: f.id, string: 'zomgstring' } 41 | end 42 | 43 | let(:dummy) do 44 | Class.new(ActiveRecord::Base) do 45 | def self.name; 'Dummy'; end 46 | def self.model_name; ActiveModel::Name.new(self, nil, "dummy"); end 47 | self.table_name = "dummies" 48 | scope :none, where('1 = 0') unless respond_to?(:none) 49 | end 50 | end 51 | 52 | describe Protector::Adapters::ActiveRecord do 53 | it "finds out whether object is AR relation" do 54 | Protector::Adapters::ActiveRecord.is?(Dummy).should == true 55 | Protector::Adapters::ActiveRecord.is?(Dummy.every).should == true 56 | end 57 | 58 | it "sets the adapter" do 59 | Dummy.restrict!('!').protector_meta.adapter.should == Protector::Adapters::ActiveRecord 60 | end 61 | end 62 | 63 | # 64 | # Model instance 65 | # 66 | describe Protector::Adapters::ActiveRecord::Base do 67 | it "includes" do 68 | Dummy.ancestors.should include(Protector::Adapters::ActiveRecord::Base) 69 | end 70 | 71 | it "scopes" do 72 | scope = Dummy.restrict!('!') 73 | scope.should be_a_kind_of ActiveRecord::Relation 74 | scope.protector_subject.should == '!' 75 | end 76 | 77 | it_behaves_like "a model" 78 | 79 | it "validates on create" do 80 | dummy.instance_eval do 81 | protect do; end 82 | end 83 | 84 | instance = dummy.restrict!('!').create(string: 'test') 85 | instance.errors[:base].should == ["Access denied to 'string'"] 86 | instance.delete 87 | end 88 | 89 | it "validates on create!" do 90 | dummy.instance_eval do 91 | protect do; end 92 | end 93 | 94 | expect { dummy.restrict!('!').create!(string: 'test').delete }.to raise_error 95 | end 96 | 97 | it "validates on new{}" do 98 | dummy.instance_eval do 99 | protect do; end 100 | end 101 | 102 | result = dummy.restrict!('!').new do |instance| 103 | instance.protector_subject.should == '!' 104 | end 105 | 106 | result.protector_subject.should == '!' 107 | end 108 | 109 | it "finds with scope on id column" do 110 | dummy.instance_eval do 111 | protect do 112 | scope { where(id: 1) } 113 | end 114 | end 115 | 116 | expect { dummy.restrict!('!').find(1) }.to_not raise_error 117 | expect { dummy.restrict!('!').find(2) }.to raise_error 118 | end 119 | 120 | it "allows for validations" do 121 | dummy.instance_eval do 122 | validates :string, presence: true 123 | protect do; can :create; end 124 | end 125 | 126 | instance = dummy.restrict!('!').new(string: 'test') 127 | instance.save.should == true 128 | instance.delete 129 | end 130 | end 131 | 132 | # 133 | # Model scope 134 | # 135 | describe Protector::Adapters::ActiveRecord::Relation do 136 | it "includes" do 137 | Dummy.none.ancestors.should include(Protector::Adapters::ActiveRecord::Base) 138 | end 139 | 140 | it "saves subject" do 141 | Dummy.restrict!('!').where(number: 999).protector_subject.should == '!' 142 | Dummy.restrict!('!').except(:order).protector_subject.should == '!' 143 | Dummy.restrict!('!').only(:order).protector_subject.should == '!' 144 | end 145 | 146 | it "forwards subject" do 147 | Dummy.restrict!('!').where(number: 999).first.protector_subject.should == '!' 148 | Dummy.restrict!('!').where(number: 999).to_a.first.protector_subject.should == '!' 149 | Dummy.restrict!('!').new.protector_subject.should == '!' 150 | Dummy.restrict!('!').first.fluffies.new.protector_subject.should == '!' 151 | Dummy.first.fluffies.restrict!('!').new.protector_subject.should == '!' 152 | end 153 | 154 | it "checks creatability" do 155 | Dummy.restrict!('!').creatable?.should == false 156 | Dummy.restrict!('!').where(number: 999).creatable?.should == false 157 | end 158 | 159 | context "with open relation" do 160 | context "adequate", paranoid: false do 161 | 162 | it "checks existence" do 163 | Dummy.any?.should == true 164 | Dummy.restrict!('!').any?.should == true 165 | end 166 | 167 | it "counts" do 168 | Dummy.count.should == 4 169 | dummy = Dummy.restrict!('!') 170 | dummy.count.should == 4 171 | dummy.protector_subject?.should == true 172 | end 173 | 174 | it "fetches" do 175 | fetched = Dummy.restrict!('!').to_a 176 | 177 | Dummy.count.should == 4 178 | fetched.length.should == 4 179 | end 180 | end 181 | 182 | context "paranoid", paranoid: true do 183 | it "checks existence" do 184 | Dummy.any?.should == true 185 | Dummy.restrict!('!').any?.should == false 186 | end 187 | 188 | it "counts" do 189 | Dummy.count.should == 4 190 | dummy = Dummy.restrict!('!') 191 | dummy.count.should == 0 192 | dummy.protector_subject?.should == true 193 | end 194 | 195 | it "fetches" do 196 | fetched = Dummy.restrict!('!').to_a 197 | 198 | Dummy.count.should == 4 199 | fetched.length.should == 0 200 | end 201 | end 202 | end 203 | 204 | context "with null relation" do 205 | it "checks existence" do 206 | Dummy.any?.should == true 207 | Dummy.restrict!('-').any?.should == false 208 | end 209 | 210 | it "counts" do 211 | Dummy.count.should == 4 212 | dummy = Dummy.restrict!('-') 213 | dummy.count.should == 0 214 | dummy.protector_subject?.should == true 215 | end 216 | 217 | it "fetches" do 218 | fetched = Dummy.restrict!('-').to_a 219 | 220 | Dummy.count.should == 4 221 | fetched.length.should == 0 222 | end 223 | 224 | it "keeps security scope when unscoped" do 225 | Dummy.unscoped.restrict!('-').count.should == 0 226 | Dummy.restrict!('-').unscoped.count.should == 0 227 | end 228 | end 229 | 230 | context "with active relation" do 231 | it "checks existence" do 232 | Dummy.any?.should == true 233 | Dummy.restrict!('+').any?.should == true 234 | end 235 | 236 | it "counts" do 237 | Dummy.count.should == 4 238 | dummy = Dummy.restrict!('+') 239 | dummy.count.should == 2 240 | dummy.protector_subject?.should == true 241 | end 242 | 243 | it "fetches" do 244 | fetched = Dummy.restrict!('+').to_a 245 | 246 | Dummy.count.should == 4 247 | fetched.length.should == 2 248 | end 249 | 250 | it "keeps security scope when unscoped" do 251 | Dummy.unscoped.restrict!('+').count.should == 2 252 | Dummy.restrict!('+').unscoped.count.should == 2 253 | end 254 | end 255 | end 256 | 257 | # 258 | # Model scope 259 | # 260 | describe Protector::Adapters::ActiveRecord::Association do 261 | describe "validates on create! within association" do 262 | it "when restricted from entity" do 263 | expect { Dummy.first.restrict!('-').fluffies.create!(string: 'test').delete }.to raise_error 264 | end 265 | 266 | it "when restricted from association" do 267 | expect { Dummy.first.fluffies.restrict!('-').create!(string: 'test').delete }.to raise_error 268 | end 269 | end 270 | 271 | context "singular association" do 272 | it "forwards subject" do 273 | Fluffy.restrict!('!').first.dummy.protector_subject.should == '!' 274 | Fluffy.first.restrict!('!').dummy.protector_subject.should == '!' 275 | end 276 | 277 | it "forwards cached subject" do 278 | Dummy.first.fluffies.restrict!('!').first.dummy.protector_subject.should == '!' 279 | end 280 | end 281 | 282 | context "collection association" do 283 | it "forwards subject" do 284 | Dummy.restrict!('!').first.fluffies.protector_subject.should == '!' 285 | Dummy.first.restrict!('!').fluffies.protector_subject.should == '!' 286 | Dummy.restrict!('!').first.fluffies.new.protector_subject.should == '!' 287 | Dummy.first.restrict!('!').fluffies.new.protector_subject.should == '!' 288 | Dummy.first.fluffies.restrict!('!').new.protector_subject.should == '!' 289 | end 290 | 291 | context "with open relation" do 292 | context "adequate", paranoid: false do 293 | 294 | it "checks existence" do 295 | Dummy.first.fluffies.any?.should == true 296 | Dummy.first.restrict!('!').fluffies.any?.should == true 297 | Dummy.first.fluffies.restrict!('!').any?.should == true 298 | end 299 | 300 | it "counts" do 301 | Dummy.first.fluffies.count.should == 2 302 | 303 | fluffies = Dummy.first.restrict!('!').fluffies 304 | fluffies.count.should == 2 305 | fluffies.protector_subject?.should == true 306 | 307 | fluffies = Dummy.first.fluffies.restrict!('!') 308 | fluffies.count.should == 2 309 | fluffies.protector_subject?.should == true 310 | end 311 | 312 | it "fetches" do 313 | Dummy.first.fluffies.count.should == 2 314 | Dummy.first.restrict!('!').fluffies.length.should == 2 315 | Dummy.first.fluffies.restrict!('!').length.should == 2 316 | end 317 | end 318 | 319 | context "paranoid", paranoid: true do 320 | it "checks existence" do 321 | Dummy.first.fluffies.any?.should == true 322 | Dummy.first.restrict!('!').fluffies.any?.should == false 323 | Dummy.first.fluffies.restrict!('!').any?.should == false 324 | end 325 | 326 | it "counts" do 327 | Dummy.first.fluffies.count.should == 2 328 | 329 | fluffies = Dummy.first.restrict!('!').fluffies 330 | fluffies.count.should == 0 331 | fluffies.protector_subject?.should == true 332 | 333 | fluffies = Dummy.first.fluffies.restrict!('!') 334 | fluffies.count.should == 0 335 | fluffies.protector_subject?.should == true 336 | end 337 | 338 | it "fetches" do 339 | Dummy.first.fluffies.count.should == 2 340 | Dummy.first.restrict!('!').fluffies.length.should == 0 341 | Dummy.first.fluffies.restrict!('!').length.should == 0 342 | end 343 | end 344 | end 345 | end 346 | 347 | context "with null relation" do 348 | it "checks existence" do 349 | Dummy.first.fluffies.any?.should == true 350 | Dummy.first.restrict!('-').fluffies.any?.should == false 351 | Dummy.first.fluffies.restrict!('-').any?.should == false 352 | end 353 | 354 | it "counts" do 355 | Dummy.first.fluffies.count.should == 2 356 | 357 | fluffies = Dummy.first.restrict!('-').fluffies 358 | fluffies.count.should == 0 359 | fluffies.protector_subject?.should == true 360 | 361 | fluffies = Dummy.first.fluffies.restrict!('-') 362 | fluffies.count.should == 0 363 | fluffies.protector_subject?.should == true 364 | end 365 | 366 | it "fetches" do 367 | Dummy.first.fluffies.count.should == 2 368 | Dummy.first.restrict!('-').fluffies.length.should == 0 369 | Dummy.first.fluffies.restrict!('-').length.should == 0 370 | end 371 | end 372 | 373 | context "with active relation" do 374 | it "checks existence" do 375 | Dummy.first.fluffies.any?.should == true 376 | Dummy.first.restrict!('+').fluffies.any?.should == true 377 | Dummy.first.fluffies.restrict!('+').any?.should == true 378 | end 379 | 380 | it "counts" do 381 | Dummy.first.fluffies.count.should == 2 382 | 383 | fluffies = Dummy.first.restrict!('+').fluffies 384 | fluffies.count.should == 1 385 | fluffies.protector_subject?.should == true 386 | 387 | fluffies = Dummy.first.fluffies.restrict!('+') 388 | fluffies.count.should == 1 389 | fluffies.protector_subject?.should == true 390 | end 391 | 392 | it "fetches" do 393 | Dummy.first.fluffies.count.should == 2 394 | Dummy.first.restrict!('+').fluffies.length.should == 1 395 | Dummy.first.fluffies.restrict!('+').length.should == 1 396 | end 397 | end 398 | end 399 | 400 | # 401 | # Eager loading 402 | # 403 | describe Protector::Adapters::ActiveRecord::Preloader do 404 | describe "eager loading" do 405 | it "scopes" do 406 | d = Dummy.restrict!('+').includes(:fluffies) 407 | d.length.should == 2 408 | d.first.fluffies.length.should == 1 409 | end 410 | 411 | context "joined to filtered association" do 412 | it "scopes" do 413 | d = Dummy.restrict!('+').includes(:fluffies).where(fluffies: {string: 'zomgstring'}) 414 | d.length.should == 2 415 | d.first.fluffies.length.should == 1 416 | end 417 | end 418 | 419 | context "joined to plain association" do 420 | it "scopes" do 421 | d = Dummy.restrict!('+').includes(:bobbies, :fluffies).where( 422 | bobbies: {string: 'zomgstring'}, fluffies: {string: 'zomgstring'} 423 | ) 424 | d.length.should == 2 425 | d.first.fluffies.length.should == 1 426 | d.first.bobbies.length.should == 2 427 | end 428 | end 429 | 430 | context "with complex include" do 431 | it "scopes" do 432 | d = Dummy.restrict!('+').includes(fluffies: :loony).where( 433 | fluffies: {string: 'zomgstring'}, 434 | loonies: {string: 'zomgstring'} 435 | ) 436 | d.length.should == 2 437 | d.first.fluffies.length.should == 1 438 | d.first.fluffies.first.loony.should be_a_kind_of(Loony) 439 | end 440 | end 441 | end 442 | 443 | context "complicated features" do 444 | # https://github.com/inossidabile/protector/commit/7ce072aa2074e0f3b48e293b952810f720bc143d 445 | it "handles scopes with includes" do 446 | fluffy = Class.new(ActiveRecord::Base) do 447 | def self.name; 'Fluffy'; end 448 | def self.model_name; ActiveModel::Name.new(self, nil, "fluffy"); end 449 | self.table_name = "fluffies" 450 | scope :none, where('1 = 0') unless respond_to?(:none) 451 | belongs_to :dummy, class_name: 'Dummy' 452 | 453 | protect do 454 | scope { includes(:dummy).where(dummies: {id: 1}) } 455 | end 456 | end 457 | 458 | expect { fluffy.restrict!('!').to_a }.to_not raise_error 459 | end 460 | 461 | # https://github.com/inossidabile/protector/issues/42 462 | if ActiveRecord::Base.respond_to?(:enum) 463 | context "enums" do 464 | before(:each) do 465 | dummy.instance_eval do 466 | enum number: [ :active, :archived ] 467 | end 468 | end 469 | 470 | it "can be read" do 471 | dummy.instance_eval do 472 | protect do 473 | can :read, :number 474 | can :create, :number 475 | can :update, :number 476 | end 477 | end 478 | 479 | d = dummy.new.restrict!('!') 480 | 481 | expect { d.active! }.to_not raise_error 482 | 483 | d.number.should == 'active' 484 | d.active?.should == true 485 | d.archived?.should == false 486 | 487 | d.delete 488 | end 489 | end 490 | end 491 | end 492 | end 493 | end 494 | 495 | end -------------------------------------------------------------------------------- /spec/lib/protector/adapters/sequel_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helpers/boot' 2 | 3 | if defined?(Sequel) 4 | load 'spec_helpers/adapters/sequel.rb' 5 | 6 | describe Protector::Adapters::Sequel do 7 | before(:all) do 8 | load 'migrations/sequel.rb' 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".to_sym => 999) } if x == '+' 17 | 18 | can :read, :dummy_id unless x == '-' 19 | end 20 | end 21 | end 22 | 23 | [Dummy, Fluffy].each{|c| c.send :include, ProtectionCase} 24 | 25 | Dummy.create string: 'zomgstring', number: 999, text: 'zomgtext' 26 | Dummy.create string: 'zomgstring', number: 999, text: 'zomgtext' 27 | Dummy.create string: 'zomgstring', number: 777, text: 'zomgtext' 28 | Dummy.create string: 'zomgstring', number: 777, text: 'zomgtext' 29 | 30 | [Fluffy, Bobby].each do |m| 31 | m.create string: 'zomgstring', number: 999, text: 'zomgtext', dummy_id: 1 32 | m.create string: 'zomgstring', number: 777, text: 'zomgtext', dummy_id: 1 33 | m.create string: 'zomgstring', number: 999, text: 'zomgtext', dummy_id: 2 34 | m.create string: 'zomgstring', number: 777, text: 'zomgtext', dummy_id: 2 35 | end 36 | 37 | Fluffy.all.each{|f| Loony.create fluffy_id: f.id, string: 'zomgstring' } 38 | end 39 | 40 | describe Protector::Adapters::Sequel do 41 | it "finds out whether object is Sequel relation" do 42 | Protector::Adapters::Sequel.is?(Dummy).should == true 43 | Protector::Adapters::Sequel.is?(Dummy.where).should == true 44 | end 45 | 46 | it "sets the adapter" do 47 | Dummy.restrict!('!').protector_meta.adapter.should == Protector::Adapters::Sequel 48 | end 49 | end 50 | 51 | 52 | # 53 | # Model instance 54 | # 55 | describe Protector::Adapters::Sequel::Model do 56 | let(:dummy) do 57 | Class.new Sequel::Model(:dummies) 58 | end 59 | 60 | it "includes" do 61 | Dummy.ancestors.should include(Protector::Adapters::Sequel::Model) 62 | end 63 | 64 | it "scopes" do 65 | scope = Dummy.restrict!('!') 66 | scope.should be_a_kind_of Sequel::Dataset 67 | scope.protector_subject.should == '!' 68 | end 69 | 70 | it_behaves_like "a model" 71 | end 72 | 73 | # 74 | # Model scope 75 | # 76 | describe Protector::Adapters::Sequel::Dataset do 77 | it "includes" do 78 | Dummy.none.class.ancestors.should include(Protector::DSL::Base) 79 | end 80 | 81 | it "saves subject" do 82 | Dummy.restrict!('!').where(number: 999).protector_subject.should == '!' 83 | end 84 | 85 | it "forwards subject" do 86 | Dummy.restrict!('!').where(number: 999).first.protector_subject.should == '!' 87 | Dummy.restrict!('!').where(number: 999).to_a.first.protector_subject.should == '!' 88 | Dummy.restrict!('!').eager_graph(fluffies: :loony).all.first.fluffies.first.loony.protector_subject.should == '!' 89 | end 90 | 91 | it "checks creatability" do 92 | Dummy.restrict!('!').creatable?.should == false 93 | Dummy.restrict!('!').where(number: 999).creatable?.should == false 94 | end 95 | 96 | context "with open relation" do 97 | context "adequate", paranoid: false do 98 | it "checks existence" do 99 | Dummy.any?.should == true 100 | Dummy.restrict!('!').any?.should == true 101 | end 102 | 103 | it "counts" do 104 | Dummy.count.should == 4 105 | Dummy.restrict!('!').count.should == 4 106 | end 107 | 108 | it "fetches first" do 109 | Dummy.restrict!('!').first.should be_a_kind_of(Dummy) 110 | end 111 | 112 | it "fetches all" do 113 | fetched = Dummy.restrict!('!').to_a 114 | 115 | Dummy.count.should == 4 116 | fetched.length.should == 4 117 | end 118 | end 119 | 120 | context "paranoid", paranoid: true do 121 | it "checks existence" do 122 | Dummy.any?.should == true 123 | Dummy.restrict!('!').any?.should == false 124 | end 125 | 126 | it "counts" do 127 | Dummy.count.should == 4 128 | Dummy.restrict!('!').count.should == 0 129 | end 130 | 131 | it "fetches first" do 132 | Dummy.restrict!('!').first.should == nil 133 | end 134 | 135 | it "fetches all" do 136 | fetched = Dummy.restrict!('!').to_a 137 | 138 | Dummy.count.should == 4 139 | fetched.length.should == 0 140 | end 141 | end 142 | end 143 | 144 | context "with null relation" do 145 | it "checks existence" do 146 | Dummy.any?.should == true 147 | Dummy.restrict!('-').any?.should == false 148 | end 149 | 150 | it "counts" do 151 | Dummy.count.should == 4 152 | Dummy.restrict!('-').count.should == 0 153 | end 154 | 155 | it "fetches first" do 156 | Dummy.restrict!('-').first.should == nil 157 | end 158 | 159 | it "fetches all" do 160 | fetched = Dummy.restrict!('-').to_a 161 | 162 | Dummy.count.should == 4 163 | fetched.length.should == 0 164 | end 165 | end 166 | 167 | context "with active relation" do 168 | it "checks existence" do 169 | Dummy.any?.should == true 170 | Dummy.restrict!('+').any?.should == true 171 | end 172 | 173 | it "counts" do 174 | Dummy.count.should == 4 175 | Dummy.restrict!('+').count.should == 2 176 | end 177 | 178 | it "fetches first" do 179 | Dummy.restrict!('+').first.should be_a_kind_of Dummy 180 | end 181 | 182 | it "fetches all" do 183 | fetched = Dummy.restrict!('+').to_a 184 | 185 | Dummy.count.should == 4 186 | fetched.length.should == 2 187 | end 188 | end 189 | end 190 | 191 | # 192 | # Eager loading 193 | # 194 | describe Protector::Adapters::Sequel::Dataset do 195 | describe "eager loading" do 196 | 197 | context "straight" do 198 | it "scopes" do 199 | d = Dummy.restrict!('+').eager(:fluffies) 200 | d.count.should == 2 201 | d.first.fluffies.length.should == 1 202 | end 203 | end 204 | 205 | context "graph" do 206 | it "scopes" do 207 | d = Dummy.restrict!('+').eager_graph(fluffies: :loony) 208 | d.count.should == 4 209 | d = d.all 210 | d.length.should == 2 # which is terribly sick :doh: 211 | d.first.fluffies.length.should == 1 212 | d.first.fluffies.first.loony.should be_a_kind_of Loony 213 | end 214 | end 215 | end 216 | end 217 | end 218 | 219 | end -------------------------------------------------------------------------------- /spec/lib/protector/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helpers/boot' 2 | 3 | describe Protector::DSL do 4 | describe Protector::DSL::Base do 5 | before :each do 6 | @base = Class.new{ include Protector::DSL::Base } 7 | end 8 | 9 | it "defines proper methods" do 10 | @base.instance_methods.should include(:restrict!) 11 | @base.instance_methods.should include(:protector_subject) 12 | end 13 | 14 | it "throws error for empty subect" do 15 | base = @base.new 16 | expect { base.protector_subject }.to raise_error 17 | end 18 | 19 | it "accepts nil as a subject" do 20 | base = @base.new.restrict!(nil) 21 | expect { base.protector_subject }.to_not raise_error 22 | end 23 | 24 | it "remembers protection subject" do 25 | base = @base.new 26 | base.restrict!("universe") 27 | base.protector_subject.should == "universe" 28 | end 29 | 30 | it "forgets protection subject" do 31 | base = @base.new 32 | base.restrict!("universe") 33 | base.protector_subject.should == "universe" 34 | base.unrestrict! 35 | expect { base.protector_subject }.to raise_error 36 | end 37 | 38 | it "respects `insecurely`" do 39 | base = @base.new 40 | base.restrict!("universe") 41 | 42 | base.protector_subject?.should == true 43 | Protector.insecurely do 44 | base.protector_subject?.should == false 45 | end 46 | end 47 | 48 | it "allows nesting of `insecurely`" do 49 | base = @base.new 50 | base.restrict!("universe") 51 | 52 | base.protector_subject?.should == true 53 | Protector.insecurely do 54 | Protector.insecurely do 55 | base.protector_subject?.should == false 56 | end 57 | end 58 | end 59 | end 60 | 61 | describe Protector::DSL::Entry do 62 | before :each do 63 | @entry = Class.new do 64 | include Protector::DSL::Entry 65 | 66 | def self.protector_meta 67 | @protector_meta ||= Protector::DSL::Meta.new(nil, nil){[]} 68 | end 69 | end 70 | end 71 | 72 | it "instantiates meta entity" do 73 | @entry.instance_eval do 74 | protect do; end 75 | end 76 | 77 | @entry.protector_meta.should be_an_instance_of(Protector::DSL::Meta) 78 | end 79 | end 80 | 81 | describe Protector::DSL::Meta do 82 | context "basic methods" do 83 | l = lambda {|x| x > 4} 84 | 85 | before :each do 86 | @meta = Protector::DSL::Meta.new(nil, nil){%w(field1 field2 field3 field4 field5)} 87 | @meta << lambda { 88 | can :read 89 | } 90 | 91 | @meta << lambda {|user| 92 | scope { 'relation' } if user 93 | } 94 | 95 | @meta << lambda {|user| 96 | user.should == 'user' if user 97 | 98 | cannot :read, %w(field5), :field4 99 | } 100 | 101 | @meta << lambda {|user, entry| 102 | user.should == 'user' if user 103 | entry.should == 'entry' if user 104 | 105 | can :update, %w(field1 field2), 106 | field3: 1, 107 | field4: 0..5, 108 | field5: l 109 | 110 | can :destroy 111 | } 112 | end 113 | 114 | it "evaluates" do 115 | @meta.evaluate('user', 'entry') 116 | end 117 | 118 | context "adequate", paranoid: false do 119 | it "sets scoped?" do 120 | data = @meta.evaluate(nil, 'entry') 121 | data.scoped?.should == false 122 | end 123 | end 124 | 125 | context "paranoid", paranoid: true do 126 | it "sets scoped?" do 127 | data = @meta.evaluate(nil, 'entry') 128 | data.scoped?.should == true 129 | end 130 | end 131 | 132 | context "evaluated" do 133 | let(:data) { @meta.evaluate('user', 'entry') } 134 | 135 | it "sets relation" do 136 | data.relation.should == 'relation' 137 | end 138 | 139 | it "sets access" do 140 | data.access.should == { 141 | update: { 142 | "field1" => nil, 143 | "field2" => nil, 144 | "field3" => 1, 145 | "field4" => 0..5, 146 | "field5" => l 147 | }, 148 | read: { 149 | "field1" => nil, 150 | "field2" => nil, 151 | "field3" => nil 152 | } 153 | } 154 | end 155 | 156 | it "marks destroyable" do 157 | data.destroyable?.should == true 158 | data.can?(:destroy).should == true 159 | end 160 | 161 | context "marks updatable" do 162 | it "with defaults" do 163 | data.updatable?.should == true 164 | data.can?(:update).should == true 165 | end 166 | 167 | it "respecting lambda", dev: true do 168 | data.updatable?('field5' => 5).should == true 169 | data.updatable?('field5' => 3).should == false 170 | end 171 | end 172 | 173 | it "gets first unupdatable field" do 174 | data.first_unupdatable_field('field1' => 1, 'field6' => 2, 'field7' => 3).should == 'field6' 175 | end 176 | 177 | it "marks creatable" do 178 | data.creatable?.should == false 179 | data.can?(:create).should == false 180 | end 181 | 182 | it "gets first uncreatable field" do 183 | data.first_uncreatable_field('field1' => 1, 'field6' => 2).should == 'field1' 184 | end 185 | end 186 | end 187 | 188 | context "deprecated methods" do 189 | before :each do 190 | @meta = Protector::DSL::Meta.new(nil, nil){%w(field1 field2 field3)} 191 | 192 | @meta << lambda { 193 | can :view 194 | cannot :view, :field2 195 | } 196 | end 197 | 198 | it "evaluates" do 199 | data = ActiveSupport::Deprecation.silence { @meta.evaluate('user', 'entry') } 200 | data.can?(:read).should == true 201 | data.can?(:read, :field1).should == true 202 | data.can?(:read, :field2).should == false 203 | end 204 | end 205 | 206 | context "custom methods" do 207 | before :each do 208 | @meta = Protector::DSL::Meta.new(nil, nil){%w(field1 field2)} 209 | 210 | @meta << lambda { 211 | can :drink, :field1 212 | can :eat 213 | cannot :eat, :field1 214 | } 215 | end 216 | 217 | it "sets field-level restriction" do 218 | box = @meta.evaluate('user', 'entry') 219 | box.can?(:drink, :field1).should == true 220 | box.can?(:drink).should == true 221 | end 222 | 223 | it "sets field-level protection" do 224 | box = @meta.evaluate('user', 'entry') 225 | box.can?(:eat, :field1).should == false 226 | box.can?(:eat).should == true 227 | end 228 | end 229 | 230 | it "avoids lambdas recursion" do 231 | base = Class.new{ include Protector::DSL::Base } 232 | meta = Protector::DSL::Meta.new(nil, nil){%w(field1)} 233 | 234 | meta << lambda { 235 | can :create, field1: lambda {|x| x.protector_subject?.should == false} 236 | } 237 | 238 | box = meta.evaluate('context', 'instance') 239 | box.creatable?('field1' => base.new.restrict!(nil)) 240 | end 241 | end 242 | end -------------------------------------------------------------------------------- /spec/lib/protector/engine_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helpers/boot' 2 | 3 | if defined?(Rails) 4 | describe Protector::Engine do 5 | before(:all) do 6 | Combustion.initialize! :active_record do 7 | config.protector.paranoid = true 8 | config.action_controller.action_on_unpermitted_parameters = :raise 9 | end 10 | 11 | Protector.activate! 12 | 13 | unless Protector::Adapters::ActiveRecord.modern? 14 | ActiveRecord::Base.send(:include, ActiveModel::ForbiddenAttributesProtection) 15 | ActiveRecord::Base.send(:include, Protector::ActiveRecord::Adapters::StrongParameters) 16 | end 17 | end 18 | 19 | after(:all) do 20 | Protector.config.paranoid = false 21 | end 22 | 23 | it "inherits Rails config" do 24 | Protector.config.paranoid?.should == true 25 | Protector.config.strong_parameters?.should == true 26 | end 27 | 28 | describe "strong_parameters" do 29 | before(:all) do 30 | load 'migrations/active_record.rb' 31 | end 32 | 33 | let(:dummy) do 34 | Class.new(ActiveRecord::Base) do 35 | def self.model_name; ActiveModel::Name.new(self, nil, "dummy"); end 36 | self.table_name = "dummies" 37 | 38 | protect do 39 | can :create, :string 40 | can :update, :number 41 | end 42 | end 43 | end 44 | 45 | def params(*args) 46 | ActionController::Parameters.new *args 47 | end 48 | 49 | it "creates" do 50 | expect{ dummy.restrict!.new params(string: 'test') }.to_not raise_error 51 | expect{ dummy.restrict!.create(params(string: 'test')).delete }.to_not raise_error 52 | expect{ dummy.restrict!.create!(params(string: 'test')).delete }.to_not raise_error 53 | expect{ dummy.restrict!.new params(number: 1) }.to raise_error 54 | end 55 | 56 | it "updates" do 57 | instance = dummy.create! 58 | 59 | expect{ instance.restrict!.assign_attributes params(string: 'test') }.to raise_error 60 | expect{ instance.restrict!.assign_attributes params(number: 1) }.to_not raise_error 61 | end 62 | end 63 | end 64 | end -------------------------------------------------------------------------------- /spec/spec_helpers/adapters/active_record.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :invalidate do 2 | match do |actual| 3 | actual.save.should == false 4 | actual.errors[:base][0].starts_with?("Access denied to").should == true 5 | end 6 | end 7 | 8 | RSpec::Matchers.define :validate do 9 | match do |actual| 10 | actual.class.transaction do 11 | actual.save.should == true 12 | raise ActiveRecord::Rollback 13 | end 14 | 15 | true 16 | end 17 | end 18 | 19 | RSpec::Matchers.define :destroy do 20 | match do |actual| 21 | actual.class.transaction do 22 | actual.destroy.should == actual 23 | raise ActiveRecord::Rollback 24 | end 25 | 26 | actual.class.where(id: actual.id).delete_all 27 | 28 | true 29 | end 30 | end 31 | 32 | RSpec::Matchers.define :survive do 33 | match do |actual| 34 | actual.class.transaction do 35 | actual.destroy.should == false 36 | raise ActiveRecord::Rollback 37 | end 38 | 39 | actual.class.where(id: actual.id).delete_all 40 | 41 | true 42 | end 43 | end 44 | 45 | def log! 46 | around(:each) do |e| 47 | ActiveRecord::Base.logger = Logger.new(STDOUT) 48 | e.run 49 | ActiveRecord::Base.logger = nil 50 | end 51 | end 52 | 53 | def assign!(model, fields) 54 | model.assign_attributes(fields) 55 | end 56 | 57 | def read_attribute(model, field) 58 | model.read_attribute(field) 59 | end -------------------------------------------------------------------------------- /spec/spec_helpers/adapters/sequel.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :invalidate do 2 | match do |actual| 3 | DB.transaction do 4 | expect{ actual.save }.to raise_error 5 | actual.errors.on(:base)[0].starts_with?("Access denied to").should == true 6 | raise Sequel::Rollback 7 | end 8 | 9 | true 10 | end 11 | end 12 | 13 | RSpec::Matchers.define :validate do 14 | match do |actual| 15 | DB.transaction do 16 | expect{ actual.save }.to_not raise_error 17 | raise Sequel::Rollback 18 | end 19 | 20 | true 21 | end 22 | end 23 | 24 | RSpec::Matchers.define :destroy do 25 | match do |actual| 26 | DB.transaction do 27 | expect{ actual.destroy.should }.to_not raise_error 28 | raise Sequel::Rollback 29 | end 30 | 31 | actual.class.where(id: actual.id).delete 32 | 33 | true 34 | end 35 | end 36 | 37 | RSpec::Matchers.define :survive do 38 | match do |actual| 39 | DB.transaction do 40 | expect{ actual.destroy.should }.to raise_error 41 | raise Sequel::Rollback 42 | end 43 | 44 | actual.class.where(id: actual.id).delete 45 | 46 | true 47 | end 48 | end 49 | 50 | def log! 51 | around(:each) do |e| 52 | DB.loggers << Logger.new(STDOUT) 53 | e.run 54 | DB.loggers = [] 55 | end 56 | end 57 | 58 | def assign!(model, fields) 59 | model.set_all(fields) 60 | end 61 | 62 | def read_attribute(model, field) 63 | model.instance_variable_get("@values")[field] 64 | end -------------------------------------------------------------------------------- /spec/spec_helpers/boot.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'simplecov-summary' 3 | 4 | SimpleCov.start do 5 | command_name File.basename(ENV['BUNDLE_GEMFILE'], '.gemfile') 6 | 7 | add_filter '/spec/' 8 | 9 | add_group 'DSL', 'lib/protector/dsl.rb' 10 | add_group 'Railtie', 'lib/protector/engine.rb' 11 | add_group 'ActiveRecord', 'lib/protector/adapters/active_record' 12 | add_group 'Sequel', 'lib/protector/adapters/sequel' 13 | 14 | at_exit do; end 15 | end 16 | 17 | Bundler.require 18 | 19 | require_relative 'contexts/paranoid' 20 | require_relative 'examples/model' 21 | 22 | RSpec.configure do |config| 23 | config.treat_symbols_as_metadata_keys_with_true_values = true 24 | config.run_all_when_everything_filtered = true 25 | config.filter_run :focus 26 | 27 | config.after(:suite) do 28 | if SimpleCov.running 29 | silence_stream(STDOUT) do 30 | SimpleCov::Formatter::HTMLFormatter.new.format(SimpleCov.result) 31 | end 32 | 33 | SimpleCov::Formatter::SummaryFormatter.new.format(SimpleCov.result) 34 | end 35 | end 36 | 37 | # Run specs in random order to surface order dependencies. If you find an 38 | # order dependency and want to debug it, you can fix the order by providing 39 | # the seed, which is printed after each run. 40 | # --seed 1234 41 | config.order = 'random' 42 | end -------------------------------------------------------------------------------- /spec/spec_helpers/contexts/paranoid.rb: -------------------------------------------------------------------------------- 1 | shared_context "paranoidal", paranoid: true do 2 | before(:all) do 3 | @paranoid_condition = Protector.config.paranoid? 4 | Protector.config.paranoid = true 5 | end 6 | 7 | after(:all) do 8 | Protector.config.paranoid = @paranoid_condition 9 | end 10 | end 11 | 12 | shared_context "adequate", paranoid: false do 13 | before(:all) do 14 | @paranoid_condition = Protector.config.paranoid? 15 | Protector.config.paranoid = false 16 | end 17 | 18 | after(:all) do 19 | Protector.config.paranoid = @paranoid_condition 20 | end 21 | end -------------------------------------------------------------------------------- /spec/spec_helpers/examples/model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a model" do 2 | it "evaluates meta properly" do 3 | dummy.instance_eval do 4 | protect do |subject, entry| 5 | subject.should == '!' 6 | entry.protector_subject?.should == false 7 | 8 | scope { limit(5) } 9 | 10 | can :read 11 | can :create 12 | can :update 13 | end 14 | end 15 | 16 | fields = Hash[*%w(id string number text dummy_id).map{|x| [x, nil]}.flatten] 17 | meta = dummy.new.restrict!('!').protector_meta 18 | 19 | meta.access[:read].should == fields 20 | meta.access[:create].should == fields 21 | meta.access[:update].should == fields 22 | end 23 | 24 | it "respects inheritance" do 25 | dummy.instance_eval do 26 | protect do 27 | can :read, :test 28 | end 29 | end 30 | 31 | attempt = Class.new(dummy) do 32 | protect do 33 | can :create, :test 34 | end 35 | end 36 | 37 | dummy.protector_meta.evaluate(nil, nil).access.should == {read: {"test"=>nil}} 38 | attempt.protector_meta.evaluate(nil, nil).access.should == {read: {"test"=>nil}, create: {"test"=>nil}} 39 | end 40 | 41 | it "drops meta on restrict" do 42 | d = Dummy.first 43 | 44 | d.restrict!('!').protector_meta 45 | d.instance_variable_get('@protector_meta').should_not == nil 46 | d.restrict!('!') 47 | d.instance_variable_get('@protector_meta').should == nil 48 | end 49 | 50 | it "doesn't get stuck with non-existing tables" do 51 | Rumba.class_eval do 52 | protect do 53 | end 54 | end 55 | end 56 | 57 | describe "visibility" do 58 | it "marks blocked" do 59 | Dummy.first.restrict!('-').visible?.should == false 60 | end 61 | 62 | context "adequate", paranoid: false do 63 | it "marks allowed" do 64 | Dummy.first.restrict!('!').visible?.should == true 65 | Dummy.first.restrict!('+').visible?.should == true 66 | end 67 | end 68 | 69 | context "paranoid", paranoid: true do 70 | it "marks allowed" do 71 | Dummy.first.restrict!('!').visible?.should == false 72 | Dummy.first.restrict!('+').visible?.should == true 73 | end 74 | end 75 | end 76 | 77 | # 78 | # Reading 79 | # 80 | describe "readability" do 81 | it "hides fields" do 82 | dummy.instance_eval do 83 | protect do 84 | can :read, :string 85 | end 86 | end 87 | 88 | d = dummy.first.restrict!('!') 89 | d.number.should == nil 90 | d[:number].should == nil 91 | read_attribute(d, :number).should_not == nil 92 | d.string.should == 'zomgstring' 93 | end 94 | 95 | it "shows fields" do 96 | dummy.instance_eval do 97 | protect do 98 | can :read, :number 99 | end 100 | end 101 | 102 | d = dummy.first.restrict!('!') 103 | d.number.should_not == nil 104 | d[:number].should_not == nil 105 | d['number'].should_not == nil 106 | read_attribute(d, :number).should_not == nil 107 | end 108 | end 109 | 110 | # 111 | # Creating 112 | # 113 | describe "creatability" do 114 | context "with empty meta" do 115 | before(:each) do 116 | dummy.instance_eval do 117 | protect do; end 118 | end 119 | end 120 | 121 | it "handles empty creations" do 122 | d = dummy.new.restrict!('!') 123 | d.can?(:create).should == false 124 | d.creatable?.should == false 125 | d.should invalidate 126 | end 127 | 128 | it "marks blocked" do 129 | d = dummy.new(string: 'bam', number: 1) 130 | d.restrict!('!').creatable?.should == false 131 | end 132 | 133 | it "invalidates" do 134 | d = dummy.new(string: 'bam', number: 1).restrict!('!') 135 | d.should invalidate 136 | end 137 | end 138 | 139 | context "by list of fields" do 140 | before(:each) do 141 | dummy.instance_eval do 142 | protect do 143 | can :create, :string 144 | end 145 | end 146 | end 147 | 148 | it "marks blocked" do 149 | d = dummy.new(string: 'bam', number: 1).restrict!('!') 150 | d.creatable?.should == false 151 | end 152 | 153 | it "marks allowed" do 154 | d = dummy.new(string: 'bam').restrict!('!') 155 | $debug = true 156 | d.creatable?.should == true 157 | end 158 | 159 | it "invalidates" do 160 | d = dummy.new(string: 'bam', number: 1).restrict!('!') 161 | d.should invalidate 162 | end 163 | 164 | it "validates" do 165 | d = dummy.new(string: 'bam').restrict!('!') 166 | d.should validate 167 | end 168 | end 169 | 170 | context "by lambdas" do 171 | before(:each) do 172 | dummy.instance_eval do 173 | protect do 174 | can :create, string: lambda {|x| x.try(:length) == 5 } 175 | end 176 | end 177 | end 178 | 179 | it "marks blocked" do 180 | d = dummy.new(string: 'bam') 181 | d.restrict!('!').creatable?.should == false 182 | end 183 | 184 | it "marks allowed" do 185 | d = dummy.new(string: '12345') 186 | d.restrict!('!').creatable?.should == true 187 | end 188 | 189 | it "invalidates" do 190 | d = dummy.new(string: 'bam').restrict!('!') 191 | d.should invalidate 192 | end 193 | 194 | it "validates" do 195 | d = dummy.new(string: '12345').restrict!('!') 196 | d.should validate 197 | end 198 | end 199 | 200 | context "by ranges" do 201 | before(:each) do 202 | dummy.instance_eval do 203 | protect do 204 | can :create, number: 0..2 205 | end 206 | end 207 | end 208 | 209 | it "marks blocked" do 210 | d = dummy.new(number: 500) 211 | d.restrict!('!').creatable?.should == false 212 | end 213 | 214 | it "marks allowed" do 215 | d = dummy.new(number: 2) 216 | d.restrict!('!').creatable?.should == true 217 | end 218 | 219 | it "invalidates" do 220 | d = dummy.new(number: 500).restrict!('!') 221 | d.should invalidate 222 | end 223 | 224 | it "validates" do 225 | d = dummy.new(number: 2).restrict!('!') 226 | d.should validate 227 | end 228 | end 229 | 230 | context "by direct values" do 231 | before(:each) do 232 | dummy.instance_eval do 233 | protect do 234 | can :create, number: 5 235 | end 236 | end 237 | end 238 | 239 | it "marks blocked" do 240 | d = dummy.new(number: 500) 241 | d.restrict!('!').creatable?.should == false 242 | end 243 | 244 | it "marks allowed" do 245 | d = dummy.new(number: 5) 246 | d.restrict!('!').creatable?.should == true 247 | end 248 | 249 | it "invalidates" do 250 | d = dummy.new(number: 500).restrict!('!') 251 | d.should invalidate 252 | end 253 | 254 | it "validates" do 255 | d = dummy.new(number: 5).restrict!('!') 256 | d.should validate 257 | end 258 | end 259 | end 260 | 261 | # 262 | # Updating 263 | # 264 | describe "updatability" do 265 | context "with empty meta" do 266 | before(:each) do 267 | dummy.instance_eval do 268 | protect do; end 269 | end 270 | end 271 | 272 | it "marks blocked" do 273 | d = dummy.first 274 | assign!(d, string: 'bam', number: 1) 275 | d.restrict!('!').updatable?.should == false 276 | end 277 | 278 | it "invalidates" do 279 | d = dummy.first.restrict!('!') 280 | assign!(d, string: 'bam', number: 1) 281 | d.should invalidate 282 | end 283 | end 284 | 285 | context "by list of fields" do 286 | before(:each) do 287 | dummy.instance_eval do 288 | protect do 289 | can :update, :string 290 | end 291 | end 292 | end 293 | 294 | it "marks blocked" do 295 | d = dummy.first 296 | assign!(d, string: 'bam', number: 1) 297 | d.restrict!('!').updatable?.should == false 298 | end 299 | 300 | it "marks allowed" do 301 | d = dummy.first 302 | assign!(d, string: 'bam') 303 | d.restrict!('!').updatable?.should == true 304 | end 305 | 306 | it "invalidates" do 307 | d = dummy.first.restrict!('!') 308 | assign!(d, string: 'bam', number: 1) 309 | d.should invalidate 310 | end 311 | 312 | it "validates" do 313 | d = dummy.first.restrict!('!') 314 | assign!(d, string: 'bam') 315 | d.should validate 316 | end 317 | end 318 | 319 | context "by lambdas" do 320 | before(:each) do 321 | dummy.instance_eval do 322 | protect do 323 | can :update, string: lambda {|x| x.try(:length) == 5 } 324 | end 325 | end 326 | end 327 | 328 | it "marks blocked" do 329 | d = dummy.first 330 | assign!(d, string: 'bam') 331 | d.restrict!('!').updatable?.should == false 332 | end 333 | 334 | it "marks allowed" do 335 | d = dummy.first 336 | assign!(d, string: '12345') 337 | d.restrict!('!').updatable?.should == true 338 | end 339 | 340 | it "invalidates" do 341 | d = dummy.first.restrict!('!') 342 | assign!(d, string: 'bam') 343 | d.should invalidate 344 | end 345 | 346 | it "validates" do 347 | d = dummy.first.restrict!('!') 348 | assign!(d, string: '12345') 349 | d.should validate 350 | end 351 | end 352 | 353 | context "by ranges" do 354 | before(:each) do 355 | dummy.instance_eval do 356 | protect do 357 | can :update, number: 0..2 358 | end 359 | end 360 | end 361 | 362 | it "marks blocked" do 363 | d = dummy.first 364 | assign!(d, number: 500) 365 | d.restrict!('!').updatable?.should == false 366 | end 367 | 368 | it "marks allowed" do 369 | d = dummy.first 370 | assign!(d, number: 2) 371 | d.restrict!('!').updatable?.should == true 372 | end 373 | 374 | it "invalidates" do 375 | d = dummy.first.restrict!('!') 376 | assign!(d, number: 500) 377 | d.should invalidate 378 | end 379 | 380 | it "validates" do 381 | d = dummy.first.restrict!('!') 382 | assign!(d, number: 2) 383 | d.should validate 384 | end 385 | end 386 | 387 | context "by direct values" do 388 | before(:each) do 389 | dummy.instance_eval do 390 | protect do 391 | can :update, number: 5 392 | end 393 | end 394 | end 395 | 396 | it "marks blocked" do 397 | d = dummy.first 398 | assign!(d, number: 500) 399 | d.restrict!('!').updatable?.should == false 400 | end 401 | 402 | it "marks allowed" do 403 | d = dummy.first 404 | assign!(d, number: 5) 405 | d.restrict!('!').updatable?.should == true 406 | end 407 | 408 | it "invalidates" do 409 | d = dummy.first.restrict!('!') 410 | assign!(d, number: 500) 411 | d.should invalidate 412 | end 413 | 414 | it "validates" do 415 | d = dummy.first.restrict!('!') 416 | assign!(d, number: 5) 417 | d.should validate 418 | end 419 | end 420 | end 421 | 422 | # 423 | # Destroying 424 | # 425 | describe "destroyability" do 426 | it "marks blocked" do 427 | dummy.instance_eval do 428 | protect do; end 429 | end 430 | 431 | dummy.first.restrict!('!').destroyable?.should == false 432 | end 433 | 434 | it "marks allowed" do 435 | dummy.instance_eval do 436 | protect do; can :destroy; end 437 | end 438 | 439 | dummy.first.restrict!('!').destroyable?.should == true 440 | end 441 | 442 | it "invalidates" do 443 | dummy.instance_eval do 444 | protect do; end 445 | end 446 | 447 | d = dummy.create.restrict!('!') 448 | d.should survive 449 | end 450 | 451 | it "validates" do 452 | dummy.instance_eval do 453 | protect do; can :destroy; end 454 | end 455 | 456 | d = dummy.create.restrict!('!') 457 | d.should destroy 458 | end 459 | end 460 | 461 | # 462 | # Associations 463 | # 464 | describe "association" do 465 | context "(has_many)" do 466 | context "adequate", paranoid: false do 467 | it "loads" do 468 | Dummy.first.restrict!('!').fluffies.length.should == 2 469 | Dummy.first.restrict!('+').fluffies.length.should == 1 470 | Dummy.first.restrict!('-').fluffies.empty?.should == true 471 | end 472 | end 473 | context "paranoid", paranoid: true do 474 | it "loads" do 475 | Dummy.first.restrict!('!').fluffies.empty?.should == true 476 | Dummy.first.restrict!('+').fluffies.length.should == 1 477 | Dummy.first.restrict!('-').fluffies.empty?.should == true 478 | end 479 | end 480 | end 481 | 482 | context "(belongs_to)" do 483 | context "adequate", paranoid: false do 484 | it "passes subject" do 485 | Fluffy.first.restrict!('!').dummy.protector_subject.should == '!' 486 | end 487 | 488 | it "loads" do 489 | Fluffy.first.restrict!('!').dummy.should be_a_kind_of(Dummy) 490 | Fluffy.first.restrict!('-').dummy.should == nil 491 | end 492 | end 493 | 494 | context "paranoid", paranoid: true do 495 | it "loads" do 496 | Fluffy.first.restrict!('!').dummy.should == nil 497 | Fluffy.first.restrict!('-').dummy.should == nil 498 | end 499 | end 500 | end 501 | end 502 | end --------------------------------------------------------------------------------