cccux 0.1.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 +7 -0
- data/CHANGELOG.md +67 -0
- data/MIT-LICENSE +20 -0
- data/README.md +382 -0
- data/Rakefile +8 -0
- data/app/assets/config/cccux_manifest.js +1 -0
- data/app/assets/stylesheets/cccux/application.css +102 -0
- data/app/controllers/cccux/ability_permissions_controller.rb +271 -0
- data/app/controllers/cccux/application_controller.rb +37 -0
- data/app/controllers/cccux/authorization_controller.rb +10 -0
- data/app/controllers/cccux/cccux_controller.rb +64 -0
- data/app/controllers/cccux/dashboard_controller.rb +172 -0
- data/app/controllers/cccux/home_controller.rb +19 -0
- data/app/controllers/cccux/roles_controller.rb +290 -0
- data/app/controllers/cccux/simple_controller.rb +7 -0
- data/app/controllers/cccux/users_controller.rb +112 -0
- data/app/controllers/concerns/cccux/application_controller_concern.rb +32 -0
- data/app/helpers/cccux/application_helper.rb +4 -0
- data/app/helpers/cccux/authorization_helper.rb +228 -0
- data/app/jobs/cccux/application_job.rb +4 -0
- data/app/mailers/cccux/application_mailer.rb +6 -0
- data/app/models/cccux/ability.rb +142 -0
- data/app/models/cccux/ability_permission.rb +61 -0
- data/app/models/cccux/application_record.rb +5 -0
- data/app/models/cccux/role.rb +90 -0
- data/app/models/cccux/role_ability.rb +49 -0
- data/app/models/cccux/user_role.rb +42 -0
- data/app/models/concerns/cccux/authorizable.rb +25 -0
- data/app/models/concerns/cccux/scoped_ownership.rb +183 -0
- data/app/models/concerns/cccux/user_concern.rb +87 -0
- data/app/views/cccux/ability_permissions/edit.html.erb +58 -0
- data/app/views/cccux/ability_permissions/index.html.erb +108 -0
- data/app/views/cccux/ability_permissions/new.html.erb +308 -0
- data/app/views/cccux/dashboard/index.html.erb +69 -0
- data/app/views/cccux/dashboard/model_discovery.html.erb +148 -0
- data/app/views/cccux/home/index.html.erb +42 -0
- data/app/views/cccux/roles/_flash.html.erb +10 -0
- data/app/views/cccux/roles/_form.html.erb +78 -0
- data/app/views/cccux/roles/_role.html.erb +67 -0
- data/app/views/cccux/roles/edit.html.erb +317 -0
- data/app/views/cccux/roles/index.html.erb +51 -0
- data/app/views/cccux/roles/new.html.erb +3 -0
- data/app/views/cccux/roles/show.html.erb +99 -0
- data/app/views/cccux/users/edit.html.erb +117 -0
- data/app/views/cccux/users/index.html.erb +99 -0
- data/app/views/cccux/users/new.html.erb +94 -0
- data/app/views/cccux/users/show.html.erb +138 -0
- data/app/views/layouts/cccux/admin.html.erb +168 -0
- data/app/views/layouts/cccux/application.html.erb +17 -0
- data/app/views/shared/_footer.html.erb +101 -0
- data/config/routes.rb +63 -0
- data/db/migrate/20250626194001_create_cccux_roles.rb +15 -0
- data/db/migrate/20250626194007_create_cccux_ability_permissions.rb +18 -0
- data/db/migrate/20250626194011_create_cccux_user_roles.rb +13 -0
- data/db/migrate/20250626194016_create_cccux_role_abilities.rb +10 -0
- data/db/migrate/20250627170611_add_owned_to_cccux_role_abilities.rb +9 -0
- data/db/migrate/20250705193709_add_context_to_cccux_role_abilities.rb +9 -0
- data/db/migrate/20250706214415_add_ownership_configuration_to_role_abilities.rb +21 -0
- data/db/seeds.rb +136 -0
- data/lib/cccux/engine.rb +50 -0
- data/lib/cccux/version.rb +3 -0
- data/lib/cccux.rb +7 -0
- data/lib/tasks/cccux.rake +703 -0
- data/lib/tasks/view_helpers.rake +274 -0
- metadata +188 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cccux
|
|
4
|
+
class Ability
|
|
5
|
+
include CanCan::Ability
|
|
6
|
+
|
|
7
|
+
def initialize(user, context = nil)
|
|
8
|
+
user ||= User.new # guest user (not logged in)
|
|
9
|
+
@context = context || {}
|
|
10
|
+
|
|
11
|
+
# Track which permissions have been defined to avoid conflicts
|
|
12
|
+
@defined_permissions = Set.new
|
|
13
|
+
|
|
14
|
+
if user.persisted?
|
|
15
|
+
# Authenticated user - use their assigned roles in priority order
|
|
16
|
+
user_roles = Cccux::UserRole.active.for_user(user).includes(:role).joins(:role).order('cccux_roles.priority DESC')
|
|
17
|
+
user_roles.each do |user_role|
|
|
18
|
+
role = user_role.role
|
|
19
|
+
apply_role_abilities(role, user)
|
|
20
|
+
end
|
|
21
|
+
else
|
|
22
|
+
# Guest user (not logged in) - use "Guest" role permissions
|
|
23
|
+
guest_role = Cccux::Role.find_by(name: 'Guest')
|
|
24
|
+
if guest_role
|
|
25
|
+
apply_role_abilities(guest_role, user)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def apply_role_abilities(role, user)
|
|
33
|
+
return unless role
|
|
34
|
+
|
|
35
|
+
# Get all abilities for this role
|
|
36
|
+
role_abilities = Cccux::RoleAbility.includes(:ability_permission)
|
|
37
|
+
.where(role: role)
|
|
38
|
+
.joins(:ability_permission)
|
|
39
|
+
.where(cccux_ability_permissions: { active: true })
|
|
40
|
+
|
|
41
|
+
role_abilities.each do |role_ability|
|
|
42
|
+
permission = role_ability.ability_permission
|
|
43
|
+
|
|
44
|
+
# Determine the model class
|
|
45
|
+
model_class = resolve_model_class(permission.subject)
|
|
46
|
+
next unless model_class
|
|
47
|
+
|
|
48
|
+
# Check if this permission has already been defined by a higher priority role
|
|
49
|
+
permission_key = "#{permission.action}:#{permission.subject}:#{role_ability.context}"
|
|
50
|
+
next if @defined_permissions.include?(permission_key)
|
|
51
|
+
|
|
52
|
+
# Mark this permission as defined
|
|
53
|
+
@defined_permissions.add(permission_key)
|
|
54
|
+
|
|
55
|
+
# Define the ability based on context and ownership
|
|
56
|
+
apply_access_ability(role_ability, permission, model_class, user)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def apply_access_ability(role_ability, permission, model_class, user)
|
|
61
|
+
action = permission.action.to_sym
|
|
62
|
+
|
|
63
|
+
# For User resource, keep owned logic for now
|
|
64
|
+
if permission.subject == 'User'
|
|
65
|
+
if role_ability.context == 'owned' || (role_ability.owned && user&.persisted?)
|
|
66
|
+
apply_owned_ability(action, model_class, user, role_ability)
|
|
67
|
+
else
|
|
68
|
+
can action, model_class
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
# For all other resources, use global/owned (contextual is now handled by owned with configuration)
|
|
72
|
+
case role_ability.access_type
|
|
73
|
+
when 'global'
|
|
74
|
+
can action, model_class
|
|
75
|
+
when 'owned'
|
|
76
|
+
apply_owned_ability(action, model_class, user, role_ability)
|
|
77
|
+
else
|
|
78
|
+
# Default: deny access if access_type is not recognized
|
|
79
|
+
Rails.logger.warn "CCCUX: Unknown access_type '#{role_ability.access_type}' for #{model_class.name}, denying access"
|
|
80
|
+
# Don't grant any permissions - CanCanCan denies by default
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply_owned_ability(action, model_class, user, role_ability = nil)
|
|
86
|
+
# 1. Dynamic ownership configuration
|
|
87
|
+
if role_ability && role_ability.ownership_source.present?
|
|
88
|
+
ownership_model = role_ability.ownership_source.constantize rescue nil
|
|
89
|
+
if ownership_model && user&.persisted?
|
|
90
|
+
# Parse conditions (should be a JSON string or nil)
|
|
91
|
+
conditions = role_ability.ownership_conditions.present? ? JSON.parse(role_ability.ownership_conditions) : {}
|
|
92
|
+
foreign_key = conditions["foreign_key"] || (model_class.name.foreign_key)
|
|
93
|
+
user_key = conditions["user_key"] || "user_id"
|
|
94
|
+
# Find all records owned by user via the join model
|
|
95
|
+
owned_ids = ownership_model.where(user_key => user.id).pluck(foreign_key)
|
|
96
|
+
can action, model_class, id: owned_ids if owned_ids.any?
|
|
97
|
+
else
|
|
98
|
+
Rails.logger.warn "CCCUX: Invalid ownership_source #{role_ability.ownership_source} for #{model_class.name}"
|
|
99
|
+
can action, model_class, id: []
|
|
100
|
+
end
|
|
101
|
+
# 2. Model custom owned_by?
|
|
102
|
+
elsif model_class.respond_to?(:owned_by?)
|
|
103
|
+
can action, model_class do |record|
|
|
104
|
+
record.owned_by?(user)
|
|
105
|
+
end
|
|
106
|
+
# 3. Model custom scoped_for_user
|
|
107
|
+
elsif model_class.respond_to?(:scoped_for_user)
|
|
108
|
+
scoped_records = model_class.scoped_for_user(user)
|
|
109
|
+
if scoped_records.is_a?(ActiveRecord::Relation)
|
|
110
|
+
ids = scoped_records.pluck(:id)
|
|
111
|
+
can action, model_class, id: ids if ids.any?
|
|
112
|
+
else
|
|
113
|
+
can action, model_class, scoped_records
|
|
114
|
+
end
|
|
115
|
+
# 4. Standard user_id
|
|
116
|
+
elsif model_class.column_names.include?('user_id')
|
|
117
|
+
can action, model_class, user_id: user.id
|
|
118
|
+
# 5. Standard creator_id
|
|
119
|
+
elsif model_class.column_names.include?('creator_id')
|
|
120
|
+
can action, model_class, creator_id: user.id
|
|
121
|
+
else
|
|
122
|
+
# Default: deny access when no ownership pattern is found
|
|
123
|
+
Rails.logger.warn "CCCUX: No ownership pattern found for #{model_class.name}, denying access"
|
|
124
|
+
# Don't grant any permissions - CanCanCan denies by default
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolve_model_class(subject)
|
|
129
|
+
# Handle namespaced models
|
|
130
|
+
if subject.include?('::')
|
|
131
|
+
subject.constantize
|
|
132
|
+
else
|
|
133
|
+
# Try to find the model in the host app
|
|
134
|
+
Object.const_get(subject)
|
|
135
|
+
end
|
|
136
|
+
rescue NameError
|
|
137
|
+
# If the model doesn't exist, we can't define permissions for it
|
|
138
|
+
Rails.logger.warn "CCCUX: Model '#{subject}' not found, skipping permission"
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Cccux
|
|
2
|
+
class AbilityPermission < ApplicationRecord
|
|
3
|
+
self.table_name = 'cccux_ability_permissions'
|
|
4
|
+
|
|
5
|
+
has_many :role_abilities, dependent: :destroy, class_name: 'Cccux::RoleAbility'
|
|
6
|
+
has_many :roles, through: :role_abilities, class_name: 'Cccux::Role'
|
|
7
|
+
|
|
8
|
+
validates :action, presence: true
|
|
9
|
+
validates :subject, presence: true
|
|
10
|
+
validates :action, uniqueness: { scope: :subject }
|
|
11
|
+
|
|
12
|
+
scope :for_subject, ->(subject) { where(subject: subject) }
|
|
13
|
+
scope :for_action, ->(action) { where(action: action) }
|
|
14
|
+
|
|
15
|
+
def display_name
|
|
16
|
+
"#{action.humanize} #{subject}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def role_count
|
|
20
|
+
roles.count
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Determine if this permission supports ownership controls
|
|
24
|
+
def supports_ownership?
|
|
25
|
+
# CCCUX system models don't support ownership
|
|
26
|
+
return false if subject.start_with?('Cccux::')
|
|
27
|
+
|
|
28
|
+
# You could add more sophisticated logic here:
|
|
29
|
+
# - Check if the model has ownership methods (owned_by?, scoped_for_user)
|
|
30
|
+
# - Check against a configuration list
|
|
31
|
+
# - Check if the model is in a specific namespace
|
|
32
|
+
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get the model class for this permission
|
|
37
|
+
def model_class
|
|
38
|
+
return nil unless subject.present?
|
|
39
|
+
|
|
40
|
+
if subject.include?('::')
|
|
41
|
+
subject.constantize
|
|
42
|
+
else
|
|
43
|
+
Object.const_get(subject)
|
|
44
|
+
end
|
|
45
|
+
rescue NameError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if the model has ownership capabilities
|
|
50
|
+
def model_supports_ownership?
|
|
51
|
+
klass = model_class
|
|
52
|
+
return false unless klass
|
|
53
|
+
|
|
54
|
+
# Check if model has ownership methods
|
|
55
|
+
klass.respond_to?(:owned_by?) ||
|
|
56
|
+
klass.respond_to?(:scoped_for_user) ||
|
|
57
|
+
klass.column_names.include?('user_id') ||
|
|
58
|
+
klass.column_names.include?('creator_id')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cccux
|
|
4
|
+
class Role < ApplicationRecord
|
|
5
|
+
self.table_name = 'cccux_roles'
|
|
6
|
+
|
|
7
|
+
has_many :user_roles, dependent: :destroy, class_name: 'Cccux::UserRole'
|
|
8
|
+
has_many :users, through: :user_roles, class_name: 'User'
|
|
9
|
+
has_many :role_abilities, dependent: :destroy, class_name: 'Cccux::RoleAbility'
|
|
10
|
+
has_many :ability_permissions, through: :role_abilities, class_name: 'Cccux::AbilityPermission'
|
|
11
|
+
|
|
12
|
+
validates :name, presence: true, uniqueness: true
|
|
13
|
+
validates :priority, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
14
|
+
|
|
15
|
+
after_initialize :set_default_priority, if: :new_record?
|
|
16
|
+
|
|
17
|
+
scope :active, -> { where(active: true) }
|
|
18
|
+
scope :ordered, -> { order(:priority, :name) }
|
|
19
|
+
|
|
20
|
+
def self.default_roles
|
|
21
|
+
[
|
|
22
|
+
{ name: 'Guest', description: 'Unauthenticated users', priority: 100 },
|
|
23
|
+
{ name: 'Basic User', description: 'Standard authenticated users', priority: 50 },
|
|
24
|
+
{ name: 'Role Manager', description: 'Can manage roles and permissions', priority: 25 },
|
|
25
|
+
{ name: 'Administrator', description: 'Full system access', priority: 1 }
|
|
26
|
+
]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assign_permission(permission)
|
|
30
|
+
return false unless permission.is_a?(Cccux::AbilityPermission)
|
|
31
|
+
|
|
32
|
+
Cccux::RoleAbility.find_or_create_by(role: self, ability_permission: permission)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove_permission(permission)
|
|
36
|
+
return false unless permission.is_a?(Cccux::AbilityPermission)
|
|
37
|
+
|
|
38
|
+
role_abilities.where(ability_permission: permission).destroy_all
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def has_permission?(permission)
|
|
42
|
+
return false unless permission.is_a?(Cccux::AbilityPermission)
|
|
43
|
+
|
|
44
|
+
role_abilities.exists?(ability_permission: permission)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def permission_names
|
|
48
|
+
ability_permissions.pluck(:action, :subject).map { |action, subject| "#{action} #{subject}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def user_count
|
|
52
|
+
users.count
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def display_name
|
|
56
|
+
"#{name} (#{user_count} users)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if role has access to all records for a given subject
|
|
60
|
+
def has_all_records_access?(subject)
|
|
61
|
+
role_abilities.joins(:ability_permission)
|
|
62
|
+
.where(cccux_ability_permissions: { subject: subject }, owned: false)
|
|
63
|
+
.exists?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if role has access to only owned records for a given subject
|
|
67
|
+
def has_owned_records_access?(subject)
|
|
68
|
+
role_abilities.joins(:ability_permission)
|
|
69
|
+
.where(cccux_ability_permissions: { subject: subject }, owned: true)
|
|
70
|
+
.exists?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get ownership scope for a subject ('all', 'owned', or nil if no permissions)
|
|
74
|
+
def ownership_scope_for(subject)
|
|
75
|
+
if has_all_records_access?(subject)
|
|
76
|
+
'all'
|
|
77
|
+
elsif has_owned_records_access?(subject)
|
|
78
|
+
'owned'
|
|
79
|
+
else
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def set_default_priority
|
|
87
|
+
self.priority ||= 50
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Cccux
|
|
2
|
+
class RoleAbility < ApplicationRecord
|
|
3
|
+
self.table_name = 'cccux_role_abilities'
|
|
4
|
+
|
|
5
|
+
belongs_to :role, class_name: 'Cccux::Role'
|
|
6
|
+
belongs_to :ability_permission, class_name: 'Cccux::AbilityPermission'
|
|
7
|
+
|
|
8
|
+
# Simplified access types: global, owned
|
|
9
|
+
ACCESS_TYPES = %w[global owned].freeze
|
|
10
|
+
|
|
11
|
+
validates :role_id, presence: true
|
|
12
|
+
validates :ability_permission_id, presence: true
|
|
13
|
+
validates :context, inclusion: { in: %w[global owned], allow_nil: true }
|
|
14
|
+
validates :owned, inclusion: { in: [true, false] }
|
|
15
|
+
|
|
16
|
+
# Ensure unique combinations of role, permission, ownership scope, and context
|
|
17
|
+
validates :ability_permission_id, uniqueness: {
|
|
18
|
+
scope: [:role_id, :owned, :context],
|
|
19
|
+
message: "already exists for this role, ownership scope, and context"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
scope :global_access, -> { where(context: 'global') }
|
|
23
|
+
scope :owned_access, -> { where(owned: true) }
|
|
24
|
+
|
|
25
|
+
# Simplified access types: global, owned
|
|
26
|
+
def access_type
|
|
27
|
+
if owned
|
|
28
|
+
'owned'
|
|
29
|
+
else
|
|
30
|
+
'global'
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def display_name
|
|
35
|
+
"#{role.name} - #{ability_permission.display_name}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def access_description
|
|
39
|
+
case access_type
|
|
40
|
+
when 'global'
|
|
41
|
+
"Global access to all records"
|
|
42
|
+
when 'owned'
|
|
43
|
+
"Access to owned records or records via configured ownership relationship"
|
|
44
|
+
else
|
|
45
|
+
"Unknown access"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cccux
|
|
4
|
+
class UserRole < ApplicationRecord
|
|
5
|
+
self.table_name = 'cccux_user_roles'
|
|
6
|
+
|
|
7
|
+
belongs_to :user, class_name: 'User', foreign_key: 'user_id'
|
|
8
|
+
belongs_to :role, class_name: 'Cccux::Role'
|
|
9
|
+
|
|
10
|
+
validates :user_id, presence: true
|
|
11
|
+
validates :role_id, presence: true
|
|
12
|
+
validates :user_id, uniqueness: { scope: :role_id, message: 'already has this role' }
|
|
13
|
+
|
|
14
|
+
scope :active, -> { joins(:role).where(cccux_roles: { active: true }) }
|
|
15
|
+
scope :for_user, ->(user) { where(user: user) }
|
|
16
|
+
scope :with_role, ->(role) { where(role: role) }
|
|
17
|
+
|
|
18
|
+
def self.assign_role_to_user(user, role)
|
|
19
|
+
find_or_create_by(user: user, role: role)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.remove_role_from_user(user, role)
|
|
23
|
+
where(user: user, role: role).destroy_all
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.user_has_role?(user, role)
|
|
27
|
+
exists?(user: user, role: role)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.roles_for_user(user)
|
|
31
|
+
joins(:role).where(user: user).pluck('cccux_roles.name')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.users_with_role(role)
|
|
35
|
+
joins(:user).where(role: role).pluck('users.email')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def display_name
|
|
39
|
+
"#{user.email} - #{role.name}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Cccux
|
|
2
|
+
module Authorizable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
class_methods do
|
|
6
|
+
# Convenience scope for authorized access (shorter than accessible_by(current_ability))
|
|
7
|
+
# Requires an explicit ability parameter for reliability
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# User.owned(current_ability) # In controllers
|
|
11
|
+
# User.owned(ability) # When you have an ability object
|
|
12
|
+
#
|
|
13
|
+
# This ensures proper authorization regardless of thread context
|
|
14
|
+
def owned(ability)
|
|
15
|
+
if ability
|
|
16
|
+
accessible_by(ability)
|
|
17
|
+
else
|
|
18
|
+
# Fallback: return all records (this should be overridden in specific models if needed)
|
|
19
|
+
Rails.logger.warn "CCCUX: No ability provided for #{self.name}.owned scope, returning all records"
|
|
20
|
+
all
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cccux
|
|
4
|
+
module ScopedOwnership
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# Ensure the model includes Cccux::Authorizable
|
|
9
|
+
include Cccux::Authorizable unless included_modules.include?(Cccux::Authorizable)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# Configure scoped ownership for models with multiple ownership patterns
|
|
14
|
+
# Example: scoped_ownership owner: :user, parent: :store, manager_through: :store_managers
|
|
15
|
+
def scoped_ownership(owner: :user, parent: nil, manager_through: nil, through: nil)
|
|
16
|
+
owner_association = owner
|
|
17
|
+
parent_association = parent
|
|
18
|
+
manager_through_association = manager_through
|
|
19
|
+
through_association = through
|
|
20
|
+
|
|
21
|
+
# Define ownership method
|
|
22
|
+
define_method :owned_by? do |user|
|
|
23
|
+
return false unless user&.persisted?
|
|
24
|
+
|
|
25
|
+
# Direct owner ownership
|
|
26
|
+
owner_owned = respond_to?("#{owner_association}_id") &&
|
|
27
|
+
send("#{owner_association}_id") == user.id
|
|
28
|
+
|
|
29
|
+
# Parent management ownership (if configured)
|
|
30
|
+
parent_owned = if parent_association && manager_through_association
|
|
31
|
+
if through_association
|
|
32
|
+
# Indirect parent relationship (e.g., through order)
|
|
33
|
+
related_record = send(through_association)
|
|
34
|
+
parent_record = related_record&.send(parent_association)
|
|
35
|
+
parent_record&.send(manager_through_association)&.exists?(user: user)
|
|
36
|
+
else
|
|
37
|
+
# Direct parent relationship
|
|
38
|
+
parent_record = send(parent_association)
|
|
39
|
+
parent_record&.send(manager_through_association)&.exists?(user: user)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
owner_owned || parent_owned
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Define scoped_for_user class method
|
|
49
|
+
define_singleton_method :scoped_for_user do |user|
|
|
50
|
+
return none unless user&.persisted?
|
|
51
|
+
|
|
52
|
+
# Get records owned by user
|
|
53
|
+
owner_records = where("#{owner_association}_id" => user.id)
|
|
54
|
+
|
|
55
|
+
# Get records from parents managed by user (if configured)
|
|
56
|
+
if parent_association && manager_through_association
|
|
57
|
+
if through_association
|
|
58
|
+
# Indirect parent relationship
|
|
59
|
+
parent_records = joins(through_association => parent_association)
|
|
60
|
+
.where("#{parent_association.to_s.pluralize}" => {
|
|
61
|
+
id: user.send(manager_through_association).select("#{parent_association}_id")
|
|
62
|
+
})
|
|
63
|
+
else
|
|
64
|
+
# Direct parent relationship
|
|
65
|
+
parent_records = joins(parent_association)
|
|
66
|
+
.where("#{table_name}.#{parent_association}_id" =>
|
|
67
|
+
user.send(manager_through_association).select("#{parent_association}_id"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Combine with UNION for efficiency
|
|
71
|
+
owner_ids = owner_records.pluck(:id)
|
|
72
|
+
parent_ids = parent_records.pluck(:id)
|
|
73
|
+
where(id: (owner_ids + parent_ids).uniq)
|
|
74
|
+
else
|
|
75
|
+
# Only owner records if no parent management configured
|
|
76
|
+
owner_records
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Define context scope method
|
|
81
|
+
define_singleton_method :in_current_scope? do |record, user, context|
|
|
82
|
+
# Check each context key for matches
|
|
83
|
+
context.each do |context_key, context_value|
|
|
84
|
+
association_name = context_key.to_s.sub(/_id$/, '')
|
|
85
|
+
|
|
86
|
+
if through_association
|
|
87
|
+
# Indirect relationship - check through association
|
|
88
|
+
related_record = record.send(through_association)
|
|
89
|
+
if related_record&.respond_to?("#{association_name}_id")
|
|
90
|
+
return true if related_record.send("#{association_name}_id")&.to_s == context_value.to_s
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
# Direct relationship - check direct association
|
|
94
|
+
if record.respond_to?("#{association_name}_id")
|
|
95
|
+
return true if record.send("#{association_name}_id")&.to_s == context_value.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# For models that only have owner ownership (no parent relationship)
|
|
105
|
+
def owner_ownership(owner: :user)
|
|
106
|
+
owner_association = owner
|
|
107
|
+
|
|
108
|
+
define_method :owned_by? do |user|
|
|
109
|
+
return false unless user&.persisted?
|
|
110
|
+
send("#{owner_association}_id") == user.id
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
define_singleton_method :scoped_for_user do |user|
|
|
114
|
+
return none unless user&.persisted?
|
|
115
|
+
where("#{owner_association}_id" => user.id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
define_singleton_method :in_current_scope? do |record, user, context|
|
|
119
|
+
context.each do |context_key, context_value|
|
|
120
|
+
association_name = context_key.to_s.sub(/_id$/, '')
|
|
121
|
+
if association_name == owner_association.to_s
|
|
122
|
+
return true if record.send("#{owner_association}_id")&.to_s == context_value.to_s
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# For models that only have parent ownership (no direct owner relationship)
|
|
130
|
+
def parent_ownership(parent: :parent, manager_through: :managers, through: nil)
|
|
131
|
+
parent_association = parent
|
|
132
|
+
manager_through_association = manager_through
|
|
133
|
+
through_association = through
|
|
134
|
+
|
|
135
|
+
define_method :owned_by? do |user|
|
|
136
|
+
return false unless user&.persisted?
|
|
137
|
+
|
|
138
|
+
if through_association
|
|
139
|
+
related_record = send(through_association)
|
|
140
|
+
parent_record = related_record&.send(parent_association)
|
|
141
|
+
parent_record&.send(manager_through_association)&.exists?(user: user)
|
|
142
|
+
else
|
|
143
|
+
parent_record = send(parent_association)
|
|
144
|
+
parent_record&.send(manager_through_association)&.exists?(user: user)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
define_singleton_method :scoped_for_user do |user|
|
|
149
|
+
return none unless user&.persisted?
|
|
150
|
+
|
|
151
|
+
if through_association
|
|
152
|
+
joins(through_association => parent_association)
|
|
153
|
+
.where("#{parent_association.to_s.pluralize}" => {
|
|
154
|
+
id: user.send(manager_through_association).select("#{parent_association}_id")
|
|
155
|
+
})
|
|
156
|
+
else
|
|
157
|
+
joins(parent_association)
|
|
158
|
+
.where("#{table_name}.#{parent_association}_id" =>
|
|
159
|
+
user.send(manager_through_association).select("#{parent_association}_id"))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
define_singleton_method :in_current_scope? do |record, user, context|
|
|
164
|
+
context.each do |context_key, context_value|
|
|
165
|
+
association_name = context_key.to_s.sub(/_id$/, '')
|
|
166
|
+
|
|
167
|
+
if through_association
|
|
168
|
+
related_record = record.send(through_association)
|
|
169
|
+
if related_record&.respond_to?("#{association_name}_id")
|
|
170
|
+
return true if related_record.send("#{association_name}_id")&.to_s == context_value.to_s
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
if association_name == parent_association.to_s
|
|
174
|
+
return true if record.send("#{parent_association}_id")&.to_s == context_value.to_s
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cccux
|
|
4
|
+
module UserConcern
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# CCCUX associations
|
|
9
|
+
has_many :cccux_user_roles, class_name: 'Cccux::UserRole', dependent: :destroy
|
|
10
|
+
has_many :cccux_roles, through: :cccux_user_roles, source: :role, class_name: 'Cccux::Role'
|
|
11
|
+
|
|
12
|
+
# Automatically assign Basic User role to new users
|
|
13
|
+
after_create :assign_default_role
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Instance methods for user authorization
|
|
17
|
+
def has_role?(role_name)
|
|
18
|
+
cccux_roles.active.exists?(name: role_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def has_any_role?(*role_names)
|
|
22
|
+
cccux_roles.active.where(name: role_names).exists?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def has_all_roles?(*role_names)
|
|
26
|
+
role_names.all? { |role_name| has_role?(role_name) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def can?(action, subject)
|
|
30
|
+
# Use CanCan's ability system
|
|
31
|
+
ability = Cccux::Ability.new(self)
|
|
32
|
+
ability.can?(action, subject)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cannot?(action, subject)
|
|
36
|
+
!can?(action, subject)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def assign_role(role)
|
|
40
|
+
return false unless role.is_a?(Cccux::Role) || role.is_a?(String)
|
|
41
|
+
|
|
42
|
+
if role.is_a?(String)
|
|
43
|
+
role = Cccux::Role.find_by(name: role)
|
|
44
|
+
return false unless role
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Cccux::UserRole.find_or_create_by(user: self, role: role)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def remove_role(role)
|
|
51
|
+
return false unless role.is_a?(Cccux::Role) || role.is_a?(String)
|
|
52
|
+
|
|
53
|
+
if role.is_a?(String)
|
|
54
|
+
role = Cccux::Role.find_by(name: role)
|
|
55
|
+
return false unless role
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
cccux_user_roles.where(role: role).destroy_all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def role_names
|
|
62
|
+
cccux_roles.active.pluck(:name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def highest_priority_role
|
|
66
|
+
cccux_roles.active.order(:priority).first
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def admin?
|
|
70
|
+
has_role?('Administrator')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def role_manager?
|
|
74
|
+
has_role?('Role Manager')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def assign_default_role
|
|
80
|
+
# Only assign if user has no roles yet
|
|
81
|
+
if cccux_roles.empty?
|
|
82
|
+
basic_user_role = Cccux::Role.find_by(name: 'Basic User')
|
|
83
|
+
assign_role(basic_user_role) if basic_user_role
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|