chronos_authz 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: dc17a6fb18f79cbc7f42761ead6902e96c09d3b3
4
+ data.tar.gz: 57e17def29d8341147bb81ef427055384914e1f2
5
+ SHA512:
6
+ metadata.gz: 53887e05e23adf136dff4856aa8bccec32815e36fde7add0012456db29b1a663a6e56c283f61d64298d634d7be42d4ab05a51530dcd795fda839a842ff91df1b
7
+ data.tar.gz: 92ccdfc2374509a8fd33132c7dbff4f188925b891f7143c8322e3d179f4660eba2d2336e06f6627b81fd718f198c3a6c66a3d3ce92d50089f4e07c47fdb1d4f9
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ .idea/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,104 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ chronos-authz (0.0.1)
5
+ activesupport
6
+ railties (>= 4.2)
7
+ request_store
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ actionpack (5.2.0)
13
+ actionview (= 5.2.0)
14
+ activesupport (= 5.2.0)
15
+ rack (~> 2.0)
16
+ rack-test (>= 0.6.3)
17
+ rails-dom-testing (~> 2.0)
18
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
19
+ actionview (5.2.0)
20
+ activesupport (= 5.2.0)
21
+ builder (~> 3.1)
22
+ erubi (~> 1.4)
23
+ rails-dom-testing (~> 2.0)
24
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
25
+ activesupport (5.2.0)
26
+ concurrent-ruby (~> 1.0, >= 1.0.2)
27
+ i18n (>= 0.7, < 2)
28
+ minitest (~> 5.1)
29
+ tzinfo (~> 1.1)
30
+ ansi (1.5.0)
31
+ builder (3.2.3)
32
+ concurrent-ruby (1.0.5)
33
+ crass (1.0.4)
34
+ diff-lcs (1.3)
35
+ docile (1.3.1)
36
+ erubi (1.7.1)
37
+ hirb (0.7.3)
38
+ i18n (1.0.1)
39
+ concurrent-ruby (~> 1.0)
40
+ json (2.1.0)
41
+ loofah (2.2.2)
42
+ crass (~> 1.0.2)
43
+ nokogiri (>= 1.5.9)
44
+ method_source (0.9.0)
45
+ mini_portile2 (2.3.0)
46
+ minitest (5.11.3)
47
+ nokogiri (1.8.4)
48
+ mini_portile2 (~> 2.3.0)
49
+ rack (2.0.5)
50
+ rack-test (1.1.0)
51
+ rack (>= 1.0, < 3)
52
+ rails-dom-testing (2.0.3)
53
+ activesupport (>= 4.2.0)
54
+ nokogiri (>= 1.6)
55
+ rails-html-sanitizer (1.0.4)
56
+ loofah (~> 2.2, >= 2.2.2)
57
+ railties (5.2.0)
58
+ actionpack (= 5.2.0)
59
+ activesupport (= 5.2.0)
60
+ method_source
61
+ rake (>= 0.8.7)
62
+ thor (>= 0.18.1, < 2.0)
63
+ rake (12.0.0)
64
+ request_store (1.4.1)
65
+ rack (>= 1.4)
66
+ rspec (3.7.0)
67
+ rspec-core (~> 3.7.0)
68
+ rspec-expectations (~> 3.7.0)
69
+ rspec-mocks (~> 3.7.0)
70
+ rspec-core (3.7.1)
71
+ rspec-support (~> 3.7.0)
72
+ rspec-expectations (3.7.0)
73
+ diff-lcs (>= 1.2.0, < 2.0)
74
+ rspec-support (~> 3.7.0)
75
+ rspec-mocks (3.7.0)
76
+ diff-lcs (>= 1.2.0, < 2.0)
77
+ rspec-support (~> 3.7.0)
78
+ rspec-support (3.7.1)
79
+ simplecov (0.16.1)
80
+ docile (~> 1.1)
81
+ json (>= 1.8, < 3)
82
+ simplecov-html (~> 0.10.0)
83
+ simplecov-console (0.4.2)
84
+ ansi
85
+ hirb
86
+ simplecov
87
+ simplecov-html (0.10.2)
88
+ thor (0.20.0)
89
+ thread_safe (0.3.6)
90
+ tzinfo (1.2.5)
91
+ thread_safe (~> 0.1)
92
+
93
+ PLATFORMS
94
+ ruby
95
+
96
+ DEPENDENCIES
97
+ chronos-authz!
98
+ rake (>= 11.3.0)
99
+ rspec
100
+ simplecov
101
+ simplecov-console
102
+
103
+ BUNDLED WITH
104
+ 1.16.2
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # chronos_authz
2
+ A declarative authorization Rack middleware that supports custom authorization logic on a per-resource basis
3
+
4
+ ## Usage sample for Rails
5
+ ### 1. Install the gem
6
+ ```ruby
7
+ gem 'chronos_authz'
8
+ ```
9
+
10
+ ### 2. Configure the ACL config/my_acl.yml
11
+ An incoming request's http method and path will be checked against the ACL file's records for a match. At minimum, you MUST configure the path of the resource in an ACL record and SHOULD configure the http_method. If the http_method isn't configured, http method checking will not be done. You can define any other configuration here per resource as needed (ex. :permissions is a custom configuration and is defined here as this will be used in the custom authorization rule.
12
+
13
+ ```ruby
14
+ manage_accounts:
15
+ path: "/accounts/.*" # regex pattern
16
+ http_method: # if array is used, this would work as an OR operation: checking if the incoming http request's method matches ANY of the configured http_method
17
+ - GET
18
+ - put
19
+ permissions:
20
+ - VIEW_ACCOUNTS
21
+ - UPDATE_ACCOUNTS
22
+
23
+ create_users:
24
+ path: "/users"
25
+ http_method: POST
26
+ permissions:
27
+ - CREATE_USERS
28
+
29
+ update_users:
30
+ path: "/users/.*"
31
+ http_method: PUT
32
+ permissions:
33
+ - UPDATE_USERS
34
+ # rule: AnotherCustomRule # Use a different rule for this ACL record
35
+ ```
36
+
37
+ ### 2. Create an authorization rule initializers/MyCustomRule.rb
38
+ An authorization rule MUST implement the __request_authorized?__ method. The rule has an access to the ff. instance variables:
39
+
40
+ 1. @request - [__Rack::Request__](https://www.rubydoc.info/gems/rack/Rack/Request) for the incoming HTTP request
41
+ 2. @acl_record - __ChronosAuthz::ACL::Record__ from the ACL yml that matches the incoming request's http method and path. Custom configuration in the ACL yaml will be accessible from this object. ex. @acl_record.permissions
42
+
43
+ ```ruby
44
+ class MyCustomRule < ChronosAuthz::Rule
45
+
46
+ # Must return a boolean to check
47
+ def request_authorized?
48
+ (@acl_record.permissions - user_claims).blank?
49
+ end
50
+
51
+ # Optional. Implement how claims are retrieved for a given user. Normally claims could be retrieved using cookies,
52
+ # JWT/id_token decoded from the request header, API calls, or a database query. Any value returned here will be available to the ChronosAuthz::User.claims helper module as well.
53
+ def user_claims
54
+ # SAMPLE ONLY! In this sample configuration, only the user with the access token '1d1234913em23' would only be able to successfully send a POST request to /users. Access token 'm123493429304' bearer could both create and update a User.
55
+ access_tokens = {
56
+ "1d1234913em23" => ["CREATE_USERS"],
57
+ "m123493429304" => ["CREATE_USERS", "UPDATE_USERS", "SomeOtherClaimInOtherFormat", "any-format-should-work-claim"]
58
+ }
59
+
60
+ access_token_from_request = @request.get_header("HTTP_AUTHORIZATION").gsub("Bearer ")
61
+ return (access_tokens[access_token_from_request] || [])
62
+ end
63
+ end
64
+ ```
65
+
66
+ ### 4. Use the middleware
67
+ ```ruby
68
+ Rails.application.config.middleware.use ChronosAuthz::Authorizer do |config|
69
+ # Required. Default authorization rule to use
70
+ config.default_rule = MyCustomRule
71
+
72
+ # Optional. Default is false. If set to true, the ACL is treated as a whitelist of resource paths: authorization would return a 403 error if no ACL Record has been configured for a given resource path. If set to false, authorization check will only be done to the resources configured in the ACL.
73
+ config.strict_mode = true
74
+
75
+ # Optional. Configure the error page to render when authorization fails
76
+ config.error_page = "public/403.html"
77
+
78
+ # Optional. Default behavior will look for 'config/authorizer_acl.yml'. Configure which ACL yml to use
79
+ config.acl_yaml = "config/my_acl.yml"
80
+ end
81
+ ```
82
+
83
+ ## Helpers
84
+ Include the helper module __ChronosAuthz::User__ as needed to have access to the current user's claims via the __.claims__ method.
85
+ example:
86
+ ```ruby
87
+ class User < ActiveRecord
88
+ include ChronosAuthz::User
89
+ end
90
+ ```
91
+
92
+ With this helper included in your User model and assuming you are using Devise or any other AuthN solution, you may do the ff.:
93
+ ```ruby
94
+ current_user.claims
95
+ => ["CREATE_USERS"]
96
+ ```
97
+
98
+ If the return value of user_claims method in your implementation is a hash:
99
+ ```ruby
100
+ current_user.claims
101
+ => {permissions: ["CREATE_USERS"], email: "someemail@yourdomain.com"}
102
+
103
+ current_user.claim[:email]
104
+ => someemail@yourdomain.com
105
+ ```
106
+
107
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,28 @@
1
+ $LOAD_PATH.push File.expand_path("../lib", __FILE__)
2
+
3
+ require "chronos_authz/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "chronos_authz"
7
+ s.version = ChronosAuthz::VERSION
8
+ s.authors = ["Marianne Angelie del Mundo", "Rodette Pedro", "JR Respino", "Jayson Uy"]
9
+ s.email = %w(marianne@chronoscloud.com rodette@chronoscloud.com jr@chronoscloud.com jayson@chronoscloud.com)
10
+ s.homepage = "https://github.com/chronoscloud/chronoscloud-authz"
11
+ s.summary = "A minimal and declarative authorization layer"
12
+ s.description = "A declarative authorization Rack middleware that supports custom authorization logic on a per-resource basis"
13
+ s.license = 'N/A'
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- spec/*`.split("\n")
17
+ s.require_paths = ["lib"]
18
+
19
+ s.add_dependency "railties", ">= 4.2"
20
+ s.add_dependency "request_store"
21
+ s.add_dependency "activesupport"
22
+ s.required_ruby_version = ">= 2.4"
23
+
24
+ s.add_development_dependency "rake", ">= 11.3.0"
25
+ s.add_development_dependency "rspec-rails"
26
+ s.add_development_dependency "simplecov"
27
+ s.add_development_dependency "simplecov-console"
28
+ end
@@ -0,0 +1,16 @@
1
+ require 'active_support/core_ext/string'
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+ require 'ostruct'
4
+ require 'request_store'
5
+ require 'yaml'
6
+
7
+ require 'chronos_authz/validations/validation_error'
8
+ require 'chronos_authz/validations/options_validator'
9
+ require 'chronos_authz/configuration'
10
+ require 'chronos_authz/acl'
11
+ require 'chronos_authz/rule'
12
+ require 'chronos_authz/user'
13
+ require 'chronos_authz/authorizer'
14
+
15
+ module ChronosAuthz
16
+ end
@@ -0,0 +1,81 @@
1
+ module ChronosAuthz
2
+ class ACL
3
+ attr_accessor :acl_hash, :records
4
+
5
+ def self.build_from_yaml(acl_yaml = nil)
6
+ acl_yaml ||= 'config/authorizer_acl.yml'
7
+ acl_hash = YAML.load_file(acl_yaml)
8
+ records = ACL.records_from_acl_hash(acl_hash)
9
+ return ACL.new(records, acl_hash)
10
+ end
11
+
12
+ # Populate @records with instances of ACL::Record and validate each instances
13
+ def self.records_from_acl_hash(acl_hash)
14
+ return acl_hash.map do |record_name, record_options = {}|
15
+ ChronosAuthz::ACL::Record.new(record_options.merge!(name: record_name))
16
+ end
17
+ end
18
+
19
+ def initialize(records = [], acl_hash = nil)
20
+ @records = records
21
+ @acl_hash = acl_hash
22
+ end
23
+
24
+ # Find matching ACL Record
25
+ def find_match(http_method, request_path)
26
+ record = @records.select{ |record| record.matches?(http_method, request_path) }.first
27
+ # puts "[ChronosAuthz] Found ACL match: #{record.to_s}" if record
28
+
29
+ return record
30
+ end
31
+
32
+
33
+ class Record < OpenStruct
34
+ include ChronosAuthz::Validations::OptionsValidator
35
+
36
+ VALID_HTTP_METHODS = ["GET",
37
+ "POST",
38
+ "PUT",
39
+ "PATCH",
40
+ "DELETE",
41
+ "HEAD"].freeze
42
+
43
+ required :name, :path
44
+ check_constraint :http_method, VALID_HTTP_METHODS, allow_nil: true
45
+
46
+ def initialize(value = {})
47
+ value = value.with_indifferent_access
48
+ value[:http_method] = normalize_http_methods(value[:http_method])
49
+ value[:path] = normalize_path(value[:path])
50
+
51
+ super(value)
52
+ validate!
53
+ end
54
+
55
+ def matches?(http_method, request_path)
56
+ return false if request_path.nil?
57
+
58
+ request_path = normalize_path(request_path)
59
+ path_pattern = /\A#{self.path}\z/
60
+
61
+ method_matched = self.http_method.empty? || self.http_method.include?(http_method.to_s.upcase)
62
+
63
+ return false if !method_matched
64
+ return !request_path.match(path_pattern).nil?
65
+ end
66
+
67
+ private
68
+
69
+ def normalize_path(resource_path)
70
+ return resource_path.squish if !resource_path.nil?
71
+ end
72
+
73
+ def normalize_http_methods(http_methods)
74
+ http_methods = [http_methods] if !http_methods.is_a? Array
75
+ http_methods.map!{ |http_method| http_method.to_s.upcase }
76
+
77
+ return http_methods.reject { |http_method| http_method.blank? }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,39 @@
1
+ module ChronosAuthz
2
+
3
+ class Authorizer
4
+
5
+ attr_accessor :configuration, :acl
6
+
7
+ def initialize(app, options = {})
8
+ @app, @configuration = app, ::ChronosAuthz::Configuration.new(options)
9
+
10
+ yield @configuration if block_given?
11
+ @configuration.validate!
12
+ @acl = ChronosAuthz::ACL.build_from_yaml(@configuration.acl_yaml)
13
+ end
14
+
15
+
16
+ def call(env)
17
+ matched_acl_record = @acl.find_match(env["REQUEST_METHOD"], env["PATH_INFO"])
18
+
19
+ return render_unauthorized if @configuration.strict_mode && matched_acl_record.nil?
20
+
21
+ request = Rack::Request.new(env)
22
+ rule_class = matched_acl_record.try(:rule).try(:constantize) || @configuration.default_rule
23
+ rule_instance = rule_class.new(request, matched_acl_record)
24
+
25
+ return render_unauthorized if !rule_instance.request_authorized?
26
+
27
+ RequestStore.store[:chronos_authz_claims] = rule_instance.user_claims
28
+ status, headers, response = @app.call(env)
29
+ end
30
+
31
+ def render_unauthorized
32
+ if @configuration.error_page
33
+ # html = ActionView::Base.new.render(file: @configuration.error_page)
34
+ return [403, {'Content-Type' => 'text/html'}, [File.read(@configuration.error_page)]]
35
+ end
36
+ return [403, {'Content-Type' => 'text/plain'}, ["Unauthorized"]]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ module ChronosAuthz
2
+ class Configuration < OpenStruct
3
+ include ChronosAuthz::Validations::OptionsValidator
4
+
5
+ required :default_rule
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ module ChronosAuthz
2
+ class Rule
3
+
4
+ attr_accessor :request, :acl_record
5
+
6
+ def initialize(request, acl_record)
7
+ @request = request
8
+ @acl_record = acl_record
9
+ end
10
+
11
+ def user_claims
12
+ nil
13
+ end
14
+
15
+ def request_authorized?
16
+ false
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ module ChronosAuthz
2
+ module User
3
+
4
+ def claim(key)
5
+ claims[key]
6
+ end
7
+
8
+ def claims
9
+ RequestStore.store[:chronos_authz_claims]
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,60 @@
1
+ module ChronosAuthz
2
+ module Validations
3
+ module OptionsValidator
4
+
5
+ def self.included base
6
+ base.extend OptionsValidatorClassMethods
7
+ end
8
+
9
+ module OptionsValidatorClassMethods
10
+ attr_accessor :required_options, :predefined_value_map
11
+
12
+ def required(*options)
13
+ self.required_options = options
14
+ end
15
+
16
+ def check_constraint(option, predefined_values = [], constraint_options = {})
17
+ self.predefined_value_map ||= {}
18
+ self.predefined_value_map[option] = { check_values: predefined_values,
19
+ constraint_options: constraint_options }
20
+ end
21
+ end
22
+
23
+ def validate!
24
+
25
+ # Required options
26
+ if !self.class.required_options.nil?
27
+ self.class.required_options.each do |required_option|
28
+ option_value = send(required_option) if respond_to?(required_option)
29
+ raise ChronosAuthz::Validations::ValidationError.new("Missing option #{required_option} in #{self.class}") if option_value.blank?
30
+ end
31
+ end
32
+
33
+ # Check constraint
34
+ if !self.class.predefined_value_map.nil?
35
+ self.class.predefined_value_map.each do |key, value|
36
+ option_values = send(key)
37
+ check_values = value[:check_values]
38
+
39
+ if !option_values.is_a? Array
40
+ option_values = [option_values]
41
+ end
42
+
43
+ if value[:constraint_options][:case_sensitive].nil? || !value[:constraint_options][:case_sensitive]
44
+ option_values = option_values.map{|option_value| option_value.to_s.upcase }
45
+ check_values = check_values.map{|check_value| check_value.to_s.upcase }
46
+ end
47
+
48
+ option_values.each do |option_value|
49
+ next if value[:constraint_options][:allow_nil] && (option_value.nil? || option_value.empty?)
50
+ raise ChronosAuthz::Validations::ValidationError.new("Invalid option value #{option_value} for #{key} in #{self.class}. Valid values are #{check_values}.") if !check_values.include?(option_value)
51
+ end
52
+ end
53
+ end
54
+
55
+ self
56
+ end
57
+ end
58
+
59
+ end
60
+ end
@@ -0,0 +1,11 @@
1
+ module ChronosAuthz
2
+ module Validations
3
+ class ValidationError < StandardError
4
+
5
+ def initialize(message)
6
+ super(message)
7
+ end
8
+
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module ChronosAuthz
2
+ VERSION = "0.0.1"
3
+ end
data/spec/acl_spec.rb ADDED
@@ -0,0 +1,186 @@
1
+ require 'spec_helper'
2
+
3
+ describe ChronosAuthz::ACL do
4
+ let(:acl_yaml) { 'spec/config/authorizer_acl_test.yml' }
5
+
6
+ describe 'attribute accessors' do
7
+ it 'assigns acl_hash' do
8
+ acl_object = ChronosAuthz::ACL.build_from_yaml(acl_yaml)
9
+
10
+ expect(acl_object.acl_hash).to_not be_nil
11
+ end
12
+
13
+ it 'assigns records' do
14
+ acl_object = ChronosAuthz::ACL.build_from_yaml(acl_yaml)
15
+
16
+ expect(acl_object.records).to_not be_empty
17
+ end
18
+ end
19
+
20
+ describe '.build_from_yaml' do
21
+ context 'when acl_yaml is specified' do
22
+ it 'loads a YAML file from the acl_yaml' do
23
+ allow(YAML).to receive(:load_file).and_return(YAML.load_file(acl_yaml))
24
+ expect(YAML).to receive(:load_file).with(acl_yaml)
25
+
26
+ ChronosAuthz::ACL.build_from_yaml(acl_yaml)
27
+ end
28
+ end
29
+
30
+ context 'when no acl_yaml is specified' do
31
+ it 'loads a YAML file using a default path' do
32
+ allow(YAML).to receive(:load_file).and_return(YAML.load_file(acl_yaml))
33
+ expect(YAML).to receive(:load_file).with('config/authorizer_acl.yml')
34
+
35
+ ChronosAuthz::ACL.build_from_yaml
36
+ end
37
+ end
38
+
39
+ it 'initializes a new ACL' do
40
+ expect(ChronosAuthz::ACL).to receive(:new)
41
+
42
+ ChronosAuthz::ACL.build_from_yaml(acl_yaml)
43
+ end
44
+
45
+ it 'returns an ACL object' do
46
+ expect(ChronosAuthz::ACL.build_from_yaml(acl_yaml)).to be_an_instance_of(ChronosAuthz::ACL)
47
+ end
48
+
49
+ end
50
+
51
+ describe '.records_from_acl_hash' do
52
+ it 'returns an array of ACL::Record from a YAML hash' do
53
+ records = ChronosAuthz::ACL.records_from_acl_hash(YAML.load_file(acl_yaml))
54
+ expect(records).to_not be_empty
55
+ expect(records).to all(be_an(ChronosAuthz::ACL::Record))
56
+ end
57
+ end
58
+
59
+ describe '#find_match' do
60
+ let(:acl) { ChronosAuthz::ACL.build_from_yaml(acl_yaml) }
61
+
62
+ context 'when using valid parameters' do
63
+ context 'when http_method and request_path is in config' do
64
+
65
+ it 'returns an ACLRecord' do
66
+ expect(acl.find_match('POST', '/users')).to be_an_instance_of(ChronosAuthz::ACL::Record)
67
+ end
68
+
69
+ it 'finds the record that matches the path pattern' do
70
+ expect(acl.find_match('GET', '/accounts/2')).to_not be_nil
71
+ end
72
+ end
73
+
74
+ context 'when http_method is not in config' do
75
+ it 'returns nil' do
76
+ expect(acl.find_match('DELETE', '/users')).to be_nil
77
+ end
78
+ end
79
+
80
+ context 'when request_path is not in config' do
81
+ it 'returns nil' do
82
+ expect(acl.find_match('GET', '/test')).to be_nil
83
+ expect(acl.find_match('POST', '/users////')).to be_nil
84
+ expect(acl.find_match('POST', '/user/1/1/1/')).to be_nil
85
+ end
86
+ end
87
+ end
88
+
89
+ context 'when using null parameters' do
90
+ context 'when http_method is null' do
91
+ it 'returns nil' do
92
+ expect(acl.find_match(nil, '/test')).to be_nil
93
+ end
94
+ end
95
+
96
+ context 'when resource_path is null' do
97
+ it 'returns nil' do
98
+ expect(acl.find_match('GET', 'nil')).to be_nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+
106
+ describe ChronosAuthz::ACL::Record do
107
+ let(:acl_record_string_method) { ChronosAuthz::ACL::Record.new(name: 'create_users',
108
+ http_method: 'post',
109
+ path: ' /users ') }
110
+ let(:acl_record_array_method) { ChronosAuthz::ACL::Record.new(name: 'create_users',
111
+ http_method: ['POST', 'put'],
112
+ path: '/users') }
113
+ let(:acl_record_implicit_all_method) { ChronosAuthz::ACL::Record.new(name: 'create_users',
114
+ path: '/users') }
115
+
116
+ describe 'initialization' do
117
+ context 'when http_method is specified' do
118
+ context 'with a valid HTTP method' do
119
+ it 'assigns http_method as a normalized array' do
120
+ expect(acl_record_string_method.http_method).to_not be_empty
121
+ expect(acl_record_string_method.http_method).eql?(['POST'])
122
+ end
123
+ end
124
+
125
+ context 'with an invalid HTTP method' do
126
+ it 'raises a validation error' do
127
+ expect{ ChronosAuthz::ACL::Record.new(name: 'create_users',
128
+ http_method: 'GETS',
129
+ path: '/users') }.to raise_error(ChronosAuthz::Validations::ValidationError)
130
+ end
131
+ end
132
+ end
133
+
134
+ context 'when path is specified' do
135
+ it 'assigns a squished http_method' do
136
+ expect(acl_record_string_method.path).eql?("/users")
137
+ end
138
+ end
139
+
140
+ it 'raises a validation error if name is not specified' do
141
+ expect{ ChronosAuthz::ACL::Record.new(path: '/users') }.to raise_error(ChronosAuthz::Validations::ValidationError)
142
+ end
143
+
144
+ it 'raises a validation error if path is not specified' do
145
+ expect{ ChronosAuthz::ACL::Record.new(name: 'create_users') }.to raise_error(ChronosAuthz::Validations::ValidationError)
146
+ end
147
+
148
+ end
149
+
150
+ describe '#matches?' do
151
+ context 'when request_path matches the request_path regex pattern config' do
152
+ context 'with an array http_method config' do
153
+ it 'returns true if http_method has a match from the http_method array config' do
154
+ expect(acl_record_array_method.matches?('POST', '/users')).to be true
155
+ end
156
+
157
+ it 'returns false if http_method doesn\'t have a match from the http_method array config' do
158
+ expect(acl_record_array_method.matches?('GET', '/accounts')).to be false
159
+ end
160
+ end
161
+
162
+ context 'with a string http_method config' do
163
+ it 'returns true if http_method matches the http_method string config' do
164
+ expect(acl_record_string_method.matches?('POST', '/users')).to be true
165
+ end
166
+
167
+ it 'returns false if http_method doesn\'t match the http_method string config' do
168
+ expect(acl_record_string_method.matches?('get', '/accounts')).to be false
169
+ end
170
+ end
171
+
172
+ context 'with a nil http_method config' do
173
+ it 'returns true' do
174
+ expect(acl_record_implicit_all_method.matches?('post', '/users')).to be true
175
+ end
176
+ end
177
+ end
178
+
179
+ context 'when request_path doesn\'t match the request_path regex pattern config' do
180
+ it 'returns false' do
181
+ expect(acl_record_implicit_all_method.matches?('post', '/account/')).to be false
182
+ end
183
+ end
184
+ end
185
+
186
+ end
@@ -0,0 +1,96 @@
1
+ require 'spec_helper'
2
+ require 'rack'
3
+ describe ChronosAuthz::Authorizer do
4
+
5
+ class AuthorizeAllRequestRule < ChronosAuthz::Rule
6
+ def request_authorized?
7
+ true
8
+ end
9
+ end
10
+
11
+ class BlockAllRequestsRule < ChronosAuthz::Rule
12
+ def request_authorized?
13
+ false
14
+ end
15
+ end
16
+
17
+ describe "Middleware configuration" do
18
+
19
+ it "should raise an error if default_rule isn't configured" do
20
+ env = mock_env("/")
21
+ app = lambda{}
22
+ expect{ rack_app(app).call(env) }.to raise_error(ChronosAuthz::Validations::ValidationError)
23
+ end
24
+
25
+ it "should use default_rule for authorization check when no :rule option was specified in the matched ACL record" do
26
+ env = mock_env("/accounts/")
27
+ app = lambda{ |env| [200, {'Content-Type' => 'text/plain'}, ["OK"]] }
28
+ default_rule_class = AuthorizeAllRequestRule
29
+ expect_any_instance_of(default_rule_class).to receive(:request_authorized?)
30
+ rack_app(app, :default_rule => default_rule_class, :strict_mode => false).call(env)
31
+ end
32
+
33
+ it "should use the matched ACL Record's :rule option if it is specified" do
34
+ env = mock_env("/users", method: "POST")
35
+ app = lambda{ |env| [200, {'Content-Type' => 'text/plain'}, ["OK"]] }
36
+ expect_any_instance_of(ChronosAuthz::Spec::Helpers::CustomRule).to receive(:request_authorized?)
37
+ expect_any_instance_of(AuthorizeAllRequestRule).to_not receive(:request_authorized?)
38
+ rack_app(app, :default_rule => AuthorizeAllRequestRule, :strict_mode => false).call(env)
39
+ end
40
+
41
+ it "should use the configured error_page if the request is unauthorized" do
42
+ env = mock_env("/accounts/123", method: "PUT")
43
+ app = lambda{ |env| [200, {'Content-Type' => 'text/plain'}, ["OK"]] }
44
+ error_page_path = 'spec/config/error_page.html'
45
+ error_page_contents = File.read(error_page_path)
46
+ result = rack_app(app, :default_rule => BlockAllRequestsRule, :error_page => error_page_path).call(env)
47
+ expect(result.last.first).to eq(error_page_contents)
48
+ end
49
+ end
50
+
51
+ describe "Authorization" do
52
+
53
+ it "should fail with a 403 response if strict_mode configuration is set to true and no ACL Record was configured for the incoming request" do
54
+ env = mock_env("/some_unknown_path")
55
+ app = lambda{}
56
+ result = rack_app(app, :default_rule => AuthorizeAllRequestRule, :strict_mode => true).call(env)
57
+ expect(result.first).to eq(403)
58
+ end
59
+
60
+ it "should not fail with a 403 response if strict_mode configuration is not set to true and no ACL Record was configured for the incoming request" do
61
+ env = mock_env("/")
62
+ app = lambda{ |env| [200, {'Content-Type' => 'text/plain'}, ["OK"]] }
63
+ result = rack_app(app, :default_rule => AuthorizeAllRequestRule, :strict_mode => false).call(env)
64
+ expect(result.first).to eq(200)
65
+ end
66
+
67
+ it "should fail with a 403 response if authorization check failed" do
68
+ env = mock_env("/")
69
+ app = lambda{}
70
+ result = rack_app(app, :default_rule => BlockAllRequestsRule).call(env)
71
+ expect(result.first).to eq(403)
72
+ end
73
+
74
+ it "should not fail with a 403 response if authorization check succeeded" do
75
+ env = mock_env("/")
76
+ app = lambda{ |env| [200, {'Content-Type' => 'text/plain'}, ["OK"]] }
77
+ result = rack_app(app, :default_rule => AuthorizeAllRequestRule).call(env)
78
+ expect(result.first).to eq(200)
79
+ end
80
+ end
81
+
82
+ def rack_app(app, options= {})
83
+ options[:acl_yaml] ||= 'spec/config/authorizer_acl_test.yml'
84
+ Rack::Builder.new do
85
+ use ChronosAuthz::Authorizer, options
86
+ run app
87
+ end
88
+ end
89
+
90
+ def mock_env(path = "/", params = {})
91
+ method = params.delete(:method) || "GET"
92
+ env = { 'HTTP_VERSION' => '1.1', 'REQUEST_METHOD' => "#{method}" }
93
+ Rack::MockRequest.env_for("#{path}?#{Rack::Utils.build_query(params)}", env)
94
+ end
95
+
96
+ end
@@ -0,0 +1,18 @@
1
+ manage_accounts:
2
+ path: "/accounts/.*"
3
+ http_method:
4
+ - GET
5
+ - put
6
+ permissions:
7
+ - AUTH::VIEW_ACCOUNT
8
+ - AUTH::MANAGE_ACCOUNTS
9
+
10
+ create_users:
11
+ path: "/users"
12
+ http_method: POST
13
+ permissions:
14
+ - AUTH::CREATE_USERS
15
+ rule: ChronosAuthz::Spec::Helpers::CustomRule
16
+
17
+ shipment:
18
+ path: "/*"
@@ -0,0 +1 @@
1
+ Custom error page
@@ -0,0 +1,9 @@
1
+ module ChronosAuthz::Spec
2
+ module Helpers
3
+ class CustomRule < ChronosAuthz::Rule
4
+ def request_authorized?
5
+ true
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ describe ChronosAuthz::Validations::OptionsValidator do
4
+ PRIMARY_COLORS = ["red", "yellow", "blue"].freeze
5
+
6
+ class WithRequiredAndCheckConstraint < OpenStruct
7
+ include ChronosAuthz::Validations::OptionsValidator
8
+ required :some_required_attribute
9
+ check_constraint :primary_color, PRIMARY_COLORS
10
+ end
11
+
12
+ class WithRequiredCheckConstraintOption < OpenStruct
13
+ include ChronosAuthz::Validations::OptionsValidator
14
+ check_constraint :primary_color, PRIMARY_COLORS, allow_nil: false
15
+ end
16
+
17
+ class WithCheckConstraint < OpenStruct
18
+ include ChronosAuthz::Validations::OptionsValidator
19
+ check_constraint :primary_color, ["red", "yellow", "blue"], allow_nil: true
20
+ end
21
+
22
+ class WithCaseSensitiveCheckConstraintOption < OpenStruct
23
+ include ChronosAuthz::Validations::OptionsValidator
24
+ check_constraint :primary_color, ["red", "yellow", "blue"], case_sensitive: true
25
+ end
26
+
27
+ describe 'required options validation' do
28
+
29
+ it 'raises a validation error if a required option is nil' do
30
+ expect{ WithRequiredAndCheckConstraint.new.validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
31
+ end
32
+
33
+ end
34
+
35
+ describe 'check constraint validation' do
36
+
37
+ context 'when option value is a string' do
38
+ it 'raises a validation error if the option value isn\'t found from the valid option values' do
39
+ expect{ WithRequiredAndCheckConstraint.new(some_required_attribute: 'some_value', primary_color: "brown").validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
40
+ end
41
+
42
+ context 'with allow_nil config set to false' do
43
+ it 'raises a validation error if option value is nil' do
44
+ expect{ WithRequiredCheckConstraintOption.new.validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
45
+ end
46
+ end
47
+
48
+ context 'with allow_nil config set to true' do
49
+ it 'doesn\'t raise any validation errors if option value is nil' do
50
+ expect{ WithCheckConstraint.new.validate! }.to_not raise_error(ChronosAuthz::Validations::ValidationError)
51
+ end
52
+ end
53
+
54
+ context 'with case_sensitive config set to true' do
55
+ it 'raises a validation error if option value is meaningfully equal to any of the valid option values but in different case' do
56
+ expect{ WithCaseSensitiveCheckConstraintOption.new(primary_color: "RED").validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
57
+ end
58
+ end
59
+ end
60
+
61
+ context 'when option value is an array' do
62
+ it 'raises a validation error if an element from the option value isn\'t found from the valid option values' do
63
+ expect{ WithRequiredAndCheckConstraint.new(some_required_attribute: 'some_value', primary_color: ["RED", "brown"]).validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
64
+ end
65
+
66
+ context 'with allow_nil config set to false' do
67
+ it 'raises a validation error if an element from the option value is nil' do
68
+ expect{ WithRequiredCheckConstraintOption.new(primary_color: ["blue", nil]).validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
69
+ end
70
+ end
71
+
72
+ context 'with allow_nil config set to true' do
73
+ it 'doesn\'t raise any validation errors if an element from the option value is nil' do
74
+ expect{ WithCheckConstraint.new(primary_color: ["blue", nil]).validate! }.to_not raise_error(ChronosAuthz::Validations::ValidationError)
75
+ end
76
+ end
77
+
78
+ context 'with case_sensitive config set to true' do
79
+ it 'raises a validation error if an element from the option value is meaningfully equal to any of the valid option values but in different case' do
80
+ expect{ WithCaseSensitiveCheckConstraintOption.new(primary_color: ["blue", "ReD"]).validate! }.to raise_error(ChronosAuthz::Validations::ValidationError)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,115 @@
1
+ require 'bundler/setup'
2
+ Bundler.setup
3
+
4
+ require 'chronos_authz'
5
+
6
+ require 'simplecov'
7
+ require 'simplecov-console'
8
+
9
+ Dir[File.join(File.dirname(__FILE__), "helpers", "**/*.rb")].each do |f|
10
+ require f
11
+ end
12
+
13
+ SimpleCov.formatter = SimpleCov.formatter = SimpleCov::Formatter::Console
14
+ SimpleCov.start
15
+
16
+ # This file was generated by the `rspec --init` command. Conventionally, all
17
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
18
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
19
+ # this file to always be loaded, without a need to explicitly require it in any
20
+ # files.
21
+ #
22
+ # Given that it is always loaded, you are encouraged to keep this file as
23
+ # light-weight as possible. Requiring heavyweight dependencies from this file
24
+ # will add to the boot time of your test suite on EVERY test run, even for an
25
+ # individual file that may not need all of that loaded. Instead, consider making
26
+ # a separate helper file that requires the additional dependencies and performs
27
+ # the additional setup, and require it from the spec files that actually need
28
+ # it.
29
+ #
30
+ # The `.rspec` file also contains a few flags that are not defaults but that
31
+ # users commonly want.
32
+ #
33
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
34
+ RSpec::Expectations.configuration.on_potential_false_positives = :nothing
35
+ RSpec.configure do |config|
36
+ # rspec-expectations config goes here. You can use an alternate
37
+ # assertion/expectation library such as wrong or the stdlib/minitest
38
+ # assertions if you prefer.
39
+ # config.include(ChronosAuthz::Spec::Helpers)
40
+
41
+ config.expect_with :rspec do |expectations|
42
+ # This option will default to `true` in RSpec 4. It makes the `description`
43
+ # and `failure_message` of custom matchers include text for helper methods
44
+ # defined using `chain`, e.g.:
45
+ # be_bigger_than(2).and_smaller_than(4).description
46
+ # # => "be bigger than 2 and smaller than 4"
47
+ # ...rather than:
48
+ # # => "be bigger than 2"
49
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
50
+ end
51
+
52
+
53
+ # rspec-mocks config goes here. You can use an alternate test double
54
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
55
+ config.mock_with :rspec do |mocks|
56
+ # Prevents you from mocking or stubbing a method that does not exist on
57
+ # a real object. This is generally recommended, and will default to
58
+ # `true` in RSpec 4.
59
+ mocks.verify_partial_doubles = true
60
+ end
61
+
62
+ # The settings below are suggested to provide a good initial experience
63
+ # with RSpec, but feel free to customize to your heart's content.
64
+ =begin
65
+ # These two settings work together to allow you to limit a spec run
66
+ # to individual examples or groups you care about by tagging them with
67
+ # `:focus` metadata. When nothing is tagged with `:focus`, all examples
68
+ # get run.
69
+ config.filter_run :focus
70
+ config.run_all_when_everything_filtered = true
71
+
72
+ # Allows RSpec to persist some state between runs in order to support
73
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
74
+ # you configure your source control system to ignore this file.
75
+ config.example_status_persistence_file_path = "spec/examples.txt"
76
+
77
+ # Limits the available syntax to the non-monkey patched syntax that is
78
+ # recommended. For more details, see:
79
+ # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
80
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
81
+ # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching
82
+ config.disable_monkey_patching!
83
+
84
+ # This setting enables warnings. It's recommended, but in some cases may
85
+ # be too noisy due to issues in dependencies.
86
+ config.warnings = true
87
+
88
+ # Many RSpec users commonly either run the entire suite or an individual
89
+ # file, and it's useful to allow more verbose output when running an
90
+ # individual spec file.
91
+ if config.files_to_run.one?
92
+ # Use the documentation formatter for detailed output,
93
+ # unless a formatter has already been configured
94
+ # (e.g. via a command-line flag).
95
+ config.default_formatter = 'doc'
96
+ end
97
+
98
+ # Print the 10 slowest examples and example groups at the
99
+ # end of the spec run, to help surface which specs are running
100
+ # particularly slow.
101
+ config.profile_examples = 10
102
+
103
+ # Run specs in random order to surface order dependencies. If you find an
104
+ # order dependency and want to debug it, you can fix the order by providing
105
+ # the seed, which is printed after each run.
106
+ # --seed 1234
107
+ config.order = :random
108
+
109
+ # Seed global randomization in this process using the `--seed` CLI option.
110
+ # Setting this allows you to use `--seed` to deterministically reproduce
111
+ # test failures related to randomization by passing the same `--seed` value
112
+ # as the one that triggered the failure.
113
+ Kernel.srand config.seed
114
+ =end
115
+ end
metadata ADDED
@@ -0,0 +1,179 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chronos_authz
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Marianne Angelie del Mundo
8
+ - Rodette Pedro
9
+ - JR Respino
10
+ - Jayson Uy
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+ date: 2018-08-10 00:00:00.000000000 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: railties
18
+ requirement: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: '4.2'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.2'
30
+ - !ruby/object:Gem::Dependency
31
+ name: request_store
32
+ requirement: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: '0'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ - !ruby/object:Gem::Dependency
45
+ name: activesupport
46
+ requirement: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ - !ruby/object:Gem::Dependency
59
+ name: rake
60
+ requirement: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 11.3.0
65
+ type: :development
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 11.3.0
72
+ - !ruby/object:Gem::Dependency
73
+ name: rspec-rails
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ type: :development
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ - !ruby/object:Gem::Dependency
87
+ name: simplecov
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ type: :development
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ - !ruby/object:Gem::Dependency
101
+ name: simplecov-console
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ description: A declarative authorization Rack middleware that supports custom authorization
115
+ logic on a per-resource basis
116
+ email:
117
+ - marianne@chronoscloud.com
118
+ - rodette@chronoscloud.com
119
+ - jr@chronoscloud.com
120
+ - jayson@chronoscloud.com
121
+ executables: []
122
+ extensions: []
123
+ extra_rdoc_files: []
124
+ files:
125
+ - ".gitignore"
126
+ - ".rspec"
127
+ - Gemfile
128
+ - Gemfile.lock
129
+ - README.md
130
+ - Rakefile
131
+ - chronos_authz.gemspec
132
+ - lib/chronos_authz.rb
133
+ - lib/chronos_authz/acl.rb
134
+ - lib/chronos_authz/authorizer.rb
135
+ - lib/chronos_authz/configuration.rb
136
+ - lib/chronos_authz/rule.rb
137
+ - lib/chronos_authz/user.rb
138
+ - lib/chronos_authz/validations/options_validator.rb
139
+ - lib/chronos_authz/validations/validation_error.rb
140
+ - lib/chronos_authz/version.rb
141
+ - spec/acl_spec.rb
142
+ - spec/authorizer_spec.rb
143
+ - spec/config/authorizer_acl_test.yml
144
+ - spec/config/error_page.html
145
+ - spec/helpers/custom_rule.rb
146
+ - spec/options_validator_spec.rb
147
+ - spec/spec_helper.rb
148
+ homepage: https://github.com/chronoscloud/chronoscloud-authz
149
+ licenses:
150
+ - N/A
151
+ metadata: {}
152
+ post_install_message:
153
+ rdoc_options: []
154
+ require_paths:
155
+ - lib
156
+ required_ruby_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '2.4'
161
+ required_rubygems_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ requirements: []
167
+ rubyforge_project:
168
+ rubygems_version: 2.6.8
169
+ signing_key:
170
+ specification_version: 4
171
+ summary: A minimal and declarative authorization layer
172
+ test_files:
173
+ - spec/acl_spec.rb
174
+ - spec/authorizer_spec.rb
175
+ - spec/config/authorizer_acl_test.yml
176
+ - spec/config/error_page.html
177
+ - spec/helpers/custom_rule.rb
178
+ - spec/options_validator_spec.rb
179
+ - spec/spec_helper.rb