current_scope 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +377 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +229 -0
- data/app/assets/stylesheets/current_scope/application.css +917 -0
- data/app/controllers/current_scope/application_controller.rb +96 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
- data/app/controllers/current_scope/roles_controller.rb +256 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
- data/app/controllers/current_scope/subjects_controller.rb +75 -0
- data/app/helpers/current_scope/application_helper.rb +261 -0
- data/app/models/concerns/current_scope/storable_keys.rb +101 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +74 -0
- data/app/models/current_scope/event.rb +136 -0
- data/app/models/current_scope/role.rb +106 -0
- data/app/models/current_scope/role_assignment.rb +42 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +98 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +229 -0
- data/app/views/current_scope/roles/index.html.erb +55 -0
- data/app/views/current_scope/roles/members.html.erb +114 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +124 -0
- data/app/views/layouts/current_scope/application.html.erb +56 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
- data/lib/current_scope/configuration.rb +613 -0
- data/lib/current_scope/context.rb +41 -0
- data/lib/current_scope/engine.rb +202 -0
- data/lib/current_scope/gating_reflection.rb +113 -0
- data/lib/current_scope/gating_tripwire.rb +84 -0
- data/lib/current_scope/grant_diagnosis.rb +216 -0
- data/lib/current_scope/guard.rb +750 -0
- data/lib/current_scope/mutation_guard.rb +90 -0
- data/lib/current_scope/parent_chain.rb +397 -0
- data/lib/current_scope/permission_catalog.rb +146 -0
- data/lib/current_scope/permission_grid.rb +132 -0
- data/lib/current_scope/permissions.rb +94 -0
- data/lib/current_scope/resolver.rb +669 -0
- data/lib/current_scope/schema_guard.rb +223 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/sod_preflight.rb +380 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +432 -0
- data/lib/generators/current_scope/install/install_generator.rb +114 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
- data/lib/tasks/current_scope_tasks.rake +454 -0
- metadata +123 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Inherits from the host's controller (config.parent_controller) so the
|
|
3
|
+
# host's authentication — and its Context before_action — run first.
|
|
4
|
+
# The management UI is the place permissions are granted, so it cannot be
|
|
5
|
+
# gated by grantable permissions: only full_access subjects get in.
|
|
6
|
+
class ApplicationController < CurrentScope.config.parent_controller.constantize
|
|
7
|
+
# The read-only-while-impersonating gate, installed directly (not via Guard):
|
|
8
|
+
# this controller SKIPS the permission check, and mutations here — role,
|
|
9
|
+
# grant, and grid edits — are the highest-value surface to keep read-only
|
|
10
|
+
# while impersonating. Host stop-impersonation/sign-out/sign-in endpoints
|
|
11
|
+
# skip it with skip_before_action :current_scope_mutation_guard!.
|
|
12
|
+
include CurrentScope::MutationGuard
|
|
13
|
+
|
|
14
|
+
layout "current_scope/application"
|
|
15
|
+
|
|
16
|
+
# The engine's controllers are excluded from the grantable catalog; they
|
|
17
|
+
# answer to require_full_access! instead of the host's Guard gate.
|
|
18
|
+
skip_before_action :current_scope_check!, raise: false
|
|
19
|
+
|
|
20
|
+
before_action :require_full_access!
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Raises rather than rendering, so the engine's front door lands in the same
|
|
25
|
+
# current_scope_denied path as every other denial and gets the reason header
|
|
26
|
+
# for free. It used to `head :forbidden` here — the one denial in the gem
|
|
27
|
+
# that sat outside that machinery, and so the one with no reason and no body
|
|
28
|
+
# (#23). MutationGuard's rescue_from catches this from a before_action.
|
|
29
|
+
#
|
|
30
|
+
# Who is denied is unchanged: the full_access? check is byte-for-byte what
|
|
31
|
+
# it was. Only how the refusal is surfaced changed.
|
|
32
|
+
def require_full_access!
|
|
33
|
+
return if CurrentScope.resolver.full_access?(CurrentScope::Current.user)
|
|
34
|
+
|
|
35
|
+
key = "#{controller_path}##{action_name}"
|
|
36
|
+
raise CurrentScope::AccessDenied.new(
|
|
37
|
+
key,
|
|
38
|
+
reason: :not_full_access,
|
|
39
|
+
permission: key,
|
|
40
|
+
subject: CurrentScope::Current.user
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The engine's UI is the one place a rendered denial belongs: the admin is
|
|
45
|
+
# looking at a browser, and "blank page" is not an answer to "why can't I get
|
|
46
|
+
# in?". Overrides ONLY the body — the reason header is still written by
|
|
47
|
+
# current_scope_denied, which stays the single place that knows about it.
|
|
48
|
+
#
|
|
49
|
+
# ONLY for :not_full_access. This concern rescues EVERY AccessDenied raised
|
|
50
|
+
# in this controller, and the other one that fires here is the impersonation
|
|
51
|
+
# gate — whose subject usually DOES have full access and is refused for an
|
|
52
|
+
# entirely different reason. Telling them they need a full-access role is a
|
|
53
|
+
# confidently wrong answer, which is worse than the blank page this fix
|
|
54
|
+
# replaces. Every other reason falls through to the bodyless default, i.e.
|
|
55
|
+
# exactly what it did before this change.
|
|
56
|
+
#
|
|
57
|
+
# HTML only: the page is a full HTML document, so a client that asked for
|
|
58
|
+
# anything else gets the bodyless 403 rather than markup under a content
|
|
59
|
+
# type it did not ask for.
|
|
60
|
+
#
|
|
61
|
+
# layout: false — the console layout is a sidebar of links to areas this
|
|
62
|
+
# subject cannot open. Offering them reads as "you're in" and then refuses
|
|
63
|
+
# every click.
|
|
64
|
+
def current_scope_render_denied(reason = nil)
|
|
65
|
+
return super unless reason == :not_full_access && request.format.html?
|
|
66
|
+
|
|
67
|
+
render "current_scope/shared/access_denied", status: :forbidden, layout: false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def subject_class
|
|
71
|
+
@subject_class ||= CurrentScope.config.subject_class.constantize
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The submitted subject GIDs for a bulk-or-single action: the multi-select
|
|
75
|
+
# subject_gids[] when present, else the single subject_gid. Raw strings —
|
|
76
|
+
# pass through locate_subjects to resolve and enforce the subject boundary.
|
|
77
|
+
def submitted_subject_gids
|
|
78
|
+
gids = Array(params[:subject_gids]).select(&:present?)
|
|
79
|
+
gids = [ params[:subject_gid] ].compact if gids.empty?
|
|
80
|
+
gids
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Resolve subject GIDs to records, keeping ONLY instances of the configured
|
|
84
|
+
# subject_class. A crafted subject_gids[] pointing at some other model must
|
|
85
|
+
# never create an assignment row for a non-subject — the picker offers only
|
|
86
|
+
# subjects, so anything else is out of bounds. Dead/unknown GIDs drop out.
|
|
87
|
+
def locate_subjects(gids)
|
|
88
|
+
Array(gids).select(&:present?).filter_map do |gid|
|
|
89
|
+
record = GlobalID::Locator.locate(gid)
|
|
90
|
+
record if record.is_a?(subject_class)
|
|
91
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
92
|
+
nil
|
|
93
|
+
end.uniq # duplicate subject_gids[] must count once (notice + audit accuracy)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Read-only view of the append-only audit ledger. Inherits the full-access
|
|
3
|
+
# gate and mutation guard from ApplicationController; there is no write path.
|
|
4
|
+
class EventsController < ApplicationController
|
|
5
|
+
PER_PAGE = 50
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@page = [ params[:page].to_i, 1 ].max
|
|
9
|
+
scope = Event.order(id: :desc)
|
|
10
|
+
@events = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
|
|
11
|
+
@has_next_page = scope.offset(@page * PER_PAGE).exists?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Sets (or clears) a subject's single org-wide role.
|
|
3
|
+
class RoleAssignmentsController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
subjects = locate_subjects(submitted_subject_gids)
|
|
6
|
+
if subjects.empty?
|
|
7
|
+
redirect_back_or_to subjects_path, alert: "No subjects selected."
|
|
8
|
+
return
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
clearing = params[:role_id].blank?
|
|
12
|
+
|
|
13
|
+
# One transaction for the whole bulk action: the UI presents it as a single
|
|
14
|
+
# operation, so a failure partway must not leave only the first subjects
|
|
15
|
+
# changed. All-or-nothing across assignments and their audit events. Count
|
|
16
|
+
# only the subjects that ACTUALLY changed so the notice can't over-report
|
|
17
|
+
# (a re-set to the same role, or a clear on a subject with no role, is a
|
|
18
|
+
# no-op and shouldn't be counted).
|
|
19
|
+
#
|
|
20
|
+
# Lock full-access holders inside the same transaction as the writes so
|
|
21
|
+
# concurrent clears cannot both observe "another holder remains" and then
|
|
22
|
+
# both proceed to zero holders.
|
|
23
|
+
changed = 0
|
|
24
|
+
refused = false
|
|
25
|
+
RoleAssignment.transaction do
|
|
26
|
+
lock_full_access_org_holders!
|
|
27
|
+
|
|
28
|
+
if would_remove_last_full_access_holders?(subjects, clearing: clearing)
|
|
29
|
+
refused = true
|
|
30
|
+
else
|
|
31
|
+
subjects.each do |subject|
|
|
32
|
+
assignment = RoleAssignment.find_or_initialize_by(subject: subject)
|
|
33
|
+
prior_role = assignment.role # nil for a brand-new assignment
|
|
34
|
+
did = clearing ? clear_org_role(subject, assignment, prior_role) : set_org_role(subject, assignment, prior_role)
|
|
35
|
+
changed += 1 if did
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if refused
|
|
41
|
+
redirect_back_or_to subjects_path,
|
|
42
|
+
alert: "Refusing to remove the last full-access org-wide assignment — " \
|
|
43
|
+
"it would lock everyone out of this UI. Grant full access to " \
|
|
44
|
+
"another subject first, then retry."
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return to wherever the action was invoked (the subjects page or a role's
|
|
49
|
+
# members page); falls back to subjects when there's no referrer.
|
|
50
|
+
redirect_back_or_to subjects_path, notice: org_notice(clearing, changed)
|
|
51
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
52
|
+
redirect_back_or_to subjects_path, alert: "Couldn't set the org-wide role — a subject or role is no longer available."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Remove ONE org-wide assignment by id — the path the members page uses to
|
|
56
|
+
# clean up an orphaned assignment whose subject was deleted (the subject-keyed
|
|
57
|
+
# clear on `create` can't target a subject that no longer resolves).
|
|
58
|
+
def destroy
|
|
59
|
+
refused = false
|
|
60
|
+
RoleAssignment.transaction do
|
|
61
|
+
# Lock FA state BEFORE the target assignment so order matches create/
|
|
62
|
+
# role demote/delete (FA roles → FA holders → target row). Locking the
|
|
63
|
+
# assignment first inverted that order and could deadlock.
|
|
64
|
+
lock_full_access_org_holders!
|
|
65
|
+
assignment = RoleAssignment.lock.find(params[:id])
|
|
66
|
+
subject = resolve_subject(assignment)
|
|
67
|
+
role_name = assignment.role.name
|
|
68
|
+
|
|
69
|
+
if last_full_access_org_assignment?(assignment)
|
|
70
|
+
refused = true
|
|
71
|
+
else
|
|
72
|
+
assignment.destroy!
|
|
73
|
+
Event.record!(event: "org_role.removed", target: subject || assignment, details: { role: role_name })
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if refused
|
|
78
|
+
redirect_back_or_to subjects_path,
|
|
79
|
+
alert: "Refusing to remove the last full-access org-wide assignment — " \
|
|
80
|
+
"it would lock everyone out of this UI. Grant full access to " \
|
|
81
|
+
"another subject first, then retry."
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
redirect_back_or_to subjects_path, notice: "Org-wide role removed."
|
|
86
|
+
rescue ActiveRecord::RecordNotFound
|
|
87
|
+
redirect_back_or_to subjects_path, notice: "That org-wide role was already removed."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# The grantee, or nil when the subject was deleted or its type no longer
|
|
93
|
+
# resolves (an orphaned assignment) — the ledger row then targets the
|
|
94
|
+
# assignment itself rather than 500ing.
|
|
95
|
+
def resolve_subject(assignment)
|
|
96
|
+
assignment.current_scope_resolved_record("subject")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def org_notice(clearing, count)
|
|
100
|
+
return "No org-wide role changes." if count.zero?
|
|
101
|
+
|
|
102
|
+
verb = clearing ? "cleared" : "set"
|
|
103
|
+
count == 1 ? "Org-wide role #{verb}." : "Org-wide role #{verb} for #{count} subjects."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# True when this assignment is a live full_access org holder and no other
|
|
107
|
+
# full_access org assignment exists. Orphan rows (deleted subject → nil)
|
|
108
|
+
# must not permanently block cleanup of the last FA assignment.
|
|
109
|
+
def last_full_access_org_assignment?(assignment)
|
|
110
|
+
return false unless assignment.role&.full_access?
|
|
111
|
+
return false if resolve_subject(assignment).nil?
|
|
112
|
+
|
|
113
|
+
!full_access_org_assignments.where.not(id: assignment.id).exists?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# True when applying clear (or reassign to a non-full_access role) to these
|
|
117
|
+
# subjects would leave zero full_access org holders.
|
|
118
|
+
def would_remove_last_full_access_holders?(subjects, clearing:)
|
|
119
|
+
holders = full_access_org_assignments.to_a
|
|
120
|
+
return false if holders.empty?
|
|
121
|
+
|
|
122
|
+
affected_ids = holders.select { |a| subjects.any? { |s| same_subject?(a, s) } }.map(&:id)
|
|
123
|
+
return false if affected_ids.empty?
|
|
124
|
+
|
|
125
|
+
unless clearing
|
|
126
|
+
new_role = Role.find_by(id: params[:role_id])
|
|
127
|
+
return false if new_role&.full_access?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
remaining = holders.reject { |a| affected_ids.include?(a.id) }
|
|
131
|
+
remaining.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def full_access_org_assignments
|
|
135
|
+
RoleAssignment.joins(:role).where(current_scope_roles: { full_access: true })
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Lock full-access holder rows (and their roles) so concurrent remove/demote
|
|
139
|
+
# paths serialize on the same set the precheck reads. Call only inside a
|
|
140
|
+
# transaction. Prefer locking by id after a join pluck — FOR UPDATE with
|
|
141
|
+
# joins is adapter-fragile.
|
|
142
|
+
def lock_full_access_org_holders!
|
|
143
|
+
Role.where(full_access: true).lock.load
|
|
144
|
+
ids = full_access_org_assignments.pluck(:id)
|
|
145
|
+
RoleAssignment.where(id: ids).lock.load if ids.any?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def same_subject?(assignment, subject)
|
|
149
|
+
# Match the polymorphic storage name the subjects page uses (not only
|
|
150
|
+
# base_class.name — hosts can customize polymorphic_name).
|
|
151
|
+
# to_s on both: subject_id is a string column (#151) while a record keyed on
|
|
152
|
+
# an integer answers 1, so a raw == silently never matches.
|
|
153
|
+
assignment.subject_type == subject.class.polymorphic_name &&
|
|
154
|
+
assignment.subject_id.to_s == subject.id.to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns true when a role was actually cleared, false when there was nothing
|
|
158
|
+
# to clear (so the caller's count stays accurate). Atomicity comes from
|
|
159
|
+
# create's outer bulk transaction — only called from inside it. (No inner
|
|
160
|
+
# transaction: without requires_new it would be a bare yield, and it isn't
|
|
161
|
+
# wanted — a failure anywhere rolls back the whole batch by design.)
|
|
162
|
+
def clear_org_role(subject, assignment, prior_role)
|
|
163
|
+
return false unless assignment.persisted? # nothing to clear ⇒ no event
|
|
164
|
+
|
|
165
|
+
assignment.destroy!
|
|
166
|
+
Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Returns true when the subject's role actually changed, false on a no-op
|
|
171
|
+
# re-set to the same role.
|
|
172
|
+
def set_org_role(subject, assignment, prior_role)
|
|
173
|
+
# Fetch the new role as its own object so `prior_role` (already loaded via
|
|
174
|
+
# the association) isn't mistaken for it after the update.
|
|
175
|
+
new_role = Role.find(params.expect(:role_id))
|
|
176
|
+
changed = prior_role.nil? || prior_role.id != new_role.id
|
|
177
|
+
|
|
178
|
+
# Atomicity comes from create's outer bulk transaction (see clear_org_role).
|
|
179
|
+
assignment.update!(role: new_role)
|
|
180
|
+
if prior_role.nil?
|
|
181
|
+
Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
|
|
182
|
+
elsif prior_role.id != new_role.id
|
|
183
|
+
Event.record!(event: "org_role.changed", target: subject,
|
|
184
|
+
details: { from: prior_role.name, to: new_role.name })
|
|
185
|
+
end
|
|
186
|
+
# same role re-set ⇒ no change ⇒ no event
|
|
187
|
+
changed
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
class RolesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
# Includes for delete-confirm holder counts (cascade warning).
|
|
5
|
+
@roles = Role.order(:name).includes(:role_assignments, :scoped_role_assignments)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def new
|
|
9
|
+
@role = Role.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
@role = Role.new(role_params)
|
|
14
|
+
saved = false
|
|
15
|
+
Role.transaction do
|
|
16
|
+
saved = @role.save
|
|
17
|
+
# Fold the initial permission set into the create event — no separate
|
|
18
|
+
# grid-diff event for a brand-new role.
|
|
19
|
+
if saved
|
|
20
|
+
Event.record!(event: "role.created", target: @role,
|
|
21
|
+
details: { name: @role.name, full_access: @role.full_access?,
|
|
22
|
+
permission_keys: @role.permission_keys })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if saved
|
|
27
|
+
redirect_to edit_role_path(@role), notice: "Role created."
|
|
28
|
+
else
|
|
29
|
+
render :new, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def edit
|
|
34
|
+
@role = Role.find(params[:id])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Who holds this role — the role-side complement to the subjects page. Org-wide
|
|
38
|
+
# holders (this role IS their one org-wide role) and per-record scoped holders,
|
|
39
|
+
# plus a capped list of subjects to add from here.
|
|
40
|
+
ADD_LIMIT = 100
|
|
41
|
+
|
|
42
|
+
def members
|
|
43
|
+
@role = Role.find(params[:id])
|
|
44
|
+
# No blanket polymorphic includes: stale subject_type/resource_type
|
|
45
|
+
# NameErrors at preload. Org holders stay lazy; scoped resources use the
|
|
46
|
+
# safe per-type preload (unresolvable types stay unloaded → inert label).
|
|
47
|
+
@org_holders = RoleAssignment.where(role: @role).to_a
|
|
48
|
+
@scoped_holders = ScopedRoleAssignment.where(role: @role).includes(role: :role_permissions).to_a
|
|
49
|
+
ScopedRoleAssignment.preload_resolvable_resources!(@scoped_holders)
|
|
50
|
+
|
|
51
|
+
# Still a subquery, not a plucked array: every subject holds exactly one
|
|
52
|
+
# org-wide role, so a default role is held by the WHOLE user table and a
|
|
53
|
+
# plucked NOT IN would carry one bind per subject. The cast is what makes it
|
|
54
|
+
# work now that subject_id is a string column (#151) while subject_class
|
|
55
|
+
# keys on a bigint — PostgreSQL refuses that comparison outright rather than
|
|
56
|
+
# coercing. Cast the candidate side, so the stored value is never altered.
|
|
57
|
+
held = RoleAssignment.where(role: @role, subject_type: subject_class.polymorphic_name)
|
|
58
|
+
.select(:subject_id)
|
|
59
|
+
remaining = subject_class.where.not(
|
|
60
|
+
Arel::Nodes::In.new(candidate_key_as_text, held.arel.ast)
|
|
61
|
+
).order(:id)
|
|
62
|
+
@candidates = remaining.limit(ADD_LIMIT).to_a
|
|
63
|
+
@more_candidates = remaining.offset(ADD_LIMIT).exists?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update
|
|
67
|
+
permitted = role_params
|
|
68
|
+
previous_name = nil
|
|
69
|
+
saved = false
|
|
70
|
+
refused = false
|
|
71
|
+
|
|
72
|
+
# Lock full-access roles + holders inside the write transaction so two
|
|
73
|
+
# concurrent demotions of the last held full-access roles cannot both pass
|
|
74
|
+
# a pre-transaction check and then both commit.
|
|
75
|
+
Role.transaction do
|
|
76
|
+
# Lock FA console state before the target role so concurrent demote/delete
|
|
77
|
+
# of two FA roles cannot invert lock order (roles first, then assignments).
|
|
78
|
+
lock_full_access_console_state!
|
|
79
|
+
@role = Role.lock.find(params[:id])
|
|
80
|
+
|
|
81
|
+
if demoting_would_lock_console?(@role, permitted)
|
|
82
|
+
refused = true
|
|
83
|
+
else
|
|
84
|
+
previous_name = @role.name
|
|
85
|
+
previous_full_access = @role.full_access?
|
|
86
|
+
saved = @role.update(permitted)
|
|
87
|
+
record_role_update(@role, previous_name, previous_full_access) if saved
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if refused
|
|
92
|
+
redirect_to edit_role_path(@role),
|
|
93
|
+
alert: "Refusing to remove full access — this is the last full-access role " \
|
|
94
|
+
"any subject holds and would lock everyone out of this UI. Grant " \
|
|
95
|
+
"full access to another subject first, then retry."
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if saved
|
|
100
|
+
redirect_to roles_path, notice: "Role updated."
|
|
101
|
+
else
|
|
102
|
+
render :edit, status: :unprocessable_entity
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def destroy
|
|
107
|
+
refused = false
|
|
108
|
+
|
|
109
|
+
Role.transaction do
|
|
110
|
+
lock_full_access_console_state!
|
|
111
|
+
role = Role.lock.find(params[:id])
|
|
112
|
+
|
|
113
|
+
if would_lock_console_by_removing_role?(role)
|
|
114
|
+
refused = true
|
|
115
|
+
else
|
|
116
|
+
# Snapshot WITHOUT polymorphic includes — includes(:subject)/:resource
|
|
117
|
+
# can raise NameError for stale types at preload (members page avoids
|
|
118
|
+
# this for the same reason). Resolve each row inside the helpers.
|
|
119
|
+
org_removed = role.role_assignments.to_a
|
|
120
|
+
scoped_revoked = role.scoped_role_assignments.to_a
|
|
121
|
+
|
|
122
|
+
role.destroy!
|
|
123
|
+
Event.record!(event: "role.deleted", target: role, details: { name: role.name })
|
|
124
|
+
org_removed.each do |a|
|
|
125
|
+
Event.record!(event: "org_role.removed", target: cascade_subject(a), details: { role: role.name })
|
|
126
|
+
end
|
|
127
|
+
scoped_revoked.each do |a|
|
|
128
|
+
Event.record!(event: "scoped_role.revoked", target: cascade_subject(a),
|
|
129
|
+
details: { role: role.name, resource: cascade_resource_label(a) })
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if refused
|
|
135
|
+
redirect_to roles_path,
|
|
136
|
+
alert: "Refusing to delete this full-access role — it is the last one held by any " \
|
|
137
|
+
"subject and would lock everyone out of this UI. Grant full access to " \
|
|
138
|
+
"another subject first, then retry."
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
redirect_to roles_path, notice: "Role deleted."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Polymorphic subject/resource may be deleted or unresolvable — never 500
|
|
148
|
+
# the cascade audit. Deleted records return nil without raising (especially
|
|
149
|
+
# after includes preload), so use || assignment, not rescue-only.
|
|
150
|
+
def cascade_subject(assignment)
|
|
151
|
+
assignment.current_scope_resolved_record("subject") || assignment
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def cascade_resource_label(assignment)
|
|
155
|
+
resource = assignment.current_scope_resolved_record("resource")
|
|
156
|
+
return "#{assignment.resource_type}##{assignment.resource_id}" if resource.nil?
|
|
157
|
+
|
|
158
|
+
helpers.current_scope_label(resource)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
"#{assignment.resource_type}##{assignment.resource_id}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# One event per save: role.renamed when the name changed (carries old/new
|
|
164
|
+
# name AND the grid/full_access diff), else role.updated. Emits nothing on
|
|
165
|
+
# a pure no-op (same name, same grid, same full_access).
|
|
166
|
+
def record_role_update(role, previous_name, previous_full_access)
|
|
167
|
+
diff = role.permission_keys_change || { added: [], removed: [], rejected: [] }
|
|
168
|
+
renamed = previous_name != role.name
|
|
169
|
+
full_access_changed = previous_full_access != role.full_access?
|
|
170
|
+
return unless renamed || full_access_changed || diff[:added].any? || diff[:removed].any?
|
|
171
|
+
|
|
172
|
+
details = { added: diff[:added], removed: diff[:removed] }
|
|
173
|
+
if full_access_changed
|
|
174
|
+
details.merge!(full_access_from: previous_full_access, full_access_to: role.full_access?)
|
|
175
|
+
end
|
|
176
|
+
event = "role.updated"
|
|
177
|
+
if renamed
|
|
178
|
+
event = "role.renamed"
|
|
179
|
+
details.merge!(old_name: previous_name, new_name: role.name)
|
|
180
|
+
end
|
|
181
|
+
Event.record!(event: event, target: role, details: details)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# True when removing/demoting this full_access role would leave zero
|
|
185
|
+
# full_access org holders. An unassigned full_access role is always safe
|
|
186
|
+
# to delete/demote (cubic). An empty spare full_access role must NOT
|
|
187
|
+
# authorize demoting the held Owner (CE) — check holders, not role rows.
|
|
188
|
+
def would_lock_console_by_removing_role?(role)
|
|
189
|
+
return false unless role.full_access?
|
|
190
|
+
return false unless RoleAssignment.where(role: role).exists?
|
|
191
|
+
|
|
192
|
+
!RoleAssignment.joins(:role)
|
|
193
|
+
.where(current_scope_roles: { full_access: true })
|
|
194
|
+
.where.not(role_id: role.id)
|
|
195
|
+
.exists?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# True when the update would turn off full_access and lock the console.
|
|
199
|
+
# Only treats an EXPLICIT full_access=false as demotion — a missing key
|
|
200
|
+
# would not change the column and must not false-positive refuse.
|
|
201
|
+
def demoting_would_lock_console?(role, permitted)
|
|
202
|
+
return false unless role.full_access?
|
|
203
|
+
return false unless permitted.key?(:full_access)
|
|
204
|
+
return false if ActiveModel::Type::Boolean.new.cast(permitted[:full_access])
|
|
205
|
+
|
|
206
|
+
would_lock_console_by_removing_role?(role)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Serialize demote/delete against concurrent last-holder removal. Lock FA
|
|
210
|
+
# role rows and their org-wide holder assignments (by id — FOR UPDATE + join
|
|
211
|
+
# is adapter-fragile). Call only inside a transaction.
|
|
212
|
+
def lock_full_access_console_state!
|
|
213
|
+
Role.where(full_access: true).lock.load
|
|
214
|
+
ids = RoleAssignment.joins(:role)
|
|
215
|
+
.where(current_scope_roles: { full_access: true })
|
|
216
|
+
.pluck(:id)
|
|
217
|
+
RoleAssignment.where(id: ids).lock.load if ids.any?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The candidate's primary key, rendered as text so it can be compared to the
|
|
221
|
+
# string subject_id column (#151). Adapters differ twice over: MySQL spells the
|
|
222
|
+
# cast CHAR where the others spell it TEXT, AND it refuses to compare two
|
|
223
|
+
# different collations.
|
|
224
|
+
#
|
|
225
|
+
# The collation is READ FROM THE COLUMN rather than hardcoded, so this cannot
|
|
226
|
+
# drift from what the migration set — and a host that chose a different binary
|
|
227
|
+
# collation still works.
|
|
228
|
+
def candidate_key_as_text
|
|
229
|
+
connection = subject_class.connection
|
|
230
|
+
column = "#{connection.quote_table_name(subject_class.table_name)}." \
|
|
231
|
+
"#{connection.quote_column_name(subject_class.primary_key)}"
|
|
232
|
+
return Arel.sql("CAST(#{column} AS TEXT)") unless CurrentScope.mysql?(connection)
|
|
233
|
+
|
|
234
|
+
collation = RoleAssignment.columns_hash["subject_id"]&.collation
|
|
235
|
+
raise ConfigurationError, "CurrentScope could not read subject_id's MySQL collation" if collation.blank?
|
|
236
|
+
|
|
237
|
+
cast = "CAST(#{column} AS CHAR)"
|
|
238
|
+
Arel.sql("#{cast} COLLATE #{collation}")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def role_params
|
|
242
|
+
permitted = params.expect(role: [ :name, :description, :full_access, permission_keys: [] ])
|
|
243
|
+
# Grid group columns (CRUD checkboxes) submit "controller:group" tokens on
|
|
244
|
+
# a separate, optional channel — permitted leniently so a raw permission_keys
|
|
245
|
+
# post (no groups) still works. Expand them into action keys; the model's
|
|
246
|
+
# permission_keys= dedups, and REJECTS anything not in the catalog (the
|
|
247
|
+
# save fails and `edit` re-renders with the error). The grid can't submit
|
|
248
|
+
# such a key — cells are built from routed actions only — so a rejection
|
|
249
|
+
# here means a hand-crafted request, which is worth saying out loud rather
|
|
250
|
+
# than dropping silently.
|
|
251
|
+
groups = params.fetch(:role, {}).permit(permission_groups: [])[:permission_groups]
|
|
252
|
+
permitted[:permission_keys] = Array(permitted[:permission_keys]) + PermissionGrid.new.expand(groups)
|
|
253
|
+
permitted
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|