rbacan 0.3.2 → 0.5.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,165 @@
1
+ module Rbacan
2
+ class ConfigurationError < StandardError; end
3
+ class UnsafeLegacyApi < StandardError; end
4
+
5
+ class AuthorizationContext
6
+ ATTRIBUTES = %i[tenant property vertical subject at metadata].freeze
7
+
8
+ attr_reader(*ATTRIBUTES)
9
+
10
+ def initialize(tenant:, property: nil, vertical: nil, subject: nil, at: Time.now, metadata: {})
11
+ raise ArgumentError, 'authorization tenant must identify a tenant' unless identifying_tenant?(tenant)
12
+
13
+ @tenant = deep_copy_and_freeze(tenant, label: 'authorization tenant')
14
+ @property = deep_copy_and_freeze(property, label: 'authorization property')
15
+ @vertical = deep_copy_and_freeze(vertical, label: 'authorization vertical')
16
+ @subject = deep_copy_and_freeze(subject, label: 'authorization subject')
17
+ @at = copy_and_freeze_time(at)
18
+ @metadata = deep_copy_and_freeze(metadata, label: 'authorization metadata')
19
+ freeze
20
+ end
21
+
22
+ private
23
+
24
+ def deep_copy_and_freeze(value, label:)
25
+ copy = case value
26
+ when Hash
27
+ value.to_h do |key, item|
28
+ [ deep_copy_and_freeze(key, label: label), deep_copy_and_freeze(item, label: label) ]
29
+ end
30
+ when Array
31
+ value.map { |item| deep_copy_and_freeze(item, label: label) }
32
+ when String
33
+ value.dup
34
+ when Integer
35
+ value
36
+ when Float
37
+ raise ArgumentError, "#{label} supports only finite JSON numbers" unless value.finite?
38
+
39
+ value
40
+ when NilClass, TrueClass, FalseClass, Symbol
41
+ value
42
+ else
43
+ raise ArgumentError,
44
+ "#{label} supports only hashes, arrays, strings, numbers, booleans, nil, and symbols"
45
+ end
46
+ copy.freeze
47
+ end
48
+
49
+ def copy_and_freeze_time(value)
50
+ time = value.to_time if value.respond_to?(:to_time)
51
+ raise ArgumentError, 'authorization timestamp must be time-like' unless time.is_a?(Time)
52
+
53
+ time.dup.freeze
54
+ end
55
+
56
+ def identifying_tenant?(value)
57
+ case value
58
+ when String, Array, Hash
59
+ !value.empty?
60
+ when Integer
61
+ true
62
+ when Float
63
+ value.finite?
64
+ when Symbol
65
+ !value.to_s.empty?
66
+ else
67
+ false
68
+ end
69
+ end
70
+ end
71
+
72
+ class AuthorizationRequest
73
+ attr_reader :user, :permission, :role, :context
74
+
75
+ def initialize(user:, permission:, role:, context:)
76
+ @user = user
77
+ @permission = permission&.to_s&.dup&.freeze
78
+ @role = role&.to_s&.dup&.freeze
79
+ @context = context
80
+ freeze
81
+ end
82
+ end
83
+
84
+ class AssignmentRequest
85
+ attr_reader :user, :role, :context
86
+
87
+ def initialize(user:, role:, context:)
88
+ @user = user
89
+ @role = role.to_s.dup.freeze
90
+ @context = context
91
+ freeze
92
+ end
93
+ end
94
+
95
+ class << self
96
+ def ensure_legacy_mode!(method_name)
97
+ return if legacy_mode
98
+
99
+ raise UnsafeLegacyApi,
100
+ "#{method_name} is a legacy unscoped API; set Rbacan.legacy_mode = true to opt in"
101
+ end
102
+
103
+ def authorized?(user:, permission: nil, role: nil, context:)
104
+ validate_authorization_target!(permission, role)
105
+ validate_context!(context)
106
+ return false unless user && authorization_adapter
107
+
108
+ request = AuthorizationRequest.new(
109
+ user: user,
110
+ permission: permission&.to_s,
111
+ role: role&.to_s,
112
+ context: context
113
+ )
114
+
115
+ !!call_authorization_adapter(request)
116
+ end
117
+
118
+ def assign_role(user:, role:, context:)
119
+ call_assignment_adapter(:assign, user: user, role: role, context: context)
120
+ end
121
+
122
+ def revoke_role(user:, role:, context:)
123
+ call_assignment_adapter(:revoke, user: user, role: role, context: context)
124
+ end
125
+
126
+ private
127
+
128
+ def validate_authorization_target!(permission, role)
129
+ if permission.nil? ^ role.nil?
130
+ target = permission || role
131
+ raise ArgumentError, 'authorization permission or role cannot be blank' if target.to_s.empty?
132
+
133
+ return
134
+ end
135
+
136
+ raise ArgumentError, 'Provide exactly one permission or role'
137
+ end
138
+
139
+ def validate_context!(context)
140
+ raise ArgumentError, 'authorization context is required' unless context.is_a?(AuthorizationContext)
141
+ end
142
+
143
+ def call_authorization_adapter(request)
144
+ if authorization_adapter.respond_to?(:allowed?)
145
+ authorization_adapter.allowed?(request)
146
+ elsif authorization_adapter.respond_to?(:call)
147
+ authorization_adapter.call(request)
148
+ else
149
+ raise ConfigurationError, 'authorization adapter must respond to allowed? or call'
150
+ end
151
+ end
152
+
153
+ def call_assignment_adapter(action, user:, role:, context:)
154
+ validate_context!(context)
155
+ raise ArgumentError, 'assignment user is required' unless user
156
+ raise ArgumentError, 'assignment role is required' if role.nil? || role.to_s.empty?
157
+ unless assignment_adapter&.respond_to?(action)
158
+ raise ConfigurationError, "assignment adapter must respond to #{action}"
159
+ end
160
+
161
+ request = AssignmentRequest.new(user: user, role: role.to_s, context: context)
162
+ assignment_adapter.public_send(action, request)
163
+ end
164
+ end
165
+ end
@@ -1,10 +1,17 @@
1
1
  module Rbacan
2
2
  class NotAuthorized < StandardError
3
- attr_reader :permission, :role
3
+ attr_reader :permission, :role, :code
4
4
 
5
5
  def initialize(message = nil, permission: nil, role: nil)
6
6
  @permission = permission
7
- @role = role
7
+ @role = role
8
+ @code = if permission
9
+ 'rbacan.not_authorized.permission'
10
+ elsif role
11
+ 'rbacan.not_authorized.role'
12
+ else
13
+ 'rbacan.not_authorized'
14
+ end
8
15
  super(message || default_message)
9
16
  end
10
17
 
@@ -16,7 +23,7 @@ module Rbacan
16
23
  elsif role
17
24
  "Not authorized: missing role '#{role}'"
18
25
  else
19
- "Not authorized"
26
+ 'Not authorized'
20
27
  end
21
28
  end
22
29
  end
@@ -7,72 +7,110 @@ module Rbacan
7
7
  included do
8
8
  has_many :user_roles,
9
9
  class_name: Rbacan.user_role_class,
10
- dependent: :destroy
10
+ dependent: :destroy
11
11
  accepts_nested_attributes_for :user_roles
12
12
  has_many :roles,
13
13
  class_name: Rbacan.role_class,
14
- through: :user_roles
14
+ through: :user_roles
15
15
 
16
16
  # Returns users that have the given role (by name).
17
- scope :with_role, ->(role_name) {
18
- joins(:roles).where(Rbacan.role_table => { name: role_name.to_s })
17
+ scope :with_role, lambda { |role_name|
18
+ Rbacan.ensure_legacy_mode!(:with_role)
19
+ joins(:roles).where(Rbacan.role_table => { Rbacan.role_key => role_name.to_s })
19
20
  }
20
21
 
21
22
  # Returns users that have the given permission via any of their roles.
22
- scope :with_permission, ->(permission_name) {
23
- joins(roles: { role_permissions: :permission })
24
- .where(Rbacan.permission_table => { name: permission_name.to_s })
23
+ scope :with_permission, lambda { |permission_name|
24
+ Rbacan.ensure_legacy_mode!(:with_permission)
25
+ joins(roles: { role_permissions: :permission })
26
+ .where(Rbacan.permission_table => { Rbacan.permission_key => permission_name.to_s })
25
27
  }
26
28
 
27
29
  # Assigns a role to the user. Idempotent — safe to call multiple times.
28
- # Fix: was find_or_initialize_by (never persisted); now find_or_create_by.
29
- def assign_role(role_name)
30
- assigned_role = Rbacan.role_class.constantize.find_by_name(role_name.to_s)
30
+ # When tenant_scoped is enabled, pass tenant_id: to scope the assignment.
31
+ def assign_role(role_name, tenant_id: nil)
32
+ Rbacan.ensure_legacy_mode!(:assign_role)
33
+ assigned_role = Rbacan.role_class.constantize.find_by(Rbacan.role_key => role_name.to_s)
31
34
  raise ArgumentError, "Role '#{role_name}' not found" unless assigned_role
32
35
 
33
- self.user_roles.find_or_create_by(role_id: assigned_role.id)
36
+ attrs = { role_id: assigned_role.id }
37
+ attrs[:tenant_id] = tenant_id if Rbacan.tenant_scoped
38
+ user_roles.find_or_create_by(attrs)
34
39
  end
35
40
 
36
41
  # Removes a role from the user.
37
- # Fix: was Rbacan::UserRole.where(user_id: ...) now uses the scoped
38
- # association so it respects the configured class and any FK setup.
39
- def remove_role(role_name)
40
- removed_role = Rbacan.role_class.constantize.find_by_name(role_name.to_s)
42
+ # When tenant_scoped is enabled, pass tenant_id: to remove from a specific tenant.
43
+ def remove_role(role_name, tenant_id: nil)
44
+ Rbacan.ensure_legacy_mode!(:remove_role)
45
+ removed_role = Rbacan.role_class.constantize.find_by(Rbacan.role_key => role_name.to_s)
41
46
  return unless removed_role
42
47
 
43
- self.user_roles.where(role_id: removed_role.id).destroy_all
48
+ scope = user_roles.where(role_id: removed_role.id)
49
+ scope = scope.where(tenant_id: tenant_id) if Rbacan.tenant_scoped
50
+ scope.destroy_all
44
51
  end
45
52
 
46
53
  # Returns true if the user has the named permission via any of their roles.
47
- # Fix: was two queries; now a single EXISTS query via association joins.
48
- def can?(permission_name)
49
- self.roles
50
- .joins(role_permissions: :permission)
51
- .where(Rbacan.permission_table => { name: permission_name.to_s })
52
- .exists?
54
+ def can?(permission_name)
55
+ Rbacan.ensure_legacy_mode!(:can?)
56
+ roles
57
+ .joins(role_permissions: :permission)
58
+ .where(Rbacan.permission_table => { Rbacan.permission_key => permission_name.to_s })
59
+ .exists?
60
+ end
61
+
62
+ # Returns true if the user has the named permission in a specific tenant context.
63
+ # Checks both global role assignments (tenant_id NULL) and tenant-specific ones.
64
+ def can_in_tenant?(permission_name, tenant_id)
65
+ Rbacan.ensure_legacy_mode!(:can_in_tenant?)
66
+ roles
67
+ .joins(role_permissions: :permission)
68
+ .where(Rbacan.permission_table => { Rbacan.permission_key => permission_name.to_s })
69
+ .where(Rbacan.user_role_table => { tenant_id: [tenant_id, nil] })
70
+ .exists?
53
71
  end
54
72
 
55
73
  # Returns true if the user has the named role.
56
- def has_role?(role_name)
57
- self.roles.where(name: role_name.to_s).exists?
74
+ def has_role?(role_name)
75
+ Rbacan.ensure_legacy_mode!(:has_role?)
76
+ roles.where(Rbacan.role_key => role_name.to_s).exists?
77
+ end
78
+
79
+ # Returns true if the user has the named role in a specific tenant context.
80
+ # Checks both global role assignments (tenant_id NULL) and tenant-specific ones.
81
+ def has_role_in_tenant?(role_name, tenant_id)
82
+ Rbacan.ensure_legacy_mode!(:has_role_in_tenant?)
83
+ user_roles
84
+ .joins(:role)
85
+ .where(Rbacan.role_table => { Rbacan.role_key => role_name.to_s })
86
+ .where(Rbacan.user_role_table => { tenant_id: [tenant_id, nil] })
87
+ .exists?
58
88
  end
59
89
 
60
90
  # Returns true if the user has ANY of the listed roles.
61
- def has_any_role?(*role_names)
62
- self.roles.where(name: role_names.map(&:to_s)).exists?
91
+ def has_any_role?(*role_names)
92
+ Rbacan.ensure_legacy_mode!(:has_any_role?)
93
+ roles.where(Rbacan.role_key => role_names.map(&:to_s)).exists?
63
94
  end
64
95
 
65
96
  # Returns true if the user has ALL of the listed permissions.
66
- # Uses a single query: counts distinct matching permissions and compares
67
- # to the requested set size.
68
- def can_all?(*permission_names)
69
- names = permission_names.map(&:to_s)
70
- self.roles
71
- .joins(role_permissions: :permission)
72
- .where(Rbacan.permission_table => { name: names })
73
- .select("#{Rbacan.permission_table}.name")
74
- .distinct
75
- .count == names.uniq.size
97
+ def can_all?(*permission_names)
98
+ Rbacan.ensure_legacy_mode!(:can_all?)
99
+ names = permission_names.map(&:to_s)
100
+ roles
101
+ .joins(role_permissions: :permission)
102
+ .where(Rbacan.permission_table => { Rbacan.permission_key => names })
103
+ .select("#{Rbacan.permission_table}.#{Rbacan.permission_key}")
104
+ .distinct
105
+ .count == names.uniq.size
106
+ end
107
+
108
+ # Returns roles assigned to this user for a specific tenant.
109
+ # Includes global role assignments (tenant_id NULL).
110
+ def roles_for_tenant(tenant_id)
111
+ Rbacan.ensure_legacy_mode!(:roles_for_tenant)
112
+ roles
113
+ .where(Rbacan.user_role_table => { tenant_id: [tenant_id, nil] })
76
114
  end
77
115
  end
78
116
  end
@@ -3,25 +3,33 @@ module Rbacan
3
3
 
4
4
  # Creates roles by name. Idempotent — skips existing ones.
5
5
  def self.create_roles(roles)
6
+ Rbacan.ensure_legacy_mode!(:create_roles)
7
+
6
8
  roles.each do |role|
7
- Rbacan.role_class.constantize.find_or_create_by(name: role.to_s)
9
+ Rbacan.role_class.constantize.find_or_create_by(Rbacan.role_key => role.to_s)
8
10
  end
9
11
  end
10
12
 
11
13
  # Creates permissions by name. Idempotent — skips existing ones.
12
14
  def self.create_permissions(permissions)
15
+ Rbacan.ensure_legacy_mode!(:create_permissions)
16
+
13
17
  permissions.each do |permission|
14
- Rbacan.permission_class.constantize.find_or_create_by(name: permission.to_s)
18
+ Rbacan.permission_class.constantize.find_or_create_by(Rbacan.permission_key => permission.to_s)
15
19
  end
16
20
  end
17
21
 
18
22
  # Assigns a list of permissions to a role. Idempotent — skips duplicates.
19
23
  def self.assign_permissions_to_role(role_name, permissions)
20
- chosen_role = Rbacan.role_class.constantize.find_by_name(role_name.to_s)
24
+ Rbacan.ensure_legacy_mode!(:assign_permissions_to_role)
25
+
26
+ chosen_role = Rbacan.role_class.constantize.find_by(Rbacan.role_key => role_name.to_s)
21
27
  return unless chosen_role
22
28
 
23
29
  permissions.each do |permission|
24
- given_permission = Rbacan.permission_class.constantize.find_by_name(permission.to_s)
30
+ given_permission = Rbacan.permission_class.constantize.find_by(
31
+ Rbacan.permission_key => permission.to_s
32
+ )
25
33
  next unless given_permission
26
34
 
27
35
  Rbacan.role_permission_class.constantize.find_or_create_by(
@@ -1,30 +1,22 @@
1
1
  module Rbacan
2
2
  class RouteConstraint
3
- # Constrains routes to users that have a given role or permission.
4
- #
5
- # Usage in config/routes.rb:
6
- # constraints Rbacan::RouteConstraint.new(role: :admin) do
7
- # namespace :admin do
8
- # resources :users
9
- # end
10
- # end
11
- #
12
- # constraints Rbacan::RouteConstraint.new(permission: :access_admin_panel) do
13
- # ...
14
- # end
15
- def initialize(role: nil, permission: nil)
3
+ def initialize(role: nil, permission: nil, user_resolver: nil, context_resolver: nil)
16
4
  if role.nil? && permission.nil?
17
- raise ArgumentError, "Provide exactly one of role: or permission:"
5
+ raise ArgumentError, 'Provide exactly one role: or permission:'
18
6
  end
19
7
  if role && permission
20
- raise ArgumentError, "Provide exactly one of role: or permission:, not both"
8
+ raise ArgumentError, 'Provide exactly one role: or permission:, not both'
21
9
  end
22
10
 
23
- @role = role&.to_s
11
+ @role = role&.to_s
24
12
  @permission = permission&.to_s
13
+ @user_resolver = user_resolver
14
+ @context_resolver = context_resolver
25
15
  end
26
16
 
27
17
  def matches?(request)
18
+ return hardened_match?(request) unless Rbacan.legacy_mode
19
+
28
20
  user = current_user_from(request)
29
21
  return false unless user
30
22
 
@@ -33,12 +25,23 @@ module Rbacan
33
25
 
34
26
  private
35
27
 
28
+ def hardened_match?(request)
29
+ return false unless @user_resolver&.respond_to?(:call) && @context_resolver&.respond_to?(:call)
30
+
31
+ Rbacan.authorized?(
32
+ user: @user_resolver.call(request),
33
+ permission: @permission,
34
+ role: @role,
35
+ context: @context_resolver.call(request)
36
+ )
37
+ rescue Rbacan::ConfigurationError, ArgumentError
38
+ false
39
+ end
40
+
36
41
  def current_user_from(request)
37
- # Warden (Devise) path — most common, no extra query needed.
38
42
  warden = request.env['warden']
39
43
  return warden.user if warden&.authenticated?
40
44
 
41
- # Fallback: manual session lookup for apps not using Warden.
42
45
  user_id = request.session[:user_id] || request.session['user_id']
43
46
  return nil unless user_id
44
47
 
@@ -1,3 +1,3 @@
1
1
  module Rbacan
2
- VERSION = "0.3.2"
2
+ VERSION = '0.5.0'
3
3
  end
@@ -1,13 +1,18 @@
1
1
  module Rbacan
2
2
  module ViewHelpers
3
- # Renders the block only if the current user has the given permission.
4
- #
5
- # Usage in views:
6
- # <% authorized?(:publish_post) do %>
7
- # <%= link_to "Publish", publish_post_path(@post) %>
8
- # <% end %>
9
- def authorized?(permission, &block)
10
- return unless current_user&.can?(permission.to_s)
3
+ # Renders the block only when the host authorization adapter permits it.
4
+ def authorized?(permission, context: nil, &block)
5
+ allowed = if Rbacan.legacy_mode
6
+ current_user&.can?(permission.to_s)
7
+ else
8
+ Rbacan.authorized?(
9
+ user: current_user,
10
+ permission: permission,
11
+ context: context || rbacan_authorization_context
12
+ )
13
+ end
14
+ return unless allowed
15
+
11
16
  capture(&block) if block_given?
12
17
  end
13
18
  end
data/lib/rbacan.rb CHANGED
@@ -1,20 +1,34 @@
1
- require "rbacan/version"
2
- require "rbacan/not_authorized"
3
- require "rbacan/permittable"
4
- require "rbacan/engine"
5
- require "rbacan/roles_and_permissions"
6
- require "rbacan/authorization"
7
- require "rbacan/view_helpers"
8
- require "rbacan/route_constraint"
1
+ require 'rbacan/version'
2
+ require 'rbacan/not_authorized'
3
+ require 'rbacan/host_authorization'
4
+ require 'rbacan/authorizable'
5
+ require 'rbacan/permittable'
6
+ require 'rbacan/engine'
7
+ require 'rbacan/roles_and_permissions'
8
+ require 'rbacan/authorization'
9
+ require 'rbacan/view_helpers'
10
+ require 'rbacan/route_constraint'
9
11
 
10
12
  module Rbacan
13
+ mattr_accessor :authorization_adapter
14
+ @@authorization_adapter = nil
15
+
16
+ mattr_accessor :assignment_adapter
17
+ @@assignment_adapter = nil
18
+
19
+ # Legacy Active Record mutation/query APIs are opt-in in the hardened API.
20
+ mattr_accessor :legacy_mode
21
+ @@legacy_mode = false
22
+
11
23
  mattr_accessor :permittable_class
12
24
  @@permittable_class = 'User'
13
25
 
14
26
  mattr_accessor :role_class
15
27
  mattr_accessor :role_table
28
+ mattr_accessor :role_key
16
29
  @@role_class = 'Rbacan::Role'
17
30
  @@role_table = 'roles'
31
+ @@role_key = :name
18
32
 
19
33
  mattr_accessor :user_role_class
20
34
  mattr_accessor :user_role_table
@@ -23,8 +37,10 @@ module Rbacan
23
37
 
24
38
  mattr_accessor :permission_class
25
39
  mattr_accessor :permission_table
40
+ mattr_accessor :permission_key
26
41
  @@permission_class = 'Rbacan::Permission'
27
42
  @@permission_table = 'permissions'
43
+ @@permission_key = :name
28
44
 
29
45
  mattr_accessor :role_permission_class
30
46
  mattr_accessor :role_permission_table
@@ -39,24 +55,57 @@ module Rbacan
39
55
  mattr_accessor :unauthorized_redirect_path
40
56
  @@unauthorized_redirect_path = '/'
41
57
 
58
+ # Primary key type for generated migrations.
59
+ # nil = auto-detect from Rails generator config (primary_key_type).
60
+ # Set to :uuid for UUID primary keys, or :bigint for standard integer keys.
61
+ mattr_accessor :primary_key_type
62
+ @@primary_key_type = nil
63
+
64
+ # When true, adds tenant_id to roles and user_roles tables,
65
+ # enabling tenant-scoped roles and role assignments.
66
+ # Permissions remain global (no tenant_id) regardless of this setting.
67
+ mattr_accessor :tenant_scoped
68
+ @@tenant_scoped = false
69
+
70
+ # The name of your tenant model class (default: "Tenant").
71
+ # Only used when tenant_scoped is true.
72
+ mattr_accessor :tenant_class
73
+ @@tenant_class = 'Tenant'
74
+
75
+ # Resolves the effective primary key type.
76
+ # Checks the explicit config first, then falls back to the Rails generator config.
77
+ def self.resolve_primary_key_type
78
+ return primary_key_type if primary_key_type.present?
79
+
80
+ Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
42
85
  def self.create_role(role_name)
43
- @@role_class.constantize.create(name: role_name)
86
+ ensure_legacy_mode!(:create_role)
87
+
88
+ @@role_class.constantize.create(@@role_key => role_name.to_s)
44
89
  end
45
90
 
46
91
  def self.create_permission(permission_name)
47
- @@permission_class.constantize.create(name: permission_name)
92
+ ensure_legacy_mode!(:create_permission)
93
+
94
+ @@permission_class.constantize.create(@@permission_key => permission_name.to_s)
48
95
  end
49
96
 
50
97
  def self.assign_permission_to_role(role_name, permission_name)
51
- chosen_role = @@role_class.constantize.find_by_name(role_name)
52
- given_permission = @@permission_class.constantize.find_by_name(permission_name)
98
+ ensure_legacy_mode!(:assign_permission_to_role)
99
+
100
+ chosen_role = @@role_class.constantize.find_by(@@role_key => role_name.to_s)
101
+ given_permission = @@permission_class.constantize.find_by(@@permission_key => permission_name.to_s)
53
102
  @@role_permission_class.constantize.create(
54
- role_id: chosen_role.id,
103
+ role_id: chosen_role.id,
55
104
  permission_id: given_permission.id
56
105
  )
57
106
  end
58
107
 
59
- def self.configure(&block)
108
+ def self.configure
60
109
  yield self
61
110
  end
62
111