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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. 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