role_fu 0.4.0 → 0.6.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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module RoleFu
6
+ module Generators
7
+ # Run after bumping the role_fu gem version. Does not track "which
8
+ # version you upgraded from" - there's nowhere reliable to read that from
9
+ # (Gemfile.lock only ever has the current version). Instead it inspects
10
+ # the actual schema/config already installed and generates whatever is
11
+ # missing, so it's safe to run regardless of how many versions you skipped,
12
+ # and safe to run again if nothing changed.
13
+ class UpgradeGenerator < ActiveRecord::Generators::Base
14
+ source_root File.expand_path("templates", __dir__)
15
+
16
+ argument :name, type: :string, default: "upgrade"
17
+
18
+ def self.banner
19
+ "bin/rails generate role_fu:upgrade\n\n" \
20
+ "Detects which optional role_fu columns/config are missing for your\n" \
21
+ "installed models and generates the migrations needed to catch up."
22
+ end
23
+
24
+ desc ""
25
+
26
+ def run_upgrade_checks
27
+ applied = upgrade_checks.count { |check| send(check) }
28
+
29
+ say "role_fu is already up to date - nothing to generate.", :green if applied.zero?
30
+ end
31
+
32
+ private
33
+
34
+ # Each check returns true when it generated something or has a warning
35
+ # to report. Add new entries here as future optional columns/config land.
36
+ def upgrade_checks
37
+ [:check_permissions_field, :check_denormalized_role_names]
38
+ end
39
+
40
+ def check_permissions_field
41
+ return false unless table_exists?(:permissions)
42
+ return false if column_exists?(:permissions, :field)
43
+
44
+ say "Adding missing 'field' column to permissions (see README: Field-level granularity)", :yellow
45
+ migration_template "upgrade_permissions_field_migration.rb.erb", "db/migrate/role_fu_add_field_to_permissions.rb"
46
+ true
47
+ end
48
+
49
+ # Not auto-fixed: blindly renaming could collide two existing roles
50
+ # (e.g. "Admin" and "admin" both already present with their own
51
+ # role_assignments) into one, which needs a human to decide how to
52
+ # merge rather than a migration silently doing it.
53
+ def check_denormalized_role_names
54
+ role_class = RoleFu.configuration.role_class_name.safe_constantize
55
+ return false unless role_class&.table_exists?
56
+
57
+ denormalized = role_class.pluck(:name).any? { |name| name != RoleFu.normalize_role_name(name) }
58
+ return false unless denormalized
59
+
60
+ say "Found role names that don't match RoleFu's normalized format (e.g. \"Admin\" vs \"admin\").", :yellow
61
+ say "Not fixed automatically - two differently-cased roles may already coexist and need a human merge decision.", :yellow
62
+ say "Review, then backfill e.g.: UPDATE #{role_class.table_name} SET name = LOWER(name) WHERE name != LOWER(name)", :yellow
63
+ true
64
+ end
65
+
66
+ def table_exists?(name)
67
+ ActiveRecord::Base.connection.table_exists?(name.to_s)
68
+ rescue
69
+ false
70
+ end
71
+
72
+ def column_exists?(table, column)
73
+ ActiveRecord::Base.connection.column_exists?(table.to_s, column)
74
+ rescue
75
+ false
76
+ end
77
+
78
+ def migration_version
79
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails::VERSION::MAJOR >= 5
80
+ end
81
+ end
82
+ end
83
+ end
@@ -4,20 +4,55 @@ module RoleFu
4
4
  module Ability
5
5
  extend ActiveSupport::Concern
6
6
 
7
- def role_fu_can?(action, _resource = nil)
8
- role_fu_permissions.include?(action.to_s)
7
+ # @param action [String, Symbol] e.g. "posts.update"
8
+ # @param field [String, Symbol, nil] restrict the check to a single attribute
9
+ # (e.g. :description). A permission granted with field: nil is a wildcard
10
+ # that satisfies any field-scoped check for the same action.
11
+ def role_fu_can?(action, _resource = nil, field: nil)
12
+ fields = role_fu_permission_fields(action)
13
+ return false if fields.nil?
14
+
15
+ field.nil? || fields.include?(nil) || fields.include?(field.to_s)
16
+ end
17
+
18
+ # Fields explicitly granted for `action`.
19
+ # Returns :all when the action was granted without a field restriction,
20
+ # [] when the action isn't granted at all, or the explicit field list otherwise.
21
+ def role_fu_permitted_fields(action)
22
+ fields = role_fu_permission_fields(action)
23
+ return [] if fields.nil?
24
+ return :all if fields.include?(nil)
25
+
26
+ fields.to_a
9
27
  end
10
28
 
11
29
  def role_fu_permissions
30
+ role_fu_permissions_index.keys.to_set
31
+ end
32
+
33
+ private
34
+
35
+ def role_fu_permission_fields(action)
36
+ role_fu_permissions_index[action.to_s]
37
+ end
38
+
39
+ def role_fu_permissions_index
12
40
  return @_role_fu_permissions if defined?(@_role_fu_permissions) && @_role_fu_permissions
13
41
 
14
42
  permission_class = "Permission".safe_constantize
15
- return Set.new unless permission_class
43
+ return (@_role_fu_permissions = {}) unless permission_class
16
44
 
17
45
  scope = roles
18
46
  scope = filter_expired(scope) if respond_to?(:filter_expired, true)
19
47
 
20
- @_role_fu_permissions = scope.joins(:permissions).pluck("permissions.action").map(&:to_s).to_set
48
+ has_field_column = permission_class.column_names.include?("field")
49
+ columns = has_field_column ? %w[permissions.action permissions.field] : %w[permissions.action]
50
+ rows = scope.joins(:permissions).pluck(*columns)
51
+
52
+ @_role_fu_permissions = rows.each_with_object({}) do |row, index|
53
+ action, field = has_field_column ? row : [row, nil]
54
+ (index[action.to_s] ||= Set.new) << field
55
+ end
21
56
  end
22
57
  end
23
58
  end
@@ -13,11 +13,20 @@ module RoleFu
13
13
 
14
14
  if parts.size == 2
15
15
  subject_name, rule = parts
16
- begin
17
- subject_class = subject_name.classify.constantize
18
- can rule.to_sym, subject_class
16
+ subject_class = begin
17
+ subject_name.classify.constantize
19
18
  rescue NameError
20
- can rule.to_sym, subject_name.to_sym
19
+ subject_name.to_sym
20
+ end
21
+
22
+ # Field-scoped permissions map onto CanCanCan's own attribute
23
+ # restriction (`can :update, Post, :title`) instead of role_fu
24
+ # re-implementing attribute authorization itself.
25
+ fields = user.role_fu_permitted_fields(action)
26
+ if fields == :all
27
+ can rule.to_sym, subject_class
28
+ else
29
+ can rule.to_sym, subject_class, *fields.map(&:to_sym)
21
30
  end
22
31
  else
23
32
  can action.to_sym, :all
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RoleFu
4
+ # Raised by RoleFu::Authorizable guard methods. Rescue it yourself
5
+ # (e.g. `rescue_from RoleFu::AccessDenied` in ApplicationController) —
6
+ # role_fu does not register a rescue handler on your behalf.
7
+ class AccessDenied < RoleFu::Error
8
+ def initialize(message = "You are not authorized to perform this action.")
9
+ super
10
+ end
11
+ end
12
+
13
+ # Minimal, framework-agnostic authorization guard for apps that don't want
14
+ # to pull in a full authorization gem just to raise on a missing role.
15
+ #
16
+ # This is deliberately NOT an `allow`/`deny` DSL or a rule-resolution
17
+ # engine — for anything beyond "raise unless this check passes", reach for
18
+ # the Pundit or CanCanCan adapters instead. Mixing this concern into a
19
+ # controller (or any object that responds to `current_user`, or overrides
20
+ # `role_fu_current_user`) just gives you two guard-clause helpers built on
21
+ # top of the `has_role?` / `role_fu_can?` methods you already have.
22
+ module Authorizable
23
+ extend ActiveSupport::Concern
24
+
25
+ # Override this if the current user isn't exposed via `current_user`
26
+ # (e.g. in a job or service object).
27
+ def role_fu_current_user
28
+ current_user if respond_to?(:current_user)
29
+ end
30
+
31
+ def role_fu_authorize!(role_name, resource = nil)
32
+ user = role_fu_current_user
33
+ raise RoleFu::AccessDenied unless user&.respond_to?(:has_role?) && user.has_role?(role_name, resource)
34
+
35
+ true
36
+ end
37
+
38
+ def role_fu_can!(action, resource = nil, field: nil)
39
+ user = role_fu_current_user
40
+ raise RoleFu::AccessDenied unless user&.respond_to?(:role_fu_can?) && user.role_fu_can?(action, resource, field: field)
41
+
42
+ true
43
+ end
44
+ end
45
+ end
data/lib/role_fu/role.rb CHANGED
@@ -15,7 +15,15 @@ module RoleFu
15
15
 
16
16
  belongs_to :resource, polymorphic: true, optional: true
17
17
 
18
+ before_validation :normalize_role_fu_name
19
+
18
20
  validates :name, presence: true, uniqueness: {scope: [:resource_type, :resource_id]}
19
21
  end
22
+
23
+ private
24
+
25
+ def normalize_role_fu_name
26
+ self.name = RoleFu.normalize_role_name(name) if name.present?
27
+ end
20
28
  end
21
29
  end
@@ -24,7 +24,7 @@ module RoleFu
24
24
  role_table = RoleFu.role_class.table_name
25
25
  assignment_table = RoleFu.role_assignment_class.table_name
26
26
 
27
- query = joins(:roles).where(role_table => {name: role_name.to_s})
27
+ query = joins(:roles).where(role_table => {name: RoleFu.normalize_role_name(role_name)})
28
28
 
29
29
  if RoleFu.role_assignment_class.column_names.include?("expires_at")
30
30
  query = query.where("#{assignment_table}.expires_at IS NULL OR #{assignment_table}.expires_at > ?", Time.current)
@@ -136,7 +136,7 @@ module RoleFu
136
136
  return false if role_name.nil?
137
137
 
138
138
  if resource == :any
139
- filter_expired(roles.where(name: role_name.to_s)).exists?
139
+ filter_expired(roles.where(name: RoleFu.normalize_role_name(role_name))).exists?
140
140
  else
141
141
  return true if filter_expired(find_roles(role_name, resource)).any?
142
142
 
@@ -157,7 +157,7 @@ module RoleFu
157
157
  end
158
158
 
159
159
  def has_cached_role?(role_name, resource = nil)
160
- role_name = role_name.to_s
160
+ role_name = RoleFu.normalize_role_name(role_name)
161
161
  roles.to_a.any? do |role|
162
162
  next false unless role.name == role_name
163
163
 
@@ -241,14 +241,14 @@ module RoleFu
241
241
 
242
242
  def find_or_create_role(role_name, resource)
243
243
  RoleFu.role_class.find_or_create_by(
244
- name: role_name.to_s,
244
+ name: RoleFu.normalize_role_name(role_name),
245
245
  resource_type: resource_type_for(resource),
246
246
  resource_id: resource_id_for(resource)
247
247
  )
248
248
  end
249
249
 
250
250
  def find_roles(role_name, resource)
251
- query = roles.where(name: role_name.to_s)
251
+ query = roles.where(name: RoleFu.normalize_role_name(role_name))
252
252
 
253
253
  if resource.is_a?(Class)
254
254
  query.where(resource_type: resource.to_s, resource_id: nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RoleFu
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/role_fu.rb CHANGED
@@ -3,6 +3,10 @@
3
3
  require "active_support/concern"
4
4
  require "active_support/core_ext/string/inflections"
5
5
 
6
+ module RoleFu
7
+ class Error < StandardError; end
8
+ end
9
+
6
10
  require_relative "role_fu/version"
7
11
  require_relative "role_fu/configuration"
8
12
  require_relative "role_fu/role"
@@ -11,14 +15,13 @@ require_relative "role_fu/roleable"
11
15
  require_relative "role_fu/resourceable"
12
16
  require_relative "role_fu/permission"
13
17
  require_relative "role_fu/ability"
18
+ require_relative "role_fu/authorizable"
14
19
  require_relative "role_fu/cleanup"
15
20
  require_relative "role_fu/adapters/cancancan"
16
21
  require_relative "role_fu/adapters/pundit"
17
22
  require_relative "role_fu/railtie" if defined?(Rails)
18
23
 
19
24
  module RoleFu
20
- class Error < StandardError; end
21
-
22
25
  class << self
23
26
  def with_actor(actor)
24
27
  Thread.current[:role_fu_actor] = actor
@@ -30,5 +33,15 @@ module RoleFu
30
33
  def current_actor
31
34
  Thread.current[:role_fu_actor]
32
35
  end
36
+
37
+ # Canonicalizes a role name so that "Admin", "admin", "Admin User" and
38
+ # "admin-user" all resolve to the same underlying Role row.
39
+ # Note: does NOT singularize (unlike acl9) — that silently mangles
40
+ # ordinary business terms (e.g. "sales" -> "sale").
41
+ def normalize_role_name(name)
42
+ return nil if name.nil?
43
+
44
+ name.to_s.strip.gsub(/[\s-]+/, "_").underscore
45
+ end
33
46
  end
34
47
  end
data/mise.toml ADDED
@@ -0,0 +1,14 @@
1
+ [tools]
2
+ ruby = "4.0.6"
3
+
4
+ [tasks.test]
5
+ description = "Run the RSpec suite"
6
+ run = "bundle exec rake spec"
7
+
8
+ [tasks.lint]
9
+ description = "Run RuboCop"
10
+ run = "bundle exec rubocop"
11
+
12
+ [tasks.release]
13
+ description = "Build and publish the gem (rake release)"
14
+ run = "bundle exec rake release"
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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Poimtsev
@@ -116,6 +116,7 @@ executables: []
116
116
  extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
+ - ".ruby-version"
119
120
  - Appraisals
120
121
  - CHANGELOG.md
121
122
  - LICENSE.txt
@@ -142,10 +143,13 @@ files:
142
143
  - lib/generators/role_fu/templates/role.rb.erb
143
144
  - lib/generators/role_fu/templates/role_assignment.rb.erb
144
145
  - lib/generators/role_fu/templates/role_fu.rb
146
+ - lib/generators/role_fu/templates/upgrade_permissions_field_migration.rb.erb
147
+ - lib/generators/role_fu/upgrade_generator.rb
145
148
  - lib/role_fu.rb
146
149
  - lib/role_fu/ability.rb
147
150
  - lib/role_fu/adapters/cancancan.rb
148
151
  - lib/role_fu/adapters/pundit.rb
152
+ - lib/role_fu/authorizable.rb
149
153
  - lib/role_fu/cleanup.rb
150
154
  - lib/role_fu/configuration.rb
151
155
  - lib/role_fu/permission.rb
@@ -156,6 +160,7 @@ files:
156
160
  - lib/role_fu/roleable.rb
157
161
  - lib/role_fu/version.rb
158
162
  - lib/tasks/role_fu.rake
163
+ - mise.toml
159
164
  - sig/role_fu.rbs
160
165
  homepage: https://github.com/alec-c4/role_fu
161
166
  licenses:
@@ -164,6 +169,17 @@ metadata:
164
169
  homepage_uri: https://github.com/alec-c4/role_fu
165
170
  source_code_uri: https://github.com/alec-c4/role_fu
166
171
  changelog_uri: https://github.com/alec-c4/role_fu/blob/main/CHANGELOG.md
172
+ post_install_message: |
173
+ Thanks for installing role_fu 0.6.0!
174
+
175
+ Upgrading from an older role_fu? Run this to pick up any missing
176
+ schema/data changes (it inspects your app, so it's a no-op if there's
177
+ nothing to do):
178
+
179
+ rails generate role_fu:upgrade
180
+ rails db:migrate
181
+
182
+ Full changelog: https://github.com/alec-c4/role_fu/blob/main/CHANGELOG.md
167
183
  rdoc_options: []
168
184
  require_paths:
169
185
  - lib
@@ -171,14 +187,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
171
187
  requirements:
172
188
  - - ">="
173
189
  - !ruby/object:Gem::Version
174
- version: 3.2.0
190
+ version: 3.3.0
175
191
  required_rubygems_version: !ruby/object:Gem::Requirement
176
192
  requirements:
177
193
  - - ">="
178
194
  - !ruby/object:Gem::Version
179
195
  version: '0'
180
196
  requirements: []
181
- rubygems_version: 4.0.6
197
+ rubygems_version: 4.0.20
182
198
  specification_version: 4
183
199
  summary: A modern role management gem for Rails, replacing rolify.
184
200
  test_files: []