rbacan 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,81 +1,332 @@
1
- # Rbacan
1
+ # RBACan
2
2
 
3
- a Role-based access control tool to control user access to the functionnalities of your application
3
+ Role-Based Access Control for Rails. Assign roles to users, attach permissions to roles, and enforce access at the controller, view, and route level.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Installation](#installation)
8
+ - [Setup](#setup)
9
+ - [Configuration](#configuration)
10
+ - [Defining Roles and Permissions](#defining-roles-and-permissions)
11
+ - [Role Management](#role-management)
12
+ - [Checking Permissions](#checking-permissions)
13
+ - [Querying Users by Role or Permission](#querying-users-by-role-or-permission)
14
+ - [Controller Authorization](#controller-authorization)
15
+ - [View Helpers](#view-helpers)
16
+ - [Route Constraints](#route-constraints)
17
+ - [Development](#development)
18
+ - [Contributing](#contributing)
19
+ - [License](#license)
20
+
21
+ ---
4
22
 
5
23
  ## Installation
6
24
 
7
- Add this line to your application's Gemfile:
25
+ Add to your Gemfile:
8
26
 
9
27
  ```ruby
10
28
  gem 'rbacan'
11
29
  ```
12
30
 
13
- And then execute:
31
+ Then run:
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Setup
40
+
41
+ **1. Run the generator**
42
+
43
+ ```bash
44
+ rails generate rbacan:install
45
+ ```
46
+
47
+ This copies four migrations, a seed helper file, and an initializer into your app.
48
+
49
+ **2. Migrate**
50
+
51
+ ```bash
52
+ rails db:migrate
53
+ ```
54
+
55
+ **3. Include the concern in your user model**
56
+
57
+ ```ruby
58
+ class User < ApplicationRecord
59
+ include Rbacan::Permittable
60
+ end
61
+ ```
62
+
63
+ **4. Define your roles and permissions in `db/seeds.rb`**
64
+
65
+ Open `db/copy_to_seeds.rb` (created by the generator) and copy its contents into your `db/seeds.rb`. Fill in your roles and permissions, then run:
66
+
67
+ ```bash
68
+ rails db:seed
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Configuration
74
+
75
+ The generator creates `config/initializers/rbacan.rb`. All options are optional — the defaults work for a standard Devise setup.
76
+
77
+ ```ruby
78
+ Rbacan.configure do |config|
79
+ # Your user model name (default: "User")
80
+ config.permittable_class = "User"
81
+
82
+ # Override model class names if you have custom implementations
83
+ # config.role_class = "Rbacan::Role"
84
+ # config.permission_class = "Rbacan::Permission"
85
+ # config.user_role_class = "Rbacan::UserRole"
86
+ # config.role_permission_class = "Rbacan::RolePermission"
87
+
88
+ # Override table names if needed
89
+ # config.role_table = "roles"
90
+ # config.permission_table = "permissions"
91
+ # config.user_role_table = "user_roles"
92
+ # config.role_permission_table = "role_permissions"
93
+
94
+ # How to handle unauthorized access (see Controller Authorization)
95
+ # config.unauthorized_handler = :raise # default — raises Rbacan::NotAuthorized
96
+ # config.unauthorized_handler = :redirect # redirects to unauthorized_redirect_path
97
+ # config.unauthorized_handler = ->(controller, permission:, role:) {
98
+ # controller.render plain: "Forbidden", status: :forbidden
99
+ # }
100
+
101
+ # Redirect path used when unauthorized_handler is :redirect (default: "/")
102
+ # config.unauthorized_redirect_path = "/login"
103
+ end
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Defining Roles and Permissions
109
+
110
+ Use the `Rbacan::RolesAndPermissions` module in your seeds. All methods are idempotent — safe to run multiple times.
111
+
112
+ ```ruby
113
+ # db/seeds.rb
114
+
115
+ roles = ["admin", "moderator", "viewer"]
116
+ permissions = ["edit_post", "delete_post", "publish_post", "view_dashboard"]
117
+
118
+ Rbacan::RolesAndPermissions.create_roles(roles)
119
+ Rbacan::RolesAndPermissions.create_permissions(permissions)
120
+
121
+ # Assign permissions to each role
122
+ Rbacan::RolesAndPermissions.assign_permissions_to_role("admin", permissions)
123
+ Rbacan::RolesAndPermissions.assign_permissions_to_role("moderator", ["edit_post", "publish_post"])
124
+ Rbacan::RolesAndPermissions.assign_permissions_to_role("viewer", ["view_dashboard"])
125
+ ```
126
+
127
+ You can also create roles and permissions programmatically at runtime:
128
+
129
+ ```ruby
130
+ Rbacan.create_role("editor")
131
+ Rbacan.create_permission("manage_comments")
132
+ Rbacan.assign_permission_to_role("editor", "manage_comments")
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Role Management
138
+
139
+ ```ruby
140
+ user = User.find(1)
141
+
142
+ # Assign a role — idempotent, safe to call multiple times
143
+ user.assign_role("admin")
144
+ user.assign_role(:moderator)
145
+
146
+ # Remove a role
147
+ user.remove_role("moderator")
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Checking Permissions
153
+
154
+ ### On a single permission
155
+
156
+ ```ruby
157
+ user.can?("edit_post") # => true or false
158
+ user.can?(:edit_post) # symbols work too
159
+ ```
160
+
161
+ ### Checking all permissions at once
162
+
163
+ ```ruby
164
+ # Returns true only if the user has every listed permission
165
+ user.can_all?(:edit_post, :delete_post, :publish_post)
166
+ ```
167
+
168
+ ### Checking roles
14
169
 
15
- $ bundle
170
+ ```ruby
171
+ # Does the user have this specific role?
172
+ user.has_role?(:admin)
173
+
174
+ # Does the user have at least one of these roles?
175
+ user.has_any_role?(:admin, :moderator)
176
+ ```
16
177
 
17
- Or install it yourself as:
178
+ ---
18
179
 
19
- $ gem install rbacan
180
+ ## Querying Users by Role or Permission
20
181
 
21
- ## Usage
182
+ Use these ActiveRecord scopes to query your user table.
22
183
 
23
184
  ```ruby
24
- run rails generate rbacan:install
185
+ # All users with the admin role
186
+ User.with_role(:admin)
187
+
188
+ # All users who have a given permission (via any of their roles)
189
+ User.with_permission(:publish_post)
190
+
191
+ # Combine with other scopes
192
+ User.with_role(:moderator).where(active: true)
25
193
  ```
26
194
 
27
- copy the content in the generated file db/copy_to_seed.rb in your seeds.rb file
28
- you have there all the tools you need to create you roles and permissions
195
+ ---
196
+
197
+ ## Controller Authorization
29
198
 
30
- You might need to configure you user class_name if you're using a different class_name for the user, go to rbacan.rb in your initializers folder and make your change
199
+ Include `Rbacan::Authorization` in your `ApplicationController` (or any specific controller):
31
200
 
32
201
  ```ruby
33
- Rbacan.configure do |config|
34
- config.permittable_class = "YOUR USER CLASS_NAME"
35
- end
202
+ class ApplicationController < ActionController::Base
203
+ include Rbacan::Authorization
204
+ end
36
205
  ```
37
206
 
38
- if you want to assign a role to a user it is simple you just have to do so:
207
+ Then use `authorize!` or `authorize_role!` as `before_action` callbacks:
39
208
 
40
- user = current_user
209
+ ```ruby
210
+ class PostsController < ApplicationController
211
+ before_action -> { authorize!(:edit_post) }, only: [:edit, :update]
212
+ before_action -> { authorize!(:delete_post) }, only: [:destroy]
213
+ before_action -> { authorize_role!(:admin) }, only: [:admin_index]
214
+ end
215
+ ```
41
216
 
42
- user.assign_role(role_name)
217
+ Or call them directly inside an action:
43
218
 
44
- to remove a role from user do this:
219
+ ```ruby
220
+ def destroy
221
+ authorize!(:delete_post)
222
+ @post.destroy
223
+ end
224
+ ```
45
225
 
46
- user.remove_role(role_name)
226
+ ### Handling unauthorized access
47
227
 
48
- now when you want to test if a user have access to a functionnality use this:
228
+ The default behavior raises `Rbacan::NotAuthorized`. You can rescue it globally:
49
229
 
50
- user.can?(permission_name)
230
+ ```ruby
231
+ # app/controllers/application_controller.rb
232
+ rescue_from Rbacan::NotAuthorized, with: :handle_unauthorized
51
233
 
52
- add this line to your user model:
234
+ private
53
235
 
54
- include Rbacan::Permittable
236
+ def handle_unauthorized(exception)
237
+ render plain: exception.message, status: :forbidden
238
+ end
239
+ ```
55
240
 
56
- run:
241
+ Or configure a different handler in the initializer:
57
242
 
58
243
  ```ruby
59
- rails db:migrate
60
- rails db:seed
244
+ # Redirect to a path instead of raising
245
+ config.unauthorized_handler = :redirect
246
+ config.unauthorized_redirect_path = "/login"
247
+
248
+ # Or use a fully custom lambda
249
+ config.unauthorized_handler = ->(controller, permission:, role:) {
250
+ controller.render json: { error: "Forbidden" }, status: :forbidden
251
+ }
61
252
  ```
62
253
 
63
- enjoy :D
254
+ ---
255
+
256
+ ## View Helpers
257
+
258
+ `Rbacan::ViewHelpers` is automatically included in all views. Use `authorized?` to conditionally render content:
259
+
260
+ ```erb
261
+ <% authorized?(:delete_post) do %>
262
+ <%= link_to "Delete", post_path(@post), data: { turbo_method: :delete } %>
263
+ <% end %>
264
+
265
+ <% authorized?(:publish_post) do %>
266
+ <%= button_to "Publish", publish_post_path(@post) %>
267
+ <% end %>
268
+ ```
269
+
270
+ You can also use `has_role?` and `has_any_role?` directly in views since they are instance methods on the user:
271
+
272
+ ```erb
273
+ <% if current_user.has_role?(:admin) %>
274
+ <%= link_to "Admin Panel", admin_root_path %>
275
+ <% end %>
276
+
277
+ <% if current_user.has_any_role?(:admin, :moderator) %>
278
+ <%= link_to "Moderation Queue", moderation_path %>
279
+ <% end %>
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Route Constraints
285
+
286
+ Restrict access to entire route namespaces based on role or permission. Add constraints in `config/routes.rb`:
287
+
288
+ ```ruby
289
+ # Restrict by role
290
+ constraints Rbacan::RouteConstraint.new(role: :admin) do
291
+ namespace :admin do
292
+ resources :users
293
+ resources :roles
294
+ end
295
+ end
296
+
297
+ # Restrict by permission
298
+ constraints Rbacan::RouteConstraint.new(permission: :access_dashboard) do
299
+ get "/dashboard", to: "dashboard#index"
300
+ end
301
+ ```
302
+
303
+ The constraint reads the current user from the Warden session (used by Devise). For apps using a manual session, it falls back to looking up `session[:user_id]`.
304
+
305
+ ---
64
306
 
65
307
  ## Development
66
308
 
67
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
309
+ ```bash
310
+ bin/setup # install dependencies
311
+ bundle exec rake spec # run all tests
312
+ bundle exec rspec spec/rbacan_spec.rb # run a specific file
313
+ bundle exec rake install # install gem locally
314
+ ```
68
315
 
69
- 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).
316
+ To release a new version:
70
317
 
71
- ## Contributing
318
+ 1. Bump the version in `lib/rbacan/version.rb`
319
+ 2. `gem build rbacan.gemspec`
320
+ 3. `gem push rbacan-<version>.gem`
72
321
 
73
- Bug reports and pull requests are welcome on GitHub at https://github.com/hamdi777/RBACan. 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.
322
+ ---
74
323
 
75
- ## License
324
+ ## Contributing
76
325
 
77
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
326
+ Bug reports and pull requests are welcome on GitHub at https://github.com/hamdi777/RBACan.
78
327
 
79
- ## Code of Conduct
328
+ ---
329
+
330
+ ## License
80
331
 
81
- Everyone interacting in the Rbacan project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/rbacan/blob/master/CODE_OF_CONDUCT.md).
332
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,6 +1,7 @@
1
1
  module Rbacan
2
2
  class UserRole < ApplicationRecord
3
3
  self.table_name = Rbacan.user_role_table
4
+
4
5
  belongs_to :role, class_name: Rbacan.role_class
5
6
  belongs_to :user, class_name: Rbacan.permittable_class
6
7
  end
@@ -1,26 +1,22 @@
1
- #Copy the content of this file into your seeds.rb and comment what you don't need
1
+ # Copy the content of this file into your seeds.rb and uncomment what you need.
2
2
 
3
-
4
- # define the roles you are going to use example: roles = ["support", "carrier", "mid lane :D", "bot lane :p"]
3
+ # Define the roles you want, e.g.:
4
+ # roles = ["admin", "moderator", "viewer"]
5
5
  roles = []
6
- # create roles
7
6
  Rbacan::RolesAndPermissions.create_roles(roles)
8
7
 
9
8
 
10
- # define the permissions you are going to use example: permissions = ["fire", "invoke", "fly"]
9
+ # Define the permissions you want, e.g.:
10
+ # permissions = ["edit_post", "delete_post", "publish_post"]
11
11
  permissions = []
12
- # create permissions
13
12
  Rbacan::RolesAndPermissions.create_permissions(permissions)
14
13
 
15
14
 
16
- # now assign some permissions to each role
17
- # to do that you need to define an array of the permissions you want to assign example:
18
- # role_permissions = ["fly", "fire"]
19
- role_permissions = []
20
- Rbacan::RolesAndPermissions.assign_permissions_to_role(role_name, role_permissions)
21
- # example Rbacan::RolesAndPermissions.assign_permissions_to_role("mid lane :D", role_permissions)
22
- # you can even define an array of many roles and then do :
23
- # roles.each do |role|
24
- # role_name = role.name
25
- # Rbacan::RolesAndPermissions.assign_permissions_to_role(role_name, role_permissions)
26
- # end
15
+ # Assign permissions to a role. Repeat this block for each role you want to configure.
16
+ # Replace "admin" with the role name, and list the permissions it should have.
17
+ #
18
+ # role_permissions = ["edit_post", "delete_post", "publish_post"]
19
+ # Rbacan::RolesAndPermissions.assign_permissions_to_role("admin", role_permissions)
20
+ #
21
+ # To assign all permissions to a role:
22
+ # Rbacan::RolesAndPermissions.assign_permissions_to_role("admin", permissions)
@@ -1,9 +1,11 @@
1
- class CreatePermissions < ActiveRecord::Migration[5.2]
2
- def change
3
- create_table :permissions do |t|
4
- t.string :name
5
-
6
- t.timestamps
7
- end
1
+ class CreatePermissions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :permissions do |t|
4
+ t.string :name, null: false
5
+
6
+ t.timestamps
8
7
  end
8
+
9
+ add_index :permissions, :name, unique: true
10
+ end
9
11
  end
@@ -1,9 +1,12 @@
1
- class CreateRolePermissions < ActiveRecord::Migration[5.2]
2
- def change
3
- create_table :role_permissions do |t|
4
- t.references :role, index: true, foreign_key: {on_delete: :cascade}
5
- t.references :permission, index: true, foreign_key: {on_delete: :cascade}
6
- t.timestamps
7
- end
1
+ class CreateRolePermissions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :role_permissions do |t|
4
+ t.references :role, null: false, index: true, foreign_key: { to_table: :roles, on_delete: :cascade }
5
+ t.references :permission, null: false, index: true, foreign_key: { to_table: :permissions, on_delete: :cascade }
6
+
7
+ t.timestamps
8
8
  end
9
+
10
+ add_index :role_permissions, [:role_id, :permission_id], unique: true
11
+ end
9
12
  end
@@ -1,9 +1,11 @@
1
- class CreateRoles < ActiveRecord::Migration[5.2]
2
- def change
3
- create_table :roles do |t|
4
- t.string :name
5
-
6
- t.timestamps
7
- end
1
+ class CreateRoles < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :roles do |t|
4
+ t.string :name, null: false
5
+
6
+ t.timestamps
8
7
  end
8
+
9
+ add_index :roles, :name, unique: true
10
+ end
9
11
  end
@@ -1,10 +1,13 @@
1
- class CreateUserRoles < ActiveRecord::Migration[5.2]
2
- def change
3
- create_table :user_roles do |t|
4
- t.references :role, index: true, foreign_key: {on_delete: :cascade}
5
- t.references :user, index: true, foreign_key: {on_delete: :cascade}
6
-
7
- t.timestamps
8
- end
1
+ class CreateUserRoles < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :user_roles do |t|
4
+ t.references :role, null: false, index: true, foreign_key: { to_table: :roles, on_delete: :cascade }
5
+ t.bigint :user_id, null: false
6
+
7
+ t.timestamps
9
8
  end
9
+
10
+ add_index :user_roles, :user_id
11
+ add_index :user_roles, [:user_id, :role_id], unique: true
12
+ end
10
13
  end
@@ -1,3 +1,30 @@
1
1
  Rbacan.configure do |config|
2
+ # The name of your user model (default: "User")
2
3
  # config.permittable_class = "User"
4
+
5
+ # Override AR model class names if you have custom models
6
+ # config.role_class = "Rbacan::Role"
7
+ # config.permission_class = "Rbacan::Permission"
8
+ # config.user_role_class = "Rbacan::UserRole"
9
+ # config.role_permission_class = "Rbacan::RolePermission"
10
+
11
+ # Override table names if needed
12
+ # config.role_table = "roles"
13
+ # config.permission_table = "permissions"
14
+ # config.user_role_table = "user_roles"
15
+ # config.role_permission_table = "role_permissions"
16
+
17
+ # Authorization failure handling:
18
+ # :raise — raises Rbacan::NotAuthorized (default)
19
+ # :redirect — redirects to unauthorized_redirect_path
20
+ # lambda — called with (controller, permission:, role:)
21
+ #
22
+ # config.unauthorized_handler = :raise
23
+ # config.unauthorized_handler = :redirect
24
+ # config.unauthorized_handler = ->(controller, permission:, role:) {
25
+ # controller.render plain: "Forbidden", status: :forbidden
26
+ # }
27
+
28
+ # Path to redirect to when unauthorized_handler is :redirect
29
+ # config.unauthorized_redirect_path = "/"
3
30
  end
@@ -0,0 +1,46 @@
1
+ require 'active_support/concern'
2
+
3
+ module Rbacan
4
+ module Authorization
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ helper_method :authorized? if respond_to?(:helper_method)
9
+ end
10
+
11
+ # Calls the unauthorized handler if current_user lacks the given permission.
12
+ def authorize!(permission)
13
+ unless current_user&.can?(permission.to_s)
14
+ _handle_unauthorized(permission: permission.to_s)
15
+ end
16
+ end
17
+
18
+ # Calls the unauthorized handler if current_user lacks the given role.
19
+ def authorize_role!(role)
20
+ unless current_user&.has_role?(role.to_s)
21
+ _handle_unauthorized(role: role.to_s)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def _handle_unauthorized(permission: nil, role: nil)
28
+ handler = Rbacan.unauthorized_handler
29
+
30
+ case handler
31
+ when :raise
32
+ raise Rbacan::NotAuthorized.new(nil, permission: permission, role: role)
33
+ when :redirect
34
+ redirect_to Rbacan.unauthorized_redirect_path,
35
+ alert: Rbacan::NotAuthorized.new(nil, permission: permission, role: role).message
36
+ else
37
+ if handler.respond_to?(:call)
38
+ handler.call(self, permission: permission, role: role)
39
+ else
40
+ raise ArgumentError,
41
+ "Rbacan.unauthorized_handler must be :raise, :redirect, or a callable. Got: #{handler.inspect}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/rbacan/engine.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  module Rbacan
2
- require "rails/all"
3
- class Engine < ::Rails::Engine
4
- engine_name 'rbacan'
2
+ require "rails/all"
3
+ class Engine < ::Rails::Engine
4
+ engine_name 'rbacan'
5
+
6
+ initializer "rbacan.view_helpers" do
7
+ ActiveSupport.on_load(:action_view) do
8
+ include Rbacan::ViewHelpers
9
+ end
5
10
  end
11
+ end
6
12
  end
@@ -0,0 +1,23 @@
1
+ module Rbacan
2
+ class NotAuthorized < StandardError
3
+ attr_reader :permission, :role
4
+
5
+ def initialize(message = nil, permission: nil, role: nil)
6
+ @permission = permission
7
+ @role = role
8
+ super(message || default_message)
9
+ end
10
+
11
+ private
12
+
13
+ def default_message
14
+ if permission
15
+ "Not authorized: missing permission '#{permission}'"
16
+ elsif role
17
+ "Not authorized: missing role '#{role}'"
18
+ else
19
+ "Not authorized"
20
+ end
21
+ end
22
+ end
23
+ end