├── .config └── cucumber.yml ├── .gitignore ├── .rspec ├── .travis.yml ├── .yardopts ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── eaco.gemspec ├── features ├── active_record.example.yml ├── active_record.travis.yml ├── authorization_parse_error.feature ├── enterprise_authorization.feature ├── rails_integration.feature ├── role_based_authorization.feature ├── step_definitions │ ├── actor_steps.rb │ ├── controller_steps.rb │ ├── enterprise_steps.rb │ ├── error_steps.rb │ ├── fixture_steps.rb │ └── resource_steps.rb └── support │ └── env.rb ├── gemfiles ├── rails_3.2.gemfile ├── rails_4.0.gemfile ├── rails_4.1.gemfile ├── rails_4.2.gemfile ├── rails_5.0.gemfile ├── rails_5.1.gemfile ├── rails_5.2.gemfile ├── rails_6.0.gemfile └── rails_6.1.gemfile ├── lib ├── eaco.rb └── eaco │ ├── acl.rb │ ├── actor.rb │ ├── adapters.rb │ ├── adapters │ ├── active_record.rb │ ├── active_record │ │ ├── compatibility.rb │ │ ├── compatibility │ │ │ ├── sanitized.rb │ │ │ ├── scoped.rb │ │ │ ├── v32.rb │ │ │ ├── v40.rb │ │ │ ├── v41.rb │ │ │ ├── v42.rb │ │ │ ├── v50.rb │ │ │ ├── v51.rb │ │ │ ├── v52.rb │ │ │ ├── v60.rb │ │ │ └── v61.rb │ │ └── postgres_jsonb.rb │ ├── couchrest_model.rb │ └── couchrest_model │ │ └── couchdb_lucene.rb │ ├── controller.rb │ ├── coverage.rb │ ├── cucumber.rb │ ├── cucumber │ ├── active_record.rb │ ├── active_record │ │ ├── department.rb │ │ ├── document.rb │ │ ├── position.rb │ │ ├── schema.rb │ │ ├── user.rb │ │ └── user │ │ │ ├── designators.rb │ │ │ └── designators │ │ │ ├── authenticated.rb │ │ │ ├── department.rb │ │ │ ├── position.rb │ │ │ └── user.rb │ └── world.rb │ ├── designator.rb │ ├── dsl.rb │ ├── dsl │ ├── acl.rb │ ├── actor.rb │ ├── actor │ │ └── designators.rb │ ├── base.rb │ ├── resource.rb │ └── resource │ │ └── permissions.rb │ ├── error.rb │ ├── railtie.rb │ ├── rake.rb │ ├── rake │ ├── default_task.rb │ └── utils.rb │ ├── resource.rb │ ├── rspec.rb │ └── version.rb └── spec ├── eaco ├── acl_spec.rb ├── actor_spec.rb ├── controller_spec.rb ├── designator_spec.rb ├── dsl │ ├── acl_spec.rb │ ├── actor │ │ └── designators_spec.rb │ ├── actor_spec.rb │ ├── resource │ │ └── permissions_spec.rb │ └── resource_spec.rb ├── error_spec.rb └── resource_spec.rb ├── eaco_spec.rb └── spec_helper.rb /.config/cucumber.yml: -------------------------------------------------------------------------------- 1 | default: --format progress 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | gemfiles/*.gemfile.lock 8 | _yardoc 9 | coverage 10 | doc/ 11 | features/active_record.yml 12 | features/active_record.log 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | tmp 17 | *.bundle 18 | *.so 19 | *.o 20 | *.a 21 | mkmf.log 22 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: xenial 3 | 4 | language: ruby 5 | 6 | rvm: 7 | - 2.1.10 8 | - 2.2.10 9 | - 2.3.8 10 | - 2.4.10 11 | - 2.5.9 12 | - 2.6.7 13 | - 2.7.3 14 | - 3.0.1 15 | 16 | gemfile: 17 | - gemfiles/rails_3.2.gemfile 18 | - gemfiles/rails_4.0.gemfile 19 | - gemfiles/rails_4.1.gemfile 20 | - gemfiles/rails_4.2.gemfile 21 | - gemfiles/rails_5.0.gemfile 22 | - gemfiles/rails_5.1.gemfile 23 | - gemfiles/rails_5.2.gemfile 24 | - gemfiles/rails_6.0.gemfile 25 | - gemfiles/rails_6.1.gemfile 26 | 27 | jobs: 28 | exclude: 29 | - rvm: 2.1.10 30 | gemfile: gemfiles/rails_5.0.gemfile 31 | - rvm: 2.1.10 32 | gemfile: gemfiles/rails_5.1.gemfile 33 | - rvm: 2.1.10 34 | gemfile: gemfiles/rails_5.2.gemfile 35 | - rvm: 2.1.10 36 | gemfile: gemfiles/rails_6.0.gemfile 37 | - rvm: 2.1.10 38 | gemfile: gemfiles/rails_6.1.gemfile 39 | 40 | - rvm: 2.2.10 41 | gemfile: gemfiles/rails_6.0.gemfile 42 | - rvm: 2.2.10 43 | gemfile: gemfiles/rails_6.1.gemfile 44 | 45 | - rvm: 2.3.8 46 | gemfile: gemfiles/rails_4.0.gemfile 47 | - rvm: 2.3.8 48 | gemfile: gemfiles/rails_4.1.gemfile 49 | - rvm: 2.3.8 50 | gemfile: gemfiles/rails_6.0.gemfile 51 | - rvm: 2.3.8 52 | gemfile: gemfiles/rails_6.1.gemfile 53 | 54 | - rvm: 2.4.10 55 | gemfile: gemfiles/rails_3.2.gemfile 56 | - rvm: 2.4.10 57 | gemfile: gemfiles/rails_4.0.gemfile 58 | - rvm: 2.4.10 59 | gemfile: gemfiles/rails_4.1.gemfile 60 | - rvm: 2.4.10 61 | gemfile: gemfiles/rails_6.0.gemfile 62 | - rvm: 2.4.10 63 | gemfile: gemfiles/rails_6.1.gemfile 64 | 65 | - rvm: 2.5.9 66 | gemfile: gemfiles/rails_3.2.gemfile 67 | - rvm: 2.5.9 68 | gemfile: gemfiles/rails_4.0.gemfile 69 | - rvm: 2.5.9 70 | gemfile: gemfiles/rails_4.1.gemfile 71 | - rvm: 2.5.9 72 | gemfile: gemfiles/rails_4.2.gemfile 73 | 74 | - rvm: 2.6.7 75 | gemfile: gemfiles/rails_3.2.gemfile 76 | - rvm: 2.6.7 77 | gemfile: gemfiles/rails_4.0.gemfile 78 | - rvm: 2.6.7 79 | gemfile: gemfiles/rails_4.1.gemfile 80 | - rvm: 2.6.7 81 | gemfile: gemfiles/rails_4.2.gemfile 82 | 83 | - rvm: 2.7.3 84 | gemfile: gemfiles/rails_3.2.gemfile 85 | - rvm: 2.7.3 86 | gemfile: gemfiles/rails_4.0.gemfile 87 | - rvm: 2.7.3 88 | gemfile: gemfiles/rails_4.1.gemfile 89 | - rvm: 2.7.3 90 | gemfile: gemfiles/rails_4.2.gemfile 91 | - rvm: 2.7.3 92 | gemfile: gemfiles/rails_5.0.gemfile 93 | - rvm: 2.7.3 94 | gemfile: gemfiles/rails_5.1.gemfile 95 | - rvm: 2.7.3 96 | gemfile: gemfiles/rails_5.2.gemfile 97 | 98 | - rvm: 3.0.1 99 | gemfile: gemfiles/rails_3.2.gemfile 100 | - rvm: 3.0.1 101 | gemfile: gemfiles/rails_4.0.gemfile 102 | - rvm: 3.0.1 103 | gemfile: gemfiles/rails_4.1.gemfile 104 | - rvm: 3.0.1 105 | gemfile: gemfiles/rails_4.2.gemfile 106 | - rvm: 3.0.1 107 | gemfile: gemfiles/rails_5.0.gemfile 108 | - rvm: 3.0.1 109 | gemfile: gemfiles/rails_5.1.gemfile 110 | - rvm: 3.0.1 111 | gemfile: gemfiles/rails_5.2.gemfile 112 | 113 | cache: bundler 114 | 115 | addons: 116 | postgresql: "9.4" 117 | 118 | before_script: 119 | - psql -c "CREATE DATABASE eaco;" -U postgres 120 | 121 | script: bundle exec rake EACO_AR_CONFIG=./features/active_record.travis.yml 122 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --private 3 | --no-private 4 | --hide-void-return 5 | --plugin cucumber 6 | '{lib,features}/**/*.rb' - README.md LICENSE.txt 7 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # Test against 3.2 -> 5.2 2 | # 3 | appraise 'rails-3.2' do 4 | gem 'rails', '~> 3.2.0' 5 | gem 'pg', '~> 0.21' 6 | gem 'activerecord-postgres-json' 7 | end 8 | 9 | appraise 'rails-4.0' do 10 | gem 'rails', '~> 4.0.0' 11 | gem 'pg', '~> 0.21' 12 | end 13 | 14 | appraise 'rails-4.1' do 15 | gem 'rails', '~> 4.1.0' 16 | gem 'pg', '~> 0.21' 17 | end 18 | 19 | appraise 'rails-4.2' do 20 | gem 'rails', '~> 4.2.0' 21 | gem 'pg', '~> 0.21' 22 | end 23 | 24 | appraise 'rails-5.0' do 25 | gem 'rails', '~> 5.0.0' 26 | gem 'pg', '~> 0.21' 27 | end 28 | 29 | appraise 'rails-5.1' do 30 | gem 'rails', '~> 5.1.0' 31 | gem 'pg', '~> 0.21' 32 | end 33 | 34 | appraise 'rails-5.2' do 35 | gem 'rails', '~> 5.2.0' 36 | end 37 | 38 | appraise 'rails-6.0' do 39 | gem 'rails', '~> 6.0.0' 40 | end 41 | 42 | appraise 'rails-6.1' do 43 | gem 'rails', '~> 6.1.0' 44 | end 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) and this 6 | project adheres to [Semantic Versioning](http://semver.org/) 7 | 8 | ## 1.1.1 - 2017-03-08 9 | 10 | ### Fixed 11 | * Fix ActionDispatch::Reloader.to_prepare deprecation 12 | 13 | ## 1.1.0 - 2016-09-27 14 | 15 | ### Changed 16 | 17 | * Add support for Rails 5. 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in eaco.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # Eaco's Guardfile 2 | # 3 | unless ENV['BUNDLE_GEMFILE'] =~ %r{gemfiles/rails} 4 | abort 'specs and features require appraisal. Try `appraisal rails-4.2 guard`' 5 | end 6 | 7 | # Watch lib/ and spec/ 8 | directories %w(lib spec features) 9 | 10 | # Clear the screen before every task 11 | clearing :on 12 | 13 | guard :rspec, version: 3, cmd: 'bundle exec rspec' do 14 | # When single specs change, run them. 15 | watch(%r{^spec/.+_spec\.rb$}) 16 | 17 | # When spec_helper changes rerun all the specs. 18 | watch('spec/spec_helper.rb') { "spec" } 19 | 20 | # When a source changes run its unit spec. 21 | watch(%r{^lib/(.+)\.rb$}) {|m| "spec/#{m[1]}_spec.rb" } 22 | end 23 | 24 | guard :cucumber do 25 | # When single features change, run them. 26 | watch(%r{^features/.+\.feature$}) 27 | 28 | # When support code changes, rerun all features. 29 | watch(%r{^features/support/.+$}) { 'features' } 30 | 31 | # When a step definition for a feature changes, rerun the corresponding feature. 32 | watch(%r{^features/step_definitions/(.+)_steps\.rb$}) { |m| Dir[File.join("**/#{m[1]}.feature")][0] || 'features' } 33 | end 34 | 35 | guard :shell do 36 | # Rerun scenarios when source code changes 37 | watch(%r{^lib/.+\.rb$}) { system 'cucumber -f progress' } 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2015 IFAD 2 | Copyright (c) 2013-2015 Marcello Barnaba 3 | 4 | MIT License 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eaco 2 | 3 | [![Build Status](https://travis-ci.org/ifad/eaco.svg)](https://travis-ci.org/ifad/eaco) 4 | [![Coverage Status](https://coveralls.io/repos/ifad/eaco/badge.svg)](https://coveralls.io/r/ifad/eaco) 5 | [![Code Climate](https://codeclimate.com/github/ifad/eaco/badges/gpa.svg)](https://codeclimate.com/github/ifad/eaco) 6 | [![Inline docs](http://inch-ci.org/github/ifad/eaco.svg?branch=master)](http://inch-ci.org/github/ifad/eaco) 7 | [![Gem Version](https://badge.fury.io/rb/eaco.svg)](http://badge.fury.io/rb/eaco) 8 | 9 | Eacus, the holder of the keys of Hades, is an Attribute-Based Access Control ([ABAC](https://en.wikipedia.org/wiki/Attribute-based_access_control)) authorization 10 | framework for Ruby. 11 | 12 | ![Eaco e Telamone][eaco-e-telamone] 13 | 14 | *"Aeacus telemon by user Ravenous at en.wikipedia.org - Public domain through Wikimedia Commons - http://commons.wikimedia.org/wiki/File:Aeacus_telemon.jpg"* 15 | 16 | ## Design 17 | 18 | Eaco provides your application's Resources discretionary access based on attributes. 19 | Access to a Resource by an Actor is determined by checking whether the Actor owns 20 | the security attributes (Designators) required by the Resource. 21 | 22 | Each Resource protected by Eaco has an ACL attached. ACLs define which security 23 | attribute grant access to the Resource, and at which level. The level of access 24 | is expressed in terms of roles. Roles are scoped per Resource types. 25 | 26 | Each Role then describes a set of abilities that it can perform. In your code, 27 | you check directly whether an Actor has a specific ability on a Resource, and 28 | all the indirection is then evaluated by Eaco. 29 | 30 | ## Designators 31 | 32 | Security attributes are extracted out of Actors through the Designators framework, 33 | a pluggable mechanism whose details are up to your application. 34 | 35 | An Actor can have many designators, that describe its identity or its belonging 36 | to a group or occupying a position in a department. 37 | 38 | Designators are Ruby classes that can embed any sort of custom behaviour that 39 | your application requires. 40 | 41 | ## ACLS 42 | 43 | ACLs are hashes with designators as keys and roles as values. Extracting 44 | authorized collections requires only an hash key lookup mechanism in your 45 | database. Adapters are provided for PG's +jsonb+ and for CouchDB-Lucene. 46 | 47 | ## Installation 48 | 49 | Add this line to your application's Gemfile: 50 | 51 | gem 'eaco' 52 | 53 | And then execute: 54 | 55 | $ bundle 56 | 57 | ## Usage 58 | 59 | Create `config/authorization.rb` [(rdoc)](http://www.rubydoc.info/github/ifad/eaco/master/Eaco/DSL) 60 | 61 | ```ruby 62 | # Defines `Document` to be an authorized resource. 63 | # 64 | # Adds Document.accessible_by and Document#allows? 65 | # 66 | authorize Document, using: :pg_jsonb do 67 | roles :owner, :editor, :reader 68 | 69 | permissions do 70 | reader :read 71 | editor reader, :edit 72 | owner editor, :destroy 73 | end 74 | end 75 | 76 | # Defines an actor and the sources from which the 77 | # designators are harvested. 78 | # 79 | # Adds User#designators 80 | # 81 | actor User do 82 | admin do |user| 83 | user.admin? 84 | end 85 | 86 | designators do 87 | user from: :id 88 | group from: :groups 89 | tag from: :tags 90 | end 91 | end 92 | ``` 93 | 94 | Given a Resource [(rdoc)](http://www.rubydoc.info/github/ifad/eaco/master/Eaco/Resource) 95 | with an ACL [(rdoc)](http://www.rubydoc.info/github/ifad/eaco/master/Eaco/ACL): 96 | 97 | ```ruby 98 | # An example ACL 99 | >> document = Document.first 100 | => # 101 | 102 | >> document.acl 103 | => # :owner, "group:reviewers" => :reader}> 104 | ``` 105 | 106 | and an Actor [(rdoc)](http://www.rubydoc.info/github/ifad/eaco/master/Eaco/Actor): 107 | 108 | ```ruby 109 | # An example Actor 110 | >> user = User.find(10) 111 | => # 112 | 113 | >> user.designators 114 | => #, #, # } 115 | ``` 116 | 117 | you can check if the Actor can perform a specific action on the Resource: 118 | 119 | ```ruby 120 | >> user.can? :read, document 121 | => true 122 | 123 | >> document.allows? :read, user 124 | => true 125 | ``` 126 | 127 | and which access level (`role`) the Actor has for this Resource: 128 | 129 | ```ruby 130 | >> document.roles_of user 131 | => [:owner] 132 | 133 | >> boss = User.find_by_group('reviewer').first 134 | => # 135 | 136 | >> document.roles_of boss 137 | => [:reader] 138 | 139 | >> boss.can? :read, document 140 | => true 141 | 142 | >> boss.can? :destroy, document 143 | => false 144 | 145 | >> user.can? :destroy, document 146 | => true 147 | ``` 148 | 149 | Grant reader access to a specific user: 150 | 151 | ```ruby 152 | >> user 153 | => # 154 | 155 | >> document.grant :reader, :user, user.id 156 | => # :reader> 157 | 158 | >> user.can? :read, document 159 | => true 160 | ``` 161 | 162 | Grant reader access to a group: 163 | 164 | ```ruby 165 | >> user 166 | => # 167 | 168 | >> document.grant :reader, :group, 3 169 | => # :reader> 170 | 171 | >> user.can? :read, document 172 | => true 173 | 174 | >> document.allows? :read, user 175 | => true 176 | ``` 177 | 178 | Obtain a collection of Resources accessible by a given Actor 179 | [(rdoc)](http://www.rubydoc.info/github/ifad/eaco/master/Eaco/Adapters): 180 | 181 | ```ruby 182 | >> Document.accessible_by(user) 183 | ``` 184 | 185 | Check whether a controller action can be accessed by an user. Your 186 | Controller must respond to `current_user` for this to work. 187 | [(rdoc)](http://www.rubydoc.info/github/ifad/eaco/master/Eaco/Controller) 188 | 189 | ```ruby 190 | class DocumentsController < ApplicationController 191 | before_filter :find_document 192 | 193 | authorize :show, [:document, :read] 194 | authorize :edit, [:document, :edit] 195 | 196 | private 197 | def find_document 198 | @document = Document.find(params[:id]) 199 | end 200 | end 201 | ``` 202 | 203 | ## Running specs 204 | 205 | You need a running postgresql 9.4 instance. 206 | 207 | Create an user and a database: 208 | 209 | $ sudo -u postgres psql 210 | 211 | postgres=# CREATE ROLE eaco LOGIN; 212 | CREATE ROLE 213 | 214 | postgres=# CREATE DATABASE eaco OWNER eaco ENCODING 'utf8'; 215 | CREATE DATABASE 216 | 217 | postgres=# ^D 218 | 219 | Create `features/active_record.yml` with your database configuration, 220 | see `features/active_record.example.yml` for an example. 221 | 222 | Run `bundle` once. This will install the base bundle. 223 | 224 | Run `appraisal` once. This will install the supported Rails versions and +pg+. 225 | 226 | Run `rake`. This will run the specs and cucumber features and report coverage. 227 | 228 | Specs are run against the supported rails versions in turn. If you want to 229 | focus on a single release, use `appraisal rails-X.Y rake`, where `X.Y` can be 230 | `3.2`, `4.0`, `4.1` or `4.2`. 231 | 232 | ## Contributing 233 | 234 | 1. Fork it ( https://github.com/ifad/eaco/fork ) 235 | 2. Create your feature branch (`git checkout -b my-new-feature`) 236 | 3. Commit your changes (`git commit -am 'Add some feature'`) 237 | 4. Push to the branch (`git push origin my-new-feature`) 238 | 5. Create a new Pull Request 239 | 240 | ## Denominazione d'Origine Controllata 241 | 242 | This software is Made in Italy :it: :smile:. 243 | 244 | [eaco-e-telamone]: http://upload.wikimedia.org/wikipedia/commons/7/70/Aeacus_telemon.jpg 245 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Bundler 2 | require 'bundler/setup' 3 | require 'bundler/gem_tasks' 4 | 5 | # YARD 6 | require 'yard' 7 | YARD::Rake::YardocTask.new 8 | 9 | # RSpec 10 | require 'rspec/core/rake_task' 11 | RSpec::Core::RakeTask.new 12 | 13 | # Appraisal 14 | require 'appraisal/task' 15 | Appraisal::Task.new 16 | 17 | # Cucumber 18 | require 'cucumber' 19 | require 'cucumber/rake/task' 20 | Cucumber::Rake::Task.new 21 | 22 | # Our default rake task 23 | require 'eaco/rake' 24 | Eaco::Rake::DefaultTask.new 25 | 26 | # Thanks for reading. 27 | -------------------------------------------------------------------------------- /eaco.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'eaco/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "eaco" 8 | spec.version = Eaco::VERSION 9 | spec.authors = ["Marcello Barnaba"] 10 | spec.email = ["vjt@openssl.it"] 11 | spec.summary = %q{Authorization framework} 12 | spec.homepage = "https://github.com/ifad/eaco" 13 | spec.license = "MIT" 14 | 15 | spec.files = `git ls-files -z`.split("\x0") 16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_development_dependency "bundler", '>= 1.6', '< 3' 21 | spec.add_development_dependency "rake" 22 | spec.add_development_dependency "byebug" 23 | spec.add_development_dependency "guard" 24 | spec.add_development_dependency "yard" 25 | spec.add_development_dependency "appraisal" 26 | spec.add_development_dependency "rspec" 27 | spec.add_development_dependency "guard-rspec" 28 | spec.add_development_dependency "cucumber" 29 | spec.add_development_dependency "guard-cucumber" 30 | spec.add_development_dependency "yard-cucumber" 31 | spec.add_development_dependency "coveralls" 32 | spec.add_development_dependency "guard-shell" 33 | spec.add_development_dependency "multi_json" 34 | spec.add_development_dependency "rails" 35 | spec.add_development_dependency "pg" 36 | end 37 | -------------------------------------------------------------------------------- /features/active_record.example.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL 9.4 and up is required. 2 | # 3 | adapter: "postgresql" 4 | database: "eaco" 5 | username: "aecus" 6 | password: "theGreat!" 7 | encoding: "utf8" 8 | hostname: "localhost" 9 | -------------------------------------------------------------------------------- /features/active_record.travis.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL 9.4 and up is required. 2 | # 3 | adapter: "postgresql" 4 | hostname: "localhost" 5 | database: "eaco" 6 | username: "postgres" 7 | password: "" 8 | -------------------------------------------------------------------------------- /features/authorization_parse_error.feature: -------------------------------------------------------------------------------- 1 | Feature: Authorization rules error handling 2 | When there's an error in the authorization rules, 3 | it is reported in detail with a backtrace showing 4 | where it happened. 5 | 6 | Scenario: Giving rubbish 7 | When I have a wrong authorization definition such as 8 | """ 9 | 1=1 10 | """ 11 | Then I should receive a DSL error SyntaxError saying 12 | """ 13 | \(feature\):1: syntax error, unexpected '=', expecting end-of-input 14 | """ 15 | 16 | Scenario: Referencing a non-existing model 17 | When I have a wrong authorization definition such as 18 | """ 19 | authorize ::Nonexistant, using: :pg_jsonb 20 | """ 21 | Then I should receive a DSL error Eaco::Error saying 22 | """ 23 | uninitialized constant Nonexistant 24 | """ 25 | 26 | Scenario: Specifying an actor class with no Designators namespace 27 | When I have a wrong authorization definition such as 28 | """ 29 | class ::Foo 30 | end 31 | 32 | actor Foo do 33 | designators do 34 | frobber yay: true 35 | end 36 | end 37 | """ 38 | Then I should receive a DSL error Eaco::Error saying 39 | """ 40 | Please put designators implementations in Foo::Designators 41 | """ 42 | 43 | Scenario: Specifing a non-existing designator implementation 44 | When I have a wrong authorization definition on model User such as 45 | """ 46 | actor $MODEL do 47 | designators do 48 | fropper from: :sgurtz 49 | end 50 | end 51 | """ 52 | Then I should receive a DSL error Eaco::Error saying 53 | """ 54 | Implementation .+User::Designators::Fropper for Designator fropper not found 55 | """ 56 | 57 | Scenario: Badly specifying the designator options 58 | When I have a wrong authorization definition on model User such as 59 | """ 60 | actor $MODEL do 61 | designators do 62 | user on_the_rocks: true 63 | end 64 | end 65 | """ 66 | Then I should receive a DSL error Eaco::Error saying 67 | """ 68 | The designator option :from is required 69 | """ 70 | 71 | Scenario: Badly specifying the permissions options 72 | When I have a wrong authorization definition on model Document such as 73 | """ 74 | authorize $MODEL do 75 | permissions do 76 | reader "Asdrubbale" 77 | end 78 | end 79 | """ 80 | Then I should receive a DSL error Eaco::Error saying 81 | """ 82 | Invalid reader permission definition: "Asdrubbale" 83 | """ 84 | 85 | Scenario: Authorizing an Object with no known ORM 86 | When I have a wrong authorization definition such as 87 | """ 88 | class ::Foo 89 | end 90 | 91 | authorize Foo 92 | """ 93 | Then I should receive a DSL error Eaco::Error saying 94 | """ 95 | Don't know how to persist ACLs using 's ORM 96 | """ 97 | 98 | Scenario: Authorizing an Resource with no known .accessible_by 99 | When I have a wrong authorization definition such as 100 | """ 101 | class ::Bar 102 | attr_accessor :acl 103 | end 104 | 105 | authorize Bar 106 | """ 107 | Then I should receive a DSL error Eaco::Error saying 108 | """ 109 | Don't know how to look up authorized records on 's ORM 110 | """ 111 | 112 | Scenario: Authorizing a Resource with a known ORM but without the acl field 113 | When I have a wrong authorization definition on model Department such as 114 | """ 115 | authorize $MODEL 116 | """ 117 | Then I should receive a DSL error Eaco::Error saying 118 | """ 119 | Please define a jsonb column named `acl` on .+Department 120 | """ 121 | 122 | Scenario: Authorizing a Resource with a known ORM but unknown strategy 123 | When I have a wrong authorization definition on model Document such as 124 | """ 125 | authorize $MODEL 126 | """ 127 | Then I should receive a DSL error Eaco::Error saying 128 | """ 129 | .+Document.+ORM.+ActiveRecord::Base.+ use one of the available strategies: pg_jsonb 130 | """ 131 | 132 | Scenario: Authorizing a Resource with the wrong ACL column type 133 | When I have a wrong authorization definition such as 134 | """ 135 | class ::Grabach < ActiveRecord::Base 136 | connection.create_table 'grabaches' do |t| 137 | t.string :acl 138 | end 139 | end 140 | 141 | authorize ::Grabach 142 | """ 143 | Then I should receive a DSL error Eaco::Error saying 144 | """ 145 | The `acl` column on Grabach must be of the jsonb type 146 | """ 147 | 148 | Scenario: Using an unsupported ActiveRecord version 149 | When I am using ActiveRecord 3.0 150 | And I have a wrong authorization definition on model Document such as 151 | """ 152 | authorize $MODEL 153 | """ 154 | Then I should receive a DSL error Eaco::Error saying 155 | """ 156 | Unsupported Active Record version: 30 157 | """ 158 | -------------------------------------------------------------------------------- /features/enterprise_authorization.feature: -------------------------------------------------------------------------------- 1 | Feature: Role-based, flexible authorization 2 | In an enterprise, rights might be granted to specific users, to any users, 3 | or to specific departments or to specific positions in said departments. 4 | 5 | Background: 6 | Given I have an User actor defined as 7 | """ 8 | actor $MODEL do 9 | designators do 10 | authenticated from: :class 11 | user from: :id 12 | position from: :position_ids 13 | department from: :department_names 14 | end 15 | end 16 | """ 17 | Given I have a Document resource defined as 18 | """ 19 | authorize $MODEL, using: :pg_jsonb do 20 | roles :writer, :reader 21 | 22 | role :reader, "R/O" 23 | role :writer, "R/W" 24 | 25 | permissions do 26 | reader :read 27 | writer reader, :write 28 | end 29 | end 30 | """ 31 | 32 | Given I have the following User records 33 | | id | name | 34 | | 1 | Dennis Ritchie | 35 | | 2 | Rob Pike | 36 | | 3 | William Gates | 37 | | 4 | Steve Jobs | 38 | | 5 | Tim Berners-Lee | 39 | 40 | Given I have the following Department records 41 | | id | name | 42 | | 1 | ICT | 43 | | 2 | BAR | 44 | | 3 | COM | 45 | 46 | Given I have the following Position records 47 | | id | name | department_id | user_id | 48 | | 1 | Director | 1 | 1 | 49 | | 2 | Systems Analyst | 1 | 2 | 50 | | 3 | Bartender | 2 | 3 | 51 | | 4 | Director | 3 | 4 | 52 | | 5 | Social Media Manager | 3 | 5 | 53 | 54 | Given I have the following Document records 55 | | name | acl | 56 | | ICT Status Report | {"department:ICT":"reader", "position:1":"writer"} | 57 | | ICT Budget Report | {"position:1":"writer"} | 58 | | Cafeteria Menu | {"position:3":"writer", "authenticated:Eaco::Cucumber::ActiveRecord::User":"reader"} | 59 | | Tim's Web Project | {"user:5":"writer", "position:2":"reader"} | 60 | 61 | Scenario: Granting access in batches 62 | When I have a confidential Document named "For BAR and ICT" 63 | And I grant access to Document "For BAR and ICT" to the following designators as reader 64 | | department:BAR | 65 | | department:ICT | 66 | When I am "Dennis Ritchie" 67 | Then I can read the Document "For BAR and ICT" being a reader 68 | But I can not write the Document "For BAR and ICT" being a reader 69 | When I am "Rob Pike" 70 | Then I can read the Document "For BAR and ICT" being a reader 71 | But I can not write the Document "For BAR and ICT" being a reader 72 | When I am "William Gates" 73 | Then I can read the Document "For BAR and ICT" being a reader 74 | But I can not write the Document "For BAR and ICT" being a reader 75 | When I am "Steve Jobs" 76 | Then I can not read the Document "For BAR and ICT" 77 | And I can not write the Document "For BAR and ICT" 78 | 79 | 80 | Scenario: The Director can access confidential document 81 | When I am "Dennis Ritchie" 82 | Then I can read the Document "ICT Status Report" being a writer 83 | And I can write the Document "ICT Budget Report" being a writer 84 | And I can read the Document "Cafeteria Menu" being a reader 85 | But I can not read the Document "Tim's Web Project" 86 | When I ask for Documents I can access, I get 87 | | ICT Status Report | 88 | | ICT Budget Report | 89 | | Cafeteria Menu | 90 | 91 | Scenario: Rob can see Tim's document 92 | When I am "Rob Pike" 93 | Then I can read the Documents "ICT Status Report, Tim's Web Project" being a reader 94 | But I can not write the Document "Tim's Web Project" being a reader 95 | When I ask for Documents I can access, I get 96 | | ICT Status Report | 97 | | Tim's Web Project | 98 | | Cafeteria Menu | 99 | 100 | Scenario: Tim can work on his project 101 | When I am "Tim Berners-Lee" 102 | Then I can not read the Document "ICT Status Report, ICT Budget Report" 103 | And I can read the Document "Tim's Web Project" being a writer 104 | And I can write the Document "Tim's Web Project" being a writer 105 | When I ask for Documents I can access, I get 106 | | Tim's Web Project | 107 | | Cafeteria Menu | 108 | 109 | Scenario: Bill is maintaining the Cafeteria Menu 110 | When I am "William Gates" 111 | Then I can not read the Documents "ICT Status Report, ICT Budget Report, Tim's Web Project" 112 | But I can write the Document "Cafeteria Menu" being a writer 113 | When I ask for Documents I can access, I get 114 | | Cafeteria Menu | 115 | 116 | Scenario: Steve can just read the menu 117 | When I am "Steve Jobs" 118 | Then I can not read the Documents "ICT Status Report, ICT Budget Report, Tim's Web Project" 119 | And I can not write the Document "Cafeteria Menu" being a reader 120 | But I can read the Document "Cafeteria Menu" being a reader 121 | When I ask for Documents I can access, I get 122 | | Cafeteria Menu | 123 | 124 | Scenario: Resolving a specific user 125 | When I parse the Designator "user:4" 126 | Then it should describe itself as "User 'Steve Jobs'" 127 | And it should have a label of "User" 128 | And it should serialize to JSON as {"label": "User 'Steve Jobs'", "value": "user:4"} 129 | And it should resolve itself to 130 | | Steve Jobs | 131 | 132 | Scenario: Resolving the ICT Director 133 | When I make a Designator with "position" and "1" 134 | Then it should describe itself as "Director in ICT" 135 | And it should have a label of "Position" 136 | And it should serialize to JSON as {"label": "Director in ICT", "value": "position:1"} 137 | And it should resolve itself to 138 | | Dennis Ritchie | 139 | 140 | Scenario: Resolving the ICT Department 141 | When I parse the Designator "department:ICT" 142 | Then it should describe itself as "ICT" 143 | And it should have a label of "Department" 144 | And it should serialize to JSON as {"label": "ICT", "value": "department:ICT"} 145 | And it should resolve itself to 146 | | Dennis Ritchie | 147 | | Rob Pike | 148 | 149 | Scenario: Resolving all authenticated users 150 | When I make a Designator with "authenticated" and "Eaco::Cucumber::ActiveRecord::User" 151 | Then it should describe itself as "Any authenticated user" 152 | And it should have a label of "Any user" 153 | And it should serialize to JSON as {"label": "Any authenticated user", "value": "authenticated:Eaco::Cucumber::ActiveRecord::User"} 154 | And it should resolve itself to 155 | | Dennis Ritchie | 156 | | Rob Pike | 157 | | William Gates | 158 | | Steve Jobs | 159 | | Tim Berners-Lee | 160 | 161 | Scenario: Resolving different designators 162 | When I have the following designators 163 | | department:ICT | 164 | | position:3 | 165 | | user:1 | 166 | Then they should resolve to 167 | | Dennis Ritchie | 168 | | Rob Pike | 169 | | William Gates | 170 | 171 | Scenario: Resolving an invalid designator 172 | When I parse the invalid Designator "foo:on the rocks" 173 | Then I should receive a Designator error Eaco::Error saying 174 | """ 175 | Designator not found: "foo" 176 | """ 177 | 178 | Scenario: Obtaining the role of a valid designator 179 | When I parse the Designator "department:ICT" 180 | Then its role on the Document "ICT Status Report" should be reader 181 | And its role on the Documents "Cafeteria Menu, ICT Budget Report, Tim's Web Project" should be nil 182 | When I make a Designator with "position" and "1" 183 | Then its role on the Documents "ICT Status Report, ICT Budget Report" should be writer 184 | And its role on the Documents "Cafeteria Menu, Tim's Web Project" should be nil 185 | 186 | Scenario: Obtaining the role of an invalid object 187 | When I have a plain object as a Designator 188 | Then its role on the Document "ICT Status Report" should give an Eaco::Error error saying 189 | """ 190 | roles_of expects .+Object.+ to be a Designator or to .+respond_to.+:designators 191 | """ 192 | 193 | Scenario: Obtaining labels for roles 194 | When I ask the Document the list of roles and labels 195 | Then I should get the following roles and labels 196 | | writer | R/W | 197 | | reader | R/O | 198 | 199 | Scenario: Authorizing a controller 200 | When I have an authorized Controller defined as 201 | """ 202 | if ActionPack::VERSION::MAJOR >= 5 203 | before_action :find_document 204 | else 205 | before_filter :find_document 206 | end 207 | 208 | authorize :show, [:document, :read ] 209 | authorize :edit, [:document, :write] 210 | 211 | def show 212 | head :ok 213 | end 214 | 215 | def edit 216 | head :ok 217 | end 218 | 219 | private 220 | 221 | def find_document 222 | @document = Eaco::Cucumber::ActiveRecord::Document.where(name: params[:name]).first 223 | end 224 | """ 225 | When I am "Dennis Ritchie" 226 | And I invoke the Controller "show" action with query string "name=ICT Status Report" 227 | Then the Controller should not raise an error 228 | And I invoke the Controller "edit" action with query string "name=ICT Status Report" 229 | Then the Controller should not raise an error 230 | 231 | When I am "Rob Pike" 232 | And I invoke the Controller "show" action with query string "name=ICT Status Report" 233 | Then the Controller should not raise an error 234 | And I invoke the Controller "edit" action with query string "name=ICT Status Report" 235 | Then the Controller should raise an Eaco::Forbidden error saying 236 | """ 237 | User.+not authorized to `edit' on .+Document 238 | """ 239 | 240 | When I am "William Gates" 241 | And I invoke the Controller "edit" action with query string "name=Cafeteria Menu" 242 | Then the Controller should not raise an error 243 | And I invoke the Controller "show" action with query string "name=ICT Status Report" 244 | Then the Controller should raise an Eaco::Forbidden error saying 245 | """ 246 | User.+not authorized to `show' on .+Document 247 | """ 248 | 249 | When I am "Steve Jobs" 250 | And I invoke the Controller "show" action with query string "name=One More Thing" 251 | Then the Controller should raise an Eaco::Error error saying 252 | """ 253 | @document is not set, can't authorize .+show 254 | """ 255 | -------------------------------------------------------------------------------- /features/rails_integration.feature: -------------------------------------------------------------------------------- 1 | Feature: Rails integration 2 | The framework should play nice with the most recent major Rails version 3 | 4 | Background: 5 | Given I have a Document resource defined as 6 | """ 7 | authorize $MODEL, using: :pg_jsonb 8 | """ 9 | 10 | Scenario: 11 | Then I should be able to set an ACL on the Document 12 | -------------------------------------------------------------------------------- /features/role_based_authorization.feature: -------------------------------------------------------------------------------- 1 | Feature: Role-Based authorization 2 | Access to a Resource by an Actor is determined by the 3 | ACL set on the Resource and the Designators the Actor 4 | is eligible. 5 | 6 | Background: 7 | Given I have a Document resource defined as 8 | """ 9 | authorize $MODEL, using: :pg_jsonb do 10 | roles :reader, :writer 11 | 12 | permissions do 13 | reader :read 14 | writer reader, :write 15 | end 16 | end 17 | """ 18 | And I have an User actor defined as 19 | """ 20 | actor $MODEL do 21 | admin do |user| 22 | user.admin? 23 | end 24 | 25 | designators do 26 | user from: :id 27 | end 28 | end 29 | """ 30 | Given I have an User actor named "Bob" 31 | And I have an User actor named "Tom" 32 | 33 | Scenario: Discretionary access to a Resource 34 | When I have a confidential Document named "Supa Dupa Fly" 35 | And I grant Bob access to Document "Supa Dupa Fly" as a reader in quality of user 36 | Then Bob should be able to read Document "Supa Dupa Fly" 37 | But Bob should not be able to write Document "Supa Dupa Fly" 38 | And Tom should not be able to read Document "Supa Dupa Fly" 39 | But I revoke Bob access to Document "Supa Dupa Fly" in quality of user 40 | Then Bob should not be able to read Document "Supa Dupa Fly" 41 | 42 | Scenario: Extraction of accessible Resources 43 | When I have a confidential Document named "Strategic Plan" 44 | And I grant Bob access to Document "Strategic Plan" as a reader in quality of user 45 | And I have a confidential Document named "For Tom" 46 | And I grant Tom access to Document "For Tom" as a reader in quality of user 47 | And I have a confidential Document named "For no one" 48 | Then Bob can see only "Strategic Plan" in the Document authorized list 49 | And Tom can see only "For Tom" in the Document authorized list 50 | 51 | Scenario: Admin can see everything 52 | When I have an admin User actor named "Boss" 53 | And I have a confidential Document named "For Bob" 54 | And I grant Bob access to Document "For Bob" as a reader in quality of user 55 | And I have a confidential Document named "For no one" 56 | Then Bob can see only "For Bob" in the Document authorized list 57 | But Boss can see "For Bob, For no one" in the Document authorized list 58 | 59 | Scenario: Handling invalid roles 60 | When I have a confidential Document named "Foo Bar" 61 | And I grant Bob access to Document "Foo Bar" as an invalid role frupper in quality of zomg 62 | Then I should receive a Resource error Eaco::Error saying 63 | """ 64 | The `frupper' role is not valid for .+Document' objects. Valid roles are: `reader, writer' 65 | """ 66 | -------------------------------------------------------------------------------- /features/step_definitions/actor_steps.rb: -------------------------------------------------------------------------------- 1 | Given(/I have an (\w+) actor defined as/) do |model_name, actor_definition| 2 | authorize_model model_name, actor_definition 3 | end 4 | 5 | Given(/I have an (\w+) actor named "(.+?)"/) do |model_name, actor_name| 6 | register_actor model_name, actor_name 7 | end 8 | 9 | Given(/I have an admin (\w+) actor named "(.+?)"/) do |model_name, actor_name| 10 | register_actor model_name, actor_name, admin: true 11 | end 12 | 13 | When(/I grant (\w+) access to (\w+) "(.+?)" as a (\w+) in quality of (\w+)/) do |actor_name, resource_model, resource_name, role_name, designator| 14 | actor = fetch_actor(actor_name) 15 | resource = fetch_resource(resource_model, resource_name) 16 | 17 | resource.grant role_name, designator, actor 18 | resource.save! 19 | end 20 | 21 | When(/I grant access to (\w+) "(.+?)" to the following designators as (\w+)/) do |resource_model, resource_name, role, table| 22 | resource = fetch_resource(resource_model, resource_name) 23 | designators = table.raw.flatten.map {|d| Eaco::Designator.parse(d) } 24 | 25 | resource.batch_grant role, designators 26 | resource.save! 27 | end 28 | 29 | When(/I revoke (\w+) access to (\w+) "(.+?)" in quality of (\w+)/) do |actor_name, resource_model, resource_name, designator| 30 | actor = fetch_actor(actor_name) 31 | resource = fetch_resource(resource_model, resource_name) 32 | 33 | resource.revoke designator, actor 34 | resource.save! 35 | end 36 | 37 | Then(/^(\w+) should be able to (\w+) (\w+) "(.+?)"$/) do |actor_name, permission_name, resource_model, resource_name| 38 | actor = fetch_actor(actor_name) 39 | resource = fetch_resource(resource_model, resource_name) 40 | 41 | expect(actor.can?(permission_name, resource)).to be(true) 42 | end 43 | 44 | Then(/^(\w+) should not be able to (\w+) (\w+) "(.+?)"$/) do |actor_name, permission_name, resource_model, resource_name| 45 | actor = fetch_actor(actor_name) 46 | resource = fetch_resource(resource_model, resource_name) 47 | 48 | expect(actor.cannot?(permission_name, resource)).to be(true) 49 | end 50 | -------------------------------------------------------------------------------- /features/step_definitions/controller_steps.rb: -------------------------------------------------------------------------------- 1 | When(/I have an authorized Controller defined as/) do |controller_code| 2 | require 'action_controller' 3 | require 'eaco/controller' 4 | 5 | @controller_class = Class.new(ActionController::Base) 6 | @controller_class.send(:attr_accessor, :current_user) 7 | @controller_class.instance_eval { include Eaco::Controller } 8 | @controller_class.class_eval controller_code 9 | end 10 | 11 | When(/I invoke the Controller "(.+?)" action with query string "(.+?)"$/) do |action_name, query| 12 | @controller = @controller_class.new 13 | @action_name = action_name 14 | 15 | @controller.current_user = @current_user 16 | 17 | #:nocov: 18 | if Rails::VERSION::MAJOR < 5 19 | @controller.request = ActionDispatch::TestRequest.new('QUERY_STRING' => query).tap do |request| 20 | request.params.update('action' => @action_name) 21 | end 22 | else 23 | @controller.request = ActionDispatch::TestRequest.create('QUERY_STRING' => query).tap do |request| 24 | request.action = @action_name 25 | end 26 | end 27 | #:nocov: 28 | 29 | @controller.response = ActionDispatch::TestResponse.new 30 | end 31 | 32 | Then(/the Controller should not raise an error/) do 33 | expect { @controller.process @action_name }.to_not raise_error 34 | end 35 | 36 | Then(/the Controller should raise an (.+?) error saying/) do |error_class, error_contents| 37 | error_class = error_class.constantize 38 | 39 | expect { @controller.process @action_name }.to \ 40 | raise_error(error_class). 41 | with_message(/#{error_contents}/) 42 | end 43 | -------------------------------------------------------------------------------- /features/step_definitions/enterprise_steps.rb: -------------------------------------------------------------------------------- 1 | When(/I am "(.+?)"$/) do |user_name| 2 | model = find_model('User') 3 | 4 | @current_user = model.where(name: user_name).first! 5 | end 6 | 7 | Then(/I can (\w+) the Documents? "(.+?)" being a (\w+)$/) do |permission, document_names, role| 8 | check_documents document_names do |document| 9 | expect(@current_user.can?(permission, document)).to eq(true) 10 | expect(document.roles_of(@current_user)).to include(role.intern) 11 | end 12 | end 13 | 14 | Then(/I can not (\w+) the Documents? "(.+?)" *(?:being a (\w+))?$/) do |permission, document_names, role| 15 | check_documents document_names do |document| 16 | expect(@current_user.cannot?(permission, document)).to eq(true) 17 | 18 | roles = document.roles_of(@current_user) 19 | if role 20 | expect(roles).to include(role.intern) 21 | else 22 | expect(roles).to be_empty 23 | end 24 | end 25 | end 26 | 27 | When(/I ask for Documents I can access, I get/) do |table| 28 | model = find_model('Document') 29 | names = table.raw.flatten 30 | 31 | expect(model.accessible_by(@current_user).map(&:name)).to match_array(names) 32 | end 33 | 34 | When(/I make a Designator with "(\w+)" and "(.+?)"/) do |type, value| 35 | @designator = Eaco::Designator.make(type, value) 36 | end 37 | 38 | When(/I parse the Designator "(.+?)"/) do |text| 39 | @designator = Eaco::Designator.parse(text) 40 | end 41 | 42 | When(/I have a plain object as a Designator/) do 43 | @designator = Object.new 44 | end 45 | 46 | Then(/it should describe itself as "(.+?)"/) do |description| 47 | expect(@designator.describe).to eq(description) 48 | end 49 | 50 | Then(/it should serialize to JSON as (.+?)$/) do |json| 51 | json = MultiJson.load(json).symbolize_keys 52 | 53 | expect(@designator.as_json).to eq(json) 54 | end 55 | 56 | Then(/it should have a label of "(.+?)"/) do |label| 57 | expect(@designator.label).to eq(label) 58 | end 59 | 60 | Then(/it should resolve itself to/) do |table| 61 | names = table.raw.flatten 62 | 63 | expect(@designator.resolve.map(&:name)).to match_array(names) 64 | end 65 | 66 | When(/I have the following designators/) do |table| 67 | @designators = table.raw.flatten 68 | end 69 | 70 | Then(/they should resolve to/) do |table| 71 | resolved = Eaco::Designator.resolve(@designators) 72 | names = table.raw.flatten 73 | 74 | expect(resolved.map(&:name)).to match_array(names) 75 | end 76 | 77 | Then(/its role on the Documents? "(.+?)" should be (\w+)/) do |document_names, role| 78 | role = role == 'nil' ? nil : role.intern 79 | 80 | check_documents document_names do |document| 81 | roles = document.roles_of(@designator) 82 | if role 83 | expect(roles).to include(role) 84 | else 85 | expect(roles).to be_empty 86 | end 87 | end 88 | end 89 | 90 | Then(/its role on the Documents? "(.+?)" should give an (.+?) error saying/) do |document_names, error_class, error_contents| 91 | error_class = error_class.constantize 92 | 93 | check_documents document_names do |document| 94 | expect { document.roles_of(@designator) }.to \ 95 | raise_error(error_class). 96 | with_message(/#{error_contents}/) 97 | end 98 | end 99 | 100 | When(/I ask the Document the list of roles and labels/) do 101 | model = find_model('Document') 102 | 103 | @roles_labels = model.roles_with_labels 104 | end 105 | 106 | Then(/I should get the following roles and labels/) do |table| 107 | expected = table.raw.map do |role, label| 108 | [role.to_sym, label] 109 | end 110 | 111 | expect(@roles_labels.to_a).to match(expected) 112 | end 113 | -------------------------------------------------------------------------------- /features/step_definitions/error_steps.rb: -------------------------------------------------------------------------------- 1 | When(/I have a wrong authorization definition (?:on model (.+?))? *such as/) do |model_name, definition| 2 | @model_name = model_name 3 | @definition = definition 4 | end 5 | 6 | Then(/I should receive a DSL error (.+?) saying/) do |error_class, error_contents| 7 | error_class = error_class.constantize 8 | 9 | model = find_model(@model_name) if @model_name 10 | 11 | error_contents += '.+(feature)' unless error_contents.include?('\(feature\)') 12 | 13 | expect { eval_dsl(@definition, model) }.to \ 14 | raise_error(error_class). 15 | with_message(/#{error_contents}/m) 16 | end 17 | 18 | When(/I am using ActiveRecord (.+?)$/) do |version| 19 | version = version.sub(/\D/, '') # FIXME 20 | 21 | allow_any_instance_of(Eaco::Adapters::ActiveRecord::Compatibility).to \ 22 | receive(:active_record_version).and_return(version) 23 | end 24 | 25 | When(/I parse the invalid Designator "(.+?)"/) do |text| 26 | @designator_text = text 27 | end 28 | 29 | Then(/I should receive a Designator error (.+?) saying/) do |error_class, error_contents| 30 | error_class = error_class.constantize 31 | 32 | expect { Eaco::Designator.parse(@designator_text) }.to \ 33 | raise_error(error_class). 34 | with_message(/#{error_contents}/) 35 | end 36 | 37 | When(/I grant (\w+) access to (\w+) "(.+?)" as an invalid role (\w+) in quality of (\w+)/) do |actor_name, resource_model, resource_name, role_name, designator| 38 | @actor = fetch_actor(actor_name) 39 | @resource = fetch_resource(resource_model, resource_name) 40 | @role_name, @designator = role_name, designator 41 | end 42 | 43 | Then(/I should receive a Resource error (.+?) saying/) do |error_class, error_contents| 44 | error_class = error_class.constantize 45 | 46 | expect { @resource.grant @role_name, @designator, @actor }.to \ 47 | raise_error(error_class). 48 | with_message(/#{error_contents}/) 49 | end 50 | -------------------------------------------------------------------------------- /features/step_definitions/fixture_steps.rb: -------------------------------------------------------------------------------- 1 | Given(/I have the following (\w+) records/) do |model_name, table| 2 | model = find_model(model_name) 3 | 4 | table.hashes.each do |attributes| 5 | instance = model.new 6 | 7 | if attributes.key?('acl') 8 | attributes['acl'] = MultiJson.load(attributes['acl']) 9 | end 10 | 11 | instance.attributes = attributes 12 | instance.save! 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /features/step_definitions/resource_steps.rb: -------------------------------------------------------------------------------- 1 | When(/I have a (\w+) resource defined as/) do |model_name, resource_definition| 2 | authorize_model model_name, resource_definition 3 | end 4 | 5 | When(/I have a confidential (\w+) named "([\w\s]+)"/) do |model_name, resource_name| 6 | register_resource model_name, resource_name 7 | end 8 | 9 | Then(/I should be able to set an ACL on the (\w+)/) do |model_name| 10 | resource_model = find_model(model_name) 11 | instance = resource_model.new 12 | 13 | instance.acl = {"foo" => :bar} 14 | instance.save! 15 | 16 | instance = resource_model.find(instance.id) 17 | 18 | expect(instance.acl).to eq({"foo" => :bar}) 19 | expect(instance.acl).to be_a(resource_model.acl) 20 | end 21 | 22 | Then(/(\w+) can see (?:only)? *"(.*?)" in the (\w+) authorized list/) do |actor_name, resource_names, resource_model| 23 | actor = fetch_actor(actor_name) 24 | 25 | resource_names = resource_names.split(/,\s*/) 26 | resources = resource_names.map {|name| fetch_resource(resource_model, name)} 27 | 28 | resource_model = find_model(resource_model) 29 | accessible = resource_model.accessible_by(actor) 30 | 31 | expect(accessible).to match_array(resources) 32 | end 33 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'rails' 3 | require 'byebug' 4 | require 'multi_json' 5 | 6 | require 'eaco/coverage' 7 | Eaco::Coverage.start! 8 | 9 | require 'eaco' 10 | require 'eaco/cucumber' 11 | 12 | require 'cucumber/rspec/doubles' 13 | 14 | ## 15 | # Create a whole new world. 16 | # @see {World} 17 | # @!method World 18 | World do 19 | Eaco::Cucumber::World.new 20 | end 21 | 22 | ## 23 | # Recreate the schema before each feature, to start fresh. 24 | # @see {ActiveRecord.define_schema!} 25 | # @!method Before 26 | Before do 27 | Eaco::Cucumber::ActiveRecord.define_schema! 28 | end 29 | -------------------------------------------------------------------------------- /gemfiles/rails_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 3.2.0" 6 | gem "pg", "~> 0.21" 7 | gem "activerecord-postgres-json" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 4.0.0" 6 | gem "pg", "~> 0.21" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 4.1.0" 6 | gem "pg", "~> 0.21" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 4.2.0" 6 | gem "pg", "~> 0.21" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.0.0" 6 | gem "pg", "~> 0.21" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_5.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.1.0" 6 | gem "pg", "~> 0.21" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.2.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.1.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/eaco.rb: -------------------------------------------------------------------------------- 1 | require 'eaco/error' 2 | require 'eaco/version' 3 | 4 | if defined? Rails 5 | # :nocov: 6 | require 'eaco/railtie' 7 | # :nocov: 8 | end 9 | 10 | require 'pathname' 11 | 12 | ############################################################################ 13 | # 14 | # Welcome to Eaco! 15 | # 16 | # Eaco is a full-fledged authorization framework for Ruby that allows you to 17 | # describe which actions are allowed on your resources, how to identify your 18 | # users as having a particular privilege and which privileges are granted to 19 | # a specific resource through the usage of ACLs. 20 | # 21 | module Eaco 22 | autoload :ACL, 'eaco/acl' 23 | autoload :Actor, 'eaco/actor' 24 | autoload :Adapters, 'eaco/adapters' 25 | autoload :DSL, 'eaco/dsl' 26 | autoload :Designator, 'eaco/designator' 27 | autoload :Resource, 'eaco/resource' 28 | 29 | # The location of the default rules file 30 | DEFAULT_RULES = Pathname('./config/authorization.rb') 31 | 32 | ## 33 | # Parses and evaluates the authorization rules from the {DEFAULT_RULES}. 34 | # 35 | # The authorization rules define all the authorization framework behaviour 36 | # through the {DSL} 37 | # 38 | # @return (see .eval!) 39 | # 40 | def self.parse_default_rules_file! 41 | parse_rules! DEFAULT_RULES 42 | end 43 | 44 | ## 45 | # Parses the given +rules+ file. 46 | # 47 | # @param rules [Pathname] 48 | # 49 | # @return (see .eval!) 50 | # 51 | # @raise [Malformed] if the +rules+ file does not exist. 52 | # 53 | def self.parse_rules!(rules) 54 | unless rules.exist? 55 | path = rules.realpath rescue rules.to_s 56 | raise Malformed, "Please create #{path} with Eaco authorization rules" 57 | end 58 | 59 | eval! rules.read, rules.realpath.to_s 60 | end 61 | 62 | ## 63 | # Evaluates the given authorization rules +source+, orignally found on 64 | # +path+. 65 | # 66 | # @param source [String] {DSL} source code 67 | # @param path [String] Source code origin, for better backtraces. 68 | # 69 | # @return true 70 | # 71 | # @raise [Error] if something goes wrong while evaluating the DSL. 72 | # 73 | # @see DSL 74 | # 75 | def self.eval!(source, path) 76 | DSL.send :eval, source, nil, path, 1 77 | 78 | true 79 | rescue => e 80 | raise Error, <<-EOF 81 | 82 | === EACO === Error while evaluating rules 83 | 84 | #{e.message} 85 | 86 | +--------- -- - 87 | | #{e.backtrace.join("\n | ")} 88 | +- 89 | 90 | === EACO === 91 | 92 | EOF 93 | end 94 | 95 | end 96 | -------------------------------------------------------------------------------- /lib/eaco/acl.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | ## 4 | # An ACL is an Hash whose keys are Designator string representations and 5 | # values are the role symbols defined in the Resource permissions 6 | # configuration. 7 | # 8 | # Example: 9 | # 10 | # authorize Document do 11 | # roles :reader, :editor 12 | # end 13 | # 14 | # @see Actor 15 | # @see Resource 16 | # 17 | class ACL < Hash 18 | 19 | ## 20 | # Builds a new ACL object from the given Hash representation with strings 21 | # as keys and values. 22 | # 23 | # @param definition [Hash] the ACL hash 24 | # 25 | # @return [ACL] this ACL 26 | # 27 | def initialize(definition = {}) 28 | (definition || {}).each do |designator, role| 29 | self[designator] = role.intern 30 | end 31 | end 32 | 33 | ## 34 | # Gives the given Designator access as the given +role+. 35 | # 36 | # @param role [Symbol] the role to grant 37 | # @param designator [Variadic] (see {#identify}) 38 | # 39 | # @return [ACL] this ACL 40 | # 41 | def add(role, *designator) 42 | identify(*designator).each do |key| 43 | self[key] = role 44 | end 45 | 46 | self 47 | end 48 | 49 | ## 50 | # Removes access from the given Designator. 51 | # 52 | # @param designator [Variadic] (see {#identify}) 53 | # 54 | # @return [ACL] this ACL 55 | # 56 | # @see Designator 57 | # 58 | def del(*designator) 59 | identify(*designator).each do |key| 60 | self.delete(key.to_s) 61 | end 62 | self 63 | end 64 | 65 | ## 66 | # @param name [Symbol] The role name 67 | # 68 | # @return [Set] A set of Designators having the given +role+. 69 | # 70 | # @see Designator 71 | # @see Resource 72 | # 73 | def find_by_role(name) 74 | self.inject(Set.new) do |ret, (designator, role)| 75 | ret.tap { ret.add Designator.parse(designator) if role == name } 76 | end 77 | end 78 | 79 | ## 80 | # @return [Set] all Designators in the ACL, regardless of their role. 81 | # 82 | def all 83 | self.inject(Set.new) do |ret, (designator,_)| 84 | ret.add Designator.parse(designator) 85 | end 86 | end 87 | 88 | ## 89 | # Gets a map of Actors in the ACL having the given +role+. 90 | # 91 | # This is a useful starting point for an Enterprise summary page of who is 92 | # granted to access a resource. Given that actor resolution is dynamic and 93 | # handled by the application's Designators implementation, you can rely on 94 | # your internal organigram APIs to resolve actual people out of positions, 95 | # groups, department of assignment, etc. 96 | # 97 | # @param name [Symbol] The role name 98 | # 99 | # @return [Hash] keyed by designator with Set of Actors as values 100 | # 101 | # @see Actor 102 | # @see Resource 103 | # 104 | def designators_map_for_role(name) 105 | find_by_role(name).inject({}) do |ret, designator| 106 | actors = designator.resolve 107 | 108 | ret.tap do 109 | ret[designator] ||= Set.new 110 | ret[designator].merge Array.new(actors) 111 | end 112 | end 113 | end 114 | 115 | ## 116 | # @param name [Symbol] the role name 117 | # 118 | # @return [Set] Actors having the given +role+. 119 | # 120 | # @see Actor 121 | # @see Resource 122 | # 123 | def actors_by_role(name) 124 | find_by_role(name).inject(Set.new) do |set, designator| 125 | set |= Array.new(designator.resolve) 126 | end.to_a 127 | end 128 | 129 | ## 130 | # Pretty prints this ACL in your console. 131 | # 132 | def inspect 133 | "#<#{self.class.name}: #{super}>" 134 | end 135 | alias pretty_print_inspect inspect 136 | 137 | ## 138 | # Pretty print for +pry+. 139 | # 140 | def pretty_inspect 141 | "#{self.class.name}\n#{super}" 142 | end 143 | 144 | protected 145 | 146 | ## 147 | # There are three ways of specifying designators: 148 | # 149 | # * Passing an +Designator+ instance obtained from somewhere else: 150 | # 151 | # >> designator 152 | # => # 153 | # 154 | # >> resource.acl.add :reader, designator 155 | # => #:reader}> 156 | # 157 | # * Passing a designator type and an unique ID valid in the designator's 158 | # namespace: 159 | # 160 | # >> resource.acl.add :reader, :user, 42 161 | # => #:reader}> 162 | # 163 | # * Passing a designator type and an Actor instance, will add all 164 | # designators of the given type owned by the Actor. 165 | # 166 | # >> actor 167 | # => # 168 | # 169 | # >> actor.designators 170 | # => #, 172 | # | #, 173 | # | # 174 | # | }> 175 | # 176 | # >> resource.acl.add :editor, :group, actor 177 | # => #:editor, 179 | # | "group:medium bloggers"=>:editor 180 | # | } 181 | # 182 | # @param designator [Designator] the designator to grant 183 | # @param actor_or_id [Actor] or [String] the actor 184 | # 185 | def identify(designator, actor_or_id = nil) 186 | if designator.is_a?(Eaco::Designator) 187 | [designator] 188 | 189 | elsif designator && actor_or_id.respond_to?(:designators) 190 | designator = designator.to_sym 191 | actor_or_id.designators.select {|d| d.type == designator} 192 | 193 | elsif designator.is_a?(Symbol) 194 | [Eaco::Designator.make(designator, actor_or_id)] 195 | 196 | else 197 | raise Error, <<-EOF 198 | Cannot infer designator 199 | from #{designator.inspect} 200 | and #{actor_or_id.inspect} 201 | EOF 202 | end 203 | end 204 | end 205 | 206 | end 207 | -------------------------------------------------------------------------------- /lib/eaco/actor.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | ## 4 | # An Actor is an entity whose access to Resources is discretionary, 5 | # depending on the Role this actor has in the ACL. 6 | # 7 | # The role of this +Actor+ is calculated from the {Designator} that 8 | # the actor instance has, and the {ACL} instance attached to the 9 | # {Resource}. 10 | # 11 | # @see ACL 12 | # @see Resource 13 | # @see Resource.roles_of 14 | # @see DSL::Actor 15 | # 16 | module Actor 17 | 18 | # @private 19 | def self.included(base) 20 | base.extend ClassMethods 21 | end 22 | 23 | ## 24 | # Singleton methods added to Actor classes. 25 | # 26 | module ClassMethods 27 | ## 28 | # The designators implementations defined for this Actor as an Hash 29 | # keyed by designator type symbol and with the concrete Designator 30 | # implementations as values. 31 | # 32 | # @see DSL::Actor#initialize 33 | # 34 | def designators 35 | end 36 | 37 | ## 38 | # The logic that evaluates whether an Actor instance is an admin. 39 | # 40 | # @see DSL::Actor#initialize 41 | # 42 | def admin_logic 43 | end 44 | end 45 | 46 | ## 47 | # @return [Set] the designators granted to this Actor. 48 | # 49 | # @see Designator 50 | # 51 | def designators 52 | Set.new.tap do |ret| 53 | self.class.designators.each do |_, designator| 54 | ret.merge designator.harvest(self) 55 | end 56 | end 57 | end 58 | 59 | ## 60 | # Checks whether this Actor fulfills the admin logic. 61 | # 62 | # This logic is called by +Resource+ Adapters' +accessible_by+, that 63 | # returns the full collection, and by {Resource#allows?}, that bypassess 64 | # access checks always returning true. 65 | # 66 | # @return [Boolean] True or False if admin logic is defined, nil if not. 67 | # 68 | def is_admin? 69 | return unless self.class.admin_logic 70 | 71 | instance_exec(self, &self.class.admin_logic) 72 | end 73 | 74 | ## 75 | # Checks wether the given Resource allows this Actor to perform the given action. 76 | # 77 | # @param action [Symbol] a valid action for this Resource (see {DSL::Resource}) 78 | # @param resource [Resource] an authorized resource 79 | # 80 | # @see Resource 81 | # 82 | def can?(action, resource) 83 | resource.allows?(action, self) 84 | end 85 | 86 | ## 87 | # Opposite of {#can?}. 88 | # 89 | # @param (see #can?) 90 | # @return (see #can?) 91 | # 92 | def cannot?(*args) 93 | !can?(*args) 94 | end 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /lib/eaco/adapters.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | # Persistance adapters for ACL objects and authorized collections extractor 4 | # strategies. 5 | # 6 | # @see ActiveRecord 7 | # @see CouchrestModel 8 | # 9 | module Adapters 10 | autoload :ActiveRecord, 'eaco/adapters/active_record' 11 | autoload :CouchrestModel, 'eaco/adapters/couchrest_model' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | 4 | ## 5 | # PostgreSQL 9.4 and up backing store for ACLs. 6 | # 7 | # @see ACL 8 | # @see PostgresJSONb 9 | # 10 | module ActiveRecord 11 | autoload :PostgresJSONb, 'eaco/adapters/active_record/postgres_jsonb' 12 | autoload :Compatibility, 'eaco/adapters/active_record/compatibility' 13 | 14 | ## 15 | # Currently defined collection extraction strategies. 16 | # 17 | # @return Hash 18 | # 19 | def self.strategies 20 | {:pg_jsonb => PostgresJSONb} 21 | end 22 | 23 | ## 24 | # Checks whether the model's data structure fits the ACL persistance 25 | # requirements. 26 | # 27 | # @param base [Class] your application's model 28 | # 29 | # @return void 30 | # 31 | def self.included(base) 32 | Compatibility.new(base).check! 33 | 34 | return unless base.table_exists? 35 | 36 | column = base.columns_hash.fetch('acl', nil) 37 | 38 | unless column 39 | raise Malformed, "Please define a jsonb column named `acl` on #{base}." 40 | end 41 | 42 | unless column.type == :json || column.type == :jsonb 43 | raise Malformed, "The `acl` column on #{base} must be of the jsonb type." 44 | end 45 | end 46 | 47 | ## 48 | # @return [ACL] this Resource's ACL. 49 | # 50 | # @see ACL 51 | # 52 | def acl 53 | acl = read_attribute(:acl) 54 | self.class.acl.new(acl) 55 | end 56 | 57 | ## 58 | # Sets the Resource's ACL. 59 | # 60 | # @param acl [ACL] the new ACL to set. 61 | # 62 | # @return [ACL] 63 | # 64 | # @see ACL 65 | # 66 | def acl=(acl) 67 | write_attribute :acl, acl.to_hash 68 | end 69 | end 70 | 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | 5 | ## 6 | # Sets up JSONB support for the different AR versions 7 | # 8 | class Compatibility 9 | autoload :V32, 'eaco/adapters/active_record/compatibility/v32.rb' 10 | autoload :V40, 'eaco/adapters/active_record/compatibility/v40.rb' 11 | autoload :V41, 'eaco/adapters/active_record/compatibility/v41.rb' 12 | autoload :V42, 'eaco/adapters/active_record/compatibility/v42.rb' 13 | autoload :V50, 'eaco/adapters/active_record/compatibility/v50.rb' 14 | autoload :V51, 'eaco/adapters/active_record/compatibility/v51.rb' 15 | autoload :V52, 'eaco/adapters/active_record/compatibility/v52.rb' 16 | autoload :V60, 'eaco/adapters/active_record/compatibility/v60.rb' 17 | autoload :V61, 'eaco/adapters/active_record/compatibility/v61.rb' 18 | 19 | autoload :Scoped, 'eaco/adapters/active_record/compatibility/scoped.rb' 20 | autoload :Sanitized, 'eaco/adapters/active_record/compatibility/sanitized.rb' 21 | 22 | ## 23 | # Memoizes the given +model+ for later {#check!} calls. 24 | # 25 | # @param model [ActiveRecord::Base] the model to check 26 | # 27 | def initialize(model) 28 | @model = model 29 | end 30 | 31 | ## 32 | # Checks whether ActiveRecord::Base is compatible. 33 | # Looks up the {#support_module} and includes it. 34 | # 35 | # @see #support_module 36 | # 37 | # @return [void] 38 | # 39 | def check! 40 | layer = support_module 41 | ::ActiveRecord::Base.instance_eval { include layer } 42 | end 43 | 44 | private 45 | 46 | ## 47 | # @return [String] the +ActiveRecord+ major and minor version numbers 48 | # 49 | # Example: "42" for 4.2 50 | # 51 | def active_record_version 52 | [ 53 | ::ActiveRecord::VERSION::MAJOR, 54 | ::ActiveRecord::VERSION::MINOR 55 | ].join 56 | end 57 | 58 | ## 59 | # Tries to look up the support module for the {#active_record_version} 60 | # in the {Compatibility} namespace. 61 | # 62 | # @return [Module] the support module 63 | # 64 | # @raise [Eaco::Error] if not found. 65 | # 66 | # @see check! 67 | # 68 | def support_module 69 | unless self.class.const_defined?(support_module_name) 70 | raise Eaco::Error, <<-EOF 71 | Unsupported Active Record version: #{active_record_version} 72 | EOF 73 | end 74 | 75 | self.class.const_get support_module_name 76 | end 77 | 78 | ## 79 | # @return [String] "V" with {.active_record_version} appended. 80 | # 81 | # Example: "V32" for Rails 3.2. 82 | # 83 | def support_module_name 84 | ['V', active_record_version].join 85 | end 86 | 87 | end 88 | 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/sanitized.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Aliases `sanitize` as `connection.quote` for ActiveRecord >= 5.1. 8 | # 9 | module Sanitized 10 | 11 | ## 12 | # Just returns +ActiveRecord::Base.connection.quote+. 13 | # 14 | def sanitize(d) 15 | self.connection.quote(d) 16 | end 17 | end 18 | 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/scoped.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Aliases `scoped` as `all` for ActiveRecord >= 4.1. 8 | # 9 | # TODO maybe is against Rails' practices to revive a 10 | # dead API? It may be, comments accepted. 11 | # 12 | module Scoped 13 | 14 | ## 15 | # Just returns +ActiveRecord::Base.all+. 16 | # 17 | def scoped 18 | all 19 | end 20 | end 21 | 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v32.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 3.2 JSONB support module. 8 | # 9 | # Uses https://github.com/romanbsd/activerecord-postgres-json to do 10 | # the dirty compatibility stuff. This module only uses +.serialize+ 11 | # to set the +JSON+ coder. 12 | # 13 | module V32 14 | require 'activerecord-postgres-json' 15 | 16 | ## 17 | # Sets the JSON coder on the acl column 18 | # 19 | def self.included(base) 20 | base.serialize :acl, ::ActiveRecord::Coders::JSON 21 | end 22 | end 23 | 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v40.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails v4.0.X compatibility layer for jsonb 8 | # 9 | module V40 10 | ## 11 | # 12 | # Sets up the OID Type Map, reloads it, hacks native database types, 13 | # and makes jsonb mimick itself as a json - for the rest of the AR 14 | # machinery to work intact. 15 | # 16 | # @param base [Class] the +ActiveRecord+ model to mangle 17 | # @return [void] 18 | # 19 | def self.included(base) 20 | adapter = base.connection 21 | 22 | adapter.class::OID.register_type 'jsonb', adapter.class::OID::Json.new 23 | adapter.send :reload_type_map 24 | 25 | adapter.native_database_types.update(jsonb: {name: 'jsonb'}) 26 | 27 | adapter.class.parent::PostgreSQLColumn.instance_eval do 28 | include Column 29 | end 30 | 31 | base.extend Scoped 32 | end 33 | 34 | ## 35 | # Patches to ActiveRecord::ConnectionAdapters::PostgreSQLColumn 36 | # 37 | module Column 38 | ## 39 | # Makes +sql_type+ return +json+ for +jsonb+ columns. This is 40 | # an hack to let the casting machinery in AR 4.0 keep working 41 | # with the unsupported +jsonb+ type. 42 | # 43 | # @return [String] the SQL type. 44 | # 45 | def sql_type 46 | orig_type = super 47 | orig_type == 'jsonb' ? 'json' : orig_type 48 | end 49 | 50 | ## 51 | # Makes +simplified_type+ return +json+ for +jsonb+ columns 52 | # 53 | # @param field_type [String] the database field type 54 | # @return [Symbol] the simplified type 55 | # 56 | def simplified_type(field_type) 57 | if field_type == 'jsonb' 58 | :json 59 | else 60 | super 61 | end 62 | end 63 | end 64 | end 65 | 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v41.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 4.1 support module. 8 | # 9 | # Magically, the 4.0 hacks work on 4.1. But on 4.1 we need the 10 | # +.scoped+ API so we revive it through the {Scoped} module. 11 | # 12 | # @see V40 13 | # @see Scoped 14 | # 15 | module V41 16 | extend ActiveSupport::Concern 17 | 18 | included do 19 | include V40 20 | extend Scoped 21 | end 22 | end 23 | 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v42.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 4.2 support module. 8 | # 9 | # JSONB works correctly, but we need +.scoped+ so we revive it through 10 | # the {Scoped} support module. 11 | # 12 | # @see Scoped 13 | # 14 | module V42 15 | extend ActiveSupport::Concern 16 | 17 | included do 18 | extend Scoped 19 | end 20 | end 21 | 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v50.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 5.0 support module. 8 | # 9 | # JSONB works correctly, but we need +.scoped+ so we revive it through 10 | # the {Scoped} support module. 11 | # 12 | # @see Scoped 13 | # 14 | module V50 15 | extend ActiveSupport::Concern 16 | 17 | included do 18 | extend Scoped 19 | end 20 | end 21 | 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v51.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 5.1 support module. 8 | # 9 | # JSONB works correctly, but we need +.scoped+ so we revive it through 10 | # the {Scoped} support module. 11 | # 12 | # @see Scoped 13 | # 14 | # Sanitize has dissapeared in favour of quote. 15 | # 16 | # @see Sanitized 17 | # 18 | module V51 19 | extend ActiveSupport::Concern 20 | 21 | included do 22 | extend Scoped 23 | extend Sanitized 24 | end 25 | end 26 | 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v52.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 5.2 support module. 8 | # 9 | # JSONB works correctly, but we need +.scoped+ so we revive it through 10 | # the {Scoped} support module. 11 | # 12 | # @see Scoped 13 | # 14 | # Sanitize has dissapeared in favour of quote. 15 | # 16 | # @see Sanitized 17 | # 18 | module V52 19 | extend ActiveSupport::Concern 20 | 21 | included do 22 | extend Scoped 23 | extend Sanitized 24 | end 25 | end 26 | 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v60.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 6.0 support module. 8 | # 9 | # JSONB works correctly, but we need +.scoped+ so we revive it through 10 | # the {Scoped} support module. 11 | # 12 | # @see Scoped 13 | # 14 | # Sanitize has dissapeared in favour of quote. 15 | # 16 | # @see Sanitized 17 | # 18 | module V60 19 | extend ActiveSupport::Concern 20 | 21 | included do 22 | extend Scoped 23 | extend Sanitized 24 | end 25 | end 26 | 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/compatibility/v61.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | class Compatibility 5 | 6 | ## 7 | # Rails 6.1 support module. 8 | # 9 | # JSONB works correctly, but we need +.scoped+ so we revive it through 10 | # the {Scoped} support module. 11 | # 12 | # @see Scoped 13 | # 14 | # Sanitize has dissapeared in favour of quote. 15 | # 16 | # @see Sanitized 17 | # 18 | module V61 19 | extend ActiveSupport::Concern 20 | 21 | included do 22 | extend Scoped 23 | extend Sanitized 24 | end 25 | end 26 | 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/eaco/adapters/active_record/postgres_jsonb.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module ActiveRecord 4 | 5 | ## 6 | # Authorized collection extractor on PostgreSQL >= 9.4 and a +jsonb+ 7 | # column named +acl+. 8 | # 9 | # TODO negative authorizations (using a separate column?) 10 | # 11 | # @see ACL 12 | # @see Actor 13 | # @see Resource 14 | # 15 | module PostgresJSONb 16 | 17 | ## 18 | # Uses the json key existance operator +?|+ to check whether one of the 19 | # +Actor+'s +Designator+ instances exist as keys in the +ACL+ objects. 20 | # 21 | # @param actor [Actor] 22 | # 23 | # @return [ActiveRecord::Relation] the authorized collection scope. 24 | # 25 | def accessible_by(actor) 26 | return scoped if actor.is_admin? 27 | 28 | designators = actor.designators.map {|d| sanitize(d) } 29 | 30 | column = "#{connection.quote_table_name(table_name)}.acl" 31 | 32 | where("#{column} ?| array[#{designators.join(',')}]::varchar[]") 33 | end 34 | end 35 | 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/eaco/adapters/couchrest_model.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | 4 | ## 5 | # CouchRest::Model backing store for ACLs, that naively uses +property+. 6 | # As the ACL class is an +Hash+, it gets unserialized automagically by 7 | # CouchRest guts. 8 | # 9 | # @see ACL 10 | # @see CouchDBLucene 11 | # 12 | # :nocov: because there are too many moving parts here and anyway we are 13 | # going to deprecate this in favour of jsonb 14 | module CouchrestModel 15 | autoload :CouchDBLucene, 'eaco/adapters/couchrest_model/couchdb_lucene' 16 | 17 | ## 18 | # Returns currently available collection extraction strategies. 19 | # 20 | def self.strategies 21 | {lucene: CouchDBLucene} 22 | end 23 | 24 | ## 25 | # Defines the +acl+ property on the given model 26 | # 27 | # @param base [CouchRest::Model] your model class. 28 | # 29 | # @return [void] 30 | # 31 | def self.included(base) 32 | base.instance_eval do 33 | property :acl, acl 34 | end 35 | end 36 | end 37 | # :nocov: 38 | 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/eaco/adapters/couchrest_model/couchdb_lucene.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Adapters 3 | module CouchrestModel 4 | 5 | ## 6 | # Authorized collection extractor on CouchDB using the CouchDB Lucene 7 | # full-text indexer , a patched 8 | # CouchRest to interact with the 9 | # "_fti" couchdb lucene API endpoint, and a patched CouchRest::Model 10 | # that provides a search() 11 | # API to run lucene queries. 12 | # 13 | # It requires an indexing strategy similar to the following: 14 | # 15 | # { 16 | # _id: "_design/lucene", 17 | # language: "javascript", 18 | # fulltext: { 19 | # search: { 20 | # defaults: { store: "no" }, 21 | # analyzer: "perfield:{acl:\"keyword\"}", 22 | # index: function(doc) { 23 | # 24 | # var acl = doc.acl; 25 | # if (!acl) { 26 | # return null; 27 | # } 28 | # 29 | # var ret = new Document(); 30 | # 31 | # for (key in acl) { 32 | # ret.add(key, { 33 | # type: 'string', 34 | # field: 'acl', 35 | # index: 'not_analyzed' 36 | # }); 37 | # } 38 | # 39 | # return ret; 40 | # } 41 | # } 42 | # } 43 | # } 44 | # 45 | # Made in Italy. 46 | # 47 | # @see ACL 48 | # @see Actor 49 | # @see Resource 50 | # 51 | # :nocov: because there are too many moving parts here and anyway we are 52 | # going to deprecate this in favour of jsonb 53 | module CouchDBLucene 54 | 55 | ## 56 | # Uses a Lucene query to extract Resources accessible by the given Actor. 57 | # 58 | # @param actor [Actor] 59 | # 60 | # @return [CouchRest::Model::Search::View] the authorized collection scope. 61 | # 62 | def accessible_by(actor) 63 | return search(nil) if actor.is_admin? 64 | 65 | designators = actor.designators.map {|item| '"%s"' % item } 66 | 67 | search "acl:(#{designators.join(' OR ')})" 68 | end 69 | end 70 | # :nocov: 71 | 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/eaco/controller.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'active_support/concern' 3 | rescue LoadError 4 | # :nocov: This is falsely true during specs ran by Guard. FIXME. 5 | abort 'Eaco::Controller requires actioncontroller. Please add it to Gemfile.' 6 | # :nocov: 7 | end 8 | 9 | module Eaco 10 | 11 | ## 12 | # An ActionController extension to verify authorization in Rails applications. 13 | # 14 | # Tested on Rails 3.2 and up on Ruby 2.0 and up. 15 | # 16 | module Controller 17 | extend ActiveSupport::Concern 18 | 19 | ## 20 | # Controller authorization DSL. 21 | # 22 | module ClassMethods 23 | 24 | ## 25 | # Defines the ability required to access a given controller action. 26 | # 27 | # Example: 28 | # 29 | # class DocumentsController < ApplicationController 30 | # authorize :index, [:folder, :index] 31 | # authorize :show, [:folder, :read] 32 | # authorize :create, :update, [:folder, :write] 33 | # end 34 | # 35 | # Here +@folder+ is expected to be an authorized +Resource+, and for the 36 | # +index+ action the +current_user+ is checked to +can?(:index, @folder)+ 37 | # while for +show+, +can?(:read, @folder)+ and for +create+ and +update+ 38 | # checks that it +can?(:write, @folder)+. 39 | # 40 | # The special +:all+ action name requires the given ability on the given 41 | # Resource for all actions. 42 | # 43 | # If an action has no authorization defined, access is granted. 44 | # 45 | # Adds {Controller#confront_eaco} as a +before_filter+. 46 | # 47 | # @param actions [Variadic] see above. 48 | # 49 | # @return void 50 | # 51 | def authorize(*actions) 52 | target = actions.pop 53 | 54 | actions.each {|action| authorization_permissions.update(action => target)} 55 | 56 | @_eaco_filter_installed ||= begin 57 | if ActionPack::VERSION::MAJOR >= 5 58 | before_action :confront_eaco 59 | else 60 | before_filter :confront_eaco 61 | end 62 | 63 | true 64 | end 65 | end 66 | 67 | ## 68 | # Gets the permission required to access the given +action+, falling 69 | # back on the default +:all+ action, or +nil+ if no permission is 70 | # defined. 71 | # 72 | # @return [Symbol] the required permission or nil 73 | # 74 | # @see {Eaco::Resource} 75 | # @see {Eaco::DSL::Resource} 76 | # 77 | def permission_for(action) 78 | authorization_permissions[action] || authorization_permissions[:all] 79 | end 80 | 81 | protected 82 | ## 83 | # Permission requirements configured on this controller, keyed by 84 | # permission symbol and with role symbols as values. 85 | # 86 | # @return [Hash] 87 | # 88 | # @see {Eaco::DSL::Resource} 89 | # 90 | def authorization_permissions 91 | @_authorization_permissions ||= {} 92 | end 93 | end 94 | 95 | protected 96 | 97 | ## 98 | # Asks Eaco whether thou shalt pass or not. 99 | # 100 | # The implementation is left in this method's body, despite a bit long for 101 | # many's taste, as it is pretty imperative and simple code. Moreover, the 102 | # less we pollute ActionController's namespace, the better. 103 | # 104 | # @return [void] 105 | # 106 | # @raise [Error] if the instance variable configured in {.authorize} is not found 107 | # @raise [Forbidden] if the +current_user+ is not granted access. 108 | # 109 | # 110 | # == La Guardiana 111 | # /\ 112 | # .-_-. / \ 113 | # || .-.( .' .-. // \ / 114 | # \\\/ (((\ /))) \ / // )( 115 | # ) '._ ,-. ___. )/ //(__) 116 | # \_((( ( :) \)))/ , / || 117 | # \_ \ '-' /_ /| ),// || 118 | # \ (_._.'_ \ (o__// _||_ 119 | # \ )\ .(/ / __) \ \ 120 | # ( \ '_ .' /( |-. \ 121 | # \_'._'.\__/)))) (__)'.'. 122 | # _._ | | _.-._ || \ '. 123 | # / //--' / '--//'-'/\||____\ '. 124 | # \---.\ .----.// // ||// '\ \ 125 | # / ' \/ ' \\__\\ ,||\\_______.' 126 | # \\___//\\____//\____\ || 127 | # _.-'''---. /\___/ \____/ \\/ || 128 | # ..'_.''''---.| /. \ / || 129 | # .'.-'O __ / _/ )_.--.____( || 130 | # / / / \__/ /' /\ \(__.--._____) || 131 | # | | /\ \ \_.' | | \ | || 132 | # \ '.__\,_.'.__/./ / ) . |\ || 133 | # '..__ O --' ___..' /\ /|'. || 134 | # ''----' | \/\.' / /'. || 135 | # |\(()).' / \ || 136 | # _/ \ \/ / \|| 137 | # __..--'' '. | ||| 138 | # .-'' / '._|/ ||| 139 | # / __.- / /|| 140 | # \ ____..-----'' / | || 141 | # '. )). | / || 142 | # ''._// \ .-----./ || 143 | # '. \ (.-----.) || 144 | # '. \ | / || 145 | # )_ \ | | || 146 | # /__'O\ ( ) ( || 147 | # _______mrf,-'____/|/__ |\ \ || 148 | # | | || 149 | # |____) (__) 150 | # '-----' || 151 | # \ | || 152 | # \ | || 153 | # \ | || 154 | # | \ || 155 | # |_ \ || 156 | # /_'O\|| 157 | # .-'___/(__) 158 | # 159 | # http://ascii.co.uk/art/guardiana 160 | # 161 | def confront_eaco 162 | action = params[:action].intern 163 | resource_ivar, permission = self.class.permission_for(action) 164 | 165 | if resource_ivar && permission 166 | resource = instance_variable_get(['@', resource_ivar].join.intern) 167 | 168 | if resource.nil? 169 | raise Error, <<-EOF 170 | @#{resource_ivar} is not set, can't authorize #{self}##{action} 171 | EOF 172 | end 173 | 174 | unless current_user.can? permission, resource 175 | raise Forbidden, <<-EOF 176 | `#{current_user}' not authorized to `#{action}' on `#{resource}' 177 | EOF 178 | end 179 | end 180 | end 181 | end 182 | 183 | end 184 | -------------------------------------------------------------------------------- /lib/eaco/coverage.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | require 'simplecov' 3 | require 'eaco/rake' 4 | 5 | module Eaco 6 | 7 | ## 8 | # Integration with code coverage tools. 9 | # 10 | # Loading this module will start collecting coverage data. 11 | # 12 | module Coverage 13 | extend self 14 | 15 | ## 16 | # Starts collecting coverage data. 17 | # 18 | # @return [nil] 19 | # 20 | def start! 21 | Coveralls.wear_merged!(&simplecov_configuration) 22 | 23 | nil 24 | end 25 | 26 | ## 27 | # Reports coverage data to the remote service 28 | # 29 | # @return [nil] 30 | # 31 | def report! 32 | simplecov 33 | Coveralls.push! 34 | 35 | nil 36 | end 37 | 38 | ## 39 | # Formats coverage results using the default formatter. 40 | # 41 | # @return [String] Coverage summary 42 | # 43 | def format! 44 | Rake::Utils.capture_stdout do 45 | result && result.format! 46 | end.strip 47 | end 48 | 49 | private 50 | 51 | ## 52 | # The coverage result 53 | # 54 | # @return [SimpleCov::Result] 55 | # 56 | def result 57 | simplecov.result 58 | end 59 | 60 | ## 61 | # Configures simplecov using {.simplecov_configuration} 62 | # 63 | # @return [Class] +SimpleCov+ 64 | # 65 | def simplecov 66 | SimpleCov.configure(&simplecov_configuration) 67 | end 68 | 69 | ## 70 | # Configures +SimpleCov+ to use a different directory 71 | # for each different appraisal +Gemfile+. 72 | # 73 | # @return [Proc] a +SimpleCov+ configuration block. 74 | # 75 | def simplecov_configuration 76 | proc do 77 | gemfile = Eaco::Rake::Utils.gemfile 78 | coverage_dir "coverage/#{gemfile}" 79 | add_filter ['/features', '/spec'] 80 | end 81 | end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /lib/eaco/cucumber.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | ## 4 | # Namespace that holds all cucumber-related helpers. 5 | # 6 | module Cucumber 7 | autoload :ActiveRecord, 'eaco/cucumber/active_record.rb' 8 | autoload :World, 'eaco/cucumber/world.rb' 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'active_record' 3 | rescue LoadError 4 | # :nocov: 5 | abort "ActiveRecord requires the rails appraisal. Try `appraisal cucumber`" 6 | # :nocov: 7 | end 8 | 9 | require 'yaml' 10 | 11 | module Eaco 12 | module Cucumber 13 | 14 | ## 15 | # +ActiveRecord+ configuration and connection. 16 | # 17 | # Database configuration is looked up in +features/active_record.yml+ by 18 | # default. Logs are sent to +features/active_record.log+, truncating the 19 | # file at each run. 20 | # 21 | # Environment variables: 22 | # 23 | # * +EACO_AR_CONFIG+ specify a different +ActiveRecord+ configuration file 24 | # * +VERBOSE+ log to +stderr+ 25 | # 26 | module ActiveRecord 27 | autoload :Document, 'eaco/cucumber/active_record/document' # Resource 28 | autoload :User, 'eaco/cucumber/active_record/user' # Actor 29 | autoload :Department, 'eaco/cucumber/active_record/department' # Designator source 30 | autoload :Position, 'eaco/cucumber/active_record/position' # Designator source 31 | 32 | extend self 33 | 34 | ## 35 | # Looks up ActiveRecord and sets the +logger+. 36 | # 37 | # @return [Class] +ActiveRecord::Base+ 38 | # 39 | def active_record 40 | @_active_record ||= ::ActiveRecord::Base.tap do |active_record| 41 | active_record.logger = ::Logger.new(active_record_log).tap {|l| l.level = 0} 42 | end 43 | end 44 | 45 | ## 46 | # Log to stderr if +VERBOSE+ is given, else log to 47 | # +features/active_record.log+ 48 | # 49 | # @return [IO] the log destination 50 | # 51 | def active_record_log 52 | @_active_record_log ||= ENV['VERBOSE'] ? $stderr : 53 | 'features/active_record.log'.tap {|f| File.open(f, "w+")} 54 | end 55 | 56 | ## 57 | # @return [Logger] the logger configured, logging to {.active_record_log}. 58 | # 59 | def logger 60 | active_record.logger 61 | end 62 | 63 | ## 64 | # Returns an Hash wit the database configuration. 65 | # 66 | # Caveat:the returned +Hash+ has a custom +.to_s+ method that formats 67 | # the configuration as a +pgsql://+ URL. 68 | # 69 | # @return [Hash] the current database configuration 70 | # 71 | # @see {#config_file} 72 | # 73 | def configuration 74 | @_config ||= YAML.load(config_file.read).tap do |conf| 75 | def conf.to_s 76 | # :nocov: 77 | 'pgsql://%s:%s@%s/%s' % values_at( 78 | :username, :password, :hostname, :database 79 | ) 80 | # :nocov: 81 | end 82 | end 83 | end 84 | 85 | ## 86 | # @return [Pathname] the currently configured configuration file. Override 87 | # using the +EACO_AR_CONFIG' envinronment variable. 88 | # 89 | def config_file 90 | Pathname.new(ENV['EACO_AR_CONFIG'] || default_config_file) 91 | end 92 | 93 | ## 94 | # @return [String] +active_record.yml+ relative to this source file. 95 | # 96 | # @raise [Errno::ENOENT] if the configuration file is not found. 97 | # 98 | # :nocov: 99 | # This isn't ran by Travis as we set EACO_AR_CONFIG, so Coveralls raises 100 | # a false positive. 101 | def default_config_file 102 | Pathname.new('features/active_record.yml').realpath 103 | 104 | rescue Errno::ENOENT => error 105 | raise error.class.new, <<-EOF.squeeze(' ') 106 | 107 | #{error.message}. 108 | 109 | Please define your Active Record database configuration in the 110 | default location, or specify your configuration file location by 111 | passing the `EACO_AR_CONFIG' environment variable. 112 | EOF 113 | end 114 | # :nocov: 115 | 116 | ## 117 | # Establish ActiveRecord connection using the given configuration hash 118 | # 119 | # @param config [Hash] the configuration to use, {#configuration} by default. 120 | # 121 | # @return [ActiveRecord::ConnectionAdapters::ConnectionPool] 122 | # 123 | # @raise [ActiveRecord::ActiveRecordError] if cannot connect 124 | # 125 | def connect!(config = self.configuration) 126 | unless ENV['VERBOSE'] 127 | config = config.merge(min_messages: 'WARNING') 128 | end 129 | 130 | active_record.establish_connection(config) 131 | end 132 | 133 | ## 134 | # Loads the defined {ActiveRecord#schema} 135 | # 136 | # @return [nil] 137 | # 138 | def define_schema! 139 | log_stdout { load 'eaco/cucumber/active_record/schema.rb' } 140 | end 141 | 142 | protected 143 | 144 | ## 145 | # Captures stdout emitted by the given +block+ and logs it 146 | # as +info+ messages. 147 | # 148 | # @param block [Proc] 149 | # @return [nil] 150 | # @see {Rake::Utils.capture_stdout} 151 | # 152 | def log_stdout(&block) 153 | stdout = Rake::Utils.capture_stdout(&block) 154 | 155 | stdout.split("\n").each do |line| 156 | logger.info line 157 | end 158 | 159 | nil 160 | end 161 | end 162 | 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/department.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | 5 | ## 6 | # A department holds many {Position}s. 7 | # 8 | # For the background story, see {Eaco::Cucumber::World}. 9 | # 10 | # @see Position 11 | # @see Eaco::Actor 12 | # @see Eaco::Cucumber::World 13 | # 14 | class Department < ::ActiveRecord::Base 15 | has_many :positions 16 | has_many :users, through: :positions 17 | 18 | validates :name, uniqueness: true 19 | end 20 | 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/document.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | 5 | ## 6 | # This is an example of a {Eaco::Resource} that can be protected by an 7 | # {Eaco::ACL}. For the background story, see {Eaco::Cucumber::World}. 8 | # 9 | # @see User 10 | # @see Eaco::Resource 11 | # @see Eaco::Cucumber::World 12 | # 13 | class Document < ::ActiveRecord::Base 14 | end 15 | 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/position.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | 5 | ## 6 | # A Position is occupied by an {User} in a {Department}. 7 | # 8 | # For the background story, see {Eaco::Cucumber::World}. 9 | # 10 | # @see User 11 | # @see Department 12 | # @see Eaco::Cucumber::World 13 | # 14 | class Position < ::ActiveRecord::Base 15 | belongs_to :user 16 | belongs_to :department 17 | 18 | validates :user, :department, presence: :true 19 | end 20 | 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/schema.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | 5 | # @!method clean 6 | # 7 | # Drops all tables currently instantiated in the database. 8 | # 9 | # @see Eaco::Cucumber::World 10 | # 11 | ::ActiveRecord::Base.connection.tap do |connection| 12 | connection.tables.each do |table_name| 13 | connection.drop_table table_name 14 | end 15 | end 16 | 17 | # @!method schema 18 | # 19 | # Defines the database schema for the {Eaco::Cucumber::World} scenario. 20 | # 21 | # @see Eaco::Cucumber::World 22 | # 23 | ::ActiveRecord::Schema.define(version: '2015022301') do 24 | # Resource 25 | create_table 'documents', force: true do |t| 26 | t.string :name 27 | t.column :acl, :jsonb 28 | end 29 | 30 | # Actor 31 | create_table 'users', force: true do |t| 32 | t.string :name 33 | t.boolean :admin, default: false 34 | end 35 | 36 | # Designator source 37 | create_table 'departments', force: true do |t| 38 | t.string :name 39 | end 40 | add_index :departments, :name, unique: true 41 | 42 | # Designator source 43 | create_table 'positions', force: true do |t| 44 | t.string :name 45 | 46 | t.references :user 47 | t.references :department 48 | end 49 | end 50 | 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/user.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | 5 | ## 6 | # This is an example of a {Eaco::Actor} that can be authorized against 7 | # the ACLs in a resource, such as the example {Document}. 8 | # 9 | # For the background story, see {Eaco::Cucumber::World}. 10 | # 11 | # @see Document 12 | # @see Eaco::Actor 13 | # @see Eaco::Cucumber::World 14 | # 15 | class User < ::ActiveRecord::Base 16 | autoload :Designators, 'eaco/cucumber/active_record/user/designators.rb' 17 | 18 | has_many :positions 19 | has_many :departments, through: :positions 20 | 21 | ## 22 | # The {Department} names this User has a {Position} in. 23 | # 24 | # @return [Array] the {Department} names as +String+s. 25 | # 26 | def department_names 27 | departments.to_set(&:name) 28 | end 29 | end 30 | 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/user/designators.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | class User 5 | 6 | ## 7 | # The example {Designator}s for the {User} class. 8 | # 9 | # @see World 10 | # 11 | module Designators 12 | autoload :Authenticated, 'eaco/cucumber/active_record/user/designators/authenticated.rb' 13 | autoload :Department, 'eaco/cucumber/active_record/user/designators/department.rb' 14 | autoload :Position, 'eaco/cucumber/active_record/user/designators/position.rb' 15 | autoload :User, 'eaco/cucumber/active_record/user/designators/user.rb' 16 | end 17 | 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/user/designators/authenticated.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | class User 5 | module Designators 6 | 7 | ## 8 | # A {Designator} based on a the {User} class. 9 | # 10 | # This is an example on how to grant rights to all instances 11 | # of a given model. 12 | # 13 | # The class name is available as the {Designator#value}. 14 | # 15 | # The String representation for an example User is 16 | # +"authenticated:User"+. 17 | # 18 | class Authenticated < Eaco::Designator 19 | label "Any user" 20 | 21 | ## 22 | # This {Designator} description. 23 | # 24 | # @return [String] an hardcoded description 25 | # 26 | def describe(*) 27 | "Any authenticated user" 28 | end 29 | 30 | ## 31 | # {User}s matching this designator. 32 | # 33 | # @return [Array] All {User}s. 34 | # 35 | def resolve 36 | klass.all 37 | end 38 | 39 | private 40 | ## 41 | # Looks up this class by constantizing it. 42 | # 43 | # @return [Class] 44 | # 45 | def klass 46 | @_klass ||= self.value.constantize 47 | end 48 | end 49 | 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/user/designators/department.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | class User 5 | module Designators 6 | 7 | ## 8 | # A {Designator} based on a the {Department} an {User} occupies 9 | # a {Position} in. It resolves {Actor}s by id looking them up 10 | # through the {Position} model. 11 | # 12 | # As {Department}s have unique names, their name instead of 13 | # their ID is used in this example. 14 | # 15 | # The Department name is available as the {Designator#value}. 16 | # 17 | # The String representation for an example ICT Department is 18 | # +"department:ICT"+. 19 | # 20 | class Department < Eaco::Designator 21 | ## 22 | # This {Designator} description. 23 | # 24 | # @return [String] the {Department} name, such as ICT or COM 25 | # or EXE or BAT. 26 | # 27 | def describe(*) 28 | department.name 29 | end 30 | 31 | ## 32 | # {User}s matching this designator. 33 | # 34 | # @return [Array] all users currently occupying a position in 35 | # this Department 36 | # 37 | def resolve 38 | department.users 39 | end 40 | 41 | private 42 | ## 43 | # Looks up this Department by name, and memoizes it in an 44 | # instance variable. 45 | # 46 | # @return [ActiveRecord::Department] the referenced department 47 | # 48 | def department 49 | @_department ||= ActiveRecord::Department. 50 | where(name: self.value).first! 51 | end 52 | end 53 | 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/user/designators/position.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | class User 5 | module Designators 6 | 7 | ## 8 | # A {Designator} based on a position an {User} occupies in an 9 | # organigram. It resolves {Actor}s by id looking them up from 10 | # the +user_id+ field. 11 | # 12 | # The Position ID is available as the {Designator#value}. 13 | # 14 | # The String representation for an example Position 42 is 15 | # +"position:42"+. 16 | # 17 | class Position < Eaco::Designator 18 | ## 19 | # This {Designator} description. 20 | # 21 | # @return [String] the {Position} name, such as "Manager" or 22 | # or "Systems Analyst" or "Consultant". 23 | # 24 | def describe(*) 25 | "#{position.name} in #{position.department.name}" 26 | end 27 | 28 | ## 29 | # {User}s matching this designator. 30 | # 31 | # @return [Array] the user currently occupying this Position. 32 | # 33 | def resolve 34 | [position.user] 35 | end 36 | 37 | private 38 | ## 39 | # Looks up this position by ID, and memoizes it in an instance 40 | # variable. 41 | # 42 | # @return [ActiveRecord::Position] the referenced Position. 43 | # 44 | def position 45 | @_position ||= ActiveRecord::Position.find(self.value) 46 | end 47 | end 48 | 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/active_record/user/designators/user.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | module ActiveRecord 4 | class User 5 | module Designators 6 | 7 | ## 8 | # The simplest {Designator}. It resolves actors by their unique ID, 9 | # such as an autoincrementing ID in a relational database. 10 | # 11 | # The ID is available as the {Designator#value}. If the Designator 12 | # is instantiated with a live instance (see {Designator#initialize}) 13 | # then it is re-used and a query to the database is avoided. 14 | # 15 | # The designator string representation for user 42 is +"user:42"+. 16 | # 17 | class User < Eaco::Designator 18 | ## 19 | # This {Designator} description 20 | # 21 | # @return [String] the {User}'s name. 22 | # 23 | def describe(*) 24 | "User '%s'" % [target_user.name] 25 | end 26 | 27 | ## 28 | # {User}s matching this designator. 29 | # 30 | # @return [Array] this very {User} wrapped in an +Array+. 31 | # 32 | def resolve 33 | [target_user] 34 | end 35 | 36 | private 37 | ## 38 | # Looks up this user by ID, and memoizes it using the 39 | # {Designator#instance=} accessor. 40 | # 41 | # @return [User] this very user. 42 | # 43 | def target_user 44 | self.instance ||= ActiveRecord::User.find(self.value) 45 | end 46 | end 47 | 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/eaco/cucumber/world.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Cucumber 3 | 4 | ## 5 | # The world in which scenarios are run. This is a story and an example, 6 | # real-world data model that can be effectively protected by Eaco. 7 | # 8 | # But +before { some :art }+ 9 | # 10 | # == AYANAMI REI 11 | # __.-"..--,__ 12 | # __..---" | _| "-_\ 13 | # __.---" | V|::.-"-._D 14 | # _--"".-.._ ,,::::::'"\/""'-:-:/ 15 | # _.-""::_:_:::::'-8b---" "' 16 | # .-/ ::::< |\::::::"\ 17 | # \/:::/::::'\\ |:::b::\ 18 | # /|::/:::/::::-::b:%b:\| 19 | # \/::::d:|8:::b:"%%%%%\ 20 | # |\:b:dP:d.:::%%%%%"""-, 21 | # \:\.V-/ _\b%P_ / .-._ 22 | # '|T\ "%j d:::--\.( "-. 23 | # ::d< -" d%|:::do%P"-:. "-, 24 | # |:I _ /%%%o::o8P "\. "\ 25 | # \8b d%%%%%%P""-._ _ \::. \ 26 | # \%%8 _./Y%%P/ .::'-oMMo ) 27 | # H"'|V | A:::...:odMMMMMM( ./ 28 | # H /_.--"JMMMMbo:d##########b/ 29 | # .-'o dMMMMMMMMMMMMMMP"" 30 | # /" / YMMMMMMMMM| 31 | # / . . "MMMMMMMM/ 32 | # :..::..:::.. MMMMMMM:| 33 | # \:/ \::::::::JMMMP":/ 34 | # :Ao ':__.-'MMMP:::Y 35 | # dMM"./:::::::::-.Y 36 | # _|b::od8::/:YM::/ 37 | # I HMMMP::/:/"Y/" 38 | # \'""' '':| 39 | # | -::::\ 40 | # | :-._ '::\ 41 | # |,.| \ _:"o 42 | # | d" / " \_:\. 43 | # ".Y. \ \::\ 44 | # \ \ \ MM\:Y 45 | # Y \ | MM \:b 46 | # >\ Y .MM MM 47 | # .IY L_ MP' MP 48 | # | \:| JM JP 49 | # | :\| MP MM 50 | # | ::: JM' JP| 51 | # | ':' JP JM | 52 | # L : JP MP | 53 | # 0 | Y JM | 54 | # 0 | JP" | 55 | # 0 | JP | 56 | # m | JP # 57 | # I | JM" Y 58 | # l | MP :" 59 | # |\ :- :| 60 | # | | '.\ :| 61 | # | | "| \ :| 62 | # \ \ \ :| 63 | # | | | \ :| 64 | # | | | \ :| 65 | # | \ \ | '. 66 | # | |:\ | :| 67 | # \ |::\..| :\ 68 | # ". /::::::' :|| 69 | # :|::/:::| /:\ 70 | # | \/::|: \' ::| 71 | # | :::|| ::| 72 | # | ::|| ::| 73 | # | ::|| ::| 74 | # | ::|| ::| 75 | # | ': | .:| 76 | # | : | :| 77 | # | : | :| 78 | # | :|| .:| 79 | # | ::\ .:| 80 | # | ::: .::| 81 | # / ::| :::| 82 | # __/ .::| ':| 83 | # ...----"" ::/ :: 84 | # /m_ AMm '/ .::: 85 | # ""MmmMMM#mmMMMMMMM" .:::m 86 | # """YMMM""""""P ':mMI 87 | # _' _MMMM 88 | # _.-" mm mMMMMMMMM" 89 | # / MMMMMMM"" 90 | # mmmmmmMMMM" 91 | # ch1x0r 92 | # 93 | # http://ascii.co.uk/art/anime 94 | # 95 | # = Scenario 96 | # 97 | # In this imaginary world we are N E R V, a Top Secret organization that 98 | # handles very confidential documents. Some users can read them, some can 99 | # edit them, and very few bosses can destroy them. 100 | # 101 | # The organization employs internal staff and employs consultants. Staff 102 | # members have official positions in the organization hierarchy, and they 103 | # belong to units within departments. They have the big picture. 104 | # 105 | # Consultants, on the other hand, come and go, and work on small parts of 106 | # the documents, for specific purposes. They do not have the big picture. 107 | # 108 | # Departments own the documents, not people. Documents are of interest of 109 | # departments, sometimes they should be accessed by the whole house, some 110 | # other time only few selected users, some times two specific departments 111 | # or some units. 112 | # 113 | # Either way, most of the time, access is granted to who owns a peculiar 114 | # authority within the organization and not to a specific person. People 115 | # may change, authorities and rules change less often. 116 | # 117 | # = Mapping Eaco concepts 118 | # 119 | # The +Document+ is a {Eaco::Resource} 120 | # 121 | # Each instance of a +Document+ has an {Eaco::ACL} +.acl+ attribute. 122 | # 123 | # The +:reader+, +:editor+ and +:owner+ are Roles on the Document 124 | # resource, and each role is granted a Permission. 125 | # 126 | # The User is a {Eaco::Actor}. 127 | # 128 | # Having an user account is the {Eaco::Designator} of type +:user+. 129 | # Occupying an official position is the Designator of type +:position+. 130 | # Belonging to a department is the Designator of type +:department+ 131 | # 132 | class World 133 | 134 | ## 135 | # Sets up the World: 136 | # 137 | # * Connect to ActiveRecord 138 | # 139 | def initialize 140 | Eaco::Cucumber::ActiveRecord.connect! 141 | end 142 | 143 | ## 144 | # Authorizes model with the given {DSL} 145 | # 146 | # @param name [String] the model name 147 | # @param definition [String] the {DSL} code 148 | # @see {#find_model} 149 | # 150 | # @return [void] 151 | # 152 | def authorize_model(name, definition) 153 | model = find_model(name) 154 | 155 | eval_dsl definition, model 156 | end 157 | 158 | ## 159 | # Registers and persists an {Actor} instance with the given +name+. 160 | # 161 | # @param model [String] the {Actor} model name 162 | # @param name [String] the {Actor} instance name 163 | # @param options [Boolean] the only supported one is +admin+, and 164 | # specifies whether this {Actor} an admin 165 | # 166 | # @return [Actor] the newly created {Actor} instance. 167 | # 168 | def register_actor(model, name, options = {}) 169 | actor_model = find_model(model) 170 | 171 | actors[name] = actor_model.new.tap do |actor| 172 | actor.name = name 173 | actor.admin = options.fetch(:admin, false) 174 | actor.save! 175 | end 176 | end 177 | 178 | ## 179 | # Fetches an {Actor} instance by name. 180 | # 181 | # @param name [String] the actor name 182 | # @return [Actor] the registered actor name 183 | # @raise [RuntimeError] if the actor is not found in the registry 184 | # 185 | def fetch_actor(name) 186 | actors.fetch(name) 187 | rescue KeyError 188 | # :nocov: 189 | raise "Actor '#{name}' not found in registry" 190 | # :nocov: 191 | end 192 | 193 | ## 194 | # Registers and persists {Resource} instance with the given name. 195 | # 196 | # @param model [String] the {Resource} model name 197 | # @param name [String] the {Resource} name 198 | # 199 | # @return [Resource] the newly instantiated {Resource} 200 | # 201 | def register_resource(model, name) 202 | resource_model = find_model(model) 203 | 204 | resource = resource_model.new.tap do |resource| 205 | resource.name = name 206 | resource.save! 207 | end 208 | 209 | resources[model] ||= {} 210 | resources[model][name] = resource 211 | end 212 | 213 | ## 214 | # Fetches a {Resource} instance by name. 215 | # 216 | # @param model [String] the {Resource} model name 217 | # @param name [String] the {Resource} name 218 | # 219 | def fetch_resource(model, name) 220 | resources.fetch(model).fetch(name) 221 | rescue KeyError 222 | # :nocov: 223 | raise "Resource #{model} '#{name}' not found in registry" 224 | # :nocov: 225 | end 226 | 227 | ## 228 | # All registered {Actor} instances. 229 | # 230 | # @return [Hash] actors keyed by name 231 | # 232 | def actors 233 | @actors ||= {} 234 | end 235 | 236 | ## 237 | # All registered {Resource} instances. 238 | # 239 | # @return [Hash] resources keyed by model name with +Hash+es 240 | # as values keyed by resource name. 241 | # 242 | def resources 243 | @resources ||= {} 244 | end 245 | 246 | ## 247 | # Checks the given block on the given set of +Document+ 248 | # 249 | # @param names [String] the document names, separated by +,+ 250 | # @param block [Proc] the code to run on each +Document+ 251 | # 252 | # @return [void] 253 | # 254 | # @see Eaco::Cucumber::ActiveRecord::Document 255 | # 256 | def check_documents(names, &block) 257 | model = find_model('Document') 258 | names = names.split(/,\s*/) 259 | model.where(name: names).each(&block) 260 | end 261 | 262 | ## 263 | # Returns a model in the {ActiveRecord} namespace. 264 | # 265 | # Example: 266 | # 267 | # World.find_model('Document') 268 | # 269 | # @param model_name [String] the model name 270 | # @return [Class] 271 | # 272 | def find_model(model_name) 273 | Eaco::Cucumber::ActiveRecord.const_get(model_name) 274 | end 275 | 276 | ## 277 | # Evaluates the given {Eaco::DSL} code, substituting the 278 | # +$MODEL+ string with the given model name. 279 | # 280 | # @param code [String] the DSL code to eval 281 | # @param model [Class] the model name to substitute (optional) 282 | # 283 | # @return [void] 284 | # 285 | def eval_dsl(code, model = nil) 286 | # Sub in place to print final code when running cucumber 287 | code.sub! '$MODEL', model.name if model 288 | Eaco.eval! code, '(feature)' 289 | end 290 | end 291 | 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /lib/eaco/designator.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | ## 4 | # A Designator characterizes an Actor. 5 | # 6 | # Example: an User Actor is uniquely identified by its numerical +id+, as 7 | # such we can define an +user+ designator that designs User 42 as +user:42+. 8 | # 9 | # The same User also could belong to the group +frobber+, uniquely 10 | # identified by its name. We can then define a +group+ designator that would 11 | # design the same User as +group:frobber+. 12 | # 13 | # In ACLs designators are given roles, and the intersection between the 14 | # designators of an Actor and the ones defined in the ACL gives the role of 15 | # the Actor for the Resource that the ACL secures. 16 | # 17 | # Designators for actors are defined through the DSL, see {DSL::Actor} 18 | # 19 | # @see ACL 20 | # @see Actor 21 | # 22 | class Designator < String 23 | class << self 24 | 25 | ## 26 | # Instantiate a designator of the given +type+ with the given +value+. 27 | # 28 | # Example: 29 | # 30 | # >> Designator.make('user', 42) 31 | # => # 32 | # 33 | # @param type [String] the designator type (e.g. +user+) 34 | # @param value [String] the designator value. It will be stringified using +.to_s+. 35 | # 36 | # @return [Designator] 37 | # 38 | def make(type, value) 39 | Eaco::DSL::Actor.find_designator(type).new(value) 40 | end 41 | 42 | ## 43 | # Parses a Designator string representation and instantiates a new 44 | # Designator instance from it. 45 | # 46 | # >> Designator.parse('user:42') 47 | # => # 48 | # 49 | # @param string [String] the designator string representation. 50 | # 51 | # @return [Designator] 52 | # 53 | def parse(string) 54 | return string if string.is_a?(Designator) 55 | make(*string.split(':', 2)) 56 | end 57 | 58 | ## 59 | # Resolves one or more designators into the target actors. 60 | # 61 | # @param designators [Array] designator string representations. 62 | # 63 | # @return [Array] resolved actors, application-dependant. 64 | # 65 | def resolve(designators) 66 | Array.new(designators||[]).inject(Set.new) {|ret, d| ret.merge parse(d).resolve} 67 | end 68 | 69 | ## 70 | # Sets up the designator implementation with the given options. 71 | # Currently: 72 | # 73 | # * +:from+ - defines the method to call on the Actor to obtain the unique 74 | # IDs for this Designator class. 75 | # 76 | # Example configuration: 77 | # 78 | # actor User do 79 | # designators do 80 | # user from: :id 81 | # group from: :group_ids 82 | # end 83 | # end 84 | # 85 | # This method is called from the DSL. 86 | # 87 | # @see DSL::Actor::Designators 88 | # @see DSL::Actor::Designators#define_designator 89 | # 90 | def configure!(options) 91 | @method = options.fetch(:from) 92 | self 93 | 94 | rescue KeyError 95 | raise Malformed, "The designator option :from is required" 96 | end 97 | 98 | ## 99 | # Harvests valid designators for the given Actor. 100 | # 101 | # It calls the +@method+ defined through the +:from+ option passed when 102 | # configuring the designators (see {Designator.configure!}). 103 | # 104 | # @param actor [Actor] 105 | # 106 | # @return [Array] an array of Designator objects the Actor owns. 107 | # 108 | # @see Actor 109 | # 110 | def harvest(actor) 111 | list = actor.send(@method) 112 | list = list.to_a if list.respond_to?(:to_a) 113 | Array.new([list]).flatten.map! {|value| new(value) } 114 | end 115 | 116 | ## 117 | # Sets this Designator label to the given value. 118 | # 119 | # Example: 120 | # 121 | # class User::Designators::Group < Eaco::Designator 122 | # label "Active Directory Group" 123 | # end 124 | # 125 | # @param value [String] the designator label 126 | # 127 | # @return [String] the configured label 128 | # 129 | def label(value = nil) 130 | @label = value if value 131 | @label ||= designator_name 132 | end 133 | 134 | ## 135 | # Returns this class' demodulized name 136 | # 137 | def designator_name 138 | self.name.split('::').last 139 | end 140 | 141 | ## 142 | # Returns the designator type. 143 | # 144 | # The type symbol is derived from the class name, on the other way 145 | # around, the {DSL} looks up designator implementation classes from the 146 | # designator type symbol. 147 | # 148 | # Example: 149 | # 150 | # >> User::Designators::Group.id 151 | # => :group 152 | # 153 | # @return [Symbol] 154 | # 155 | # @see DSL::Actor::Designators#implementation_for 156 | # 157 | def id 158 | @_id ||= self.designator_name.gsub(/([a-z])?([A-Z])/) do |x| 159 | [$1, $2.downcase].compact.join '_' 160 | end.intern 161 | end 162 | alias type id 163 | 164 | ## 165 | # Searches designator definitions using the given query. 166 | # 167 | # To be implemented by derived classes. E.g. for a "User" designator 168 | # this would return your own User instances that you may want to display 169 | # in a typeahead menu, for your Enterprise authorization management 170 | # UI... :-) 171 | # 172 | # @param query [String] the query to search against 173 | # @return [Enumerable] application {Actor}s collection 174 | # 175 | # @raise [NotImplementedError] 176 | # 177 | def search(query) 178 | # :nocov: 179 | raise NotImplementedError 180 | # :nocov: 181 | end 182 | end 183 | 184 | ## 185 | # Initializes the designator with the given value. The string 186 | # representation is then calculated by concatenating the type and the 187 | # given value. 188 | # 189 | # An optional instance can be attached, if you use to pass around 190 | # designators in your app. 191 | # 192 | # @param value [String] the unique ID valid in this designator namespace 193 | # @param instance [Actor] optional Actor instance 194 | # 195 | def initialize(value, instance = nil) 196 | @value, @instance = value, instance 197 | super([ self.class.id, value ].join(':')) 198 | end 199 | 200 | # This designator unique ID in the namespace of the designator type. 201 | attr_reader :value 202 | 203 | # The instance given to {Designator#initialize} 204 | attr_accessor :instance 205 | 206 | ## 207 | # Should return an extended description for this designator. You can then 208 | # use this to display it in your application. 209 | # 210 | # E.g. for an "User" designator this would be the user name, for a "Group" 211 | # designator this would be the group name. 212 | # 213 | # @param style [Symbol] the description style. {#as_json} uses +:full+, 214 | # but you are free to define whatever styles you do see fit. 215 | # 216 | # @return [String] the description 217 | # 218 | def describe(style = nil) 219 | nil 220 | end 221 | 222 | ## 223 | # Translates this designator to concrete Actor instances in your 224 | # application. To be implemented by derived classes. 225 | # 226 | # @raise [NotImplementedError] 227 | # 228 | def resolve 229 | # :nocov: 230 | raise NotImplementedError 231 | # :nocov: 232 | end 233 | 234 | ## 235 | # Converts this designator to an Hash for +.to_json+ to work. 236 | # 237 | # @param options [Ignored] 238 | # 239 | # @return [Hash] 240 | # 241 | def as_json(options = nil) 242 | { :value => to_s, :label => describe(:full) } 243 | end 244 | 245 | ## 246 | # Pretty prints the designator in your console. 247 | # 248 | # @return [String] 249 | # 250 | def inspect 251 | %[#] 252 | end 253 | 254 | ## 255 | # @return [String] the designator's class label. 256 | # 257 | # @see Designator.label 258 | # 259 | def label 260 | self.class.label 261 | end 262 | 263 | ## 264 | # @return [Symbol] the designator's class type. 265 | # 266 | # @see Designator.type 267 | # 268 | def type 269 | self.class.type 270 | end 271 | end 272 | 273 | end 274 | -------------------------------------------------------------------------------- /lib/eaco/dsl.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | ## 4 | # Eaco DSL entry point. 5 | # 6 | # @see DSL::Resource 7 | # @see DSL::Actor 8 | # @see DSL::ACL 9 | # 10 | module DSL 11 | extend self # Oh the irony. 12 | 13 | autoload :Base, 'eaco/dsl/base' 14 | 15 | autoload :ACL, 'eaco/dsl/acl' 16 | autoload :Actor, 'eaco/dsl/actor' 17 | autoload :Resource, 'eaco/dsl/resource' 18 | 19 | ## 20 | # Entry point for the {Resource} authorization definition. 21 | # 22 | # @param resource_class [Class] the application resource class 23 | # @param options [Hash] options passed to {DSL::Resource} and 24 | # and {DSL::ACL}. 25 | # 26 | # @see DSL::Resource 27 | # @see DSL::ACL 28 | # 29 | def authorize(resource_class, options = {}, &block) 30 | DSL::Resource.eval(resource_class, options, &block) 31 | DSL::ACL.eval(resource_class, options) 32 | end 33 | 34 | ## 35 | # Entry point for an {Actor} definition. 36 | # 37 | # @param actor_class [Class] the application actor class 38 | # @param options [Hash] currently unused 39 | # @param block [Proc] the DSL code to eval 40 | # 41 | # @see DSL::Actor 42 | # 43 | def actor(actor_class, options = {}, &block) 44 | DSL::Actor.eval(actor_class, options, &block) 45 | end 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /lib/eaco/dsl/acl.rb: -------------------------------------------------------------------------------- 1 | require 'eaco/dsl/base' 2 | 3 | module Eaco 4 | module DSL 5 | 6 | ## 7 | # Block-less DSL to set up the {ACL} machinery onto an authorized {Resource}. 8 | # 9 | # * Defines an {ACL} subclass in the Resource namespace 10 | # ({#define_acl_subclass}) 11 | # 12 | # * Defines syntactic sugar on the ACL to easily retrieve {Actor}s with a 13 | # specific Role ({#define_role_getters}) 14 | # 15 | # * Installs {ACL} objects persistance for the supported ORMs 16 | # ({#install_persistance}) 17 | # 18 | # * Installs the authorized collection extraction strategy 19 | # +.accessible_by+ ({#install_strategy}) 20 | # 21 | class ACL < Base 22 | 23 | ## 24 | # Performs ACL setup on the target Resource model. 25 | # 26 | # @return [nil] 27 | # 28 | def initialize(*) 29 | super 30 | 31 | define_acl_subclass 32 | define_role_getters 33 | install_persistance 34 | install_strategy 35 | 36 | nil 37 | end 38 | 39 | private 40 | 41 | ## 42 | # Creates the ACL constant on the target, inheriting from {Eaco::ACL}. 43 | # Removes if it is already set, so that a reload of the authorization 44 | # rules refreshes also these constants. 45 | # 46 | # The ACL subclass can be retrieved using the +.acl+ singleton method 47 | # on the {Resource} class. 48 | # 49 | # @return [void] 50 | # 51 | def define_acl_subclass 52 | target_eval do 53 | remove_const(:ACL) if const_defined?(:ACL, false) 54 | 55 | Class.new(Eaco::ACL).tap do |acl_class| 56 | define_singleton_method(:acl) { acl_class } 57 | const_set(:ACL, acl_class) 58 | end 59 | end 60 | end 61 | 62 | ## 63 | # Define getter methods on the ACL for each role, syntactic sugar 64 | # for calling {ACL#find_by_role}. 65 | # 66 | # Example: 67 | # 68 | # If a +reader+ role is defined, allows doing +resource.acl.readers+ 69 | # and returns all the designators having the +reader+ role set. 70 | # 71 | # @return [void] 72 | # 73 | def define_role_getters 74 | roles = self.target.roles 75 | 76 | target.acl.instance_eval do 77 | roles.each do |role| 78 | define_method(role.to_s.pluralize) { find_by_role(role) } 79 | end 80 | end 81 | end 82 | 83 | ## 84 | # Sets up the persistance layer for ACLs (+#acl+ and +#acl=+). 85 | # 86 | # These APIs can be implemented directly in your Resource model, as long 87 | # as the +acl+ accessor accepts and returns the Resource model's ACL 88 | # subclass (see {.define_acl_subclass}) 89 | # 90 | # See each adapter for the details of the extraction strategies 91 | # they provide. 92 | # 93 | # @return [void] 94 | # 95 | def install_persistance 96 | if adapter 97 | target.send(:include, adapter) 98 | install_authorized_collection_strategy 99 | 100 | elsif (target.instance_methods & [:acl, :acl=]).size != 2 101 | raise Malformed, <<-EOF 102 | Don't know how to persist ACLs using <#{target}>'s ORM 103 | (identified as <#{orm}>). Please define an `acl' instance 104 | accessor on <#{target}> that accepts and returns a <#{target.acl}>. 105 | EOF 106 | end 107 | end 108 | 109 | ## 110 | # Sets up the authorized collection extraction strategy 111 | # (+.accessible_by+). 112 | # 113 | # This API can be implemented directly in your model, as long as 114 | # +.accessible_by+ returns an +Enumerable+ collection. 115 | # 116 | # @return [void] 117 | # 118 | def install_strategy 119 | unless target.respond_to?(:accessible_by) 120 | strategies = adapter ? adapter.strategies.keys : [] 121 | 122 | raise Malformed, <<-EOF 123 | Don't know how to look up authorized records on <#{target}>'s 124 | ORM (identified as <#{orm}>). To authorize <#{target}> 125 | 126 | #{ if strategies.size > 0 127 | "either use one of the available strategies: #{strategies.join(', ')} or" 128 | end } 129 | 130 | please define your own #{target}.accessible_by method. 131 | You may at one point want to move this in a new strategy, 132 | and send a pull request :-). 133 | EOF 134 | end 135 | end 136 | 137 | ## 138 | # Looks up the authorized collection strategy within the Adapter, 139 | # using the +:using+ option given to the +authorize+ Resource DSL 140 | # 141 | # @see DSL::Resource 142 | # 143 | # @return [void] 144 | # 145 | def install_authorized_collection_strategy 146 | if adapter && (strategy = adapter.strategies[ options.fetch(:using, nil) ]) 147 | target.extend strategy 148 | end 149 | end 150 | 151 | ## 152 | # Tries to identify which ORM adapter to use for the +target+ class. 153 | # 154 | # @return [Class] the adapter implementation or nil if not available. 155 | # 156 | def adapter 157 | { 'ActiveRecord::Base' => Eaco::Adapters::ActiveRecord, 158 | 'CouchRest::Model::Base' => Eaco::Adapters::CouchrestModel, 159 | }.fetch(orm.name, nil) 160 | end 161 | 162 | ## 163 | # Tries to naively identify which ORM the target model is using. 164 | # 165 | # TODO support more stuff 166 | # 167 | # @return [Class] the ORM base class. 168 | # 169 | def orm 170 | if defined?(ActiveRecord::Base) && target.ancestors.include?(ActiveRecord::Base) 171 | ActiveRecord::Base 172 | else 173 | target.superclass # Naive 174 | end 175 | end 176 | end 177 | 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/eaco/dsl/actor.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module DSL 3 | 4 | ## 5 | # Parses the Actor DSL, that describes how to harvest {Designator}s from 6 | # an {Actor} and how to identify it as an +admin+, or superuser. 7 | # 8 | # actor User do 9 | # admin do |user| 10 | # user.admin? 11 | # end 12 | # 13 | # designators do 14 | # authenticated from: :class 15 | # user from: :id 16 | # group from: :group_ids 17 | # end 18 | # end 19 | # 20 | class Actor < Base 21 | autoload :Designators, 'eaco/dsl/actor/designators' 22 | 23 | ## 24 | # Makes an application model a valid {Eaco::Actor}. 25 | # 26 | # @see Eaco::Actor 27 | # 28 | def initialize(*) 29 | super 30 | 31 | target_eval do 32 | include Eaco::Actor 33 | 34 | def designators 35 | @_designators 36 | end 37 | 38 | def admin_logic 39 | @_admin_logic 40 | end 41 | end 42 | end 43 | 44 | ## 45 | # Defines the designators that apply to this {Actor}. 46 | # 47 | # Example: 48 | # 49 | # actor User do 50 | # designators do 51 | # authenticated from: :class 52 | # user from: :id 53 | # group from: :group_ids 54 | # end 55 | # end 56 | # 57 | # {Designator} names are collected using +method_missing+, and are 58 | # named after the method name. Implementations are looked up in 59 | # a +Designators+ module in the {Actor}'s class. 60 | # 61 | # Each designator implementation is expected to be named after the 62 | # designator's name, camelized, and inherit from {Eaco::Designator}. 63 | # 64 | # TODO all designators share the same namespace. This is due to the 65 | # fact that designator string representations aren't scoped by the 66 | # Actor model they belong to. As such when instantiating a designator 67 | # from +Eaco::Designator.make+ the registry is consulted to find the 68 | # designator implementation. 69 | # 70 | # @see DSL::Actor::Designators 71 | # 72 | def designators(&block) 73 | new_designators = target_eval do 74 | @_designators = Designators.eval(self, &block).result.freeze 75 | end 76 | 77 | Actor.register_designators(new_designators) 78 | end 79 | 80 | ## 81 | # Defines the boolean logic that determines whether an {Actor} is an 82 | # admin. Usually you'll have an +admin?+ method on your model, that you 83 | # can call from here. Or, feel free to just return +false+ to disable 84 | # this functionality. 85 | # 86 | # Example: 87 | # 88 | # actor User do 89 | # admin do |user| 90 | # user.admin? 91 | # end 92 | # end 93 | # 94 | # @param block [Proc] 95 | # @return [void] 96 | # 97 | def admin(&block) 98 | target_eval do 99 | @_admin_logic = block 100 | end 101 | end 102 | 103 | class << self 104 | ## 105 | # Looks up the given designator implementation by its +name+. 106 | # 107 | # @param name [Symbol] the designator name. 108 | # 109 | # @raise [Eaco::Malformed] if the designator is not found. 110 | # 111 | # @return [Class] 112 | # 113 | def find_designator(name) 114 | all_designators.fetch(name.intern) 115 | 116 | rescue KeyError 117 | raise Malformed, "Designator not found: #{name.inspect}" 118 | end 119 | 120 | ## 121 | # Saves the given designators in the global designators registry. 122 | # 123 | # @param new_designators [Hash] 124 | # 125 | # @return [Hash] the designators registry. 126 | # 127 | def register_designators(new_designators) 128 | all_designators.update(new_designators) 129 | end 130 | 131 | private 132 | ## 133 | # @return [Hash] a registry of all the defined designators. 134 | # 135 | def all_designators 136 | @_all_designators ||= {} 137 | end 138 | end 139 | end 140 | 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/eaco/dsl/actor/designators.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module DSL 3 | class Actor < Base 4 | 5 | ## 6 | # Designators collector using +method_missing+. 7 | # 8 | # Parses the following DSL: 9 | # 10 | # actor User do 11 | # designators do 12 | # authenticated from: :class 13 | # user from: :id 14 | # group from: :group_ids 15 | # end 16 | # end 17 | # 18 | # and looks up within the Designators namespace of the Actor model the 19 | # concrete implementations of the described designators. 20 | # 21 | # Here the User model is expected to define an User::Designators module 22 | # and to implement within it a +class Authenticated < Eaco::Designator+ 23 | # 24 | # @see Designator 25 | # 26 | class Designators < Base 27 | ## 28 | # Sets up the designators registry. 29 | # 30 | def initialize(*) 31 | super 32 | 33 | @designators = {} 34 | end 35 | 36 | ## 37 | # The parsed designators, keyed by type symbol and with concrete 38 | # implementations as values. 39 | # 40 | # @return [Hash] 41 | # 42 | attr_reader :designators 43 | alias result designators 44 | 45 | private 46 | ## 47 | # Looks up the implementation for the designator of the given 48 | # +name+, configures it with the given +options+ and saves it in 49 | # the designators registry. 50 | # 51 | # @param name [Symbol] 52 | # @param options [Hash] 53 | # 54 | # @return [Class] 55 | # 56 | # @see #implementation_for 57 | # 58 | def define_designator(name, options) 59 | designators[name] = implementation_for(name).configure!(options) 60 | end 61 | alias method_missing define_designator 62 | 63 | ## 64 | # Looks up the +name+ designator implementation in the {Actor}'s 65 | # +Designators+ namespace. 66 | # 67 | # @param name [Symbol] 68 | # 69 | # @return [Class] 70 | # 71 | # @raise [Malformed] if the implementation class is not found. 72 | # 73 | # @see #container 74 | # @see Designator.type 75 | # 76 | def implementation_for(name) 77 | impl = name.to_s.camelize.intern 78 | 79 | unless container.const_defined?(impl) 80 | raise Malformed, <<-EOF 81 | Implementation #{container}::#{impl} for Designator #{name} not found. 82 | EOF 83 | end 84 | 85 | container.const_get(impl) 86 | end 87 | 88 | ## 89 | # Looks up the +Designators+ namespace within the {Actor}'s class. 90 | # 91 | # @return [Class] 92 | # 93 | # @raise Malformed if the +Designators+ module cannot be found. 94 | # 95 | # @see #implementation_for 96 | # 97 | def container 98 | @_container ||= begin 99 | unless target.const_defined?(:Designators) 100 | raise Malformed, "Please put designators implementations in #{target}::Designators" 101 | end 102 | 103 | target.const_get(:Designators) 104 | end 105 | end 106 | end 107 | 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/eaco/dsl/base.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module DSL 3 | 4 | ## 5 | # Base DSL class. Provides handy access to the +target+ class being 6 | # manipulated, DSL-specific options, and a {#target_eval} helper to do 7 | # +instance_eval+ on the +target+. 8 | # 9 | # Nothing too fancy. 10 | # 11 | class Base 12 | 13 | ## 14 | # Executes a DSL block in the context of a DSL manipulator. 15 | # 16 | # @see DSL::ACL 17 | # @see DSL::Actor 18 | # @see DSL::Resource 19 | # 20 | # @return [Base] 21 | # 22 | def self.eval(klass, options = {}, &block) 23 | new(klass, options).tap do |dsl| 24 | dsl.instance_eval(&block) if block 25 | end 26 | end 27 | 28 | # The target class of the manipulation 29 | attr_reader :target 30 | 31 | # DSL-specific options 32 | attr_reader :options 33 | 34 | ## 35 | # Sets up the DSL instance target class and the options. 36 | # 37 | # @param target [Class] 38 | # @param options [Hash] 39 | # 40 | def initialize(target, options) 41 | @target, @options = target, options 42 | end 43 | 44 | protected 45 | ## 46 | # Evaluates the given block in the context of the target class 47 | # 48 | # @param block [Proc] 49 | # @return [void] 50 | # 51 | def target_eval(&block) 52 | target.instance_eval(&block) 53 | end 54 | end 55 | 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/eaco/dsl/resource.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module DSL 3 | 4 | ## 5 | # Parses the Resource definition DSL. 6 | # 7 | # Example: 8 | # 9 | # authorize Document do 10 | # roles :owner, :editor, :reader 11 | # 12 | # role :owner, 'Author' 13 | # 14 | # permissions do 15 | # reader :read 16 | # editor reader, :edit 17 | # owner editor, :destroy 18 | # end 19 | # end 20 | # 21 | # The DSL installs authorization in your +Document+ model, 22 | # defining three access roles. 23 | # 24 | # The +owner+ role is given a label of "Author". 25 | # 26 | # Each role has then different abilities, defined in the 27 | # permissions block. 28 | # 29 | # @see DSL::Resource::Permissions 30 | # 31 | class Resource < Base 32 | autoload :Permissions, 'eaco/dsl/resource/permissions' 33 | 34 | ## 35 | # Sets up an authorized resource. The only required API 36 | # is +accessible_by+. For available implementations, see 37 | # the {Adapters} module. 38 | # 39 | # @see Resource 40 | # 41 | def initialize(*) 42 | super 43 | 44 | target_eval do 45 | include Eaco::Resource 46 | 47 | def permissions 48 | @_permissions 49 | end 50 | 51 | def roles 52 | @_roles || [] 53 | end 54 | 55 | def roles_with_labels 56 | @_roles_with_labels ||= roles.inject({}) do |labels, role| 57 | labels.update(role => role.to_s.humanize) 58 | end 59 | end 60 | 61 | # Reset memoizations when this method is called on the target class, 62 | # so that reloading the authorizations configuration file will 63 | # refresh the models' configuration. 64 | @_roles_with_labels = nil 65 | end 66 | end 67 | 68 | ## 69 | # Defines the permissions on this resource. 70 | # The evaluated registries are memoized in the target class. 71 | # 72 | # @return [void] 73 | # 74 | def permissions(&block) 75 | target_eval do 76 | @_permissions = Permissions.eval(self, &block).result.freeze 77 | end 78 | end 79 | 80 | ## 81 | # Defines the roles valid for this resource. e.g. 82 | # 83 | # authorize Foobar do 84 | # roles :owner, :editor, :reader 85 | # end 86 | # 87 | # If the same user is at the same time +reader+ and +editor+, the 88 | # resulting role is +editor+. 89 | # 90 | # @param keys [Variadic] 91 | # 92 | # @return [void] 93 | # 94 | def roles(*keys) 95 | target_eval do 96 | @_roles = keys.flatten.freeze 97 | end 98 | end 99 | 100 | ## 101 | # Sets the given label on the given role. 102 | # 103 | # TODO rename this method, or use it to pass options 104 | # to improve readability of the DSL and to store more 105 | # metadata with each role for future extensibility. 106 | # 107 | # @param role [Symbol] 108 | # @param label [String] 109 | # 110 | # @return [void] 111 | # 112 | def role(role, label) 113 | target_eval do 114 | roles_with_labels[role] = label 115 | end 116 | end 117 | end 118 | 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/eaco/dsl/resource/permissions.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module DSL 3 | class Resource < Base 4 | 5 | ## 6 | # Permission collector, based on +method_missing+. 7 | # 8 | # Example: 9 | # 10 | # 1 authorize Foobar do 11 | # 2 permissions do 12 | # 3 reader :read_foo, :read_bar 13 | # 4 editor reader, :edit_foo, :edit_bar 14 | # 5 owner editor, :destroy 15 | # 6 end 16 | # 7 end 17 | # 18 | # Within the block, each undefined method call defines a new 19 | # method that returns the given arguments. 20 | # 21 | # After evaluating line 3 above: 22 | # 23 | # >> reader 24 | # => # 25 | # 26 | # The method is used then on line 4, giving the +editor+ role the 27 | # same set of permissions granted to the +reader+, plus its own 28 | # set of permissions: 29 | # 30 | # >> editor 31 | # => # 32 | # 33 | class Permissions < Base 34 | 35 | ## 36 | # Evaluates the given block in the context of a new collector 37 | # 38 | # Returns an Hash of permissions, keyed by role. 39 | # 40 | # >> Permissions.eval do 41 | # | permissions do 42 | # | reader :read 43 | # | editor reader, :edit 44 | # | end 45 | # | end 46 | # 47 | # => { 48 | # | reader: #>> 184 | \033[1;32m>>> EACO: \033[1;37m#{msg} 185 | \033[1;32m>>> 186 | \033[0m 187 | EOF 188 | end 189 | 190 | ## 191 | # @see {Rake::Utils.gemfile} 192 | # @private 193 | # 194 | def gemfile 195 | Rake::Utils.gemfile 196 | end 197 | 198 | ## 199 | # @return [Boolean] Are we running appraisals? 200 | # 201 | def running_appraisals? 202 | ENV["APPRAISAL_INITIALIZED"] 203 | end 204 | 205 | ## 206 | # @return [Boolean] Are we running on Travis CI? 207 | # 208 | def running_in_travis? 209 | ENV["TRAVIS"] 210 | end 211 | end 212 | 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /lib/eaco/rake/utils.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | module Rake 3 | 4 | ## 5 | # Assorted utilities. 6 | # 7 | module Utils 8 | extend self 9 | 10 | ## 11 | # Captures the stdout emitted by the given +block+ 12 | # 13 | # @param block [Proc] 14 | # @return [String] the captured output 15 | # 16 | def capture_stdout(&block) 17 | stdout, string = $stdout, StringIO.new 18 | $stdout = string 19 | 20 | yield 21 | 22 | string.tap(&:rewind).read 23 | ensure 24 | $stdout = stdout 25 | end 26 | 27 | ## 28 | # @return [String] the current gemfile name 29 | # 30 | def gemfile 31 | gemfile = ENV['BUNDLE_GEMFILE'] 32 | 33 | File.basename(gemfile, '.*') if gemfile 34 | end 35 | end 36 | 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/eaco/resource.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | ## 4 | # A Resource is an object that can be authorized. It has an {ACL}, that 5 | # defines the access levels of {Designator}s. {Actor}s have many designators 6 | # and those that match the {ACL} yield the access level of the {Actor} to 7 | # this {Resource}. 8 | # 9 | # If there is no match between the {Actor}'s designators and the {ACL}, then 10 | # access is denied. 11 | # 12 | # Authorized resources are defined through the DSL, see {DSL::Resource}. 13 | # 14 | # TODO Negative authorizations 15 | # 16 | # @see ACL 17 | # @see Actor 18 | # @see Designator 19 | # 20 | # @see DSL::Resource 21 | # 22 | module Resource 23 | 24 | # @private 25 | def self.included(base) 26 | base.extend ClassMethods 27 | end 28 | 29 | ## 30 | # Singleton methods added to authorized Resources. 31 | # 32 | module ClassMethods 33 | ## 34 | # @return [Boolean] checks whether the given +role+ is valid in the 35 | # context of this Resource. 36 | # 37 | # @param role [Symbol] role name. 38 | # 39 | def role?(role) 40 | roles.include?(role.to_sym) 41 | end 42 | 43 | ## 44 | # Checks whether the {ACL} and permissions defined on this Resource 45 | # allow the given +actor+ to perform the given +action+ on it, that 46 | # depends on the +roles+ the user has on the resource, calculated from 47 | # the {ACL}. 48 | # 49 | # @param action [Symbol] 50 | # @param actor [Actor] 51 | # @param resource [Resource] 52 | # 53 | # @return [Boolean] 54 | # 55 | def allows?(action, actor, resource) 56 | return true if actor.is_admin? 57 | 58 | roles = roles_of(actor, resource) 59 | 60 | return false if roles.empty? 61 | 62 | perms = roles.flat_map do |role| 63 | permissions[role].to_a 64 | end.compact.uniq 65 | 66 | perms.include?(action.to_sym) 67 | end 68 | 69 | ## 70 | # @return [Array] the given +actor+ roles in the given resource 71 | # 72 | # @param actor_or_designator [Actor or Designator] 73 | # @param resource [Resource] 74 | # 75 | def roles_of(actor_or_designator, resource) 76 | designators = if actor_or_designator.is_a?(Eaco::Designator) 77 | [actor_or_designator] 78 | 79 | elsif actor_or_designator.respond_to?(:designators) 80 | actor_or_designator.designators 81 | 82 | else 83 | raise Error, <<-EOF 84 | #{__method__} expects #{actor_or_designator.inspect} 85 | to be a Designator or to `respond_to?(:designators)` 86 | EOF 87 | end 88 | 89 | roles = [] 90 | 91 | resource.acl.each do |designator, role| 92 | if designators.include?(designator) 93 | roles << role 94 | end 95 | end 96 | 97 | roles 98 | end 99 | 100 | ## 101 | # The permissions defined for each role. 102 | # 103 | # @return [Hash] the defined permissions, keyed by +role+ 104 | # 105 | # @see DSL::Resource::Permissions 106 | # 107 | def permissions 108 | end 109 | 110 | # The defined roles. 111 | # 112 | # @return [Array] 113 | # 114 | # @see DSL::Resource 115 | # 116 | def roles 117 | end 118 | 119 | # Role labels map keyed by role symbol 120 | # 121 | # @return [Hash] 122 | # 123 | # @see DSL::Resource 124 | # 125 | def roles_with_labels 126 | end 127 | end 128 | 129 | ## 130 | # @return [Boolean] whether the given +action+ is allowed to the given +actor+. 131 | # 132 | # @param action [Symbol] 133 | # @param actor [Actor] 134 | # 135 | def allows?(action, actor) 136 | self.class.allows?(action, actor, self) 137 | end 138 | 139 | 140 | ## 141 | # @return [Array] list of roles for the given +actor+ 142 | # 143 | # @param actor [Actor] 144 | # 145 | def roles_of(actor) 146 | self.class.roles_of(actor, self) 147 | end 148 | 149 | ## 150 | # Grants the given +designator+ access to this Resource as the given +role+. 151 | # 152 | # @param role [Symbol] 153 | # @param designator [Variadic], see {ACL#add} 154 | # 155 | # @return [ACL] 156 | # 157 | # @see #change_acl 158 | # 159 | def grant(role, *designator) 160 | self.check_role!(role) 161 | 162 | change_acl {|acl| acl.add(role, *designator) } 163 | end 164 | 165 | ## 166 | # Revokes the given +designator+ access to this Resource. 167 | # 168 | # @param designator [Variadic], see {ACL#del} 169 | # 170 | # @return [ACL] 171 | # 172 | # @see #change_acl 173 | # 174 | def revoke(*designator) 175 | change_acl {|acl| acl.del(*designator) } 176 | end 177 | 178 | # Grants the given set of +designators+ access as to this Resource as the 179 | # given +role+. 180 | # 181 | # @param role [Symbol] 182 | # @param designators [Array] of {Designator}, see {ACL#add} 183 | # 184 | # @return [ACL] 185 | # 186 | # @see #change_acl 187 | # 188 | def batch_grant(role, designators) 189 | self.check_role!(role) 190 | 191 | change_acl do |acl| 192 | designators.each do |designator| 193 | acl.add(role, designator) 194 | end 195 | acl 196 | end 197 | end 198 | 199 | protected 200 | ## 201 | # Changes the ACL, calling the persistance setter if it changes. 202 | # 203 | # @yield [ACL] the current ACL or a new one if no ACL is set 204 | # 205 | # @return [ACL] the new ACL 206 | # 207 | def change_acl 208 | acl = yield self.acl.try(:dup) || self.class.acl.new 209 | 210 | self.acl = acl unless acl == self.acl 211 | 212 | return self.acl 213 | end 214 | 215 | ## 216 | # Checks whether the given +role+ is valid for this Resource. 217 | # 218 | # @param role [Symbol] the role name. 219 | # 220 | # @raise [Eaco::Error] if not valid. 221 | # 222 | def check_role!(role) 223 | unless self.class.role?(role) 224 | raise Error, 225 | "The `#{role}' role is not valid for `#{self.class.name}' objects. " \ 226 | "Valid roles are: `#{self.class.roles.join(', ')}'" 227 | end 228 | end 229 | end 230 | 231 | end 232 | -------------------------------------------------------------------------------- /lib/eaco/rspec.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_able_to do |*args| 2 | match do |actor| 3 | actor.can?(*args) 4 | end 5 | 6 | failure_message do |actor| 7 | "expected to be able to #{args.map(&:inspect).join(" ")}" 8 | end 9 | 10 | failure_message_when_negated do |actor| 11 | "expected not to be able to #{args.map(&:inspect).join(" ")}" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/eaco/version.rb: -------------------------------------------------------------------------------- 1 | module Eaco 2 | 3 | # Current version 4 | # 5 | VERSION = '1.1.2' 6 | 7 | end 8 | -------------------------------------------------------------------------------- /spec/eaco/acl_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::ACL do 6 | 7 | describe '#initialize' do 8 | subject { described_class.new(plain_acl) } 9 | 10 | let(:plain_acl) { Hash['user:42' => 'reader', 'group:fropper' => 'editor'] } 11 | 12 | it { expect(subject).to be_a(described_class) } 13 | it { expect(subject).to eq({'user:42' => :reader, 'group:fropper' => :editor}) } 14 | end 15 | 16 | describe '#add' do 17 | let(:designator) { Eaco::Designator.new 'test' } 18 | 19 | subject { acl.add(:reader, designator) } 20 | 21 | context 'when adding non-existing permissions' do 22 | let(:acl) { described_class.new } 23 | 24 | it { expect(subject).to be_a(described_class) } 25 | it { expect(subject).to include({designator => :reader}) } 26 | end 27 | 28 | context 'when replacing existing permissions' do 29 | let(:acl) { described_class.new(designator => :editor) } 30 | 31 | it { expect(subject).to be_a(described_class) } 32 | it { expect(subject).to include({designator => :reader}) } 33 | end 34 | 35 | context 'when looking up designators' do 36 | after { acl.add(:reader, :foo, 1) } 37 | 38 | let(:acl) { described_class.new } 39 | 40 | it { expect(Eaco::Designator).to receive(:make).with(:foo, 1) } 41 | end 42 | 43 | context 'when giving rubbish' do 44 | subject { acl.add(:reader, 'rubbish') } 45 | 46 | let(:acl) { described_class.new } 47 | 48 | it { expect { subject }.to \ 49 | raise_error(Eaco::Error). 50 | with_message(/Cannot infer designator from "rubbish"/) } 51 | end 52 | end 53 | 54 | describe '#del' do 55 | let(:designator) { Eaco::Designator.new 'test' } 56 | 57 | before { acl.del(designator) } 58 | 59 | context 'when removing non-existing permissions' do 60 | let(:acl) { described_class.new } 61 | it { expect(acl).to eq({}) } 62 | end 63 | 64 | context 'when removing existing permissions' do 65 | let(:acl) { described_class.new(designator => :editor) } 66 | it { expect(acl).to eq({}) } 67 | end 68 | end 69 | 70 | describe '#find_by_role' do 71 | let(:reader1) { Eaco::Designator.new 'Tom.Fropp' } 72 | let(:reader2) { Eaco::Designator.new 'Bob.Prutz' } 73 | let(:editor) { Eaco::Designator.new 'Jake.Boon' } 74 | 75 | let(:acl) do 76 | described_class.new({ 77 | reader1 => :reader, 78 | reader2 => :reader, 79 | editor => :editor, 80 | }) 81 | end 82 | 83 | context 'when looking up valid roles' do 84 | subject { acl.find_by_role(:reader) } 85 | 86 | it { expect(subject).to eq(Set.new([reader1, reader2])) } 87 | end 88 | 89 | context 'when looking up nonexisting roles' do 90 | subject { acl.find_by_role(:froober) } 91 | 92 | it { expect(subject).to eq(Set.new) } 93 | end 94 | end 95 | 96 | describe '#all' do 97 | let(:reader) { Eaco::Designator.new 'John.Loom' } 98 | let(:editor) { Eaco::Designator.new 'Mark.Poof' } 99 | 100 | let(:acl) do 101 | described_class.new({ 102 | reader => :reader, 103 | editor => :editor, 104 | }) 105 | end 106 | 107 | subject { acl.all } 108 | 109 | it { expect(subject).to eq(Set.new([reader, editor])) } 110 | end 111 | 112 | describe '#designators_map_for_role' do 113 | let(:reader) { Eaco::Designator.new 'Pete.Raid' } 114 | let(:editor1) { Eaco::Designator.new 'John.Alls' } 115 | let(:editor2) { Eaco::Designator.new 'Bob.Prutz' } 116 | 117 | let(:acl) do 118 | described_class.new({ 119 | reader => :reader, 120 | editor1 => :editor, 121 | editor2 => :editor, 122 | }) 123 | end 124 | 125 | subject { acl.designators_map_for_role(:editor) } 126 | 127 | before do 128 | expect(editor1).to receive(:resolve) { ['John Alls'] } 129 | expect(editor2).to receive(:resolve) { ['Robert Prutzon'] } 130 | end 131 | 132 | example do 133 | expect(subject).to eq({ 134 | editor1 => Set.new(['John Alls']), 135 | editor2 => Set.new(['Robert Prutzon']) 136 | }) 137 | end 138 | end 139 | 140 | describe '#actors_by_role' do 141 | let(:reader) { Eaco::Designator.new 'Pete.Raid' } 142 | let(:editor1) { Eaco::Designator.new 'John.Alls' } 143 | let(:editor2) { Eaco::Designator.new 'Bob.Prutz' } 144 | 145 | let(:acl) do 146 | described_class.new({ 147 | reader => :reader, 148 | editor1 => :editor, 149 | editor2 => :editor, 150 | }) 151 | end 152 | 153 | subject { acl.actors_by_role(:editor) } 154 | 155 | before do 156 | expect(editor1).to receive(:resolve) { ['John Alls'] } 157 | expect(editor2).to receive(:resolve) { ['Robert Prutzon'] } 158 | end 159 | 160 | example do 161 | expect(subject).to eq(['John Alls', 'Robert Prutzon']) 162 | end 163 | end 164 | 165 | describe '#inspect' do 166 | let(:acl) { described_class.new('foo' => :bar) } 167 | 168 | subject { acl.inspect } 169 | 170 | it { expect(subject).to eq('#:bar}>') } 171 | end 172 | 173 | describe '#pretty_inspect' do 174 | require 'pp' 175 | 176 | let(:acl) { described_class.new('foo' => :bar) } 177 | 178 | subject { acl.pretty_inspect } 179 | 180 | it { expect(subject).to eq("Eaco::ACL\n{\"foo\"=>:bar}\n") } 181 | end 182 | 183 | end 184 | -------------------------------------------------------------------------------- /spec/eaco/actor_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::Actor do 6 | 7 | #pending '#designators' 8 | 9 | #pending '#is_admin?' 10 | 11 | #pending '#can?' 12 | 13 | end 14 | -------------------------------------------------------------------------------- /spec/eaco/controller_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | require 'eaco/controller' 5 | 6 | RSpec.describe Eaco::Controller do 7 | 8 | #pending '.authorize' 9 | 10 | #pending '.permission_for' 11 | 12 | end 13 | -------------------------------------------------------------------------------- /spec/eaco/designator_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::Designator do 6 | 7 | #pending '#make' 8 | 9 | #pending '#parse' 10 | 11 | #pending '#resolve' 12 | 13 | #pending '#configure!' 14 | 15 | #pending '#harvest' 16 | 17 | #pending '#label' 18 | 19 | #pending '#id' 20 | 21 | #pending '#search' 22 | 23 | #pending '#new' 24 | 25 | end 26 | -------------------------------------------------------------------------------- /spec/eaco/dsl/acl_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::DSL::ACL do 6 | 7 | #pending '#new' 8 | 9 | end 10 | -------------------------------------------------------------------------------- /spec/eaco/dsl/actor/designators_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::DSL::Actor::Designators do 6 | 7 | end 8 | -------------------------------------------------------------------------------- /spec/eaco/dsl/actor_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::DSL::Actor do 6 | 7 | #pending '#new' 8 | 9 | #pending '#designators' 10 | 11 | #pending '#admin_logic' 12 | 13 | #pending '.find_designator' 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/eaco/dsl/resource/permissions_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::DSL::Resource::Permissions do 6 | 7 | end 8 | -------------------------------------------------------------------------------- /spec/eaco/dsl/resource_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::DSL::Resource do 6 | 7 | #pending '#new' 8 | 9 | #pending '#permissions' 10 | 11 | #pending '#roles' 12 | 13 | #pending '#roles_with_labels' 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/eaco/error_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::Error do 6 | 7 | #pending '#new' 8 | 9 | end 10 | -------------------------------------------------------------------------------- /spec/eaco/resource_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco::Resource do 6 | 7 | #pending '.role?' 8 | 9 | #pending '.allows?' 10 | 11 | #pending '.permissions' 12 | 13 | #pending '.roles' 14 | 15 | #pending '.roles_with_labels' 16 | 17 | #pending '#allows?' 18 | 19 | #pending '#role_of' 20 | 21 | #pending '#grant' 22 | 23 | #pending '#revoke' 24 | 25 | #pending '#batch_grant' 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/eaco_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Eaco do 6 | 7 | describe '.parse_default_rules_file!' do 8 | subject { Eaco.parse_default_rules_file! } 9 | 10 | before { expect(Eaco).to receive(:parse_rules!).with(Eaco::DEFAULT_RULES) } 11 | 12 | it { expect(subject).to be(nil) } 13 | end 14 | 15 | 16 | describe '.parse_rules!' do 17 | subject { Eaco.parse_rules! file } 18 | 19 | context 'when the file does exist' do 20 | let(:file) { double() } 21 | 22 | before do 23 | expect(file).to receive(:exist?).and_return(true) 24 | expect(file).to receive(:read).and_return('') 25 | expect(file).to receive(:realpath).and_return('test') 26 | end 27 | 28 | it { expect(subject).to be(true) } 29 | end 30 | 31 | context 'when the file does not exist' do 32 | let(:file) { Pathname('/nonexistant') } 33 | 34 | it { expect { subject }.to raise_error(Eaco::Malformed, /Please create \/nonexistant/) } 35 | end 36 | end 37 | 38 | describe '.eval!' do 39 | let(:source) { '' } 40 | let(:path) { '' } 41 | 42 | subject { Eaco.eval! source, path } 43 | 44 | before { expect(Eaco::DSL).to receive(:eval).with(source, nil, path, 1) } 45 | 46 | it { expect(subject).to be(true) } 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'byebug' 3 | 4 | require 'eaco/coverage' 5 | Eaco::Coverage.start! 6 | 7 | require 'eaco' 8 | 9 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 10 | # 11 | RSpec.configure do |config| 12 | 13 | config.expect_with :rspec do |expectations| 14 | # This option will default to `true` in RSpec 4. It makes the `description` 15 | # and `failure_message` of custom matchers include text for helper methods 16 | # defined using `chain`, e.g.: 17 | # be_bigger_than(2).and_smaller_than(4).description 18 | # # => "be bigger than 2 and smaller than 4" 19 | # ...rather than: 20 | # # => "be bigger than 2" 21 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 22 | end 23 | 24 | config.mock_with :rspec do |mocks| 25 | # Prevents you from mocking or stubbing a method that does not exist on 26 | # a real object. This is generally recommended, and will default to 27 | # `true` in RSpec 4. 28 | mocks.verify_partial_doubles = true 29 | end 30 | 31 | # These two settings work together to allow you to limit a spec run 32 | # to individual examples or groups you care about by tagging them with 33 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 34 | # get run. 35 | config.filter_run :focus 36 | config.run_all_when_everything_filtered = true 37 | 38 | # Limits the available syntax to the non-monkey patched syntax that is 39 | # recommended. For more details, see: 40 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 41 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 42 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 43 | config.disable_monkey_patching! 44 | 45 | # This setting enables warnings. It's recommended, but in some cases may 46 | # be too noisy due to issues in dependencies. 47 | config.warnings = true 48 | 49 | # Many RSpec users commonly either run the entire suite or an individual 50 | # file, and it's useful to allow more verbose output when running an 51 | # individual spec file. 52 | if config.files_to_run.one? 53 | # Use the documentation formatter for detailed output, 54 | # unless a formatter has already been configured 55 | # (e.g. via a command-line flag). 56 | config.default_formatter = 'doc' 57 | end 58 | 59 | # Print the 5 slowest examples and example groups at the 60 | # end of the spec run, to help surface which specs are running 61 | # particularly slow. 62 | # config.profile_examples = 5 63 | 64 | # Run specs in random order to surface order dependencies. If you find an 65 | # order dependency and want to debug it, you can fix the order by providing 66 | # the seed, which is printed after each run. 67 | # --seed 1234 68 | config.order = :random 69 | 70 | # Seed global randomization in this process using the `--seed` CLI option. 71 | # Setting this allows you to use `--seed` to deterministically reproduce 72 | # test failures related to randomization by passing the same `--seed` value 73 | # as the one that triggered the failure. 74 | Kernel.srand config.seed 75 | end 76 | --------------------------------------------------------------------------------