roleback 0.1.0

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
+ SHA256:
3
+ metadata.gz: ba408ae577a2cf4e25474b107715294f454a7d1f9c99b390d4f19b10e4c9e355
4
+ data.tar.gz: 268e804302e6b0f79a51b6e0620b03850927eee864c6d17232fe2c37868ed07f
5
+ SHA512:
6
+ metadata.gz: 56ab51c35baeed4d942250986065461deec60ac9cc0c32d5d46058b26b8bc32f043e179cbcafa17daf7fe8e97028acf3975ea7a858f762319bc3ff7762a9998c
7
+ data.tar.gz: f833af2acffdb889406f0d61e6c32beef2f805044ee233a27acce888b257b2db2627dfcb42bdacabe3c579fbc7e0c775108fe105d546326f501e9bc1948abd87
data/.editorconfig ADDED
@@ -0,0 +1,37 @@
1
+ # EditorConfig is awesome: http://EditorConfig.org
2
+ # top-most EditorConfig file
3
+ root = true
4
+ # all files
5
+ [*]
6
+ # Unix-style newlines
7
+ end_of_line = lf
8
+ # newline ending every file
9
+ insert_final_newline = true
10
+ # utf-8 charset
11
+ charset = utf-8
12
+ # remove and trailing whitespace chars
13
+ trim_trailing_whitespace = true
14
+ # default to space spacing
15
+ indent_style = space
16
+ # default to 2 char tabs
17
+ indent_size = 2
18
+ # no max line length
19
+ max_line_length = off
20
+ # keep curly on same line if possible
21
+ curly_bracket_next_line = false
22
+ # https://en.wikipedia.org/wiki/Indent_style#K.26R_style
23
+ indent_brace_style = K&R
24
+ # "something + something" not "something+something"
25
+ spaces_around_operators = true
26
+ # "something(true)" not "something ( true )"
27
+ spaces_around_brackets = none
28
+ [*.{js,css,scss,html,xml}]
29
+ indent_style = space
30
+ indent_size = 4
31
+ [*.{yml,sh,sh.erb}]
32
+ indent_style = space
33
+ indent_size = 2
34
+ spaces_around_operators = false
35
+ [*.{rb,rb.erb,rb.static,gemspec}]
36
+ indent_style = tab
37
+ indent_size = 4
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.10
7
+ before_install: gem install bundler -v 1.17.2
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in roleback.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,44 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ roleback (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ debug (1.8.0)
10
+ irb (>= 1.5.0)
11
+ reline (>= 0.3.1)
12
+ diff-lcs (1.5.0)
13
+ io-console (0.7.1)
14
+ irb (1.6.3)
15
+ reline (>= 0.3.0)
16
+ rake (10.5.0)
17
+ reline (0.4.2)
18
+ io-console (~> 0.5)
19
+ rspec (3.12.0)
20
+ rspec-core (~> 3.12.0)
21
+ rspec-expectations (~> 3.12.0)
22
+ rspec-mocks (~> 3.12.0)
23
+ rspec-core (3.12.2)
24
+ rspec-support (~> 3.12.0)
25
+ rspec-expectations (3.12.3)
26
+ diff-lcs (>= 1.2.0, < 2.0)
27
+ rspec-support (~> 3.12.0)
28
+ rspec-mocks (3.12.6)
29
+ diff-lcs (>= 1.2.0, < 2.0)
30
+ rspec-support (~> 3.12.0)
31
+ rspec-support (3.12.1)
32
+
33
+ PLATFORMS
34
+ ruby
35
+
36
+ DEPENDENCIES
37
+ bundler (~> 1.17)
38
+ debug
39
+ rake (~> 10.0)
40
+ roleback!
41
+ rspec (~> 3.0)
42
+
43
+ BUNDLED WITH
44
+ 1.17.2
data/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # Roleback
2
+
3
+ Roleback is a simple DSL for writing static RBAC rules for your application. Roleback is not concerned with how you store or enforce your roles, it only cares about how you define them. Storing roles against a user class is easy enough, and there are plenty of gems out there to help you enforce them, like [Pundit](https://github.com/varvet/pundit) and [CanCanCan](https://github.com/CanCanCommunity/cancancan).
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'roleback'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install roleback
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ Using Roleback is simple. Define your roles in a ruby file, and then load them into your application. For example in Rails, you can create a file loaded during your application load, like `config/initializers/roles.rb`:
28
+
29
+ ```ruby
30
+ # config/initializers/roles.rb
31
+ Roleback.define do
32
+ role :admin do
33
+ can :manage
34
+ end
35
+ end
36
+ ```
37
+
38
+ Roleback defines permissions on by roles. In the example above, we've defined a role called `admin` that can `manage` anything. Usually permissions are defined with three pieces of information: `scope`, `resource` and `action`.
39
+
40
+ `resource` is the object you want to check permissions against. `action` is the action you want to check permissions for. For example, you might want to check `read` action, on a blog `post`:
41
+
42
+ ```ruby
43
+ # config/initializers/roles.rb
44
+ Roleback.define do
45
+ role :admin do
46
+ resource :post do
47
+ can :read
48
+ end
49
+ end
50
+ end
51
+ ```
52
+
53
+ `resource` however, always includes 7 more actions: `create`, `read`, `update`, `delete`, `list`, `edit` and `new` to make it easier to define permissions for common actions. You can change this behavior using the `only` and `except` options:
54
+
55
+ ```ruby
56
+ # config/initializers/roles.rb
57
+ Roleback.define do
58
+ role :admin do
59
+ resource :post, only: [:read, :create, :update, :delete] do
60
+ can :read
61
+ end
62
+ end
63
+ end
64
+ ```
65
+
66
+ `scope` adds context to your permissions. For example, you might want to grant `read` on a `post` in the web, but not in other contexts (like an API):
67
+
68
+ ```ruby
69
+ # config/initializers/roles.rb
70
+ Roleback.define do
71
+ role :admin do
72
+ scope :web do
73
+ resource :post do
74
+ can :read
75
+ end
76
+ end
77
+ end
78
+ end
79
+ ```
80
+
81
+ ## Grant and Deny
82
+ Permissions are granted using `can` and denied using `cannot`:
83
+
84
+ ```ruby
85
+ # config/initializers/roles.rb
86
+ Roleback.define do
87
+ role :admin do
88
+ scope :web do
89
+ resource :post do
90
+ can :read
91
+ cannot :write
92
+ end
93
+ end
94
+ end
95
+ end
96
+ ```
97
+
98
+ By default, all `resource` default permissions (create, read, update, delete, list, edit and new) are granted (ie `can`).
99
+
100
+ ## Inheritance
101
+ Roles can inherit from other roles:
102
+
103
+ ```ruby
104
+ # config/initializers/roles.rb
105
+ Roleback.define do
106
+ role :admin do
107
+ can :manage
108
+ end
109
+
110
+ role :editor, inherits_from: :admin do
111
+ cannot :delete
112
+ end
113
+ end
114
+ ```
115
+
116
+ Roles can also inherit from multiple roles:
117
+
118
+ ```ruby
119
+ # config/initializers/roles.rb
120
+ Roleback.define do
121
+ role :admin do
122
+ can :manage
123
+ end
124
+
125
+ role :author do
126
+ can :write
127
+ end
128
+
129
+ role :editor, inherits_from: [:admin, :author] do
130
+ cannot :delete
131
+ end
132
+ end
133
+ ```
134
+
135
+ While you don't need to define a parent role before the child role, circular dependencies are not allowed, within the same parental line. For example, if `:moderator` inherits from `:admin`, `:admin` cannot inherit from `:moderator`, directly or indirectly. However, when inheriting from multiple parents, circular dependencies are allowed, as long as they are not in the same parental line. For example, `:editor` can inherit from `:admin` and `:author`, and `:author` can inherit from `:editor`, as long as `:editor` does not inherit from `:author` directly or indirectly.
136
+
137
+ When it comes to consolidating the rules of inherited roles, Roleback allows repeated rules as long as they don't belong to the same role. For example, it is not allowed to define a rule twice, even with the same outcome:
138
+
139
+ ```ruby
140
+ # config/initializers/roles.rb
141
+ Roleback.define do
142
+ role :admin do
143
+ can :manage
144
+ can :manage # <- not allowed
145
+ end
146
+ end
147
+ ```
148
+
149
+ You can however, define the same rule in different roles, as long as they don't contradict each other:
150
+
151
+ ```ruby
152
+ # config/initializers/roles.rb
153
+ Roleback.define do
154
+ role :admin do
155
+ can :manage
156
+ end
157
+
158
+ role :editor, inherits_from: :admin do
159
+ can :manage
160
+ end
161
+ end
162
+ ```
163
+
164
+ ```ruby
165
+ # config/initializers/roles.rb
166
+ Roleback.define do
167
+ role :admin do
168
+ can :manage
169
+ end
170
+
171
+ role :editor, inherits_from: :admin do
172
+ cannot :manage # <- not allowed
173
+ end
174
+ end
175
+ ```
176
+
177
+ ## Checking Permissions
178
+ Roleback doesn't care how you check permissions, but it does provide a simple API for doing so:
179
+
180
+ ```ruby
181
+ Roleback.can?(:admin, resource: :post, action: :read) # => true
182
+ Roleback.can?(:editor, resource: :post, :delete) # => false
183
+ ```
184
+
185
+ After the definition of roles is finished (`Roleback.define`), all each role, ends up with the collection of all rules it has plus all the rules it has inherited from other roles. These rules are used to check for permissions. When the `can?` method is called with `scope`, `resource` and `action`, `can?` will return the outcome of the most specific rule that matches the given `scope`, `resource` and `action`. If no rule matches, `can?` will return `false`. If you have both `can` and `cannot` rules for a check, `cannot` will take precedence (deny over grant).
186
+
187
+ ### `User` class
188
+ If you have a `User` class, Roleback will automatically, add a `can?` method to it:
189
+
190
+ ```ruby
191
+ user = User.find(1)
192
+ user.can?(:admin, resource: :post, action: :read) # => true
193
+ user.can?(:editor, resource: :post, :delete) # => false
194
+ ```
195
+
196
+ Your `User` class has to have a method called `roles` that returns an array of role names as symbols.
197
+
198
+ The `User` class returns an array of roles, then Roleback will check each role for a match and will return `true` (grant) when the first role matches. If no role matches, `can?` will return `false`. This is an important point to remember when using class extension, which basically means if you grant the user multiple rules, it will return `true` if any of the rules match, even you have rules that deny access to the same resource and action.
199
+
200
+ You can change the class to be extended from `User`, using `user_class` option in `define`:
201
+
202
+ ```ruby
203
+ Roleback.define(user_class: Admin) do
204
+ # ...
205
+ end
206
+ ```
207
+
208
+ If you don't want to extend your `User` class, pass in `nil` as the `user_class` option:
209
+
210
+ ```ruby
211
+ Roleback.define(user_class: nil) do
212
+ # ...
213
+ end
214
+ ```
215
+
216
+ ## Recommendations
217
+
218
+ Even though Roleback doesn't impose any opinions on how define your rules (sacrilegious in Rails world, I know), here are some recommendations that might help using it with more ease:
219
+
220
+ 1. Although Roleback, support deny permissions (`cannot`), I recommend against using those and always define your rules with grant permissions (`can`). This will make it easier to reason about your rules and will make it easier to debug them.
221
+ 2. Either map your roles to actual organizational roles (marketing, support, etc), or define them based on their access context (commenter, editor, etc). Don't mix the two. Use multiple inheritance when defining the roles based on access context and use single inheritance when defining them based on organizational roles.
222
+ 3. Define your roles in a single file, and load them during application load. (`config/initializers/roles.rb` in Rails is a good place).
223
+
224
+ ## Contributing
225
+
226
+ Bug reports and pull requests are welcome on this GitHub repository. PRs are welcome and more likely to be accepted if they include tests.
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
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "roleback"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,138 @@
1
+ module Roleback
2
+ def self.define(options = {}, &block)
3
+ @config = ::Roleback::Configuration::Builder.new(options, &block).build if block_given?
4
+ @config.construct!
5
+
6
+ # is there a ::User class defined?
7
+ if options[:user_class]
8
+ @user_class = options[:user_class]
9
+ elsif defined?(::User)
10
+ @user_class = ::User
11
+ else
12
+ @user_class = nil
13
+ end
14
+
15
+ # extend the user class
16
+ ::Roleback::UserExtension.extend!(@user_class) if @user_class
17
+
18
+ @config
19
+ end
20
+
21
+ def self.configuration
22
+ @config || (raise ::Roleback::NotConfiguredError)
23
+ end
24
+
25
+ def self.any
26
+ ::Roleback::ANY
27
+ end
28
+
29
+ def self.allow
30
+ ::Roleback::ALLOW
31
+ end
32
+
33
+ def self.deny
34
+ ::Roleback::DENY
35
+ end
36
+
37
+ def self.clear!
38
+ @config = nil
39
+ end
40
+
41
+ def self.roles
42
+ self.configuration.roles
43
+ end
44
+
45
+ def self.can?(role_name, resource: ::Roleback.any, scope: ::Roleback.any, action: ::Roleback.any)
46
+ role = self.configuration.find_role!(role_name)
47
+
48
+ return true if role.rules.can?(resource: resource, scope: scope, action: action)
49
+
50
+ false
51
+ end
52
+
53
+ class Configuration
54
+ attr_reader :roles
55
+ attr_reader :max_inheritance_depth
56
+
57
+ def initialize(options = {})
58
+ @options = options
59
+ @roles = {}
60
+
61
+ if options[:max_inheritance_depth]
62
+ @max_inheritance_depth = options[:max_inheritance_depth]
63
+ else
64
+ @max_inheritance_depth = 10
65
+ end
66
+ end
67
+
68
+ def find_role!(name)
69
+ role = self.roles[name.to_sym]
70
+ raise ::Roleback::MissingRole, "Role #{name} not found" unless role
71
+ role
72
+ end
73
+
74
+ def can?(role_name, scope: ::Roleback::ANY, resource: ::Roleback::ANY, action: ::Roleback::ANY)
75
+ role = self.find_role!(role_name)
76
+ role.can?(scope: scope, resource: resource, action: action)
77
+ end
78
+
79
+ def construct!
80
+ # go through all roles and find their parents
81
+ @roles.each do |name, role|
82
+ parents = role.parents
83
+ next unless parents && !parents.empty?
84
+
85
+ found_parents = []
86
+
87
+ parents.each do |parent|
88
+ found_parent = @roles[parent]
89
+ raise ::Roleback::BadConfiguration, "Role #{parent} not found" unless found_parent
90
+
91
+ found_parents << found_parent
92
+ role.instance_variable_set(:@parents, found_parents)
93
+ end
94
+ end
95
+
96
+ # go through all roles, and inherit their parents' rules
97
+ @roles.each do |name, role|
98
+ role.inherit
99
+ end
100
+ end
101
+
102
+ class Builder
103
+ def initialize(options = {}, &block)
104
+ @options = options
105
+ @config = ::Roleback::Configuration.new(options)
106
+ @parent = nil
107
+
108
+ instance_eval(&block) if block_given?
109
+ end
110
+
111
+ def build
112
+ @config
113
+ end
114
+
115
+ def role(name, options = {}, &block)
116
+ roles = @config.instance_variable_get(:@roles) || {}
117
+
118
+ raise ::Roleback::BadConfiguration, "Role #{name} already defined" if roles[name]
119
+
120
+ validate_options!(options)
121
+
122
+ parents = options[:inherits_from]
123
+
124
+ role = ::Roleback::Definitions::Role.new(name, parents: parents)
125
+ role.instance_eval(&block) if block_given?
126
+
127
+ roles[name] = role
128
+ @config.instance_variable_set(:@roles, roles)
129
+
130
+ role
131
+ end
132
+
133
+ def validate_options!(options)
134
+ raise ::Roleback::BadConfiguration, "Invalid options" if options.keys.any? { |k| ![:inherits_from].include?(k) }
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,2 @@
1
+ require_relative 'rule_based'
2
+ Dir["#{File.dirname(__FILE__)}/roleback/**/*.rb"].each { |f| load(f) }
@@ -0,0 +1,68 @@
1
+ module Roleback
2
+ module Definitions
3
+ class Resource < ::Roleback::Definitions::RuleBased
4
+ attr_reader :name
5
+
6
+ DEFAULT_ACTION_PATH = [:create, :show, :update, :delete, :index, :new, :edit]
7
+
8
+ def initialize(name, role:, scope: ::Roleback::ANY, options: {}, &block)
9
+ @name = name
10
+ @role = role
11
+ @scope = scope
12
+ @options = options
13
+
14
+ super(role: role, resource: self, scope: scope)
15
+
16
+ validate_options!
17
+
18
+ # create rules for each action
19
+ selected_actions.each do |action|
20
+ do_rule(role: @role, resource: self, scope: @scope, action: action, outcome: ::Roleback::ALLOW)
21
+ end
22
+
23
+ instance_eval(&block) if block_given?
24
+ end
25
+
26
+ def match(resource)
27
+ to_check = resource.is_a?(::Roleback::Definitions::Resource) ? resource.name.to_s : resource.to_s
28
+ @name.to_s == to_check || @name == ::Roleback.any || resource == ::Roleback.any
29
+ end
30
+
31
+ def ==(other)
32
+ return false unless other.respond_to?(:name)
33
+
34
+ other.name == name
35
+ end
36
+
37
+ private
38
+
39
+ def validate_options!
40
+ if @options[:only] && @options[:except]
41
+ raise ::Roleback::BadConfiguration, "You can't specify both :only and :except options"
42
+ end
43
+
44
+ if @options[:only] && !@options[:only].is_a?(Array)
45
+ raise ::Roleback::BadConfiguration, "The :only option must be an array"
46
+ end
47
+
48
+ if @options[:except] && !@options[:except].is_a?(Array)
49
+ raise ::Roleback::BadConfiguration, "The :except option must be an array"
50
+ end
51
+
52
+ if @options.keys.any? { |k| ![:only, :except].include?(k) }
53
+ raise ::Roleback::BadConfiguration, "Invalid options"
54
+ end
55
+ end
56
+
57
+ def selected_actions
58
+ if @options[:only]
59
+ return @options[:only]
60
+ elsif @options[:except]
61
+ return DEFAULT_ACTION_PATH - @options[:except]
62
+ else
63
+ return DEFAULT_ACTION_PATH
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,98 @@
1
+ module Roleback
2
+ module Definitions
3
+ class Role < Roleback::Definitions::RuleBased
4
+ attr_reader :name
5
+ attr_reader :parents
6
+
7
+ def initialize(name, parents: nil)
8
+ @name = name
9
+ @rule_book = ::Roleback::RuleBook.new(self)
10
+ @scopes = {}
11
+ @resources = {}
12
+
13
+ if parents
14
+ if parents.is_a?(Symbol)
15
+ @parents = [parents]
16
+ elsif parents.is_a?(Array)
17
+ # check for duplicates
18
+ raise ::Roleback::BadConfiguration, "Duplicate parents found for role #{name}" if parents.uniq.length != parents.length
19
+
20
+ @parents = parents
21
+ else
22
+ raise ::Roleback::BadConfiguration, "Parent must be a symbol or an array of symbols"
23
+ end
24
+ else
25
+ @parents = nil
26
+ end
27
+
28
+ super(role: self, resource: ::Roleback::ANY, scope: ::Roleback::ANY)
29
+ end
30
+
31
+ def rules
32
+ @rule_book
33
+ end
34
+
35
+ def keys
36
+ @rule_book.rules.keys
37
+ end
38
+
39
+ def add_rule(rule)
40
+ @rule_book.add(rule)
41
+ end
42
+
43
+ def resource(name, options = {}, &block)
44
+ raise ::Roleback::BadConfiguration, "Resource #{name} already defined" if @resources[name]
45
+
46
+ resource = ::Roleback::Definitions::Resource.new(name, role: self, options: options, &block)
47
+ @resources[name] = resource
48
+ end
49
+
50
+ def scope(name, &block)
51
+ raise ::Roleback::BadConfiguration, "Scope #{name} already defined" if @scopes[name]
52
+
53
+ scope = ::Roleback::Definitions::Scope.new(name, role: self, &block)
54
+ @scopes[name] = scope
55
+ end
56
+
57
+ def to_s
58
+ self.name.to_s
59
+ end
60
+
61
+ def inherit
62
+ return if @parents.nil?
63
+
64
+ new_rules = do_inherit
65
+
66
+ if new_rules.empty?
67
+ # no rules to inherit
68
+ return
69
+ end
70
+
71
+ # add the new rules to the rule book
72
+ self.rules.clear_rules
73
+ new_rules.each do |rule|
74
+ self.rules.add(rule)
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def do_inherit(rule_set = [], level = 0)
81
+ # don't go too deep
82
+ raise ::Roleback::BadConfiguration, "Circular dependency detected (#{level} out of maximum allowed of #{::Roleback.configuration.max_inheritance_depth})" if level > ::Roleback.configuration.max_inheritance_depth
83
+
84
+ new_rules = rule_set + self.rules.to_a
85
+
86
+ return new_rules if @parents.nil? || @parents.empty?
87
+
88
+ @parents.each do |parent|
89
+ parent_rules = parent.send(:do_inherit, rule_set, level + 1)
90
+ new_rules = new_rules + parent_rules
91
+ end
92
+
93
+ return new_rules
94
+ end
95
+
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,39 @@
1
+ module Roleback
2
+ module Definitions
3
+ class RuleBased
4
+ attr_reader :role
5
+ attr_reader :resource
6
+
7
+ def initialize(role:, resource:, scope:)
8
+ @role = role
9
+ @resource = resource
10
+ @scope = scope
11
+ end
12
+
13
+ def can(action)
14
+ do_rule(role: @role, resource: @resource, scope: @scope, action: action, outcome: ::Roleback::ALLOW)
15
+ end
16
+
17
+ def cannot(action)
18
+ do_rule(role: @role, resource: @resource, scope: @scope, action: action, outcome: ::Roleback::DENY)
19
+ end
20
+
21
+ def <=>(other)
22
+ other.numerical_value <=> numerical_value
23
+ end
24
+
25
+ protected
26
+
27
+ def do_rule(role:, resource:, scope:, action:, outcome:)
28
+ rule = ::Roleback::Rule.new(
29
+ role: role,
30
+ resource: resource,
31
+ scope: scope,
32
+ action: action,
33
+ outcome: outcome)
34
+
35
+ role.add_rule(rule)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ module Roleback
2
+ module Definitions
3
+ class Scope < ::Roleback::Definitions::RuleBased
4
+ attr_reader :name
5
+
6
+ def initialize(name, role:, options: {}, &block)
7
+ @name = name
8
+ @role = role
9
+ @options = options
10
+
11
+ super(role: role, scope: self, resource: ::Roleback::ANY)
12
+
13
+ instance_eval(&block) if block_given?
14
+ end
15
+
16
+ def resource(name, options = {}, &block)
17
+ ::Roleback::Definitions::Resource.new(name, role: @role, scope: self, options: options, &block)
18
+ end
19
+
20
+ def match(scope)
21
+ to_check = scope.is_a?(::Roleback::Definitions::Scope) ? scope.name.to_s : scope.to_s
22
+ @name.to_s == scope.name.to_s || @name == ::Roleback.any || scope == ::Roleback.any
23
+ end
24
+
25
+ def ==(other)
26
+ return false unless other.respond_to?(:name)
27
+
28
+ other.name == name
29
+ end
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,61 @@
1
+ module Roleback
2
+ class OutcomeBase
3
+ attr_reader :outcome
4
+
5
+ def initialize(outcome)
6
+ @outcome = outcome
7
+ end
8
+
9
+ def allowed?
10
+ @outcome == :allow
11
+ end
12
+
13
+ def denied?
14
+ @outcome == :deny
15
+ end
16
+
17
+ def to_s
18
+ @outcome.to_s
19
+ end
20
+
21
+ def ==(other)
22
+ return false unless other.respond_to?(:outcome)
23
+
24
+ other.outcome == outcome
25
+ end
26
+ end
27
+
28
+ class Allow < OutcomeBase
29
+ def initialize
30
+ super(:allow)
31
+ end
32
+ end
33
+
34
+ class Deny < OutcomeBase
35
+ def initialize
36
+ super(:deny)
37
+ end
38
+ end
39
+
40
+ class Any
41
+ def name
42
+ :'*'
43
+ end
44
+
45
+ def ==(other)
46
+ return true if other.is_a?(Any)
47
+ return false unless other.respond_to?(:name)
48
+
49
+ other.name.to_s == name.to_s
50
+ end
51
+
52
+ def match(scope)
53
+ true
54
+ end
55
+ end
56
+
57
+ ANY = Any.new
58
+
59
+ ALLOW = Allow.new
60
+ DENY = Deny.new
61
+ end
@@ -0,0 +1,65 @@
1
+ module Roleback
2
+ class Rule
3
+ attr_reader :role
4
+ attr_reader :resource
5
+ attr_reader :scope
6
+ attr_reader :action
7
+ attr_reader :outcome
8
+
9
+ def initialize(role:, resource:, scope:, action:, outcome:)
10
+ @role = role
11
+ @resource = resource
12
+ @scope = scope
13
+ @action = action
14
+ @outcome = outcome
15
+ end
16
+
17
+ def key
18
+ "#{@scope.name}:/#{@resource.name}/#{@action}"
19
+ end
20
+
21
+ def to_s
22
+ "#{key}->#{@outcome}"
23
+ end
24
+
25
+ def match(resource:, scope:, action:)
26
+ if @resource.match(resource) && @scope.match(scope)
27
+ if @action == ::Roleback.any || @action.to_s == action.to_s
28
+ return self
29
+ end
30
+ end
31
+
32
+ nil
33
+ end
34
+
35
+ # two rules are conflicting, when the have the same scope, resource and action, but different outcomes
36
+ def conflicts_with?(rule)
37
+ # if the rules are the same, they don't conflict
38
+ return false if self == rule
39
+
40
+ # if the scope, resource and action are the same, but the outcome is different, they conflict
41
+ return true if @scope.name == rule.scope.name && @resource.name == rule.resource.name && @action == rule.action && @outcome.outcome != rule.outcome.outcome
42
+
43
+ # otherwise, they don't conflict
44
+ false
45
+ end
46
+
47
+ # calculate a numerical value for this rule to be used for sorting
48
+ # the value is the sum of the following:
49
+ # (scope_value * scope_weight) + (resource_value * resource_weight) * (outcome_value * outcome_weight)
50
+ # scope_weight = 100
51
+ # resource_weight = 10
52
+ # outcome_weight = 1
53
+ # scope_value = 0 if scope == ANY, 1 otherwise
54
+ # resource_value = 0 if resource == ANY, 1 otherwise
55
+ # outcome_value = 0 if outcome == ALLOW, 1 otherwise
56
+ def numerical_value
57
+ scope_value = @scope == ::Roleback::ANY ? 0 : 1
58
+ resource_value = @resource == ::Roleback::ANY ? 0 : 1
59
+ outcome_value = @outcome == ::Roleback::ALLOW ? 0 : 1
60
+
61
+ (scope_value * 100) + (resource_value * 10) + (outcome_value * 1)
62
+ end
63
+
64
+ end
65
+ end
@@ -0,0 +1,119 @@
1
+ module Roleback
2
+ class RuleBook
3
+ attr_reader :rules
4
+
5
+ def initialize(role)
6
+ @rules = {}
7
+ @role = role
8
+ end
9
+
10
+ def add(rule)
11
+ raise ::Roleback::BadConfiguration, "Adding a rule with no role" unless rule.role
12
+
13
+ if @rules[rule.key]
14
+ if @rules[rule.key].outcome.outcome == rule.outcome.outcome
15
+ # don't allow it if they share a rulebook
16
+ if @role.name == rule.role.name
17
+ raise ::Roleback::BadConfiguration, "Rule #{rule.key} already defined"
18
+ else
19
+ # this duplicate is through inheritance, so we can safely ignore it
20
+ return
21
+ end
22
+ else
23
+ raise ::Roleback::BadConfiguration, "Rule #{rule.key} already defined with a different outcome (conflicting rules)"
24
+ end
25
+ end
26
+
27
+ # detect conflicting rules
28
+ @rules.each do |key, existing_rule|
29
+ if existing_rule.conflicts_with?(rule)
30
+ raise ::Roleback::BadConfiguration, "Rule #{rule.key} conflicts with #{existing_rule.key}"
31
+ end
32
+ end
33
+
34
+ @rules[rule.key] = rule
35
+ end
36
+
37
+ def clear_rules
38
+ @rules = {}
39
+ end
40
+
41
+ def length
42
+ @rules.length
43
+ end
44
+
45
+ def [](key)
46
+ @rules[key]
47
+ end
48
+
49
+ def keys
50
+ @rules.keys
51
+ end
52
+
53
+ def match_all(resource:, scope:, action:)
54
+ result = []
55
+ @rules.each do |key, rule|
56
+ result << rule if rule.match(resource: resource, scope: scope, action: action)
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ def can?(resource:, scope:, action:)
63
+ # get all rules that matches the given resource, scope and action
64
+ rules = match_all(resource: resource, scope: scope, action: action)
65
+
66
+ # if there are no rules, return false
67
+ return false if rules.empty?
68
+
69
+ # create a rule book with the matching rules
70
+ match_book = self.class.new(@role)
71
+ rules.each do |rule|
72
+ match_book.add(rule)
73
+ end
74
+
75
+ # sort the rules
76
+ sorted_rules = self.class.sort(match_book.rules)
77
+
78
+ # iterate over the sorted rules and find the first rule that matches
79
+ sorted_rules.each do |rule|
80
+ if rule.match(resource: resource, scope: scope, action: action)
81
+ return rule.outcome.allowed?
82
+ end
83
+ end
84
+
85
+ # if no rule matches, return false
86
+ return false
87
+ end
88
+
89
+ # sorts the rules, based on the rules' numerical value
90
+ def self.sort(rules)
91
+ # rules should be a hash
92
+ raise ::ArgumentError, "rules should be a hash but it is a #{rules.class}" unless rules.is_a?(Hash)
93
+
94
+ # rules is a hash, so we need to convert it to an array
95
+ rules = rules.values
96
+
97
+ # sort the rules
98
+ rules.sort! do |a, b|
99
+ b.numerical_value <=> a.numerical_value
100
+ end
101
+ end
102
+
103
+ def sort!
104
+ @rules = self.class.sort(@rules)
105
+ end
106
+
107
+ def sort
108
+ self.class.sort(@rules)
109
+ end
110
+
111
+ def to_s
112
+ @rules.values.map(&:to_s).join("\n")
113
+ end
114
+
115
+ def to_a
116
+ @rules.values
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,33 @@
1
+ module Roleback
2
+ class UserExtension
3
+ def self.extend!(user_class)
4
+ user_instance = user_class.new
5
+ unless user_instance.respond_to?(:roles) && user_instance.method(:roles).arity == 0
6
+ raise ::Roleback::InvalidOrMisconfiguredUserClass, "User class #{user_class.name} should have a method call roles that returns an array of role names"
7
+ end
8
+
9
+ if user_class.instance_methods.include?(:can?)
10
+ raise ::Roleback::InvalidOrMisconfiguredUserClass, "User class #{user_class.name} already has a method called can?"
11
+ end
12
+
13
+ user_class.class_eval do
14
+ def can?(resource: Roleback.any, scope: Roleback.any, action: Roleback.any)
15
+ # get all user roles
16
+ roles = self.roles
17
+ return false if roles.empty?
18
+
19
+ if !roles.is_a?(Array)
20
+ raise ::Roleback::InvalidOrMisconfiguredUserClass, "User class #{self.class}#roles should return an array of role names"
21
+ end
22
+
23
+ roles.each do |role|
24
+ return true if ::Roleback.can?(role.to_sym, resource: resource, scope: scope, action: action)
25
+ end
26
+
27
+ # no role can perform the action on the resource
28
+ false
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module Roleback
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/roleback.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "roleback/version"
2
+ require 'roleback/definitions/load'
3
+ Dir["#{File.dirname(__FILE__)}/roleback/**/*.rb"].each { |f| load(f) }
4
+
5
+ module Roleback
6
+ class Error < StandardError; end
7
+ class NotConfiguredError < StandardError; end
8
+ class BadConfiguration < StandardError; end
9
+ class BadMatch < StandardError; end
10
+ class InvalidOrMisconfiguredUserClass < StandardError; end
11
+ class MissingRole < StandardError; end
12
+ end
data/roleback.gemspec ADDED
@@ -0,0 +1,40 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "roleback/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "roleback"
8
+ spec.version = Roleback::VERSION
9
+ spec.authors = ["Khash Sajadi"]
10
+ spec.email = ["khash@cloud66.com"]
11
+
12
+ spec.summary = "Roleback provides a simple DSL to define RBAC rules for your application."
13
+ spec.homepage = "https://github.com/cloud66-oss/roleback"
14
+
15
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
16
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
17
+ if spec.respond_to?(:metadata)
18
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
19
+
20
+ spec.metadata["homepage_uri"] = spec.homepage
21
+ spec.metadata["source_code_uri"] = "https://github.com/cloud66-oss/roleback"
22
+ spec.metadata["changelog_uri"] = "https://github.com/cloud66-oss/roleback/blob/main/CHANGELOG.md"
23
+ else
24
+ raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
25
+ end
26
+
27
+ # Specify which files should be added to the gem when it is released.
28
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
29
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
30
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
31
+ end
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib"]
35
+
36
+ spec.add_development_dependency "bundler", "~> 1.17"
37
+ spec.add_development_dependency "rake", "~> 10.0"
38
+ spec.add_development_dependency "rspec", "~> 3.0"
39
+ spec.add_development_dependency "debug"
40
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: roleback
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Khash Sajadi
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-01-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.17'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.17'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: debug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description:
70
+ email:
71
+ - khash@cloud66.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".editorconfig"
77
+ - ".gitignore"
78
+ - ".rspec"
79
+ - ".travis.yml"
80
+ - Gemfile
81
+ - Gemfile.lock
82
+ - README.md
83
+ - Rakefile
84
+ - bin/console
85
+ - bin/setup
86
+ - lib/roleback.rb
87
+ - lib/roleback/configuration.rb
88
+ - lib/roleback/definitions/load.rb
89
+ - lib/roleback/definitions/resource.rb
90
+ - lib/roleback/definitions/role.rb
91
+ - lib/roleback/definitions/rule_based.rb
92
+ - lib/roleback/definitions/scope.rb
93
+ - lib/roleback/outcome.rb
94
+ - lib/roleback/rule.rb
95
+ - lib/roleback/rule_book.rb
96
+ - lib/roleback/user_extension.rb
97
+ - lib/roleback/version.rb
98
+ - roleback.gemspec
99
+ homepage: https://github.com/cloud66-oss/roleback
100
+ licenses: []
101
+ metadata:
102
+ allowed_push_host: https://rubygems.org
103
+ homepage_uri: https://github.com/cloud66-oss/roleback
104
+ source_code_uri: https://github.com/cloud66-oss/roleback
105
+ changelog_uri: https://github.com/cloud66-oss/roleback/blob/main/CHANGELOG.md
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.0.3.1
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: Roleback provides a simple DSL to define RBAC rules for your application.
125
+ test_files: []