current_scope 0.2.0 → 0.3.1
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.
Potentially problematic release.
This version of current_scope might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/README.md +317 -18
- data/app/assets/javascripts/current_scope/application.js +4 -0
- data/app/assets/stylesheets/current_scope/application.css +101 -0
- data/app/controllers/current_scope/application_controller.rb +39 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
- data/app/controllers/current_scope/roles_controller.rb +130 -32
- data/app/helpers/current_scope/application_helper.rb +168 -12
- data/app/models/current_scope/current.rb +24 -0
- data/app/models/current_scope/event.rb +10 -6
- data/app/models/current_scope/role.rb +61 -10
- data/app/views/current_scope/roles/edit.html.erb +92 -4
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/roles/members.html.erb +3 -3
- data/app/views/current_scope/roles/new.html.erb +1 -1
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +19 -10
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +21 -7
- data/app/views/layouts/current_scope/application.html.erb +4 -1
- data/config/routes.rb +3 -4
- data/lib/current_scope/configuration.rb +411 -18
- data/lib/current_scope/engine.rb +7 -0
- data/lib/current_scope/gating_reflection.rb +62 -0
- data/lib/current_scope/gating_tripwire.rb +36 -5
- data/lib/current_scope/guard.rb +410 -10
- data/lib/current_scope/mutation_guard.rb +30 -5
- data/lib/current_scope/permission_catalog.rb +116 -3
- data/lib/current_scope/permission_grid.rb +34 -4
- data/lib/current_scope/permissions.rb +45 -8
- data/lib/current_scope/resolver.rb +317 -13
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +113 -5
- data/lib/generators/current_scope/install/install_generator.rb +64 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
- data/lib/tasks/current_scope_tasks.rake +153 -0
- metadata +6 -2
|
@@ -16,16 +16,35 @@ module CurrentScope
|
|
|
16
16
|
# only the subjects that ACTUALLY changed so the notice can't over-report
|
|
17
17
|
# (a re-set to the same role, or a clear on a subject with no role, is a
|
|
18
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.
|
|
19
23
|
changed = 0
|
|
24
|
+
refused = false
|
|
20
25
|
RoleAssignment.transaction do
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
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
|
+
|
|
29
48
|
# Return to wherever the action was invoked (the subjects page or a role's
|
|
30
49
|
# members page); falls back to subjects when there's no referrer.
|
|
31
50
|
redirect_back_or_to subjects_path, notice: org_notice(clearing, changed)
|
|
@@ -37,14 +56,32 @@ module CurrentScope
|
|
|
37
56
|
# clean up an orphaned assignment whose subject was deleted (the subject-keyed
|
|
38
57
|
# clear on `create` can't target a subject that no longer resolves).
|
|
39
58
|
def destroy
|
|
40
|
-
|
|
41
|
-
subject = resolve_subject(assignment)
|
|
42
|
-
role_name = assignment.role.name
|
|
43
|
-
|
|
59
|
+
refused = false
|
|
44
60
|
RoleAssignment.transaction do
|
|
45
|
-
assignment
|
|
46
|
-
|
|
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
|
|
47
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
|
+
|
|
48
85
|
redirect_back_or_to subjects_path, notice: "Org-wide role removed."
|
|
49
86
|
rescue ActiveRecord::RecordNotFound
|
|
50
87
|
redirect_back_or_to subjects_path, notice: "That org-wide role was already removed."
|
|
@@ -68,15 +105,64 @@ module CurrentScope
|
|
|
68
105
|
count == 1 ? "Org-wide role #{verb}." : "Org-wide role #{verb} for #{count} subjects."
|
|
69
106
|
end
|
|
70
107
|
|
|
108
|
+
# True when this assignment is a live full_access org holder and no other
|
|
109
|
+
# full_access org assignment exists. Orphan rows (deleted subject → nil)
|
|
110
|
+
# must not permanently block cleanup of the last FA assignment.
|
|
111
|
+
def last_full_access_org_assignment?(assignment)
|
|
112
|
+
return false unless assignment.role&.full_access?
|
|
113
|
+
return false if resolve_subject(assignment).nil?
|
|
114
|
+
|
|
115
|
+
!full_access_org_assignments.where.not(id: assignment.id).exists?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when applying clear (or reassign to a non-full_access role) to these
|
|
119
|
+
# subjects would leave zero full_access org holders.
|
|
120
|
+
def would_remove_last_full_access_holders?(subjects, clearing:)
|
|
121
|
+
holders = full_access_org_assignments.to_a
|
|
122
|
+
return false if holders.empty?
|
|
123
|
+
|
|
124
|
+
affected_ids = holders.select { |a| subjects.any? { |s| same_subject?(a, s) } }.map(&:id)
|
|
125
|
+
return false if affected_ids.empty?
|
|
126
|
+
|
|
127
|
+
unless clearing
|
|
128
|
+
new_role = Role.find_by(id: params[:role_id])
|
|
129
|
+
return false if new_role&.full_access?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
remaining = holders.reject { |a| affected_ids.include?(a.id) }
|
|
133
|
+
remaining.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def full_access_org_assignments
|
|
137
|
+
RoleAssignment.joins(:role).where(current_scope_roles: { full_access: true })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Lock full-access holder rows (and their roles) so concurrent remove/demote
|
|
141
|
+
# paths serialize on the same set the precheck reads. Call only inside a
|
|
142
|
+
# transaction. Prefer locking by id after a join pluck — FOR UPDATE with
|
|
143
|
+
# joins is adapter-fragile.
|
|
144
|
+
def lock_full_access_org_holders!
|
|
145
|
+
Role.where(full_access: true).lock.load
|
|
146
|
+
ids = full_access_org_assignments.pluck(:id)
|
|
147
|
+
RoleAssignment.where(id: ids).lock.load if ids.any?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def same_subject?(assignment, subject)
|
|
151
|
+
# Match the polymorphic storage name the subjects page uses (not only
|
|
152
|
+
# base_class.name — hosts can customize polymorphic_name).
|
|
153
|
+
assignment.subject_type == subject.class.polymorphic_name && assignment.subject_id == subject.id
|
|
154
|
+
end
|
|
155
|
+
|
|
71
156
|
# Returns true when a role was actually cleared, false when there was nothing
|
|
72
|
-
# to clear (so the caller's count stays accurate).
|
|
157
|
+
# to clear (so the caller's count stays accurate). Atomicity comes from
|
|
158
|
+
# create's outer bulk transaction — only called from inside it. (No inner
|
|
159
|
+
# transaction: without requires_new it would be a bare yield, and it isn't
|
|
160
|
+
# wanted — a failure anywhere rolls back the whole batch by design.)
|
|
73
161
|
def clear_org_role(subject, assignment, prior_role)
|
|
74
162
|
return false unless assignment.persisted? # nothing to clear ⇒ no event
|
|
75
163
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
|
|
79
|
-
end
|
|
164
|
+
assignment.destroy!
|
|
165
|
+
Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
|
|
80
166
|
true
|
|
81
167
|
end
|
|
82
168
|
|
|
@@ -88,16 +174,15 @@ module CurrentScope
|
|
|
88
174
|
new_role = Role.find(params.expect(:role_id))
|
|
89
175
|
changed = prior_role.nil? || prior_role.id != new_role.id
|
|
90
176
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
end
|
|
99
|
-
# same role re-set ⇒ no change ⇒ no event
|
|
177
|
+
# Atomicity comes from create's outer bulk transaction (see clear_org_role).
|
|
178
|
+
assignment.update!(role: new_role)
|
|
179
|
+
if prior_role.nil?
|
|
180
|
+
Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
|
|
181
|
+
elsif prior_role.id != new_role.id
|
|
182
|
+
Event.record!(event: "org_role.changed", target: subject,
|
|
183
|
+
details: { from: prior_role.name, to: new_role.name })
|
|
100
184
|
end
|
|
185
|
+
# same role re-set ⇒ no change ⇒ no event
|
|
101
186
|
changed
|
|
102
187
|
end
|
|
103
188
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module CurrentScope
|
|
2
2
|
class RolesController < ApplicationController
|
|
3
3
|
def index
|
|
4
|
-
|
|
4
|
+
# Includes for delete-confirm holder counts (cascade warning).
|
|
5
|
+
@roles = Role.order(:name).includes(:role_assignments, :scoped_role_assignments)
|
|
5
6
|
end
|
|
6
7
|
|
|
7
8
|
def new
|
|
@@ -55,12 +56,36 @@ module CurrentScope
|
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def update
|
|
58
|
-
|
|
59
|
-
previous_name =
|
|
59
|
+
permitted = role_params
|
|
60
|
+
previous_name = nil
|
|
60
61
|
saved = false
|
|
62
|
+
refused = false
|
|
63
|
+
|
|
64
|
+
# Lock full-access roles + holders inside the write transaction so two
|
|
65
|
+
# concurrent demotions of the last held full-access roles cannot both pass
|
|
66
|
+
# a pre-transaction check and then both commit.
|
|
61
67
|
Role.transaction do
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
# Lock FA console state before the target role so concurrent demote/delete
|
|
69
|
+
# of two FA roles cannot invert lock order (roles first, then assignments).
|
|
70
|
+
lock_full_access_console_state!
|
|
71
|
+
@role = Role.lock.find(params[:id])
|
|
72
|
+
|
|
73
|
+
if demoting_would_lock_console?(@role, permitted)
|
|
74
|
+
refused = true
|
|
75
|
+
else
|
|
76
|
+
previous_name = @role.name
|
|
77
|
+
previous_full_access = @role.full_access?
|
|
78
|
+
saved = @role.update(permitted)
|
|
79
|
+
record_role_update(@role, previous_name, previous_full_access) if saved
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if refused
|
|
84
|
+
redirect_to edit_role_path(@role),
|
|
85
|
+
alert: "Refusing to remove full access — this is the last full-access role " \
|
|
86
|
+
"any subject holds and would lock everyone out of this UI. Grant " \
|
|
87
|
+
"full access to another subject first, then retry."
|
|
88
|
+
return
|
|
64
89
|
end
|
|
65
90
|
|
|
66
91
|
if saved
|
|
@@ -71,44 +96,81 @@ module CurrentScope
|
|
|
71
96
|
end
|
|
72
97
|
|
|
73
98
|
def destroy
|
|
74
|
-
|
|
99
|
+
refused = false
|
|
100
|
+
|
|
101
|
+
Role.transaction do
|
|
102
|
+
lock_full_access_console_state!
|
|
103
|
+
role = Role.lock.find(params[:id])
|
|
104
|
+
|
|
105
|
+
if would_lock_console_by_removing_role?(role)
|
|
106
|
+
refused = true
|
|
107
|
+
else
|
|
108
|
+
# Snapshot WITHOUT polymorphic includes — includes(:subject)/:resource
|
|
109
|
+
# can raise NameError for stale types at preload (members page avoids
|
|
110
|
+
# this for the same reason). Resolve each row inside the helpers.
|
|
111
|
+
org_removed = role.role_assignments.to_a
|
|
112
|
+
scoped_revoked = role.scoped_role_assignments.to_a
|
|
113
|
+
|
|
114
|
+
role.destroy!
|
|
115
|
+
Event.record!(event: "role.deleted", target: role, details: { name: role.name })
|
|
116
|
+
org_removed.each do |a|
|
|
117
|
+
Event.record!(event: "org_role.removed", target: cascade_subject(a), details: { role: role.name })
|
|
118
|
+
end
|
|
119
|
+
scoped_revoked.each do |a|
|
|
120
|
+
Event.record!(event: "scoped_role.revoked", target: cascade_subject(a),
|
|
121
|
+
details: { role: role.name, resource: cascade_resource_label(a) })
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
75
125
|
|
|
76
|
-
if
|
|
126
|
+
if refused
|
|
77
127
|
redirect_to roles_path,
|
|
78
|
-
alert: "Refusing to delete
|
|
128
|
+
alert: "Refusing to delete this full-access role — it is the last one held by any " \
|
|
129
|
+
"subject and would lock everyone out of this UI. Grant full access to " \
|
|
130
|
+
"another subject first, then retry."
|
|
79
131
|
return
|
|
80
132
|
end
|
|
81
133
|
|
|
82
|
-
# Snapshot the cascade BEFORE destroy! — dependent: :destroy takes the
|
|
83
|
-
# assignments with the role, so they can't be read afterwards.
|
|
84
|
-
org_removed = role.role_assignments.includes(:subject).to_a
|
|
85
|
-
scoped_revoked = role.scoped_role_assignments.includes(:subject, :resource).to_a
|
|
86
|
-
|
|
87
|
-
Role.transaction do
|
|
88
|
-
role.destroy!
|
|
89
|
-
Event.record!(event: "role.deleted", target: role, details: { name: role.name })
|
|
90
|
-
org_removed.each do |a|
|
|
91
|
-
Event.record!(event: "org_role.removed", target: a.subject, details: { role: role.name })
|
|
92
|
-
end
|
|
93
|
-
scoped_revoked.each do |a|
|
|
94
|
-
Event.record!(event: "scoped_role.revoked", target: a.subject,
|
|
95
|
-
details: { role: role.name, resource: helpers.current_scope_label(a.resource) })
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
134
|
redirect_to roles_path, notice: "Role deleted."
|
|
99
135
|
end
|
|
100
136
|
|
|
101
137
|
private
|
|
102
138
|
|
|
139
|
+
# Polymorphic subject/resource may be deleted or unresolvable — never 500
|
|
140
|
+
# the cascade audit. Deleted records return nil without raising (especially
|
|
141
|
+
# after includes preload), so use || assignment, not rescue-only.
|
|
142
|
+
def cascade_subject(assignment)
|
|
143
|
+
assignment.subject || assignment
|
|
144
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
145
|
+
assignment
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def cascade_resource_label(assignment)
|
|
149
|
+
resource = begin
|
|
150
|
+
assignment.resource
|
|
151
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
return "#{assignment.resource_type}##{assignment.resource_id}" if resource.nil?
|
|
155
|
+
|
|
156
|
+
helpers.current_scope_label(resource)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
"#{assignment.resource_type}##{assignment.resource_id}"
|
|
159
|
+
end
|
|
160
|
+
|
|
103
161
|
# One event per save: role.renamed when the name changed (carries old/new
|
|
104
|
-
# name AND the grid diff), else role.updated
|
|
105
|
-
#
|
|
106
|
-
def record_role_update(role, previous_name)
|
|
107
|
-
diff = role.permission_keys_change || { added: [], removed: [] }
|
|
162
|
+
# name AND the grid/full_access diff), else role.updated. Emits nothing on
|
|
163
|
+
# a pure no-op (same name, same grid, same full_access).
|
|
164
|
+
def record_role_update(role, previous_name, previous_full_access)
|
|
165
|
+
diff = role.permission_keys_change || { added: [], removed: [], rejected: [] }
|
|
108
166
|
renamed = previous_name != role.name
|
|
109
|
-
|
|
167
|
+
full_access_changed = previous_full_access != role.full_access?
|
|
168
|
+
return unless renamed || full_access_changed || diff[:added].any? || diff[:removed].any?
|
|
110
169
|
|
|
111
170
|
details = { added: diff[:added], removed: diff[:removed] }
|
|
171
|
+
if full_access_changed
|
|
172
|
+
details.merge!(full_access_from: previous_full_access, full_access_to: role.full_access?)
|
|
173
|
+
end
|
|
112
174
|
event = "role.updated"
|
|
113
175
|
if renamed
|
|
114
176
|
event = "role.renamed"
|
|
@@ -117,8 +179,40 @@ module CurrentScope
|
|
|
117
179
|
Event.record!(event: event, target: role, details: details)
|
|
118
180
|
end
|
|
119
181
|
|
|
120
|
-
|
|
121
|
-
|
|
182
|
+
# True when removing/demoting this full_access role would leave zero
|
|
183
|
+
# full_access org holders. An unassigned full_access role is always safe
|
|
184
|
+
# to delete/demote (cubic). An empty spare full_access role must NOT
|
|
185
|
+
# authorize demoting the held Owner (CE) — check holders, not role rows.
|
|
186
|
+
def would_lock_console_by_removing_role?(role)
|
|
187
|
+
return false unless role.full_access?
|
|
188
|
+
return false unless RoleAssignment.where(role: role).exists?
|
|
189
|
+
|
|
190
|
+
!RoleAssignment.joins(:role)
|
|
191
|
+
.where(current_scope_roles: { full_access: true })
|
|
192
|
+
.where.not(role_id: role.id)
|
|
193
|
+
.exists?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# True when the update would turn off full_access and lock the console.
|
|
197
|
+
# Only treats an EXPLICIT full_access=false as demotion — a missing key
|
|
198
|
+
# would not change the column and must not false-positive refuse.
|
|
199
|
+
def demoting_would_lock_console?(role, permitted)
|
|
200
|
+
return false unless role.full_access?
|
|
201
|
+
return false unless permitted.key?(:full_access)
|
|
202
|
+
return false if ActiveModel::Type::Boolean.new.cast(permitted[:full_access])
|
|
203
|
+
|
|
204
|
+
would_lock_console_by_removing_role?(role)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Serialize demote/delete against concurrent last-holder removal. Lock FA
|
|
208
|
+
# role rows and their org-wide holder assignments (by id — FOR UPDATE + join
|
|
209
|
+
# is adapter-fragile). Call only inside a transaction.
|
|
210
|
+
def lock_full_access_console_state!
|
|
211
|
+
Role.where(full_access: true).lock.load
|
|
212
|
+
ids = RoleAssignment.joins(:role)
|
|
213
|
+
.where(current_scope_roles: { full_access: true })
|
|
214
|
+
.pluck(:id)
|
|
215
|
+
RoleAssignment.where(id: ids).lock.load if ids.any?
|
|
122
216
|
end
|
|
123
217
|
|
|
124
218
|
def role_params
|
|
@@ -126,7 +220,11 @@ module CurrentScope
|
|
|
126
220
|
# Grid group columns (CRUD checkboxes) submit "controller:group" tokens on
|
|
127
221
|
# a separate, optional channel — permitted leniently so a raw permission_keys
|
|
128
222
|
# post (no groups) still works. Expand them into action keys; the model's
|
|
129
|
-
# permission_keys= dedups and
|
|
223
|
+
# permission_keys= dedups, and REJECTS anything not in the catalog (the
|
|
224
|
+
# save fails and `edit` re-renders with the error). The grid can't submit
|
|
225
|
+
# such a key — cells are built from routed actions only — so a rejection
|
|
226
|
+
# here means a hand-crafted request, which is worth saying out loud rather
|
|
227
|
+
# than dropping silently.
|
|
130
228
|
groups = params.fetch(:role, {}).permit(permission_groups: [])[:permission_groups]
|
|
131
229
|
permitted[:permission_keys] = Array(permitted[:permission_keys]) + PermissionGrid.new.expand(groups)
|
|
132
230
|
permitted
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
module CurrentScope
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
# Best-effort human label for any host subject/resource.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Best-effort human label for any host subject/resource. Delegates to the
|
|
4
|
+
# one shared chain (CurrentScope.label_for) — the same definition the audit
|
|
5
|
+
# ledger denormalizes (see Event.label_for), so the picker, the chips, and
|
|
6
|
+
# the ledger agree by construction, not by parallel maintenance.
|
|
7
7
|
def current_scope_label(record)
|
|
8
8
|
return "(none)" if record.nil?
|
|
9
|
-
return record.current_scope_label if record.respond_to?(:current_scope_label)
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
name || "#{record.class.name} ##{record.id}"
|
|
10
|
+
CurrentScope.label_for(record)
|
|
13
11
|
end
|
|
14
12
|
|
|
15
13
|
# Human label for a subject (user/account), honouring config.subject_label
|
|
@@ -36,14 +34,49 @@ module CurrentScope
|
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
# The configured subject_label (Symbol or Proc) applied to a subject, or nil
|
|
39
|
-
# when unset
|
|
37
|
+
# when unset, when it resolves to a blank value, or when it fails.
|
|
38
|
+
#
|
|
39
|
+
# subject_label is arbitrary host code running once per row on the admin's
|
|
40
|
+
# main tool for granting and reviewing roles. One subject with incomplete
|
|
41
|
+
# data (a Proc doing `u.email.upcase` on a subject whose email is nil) must
|
|
42
|
+
# not take the whole page down — the same intent the holder-label helpers
|
|
43
|
+
# below already encode for stale polymorphic types. A failure here costs one
|
|
44
|
+
# label; a raise costs the page.
|
|
40
45
|
def configured_subject_label(subject)
|
|
41
46
|
label = CurrentScope.config.subject_label
|
|
42
|
-
if label.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
return if label.nil?
|
|
48
|
+
return resolve_subject_label(label) { label.call(subject) } if label.respond_to?(:call)
|
|
49
|
+
|
|
50
|
+
# Only a Symbol or String names a method. Checked BEFORE respond_to?,
|
|
51
|
+
# which raises TypeError on anything else ("false is not a symbol nor a
|
|
52
|
+
# string") — and that raise would land outside the rescue below and 500
|
|
53
|
+
# the page, which is the bug this method exists to prevent, reached
|
|
54
|
+
# through a different door.
|
|
55
|
+
unless label.is_a?(Symbol) || label.is_a?(String)
|
|
56
|
+
CurrentScope::ApplicationHelper.warn_unusable_subject_label_once(label)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if subject.respond_to?(label)
|
|
61
|
+
resolve_subject_label(label) { subject.public_send(label) }
|
|
62
|
+
else
|
|
63
|
+
# Not a mistake we can render around: this config does nothing for EVERY
|
|
64
|
+
# subject, silently, which is why it needs saying out loud once.
|
|
65
|
+
CurrentScope::ApplicationHelper.warn_unknown_subject_label_once(label)
|
|
66
|
+
nil
|
|
46
67
|
end
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
# Last resort, and the reason it exists: every branch above calls a method
|
|
70
|
+
# ON the host's config value to classify it — `nil?`, `respond_to?`,
|
|
71
|
+
# `is_a?`. A pathological value breaks the classification itself, before
|
|
72
|
+
# any specific guard can classify it, so the guards cannot be where this
|
|
73
|
+
# is caught. A BasicObject has no #nil?; a delegator can raise from
|
|
74
|
+
# respond_to?.
|
|
75
|
+
#
|
|
76
|
+
# Deliberately does NOT touch `label`: it is the thing that just proved it
|
|
77
|
+
# cannot be touched. The warning is keyed off the error alone.
|
|
78
|
+
CurrentScope::ApplicationHelper.warn_subject_label_broken_once(e)
|
|
79
|
+
nil
|
|
47
80
|
end
|
|
48
81
|
|
|
49
82
|
# Members-list labels that survive a stale/renamed polymorphic type — a
|
|
@@ -67,6 +100,129 @@ module CurrentScope
|
|
|
67
100
|
def current_scope_gid_label(gid)
|
|
68
101
|
record = GlobalID::Locator.locate(gid)
|
|
69
102
|
record ? current_scope_label(record) : gid
|
|
103
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
104
|
+
# locate RAISES for a deleted record or renamed class (nil is only for
|
|
105
|
+
# unparseable strings) — same contract the holder helpers rescue above.
|
|
106
|
+
gid
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# ponytail: display fallback only — NEVER a decision path. This rescue makes
|
|
112
|
+
# a label degrade instead of erroring; the resolver, Guard and catalog are
|
|
113
|
+
# untouched, so it relaxes no fail-closed guarantee. (The same `rescue
|
|
114
|
+
# StandardError` on a decision path would be a fail-open bug.)
|
|
115
|
+
#
|
|
116
|
+
# StandardError rather than the NameError family the holder helpers catch:
|
|
117
|
+
# those wrap OUR call into a known-shaped record, while this runs a host's
|
|
118
|
+
# arbitrary Proc, which can raise anything. Catching only NameError would
|
|
119
|
+
# fix the reported NoMethodError and leave the same page-down bug for a
|
|
120
|
+
# differently-broken Proc.
|
|
121
|
+
#
|
|
122
|
+
# nil rejoins the existing "resolved blank -> default chain" path in
|
|
123
|
+
# current_scope_subject_label, so no caller learns a new branch.
|
|
124
|
+
def resolve_subject_label(label)
|
|
125
|
+
yield.to_s.presence
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
CurrentScope::ApplicationHelper.warn_subject_label_raised_once(label, e)
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class << self
|
|
132
|
+
# Warn ONCE per (reason, label): a misconfigured subject_label is the same
|
|
133
|
+
# mistake on every row, so one line is a signal and one-per-subject is
|
|
134
|
+
# noise the admin will scroll past. Keyed by the label value, so changing
|
|
135
|
+
# the config in dev warns again for the new value.
|
|
136
|
+
#
|
|
137
|
+
# Always-on rather than behind a warn_on_* flag (cf. warn_on_nil_sod_record,
|
|
138
|
+
# which defaults off because a nil record is often legitimate): a label the
|
|
139
|
+
# subject can't answer, or one that raises, is unambiguously a mistake.
|
|
140
|
+
# Mirrors Event.warn_missing_events_table_once.
|
|
141
|
+
#
|
|
142
|
+
# Says "this subject" rather than "subjects": under STI some subclasses may
|
|
143
|
+
# answer and others not, and warn-once means the first non-responder would
|
|
144
|
+
# otherwise speak for all of them.
|
|
145
|
+
def warn_unknown_subject_label_once(label)
|
|
146
|
+
return unless new_subject_label_warning?(:unknown, label)
|
|
147
|
+
|
|
148
|
+
Rails.logger&.warn(
|
|
149
|
+
"[CurrentScope] config.subject_label is #{label.inspect}, but this subject does not " \
|
|
150
|
+
"respond to it — it is falling back to the default label. If no subject responds to it, " \
|
|
151
|
+
"this config does nothing. Set it to a method the subject responds to (e.g. :email), a " \
|
|
152
|
+
"Proc, or nil."
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Not a Symbol, String, Proc, or nil — so it can't name a method and can't
|
|
157
|
+
# be called. Reports the TYPE, not the value: this is the one warning whose
|
|
158
|
+
# subject is an arbitrary host object, and calling `inspect` on it could
|
|
159
|
+
# itself raise — inside the very path that exists to stop a raise.
|
|
160
|
+
def warn_unusable_subject_label_once(label)
|
|
161
|
+
return unless new_subject_label_warning?(:unusable, label.class)
|
|
162
|
+
|
|
163
|
+
Rails.logger&.warn(
|
|
164
|
+
"[CurrentScope] config.subject_label is a #{label.class} — it can't name a method and " \
|
|
165
|
+
"can't be called, so every subject is falling back to the default label. Set it to a " \
|
|
166
|
+
"Symbol (e.g. :email), a Proc taking the subject, or nil."
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def warn_subject_label_raised_once(label, error)
|
|
171
|
+
return unless new_subject_label_warning?(:raised, label)
|
|
172
|
+
|
|
173
|
+
Rails.logger&.warn(
|
|
174
|
+
"[CurrentScope] config.subject_label raised #{safe_class(error)}: #{safe_message(error)} " \
|
|
175
|
+
"— that subject fell back to the default label rather than erroring the page. A " \
|
|
176
|
+
"subject_label must be total: it runs for every subject, including ones with nil or " \
|
|
177
|
+
"blank attributes."
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The configured value could not even be classified — see the rescue in
|
|
182
|
+
# configured_subject_label. Keyed by the error's class, and says nothing
|
|
183
|
+
# about the label itself, because reading the label is what failed.
|
|
184
|
+
def warn_subject_label_broken_once(error)
|
|
185
|
+
return unless new_subject_label_warning?(:broken, safe_class(error))
|
|
186
|
+
|
|
187
|
+
Rails.logger&.warn(
|
|
188
|
+
"[CurrentScope] config.subject_label could not be read — #{safe_class(error)}: " \
|
|
189
|
+
"#{safe_message(error)}. Every subject is falling back to the default label. Set it to " \
|
|
190
|
+
"a Symbol (e.g. :email), a Proc taking the subject, or nil."
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# A host exception's message is arbitrary text produced by arbitrary code:
|
|
197
|
+
# it may carry newlines that split one warning across several log records,
|
|
198
|
+
# run to any length, or raise from #message itself. All three would land
|
|
199
|
+
# in the rescue this runs inside — reporting the failure must not become
|
|
200
|
+
# the failure.
|
|
201
|
+
def safe_message(error)
|
|
202
|
+
error.message.to_s.gsub(/\s+/, " ").strip.truncate(200)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
"(message unavailable)"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Same reason as safe_message, one step earlier: `#class` is interpolated
|
|
208
|
+
# before safe_message is even called, and an exception raised by a host
|
|
209
|
+
# Proc can override it. The method-level rescue in configured_subject_label
|
|
210
|
+
# does stop that reaching the page — but at the cost of the accurate
|
|
211
|
+
# warning, replaced by a misattributed one about the secondary failure.
|
|
212
|
+
# The diagnostic is the whole point of warning at all, so protect it.
|
|
213
|
+
def safe_class(error)
|
|
214
|
+
error.class.name.presence || "an exception"
|
|
215
|
+
rescue StandardError
|
|
216
|
+
"an exception"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Set is a core built-in on this gem's Ruby floor (>= 3.2, no require
|
|
220
|
+
# needed — the codebase already uses bare Set elsewhere), so nothing here
|
|
221
|
+
# can raise inside the rescue this runs under.
|
|
222
|
+
def new_subject_label_warning?(reason, label)
|
|
223
|
+
@subject_label_warnings ||= Set.new
|
|
224
|
+
!!@subject_label_warnings.add?([ reason, label ])
|
|
225
|
+
end
|
|
70
226
|
end
|
|
71
227
|
end
|
|
72
228
|
end
|
|
@@ -14,6 +14,16 @@ module CurrentScope
|
|
|
14
14
|
# each event; nil when the host hasn't set it. Additive — no reader override.
|
|
15
15
|
attribute :user, :actor, :request_id
|
|
16
16
|
|
|
17
|
+
# #50: the collection type the request's controller declared via
|
|
18
|
+
# current_scope_model, and the controller_path it was declared for. The
|
|
19
|
+
# Guard stashes both once per request so the advisory path (allowed_to? in
|
|
20
|
+
# a view) can bind the record-less gate the same way the controller gate
|
|
21
|
+
# did — but the PATH is stored so a cross-controller advisory question
|
|
22
|
+
# (allowed_to?(:index, controller: "reports") from a projects view) does
|
|
23
|
+
# NOT borrow the wrong controller's type (KTD-6). Request-scoped, reset
|
|
24
|
+
# with everything else, so it never leaks between requests.
|
|
25
|
+
attribute :collection_model, :collection_model_path
|
|
26
|
+
|
|
17
27
|
# Per-request memo for the resolver's org-role lookup. A view with N
|
|
18
28
|
# permission-gated elements calls the gate N times, each otherwise re-running
|
|
19
29
|
# the same `RoleAssignment.find_by(subject:)`; caching it here collapses that
|
|
@@ -24,10 +34,24 @@ module CurrentScope
|
|
|
24
34
|
|
|
25
35
|
# Falls back to the effective subject, so actor is never nil when user is
|
|
26
36
|
# set — callers attribute to `actor` without a nil branch.
|
|
37
|
+
#
|
|
38
|
+
# ROUND-TRIP HAZARD: the reader answers with the fallback, not stored
|
|
39
|
+
# state. Snapshot/restore code (Current.set, Object#with, anything that
|
|
40
|
+
# reads #actor to write it back) would pin the fallback as an explicit
|
|
41
|
+
# actor and restore a stale identity once user changes — read the raw
|
|
42
|
+
# `attributes` hash instead, as TestHelpers#with_current_user does.
|
|
27
43
|
def actor
|
|
28
44
|
super || user
|
|
29
45
|
end
|
|
30
46
|
|
|
47
|
+
# True only while a distinct real actor stands behind the effective
|
|
48
|
+
# subject (act-as). THE definition of "impersonating" — the Permissions
|
|
49
|
+
# mixin and the mutation guard both delegate here, so the view-level
|
|
50
|
+
# read-only signal and the write gate can never drift apart.
|
|
51
|
+
def impersonating?
|
|
52
|
+
user.present? && actor != user
|
|
53
|
+
end
|
|
54
|
+
|
|
31
55
|
# Memoize the org-role lookup for `subject` for the rest of this request/job.
|
|
32
56
|
# Keyed by subject so a check that spans several subjects stays correct.
|
|
33
57
|
# Caches nil (no role) too, so a repeated "no grant" check is one query, not N.
|
|
@@ -90,13 +90,11 @@ module CurrentScope
|
|
|
90
90
|
)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
#
|
|
94
|
-
#
|
|
93
|
+
# The shared chain (CurrentScope.label_for) — the same label the UI
|
|
94
|
+
# renders is the one denormalized into target_label, so the ledger and
|
|
95
|
+
# the screen can't disagree about what a record was called.
|
|
95
96
|
def label_for(record)
|
|
96
|
-
|
|
97
|
-
return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
|
|
98
|
-
|
|
99
|
-
record.to_s
|
|
97
|
+
CurrentScope.label_for(record)
|
|
100
98
|
end
|
|
101
99
|
|
|
102
100
|
# Recognizes "table is missing" across adapters and Rails' own schema
|
|
@@ -112,6 +110,12 @@ module CurrentScope
|
|
|
112
110
|
!message.match?(/\bcolumn\b/i) &&
|
|
113
111
|
message.match?(/no such table|could not find table|does(?:n't| not) exist|unknown table|undefined table/i)
|
|
114
112
|
end
|
|
113
|
+
# PUBLIC: report mode's ledger warning asks this too (#37). It is one
|
|
114
|
+
# question — "is this the un-migrated-table case?" — and one adapter-shaped
|
|
115
|
+
# answer, so it gets one definition. A second opinion elsewhere is a second
|
|
116
|
+
# opinion that drifts, and this one decides which fix an operator is sent
|
|
117
|
+
# after. (#59 review)
|
|
118
|
+
public :missing_events_table?
|
|
115
119
|
end
|
|
116
120
|
end
|
|
117
121
|
end
|