recurso 0.5.3

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: b76502a3d8dcabffaece8ca8e0c1e180119a6f2abafe095e41aacf6bd6d916ce
4
+ data.tar.gz: c9f3e6241010e82e24cefa1529a23b5fbc8567e98f3820cbd9aab83de2b89816
5
+ SHA512:
6
+ metadata.gz: 73c28571558f188c57a9e17f972d64d8a4abd757ef77981a045125c13742d739a8fea35dd99993c5db319b8f24ac04318630779c4f8efe1835c4d115f9f06b2c
7
+ data.tar.gz: 4d10393228eed9cba9aba3e5d34143343600d7ca55eff105d4c6e6b074adbf18d84298ea119e48e84ccad8fb7487db8fc3a26417f51eb999022de80445718185
data/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+
2
+ /.bundle/
3
+ /.yardoc
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ *.gem
12
+ *.sqlite3
13
+ .byebug_history
14
+ .ruby-gemset
15
+ .ruby-version
16
+ Gemfile.lock
@@ -0,0 +1,16 @@
1
+ version: v1.0
2
+ name: Initial Pipeline
3
+ agent:
4
+ machine:
5
+ type: e1-standard-2
6
+ os_image: ubuntu1804
7
+ blocks:
8
+ - name: 'Block #1'
9
+ task:
10
+ jobs:
11
+ - name: 'Job #1'
12
+ commands:
13
+ - checkout
14
+ - gem install bundler
15
+ - bundle install
16
+ - bundle exec rake
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at james.kiesel@gmail.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
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 recurso.gemspec
6
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 James Kiesel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # Recurso
2
+
3
+ Recurso is a gem designed to make complicated permissions systems a breeze.
4
+
5
+ It uses a 'permission' model to relate 'identities' (in most cases, your users), with various related 'resources' within your app.
6
+
7
+ It offers a simple, performant way to manage complex or cascading permissions
8
+
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'recurso'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ $ bundle
21
+
22
+ Or install it yourself as:
23
+
24
+ $ gem install recurso
25
+
26
+ ## Usage
27
+
28
+ #### 1. Specify the 'identity' class
29
+
30
+ Include the concern in the class represented by your users (usually User):
31
+ ```ruby
32
+ # user.rb
33
+ include Recurso::Identity
34
+ ```
35
+
36
+ #### 2. Specify the 'resource' classes
37
+
38
+ Next, we'll specify which classes those users may gain access to by adding the `Recurso::Resource` concern:
39
+
40
+ ```ruby
41
+ class Organization
42
+ include Recurso::Resource
43
+ has_many :teams
44
+ end
45
+
46
+ class Team
47
+ include Recurso::Resource
48
+ belongs_to :organization
49
+ has_many :squads
50
+ end
51
+
52
+ class Squad
53
+ include Recurso::Resource
54
+ belongs_to :team
55
+ end
56
+ ```
57
+
58
+ #### 3. Using policy classes
59
+
60
+ Now, we'll be able to combine the two via a 'policy' method, which will allow us to ask questions about what permissions the user has on any given resource.
61
+
62
+ These policy classes can be used in two ways; the first to ask whether a user can perform an action on a given resource:
63
+
64
+ ```ruby
65
+ @user.policy(@organization).view?
66
+ # ^^ can I view the given organization?
67
+
68
+ @user.policy(@team).modify?
69
+ # ^^ can I modify the given team?
70
+
71
+ @user.policy(@squad).administer?
72
+ # ^^ can I administer the given squad?
73
+ ```
74
+
75
+ The second, which resources a user can access of a given relation:
76
+ ```ruby
77
+ @user.policy(@organization).resources_with_permission(:teams)
78
+ # ^^ which teams can I view within this organization?
79
+
80
+ @user.policy(@team).resources_with_permission(:squads)
81
+ # ^^ which squad can I view within this team?
82
+ ```
83
+
84
+ #### 4. Authorizing resources
85
+
86
+ A common use case for these permissions is to authorize actions on a certain controller action.
87
+
88
+ Recurso provides a controller helper method to make this easy!
89
+
90
+ ```ruby
91
+ # teams_controller.rb
92
+ include Recurso::Controller
93
+
94
+ def show
95
+ authorize @team, :view?
96
+ end
97
+
98
+ def update
99
+ authorize @team, :modify?
100
+ end
101
+
102
+ def destroy
103
+ authorize @team, :administer?
104
+ end
105
+ ```
106
+
107
+ if an authorization fails, Recurso will throw a `Recurso::Forbidden` error, which you can handle as you see fit:
108
+ ```ruby
109
+ rescue_from(Recurso::Forbidden) { render json: { error: :forbidden }, status: 403 }
110
+ ```
111
+
112
+ The `Recurso::Controller` module also includes a `policy` shorthand method, which allows for easy permission checking.
113
+
114
+ ```ruby
115
+ include Recurso::Controller
116
+
117
+ private
118
+
119
+ # required: define a default identity, like the currently logged in user
120
+ def default_policy_identity
121
+ current_user
122
+ end
123
+
124
+ # optional: define a default resource, like the current resource (defaults to nil)
125
+ def default_policy_resource
126
+ current_resource
127
+ end
128
+ ```
129
+
130
+ This will be included as a helper method if included into a controller, so you can use it in the view as well:
131
+ ```
132
+ <%= if policy(@squad).modify? %>
133
+ <button>Edit squad</button>
134
+ <% end %>
135
+ ```
136
+
137
+ if no resource is passed, the `default_policy_resource` will be used
138
+ ```ruby
139
+ def default_policy_resource
140
+ @squad
141
+ end
142
+ ```
143
+ ```ruby
144
+ assert policy.modify? == policy(@squad).modify?
145
+ ```
146
+
147
+ #### 5. Enabling cascading permissions
148
+
149
+ One of the more powerful features of Recurso is to allow permissions to cascade between resources. So, for instance, if a user has administer access to a `Team`, they will also have administer access to all `Squad`s within that team.
150
+
151
+ In order to set this up, we need to defined the `relevant_association_names` on a resource
152
+
153
+ ```ruby
154
+ class Squad
155
+ include Recurso::Resource
156
+ belongs_to :team
157
+ has_one :organization, through: :team
158
+
159
+ def relevant_association_names
160
+ [:itself, :team, :organization]
161
+ end
162
+ end
163
+ ```
164
+ ^^ NB the use of the special association `:itself` there, which specifies that we should look to see if the user has permission to the Squad, in addition to its team and organization.
165
+
166
+ Now, calling the `view?`, `modify?`, or `administer?` methods on a squad's policy will also check for permissions (performantly!) on the relevant resources.
167
+
168
+ ```ruby
169
+ @user.policy(@squad).view?
170
+ # ^^ Does the user have view access to the squad, its team, or its organization?
171
+
172
+ @user.policy(@squad).modify?
173
+ # ^^ Does the user have modify access to the squad, its team, or its organization?
174
+ ```
175
+
176
+ #### 6. Applying permissions to a resource
177
+
178
+ Permissions are polymorphic to a resource; this means you can apply permissions to anything which has a `Recurso::Resource` concern applied to it. Doing so is as you'd expect:
179
+
180
+ ```ruby
181
+ @user.permissions.create(resource: @team, level: :admin)
182
+ # ^^ make this user an admin of this team
183
+
184
+ @user.permissions.create(resource: @organization, level: :editor)
185
+ # ^^ give this user editor righrs for this organization
186
+ ```
187
+
188
+ #### 7. Permission policies
189
+
190
+ Recurso has the concept of a 'default' permission level (`default` by default). This rights granted by this permission level can change based on the `policy_type` of the model in question.
191
+
192
+ There are three policy types available out of the box (this can be configured with options described below):
193
+
194
+ - Users with `default` permission on a resource that is `open` can `view` and `edit` content
195
+ - Users with `default` permission on a resource that is `closed` can `view` content
196
+ - Users with `default` permission on a resource that is `secret` can do neither
197
+
198
+ Permission policies will cascade upwards if a level is not set. For example:
199
+
200
+ ```ruby
201
+ class Team
202
+ def relevant_association_names
203
+ [:itself, :organization]
204
+ end
205
+ end
206
+ ```
207
+ ```ruby
208
+ @team = Team.create(organization: @organization, policy_type: nil)
209
+ @organization.update(policy_type: :closed)
210
+ @team.relevant_policy_type # => :closed
211
+ ```
212
+
213
+ #### 8. Configuration options
214
+
215
+ Recurso provides a set of granular configuration options to customize it to work the way you need.
216
+
217
+ An updated list, as well as all defaults can be viewed in `lib/recurso/config.rb`
218
+
219
+ **levels_for_action**:
220
+
221
+ A hash which maps which levels enable which actions. For instance, passing
222
+ ```ruby
223
+ {
224
+ view: [:viewer, :editor],
225
+ edit: [:editor]
226
+ }
227
+ ```
228
+ will create a system where users with `viewer` or `editor` permission may view a resource, and users with `editor` permission may edit a resource.
229
+
230
+ **actions_for_default**:
231
+
232
+ A hash which maps a `permission_policy` to actions that the default level can perform. For instance, passing
233
+ ```ruby
234
+ {
235
+ open: [:view, :edit],
236
+ readonly: [:view]
237
+ }
238
+ ```
239
+ will create a system where resources with a `permission_policy` of `open` allows default members to `view` and `edit`, while `readonly` resources will only allow members to `view`.
240
+
241
+ **levels**:
242
+
243
+ A list of valid levels which can be applied to your permissions
244
+
245
+ **default_level**:
246
+
247
+ The default value of the `permissions.level` column, and the one which will be affected by a resource's `permission_policy` (described above)
248
+
249
+ **identity_foreign_key**:
250
+
251
+ The foreign_key linking the permissions table to your identity table. By default, this is `identity_id`, but could easily be `user_id` or `person_id` depending on the existing columns in your database.
252
+
253
+ **permission_class_name**:
254
+
255
+ The name of the class that holds the permissions (defaults to `Permission`).
256
+
257
+ Both `identity_foreign_key` and `permission_class_name` accept lambdas. This is perfect if you want to support multiple models for authentication:
258
+
259
+ ```ruby
260
+ Recurso::Config.instance.permission_class_name = lambda do |model|
261
+ case model
262
+ when CustomIdentity then 'CustomPermission'
263
+ else 'Permission'
264
+ end
265
+ end
266
+
267
+ Recurso::Config.instance.identity_foreign_key = lambda do |model|
268
+ case model
269
+ when CustomIdentity then :identity_id
270
+ else :user_id
271
+ end
272
+ end
273
+ ```
274
+
275
+ ## Development
276
+
277
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
278
+
279
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
280
+
281
+ ## Contributing
282
+
283
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/recurso. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
284
+
285
+ ## License
286
+
287
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
288
+
289
+ ## Code of Conduct
290
+
291
+ Everyone interacting in the Recurso project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/gdpelican/recurso/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ require 'active_record'
2
+ require 'bundler/gem_tasks'
3
+ require 'rake/testtask'
4
+ require 'recurso'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ ActiveRecord::Base.establish_connection("sqlite3:test/support/db.sqlite3")
8
+ File.read('test/support/schema.sql').split(';').select { |sql| sql.strip.length > 0 }.each do |sql|
9
+ ActiveRecord::Base.connection.execute("#{sql};")
10
+ end
11
+
12
+ t.libs << "test"
13
+ t.libs << "lib"
14
+ t.test_files = FileList["test/**/*_test.rb"]
15
+ end
16
+
17
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "recurso"
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,12 @@
1
+ class CreatePermissionPolicies < ActiveRecord::Migration[5.2]
2
+ def up
3
+ if !table_exists?(:permission_policies)
4
+ create_table :permission_policies do |t|
5
+ t.belongs_to :resource, polymorphic: true, index: false
6
+ t.string :policy_type
7
+ t.timestamps
8
+ end
9
+ add_index :permission_policies, [:resource_type, :resource_id], unique: true
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ class CreatePermissions < ActiveRecord::Migration[5.2]
2
+ def up
3
+ if !table_exists?(:permissions)
4
+ create_table(:permissions) do |t|
5
+ t.belongs_to :resource, polymorphic: true
6
+ t.integer Recurso.identity_foreign_key
7
+ t.integer :level, default: 0
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :permissions, [:user_id]
12
+ add_index :permissions, [:resource_type, :resource_id]
13
+ add_index :permissions, [:user_id, :resource_type, :resource_id]
14
+ end
15
+ end
16
+ end
data/lib/recurso.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'recurso/config'
2
+ require 'recurso/version'
3
+ require 'recurso/concerns/identity'
4
+ require 'recurso/concerns/resource'
5
+ require 'recurso/concerns/permission'
6
+ require 'recurso/concerns/controller'
7
+ require 'recurso/models/permission'
8
+ require 'recurso/models/permission_policy'
9
+ require 'recurso/policies/base_policy'
10
+ require 'recurso/policies/nil_class_policy'
11
+ require 'recurso/policies/resource_policy'
12
+ require 'recurso/queries/relation'
13
+ require 'recurso/queries/single'
@@ -0,0 +1,33 @@
1
+ module Recurso
2
+ module Controller
3
+ class Recurso::Forbidden < StandardError; end
4
+
5
+ def self.included(base)
6
+ base.helper_method :policy if base.respond_to?(:helper_method)
7
+ end
8
+
9
+ def authorize(resource, action = default_authorize_action)
10
+ raise Recurso::Forbidden unless policy(resource).public_send(action)
11
+ end
12
+
13
+ def policy(resource = default_policy_resource)
14
+ return Recurso::NilClassPolicy.new unless default_policy_identity.present?
15
+
16
+ default_policy_identity.policy(resource)
17
+ end
18
+
19
+ private
20
+
21
+ def default_authorize_action
22
+ :view?
23
+ end
24
+
25
+ def default_policy_identity
26
+ raise NotImplentedError.new("Please define a default policy identity with Recurso::Controller")
27
+ end
28
+
29
+ def default_policy_resource
30
+ nil
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ module Recurso
2
+ module Identity
3
+ def self.included(base)
4
+ base.has_many :permissions,
5
+ dependent: :destroy,
6
+ class_name: Recurso::Config.instance.permission_class_name_for(base),
7
+ foreign_key: Recurso::Config.instance.identity_foreign_key_for(base)
8
+ end
9
+
10
+ def policy(resource = self)
11
+ (resource&.policy_class || Recurso::NilClassPolicy).new(self, resource)
12
+ end
13
+
14
+ def policy_class
15
+ Recurso::BasePolicy
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ module Recurso
2
+ module Permission
3
+ def self.included(base)
4
+ base.belongs_to :resource, polymorphic: true
5
+
6
+ base.enum level: [:member, :admin, :viewer, :editor, :guest]
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,57 @@
1
+ module Recurso
2
+ module Resource
3
+
4
+ def self.included(base)
5
+ base.has_many :permissions, as: :resource, dependent: :destroy, class_name: Recurso::Config.instance.default_permission_class_name
6
+ base.has_one :permission_policy, as: :resource, autosave: true
7
+ base.belongs_to :itself, class_name: base.to_s, foreign_key: :id, required: false
8
+
9
+ def base.relevant_associations
10
+ relevant_association_names.map(&method(:reflect_on_association))
11
+ end
12
+
13
+ def base.relevant_association_names
14
+ [:itself]
15
+ end
16
+ end
17
+
18
+ def policy_type
19
+ permission_policy&.policy_type
20
+ end
21
+
22
+ def policy_type=(value)
23
+ if permission_policy
24
+ permission_policy.policy_type = value
25
+ else
26
+ build_permission_policy(policy_type: value)
27
+ end
28
+ end
29
+
30
+ def policy_class
31
+ "#{self.class}Policy".constantize
32
+ rescue NameError
33
+ Recurso::ResourcePolicy
34
+ end
35
+
36
+ def relevant_policy_type
37
+ (relevant_resources.map(&:policy_type).detect(&:present?) || :open).to_sym
38
+ end
39
+
40
+ def relevant_resources
41
+ @relevant_resources ||= self.class.relevant_association_names.map(&method(:public_send)).compact.uniq
42
+ end
43
+
44
+ def relevant_levels_for(action)
45
+ [
46
+ Recurso::Config.instance.levels_for_action[action],
47
+ (Recurso::Config.instance.default_level if default_can?(action))
48
+ ].flatten.compact
49
+ end
50
+
51
+ private
52
+
53
+ def default_can?(action)
54
+ Recurso::Config.instance.actions_for_default[relevant_policy_type].include?(action)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ module Recurso
2
+ class Config
3
+ include Singleton
4
+
5
+ DEFAULT_IDENTITY_FOREIGN_KEY = :identity_id
6
+
7
+ DEFAULT_PERMISSION_CLASS_NAME = 'Permission'
8
+
9
+ attr_accessor :levels_for_action,
10
+ :actions_for_default,
11
+ :levels,
12
+ :default_level,
13
+ :identity_foreign_key,
14
+ :permission_class_name,
15
+ :default_permission_class_name
16
+
17
+ def initialize
18
+ @levels_for_action = {
19
+ view: [:admin, :editor, :viewer],
20
+ modify: [:admin, :editor],
21
+ administer: [:admin],
22
+ }
23
+
24
+ @actions_for_default = {
25
+ open: [:view, :modify],
26
+ closed: [:view],
27
+ secret: [],
28
+ }
29
+
30
+ @levels = [:member, :admin, :editor, :viewer]
31
+
32
+ @default_level = :member
33
+
34
+ @identity_foreign_key = DEFAULT_IDENTITY_FOREIGN_KEY
35
+
36
+ @permission_class_name = DEFAULT_PERMISSION_CLASS_NAME
37
+ end
38
+
39
+ def model_specific(value, model)
40
+ if value.respond_to?(:call)
41
+ value.call(model)
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ def identity_foreign_key_for(model)
48
+ model_specific(identity_foreign_key, model) || DEFAULT_IDENTITY_FOREIGN_KEY
49
+ end
50
+
51
+ def permission_class_name_for(identity_model)
52
+ model_specific(permission_class_name, identity_model) || DEFAULT_PERMISSION_CLASS_NAME
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ require 'active_record'
2
+
3
+ class Permission < ActiveRecord::Base
4
+ include Recurso::Permission
5
+
6
+ belongs_to :user
7
+ end
@@ -0,0 +1,6 @@
1
+ require 'active_record'
2
+
3
+ class PermissionPolicy < ActiveRecord::Base
4
+ belongs_to :resource, polymorphic: true
5
+ validates :resource_id, uniqueness: { scope: [:resource_id, :resource_type] }
6
+ end
@@ -0,0 +1,4 @@
1
+ module Recurso
2
+ class BasePolicy < Struct.new(:identity, :resource)
3
+ end
4
+ end
@@ -0,0 +1,7 @@
1
+ module Recurso
2
+ class NilClassPolicy < BasePolicy
3
+ def method_missing(*_args)
4
+ false
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ module Recurso
2
+ class ResourcePolicy < BasePolicy
3
+ def method_missing(method)
4
+ action = method.to_s.sub('?', '').to_sym
5
+ super unless Recurso::Config.instance.levels_for_action.keys.include?(action)
6
+
7
+ Recurso::Queries::Single.new(identity, resource, action).permission?
8
+ end
9
+
10
+ def resources_with_permission(relation_name, all_columns: true, include_actions: [:modify, :administer])
11
+ include_actions.reduce(resource_query_for(relation_name, :view, all_columns: all_columns)) do |resources, action|
12
+ resources
13
+ .joins("LEFT OUTER JOIN(#{resource_query_for(relation_name, action).to_sql}) AS #{action} ON #{action}.id = #{relation_name}.id")
14
+ .select("#{action}.id IS NOT NULL AS can_#{action}")
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def resource_query_for(relation_name, action, all_columns: false)
21
+ Recurso::Queries::Relation.new(
22
+ identity,
23
+ resource,
24
+ relation_name,
25
+ all_columns: all_columns,
26
+ action: action
27
+ ).resources
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,66 @@
1
+ module Recurso
2
+ module Queries
3
+ class Relation
4
+
5
+ def initialize(identity, resource, relation_name, all_columns: true, action: :view)
6
+ @identity = identity
7
+ @resource = resource
8
+ @relation = resource.send(relation_name)
9
+ @all_columns = all_columns
10
+ @action = action
11
+ end
12
+
13
+ def resources
14
+ @resources ||= join_permissions
15
+ .select("#{@relation.table_name}.#{@all_columns ? :* : :id}")
16
+ .where(coalesce(@action))
17
+ .distinct
18
+ end
19
+
20
+ private
21
+
22
+ def join_permissions
23
+ @relation.relevant_associations.reduce(@relation) do |result, assoc|
24
+ result.joins(through_join_for(assoc)).joins("
25
+ LEFT OUTER JOIN #{permission_class.table_name} #{assoc.name}_permissions
26
+ ON #{assoc.name}_permissions.resource_type = '#{assoc.class_name}'
27
+ AND #{assoc.name}_permissions.resource_id = #{resource_id_for(assoc)}
28
+ AND #{assoc.name}_permissions.#{identity_foreign_key} = #{@identity.id.to_i}
29
+ ")
30
+ end
31
+ end
32
+
33
+ def resource_id_for(assoc)
34
+ "#{(assoc.through_reflection || assoc.active_record).table_name}.#{assoc.foreign_key}"
35
+ end
36
+
37
+ def through_join_for(assoc)
38
+ return unless through = assoc.through_reflection
39
+ "
40
+ LEFT OUTER JOIN #{through.table_name}
41
+ ON #{through.table_name}.#{through.association_primary_key} = #{resource_id_for(through)}
42
+ "
43
+ end
44
+
45
+ def coalesce(action)
46
+ "coalesce(#{level_columns}) IN (#{level_values(action)})"
47
+ end
48
+
49
+ def level_columns
50
+ @relation.relevant_associations.map { |assoc| "#{assoc.name}_permissions.level" }.join(',')
51
+ end
52
+
53
+ def level_values(action)
54
+ @resource.relevant_levels_for(action).map { |level| permission_class.levels[level] }.join(',')
55
+ end
56
+
57
+ def permission_class
58
+ @permission_class ||= Recurso::Config.instance.permission_class_name_for(@identity.class).constantize
59
+ end
60
+
61
+ def identity_foreign_key
62
+ @identity_foreign_key ||= Recurso::Config.instance.identity_foreign_key_for(@identity.class)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,12 @@
1
+ module Recurso
2
+ module Queries
3
+ Single = Struct.new(:identity, :resource, :action) do
4
+ def permission?
5
+ @permission ||= identity.permissions.exists?(
6
+ resource: resource.relevant_resources,
7
+ level: resource.relevant_levels_for(action)
8
+ )
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ module Recurso
2
+ VERSION = "0.5.3"
3
+ end
data/recurso.gemspec ADDED
@@ -0,0 +1,42 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "recurso/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "recurso"
8
+ spec.version = Recurso::VERSION
9
+ spec.authors = ["James Kiesel"]
10
+ spec.email = ["james.kiesel@gmail.com"]
11
+
12
+ spec.summary = "Easy cascading permissions"
13
+ spec.homepage = "https://www.github.com/optimalworkshop/recurso"
14
+ spec.license = "MIT"
15
+
16
+ if spec.respond_to?(:metadata)
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://www.github.com/optimalworkshop/recurso"
19
+ else
20
+ raise "RubyGems 2.0 or newer is required to protect against " \
21
+ "public gem pushes."
22
+ end
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "activerecord", ">= 5.2", "< 7"
34
+
35
+ spec.add_development_dependency "shoulda-context", "~> 1.2"
36
+ spec.add_development_dependency "sqlite3", "~> 1.4"
37
+ spec.add_development_dependency "factory_bot", "~> 5.1"
38
+ spec.add_development_dependency "faker", "~> 2.6"
39
+ spec.add_development_dependency "rake", "~> 11.2"
40
+ spec.add_development_dependency "minitest-reporters", "~> 1.4"
41
+ spec.add_development_dependency "byebug", "~> 11.0"
42
+ end
metadata ADDED
@@ -0,0 +1,189 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: recurso
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.3
5
+ platform: ruby
6
+ authors:
7
+ - James Kiesel
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-07-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '7'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '7'
33
+ - !ruby/object:Gem::Dependency
34
+ name: shoulda-context
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.2'
47
+ - !ruby/object:Gem::Dependency
48
+ name: sqlite3
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.4'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.4'
61
+ - !ruby/object:Gem::Dependency
62
+ name: factory_bot
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.1'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.1'
75
+ - !ruby/object:Gem::Dependency
76
+ name: faker
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.6'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.6'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rake
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '11.2'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '11.2'
103
+ - !ruby/object:Gem::Dependency
104
+ name: minitest-reporters
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.4'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.4'
117
+ - !ruby/object:Gem::Dependency
118
+ name: byebug
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '11.0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '11.0'
131
+ description:
132
+ email:
133
+ - james.kiesel@gmail.com
134
+ executables: []
135
+ extensions: []
136
+ extra_rdoc_files: []
137
+ files:
138
+ - ".gitignore"
139
+ - ".semaphore/semaphore.yml"
140
+ - CODE_OF_CONDUCT.md
141
+ - Gemfile
142
+ - LICENSE.txt
143
+ - README.md
144
+ - Rakefile
145
+ - bin/console
146
+ - bin/setup
147
+ - db/migrate/create_permission_policies.rb
148
+ - db/migrate/create_permissions.rb
149
+ - lib/recurso.rb
150
+ - lib/recurso/concerns/controller.rb
151
+ - lib/recurso/concerns/identity.rb
152
+ - lib/recurso/concerns/permission.rb
153
+ - lib/recurso/concerns/resource.rb
154
+ - lib/recurso/config.rb
155
+ - lib/recurso/models/permission.rb
156
+ - lib/recurso/models/permission_policy.rb
157
+ - lib/recurso/policies/base_policy.rb
158
+ - lib/recurso/policies/nil_class_policy.rb
159
+ - lib/recurso/policies/resource_policy.rb
160
+ - lib/recurso/queries/relation.rb
161
+ - lib/recurso/queries/single.rb
162
+ - lib/recurso/version.rb
163
+ - recurso.gemspec
164
+ homepage: https://www.github.com/optimalworkshop/recurso
165
+ licenses:
166
+ - MIT
167
+ metadata:
168
+ homepage_uri: https://www.github.com/optimalworkshop/recurso
169
+ source_code_uri: https://www.github.com/optimalworkshop/recurso
170
+ post_install_message:
171
+ rdoc_options: []
172
+ require_paths:
173
+ - lib
174
+ required_ruby_version: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ required_rubygems_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: '0'
184
+ requirements: []
185
+ rubygems_version: 3.0.3
186
+ signing_key:
187
+ specification_version: 4
188
+ summary: Easy cascading permissions
189
+ test_files: []