current_scope 0.3.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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/current_scope/application.css +2 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +96 -11
- data/app/controllers/current_scope/roles_controller.rb +124 -30
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +7 -7
- data/app/views/current_scope/subjects/index.html.erb +4 -2
- data/lib/current_scope/configuration.rb +42 -11
- data/lib/current_scope/guard.rb +4 -2
- data/lib/current_scope/permissions.rb +14 -11
- data/lib/current_scope/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b43df7725417ffd6088533edb72032fd26a4bad9e8eefb6a56fa52d9a63a29e3
|
|
4
|
+
data.tar.gz: b77bb0cdb38b19efd19ac754666d23abe14220ae3a0b70566f217eda26b016cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dedf447ca858342304bbd1dddeff2187291bd78780271f01328e9d328b090081f3c1bc293639dc4e2855b719ba9a61f4d49befac17673cb7f44dca10d2405037
|
|
7
|
+
data.tar.gz: 56cd899f391607a79bf550fb416f961911b9c192e6a430bceedc3a92fabcab251bb0cad7aeabce20f4b10c9463939f0bd6afa6e55d7b535d6fbd047ea9fc426d
|
|
@@ -659,6 +659,8 @@ input[type="checkbox"], input[type="radio"] {
|
|
|
659
659
|
host might place in the sidebar or topbar. */
|
|
660
660
|
.cs-main form p { margin: 0 0 1rem; max-width: 30rem; }
|
|
661
661
|
|
|
662
|
+
/* Non-control captions (bulk subjects line) share form-label type. */
|
|
663
|
+
.cs-main form p > .cs-label,
|
|
662
664
|
.cs-main form p > label {
|
|
663
665
|
display: block;
|
|
664
666
|
font-size: 0.7rem;
|
|
@@ -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,6 +105,54 @@ 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
157
|
# to clear (so the caller's count stays accurate). Atomicity comes from
|
|
73
158
|
# create's outer bulk transaction — only called from inside it. (No inner
|
|
@@ -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)
|
|
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)
|
|
107
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
|
|
@@ -30,9 +30,23 @@
|
|
|
30
30
|
</td>
|
|
31
31
|
<td>
|
|
32
32
|
<%= link_to "Members", members_role_path(role), class: "cs-btn" %>
|
|
33
|
+
<%
|
|
34
|
+
org_n = role.role_assignments.size
|
|
35
|
+
scoped_n = role.scoped_role_assignments.size
|
|
36
|
+
parts = []
|
|
37
|
+
parts << "#{org_n} org-wide #{"holder".pluralize(org_n)}" if org_n.positive?
|
|
38
|
+
parts << "#{scoped_n} scoped #{"holder".pluralize(scoped_n)}" if scoped_n.positive?
|
|
39
|
+
delete_prompt = if parts.empty?
|
|
40
|
+
"Delete role #{role.name}?"
|
|
41
|
+
else
|
|
42
|
+
"Delete role #{role.name}? This also removes it from #{parts.to_sentence}."
|
|
43
|
+
end
|
|
44
|
+
%>
|
|
33
45
|
<%= button_to "Delete", role_path(role), method: :delete,
|
|
34
|
-
|
|
35
|
-
data: {
|
|
46
|
+
class: "cs-btn cs-btn-danger",
|
|
47
|
+
form: { data: { cs_confirm: delete_prompt } },
|
|
48
|
+
aria: { label: "Delete role #{role.name}" },
|
|
49
|
+
data: { turbo_confirm: delete_prompt } %>
|
|
36
50
|
</td>
|
|
37
51
|
</tr>
|
|
38
52
|
<% end %>
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
<%= form_with url: new_scoped_role_assignment_path, method: :get, id: "cs-cascade",
|
|
25
25
|
data: { turbo_frame: "cascade" }, class: "cs-picker" do %>
|
|
26
26
|
<p>
|
|
27
|
-
|
|
27
|
+
<%= label_tag :role_id, "Role" %>
|
|
28
28
|
<%= select_tag :role_id,
|
|
29
29
|
options_from_collection_for_select(@roles, :id, :name, params[:role_id]),
|
|
30
30
|
data: { current_scope_autosubmit: true } %>
|
|
31
31
|
</p>
|
|
32
32
|
<% if @bulk_subjects.any? %>
|
|
33
33
|
<p>
|
|
34
|
-
<label>Subjects</
|
|
35
|
-
<span class="cs-hint">
|
|
34
|
+
<span class="cs-label" id="cs_bulk_subjects_label">Subjects</span>
|
|
35
|
+
<span class="cs-hint" aria-labelledby="cs_bulk_subjects_label">
|
|
36
36
|
Granting to <strong><%= @bulk_subjects.size %></strong>:
|
|
37
37
|
<%= @bulk_subjects.map { |s| current_scope_subject_label(s) }.to_sentence %>
|
|
38
38
|
</span>
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
</p>
|
|
43
43
|
<% else %>
|
|
44
44
|
<p>
|
|
45
|
-
|
|
45
|
+
<%= label_tag :subject_gid, "Subject" %>
|
|
46
46
|
<%= select_tag :subject_gid,
|
|
47
47
|
options_for_select(@subjects.map { |s| [ current_scope_subject_label(s), s.to_gid.to_s ] },
|
|
48
48
|
params[:subject_gid]),
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
end
|
|
68
68
|
%>
|
|
69
69
|
<p>
|
|
70
|
-
|
|
70
|
+
<%= label_tag :resource_type, "Resource type" %>
|
|
71
71
|
<%= select_tag :resource_type,
|
|
72
72
|
options_for_select(type_options, @resource_type&.name),
|
|
73
73
|
include_blank: "— choose a type —", form: "cs-cascade",
|
|
@@ -82,13 +82,13 @@
|
|
|
82
82
|
<% else %>
|
|
83
83
|
<% if @searchable %>
|
|
84
84
|
<p>
|
|
85
|
-
|
|
85
|
+
<%= label_tag :q, "Search records" %>
|
|
86
86
|
<%= search_field_tag :q, params[:q], placeholder: "Filter by label",
|
|
87
87
|
form: "cs-cascade", data: { current_scope_autosubmit: true } %>
|
|
88
88
|
</p>
|
|
89
89
|
<% end %>
|
|
90
90
|
<p>
|
|
91
|
-
|
|
91
|
+
<%= label_tag :resource_gid, "Record" %>
|
|
92
92
|
<%
|
|
93
93
|
record_options = (@records || []).map { |record| [ current_scope_label(record), record.to_gid.to_s ] }
|
|
94
94
|
# Keep a deep-linked record selectable even if it fell outside the
|
|
@@ -77,8 +77,10 @@
|
|
|
77
77
|
<%= hidden_field_tag :subject_gid, subject.to_gid %>
|
|
78
78
|
<%= select_tag :role_id,
|
|
79
79
|
options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
|
|
80
|
-
include_blank: "— none —"
|
|
81
|
-
|
|
80
|
+
include_blank: "— none —",
|
|
81
|
+
aria: { label: "Org-wide role for #{label}" } %>
|
|
82
|
+
<%= submit_tag "Set", name: nil,
|
|
83
|
+
aria: { label: "Set org-wide role for #{label}" } %>
|
|
82
84
|
<% end %>
|
|
83
85
|
</td>
|
|
84
86
|
<td>
|
|
@@ -17,7 +17,11 @@ module CurrentScope
|
|
|
17
17
|
# EMPTY BY DEFAULT — SoD is opt-in. The engine's baseline is scoped RBAC;
|
|
18
18
|
# many hosts want nothing to do with four-eyes. Enable it by listing the
|
|
19
19
|
# actions to gate, e.g. `config.sod_actions = %w[approve]`.
|
|
20
|
-
|
|
20
|
+
#
|
|
21
|
+
# Matched as STRINGS against the key's action segment (see Resolver#sod_action?).
|
|
22
|
+
# Use the writer below — a plain Symbol list ([:approve]) used to silently
|
|
23
|
+
# disable the veto (#91). Same shape as collection_read_actions=.
|
|
24
|
+
attr_reader :sod_actions
|
|
21
25
|
|
|
22
26
|
# Which identities the separation-of-duties veto weighs:
|
|
23
27
|
# :either (default) — veto if the effective subject OR (while
|
|
@@ -32,6 +36,32 @@ module CurrentScope
|
|
|
32
36
|
|
|
33
37
|
SOD_IDENTITY_MODES = %i[either subject].freeze
|
|
34
38
|
|
|
39
|
+
# Normalizing writer for sod_actions (#91). The resolver matches with
|
|
40
|
+
# include?(permission.split("#").last) — a String — so symbols never match
|
|
41
|
+
# and silently turn the fraud control off. Normalize symbols to strings,
|
|
42
|
+
# reject non-String/Symbol elements and full keys (same honesty as
|
|
43
|
+
# collection_read_actions=), freeze the list.
|
|
44
|
+
def sod_actions=(value)
|
|
45
|
+
elements = Array(value)
|
|
46
|
+
|
|
47
|
+
if (bad = elements.reject { |e| e.is_a?(String) || e.is_a?(Symbol) }).any?
|
|
48
|
+
raise ConfigurationError,
|
|
49
|
+
"config.sod_actions takes action NAMES (strings or symbols); got " \
|
|
50
|
+
"#{bad.map(&:inspect).join(', ')}. Write e.g. %w[approve] or [:approve]."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
actions = elements.map(&:to_s)
|
|
54
|
+
|
|
55
|
+
if (keyed = actions.grep(/#/)).any?
|
|
56
|
+
raise ConfigurationError,
|
|
57
|
+
"config.sod_actions is matched on the ACTION segment of a key — " \
|
|
58
|
+
"#{keyed.map(&:inspect).join(', ')} can never match. Write \"approve\", " \
|
|
59
|
+
"not \"reports#approve\"."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@sod_actions = actions.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
35
65
|
# Validating writer, same contract as enforcement=: the resolver compares
|
|
36
66
|
# `== :either`, so a typo (`:both`, `:actor`) would otherwise silently
|
|
37
67
|
# behave as :subject — narrowing the fraud control with no signal. Raise at
|
|
@@ -161,12 +191,11 @@ module CurrentScope
|
|
|
161
191
|
# reads, so review the declaration like the record hook.
|
|
162
192
|
attr_reader :collection_read_actions
|
|
163
193
|
|
|
164
|
-
# Normalizing writer
|
|
165
|
-
#
|
|
166
|
-
# [
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
# security knob is the failure this writer exists to prevent:
|
|
194
|
+
# Normalizing writer: the list is matched as STRINGS, so [:index] matching
|
|
195
|
+
# nothing would silently un-fix #65. Normalize rather than raise for shape
|
|
196
|
+
# (nil ⇒ [], symbols ⇒ strings — unambiguous), but two inputs get a voice,
|
|
197
|
+
# because a silently-inert or silently-widened security knob is the failure
|
|
198
|
+
# this writer exists to prevent:
|
|
170
199
|
#
|
|
171
200
|
# - a full KEY ("reports#index") RAISES — the list is matched on the
|
|
172
201
|
# action segment and applies to every controller, so a keyed member
|
|
@@ -213,9 +242,11 @@ module CurrentScope
|
|
|
213
242
|
@collection_read_actions = actions.freeze
|
|
214
243
|
end
|
|
215
244
|
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
|
|
245
|
+
# Canonical mutating / bulk-write action names the collection_read writer
|
|
246
|
+
# warns about. Includes destroy_all/update_all — the docs' escalation
|
|
247
|
+
# examples — so those cannot reintroduce #49 via config with no signal.
|
|
248
|
+
# Custom names still evade any partial blocklist; that ceiling is deliberate.
|
|
249
|
+
MUTATING_ACTION_NAMES = %w[create update destroy destroy_all update_all].freeze
|
|
219
250
|
|
|
220
251
|
# Tri-state: false | true (default) | :strict — controls
|
|
221
252
|
# CurrentScope::Event.record!.
|
|
@@ -407,7 +438,7 @@ module CurrentScope
|
|
|
407
438
|
def initialize
|
|
408
439
|
@user_method = :current_user
|
|
409
440
|
@actor_method = nil
|
|
410
|
-
@sod_actions = []
|
|
441
|
+
@sod_actions = [].freeze
|
|
411
442
|
@sod_identity = :either
|
|
412
443
|
@allow_sod_bypass = false
|
|
413
444
|
@sod_bypass_permission = "bypass_sod"
|
data/lib/current_scope/guard.rb
CHANGED
|
@@ -134,8 +134,10 @@ module CurrentScope
|
|
|
134
134
|
CurrentScope::Current.collection_model = record.equal?(NO_RECORD) ? nil : model
|
|
135
135
|
CurrentScope::Current.collection_model_path = controller_path
|
|
136
136
|
|
|
137
|
-
#
|
|
138
|
-
#
|
|
137
|
+
# Decision *inputs* (subject, permission, record, model, actor) are
|
|
138
|
+
# explicit — the resolver does not read ambient identity for the allow/
|
|
139
|
+
# deny answer. Org-role *lookup* may use Current.memoized_org_role (a
|
|
140
|
+
# per-request cache, not a decision input). Actor only widens SoD under
|
|
139
141
|
# :either while impersonating; otherwise actor == subject.
|
|
140
142
|
allowed, reason = CurrentScope.resolver.decide(
|
|
141
143
|
subject: CurrentScope::Current.user, permission: permission,
|
|
@@ -60,17 +60,6 @@ module CurrentScope
|
|
|
60
60
|
# default — the class form allowed_to?(:index, Report) is how you ask about
|
|
61
61
|
# another controller, and it binds from its argument (R5). (KTD-6)
|
|
62
62
|
#
|
|
63
|
-
# The match keys on the KEY's controller, not just the controller: kwarg: a
|
|
64
|
-
# full "reports#index" key from a projects view names "reports" and must NOT
|
|
65
|
-
# borrow the projects ambient (a Project grant answering a reports key). A
|
|
66
|
-
# bare action uses the resolved controller. (#50 review, cubic)
|
|
67
|
-
def ambient_collection_model(action, controller)
|
|
68
|
-
key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
|
|
69
|
-
return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
|
|
70
|
-
|
|
71
|
-
CurrentScope::Current.collection_model
|
|
72
|
-
end
|
|
73
|
-
|
|
74
63
|
def current_scope_user
|
|
75
64
|
CurrentScope::Current.user
|
|
76
65
|
end
|
|
@@ -87,5 +76,19 @@ module CurrentScope
|
|
|
87
76
|
def impersonating?
|
|
88
77
|
CurrentScope::Current.impersonating?
|
|
89
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Internal binding for gate/view agreement (#50 KTD-6) — not host API.
|
|
83
|
+
# Private so it does not appear in controller action_methods. Matches on
|
|
84
|
+
# the KEY's controller, not just the controller: kwarg: a full
|
|
85
|
+
# "reports#index" key from a projects view must not borrow the projects
|
|
86
|
+
# ambient. A bare action uses the resolved controller. (#50 review, cubic)
|
|
87
|
+
def ambient_collection_model(action, controller)
|
|
88
|
+
key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
|
|
89
|
+
return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
|
|
90
|
+
|
|
91
|
+
CurrentScope::Current.collection_model
|
|
92
|
+
end
|
|
90
93
|
end
|
|
91
94
|
end
|