role_fu 0.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -1
- data/README.md +148 -36
- data/lib/generators/role_fu/abilities_generator.rb +51 -0
- data/lib/generators/role_fu/audit_generator.rb +25 -0
- data/lib/generators/role_fu/job_generator.rb +15 -0
- data/lib/generators/role_fu/templates/abilities_migration.rb.erb +15 -0
- data/lib/generators/role_fu/templates/audit_migration.rb.erb +23 -0
- data/lib/generators/role_fu/templates/audit_model.rb.erb +7 -0
- data/lib/generators/role_fu/templates/cleanup_job.rb.erb +12 -0
- data/lib/generators/role_fu/templates/migration.rb.erb +2 -0
- data/lib/generators/role_fu/templates/permission.rb.erb +5 -0
- data/lib/role_fu/ability.rb +23 -0
- data/lib/role_fu/adapters/cancancan.rb +29 -0
- data/lib/role_fu/adapters/pundit.rb +28 -0
- data/lib/role_fu/cleanup.rb +16 -0
- data/lib/role_fu/configuration.rb +2 -1
- data/lib/role_fu/permission.rb +12 -0
- data/lib/role_fu/railtie.rb +11 -0
- data/lib/role_fu/resourceable.rb +35 -69
- data/lib/role_fu/role_assignment.rb +37 -0
- data/lib/role_fu/roleable.rb +94 -120
- data/lib/role_fu/version.rb +2 -2
- data/lib/role_fu.rb +19 -1
- data/lib/tasks/role_fu.rake +9 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a8521c0afe782f8985577cf76d9b59b950522b1c445150a11db74e7f944718b
|
|
4
|
+
data.tar.gz: 0a82d9b19260c3ea95b2506cb45ad2e12ab23b64bdb0e3b1c0b84c8f93d8061f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 386f524a75c3116601af67eaf6612c0bc23893695120a2646ba6d0caafba8bcf7e0bc12dc05d93dc7ddcbc9c508da936cfebf679d973675f5b9742438ed85c50
|
|
7
|
+
data.tar.gz: d9db11c9b4490d35714acb1f20a4eb576a53584fd235bf0328ca33959f53756221478e5af877dfec5833f1431ccdde9fafded39980d5f90ce87e995dfef20695
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-02-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Temporal Roles**: Support for role expiration (`expires_at`) and automatic filtering.
|
|
7
|
+
- **Audit Logging**: Built-in generator for audit trails (`RoleAssignmentAudit`) and actor tracking (`RoleFu.with_actor`).
|
|
8
|
+
- **Role Abilities**: Granular permissions system with `Permission` model and `role_fu_can?` helper.
|
|
9
|
+
- **Metadata**: Support for attaching arbitrary JSON metadata to role assignments.
|
|
10
|
+
- **Adapters**: Seamless integration with **Pundit** and **CanCanCan**.
|
|
11
|
+
- **Permissive Mode**: Configuration option `global_roles_override` to match Rolify behavior.
|
|
12
|
+
- **Cleanup Automation**: `rake role_fu:cleanup` task and ActiveJob generator for expired roles.
|
|
13
|
+
- **Custom Aliases**: `role_fu_alias` to generate domain-specific methods (e.g., `add_group`, `in_group`).
|
|
14
|
+
|
|
3
15
|
## [0.1.0] - 2026-02-03
|
|
4
16
|
|
|
5
|
-
- Initial release: modern role management for Rails with explicit models and N+1 prevention.
|
|
17
|
+
- Initial release: modern role management for Rails with explicit models and N+1 prevention.
|
data/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# RoleFu
|
|
2
2
|
|
|
3
|
-
RoleFu is a modern, explicit role management gem for Ruby on Rails. It is designed as a cleaner, more performant alternative to
|
|
3
|
+
RoleFu is a modern, explicit role management gem for Ruby on Rails. It is designed as a cleaner, more performant alternative to legacy role gems, providing full control over role assignments and granular permissions.
|
|
4
4
|
|
|
5
5
|
## Why RoleFu?
|
|
6
6
|
|
|
7
|
-
- **Explicit Models**:
|
|
8
|
-
- **N+1 Prevention**: Built-in support for `has_cached_role?`
|
|
9
|
-
- **Strict by Default**:
|
|
10
|
-
- **
|
|
7
|
+
- **Explicit Models**: Uses an explicit `RoleAssignment` join model instead of hidden tables, making it easy to add metadata or audit trails.
|
|
8
|
+
- **N+1 Prevention**: Built-in support for `has_cached_role?` and optimized scopes.
|
|
9
|
+
- **Strict by Default**: Resource-specific checks are strict, ensuring global roles don't accidentally leak permissions unless configured otherwise.
|
|
10
|
+
- **Advanced Features**: Supports temporal (expiring) roles, metadata, audit logging, and granular abilities.
|
|
11
|
+
- **Modern Infrastructure**: Fully compatible with Rails 7.0 through 8.1, includes Lefthook and Appraisal support.
|
|
11
12
|
|
|
12
13
|
## Installation
|
|
13
14
|
|
|
@@ -25,17 +26,18 @@ bundle install
|
|
|
25
26
|
|
|
26
27
|
### Setup
|
|
27
28
|
|
|
28
|
-
1.
|
|
29
|
+
1. **Install Configuration:**
|
|
29
30
|
```bash
|
|
30
31
|
rails generate role_fu:install
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
2. Generate
|
|
34
|
+
2. **Generate Models:**
|
|
35
|
+
Default names are `Role` and `RoleAssignment`, linked to the `User` model.
|
|
34
36
|
```bash
|
|
35
37
|
rails generate role_fu Role User
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
3. Run
|
|
40
|
+
3. **Run Migrations:**
|
|
39
41
|
```bash
|
|
40
42
|
rails db:migrate
|
|
41
43
|
```
|
|
@@ -49,10 +51,10 @@ Include `RoleFu::Roleable` in your User model (done automatically by the generat
|
|
|
49
51
|
```ruby
|
|
50
52
|
class User < ApplicationRecord
|
|
51
53
|
include RoleFu::Roleable
|
|
52
|
-
|
|
54
|
+
|
|
53
55
|
# Optional callbacks
|
|
54
56
|
role_fu_options after_add: :notify_user
|
|
55
|
-
|
|
57
|
+
|
|
56
58
|
def notify_user(role)
|
|
57
59
|
# ...
|
|
58
60
|
end
|
|
@@ -65,35 +67,122 @@ end
|
|
|
65
67
|
user = User.find(1)
|
|
66
68
|
|
|
67
69
|
# Global roles
|
|
68
|
-
user.
|
|
69
|
-
user.grant(:admin) # Alias
|
|
70
|
+
user.grant(:admin)
|
|
70
71
|
user.has_role?(:admin) # => true
|
|
71
|
-
user.
|
|
72
|
-
user.revoke(:admin) # Alias
|
|
72
|
+
user.revoke(:admin)
|
|
73
73
|
|
|
74
74
|
# Resource-specific roles
|
|
75
75
|
org = Organization.first
|
|
76
|
-
user.
|
|
76
|
+
user.grant(:manager, org)
|
|
77
77
|
user.has_role?(:manager, org) # => true
|
|
78
78
|
user.has_role?(:manager) # => false (strict check)
|
|
79
79
|
user.has_role?(:manager, :any) # => true
|
|
80
80
|
user.only_has_role?(:manager, org) # => true if this is their only role
|
|
81
|
+
```
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
#### Scopes (Finders)
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
83
86
|
User.with_role(:admin)
|
|
84
87
|
User.with_role(:manager, org)
|
|
85
88
|
User.without_role(:admin)
|
|
86
89
|
User.with_any_role(:admin, :editor)
|
|
87
90
|
User.with_all_roles(:admin, :manager)
|
|
91
|
+
```
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### Advanced Features
|
|
96
|
+
|
|
97
|
+
#### 1. Temporal Roles (Expiration)
|
|
98
|
+
Roles can be assigned with an expiration time. They are automatically filtered out from queries once expired.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
user.grant(:manager, org, expires_at: 1.week.from_now)
|
|
102
|
+
|
|
103
|
+
# To physically remove expired roles from the database:
|
|
104
|
+
# rake role_fu:cleanup
|
|
105
|
+
|
|
106
|
+
# Or generate a scheduled ActiveJob:
|
|
107
|
+
# rails generate role_fu:job
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### 2. Metadata
|
|
111
|
+
Attach arbitrary metadata to a role assignment.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
user.grant(:manager, org, meta: { assigned_by: current_user.id, reason: "Project lead" })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### 3. Audit Log
|
|
118
|
+
Track every grant, revoke, and update (e.g., expiration extensions).
|
|
119
|
+
|
|
120
|
+
**Setup:**
|
|
121
|
+
```bash
|
|
122
|
+
rails generate role_fu:audit
|
|
123
|
+
rails db:migrate
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Usage:**
|
|
127
|
+
Wrap changes in `with_actor` to capture the responsible user:
|
|
128
|
+
```ruby
|
|
129
|
+
RoleFu.with_actor(current_user) do
|
|
130
|
+
user.grant(:manager, org)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Check history
|
|
134
|
+
RoleAssignmentAudit.where(user: user).last
|
|
135
|
+
# => #<RoleAssignmentAudit operation: "INSERT", whodunnit: "1", ...>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### 4. Role Abilities (Permissions)
|
|
139
|
+
Attach granular permissions to roles.
|
|
140
|
+
|
|
141
|
+
**Setup:**
|
|
142
|
+
```bash
|
|
143
|
+
rails generate role_fu:abilities
|
|
144
|
+
rails db:migrate
|
|
92
145
|
```
|
|
93
146
|
|
|
94
|
-
|
|
147
|
+
**Usage:**
|
|
148
|
+
```ruby
|
|
149
|
+
# Setup permissions
|
|
150
|
+
manager_role = Role.find_by(name: "manager")
|
|
151
|
+
manager_role.permissions.create(action: "posts.edit")
|
|
152
|
+
|
|
153
|
+
# Check abilities
|
|
154
|
+
user.role_fu_can?("posts.edit") # => true
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### Adapters (Pundit & CanCanCan)
|
|
160
|
+
|
|
161
|
+
#### CanCanCan
|
|
162
|
+
```ruby
|
|
163
|
+
class Ability
|
|
164
|
+
include CanCan::Ability
|
|
165
|
+
include RoleFu::Adapters::CanCanCan
|
|
166
|
+
|
|
167
|
+
def initialize(user)
|
|
168
|
+
role_fu_load_permissions!(user)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
95
172
|
|
|
96
|
-
|
|
173
|
+
#### Pundit
|
|
174
|
+
```ruby
|
|
175
|
+
class ApplicationPolicy
|
|
176
|
+
include RoleFu::Adapters::Pundit
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
*`PostPolicy#update?` will automatically check `user.role_fu_can?('posts.update')`.*
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### Performance (N+1 Prevention)
|
|
184
|
+
|
|
185
|
+
Use `has_cached_role?` when roles are preloaded to avoid database roundtrips.
|
|
97
186
|
|
|
98
187
|
```ruby
|
|
99
188
|
users = User.includes(:roles).all
|
|
@@ -102,7 +191,7 @@ users.each do |user|
|
|
|
102
191
|
end
|
|
103
192
|
```
|
|
104
193
|
|
|
105
|
-
### Resourceable
|
|
194
|
+
### Resourceable
|
|
106
195
|
|
|
107
196
|
Include `RoleFu::Resourceable` in any model that should have roles scoped to it.
|
|
108
197
|
|
|
@@ -113,32 +202,55 @@ end
|
|
|
113
202
|
|
|
114
203
|
org = Organization.first
|
|
115
204
|
org.users_with_role(:manager)
|
|
116
|
-
org.count_users_with_role(:admin)
|
|
117
205
|
org.available_roles # ["manager", "admin"]
|
|
118
|
-
```
|
|
119
206
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
| Join Model | Implicit (HABTM) | Explicit (RoleAssignment) |
|
|
125
|
-
| Performance | Frequent N+1 | Cached role support + Optimized Scopes |
|
|
126
|
-
| Orphaned Roles | Configurable cleanup | Automatic cleanup |
|
|
127
|
-
| Scopes | `User.with_role` | `User.with_role`, `without_role`, `with_any`, `with_all` |
|
|
128
|
-
| Modern Rails | Older codebase | Optimized for Rails 7+ |
|
|
207
|
+
# Scopes
|
|
208
|
+
Organization.with_role(:manager, user)
|
|
209
|
+
Organization.without_role(:manager, user)
|
|
210
|
+
```
|
|
129
211
|
|
|
130
212
|
## Configuration
|
|
131
213
|
|
|
132
|
-
|
|
214
|
+
Customize your model names in `config/initializers/role_fu.rb`:
|
|
133
215
|
|
|
134
216
|
```ruby
|
|
135
217
|
RoleFu.configure do |config|
|
|
136
218
|
config.user_class_name = "Account"
|
|
137
219
|
config.role_class_name = "Group"
|
|
138
|
-
|
|
220
|
+
|
|
221
|
+
# Enable Rolify-style permissive checks (Global roles override resource checks)
|
|
222
|
+
config.global_roles_override = true
|
|
139
223
|
end
|
|
140
224
|
```
|
|
141
225
|
|
|
226
|
+
## Custom Aliases (e.g. Groups)
|
|
227
|
+
|
|
228
|
+
If you prefer different terminology (e.g., "Groups" instead of "Roles"), you can alias the methods in your models:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class User < ApplicationRecord
|
|
232
|
+
include RoleFu::Roleable
|
|
233
|
+
role_fu_alias :group
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Now you can use:
|
|
237
|
+
user.add_group(:admin)
|
|
238
|
+
user.has_group?(:admin)
|
|
239
|
+
User.in_group(:admin) # Alias for with_group/with_role
|
|
240
|
+
User.not_in_group(:admin) # Alias for without_group/without_role
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Migrating from Rolify
|
|
244
|
+
|
|
245
|
+
1. **Code Changes**: Replace `rolify` with `include RoleFu::Roleable` and `resourcify` with `include RoleFu::Resourceable`.
|
|
246
|
+
2. **Data Migration**:
|
|
247
|
+
```sql
|
|
248
|
+
INSERT INTO role_assignments (user_id, role_id, created_at, updated_at)
|
|
249
|
+
SELECT user_id, role_id, NOW(), NOW()
|
|
250
|
+
FROM users_roles;
|
|
251
|
+
```
|
|
252
|
+
3. **Behavior**: Set `config.global_roles_override = true` if you rely on global roles satisfying resource checks.
|
|
253
|
+
|
|
142
254
|
## License
|
|
143
255
|
|
|
144
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
256
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
|
|
5
|
+
module RoleFu
|
|
6
|
+
module Generators
|
|
7
|
+
class AbilitiesGenerator < ActiveRecord::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def generate_permission_model
|
|
11
|
+
template "permission.rb.erb", "app/models/permission.rb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_migration
|
|
15
|
+
migration_template "abilities_migration.rb.erb", "db/migrate/role_fu_create_permissions.rb", migration_version: migration_version
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def inject_into_role_model
|
|
19
|
+
role_cname = RoleFu.configuration.role_class_name
|
|
20
|
+
path = "app/models/#{role_cname.underscore}.rb"
|
|
21
|
+
|
|
22
|
+
if File.exist?(path)
|
|
23
|
+
inject_into_class(path, role_cname.constantize) do
|
|
24
|
+
" has_many :permissions, dependent: :destroy\n"
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
say "Role model #{role_cname} not found. Please add 'has_many :permissions' manually."
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inject_into_user_model
|
|
32
|
+
user_cname = RoleFu.configuration.user_class_name
|
|
33
|
+
path = "app/models/#{user_cname.underscore}.rb"
|
|
34
|
+
|
|
35
|
+
if File.exist?(path)
|
|
36
|
+
inject_into_class(path, user_cname.constantize) do
|
|
37
|
+
" include RoleFu::Ability\n"
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
say "User model #{user_cname} not found. Please add 'include RoleFu::Ability' manually."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def migration_version
|
|
47
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails::VERSION::MAJOR >= 5
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
|
|
5
|
+
module RoleFu
|
|
6
|
+
module Generators
|
|
7
|
+
class AuditGenerator < ActiveRecord::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def create_migration
|
|
11
|
+
migration_template "audit_migration.rb.erb", "db/migrate/role_fu_create_audits.rb", migration_version: migration_version
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_model
|
|
15
|
+
template "audit_model.rb.erb", "app/models/role_assignment_audit.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def migration_version
|
|
21
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails::VERSION::MAJOR >= 5
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module RoleFu
|
|
6
|
+
module Generators
|
|
7
|
+
class JobGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def create_job
|
|
11
|
+
template "cleanup_job.rb.erb", "app/jobs/role_fu/cleanup_job.rb"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RoleFuCreatePermissions < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :permissions do |t|
|
|
6
|
+
t.references :role, null: false, index: true
|
|
7
|
+
t.string :action, null: false
|
|
8
|
+
t.jsonb :conditions, default: {}
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :permissions, [:role_id, :action]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RoleFuCreateAudits < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :role_assignment_audits do |t|
|
|
6
|
+
t.references :user, index: true
|
|
7
|
+
t.references :role, index: true
|
|
8
|
+
t.references :role_assignment, index: true
|
|
9
|
+
t.string :operation, null: false # INSERT, UPDATE, DELETE
|
|
10
|
+
t.string :whodunnit
|
|
11
|
+
t.jsonb :meta_snapshot
|
|
12
|
+
t.datetime :expires_at_snapshot
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# If using PostgreSQL, you can add a trigger here to automatically populate this table.
|
|
18
|
+
# Example (Conceptual):
|
|
19
|
+
# execute <<-SQL
|
|
20
|
+
# CREATE TRIGGER ...
|
|
21
|
+
# SQL
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RoleFu
|
|
4
|
+
class CleanupJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
result = RoleFu::Cleanup.call
|
|
9
|
+
Rails.logger.info("RoleFu::CleanupJob: Deleted #{result[:deleted]} expired assignments.")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -12,6 +12,8 @@ class RoleFuCreate<%= table_name.camelize %> < ActiveRecord::Migration<%= migrat
|
|
|
12
12
|
create_table :<%= table_name.singularize %>_assignments do |t|
|
|
13
13
|
t.references :<%= user_cname.underscore %>, index: true
|
|
14
14
|
t.references :<%= table_name.singularize %>, index: true
|
|
15
|
+
t.datetime :expires_at, index: true
|
|
16
|
+
t.jsonb :meta, default: {}
|
|
15
17
|
|
|
16
18
|
t.timestamps
|
|
17
19
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RoleFu
|
|
4
|
+
module Ability
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
def role_fu_can?(action, _resource = nil)
|
|
8
|
+
role_fu_permissions.include?(action.to_s)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def role_fu_permissions
|
|
12
|
+
return @_role_fu_permissions if defined?(@_role_fu_permissions) && @_role_fu_permissions
|
|
13
|
+
|
|
14
|
+
permission_class = "Permission".safe_constantize
|
|
15
|
+
return Set.new unless permission_class
|
|
16
|
+
|
|
17
|
+
scope = roles
|
|
18
|
+
scope = filter_expired(scope) if respond_to?(:filter_expired, true)
|
|
19
|
+
|
|
20
|
+
@_role_fu_permissions = scope.joins(:permissions).pluck("permissions.action").map(&:to_s).to_set
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RoleFu
|
|
4
|
+
module Adapters
|
|
5
|
+
module CanCanCan
|
|
6
|
+
# Mixin for CanCan::Ability class
|
|
7
|
+
def role_fu_load_permissions!(user)
|
|
8
|
+
return unless user.respond_to?(:role_fu_permissions)
|
|
9
|
+
|
|
10
|
+
user.role_fu_permissions.each do |action|
|
|
11
|
+
# Action format: "posts.update" (resource.action) or "manage_all"
|
|
12
|
+
parts = action.split(".")
|
|
13
|
+
|
|
14
|
+
if parts.size == 2
|
|
15
|
+
subject_name, rule = parts
|
|
16
|
+
begin
|
|
17
|
+
subject_class = subject_name.classify.constantize
|
|
18
|
+
can rule.to_sym, subject_class
|
|
19
|
+
rescue NameError
|
|
20
|
+
can rule.to_sym, subject_name.to_sym
|
|
21
|
+
end
|
|
22
|
+
else
|
|
23
|
+
can action.to_sym, :all
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RoleFu
|
|
4
|
+
module Adapters
|
|
5
|
+
module Pundit
|
|
6
|
+
# Mixin for Pundit Policies (e.g. ApplicationPolicy)
|
|
7
|
+
def method_missing(method, *args, &block)
|
|
8
|
+
if method.to_s.end_with?("?")
|
|
9
|
+
action = method.to_s.delete_suffix("?")
|
|
10
|
+
|
|
11
|
+
# Infer resource from policy name: PostPolicy -> "posts"
|
|
12
|
+
resource_name = self.class.to_s.gsub("Policy", "").underscore.pluralize
|
|
13
|
+
|
|
14
|
+
# Check "posts.update"
|
|
15
|
+
permission = "#{resource_name}.#{action}"
|
|
16
|
+
|
|
17
|
+
return true if user.role_fu_can?(permission)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def respond_to_missing?(method, include_private = false)
|
|
24
|
+
method.to_s.end_with?("?") || super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RoleFu
|
|
4
|
+
class Cleanup
|
|
5
|
+
def self.call
|
|
6
|
+
assignment_class = RoleFu.configuration.role_assignment_class_name.constantize
|
|
7
|
+
|
|
8
|
+
if assignment_class.column_names.include?("expires_at")
|
|
9
|
+
count = assignment_class.where("expires_at < ?", Time.current).delete_all
|
|
10
|
+
{deleted: count}
|
|
11
|
+
else
|
|
12
|
+
{deleted: 0, error: "expires_at column missing"}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module RoleFu
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :user_class_name, :role_class_name, :role_assignment_class_name
|
|
5
|
+
attr_accessor :user_class_name, :role_class_name, :role_assignment_class_name, :global_roles_override
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
@user_class_name = "User"
|
|
9
9
|
@role_class_name = "Role"
|
|
10
10
|
@role_assignment_class_name = "RoleAssignment"
|
|
11
|
+
@global_roles_override = false
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
|
data/lib/role_fu/resourceable.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RoleFu
|
|
4
|
-
# Resourceable concern - provides resource management for models with roles
|
|
5
4
|
module Resourceable
|
|
6
5
|
extend ActiveSupport::Concern
|
|
7
6
|
|
|
@@ -13,144 +12,111 @@ module RoleFu
|
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
module ClassMethods
|
|
16
|
-
# Find roles defined on any instance of this resource class
|
|
17
|
-
# @param role_name [String, Symbol, nil] Filter by role name
|
|
18
|
-
# @param user [User, nil] Filter by user
|
|
19
|
-
# @return [ActiveRecord::Relation] Roles
|
|
20
15
|
def find_roles(role_name = nil, user = nil)
|
|
21
|
-
RoleFu.role_class.table_name
|
|
22
16
|
query = RoleFu.role_class.where(resource_type: name)
|
|
23
17
|
query = query.where(name: role_name.to_s) if role_name
|
|
24
18
|
query = query.joins(:users).where(RoleFu.user_class.table_name => {id: user.id}) if user
|
|
25
19
|
query
|
|
26
20
|
end
|
|
27
21
|
|
|
28
|
-
# Find resources that have a specific role applied (to a user)
|
|
29
|
-
# @param role_name [String, Symbol] The role name
|
|
30
|
-
# @param user [User, nil] Filter by specific user having the role
|
|
31
|
-
# @return [ActiveRecord::Relation] Resources
|
|
32
22
|
def with_role(role_name, user = nil)
|
|
33
23
|
role_table = RoleFu.role_class.table_name
|
|
34
|
-
|
|
35
24
|
query = joins(:roles).where(role_table => {name: role_name.to_s})
|
|
36
|
-
|
|
37
|
-
if user
|
|
38
|
-
query = query.joins(roles: :users).where(RoleFu.user_class.table_name => {id: user.id})
|
|
39
|
-
end
|
|
40
|
-
|
|
25
|
+
query = query.joins(roles: :users).where(RoleFu.user_class.table_name => {id: user.id}) if user
|
|
41
26
|
query.distinct
|
|
42
27
|
end
|
|
43
28
|
|
|
44
|
-
# Find resources that do NOT have a specific role applied
|
|
45
|
-
# @param role_name [String, Symbol] The role name
|
|
46
|
-
# @param user [User, nil] Filter by user
|
|
47
|
-
# @return [ActiveRecord::Relation] Resources
|
|
48
29
|
def without_role(role_name, user = nil)
|
|
49
30
|
where.not(id: with_role(role_name, user).select(:id))
|
|
50
31
|
end
|
|
32
|
+
|
|
33
|
+
# Dynamically create aliases for role methods
|
|
34
|
+
# @param name [Symbol, String] The alias name (e.g. :group)
|
|
35
|
+
def role_fu_alias(name)
|
|
36
|
+
singular = name.to_s.singularize
|
|
37
|
+
plural = name.to_s.pluralize
|
|
38
|
+
|
|
39
|
+
# Class methods
|
|
40
|
+
singleton_class.class_eval do
|
|
41
|
+
alias_method "find_#{plural}", :find_roles
|
|
42
|
+
alias_method "with_#{singular}", :with_role
|
|
43
|
+
alias_method "without_#{singular}", :without_role
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Instance methods
|
|
47
|
+
alias_method "users_with_#{singular}", :users_with_role
|
|
48
|
+
alias_method "users_with_any_#{singular}", :users_with_any_role
|
|
49
|
+
alias_method "users_with_all_#{plural}", :users_with_all_roles
|
|
50
|
+
alias_method "users_with_#{plural}", :users_with_roles
|
|
51
|
+
alias_method "available_#{plural}", :available_roles
|
|
52
|
+
alias_method "has_#{singular}?", :has_role?
|
|
53
|
+
alias_method "count_users_with_#{singular}", :count_users_with_role
|
|
54
|
+
alias_method "user_has_#{singular}?", :user_has_role?
|
|
55
|
+
alias_method "add_#{singular}_to_user", :add_role_to_user
|
|
56
|
+
alias_method "remove_#{singular}_from_user", :remove_role_from_user
|
|
57
|
+
end
|
|
51
58
|
end
|
|
52
59
|
|
|
53
|
-
# Get roles applied to this resource instance (plus global class-level roles if any - though RoleFu focuses on instance roles)
|
|
54
|
-
# @return [ActiveRecord::Relation] Roles
|
|
55
60
|
def applied_roles
|
|
56
61
|
roles
|
|
57
62
|
end
|
|
58
63
|
|
|
59
|
-
# Get users with a specific role on this resource
|
|
60
|
-
# @param role_name [String, Symbol] The role name
|
|
61
|
-
# @return [ActiveRecord::Relation] Relation of users
|
|
62
64
|
def users_with_role(role_name)
|
|
63
65
|
role_table = RoleFu.role_class.table_name
|
|
64
|
-
user_class.joins(:roles)
|
|
66
|
+
RoleFu.user_class.joins(:roles)
|
|
65
67
|
.where(role_table => {name: role_name.to_s, resource_type: self.class.name, resource_id: id})
|
|
66
68
|
.distinct
|
|
67
69
|
end
|
|
68
70
|
|
|
69
|
-
# Get users with any role on this resource
|
|
70
|
-
# @param role_names [Array<String, Symbol>] Array of role names
|
|
71
|
-
# @return [ActiveRecord::Relation] Relation of users
|
|
72
71
|
def users_with_any_role(*role_names)
|
|
73
72
|
role_table = RoleFu.role_class.table_name
|
|
74
|
-
user_class.joins(:roles)
|
|
73
|
+
RoleFu.user_class.joins(:roles)
|
|
75
74
|
.where(role_table => {name: role_names.flatten.map(&:to_s), resource_type: self.class.name, resource_id: id})
|
|
76
75
|
.distinct
|
|
77
76
|
end
|
|
78
77
|
|
|
79
|
-
# Get users with all specified roles on this resource
|
|
80
|
-
# @param role_names [Array<String, Symbol>] Array of role names
|
|
81
|
-
# @return [Array<User>] Array of users
|
|
82
78
|
def users_with_all_roles(*role_names)
|
|
83
79
|
role_names = role_names.flatten.map(&:to_s)
|
|
84
80
|
role_table = RoleFu.role_class.table_name
|
|
81
|
+
user_table = RoleFu.user_class.table_name
|
|
82
|
+
user_pk = RoleFu.user_class.primary_key
|
|
85
83
|
|
|
86
|
-
user_class.joins(:roles)
|
|
84
|
+
RoleFu.user_class.joins(:roles)
|
|
87
85
|
.where(role_table => {name: role_names, resource_type: self.class.name, resource_id: id})
|
|
88
|
-
.group("#{
|
|
86
|
+
.group("#{user_table}.#{user_pk}")
|
|
89
87
|
.having("COUNT(DISTINCT #{role_table}.name) = ?", role_names.size)
|
|
90
88
|
.distinct
|
|
91
89
|
end
|
|
92
90
|
|
|
93
|
-
# Get all users with any role on this resource
|
|
94
|
-
# @return [ActiveRecord::Relation] Relation of users
|
|
95
91
|
def users_with_roles
|
|
96
92
|
role_table = RoleFu.role_class.table_name
|
|
97
|
-
user_class.joins(:roles)
|
|
93
|
+
RoleFu.user_class.joins(:roles)
|
|
98
94
|
.where(role_table => {resource_type: self.class.name, resource_id: id})
|
|
99
95
|
.distinct
|
|
100
96
|
end
|
|
101
97
|
|
|
102
|
-
# Get all role names defined for this resource
|
|
103
|
-
# @return [Array<String>] Array of role names
|
|
104
98
|
def available_roles
|
|
105
99
|
roles.pluck(:name).uniq
|
|
106
100
|
end
|
|
107
101
|
|
|
108
|
-
# Check if resource has any users with a specific role
|
|
109
|
-
# @param role_name [String, Symbol] The role name
|
|
110
|
-
# @return [Boolean] true if any user has this role
|
|
111
102
|
def has_role?(role_name)
|
|
112
103
|
roles.exists?(name: role_name.to_s)
|
|
113
104
|
end
|
|
114
105
|
|
|
115
|
-
# Count users with a specific role
|
|
116
|
-
# @param role_name [String, Symbol] The role name
|
|
117
|
-
# @return [Integer] Number of users
|
|
118
106
|
def count_users_with_role(role_name)
|
|
119
107
|
users_with_role(role_name).count
|
|
120
108
|
end
|
|
121
109
|
|
|
122
|
-
# Check if a specific user has a role on this resource
|
|
123
|
-
# @param user [User] The user
|
|
124
|
-
# @param role_name [String, Symbol] The role name
|
|
125
|
-
# @return [Boolean] true if user has the role
|
|
126
110
|
def user_has_role?(user, role_name)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
user.has_role?(role_name, self)
|
|
111
|
+
user&.has_role?(role_name, self) || false
|
|
130
112
|
end
|
|
131
113
|
|
|
132
|
-
# Add a role to a user on this resource
|
|
133
|
-
# @param user [User] The user
|
|
134
|
-
# @param role_name [String, Symbol] The role name
|
|
135
|
-
# @return [Role] The role
|
|
136
114
|
def add_role_to_user(user, role_name)
|
|
137
115
|
user.add_role(role_name, self)
|
|
138
116
|
end
|
|
139
117
|
|
|
140
|
-
# Remove a role from a user on this resource
|
|
141
|
-
# @param user [User] The user
|
|
142
|
-
# @param role_name [String, Symbol] The role name
|
|
143
|
-
# @return [Array<Role>] The removed roles
|
|
144
118
|
def remove_role_from_user(user, role_name)
|
|
145
119
|
user.remove_role(role_name, self)
|
|
146
120
|
end
|
|
147
|
-
|
|
148
|
-
private
|
|
149
|
-
|
|
150
|
-
# Get the User class
|
|
151
|
-
# @return [Class] User class
|
|
152
|
-
def user_class
|
|
153
|
-
RoleFu.user_class
|
|
154
|
-
end
|
|
155
121
|
end
|
|
156
122
|
end
|
|
@@ -12,11 +12,48 @@ module RoleFu
|
|
|
12
12
|
scope :global_roles, -> { joins(:role).where(RoleFu.role_class.table_name => {resource_type: nil, resource_id: nil}) }
|
|
13
13
|
scope :resource_specific, -> { joins(:role).where.not(RoleFu.role_class.table_name => {resource_type: nil}) }
|
|
14
14
|
|
|
15
|
+
after_create :audit_create
|
|
16
|
+
after_update :audit_update
|
|
17
|
+
after_destroy :audit_destroy
|
|
18
|
+
|
|
15
19
|
after_destroy :cleanup_orphaned_role
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
private
|
|
19
23
|
|
|
24
|
+
def audit_create
|
|
25
|
+
audit_log("INSERT")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def audit_update
|
|
29
|
+
audit_log("UPDATE")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def audit_destroy
|
|
33
|
+
audit_log("DELETE")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def audit_log(operation)
|
|
37
|
+
# Only audit if the Audit model exists
|
|
38
|
+
audit_class = "RoleAssignmentAudit".safe_constantize
|
|
39
|
+
return unless audit_class
|
|
40
|
+
|
|
41
|
+
actor = RoleFu.current_actor
|
|
42
|
+
|
|
43
|
+
audit_class.create(
|
|
44
|
+
role_assignment_id: id,
|
|
45
|
+
user_id: user_id,
|
|
46
|
+
role_id: role_id,
|
|
47
|
+
operation: operation,
|
|
48
|
+
whodunnit: actor.try(:id) || actor.to_s,
|
|
49
|
+
meta_snapshot: try(:meta),
|
|
50
|
+
expires_at_snapshot: try(:expires_at)
|
|
51
|
+
)
|
|
52
|
+
rescue
|
|
53
|
+
# Fail silently or log error? Logging is safer to avoid breaking the main flow.
|
|
54
|
+
# defined?(Rails) ? Rails.logger.error("RoleFu Audit Error: #{e.message}") : puts("RoleFu Audit Error: #{e.message}")
|
|
55
|
+
end
|
|
56
|
+
|
|
20
57
|
def cleanup_orphaned_role
|
|
21
58
|
# If this record is being destroyed as part of role destruction,
|
|
22
59
|
# do not try to destroy the same role again.
|
data/lib/role_fu/roleable.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RoleFu
|
|
4
|
-
# Roleable concern - provides role management for User model
|
|
5
4
|
module Roleable
|
|
6
5
|
extend ActiveSupport::Concern
|
|
7
6
|
|
|
@@ -21,16 +20,16 @@ module RoleFu
|
|
|
21
20
|
self.role_fu_callbacks = options.slice(:before_add, :after_add, :before_remove, :after_remove)
|
|
22
21
|
end
|
|
23
22
|
|
|
24
|
-
# Find users with a specific role
|
|
25
|
-
# @param role_name [String, Symbol] The name of the role
|
|
26
|
-
# @param resource [ActiveRecord::Base, Class, nil, :any] The resource
|
|
27
|
-
# @return [ActiveRecord::Relation] Users with the role
|
|
28
23
|
def with_role(role_name, resource = nil)
|
|
29
24
|
role_table = RoleFu.role_class.table_name
|
|
30
|
-
RoleFu.role_assignment_class.table_name
|
|
25
|
+
assignment_table = RoleFu.role_assignment_class.table_name
|
|
31
26
|
|
|
32
27
|
query = joins(:roles).where(role_table => {name: role_name.to_s})
|
|
33
28
|
|
|
29
|
+
if RoleFu.role_assignment_class.column_names.include?("expires_at")
|
|
30
|
+
query = query.where("#{assignment_table}.expires_at IS NULL OR #{assignment_table}.expires_at > ?", Time.current)
|
|
31
|
+
end
|
|
32
|
+
|
|
34
33
|
if resource.nil?
|
|
35
34
|
query.where(role_table => {resource_type: nil, resource_id: nil})
|
|
36
35
|
elsif resource == :any
|
|
@@ -42,75 +41,87 @@ module RoleFu
|
|
|
42
41
|
end.distinct
|
|
43
42
|
end
|
|
44
43
|
|
|
45
|
-
# Find users without a specific role
|
|
46
|
-
# @param role_name [String, Symbol] The name of the role
|
|
47
|
-
# @param resource [ActiveRecord::Base, Class, nil, :any] The resource
|
|
48
|
-
# @return [ActiveRecord::Relation] Users without the role
|
|
49
44
|
def without_role(role_name, resource = nil)
|
|
50
45
|
where.not(id: with_role(role_name, resource).select(:id))
|
|
51
46
|
end
|
|
52
47
|
|
|
53
|
-
# Find users with any of the specified roles
|
|
54
|
-
# @param args [Array<String, Symbol, Hash>] Roles to check
|
|
55
|
-
# @return [ActiveRecord::Relation] Users with any of the roles
|
|
56
48
|
def with_any_role(*args)
|
|
57
|
-
ids =
|
|
58
|
-
|
|
59
|
-
ids += if arg.is_a?(Hash)
|
|
60
|
-
with_role(arg[:name], arg[:resource]).pluck(:id)
|
|
61
|
-
else
|
|
62
|
-
with_role(arg).pluck(:id)
|
|
63
|
-
end
|
|
49
|
+
ids = args.flat_map do |arg|
|
|
50
|
+
arg.is_a?(Hash) ? with_role(arg[:name], arg[:resource]).pluck(:id) : with_role(arg).pluck(:id)
|
|
64
51
|
end
|
|
65
52
|
where(id: ids.uniq)
|
|
66
53
|
end
|
|
67
54
|
|
|
68
|
-
# Find users with all of the specified roles
|
|
69
|
-
# @param args [Array<String, Symbol, Hash>] Roles to check
|
|
70
|
-
# @return [ActiveRecord::Relation] Users with all of the roles
|
|
71
55
|
def with_all_roles(*args)
|
|
72
56
|
ids = nil
|
|
73
57
|
args.each do |arg|
|
|
74
|
-
current_ids =
|
|
75
|
-
with_role(arg[:name], arg[:resource]).pluck(:id)
|
|
76
|
-
else
|
|
77
|
-
with_role(arg).pluck(:id)
|
|
78
|
-
end
|
|
58
|
+
current_ids = arg.is_a?(Hash) ? with_role(arg[:name], arg[:resource]).pluck(:id) : with_role(arg).pluck(:id)
|
|
79
59
|
ids = ids.nil? ? current_ids : ids & current_ids
|
|
80
60
|
return none if ids.empty?
|
|
81
61
|
end
|
|
82
62
|
where(id: ids)
|
|
83
63
|
end
|
|
64
|
+
|
|
65
|
+
# Dynamically create aliases for role methods
|
|
66
|
+
# @param name [Symbol, String] The alias name (e.g. :group)
|
|
67
|
+
# @example
|
|
68
|
+
# role_fu_alias :group
|
|
69
|
+
# # Creates: add_group, remove_group, has_group?, with_group, in_group, etc.
|
|
70
|
+
def role_fu_alias(name)
|
|
71
|
+
singular = name.to_s.singularize
|
|
72
|
+
plural = name.to_s.pluralize
|
|
73
|
+
|
|
74
|
+
# Instance methods
|
|
75
|
+
alias_method "add_#{singular}", :add_role
|
|
76
|
+
alias_method "remove_#{singular}", :remove_role
|
|
77
|
+
alias_method "has_#{singular}?", :has_role?
|
|
78
|
+
alias_method "only_has_#{singular}?", :only_has_role?
|
|
79
|
+
alias_method "has_any_#{singular}?", :has_any_role?
|
|
80
|
+
alias_method "has_all_#{plural}?", :has_all_roles?
|
|
81
|
+
alias_method "#{plural}_name", :roles_name
|
|
82
|
+
|
|
83
|
+
# Class methods (Scopes)
|
|
84
|
+
singleton_class.class_eval do
|
|
85
|
+
alias_method "with_#{singular}", :with_role
|
|
86
|
+
alias_method "without_#{singular}", :without_role
|
|
87
|
+
alias_method "with_any_#{singular}", :with_any_role
|
|
88
|
+
alias_method "with_all_#{plural}", :with_all_roles
|
|
89
|
+
|
|
90
|
+
# Additional natural aliases
|
|
91
|
+
alias_method "in_#{singular}", :with_role
|
|
92
|
+
alias_method "not_in_#{singular}", :without_role
|
|
93
|
+
end
|
|
94
|
+
end
|
|
84
95
|
end
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
# @param role_name [String, Symbol] The name of the role
|
|
88
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource (organization, etc.) or nil for global role
|
|
89
|
-
# @return [Role] The role that was added
|
|
90
|
-
def add_role(role_name, resource = nil)
|
|
97
|
+
def add_role(role_name, resource = nil, expires_at: nil, meta: nil)
|
|
91
98
|
role = find_or_create_role(role_name, resource)
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
existing_assignment = role_assignments.find_by(role: role)
|
|
100
|
+
|
|
101
|
+
if existing_assignment
|
|
102
|
+
if expires_at || meta
|
|
103
|
+
updates = {}
|
|
104
|
+
updates[:expires_at] = expires_at if expires_at
|
|
105
|
+
updates[:meta] = meta if meta
|
|
106
|
+
existing_assignment.update(updates)
|
|
107
|
+
end
|
|
108
|
+
return role
|
|
109
|
+
end
|
|
94
110
|
|
|
95
111
|
run_role_fu_callback(:before_add, role)
|
|
96
|
-
|
|
112
|
+
role_assignments.create!(role: role, expires_at: expires_at, meta: meta)
|
|
113
|
+
roles.reload
|
|
97
114
|
run_role_fu_callback(:after_add, role)
|
|
98
115
|
|
|
99
116
|
role
|
|
100
117
|
end
|
|
101
118
|
alias_method :grant, :add_role
|
|
102
119
|
|
|
103
|
-
# Remove a role from the user
|
|
104
|
-
# @param role_name [String, Symbol] The name of the role
|
|
105
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource or nil for global role
|
|
106
|
-
# @return [Array<Role>] The roles that were removed
|
|
107
120
|
def remove_role(role_name, resource = nil)
|
|
108
121
|
roles_to_remove_relation = find_roles(role_name, resource)
|
|
109
122
|
return [] if roles_to_remove_relation.empty?
|
|
110
123
|
|
|
111
|
-
# Materialize before removing associations, because removing may trigger cleanup that deletes the role.
|
|
112
124
|
removed_roles = roles_to_remove_relation.to_a
|
|
113
|
-
|
|
114
125
|
removed_roles.each do |role|
|
|
115
126
|
run_role_fu_callback(:before_remove, role)
|
|
116
127
|
role_assignments.where(role_id: role.id).destroy_all
|
|
@@ -121,45 +132,38 @@ module RoleFu
|
|
|
121
132
|
end
|
|
122
133
|
alias_method :revoke, :remove_role
|
|
123
134
|
|
|
124
|
-
# Check if user has a specific role
|
|
125
|
-
# @param role_name [String, Symbol] The name of the role
|
|
126
|
-
# @param resource [ActiveRecord::Base, Class, nil, :any] The resource, nil for global, or :any for any resource
|
|
127
|
-
# @return [Boolean] true if user has the role
|
|
128
135
|
def has_role?(role_name, resource = nil)
|
|
129
136
|
return false if role_name.nil?
|
|
130
137
|
|
|
131
138
|
if resource == :any
|
|
132
|
-
roles.
|
|
139
|
+
filter_expired(roles.where(name: role_name.to_s)).exists?
|
|
133
140
|
else
|
|
134
|
-
find_roles(role_name, resource).any?
|
|
141
|
+
return true if filter_expired(find_roles(role_name, resource)).any?
|
|
142
|
+
|
|
143
|
+
if RoleFu.configuration.global_roles_override && resource && !resource.is_a?(Class)
|
|
144
|
+
return filter_expired(find_roles(role_name, nil)).any?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
false
|
|
135
148
|
end
|
|
136
149
|
end
|
|
137
150
|
|
|
138
|
-
# Check if user has a specific role strictly (resource match must be exact, no globals overriding)
|
|
139
|
-
# Note: In RoleFu, has_role? is already strict about resource matching unless :any is passed,
|
|
140
|
-
# but this method explicitly bypasses any future global-fallback logic if we were to add it.
|
|
141
|
-
# Included for API compatibility.
|
|
142
|
-
# @param role_name [String, Symbol] The name of the role
|
|
143
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
144
|
-
# @return [Boolean] true if user has the role strictly
|
|
145
151
|
def has_strict_role?(role_name, resource = nil)
|
|
146
|
-
|
|
152
|
+
filter_expired(find_roles(role_name, resource)).any?
|
|
147
153
|
end
|
|
148
154
|
|
|
149
|
-
# Check if user only has this one role
|
|
150
|
-
# @param role_name [String, Symbol] The name of the role
|
|
151
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
152
|
-
# @return [Boolean] true if user has this role and no others
|
|
153
155
|
def only_has_role?(role_name, resource = nil)
|
|
154
|
-
has_role?(role_name, resource) && roles.count == 1
|
|
156
|
+
has_role?(role_name, resource) && filter_expired(roles).count == 1
|
|
155
157
|
end
|
|
156
158
|
|
|
157
|
-
# Check for role using preloaded association to avoid N+1
|
|
158
159
|
def has_cached_role?(role_name, resource = nil)
|
|
159
160
|
role_name = role_name.to_s
|
|
160
161
|
roles.to_a.any? do |role|
|
|
161
162
|
next false unless role.name == role_name
|
|
162
163
|
|
|
164
|
+
assignment = role_assignments.find { |ra| ra.role_id == role.id }
|
|
165
|
+
next false if assignment&.respond_to?(:expires_at) && assignment.expires_at && assignment.expires_at <= Time.current
|
|
166
|
+
|
|
163
167
|
if resource == :any
|
|
164
168
|
true
|
|
165
169
|
elsif resource.is_a?(Class)
|
|
@@ -172,80 +176,58 @@ module RoleFu
|
|
|
172
176
|
end
|
|
173
177
|
end
|
|
174
178
|
|
|
175
|
-
# Get all role names for this user
|
|
176
|
-
# @param resource [ActiveRecord::Base, Class, nil] Filter by resource
|
|
177
|
-
# @return [Array<String>] Array of role names
|
|
178
179
|
def roles_name(resource = nil)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
else
|
|
182
|
-
roles.pluck(:name)
|
|
183
|
-
end
|
|
180
|
+
scope = resource ? roles.where(resource: resource) : roles
|
|
181
|
+
filter_expired(scope).pluck(:name)
|
|
184
182
|
end
|
|
185
183
|
|
|
186
|
-
# Check if user has only global roles (no resource-specific roles)
|
|
187
|
-
# @return [Boolean] true if user has only global roles
|
|
188
184
|
def has_only_global_roles?
|
|
189
|
-
roles.where.not(resource_type: nil).empty?
|
|
185
|
+
filter_expired(roles).where.not(resource_type: nil).empty?
|
|
190
186
|
end
|
|
191
187
|
|
|
192
|
-
# Check if user has any role (global or resource-specific)
|
|
193
|
-
# @param resource [ActiveRecord::Base, Class, nil] Filter by resource
|
|
194
|
-
# @return [Boolean] true if user has any role
|
|
195
188
|
def has_any_role?(resource = nil)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
else
|
|
199
|
-
roles.exists?
|
|
200
|
-
end
|
|
189
|
+
scope = resource ? roles.where(resource: resource) : roles
|
|
190
|
+
filter_expired(scope).exists?
|
|
201
191
|
end
|
|
202
192
|
|
|
203
|
-
# Check if user has all specified roles
|
|
204
|
-
# @param role_names [Array<String, Symbol>] Array of role names
|
|
205
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
206
|
-
# @return [Boolean] true if user has all roles
|
|
207
193
|
def has_all_roles?(*role_names, resource: nil)
|
|
208
194
|
role_names.flatten.all? { |role_name| has_role?(role_name, resource) }
|
|
209
195
|
end
|
|
210
196
|
|
|
211
|
-
# Check if user has any of the specified roles
|
|
212
|
-
# @param role_names [Array<String, Symbol>] Array of role names
|
|
213
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
214
|
-
# @return [Boolean] true if user has any of the roles
|
|
215
197
|
def has_any_role_of?(*role_names, resource: nil)
|
|
216
198
|
role_names.flatten.any? { |role_name| has_role?(role_name, resource) }
|
|
217
199
|
end
|
|
218
200
|
|
|
219
|
-
# Get all resources of a specific type where user has a role
|
|
220
|
-
# @param resource_class [Class] The resource class (e.g., Organization)
|
|
221
|
-
# @return [ActiveRecord::Relation] Relation of resources
|
|
222
201
|
def resources(resource_class)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
202
|
+
assignment_table = RoleFu.role_assignment_class.table_name
|
|
203
|
+
query = resource_class.joins(:roles).merge(roles.where(resource_type: resource_class.name))
|
|
204
|
+
|
|
205
|
+
if RoleFu.role_assignment_class.column_names.include?("expires_at")
|
|
206
|
+
query = query.where("#{assignment_table}.expires_at IS NULL OR #{assignment_table}.expires_at > ?", Time.current)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
query.distinct
|
|
226
210
|
end
|
|
227
211
|
|
|
228
212
|
private
|
|
229
213
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
214
|
+
def filter_expired(relation)
|
|
215
|
+
return relation unless RoleFu.role_assignment_class.column_names.include?("expires_at")
|
|
216
|
+
|
|
217
|
+
assignment_table = RoleFu.role_assignment_class.table_name
|
|
218
|
+
relation.joins(:role_assignments)
|
|
219
|
+
.where("#{assignment_table}.user_id = ? AND (#{assignment_table}.expires_at IS NULL OR #{assignment_table}.expires_at > ?)", id, Time.current)
|
|
220
|
+
.distinct
|
|
221
|
+
end
|
|
237
222
|
|
|
223
|
+
def find_or_create_role(role_name, resource)
|
|
238
224
|
RoleFu.role_class.find_or_create_by(
|
|
239
225
|
name: role_name.to_s,
|
|
240
|
-
resource_type:
|
|
241
|
-
resource_id:
|
|
226
|
+
resource_type: resource_type_for(resource),
|
|
227
|
+
resource_id: resource_id_for(resource)
|
|
242
228
|
)
|
|
243
229
|
end
|
|
244
230
|
|
|
245
|
-
# Find roles matching criteria
|
|
246
|
-
# @param role_name [String, Symbol] The role name
|
|
247
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
248
|
-
# @return [ActiveRecord::Relation] Relation of matching roles
|
|
249
231
|
def find_roles(role_name, resource)
|
|
250
232
|
query = roles.where(name: role_name.to_s)
|
|
251
233
|
|
|
@@ -258,20 +240,14 @@ module RoleFu
|
|
|
258
240
|
end
|
|
259
241
|
end
|
|
260
242
|
|
|
261
|
-
# Get resource type for a resource
|
|
262
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
263
|
-
# @return [String, nil] The resource type
|
|
264
243
|
def resource_type_for(resource)
|
|
265
|
-
return
|
|
244
|
+
return if resource.nil?
|
|
266
245
|
|
|
267
246
|
resource.is_a?(Class) ? resource.to_s : resource.class.name
|
|
268
247
|
end
|
|
269
248
|
|
|
270
|
-
# Get resource id for a resource
|
|
271
|
-
# @param resource [ActiveRecord::Base, Class, nil] The resource
|
|
272
|
-
# @return [Integer, nil] The resource id
|
|
273
249
|
def resource_id_for(resource)
|
|
274
|
-
return
|
|
250
|
+
return if resource.nil? || resource.is_a?(Class)
|
|
275
251
|
|
|
276
252
|
resource.id
|
|
277
253
|
end
|
|
@@ -280,10 +256,8 @@ module RoleFu
|
|
|
280
256
|
method_name = role_fu_callbacks[callback_name]
|
|
281
257
|
return unless method_name
|
|
282
258
|
|
|
283
|
-
if method_name.is_a?(Proc)
|
|
284
|
-
instance_exec(role, &method_name)
|
|
285
|
-
elsif respond_to?(method_name, true)
|
|
286
|
-
send(method_name, role)
|
|
259
|
+
if respond_to?(method_name, true) || method_name.is_a?(Proc)
|
|
260
|
+
method_name.is_a?(Proc) ? instance_exec(role, &method_name) : send(method_name, role)
|
|
287
261
|
end
|
|
288
262
|
end
|
|
289
263
|
end
|
data/lib/role_fu/version.rb
CHANGED
data/lib/role_fu.rb
CHANGED
|
@@ -9,8 +9,26 @@ require_relative "role_fu/role"
|
|
|
9
9
|
require_relative "role_fu/role_assignment"
|
|
10
10
|
require_relative "role_fu/roleable"
|
|
11
11
|
require_relative "role_fu/resourceable"
|
|
12
|
+
require_relative "role_fu/permission"
|
|
13
|
+
require_relative "role_fu/ability"
|
|
14
|
+
require_relative "role_fu/cleanup"
|
|
15
|
+
require_relative "role_fu/adapters/cancancan"
|
|
16
|
+
require_relative "role_fu/adapters/pundit"
|
|
17
|
+
require_relative "role_fu/railtie" if defined?(Rails)
|
|
12
18
|
|
|
13
19
|
module RoleFu
|
|
14
20
|
class Error < StandardError; end
|
|
15
|
-
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def with_actor(actor)
|
|
24
|
+
Thread.current[:role_fu_actor] = actor
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
Thread.current[:role_fu_actor] = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current_actor
|
|
31
|
+
Thread.current[:role_fu_actor]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
16
34
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: role_fu
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alexey Poimtsev
|
|
@@ -132,19 +132,34 @@ files:
|
|
|
132
132
|
- gemfiles/rails_8.1.gemfile
|
|
133
133
|
- gemfiles/rails_8.1.gemfile.lock
|
|
134
134
|
- lefthook.yml
|
|
135
|
+
- lib/generators/role_fu/abilities_generator.rb
|
|
136
|
+
- lib/generators/role_fu/audit_generator.rb
|
|
135
137
|
- lib/generators/role_fu/install_generator.rb
|
|
138
|
+
- lib/generators/role_fu/job_generator.rb
|
|
136
139
|
- lib/generators/role_fu/role_fu_generator.rb
|
|
140
|
+
- lib/generators/role_fu/templates/abilities_migration.rb.erb
|
|
141
|
+
- lib/generators/role_fu/templates/audit_migration.rb.erb
|
|
142
|
+
- lib/generators/role_fu/templates/audit_model.rb.erb
|
|
143
|
+
- lib/generators/role_fu/templates/cleanup_job.rb.erb
|
|
137
144
|
- lib/generators/role_fu/templates/migration.rb.erb
|
|
145
|
+
- lib/generators/role_fu/templates/permission.rb.erb
|
|
138
146
|
- lib/generators/role_fu/templates/role.rb.erb
|
|
139
147
|
- lib/generators/role_fu/templates/role_assignment.rb.erb
|
|
140
148
|
- lib/generators/role_fu/templates/role_fu.rb
|
|
141
149
|
- lib/role_fu.rb
|
|
150
|
+
- lib/role_fu/ability.rb
|
|
151
|
+
- lib/role_fu/adapters/cancancan.rb
|
|
152
|
+
- lib/role_fu/adapters/pundit.rb
|
|
153
|
+
- lib/role_fu/cleanup.rb
|
|
142
154
|
- lib/role_fu/configuration.rb
|
|
155
|
+
- lib/role_fu/permission.rb
|
|
156
|
+
- lib/role_fu/railtie.rb
|
|
143
157
|
- lib/role_fu/resourceable.rb
|
|
144
158
|
- lib/role_fu/role.rb
|
|
145
159
|
- lib/role_fu/role_assignment.rb
|
|
146
160
|
- lib/role_fu/roleable.rb
|
|
147
161
|
- lib/role_fu/version.rb
|
|
162
|
+
- lib/tasks/role_fu.rake
|
|
148
163
|
- sig/role_fu.rbs
|
|
149
164
|
homepage: https://github.com/alec-c4/role_fu
|
|
150
165
|
licenses:
|