├── .rspec ├── .ruby-version ├── .ruby-gemset ├── .gitignore ├── .travis.yml ├── Gemfile ├── spec ├── spec_helper.rb ├── storage_spec.rb ├── configuration_spec.rb ├── rules.json ├── iron_hide_spec.rb ├── storage │ └── file_adapter_spec.rb ├── rule_spec.rb ├── integration_spec.rb └── condition_spec.rb ├── lib ├── iron_hide │ ├── version.rb │ ├── memoize.rb │ ├── errors.rb │ ├── storage.rb │ ├── configuration.rb │ ├── storage │ │ └── file_adapter.rb │ ├── rule.rb │ └── condition.rb └── iron_hide.rb ├── benchmark ├── benchmark.log └── benchmark.rb ├── Rakefile ├── LICENSE.txt ├── iron_hide.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.1 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | iron_hide 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .bundle/ 3 | .DS_STORE 4 | .DS_Store 5 | doc/ 6 | tags 7 | .yardoc/ 8 | *.gem 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1.1 4 | - 2.1.0 5 | - 2.0.0 6 | - 1.9.3 7 | - jruby-19mode 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in iron_hide.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'rspec/mocks' 3 | require 'rspec/expectations' 4 | require 'rspec/autorun' 5 | require 'iron_hide' 6 | -------------------------------------------------------------------------------- /lib/iron_hide/version.rb: -------------------------------------------------------------------------------- 1 | module IronHide 2 | MAJOR = "0" 3 | MINOR = "4" 4 | BUILD = "1" 5 | VERSION = [MAJOR, MINOR, BUILD].join(".") 6 | end 7 | -------------------------------------------------------------------------------- /spec/storage_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe IronHide::Storage do 4 | describe "ADAPTERS" do 5 | it "returns a Hash of valid adapter types" do 6 | expect(IronHide::Storage::ADAPTERS).to eq( 7 | { 8 | file: :FileAdapter 9 | } 10 | ) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /benchmark/benchmark.log: -------------------------------------------------------------------------------- 1 | 2014-04-18 2 | ---------- 3 | user system total real 4 | 10 0.010000 0.000000 0.010000 ( 0.002792) 5 | 10 - Expensive 0.000000 0.000000 0.000000 ( 1.002170) 6 | 10 - Expensive: No Cache 0.000000 0.000000 0.000000 ( 10.010425) 7 | 1000 0.050000 0.000000 0.050000 ( 0.052537) 8 | 100_000 5.870000 0.480000 6.350000 ( 6.373051) 9 | 10 | -------------------------------------------------------------------------------- /lib/iron_hide/memoize.rb: -------------------------------------------------------------------------------- 1 | module IronHide 2 | 3 | # The SimpleCache does not expire cache entries 4 | # It is used only to memoize method calls during a single authorization 5 | # decision. 6 | # 7 | class SimpleCache 8 | attr_accessor :cache 9 | 10 | def initialize 11 | @cache = {} 12 | end 13 | 14 | def fetch(expression) 15 | cache.fetch(expression) { 16 | cache[expression] = yield 17 | } 18 | end 19 | end 20 | 21 | class NullCache 22 | def fetch(_) 23 | yield 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/iron_hide/errors.rb: -------------------------------------------------------------------------------- 1 | module IronHide 2 | # All exceptions inherit from IronHideError to allow rescuing from all 3 | # exceptions that occur in this gem. 4 | # 5 | class IronHideError < StandardError ; end 6 | 7 | # Exception raised when an authorization failure occurs. 8 | # Typically when IronHide::authorize! is invoked 9 | # 10 | class AuthorizationError < IronHideError ; end 11 | 12 | # Exception raised when a conditional is incorrectly defined 13 | # in the rules. 14 | # @see IronHide::Condition 15 | # 16 | class InvalidConditional < IronHideError ; end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/iron_hide/storage.rb: -------------------------------------------------------------------------------- 1 | # IronHide::Storage provides a common interface regardless of storage type 2 | # 3 | require 'multi_json' 4 | 5 | module IronHide 6 | # @api private 7 | class Storage 8 | 9 | ADAPTERS = { 10 | file: :FileAdapter 11 | } 12 | 13 | attr_reader :adapter 14 | 15 | def initialize(adapter_type) 16 | @adapter = self.class.const_get(ADAPTERS[adapter_type]).new 17 | end 18 | 19 | # @see AbstractAdapter#where 20 | def where(opts = {}) 21 | adapter.where(opts) 22 | end 23 | end 24 | end 25 | 26 | 27 | require 'iron_hide/storage/file_adapter' 28 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | require 'date' 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << 'spec' 7 | t.test_files = FileList['spec/**/*_spec.rb'] 8 | t.verbose = true 9 | end 10 | 11 | desc 'Run tests' 12 | task :default => :test 13 | 14 | desc 'Run and log performance benchmarks' 15 | task :benchmark do 16 | begin 17 | target = File.join('benchmark','benchmark.log') 18 | file = File.open(target, 'a') 19 | result = %x(benchmark/benchmark.rb) 20 | puts result 21 | file.puts DateTime.now.strftime('%F') 22 | file.puts '-'*10 23 | file.puts result 24 | file.puts "\n" 25 | ensure 26 | file.close 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe IronHide::Configuration do 4 | describe "defaults" do 5 | it "initializes with default configuration variables" do 6 | configuration = IronHide::Configuration.new 7 | 8 | expect(configuration.adapter).to eq :file 9 | expect(configuration.namespace).to eq 'com::IronHide' 10 | expect(configuration.json).to eq nil 11 | end 12 | end 13 | 14 | describe "::add_configuration" do 15 | it "creates an accessor and default values for additional configuration variables" do 16 | configuration = IronHide::Configuration.new 17 | 18 | configuration.add_configuration(var1: :default1, var2: :default2, var3: nil) 19 | 20 | expect(configuration.var1).to eq :default1 21 | expect(configuration.var2).to eq :default2 22 | 23 | configuration.var3 = :nondefault 24 | expect(configuration.var3).to eq :nondefault 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "resource": "com::test::TestResource", 4 | "action": ["read", "update"], 5 | "description": "Read/update access for TestResource.", 6 | "effect": "allow", 7 | "conditions": [ 8 | { 9 | "equal": { 10 | "user::user_role_ids": ["1", "2"] 11 | } 12 | } 13 | ] 14 | }, 15 | { 16 | "resource": "com::test::TestResource", 17 | "action": ["delete"], 18 | "description": "Delete access for TestResource", 19 | "effect": "allow", 20 | "conditions": [ 21 | { 22 | "equal": { 23 | "user::user_role_ids": ["1"] 24 | } 25 | } 26 | ] 27 | }, 28 | { 29 | "resource": "com::test::TestResource", 30 | "action": ["read"], 31 | "description": "Read access for TestResource.", 32 | "effect": "deny", 33 | "conditions": [ 34 | { 35 | "equal": { 36 | "user::user_role_ids": ["5"] 37 | } 38 | } 39 | ] 40 | } 41 | ] 42 | -------------------------------------------------------------------------------- /lib/iron_hide/configuration.rb: -------------------------------------------------------------------------------- 1 | module IronHide 2 | class Configuration 3 | 4 | attr_accessor :adapter, :namespace, :json, :memoize 5 | 6 | def initialize 7 | @adapter = :file 8 | @namespace = 'com::IronHide' 9 | @memoize = true 10 | end 11 | 12 | def memoizer 13 | memoize ? SimpleCache : NullCache 14 | end 15 | 16 | # Extend configuration variables 17 | # 18 | # @param config_hash [Hash] 19 | # 20 | # @example 21 | # IronHide.configuration.add_configuration(couchdb_server: 'http://127.0.0.1:5984') 22 | # IronHide.configuration.couchdb_server) 23 | # #=> 'http://127.0.0.1:5984' 24 | # 25 | # IronHide.configuration.couchdb_server = 'other' 26 | # #=> 'other' 27 | # 28 | def add_configuration(config_hash) 29 | config_hash.each do |key, val| 30 | instance_eval { instance_variable_set("@#{key}",val) } 31 | self.class.instance_eval { attr_accessor key } 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014 The Climate Corporation 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /iron_hide.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'iron_hide/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "iron_hide" 8 | spec.version = IronHide::VERSION 9 | spec.authors = ["Alan Cohen"] 10 | spec.email = ["acohen@climate.com"] 11 | spec.description = %q{A Ruby authorization library} 12 | spec.summary = %q{Describe your authorization rules with JSON} 13 | spec.homepage = "http://github.com/TheClimateCorporation/iron_hide" 14 | spec.license = "MIT" 15 | 16 | spec.files = Dir['lib/**/*.rb'] 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency "multi_json" 22 | spec.add_runtime_dependency "json_minify", "~> 0.2" 23 | 24 | spec.add_development_dependency "bundler", "~> 1" 25 | spec.add_development_dependency "rake", "~> 10" 26 | spec.add_development_dependency "rspec", "~> 2" 27 | spec.add_development_dependency "yard", "~> 0" 28 | spec.add_development_dependency "pry" 29 | end 30 | -------------------------------------------------------------------------------- /lib/iron_hide.rb: -------------------------------------------------------------------------------- 1 | module IronHide 2 | class << self 3 | 4 | # @raise [IronHide::AuthorizationError] if authorization fails 5 | # @return [true] if authorization succeeds 6 | # 7 | def authorize!(user, action, resource) 8 | unless can?(user, action, resource) 9 | raise AuthorizationError 10 | end 11 | true 12 | end 13 | 14 | # @return [Boolean] 15 | # @param user [Object] 16 | # @param action [Symbol, String] 17 | # @param resource [Object] 18 | # @see IronHide::Rule::allow? 19 | # 20 | def can?(user, action, resource) 21 | Rule.allow?(user, action.to_s, resource) 22 | end 23 | 24 | # @return [IronHide::Storage] 25 | def storage 26 | @storage ||= IronHide::Storage.new(configuration.adapter) 27 | end 28 | 29 | attr_reader :configuration 30 | 31 | # @yield [IronHide::Configuration] 32 | def config 33 | yield configuration 34 | end 35 | 36 | def configuration 37 | @configuration ||= IronHide::Configuration.new 38 | end 39 | 40 | alias_method :configure, :config 41 | 42 | # Resets storage 43 | # Useful primarily for testing 44 | # 45 | # @return [void] 46 | def reset 47 | @storage = nil 48 | end 49 | end 50 | end 51 | 52 | require "iron_hide/version" 53 | require 'iron_hide/errors' 54 | require 'iron_hide/rule' 55 | require 'iron_hide/condition' 56 | require 'iron_hide/storage' 57 | require 'iron_hide/configuration' 58 | -------------------------------------------------------------------------------- /spec/iron_hide_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe IronHide do 4 | let(:user) { double('user') } 5 | let(:action) { double('action') } 6 | let(:resource) { double('resource') } 7 | 8 | describe "::authorize!" do 9 | context "when the rules allow" do 10 | before do 11 | IronHide::Rule.stub(:allow?) 12 | .with(user,action.to_s,resource) { true } 13 | end 14 | 15 | it "returns true" do 16 | expect(IronHide.authorize!(user, action, resource)).to eq true 17 | end 18 | end 19 | 20 | context "when the rules do not allow" do 21 | before do 22 | IronHide::Rule.stub(:allow?) 23 | .with(user,action.to_s,resource) { false } 24 | end 25 | 26 | it "raise IronHide::AuthorizationError" do 27 | expect{IronHide.authorize!(user, action, resource)}.to raise_error(IronHide::AuthorizationError) 28 | end 29 | end 30 | end 31 | 32 | describe "::can?" do 33 | context "when the rules allow" do 34 | before do 35 | IronHide::Rule.stub(:allow?) 36 | .with(user,action.to_s,resource) { true } 37 | end 38 | 39 | it "returns true" do 40 | expect(IronHide.can?(user, action, resource)).to eq true 41 | end 42 | end 43 | context "when the rules do not allow" do 44 | before do 45 | IronHide::Rule.stub(:allow?) 46 | .with(user,action.to_s,resource) { false } 47 | end 48 | 49 | it "returns false" do 50 | expect(IronHide.can?(user, action, resource)).to eq false 51 | end 52 | end 53 | end 54 | 55 | describe "::storage" do 56 | before do 57 | IronHide.configure do |config| 58 | config.adapter = :file 59 | config.json = 'spec/rules.json' 60 | end 61 | end 62 | 63 | it "returns an IronHide::Storage object" do 64 | expect(IronHide.storage).to be_instance_of(IronHide::Storage) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/iron_hide/storage/file_adapter.rb: -------------------------------------------------------------------------------- 1 | module IronHide 2 | class Storage 3 | # @api private 4 | class FileAdapter 5 | attr_reader :rules 6 | 7 | def initialize 8 | json = Array(IronHide.configuration.json).each_with_object([]) do |file, ary| 9 | ary.concat(MultiJson.load(File.open(file).read, minify: true)) 10 | end 11 | @rules = unfold(json) 12 | rescue MultiJson::ParseError => e 13 | raise IronHideError, "#{e.cause}: #{e.data}" 14 | rescue => e 15 | raise IronHideError, "Invalid or missing JSON file: #{e.to_s}" 16 | end 17 | 18 | def where(opts = {}) 19 | self[opts[:resource]][opts[:action]] 20 | end 21 | 22 | # Unfold the JSON definitions of the rules into a Hash with this structure: 23 | # { 24 | # "com::test::TestResource" => { 25 | # "action" => [ 26 | # { ... }, { ... }, { ... } 27 | # ] 28 | # } 29 | # } 30 | # 31 | # @param json [Array] 32 | # @return [Hash] 33 | def unfold(json) 34 | json.inject(hash_of_hashes) do |rules, json_rule| 35 | resource, actions = json_rule["resource"], json_rule["action"] 36 | actions.each { |act| rules[resource][act] << json_rule } 37 | rules 38 | end 39 | end 40 | 41 | private 42 | 43 | # Return a Hash with default value that is a Hash with default value of Array 44 | # @return [Hash] 45 | def hash_of_hashes 46 | Hash.new { |h1,k1| 47 | h1[k1] = Hash.new { |h,k| h[k] = [] } 48 | } 49 | end 50 | 51 | # Implements an interface that makes selecting rules look like a Hash: 52 | # @example 53 | # { 54 | # 'com::test::TestResource' => { 55 | # 'read' => [], 56 | # ... 57 | # } 58 | # } 59 | # adapter['com::test::TestResource']['read'] 60 | # #=> [Array] 61 | # 62 | # @param [Symbol] val 63 | # @return [Array] array of canonical JSON representation of rules 64 | def [](val) 65 | rules[val] 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/iron_hide/rule.rb: -------------------------------------------------------------------------------- 1 | require 'iron_hide/memoize' 2 | 3 | module IronHide 4 | class Rule 5 | ALLOW = 'allow'.freeze 6 | DENY = 'deny'.freeze 7 | 8 | attr_reader :description, :effect, :conditions, :user, :resource, :cache 9 | 10 | def initialize(user, resource, params = {}, cache = NullCache.new) 11 | @user = user 12 | @resource = resource 13 | @description = params['description'] 14 | @effect = params.fetch('effect', DENY) # Default DENY 15 | @conditions = Array(params['conditions']).map { |c| Condition.new(c, cache) } 16 | end 17 | 18 | # Returns all applicable rules matching on resource and action 19 | # 20 | # @param user [Object] 21 | # @param action [String] 22 | # @param resource [Object] 23 | # @return [Array] 24 | def self.find(user, action, resource) 25 | cache = IronHide.configuration.memoizer.new 26 | ns_resource = "#{IronHide.configuration.namespace}::#{resource.class.name}" 27 | storage.where(resource: ns_resource, action: action).map do |json| 28 | new(user, resource, json, cache) 29 | end 30 | end 31 | 32 | # NOTE: If any Rule is an explicit DENY, then an allow cannot override the Rule 33 | # If any Rule is explicit ALLOW, and there is no explicit DENY, then ALLOW 34 | # If no Rules match, then DENY 35 | # 36 | # @return [Boolean] 37 | # @param user [Object] 38 | # @param action [String] 39 | # @param resource [String] 40 | # 41 | def self.allow?(user, action, resource) 42 | find(user, action, resource).inject(false) do |rval, rule| 43 | # For an explicit DENY, stop evaluating, and return false 44 | rval = false and break if rule.explicit_deny? 45 | 46 | # For an explicit ALLOW, true 47 | rval = true if rule.allow? 48 | 49 | rval 50 | end 51 | end 52 | 53 | # An abstraction over the storage of the rules 54 | # @see IronHide::Storage 55 | # @return [IronHide::Storage] 56 | def self.storage 57 | IronHide.storage 58 | end 59 | 60 | # @return [Boolean] 61 | def allow? 62 | effect == ALLOW && conditions.all? { |c| c.met?(user,resource) } 63 | end 64 | 65 | # @return [Boolean] 66 | def explicit_deny? 67 | effect == DENY && conditions.all? { |c| c.met?(user,resource) } 68 | end 69 | 70 | alias_method :deny?, :explicit_deny? 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /benchmark/benchmark.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'iron_hide' 4 | require 'multi_json' 5 | require 'tempfile' 6 | require 'benchmark' 7 | require 'json/ext' 8 | 9 | User = Class.new 10 | Resource = Class.new 11 | 12 | class BenchmarkTest 13 | 14 | attr_reader :num, :expensive, :cache 15 | 16 | # @param [Integer] num is the number of rules to benchmark with 17 | # @param [Boolean] expensive tests the effect of a repeated, expensive 18 | # attribute/method call in the rules 19 | # @param [Boolean] cache turn attribution evaluation caching on or off 20 | def initialize(num, expensive = false, cache = true) 21 | @num = num 22 | @expensive = expensive 23 | @cache = cache 24 | end 25 | 26 | def test 27 | num = @num 28 | expensive = @expensive 29 | 30 | [ User, Resource ].each do |klass| 31 | klass.instance_eval do 32 | num.times do |n| 33 | # Dynamically create num attributes 34 | # i.e., :attr0, :attr1, :attr(num-1) 35 | define_method("attr#{n}") do 36 | return 10 37 | end 38 | end 39 | end 40 | end 41 | 42 | User.instance_eval do 43 | define_method(:expensive_attr) do 44 | # Sleep for 10 if :expensive is true 45 | sleep(expensive ? 1 : 0) and true 46 | end 47 | end 48 | 49 | @rules = begin 50 | num.times.map do |n| 51 | { 52 | :resource => 'benchmark::Resource', 53 | :action => ['read'], 54 | :effect => 'allow', 55 | :conditions => [ 56 | { :equal => { "user::expensive_attr" => ["1"] } }, 57 | { :equal => { "user::attr#{n}" => ["resource::attr#{n}"] } } 58 | ] 59 | } 60 | end 61 | end 62 | 63 | @file = Tempfile.new('rules.json') 64 | File.open(@file, 'w+') { |f| f << MultiJson.dump(@rules) } 65 | @file.rewind 66 | 67 | IronHide.reset 68 | IronHide.configure do |config| 69 | config.namespace = 'benchmark' 70 | config.json = @file.path 71 | config.memoize = @cache 72 | end 73 | 74 | @resource = Resource.new 75 | @user = User.new 76 | IronHide.can?(@user, :read, @resource) 77 | 78 | ensure 79 | @file.close 80 | end 81 | end 82 | 83 | ten = BenchmarkTest.new(10) 84 | ten_expensive_cache = BenchmarkTest.new(10,true) 85 | ten_expensive_no_cache = BenchmarkTest.new(10,true, false) 86 | thousand = BenchmarkTest.new(1000) 87 | hundred_thousand = BenchmarkTest.new(100_000) 88 | 89 | Benchmark.bm do |b| 90 | b.report("10 ") { ten.test} 91 | b.report("10 - Expensive ") { ten_expensive_cache.test} 92 | b.report("10 - Expensive: No Cache") { ten_expensive_no_cache.test} 93 | b.report("1000 ") { thousand.test} 94 | b.report("100_000 ") { hundred_thousand.test} 95 | end 96 | -------------------------------------------------------------------------------- /spec/storage/file_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe IronHide::Storage::FileAdapter do 4 | context "when using FileAdapter" do 5 | before(:all) do 6 | IronHide.reset 7 | IronHide.config do |config| 8 | config.adapter = :file 9 | config.json = File.join('spec','rules.json') 10 | end 11 | end 12 | 13 | let(:storage) { IronHide.storage } 14 | 15 | describe "#adapter" do 16 | it "returns a FileAdapter" do 17 | expect(storage.adapter).to be_instance_of(IronHide::Storage::FileAdapter) 18 | end 19 | end 20 | 21 | describe "#where" do 22 | # Examples are stored in spec/rules.json 23 | let(:example1) do 24 | [ 25 | { 26 | "resource" => "com::test::TestResource", 27 | "action" => [ "read", "update" ], 28 | "description" => "Read/update access for TestResource.", 29 | "effect" => "allow", 30 | "conditions" => [ 31 | {"equal"=>{"user::user_role_ids"=>["1", "2"]}} 32 | ] 33 | }, 34 | { 35 | "resource" => "com::test::TestResource", 36 | "action" => [ "read" ], 37 | "description" => "Read access for TestResource.", 38 | "effect" => "deny", 39 | "conditions" => [ 40 | {"equal"=>{"user::user_role_ids"=>["5"]}} 41 | ] 42 | } 43 | ] 44 | end 45 | 46 | context "example1" do 47 | it "returns all the JSON rules for a specified action/resource" do 48 | json = storage.where( 49 | resource: "com::test::TestResource", 50 | action: "read") 51 | 52 | expect(json).to eq(example1) 53 | end 54 | end 55 | 56 | let(:example2) do 57 | [ 58 | { 59 | "resource" => "com::test::TestResource", 60 | "action" => [ "read", "update" ], 61 | "description" => "Read/update access for TestResource.", 62 | "effect" => "allow", 63 | "conditions" => [ 64 | {"equal"=>{"user::user_role_ids"=>["1", "2"]}} 65 | ] 66 | } 67 | ] 68 | end 69 | context "example2" do 70 | it "returns all the JSON rules for a specified action/resource" do 71 | json = storage.where( 72 | resource: "com::test::TestResource", 73 | action: "update") 74 | 75 | expect(json).to eq(example2) 76 | end 77 | end 78 | 79 | let(:example3) do 80 | [ 81 | { 82 | "resource" => "com::test::TestResource", 83 | "action"=> [ "delete" ], 84 | "description"=> "Delete access for TestResource", 85 | "effect"=> "allow", 86 | "conditions"=> [ 87 | { 88 | "equal"=> { 89 | "user::user_role_ids"=> ["1"] 90 | } 91 | } 92 | ] 93 | } 94 | ] 95 | end 96 | context "example3" do 97 | it "returns all the JSON rules for a specified action/resource" do 98 | json = storage.where( 99 | resource: "com::test::TestResource", 100 | action: "delete") 101 | 102 | expect(json).to eq(example3) 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/iron_hide/condition.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module IronHide 4 | class Condition 5 | VALID_TYPES = { 6 | 'equal'=> :EqualCondition, 7 | 'not_equal'=> :NotEqualCondition 8 | }.freeze 9 | 10 | # @param params [Hash] It has a single key, which is the conditional operator 11 | # type. The value is the set of conditionals that must be met. 12 | # 13 | # @example 14 | # { :equal => { 15 | # 'resource::manager_id' => ['user::manager_id'], 16 | # 'user::user_role_ids' => ['8'] 17 | # } 18 | # } 19 | # 20 | # @return [EqualCondition, NotEqualCondition] 21 | # @raise [IronHide::InvalidConditional] for too many keys 22 | # 23 | def self.new(params, cache = NullCache.new) 24 | if params.length > 1 25 | raise InvalidConditional, "Expected #{params} to have one key" 26 | end 27 | type, conditionals = params.first 28 | #=> :equal, { key: val, key: val } 29 | # 30 | # See: http://ruby-doc.org/core-1.9.3/Class.html#method-i-allocate 31 | klass = VALID_TYPES.fetch(type){ raise InvalidConditional, "#{type} is not valid"} 32 | cond = IronHide.const_get(klass).allocate 33 | cond.send(:initialize, conditionals, cache) 34 | cond 35 | end 36 | 37 | # @param conditionals [Hash] 38 | # @example 39 | # { 40 | # 'resource::manager_id' => ['user::manager_id'], 41 | # 'user::user_role_ids' => ['8'] 42 | # } 43 | # 44 | # @param [IronHide::SimpleCache, IronHide::NullCache] cache 45 | # 46 | def initialize(conditionals, cache) 47 | @conditionals = conditionals 48 | @cache = cache 49 | end 50 | 51 | attr_reader :conditionals, :cache 52 | 53 | # @param user [Object] 54 | # @param resource [Object] 55 | # return [Boolean] if is met 56 | def met?(user, resource) 57 | raise NotImplementedError 58 | end 59 | 60 | protected 61 | 62 | EVALUATE_REGEX = / 63 | ( 64 | \Auser\z| # 'user' or 'resource' 65 | \Aresource\z 66 | ) 67 | | # OR 68 | \A\w+:{2}\w+ # "word::word" 69 | (:{2}\w+)* # Followed by any number of "::word" 70 | \z # End of string 71 | /x 72 | 73 | # *Safely* evaluate a conditional expression 74 | # 75 | # @note 76 | # This does not guarantee that conditions are correctly specified. 77 | # For example, 'user:::manager' will not resolve to anything, and 78 | # and an exception will *not* be raised. The same goes for 'user:::' and 79 | # 'user:id'. 80 | # 81 | # @param expressions [Array, String, Object] an array or 82 | # a single expression. This represents either an immediate value (e.g., 83 | # '1', 99) or a valid expression that can be interpreted (see example) 84 | # 85 | # @example 86 | # ['user::manager_id'] #=> [1] 87 | # ['user::role_ids'] #=> [1,2,3,4] 88 | # ['resource::manager_id'] #=> [1] 89 | # [1,2,3,4] #=> [1,2,3,4] 90 | # 'user::id' #=> [1] 91 | # 'resource::id' #=> [2] 92 | # 93 | # @return [Array] a collection of 0 or more objects 94 | # representing attributes on the user or resource 95 | # 96 | def evaluate(expression, user, resource) 97 | Array(expression).flat_map do |el| 98 | if expression?(el) 99 | cache.fetch(el) { 100 | type, *ary = el.split('::') 101 | if type == 'user' 102 | Array(ary.inject(user) do |rval, attr| 103 | rval.freeze.public_send(attr) 104 | end) 105 | elsif type == 'resource' 106 | Array(ary.inject(resource) do |rval, attr| 107 | rval.freeze.public_send(attr) 108 | end) 109 | else 110 | raise "Expected #{type} to be 'resource' or 'user'" 111 | end 112 | } 113 | else 114 | el 115 | end 116 | end 117 | end 118 | 119 | def expression?(expression) 120 | !!(expression =~ EVALUATE_REGEX) 121 | end 122 | 123 | def with_error_handling 124 | yield 125 | rescue => e 126 | new_exception = InvalidConditional.new(e.to_s) 127 | new_exception.set_backtrace(e.backtrace) 128 | raise new_exception 129 | end 130 | end 131 | 132 | # @api private 133 | class EqualCondition < Condition 134 | def met?(user, resource) 135 | with_error_handling do 136 | conditionals.all? do |left, right| 137 | (evaluate(left, user, resource) & evaluate(right, user, resource)).size > 0 138 | end 139 | end 140 | end 141 | end 142 | 143 | # @api private 144 | class NotEqualCondition < Condition 145 | def met?(user, resource) 146 | with_error_handling do 147 | conditionals.all? do |left, right| 148 | !((evaluate(left, user, resource) & evaluate(right, user, resource)).size > 0) 149 | end 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/rule_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe IronHide::Rule do 4 | describe "ALLOW" do 5 | it "returns 'allow'" do 6 | expect(IronHide::Rule::ALLOW).to eq 'allow' 7 | end 8 | end 9 | 10 | describe "DENY" do 11 | it "returns 'deny'" do 12 | expect(IronHide::Rule::DENY).to eq 'deny' 13 | end 14 | end 15 | 16 | describe "::find" do 17 | before do 18 | IronHide.configure do |config| 19 | config.adapter = :file 20 | config.json = 'spec/rules.json' 21 | end 22 | end 23 | 24 | let(:action) { 'read' } 25 | let(:resource) { double('test_resource') } 26 | let(:user) { double('user') } 27 | 28 | before do 29 | IronHide.configure { |c| c.namespace = "com::test" } 30 | resource.stub_chain(:class, :name) { 'TestResource' } 31 | end 32 | 33 | it "returns a collection of Rule instances that match an action and resource" do 34 | expect(IronHide::Rule.find(user,action,resource).first).to be_instance_of(IronHide::Rule) 35 | end 36 | end 37 | 38 | describe "::allow?" do 39 | let(:user) { :user } 40 | let(:action) { :action } 41 | let(:resource) { double('resource') } 42 | let(:rule1) { double('rule', allow?: true, explicit_deny?: false) } 43 | let(:rule2) { double('rule', allow?: true, explicit_deny?: false) } 44 | let(:rules) { [ rule1, rule1, rule2 ] } 45 | 46 | context "when all Rules allow the action" do 47 | it "returns true" do 48 | expect(IronHide::Rule).to receive(:find).with(user,action,resource) { rules } 49 | expect(IronHide::Rule.allow?(user,action,resource)).to eq true 50 | end 51 | end 52 | 53 | context "when at least one Rule does not allow the action" do 54 | before { rule2.stub(allow?: false) } 55 | 56 | context "when it does NOT explictly deny" do 57 | before { rule2.stub(explicit_deny?: false) } 58 | 59 | it "returns true" do 60 | expect(IronHide::Rule).to receive(:find).with(user,action,resource) { rules } 61 | expect(IronHide::Rule.allow?(user,action,resource)).to eq true 62 | end 63 | end 64 | 65 | context "when it does explicitly deny" do 66 | before { rule2.stub(explicit_deny?: true) } 67 | 68 | it "returns false" do 69 | expect(IronHide::Rule).to receive(:find).with(user,action,resource) { rules } 70 | expect(IronHide::Rule.allow?(user,action,resource)).to eq false 71 | end 72 | end 73 | end 74 | 75 | context "when no rules match" do 76 | it "returns false" do 77 | expect(IronHide::Rule).to receive(:find).with(user,action,resource) { [] } 78 | expect(IronHide::Rule.allow?(user,action,resource)).to eq false 79 | end 80 | end 81 | end 82 | 83 | describe "::storage" do 84 | it "returns an IronHide::Storage instance" do 85 | expect(IronHide::Rule.storage).to be_instance_of(IronHide::Storage) 86 | end 87 | end 88 | 89 | let(:params) do 90 | { 91 | 'action'=> :test_action, 92 | 'effect'=> effect, 93 | 'conditions'=> [1,2,3,4] 94 | } 95 | end 96 | 97 | let(:condition) { double('condition') } 98 | let(:user) { double('user') } 99 | let(:resource) { double('resource') } 100 | let(:effect) { double('effect') } 101 | let(:rule) { IronHide::Rule.new(user, resource, params) } 102 | 103 | describe "#initialize" do 104 | before { IronHide::Condition.stub(new: condition) } 105 | 106 | it "assigns user, action, description, effect, and conditions" do 107 | expect(rule.user).to eq user 108 | expect(rule.resource). to eq resource 109 | expect(rule.conditions).to eq 4.times.map { condition } 110 | end 111 | end 112 | 113 | describe "#allow?" do 114 | before { IronHide::Condition.stub(new: condition) } 115 | 116 | context "when at least one condition is not met" do 117 | before { condition.stub(:met?).and_return(true,true,true,false) } 118 | 119 | it "returns false" do 120 | expect(rule.allow?).to eq false 121 | end 122 | end 123 | 124 | context "when all conditions are met" do 125 | before { condition.stub(met?: true) } 126 | 127 | context "when effect is allow" do 128 | let(:effect) { IronHide::Rule::ALLOW } 129 | 130 | it "returns true" do 131 | expect(rule.allow?).to eq true 132 | end 133 | end 134 | 135 | context "when effect is deny" do 136 | let(:effect) { IronHide::Rule::DENY } 137 | 138 | it "returns false" do 139 | expect(rule.allow?).to eq false 140 | end 141 | end 142 | end 143 | 144 | context "when all conditions are not met" do 145 | before { condition.stub(met?: false) } 146 | let(:effect) { IronHide::Rule::ALLOW } 147 | 148 | it "returns false" do 149 | expect(rule.allow?).to eq false 150 | end 151 | end 152 | 153 | context "when there are no conditions" do 154 | let(:params) do 155 | { 156 | 'action'=> :test_action, 157 | 'effect'=> effect, 158 | 'conditions'=> [] 159 | } 160 | end 161 | 162 | context "when effect is ALLOW" do 163 | let(:effect) { IronHide::Rule::ALLOW } 164 | 165 | it "returns true" do 166 | expect(rule.allow?).to eq true 167 | end 168 | end 169 | 170 | context "when effect is DENY" do 171 | let(:effect) { IronHide::Rule::DENY } 172 | 173 | it "returns false" do 174 | expect(rule.allow?).to eq false 175 | end 176 | end 177 | end 178 | end 179 | 180 | describe "#explicit_deny?" do 181 | before { IronHide::Condition.stub(new: condition) } 182 | 183 | context "when at least one condition is not met" do 184 | before { condition.stub(:met?).and_return(true,true,true,false) } 185 | it "returns false" do 186 | expect(rule.explicit_deny?).to eq false 187 | end 188 | end 189 | 190 | context "when all conditions are met" do 191 | before { condition.stub(met?: true) } 192 | 193 | context "when effect is DENY" do 194 | let(:effect) { IronHide::Rule::DENY } 195 | 196 | it "returns true" do 197 | expect(rule.explicit_deny?).to eq true 198 | end 199 | end 200 | 201 | context "when effect is ALLOW" do 202 | let(:effect) { IronHide::Rule::ALLOW } 203 | 204 | it "returns false" do 205 | expect(rule.explicit_deny?).to eq false 206 | end 207 | end 208 | end 209 | 210 | context "when all conditions are not met" do 211 | before { condition.stub(met?: false) } 212 | 213 | it "returns false" do 214 | expect(rule.explicit_deny?).to eq false 215 | end 216 | end 217 | 218 | context "when there are no conditions" do 219 | let(:params) do 220 | { 221 | 'action'=> :test_action, 222 | 'effect'=> effect, 223 | 'conditions'=> [] 224 | } 225 | end 226 | 227 | context "when effect is ALLOW" do 228 | let(:effect) { IronHide::Rule::ALLOW } 229 | 230 | it "returns false" do 231 | expect(rule.explicit_deny?).to eq false 232 | end 233 | end 234 | 235 | context "when effect is DENY" do 236 | let(:effect) { IronHide::Rule::DENY } 237 | 238 | it "returns true" do 239 | expect(rule.explicit_deny?).to eq true 240 | end 241 | end 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /spec/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'tempfile' 3 | 4 | describe "Integration Testing" do 5 | before(:all) do 6 | @file = Tempfile.new('rules') 7 | @file.write <<-RULES 8 | [ 9 | { 10 | "resource": "com::test::TestResource", 11 | "action": ["read", "write"], 12 | "description": "Read/write access for TestResource.", 13 | "effect": "allow", 14 | "conditions": [ 15 | { 16 | "equal": { 17 | "user::role_ids": [1], 18 | "user::name": ["Cyril Figgis"] 19 | } 20 | } 21 | ] 22 | }, 23 | { 24 | "resource": "com::test::TestResource", 25 | "action": ["disable"], 26 | "description": "Read/write access for TestResource.", 27 | "effect": "deny", 28 | "conditions": [ 29 | { 30 | "equal": { 31 | "user::role_ids": [99] 32 | } 33 | } 34 | ] 35 | }, 36 | { 37 | "resource": "com::test::TestResource", 38 | "action": ["read"], 39 | "description": "Read access for TestResource.", 40 | "effect": "allow", 41 | "conditions": [ 42 | { 43 | "equal": { 44 | "user::role_ids": [5] 45 | } 46 | } 47 | ] 48 | }, 49 | { 50 | "resource": "com::test::TestResource", 51 | "action": ["read"], 52 | "effect": "deny", 53 | "conditions": [ 54 | { 55 | "equal": { 56 | "resource::active": [false] 57 | } 58 | } 59 | ] 60 | }, 61 | { 62 | "resource": "com::test::TestResource", 63 | "action": ["destroy"], 64 | "effect": "allow", 65 | "description": "Rule with multiple conditions", 66 | "conditions": [ 67 | { 68 | "equal": { 69 | "resource::active": [false] 70 | } 71 | }, 72 | { 73 | "not_equal": { 74 | "user::role_ids": [954] 75 | } 76 | } 77 | ] 78 | }, 79 | { 80 | "resource": "com::test::TestResource", 81 | "action": ["fire"], 82 | "effect": "allow", 83 | "description": "Rule with nested attributes", 84 | "conditions": [ 85 | { 86 | "equal": { 87 | "user::manager::name": ["Lumbergh"] 88 | } 89 | } 90 | ] 91 | } 92 | ] 93 | RULES 94 | @file.rewind 95 | IronHide.configure do |config| 96 | config.adapter = :file 97 | config.json = @file.path 98 | config.namespace = "com::test" 99 | end 100 | end 101 | 102 | after(:all) { @file.close } 103 | 104 | class TestUser 105 | attr_accessor :role_ids, :name 106 | def initialize 107 | @role_ids = [] 108 | end 109 | 110 | def manager 111 | @manager ||= TestUser.new 112 | end 113 | end 114 | 115 | class TestResource 116 | attr_accessor :active 117 | end 118 | 119 | let(:user) { TestUser.new } 120 | let(:resource) { TestResource.new } 121 | 122 | context "when one rule matches an action" do 123 | context "when effect is 'allow'" do 124 | let(:action) { 'write' } 125 | let(:rules) { IronHide::Rule.find(user,action,resource) } 126 | specify { expect(rules.size).to eq 1 } 127 | specify { expect(rules.first.effect).to eq 'allow' } 128 | 129 | context "when all conditions are met" do 130 | before do 131 | user.role_ids << 1 << 2 132 | user.name = 'Cyril Figgis' 133 | end 134 | 135 | specify { expect(IronHide.can?(user,action,resource)).to be_true } 136 | specify { expect{IronHide.authorize!(user,action,resource)}.to_not raise_error } 137 | end 138 | 139 | context "when some conditions are met" do 140 | before do 141 | user.role_ids << 1 << 2 142 | user.name = 'Pam' 143 | end 144 | 145 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 146 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 147 | end 148 | end 149 | 150 | context "when effect is 'deny'" do 151 | let(:action) { 'disable' } 152 | let(:rules) { IronHide::Rule.find(user,action,resource) } 153 | specify { expect(rules.size).to eq 1 } 154 | specify { expect(rules.first.effect).to eq 'deny' } 155 | 156 | context "when all conditions are met" do 157 | before { user.role_ids << 99 } 158 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 159 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 160 | end 161 | 162 | context "when no conditions are met" do 163 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 164 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 165 | end 166 | end 167 | end 168 | 169 | context "when no rule matches an action" do 170 | let(:action) { 'some-crazy-rule' } 171 | let(:rules) { IronHide::Rule.find(user,action,resource) } 172 | specify { expect(rules.size).to eq 0 } 173 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 174 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 175 | end 176 | 177 | context "when multiple rules match an action" do 178 | let(:action) { 'read' } 179 | let(:rules) { IronHide::Rule.find(user,action,resource) } 180 | specify { expect(rules.size).to eq 3 } 181 | 182 | context "when conditions for only one rule are met" do 183 | context "when effect is 'allow'" do 184 | before { user.role_ids << 5 } 185 | specify { expect(IronHide.can?(user,action,resource)).to be_true } 186 | specify { expect{IronHide.authorize!(user,action,resource)}.to_not raise_error } 187 | end 188 | 189 | context "when effect is 'deny'" do 190 | before { resource.active = false } 191 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 192 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 193 | end 194 | end 195 | 196 | context "when conditions for all rules are met" do 197 | context "when at least one rule's effect is 'deny'" do 198 | before do 199 | resource.active = false 200 | user.name = 'Cyril Figgis' 201 | user.role_ids << 5 202 | end 203 | 204 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 205 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 206 | end 207 | end 208 | end 209 | 210 | describe "testing rule with multiple conditions" do 211 | let(:action) { 'destroy' } 212 | let(:rules) { IronHide::Rule.find(user,action,resource) } 213 | specify { expect(rules.size).to eq 1 } 214 | context "when only one condition is met" do 215 | before { resource.active = false ; user.role_ids << 954 } 216 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 217 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 218 | end 219 | 220 | context "when all conditions are met" do 221 | before { resource.active = false ; user.role_ids << 25 } 222 | specify { expect(IronHide.can?(user,action,resource)).to be_true } 223 | specify { expect{IronHide.authorize!(user,action,resource)}.to_not raise_error } 224 | end 225 | end 226 | 227 | describe "testing rule with nested attributes" do 228 | let(:action) { 'fire' } 229 | let(:rules) { IronHide::Rule.find(user,action,resource) } 230 | context "when conditions are met" do 231 | before { user.manager.name = "Lumbergh" } 232 | specify { expect(IronHide.can?(user,action,resource)).to be_true } 233 | specify { expect{IronHide.authorize!(user,action,resource)}.to_not raise_error } 234 | end 235 | context "when conditions are not met" do 236 | before { user.manager.name = "Phil" } 237 | specify { expect(IronHide.can?(user,action,resource)).to be_false } 238 | specify { expect{IronHide.authorize!(user,action,resource)}.to raise_error } 239 | end 240 | end 241 | end 242 | 243 | -------------------------------------------------------------------------------- /spec/condition_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe IronHide::Condition do 4 | describe "VALID_TYPES" do 5 | it "returns a Hash that maps condition types to class names" do 6 | expect(IronHide::Condition::VALID_TYPES).to eq({ 7 | 'equal'=> :EqualCondition, 8 | 'not_equal'=> :NotEqualCondition 9 | }) 10 | end 11 | 12 | it "returns a frozen object" do 13 | expect(IronHide::Condition::VALID_TYPES).to be_frozen 14 | end 15 | end 16 | 17 | # The manager_id of the resource must equal the user's manager_id 18 | # The user's user_role_ids must include 1, 2, 3, or 4 (logical OR) 19 | let(:eq_params) do 20 | { 21 | 'equal'=> { 22 | 'resource::manager_id' => ['user::manager_id'] , 23 | 'user::user_role_ids' => [1,2,3,4] 24 | } 25 | } 26 | end 27 | 28 | # The manager_id of the resource must not equal the user's manager_id 29 | let(:not_eq_params) do 30 | { 31 | 'not_equal'=> { 'resource::manager_id' => ['user::manager_id'] }, 32 | } 33 | end 34 | 35 | let(:equal_condition) { IronHide::Condition.new(eq_params) } 36 | let(:not_eq_condition) { IronHide::Condition.new(not_eq_params) } 37 | let(:user) { double('user') } 38 | let(:resource) { double('resource') } 39 | 40 | # See: https://github.com/rspec/rspec-mocks/issues/494 41 | # These objects are frozen to protect them from modification. 42 | # RSpec modifies the meta-class of the objects when setting method exepctations, 43 | # so we need to stub #freeze and render it useless. 44 | # 45 | before do 46 | user.stub(freeze: user) 47 | resource.stub(freeze: resource) 48 | end 49 | 50 | describe "::new" do 51 | context "when condition type is 'equal'" do 52 | it "returns an instance of EqualCondition" do 53 | expect(equal_condition).to be_instance_of(IronHide::EqualCondition) 54 | end 55 | end 56 | 57 | context "when condition type is 'not_equal'" do 58 | it "returns an instance of EqualCondition" do 59 | expect(not_eq_condition).to be_instance_of(IronHide::NotEqualCondition) 60 | end 61 | end 62 | 63 | context "when more than 1 key present in params" do 64 | let(:invalid_params) { not_eq_params.merge(eq_params) } 65 | 66 | it "raises IronHide::InvalidConditional exception" do 67 | expect{IronHide::Condition.new(invalid_params)}.to raise_error(IronHide::InvalidConditional) 68 | end 69 | end 70 | 71 | context "when condition type is unknown" do 72 | let(:invalid_params) { { 'wrong' => { 'resource::manager_id' => ['user::manager_id']} } } 73 | 74 | it "raises an error" do 75 | expect{IronHide::Condition.new(invalid_params)}.to raise_error(IronHide::InvalidConditional) 76 | end 77 | end 78 | end 79 | 80 | describe "#met?" do 81 | context "when condition type is 'equal'" do 82 | context "when all expressions in the condition are met (logical AND)" do 83 | 84 | let(:role_ids) { [1,2] } 85 | let(:manager_id) { 99 } 86 | 87 | before do 88 | user.stub(user_role_ids: role_ids, manager_id: manager_id) 89 | resource.stub(manager_id: manager_id) 90 | end 91 | 92 | it "returns true" do 93 | expect(equal_condition.met?(user, resource)).to eq true 94 | end 95 | end 96 | 97 | context "when all expressions in the condition are not met" do 98 | 99 | let(:role_ids) { [] } 100 | let(:manager_id) { 99 } 101 | 102 | before do 103 | user.stub(user_role_ids: role_ids, manager_id: manager_id) 104 | resource.stub(manager_id: manager_id) 105 | end 106 | 107 | it "returns false" do 108 | expect(equal_condition.met?(user,resource)).to eq false 109 | end 110 | end 111 | 112 | context "when conditional expressions are empty" do 113 | before { eq_params['equal'] = {} } 114 | it "returns true" do 115 | expect(equal_condition.met?(user,resource)).to eq true 116 | end 117 | end 118 | end 119 | 120 | context "when condition type is :not_equal" do 121 | context "when all expressions in the condition are met (logical AND)" do 122 | 123 | let(:manager_id) { 99 } 124 | 125 | before do 126 | user.stub(manager_id: manager_id) 127 | # Satisfy the condition that manager_id of the resource and user don't match 128 | resource.stub(manager_id: manager_id + 1) 129 | end 130 | 131 | it "returns true" do 132 | expect(not_eq_condition.met?(user,resource)).to eq true 133 | end 134 | end 135 | 136 | context "when all expressions in the condition are not met" do 137 | # Don't satisfy the condition by setting the manager_ids on user 138 | # and resource to be the same 139 | let(:manager_id) { 99 } 140 | 141 | before do 142 | user.stub(manager_id: manager_id) 143 | resource.stub(manager_id: manager_id) 144 | end 145 | 146 | it "returns false" do 147 | expect(not_eq_condition.met?(user,resource)).to eq false 148 | end 149 | end 150 | 151 | context "when conditional expressions are empty" do 152 | before { not_eq_params['not_equal'] = {} } 153 | it "returns true" do 154 | expect(not_eq_condition.met?(user,resource)).to eq true 155 | end 156 | end 157 | end 158 | 159 | context "when conditional expressions are invalid" do 160 | context "when key is invalid" do 161 | # The key can only reference a 'user' or 'resource', otherwise, 162 | # it's an invalid expression 163 | let(:eq_params) do 164 | { 165 | 'equal' => { 166 | 'something_wrong::manager_id' => ['user::manager_id'] , 167 | 'user::user_role_ids' => [1,2,3,4] 168 | } 169 | } 170 | end 171 | 172 | it "raises an InvalidConditional error" do 173 | expect{equal_condition.met?(user, resource)}.to raise_error(IronHide::InvalidConditional) 174 | end 175 | end 176 | 177 | context "when value is nil" do 178 | before { user.stub(manager_id: 1) } 179 | 180 | let(:eq_params) do 181 | { 182 | 'equal' => nil 183 | } 184 | end 185 | 186 | it "raises an InvalidConditional error" do 187 | expect{equal_condition.met?(user, resource)}.to raise_error(IronHide::InvalidConditional) 188 | end 189 | end 190 | 191 | context "when value is wrong type" do 192 | before { user.stub(manager_id: 1) } 193 | 194 | let(:eq_params) do 195 | { 196 | 'equal' => "wrong_type" 197 | } 198 | end 199 | 200 | it "raises an InvalidConditional error" do 201 | expect{equal_condition.met?(user, resource)}.to raise_error(IronHide::InvalidConditional) 202 | end 203 | end 204 | end 205 | 206 | #TODO: Additional tests. 207 | # duplication of the same information that conflicts / conditions that cannot be satisfied 208 | end 209 | 210 | describe "#evaluate" do 211 | before do 212 | user.stub(manager_id: 1) 213 | resource.stub(manager_id: 5) 214 | end 215 | 216 | let(:condition) { IronHide::Condition.new(eq_params) } 217 | 218 | context "when input is a valid expression" do 219 | let(:input1) { 'user::manager_id' } 220 | let(:input2) { 'resource::manager_id' } 221 | let(:input3) { 'user' } 222 | let(:input4) { 'resource' } 223 | 224 | it "returns the evaluated expression" do 225 | expect(condition.send(:evaluate, input1, user, resource)).to eq [1] 226 | expect(condition.send(:evaluate, input2, user, resource)).to eq [5] 227 | expect(condition.send(:evaluate, input3, user, resource)).to eq [user] 228 | expect(condition.send(:evaluate, input4, user, resource)).to eq [resource] 229 | end 230 | end 231 | 232 | context "when input is not a valid expression" do 233 | let(:input1) { 'user::instance_eval()' } 234 | let(:input2) { 'user::delete!' } 235 | let(:input3) { 'user::id=' } 236 | let(:input4) { 'user::' } 237 | let(:input5) { 'user::something::' } 238 | 239 | it "returns the input" do 240 | expect(condition.send(:evaluate, input1, user, resource)).to eq [input1] 241 | expect(condition.send(:evaluate, input2, user, resource)).to eq [input2] 242 | expect(condition.send(:evaluate, input3, user, resource)).to eq [input3] 243 | expect(condition.send(:evaluate, input4, user, resource)).to eq [input4] 244 | expect(condition.send(:evaluate, input5, user, resource)).to eq [input5] 245 | end 246 | end 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IronHide 2 | 3 | [![Gem Version](https://badge.fury.io/rb/iron_hide.svg)](http://badge.fury.io/rb/iron_hide) 4 | [![Build Status](https://travis-ci.org/TheClimateCorporation/iron_hide.svg?branch=master)](https://travis-ci.org/TheClimateCorporation/iron_hide) 5 | [![Code Climate](https://codeclimate.com/github/TheClimateCorporation/iron_hide.png)](https://codeclimate.com/github/TheClimateCorporation/iron_hide) 6 | 7 | _Experimenting with a new way to implement authorization._ 8 | 9 | IronHide is an authorization library. It uses a simple, declarative language implemented in JSON. 10 | 11 | For more details around the motivation for this project, see: http://eng.climate.com/2014/02/12/service-oriented-authorization-part-1/ 12 | 13 | For a _tiny_ example, look here https://github.com/TheClimateCorporation/iron_hide_sample_app 14 | 15 | 16 | ## Installation 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | gem 'iron_hide', path: '/path/to/source' 21 | 22 | And then execute: 23 | 24 | $ bundle install 25 | 26 | Or build and install it yourself as: 27 | 28 | $ gem build '/path/to/iron_hide.gemspec' 29 | $ gem install iron_hide.gem 30 | 31 | ## Usage 32 | 33 | ### Rules Language 34 | 35 | Authorization rules are JSON documents. Here is an example of a document: 36 | 37 | ```javascript 38 | [ 39 | { 40 | // [String] 41 | "resource": "namespace::Test", 42 | 43 | // [Array] 44 | "action": ["read", "write"], 45 | 46 | // [String] 47 | "description": "Something descriptive", 48 | 49 | // [String] 50 | "effect": "allow", 51 | 52 | // [Array] 53 | "conditions": [ 54 | // All conditions must be met (logical AND) 55 | { 56 | "equal": { 57 | // The numeric value of the key must be equal to any value in the array (logical OR) 58 | "resource::manager_id": ["user::id"] 59 | } 60 | }, 61 | { 62 | "not_equal": { 63 | "user::disabled": [true] 64 | } 65 | } 66 | ] 67 | } 68 | ] 69 | ``` 70 | 71 | The language enables a context-aware attribute-based access control (ABAC) authorization model. The language allows references to the `user` and `resource` objects. The library (i.e., `IronHide`) should guarantee that it is able to parse the attributes of these objects (e.g., `user::attribute::nested_attribute`), while maintaining immutability of the object itself. 72 | 73 | #### Resource 74 | 75 | The resource to which the rule applies. These should be namespaced properly, since multiple applications may share resources. 76 | 77 | #### Action 78 | 79 | An array of Strings that specifies the set of actions to which the current rule applies. 80 | 81 | Actions can be named anything you want and in Ruby/Rails these would typically be aligned with the instance methods for a class: 82 | 83 | ```ruby 84 | class User 85 | # The 'delete' action 86 | def delete 87 | ... 88 | end 89 | 90 | # The 'charge' action 91 | def charge 92 | ... 93 | end 94 | end 95 | ``` 96 | 97 | #### Description 98 | 99 | A string that helps humans reading the rule JSON understand it more easily. It’s optional. 100 | 101 | #### Effect 102 | 103 | This is required. It is the effect a rule has when a user requests access to conduct an action to which the rule applies. It is either ‘allow’ or ‘deny’. 104 | 105 | #### Evaluation of Rules 106 | 107 | 1. Default: Deny 108 | 2. Evaluate applicable policies 109 | - Match on: resource and action 110 | 3. Does policy exist for resource and action? 111 | - If no: Deny 112 | - Do any rules resolve to Deny? 113 | - If yes, Deny 114 | - If no, Do any rules resolve to Allow? 115 | - If yes, Allow 116 | - Else: Deny 117 | 118 | **If access to a resource is not specifically allowed, authorization will default to DENY. This should make it easy to reason about: “A user was denied this request. I should create a rule that specifically allows access.”** 119 | 120 | #### Conditions 121 | 122 | Conditions are expressions that are evaluated to decide whether the effect of a particular rule should or should not apply. The expression semantics are dictated by the consuming application and the implementation of the library code that is used to communicate with and parse our rules. 123 | 124 | This object is optional (i.e., the rule is always in effect). It is an array of objects to allow multiple of the same type of condition to be evaluated (e.g., `equal`, `not_equal`). 125 | 126 | When creating a condition block, the name of each condition is specified, and there is at least one key-value pair for each condition. 127 | 128 | **How conditions are evaluated:** 129 | 130 | * A logical AND is applied to conditions within a condition block and to the keys with that condition. 131 | * A logical OR is applied to the values of a single key. 132 | * All conditions must be met (logical AND across all conditions) to return an allow or deny decision. 133 | 134 | For example, here the agency_id of a resource must equal the agency_id of a user. 135 | 136 | ```javascript 137 | // Condition 138 | { 139 | "equal": { 140 | "resource::agency_id": ["user::agency_id"] 141 | } 142 | } 143 | ``` 144 | 145 | The value of a key in a condition may be checked against multiple values. It must match at least one for the condition to hold. 146 | 147 | ```javascript 148 | // Condition 149 | { 150 | "equal": { 151 | "user::role_id": [1,2,3,4] 152 | } 153 | } 154 | ``` 155 | 156 | ### Configuration 157 | 158 | IronHide must be configured during application load time. 159 | 160 | This is an example configuration that uses authorization rules defined in a JSON file. 161 | 162 | ```ruby 163 | # config/application.rb 164 | require 'iron_hide' 165 | 166 | IronHide.config do |c| 167 | c.adapter = :file 168 | 169 | # This can be one or more files 170 | c.json = '/path/to/json/file' 171 | 172 | # This is helpful if you have multiple projects with similarly named 173 | # resources 174 | c.namespace = 'com::myproject' # Default 'com::IronHide' 175 | 176 | # See Memoizing below 177 | c.memoize = true # Default 178 | end 179 | ``` 180 | 181 | ### Public API 182 | 183 | There are two ways to perform an authorization check. If you have used [CanCan](https://github.com/ryanb/cancan), then these should look familiar. 184 | 185 | Given a very simple relational schema, with one table (`users`): 186 | 187 | | users | 188 | | ---------- | 189 | | id | 190 | | manager_id | 191 | 192 | Given a rule like this: 193 | 194 | ```javascript 195 | { 196 | "resource": "namespace::User", 197 | "action": ["read", "manage"], 198 | "description": "Allow users and managers to read and manage users", 199 | "effect": "allow", 200 | "conditions": [ 201 | { 202 | "equal": { 203 | // The user's ID must be equal to the resource's ID or the resource's manager's ID 204 | "user::id": ["resource::id", "resource::manager_id"] 205 | } 206 | } 207 | ] 208 | } 209 | ``` 210 | 211 | Authorize one user for "reading" another: 212 | 213 | ```ruby 214 | current_user = User.find(2) 215 | IronHide.authorize! current_user, :read, User.find(1) 216 | #=> Raises an IronHide::Error if authorization fails 217 | ``` 218 | 219 | ```ruby 220 | current_user = User.find(2) 221 | IronHide.can? current_user, :read, User.find(1) 222 | #=> true 223 | ``` 224 | 225 | ### Attribute Memoization 226 | 227 | Each time `::can?` or `::authorize!` is called, 0 or more rules are evaluated. 228 | Each of these rules could depend on the evaluation of an unbounded number of 229 | expressions. 230 | 231 | In the last example of the previous section, the `:id` attribute of a user must 232 | match the `:manager_id` attribute of a resource. We can imagine the case where 233 | the method call, `resource.manager_id` could potentially be expensive (e.g., 234 | it's not a simple DB attribute and requires a complex SQL query). 235 | 236 | Memoization caches the method call, `resource.manager_id`, so that subsquent 237 | rules that attribute do not repeat the call. Here is a simple example where two 238 | rules need to be evaluated for a single action, `read` and memoization can 239 | improve performance. 240 | 241 | ```javascript 242 | [ 243 | { 244 | "resource": "namespace::User", 245 | "action": ["read"], 246 | "description": "Allow users read users", 247 | "effect": "allow", 248 | "conditions": [ 249 | { 250 | "equal": { 251 | "user::id": ["resource::id", "resource::manager_id"] 252 | } 253 | } 254 | ] 255 | }, 256 | { 257 | "resource": "namespace::User", 258 | "action": ["read", "manage"], 259 | "description": "Allow users to read and manage users", 260 | "effect": "allow", 261 | "conditions": [ 262 | { 263 | "equal": { 264 | "user::id": ["resource::manager_id"] 265 | } 266 | } 267 | ] 268 | } 269 | ] 270 | ``` 271 | 272 | 273 | 274 | ### Adapters 275 | 276 | IronHide works with rules defined in the canonical JSON language. The storage back-end is abstracted through the use of adapters. 277 | 278 | An available adapter type must be specified in a configuration file, which gets loaded with the application at start time. 279 | 280 | The default adapter is the `File Adapter`. 281 | 282 | #### File Adapter 283 | 284 | The File adapter allows rules to be written into a flat file. See `spec/rules.json` for an example. 285 | 286 | #### CouchDB Adapter 287 | 288 | See: https://github.com/TheClimateCorporation/iron_hide-storage-couchdb_adapter 289 | 290 | ## Contributing 291 | 292 | - `bundle install` to install dependencies 293 | - `rake` to run tests 294 | - `yard` to generate documentation 295 | - Pull requests, issues, comments are welcome 296 | 297 | ## Further Reading 298 | - Service-Oriented Authorization blog posts: 299 | - [Part 1](http://eng.climate.com/2014/02/12/service-oriented-authorization-part-1/) 300 | - [Part 2](http://eng.climate.com/2014/02/12/service-oriented-authorization-part-2/) 301 | - [XACML(eXtensible Access Control Markup Language)](http://en.wikipedia.org/wiki/XACML) 302 | - Amazon: [Access Policy Language](http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/AccessPolicyLanguage.html) 303 | 304 | ## TODO 305 | 306 | - Write a more detailed language specification 307 | - Better README 308 | - Admin interface for modifying policies 309 | 310 | 311 | 312 | --------------------------------------------------------------------------------