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.
- 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
|
@@ -8,13 +8,17 @@ module CurrentScope
|
|
|
8
8
|
has_many :scoped_role_assignments, dependent: :destroy
|
|
9
9
|
|
|
10
10
|
validates :name, presence: true, uniqueness: true
|
|
11
|
+
validate :permission_keys_in_catalog
|
|
11
12
|
|
|
12
13
|
after_save :persist_permission_keys
|
|
13
14
|
|
|
14
|
-
# The grid diff computed by the last save:
|
|
15
|
-
#
|
|
16
|
-
# save
|
|
17
|
-
#
|
|
15
|
+
# The grid diff computed by the last save:
|
|
16
|
+
# { added: [...], removed: [...], rejected: [...] }. Empty arrays on a no-op
|
|
17
|
+
# save; nil when no grid was staged (a programmatic save that never set
|
|
18
|
+
# permission_keys). `added`/`removed` describe what actually persisted;
|
|
19
|
+
# `rejected` names keys the scrub path threw away, so a caller that opted
|
|
20
|
+
# into silence can still log it. Controllers read this to record the change
|
|
21
|
+
# — the model itself records nothing (seeds must stay silent).
|
|
18
22
|
attr_reader :permission_keys_change
|
|
19
23
|
|
|
20
24
|
def grants?(permission_key)
|
|
@@ -25,31 +29,78 @@ module CurrentScope
|
|
|
25
29
|
@pending_permission_keys || role_permissions.pluck(:permission_key)
|
|
26
30
|
end
|
|
27
31
|
|
|
28
|
-
# Stages a replacement permission set
|
|
29
|
-
# catalog
|
|
30
|
-
#
|
|
32
|
+
# Stages a replacement permission set. STRICT: a key that isn't in the
|
|
33
|
+
# route-derived catalog makes the record invalid rather than disappearing.
|
|
34
|
+
# This is the mass-assignment writer (update!, strong params, the role
|
|
35
|
+
# grid), so it is deliberately the strict one — the scrub escape hatch must
|
|
36
|
+
# never be reachable from form params.
|
|
31
37
|
def permission_keys=(keys)
|
|
32
|
-
|
|
38
|
+
assign_permission_keys(keys, scrub: false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ponytail: one implementation, two intents — the setter is the strict
|
|
42
|
+
# front door, this is the same staging with the scrub opt-in named at the
|
|
43
|
+
# call site.
|
|
44
|
+
#
|
|
45
|
+
# `scrub: true` is the ONLY sanctioned silent drop, and it exists for one
|
|
46
|
+
# real case: a controller was removed, so a role still holds keys that no
|
|
47
|
+
# longer route. Cleaning those up is legitimate and shouldn't require the
|
|
48
|
+
# operator to name every dead key. Everything else — typos, unrouted
|
|
49
|
+
# programmatic grants, the never-routed break-glass permission — is a
|
|
50
|
+
# mistake, and a security-grant API must not swallow mistakes.
|
|
51
|
+
def assign_permission_keys(keys, scrub: false)
|
|
52
|
+
# Literal `true` only. Ruby truthiness would let the string "false" — or
|
|
53
|
+
# any params/config value that found its way here — silently disable the
|
|
54
|
+
# strict path, which is the exact hole this API exists to close. The
|
|
55
|
+
# escape hatch opens for a caller that means it, not for one that passed
|
|
56
|
+
# something along.
|
|
57
|
+
@scrub_permission_keys = scrub == true
|
|
58
|
+
# Blank entries are the grid's hidden-field padding, not typos (R2).
|
|
59
|
+
@pending_permission_keys = Array(keys).map(&:to_s).reject(&:blank?).uniq
|
|
33
60
|
end
|
|
34
61
|
|
|
35
62
|
def reload(...)
|
|
36
63
|
@pending_permission_keys = nil
|
|
64
|
+
@scrub_permission_keys = false
|
|
37
65
|
super
|
|
38
66
|
end
|
|
39
67
|
|
|
40
68
|
private
|
|
41
69
|
|
|
70
|
+
def permission_keys_in_catalog
|
|
71
|
+
return if @pending_permission_keys.nil? || @scrub_permission_keys
|
|
72
|
+
|
|
73
|
+
unknown = @pending_permission_keys.reject { |k| CurrentScope.catalog.include?(k) }
|
|
74
|
+
return if unknown.empty?
|
|
75
|
+
|
|
76
|
+
errors.add(
|
|
77
|
+
:permission_keys,
|
|
78
|
+
"not in the permission catalog: #{unknown.join(', ')} — check for typos, or use " \
|
|
79
|
+
"assign_permission_keys(..., scrub: true) to drop stale keys deliberately"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
42
83
|
def persist_permission_keys
|
|
43
84
|
return if @pending_permission_keys.nil?
|
|
44
85
|
|
|
45
86
|
# Capture the prior keys BEFORE delete_all so the diff survives the swap.
|
|
46
87
|
previous = role_permissions.pluck(:permission_key)
|
|
47
|
-
|
|
48
|
-
|
|
88
|
+
# Defense in depth: on the strict path validation already proved every key
|
|
89
|
+
# is in the catalog, so this filter is a no-op. It is what the scrub path
|
|
90
|
+
# relies on, and it means no future code path that skips validations
|
|
91
|
+
# (insert_all, update_column, a bare `save(validate: false)`) can smuggle
|
|
92
|
+
# an unknown key into the table.
|
|
93
|
+
staged = @pending_permission_keys.select { |k| CurrentScope.catalog.include?(k) }
|
|
94
|
+
@permission_keys_change = {
|
|
95
|
+
added: staged - previous,
|
|
96
|
+
removed: previous - staged,
|
|
97
|
+
rejected: @pending_permission_keys - staged
|
|
98
|
+
}
|
|
49
99
|
|
|
50
100
|
role_permissions.delete_all
|
|
51
101
|
role_permissions.insert_all(staged.map { |k| { permission_key: k } }) if staged.any?
|
|
52
102
|
@pending_permission_keys = nil
|
|
103
|
+
@scrub_permission_keys = false
|
|
53
104
|
end
|
|
54
105
|
end
|
|
55
106
|
end
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<%= form_with model: @role do |f| %>
|
|
6
6
|
<% if @role.errors.any? %>
|
|
7
|
-
<p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
7
|
+
<p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
8
8
|
<% end %>
|
|
9
9
|
|
|
10
10
|
<p><%= f.label :name %> <%= f.text_field :name %></p>
|
|
@@ -20,10 +20,33 @@
|
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
<h2>Permission grid</h2>
|
|
23
|
+
<%# The injected break-glass action's bare name (nil when break-glass is
|
|
24
|
+
off) — the hint below and the per-row exemption check both read it. The
|
|
25
|
+
CATALOG owns the parse: its split("#", -1) + blank-raise catches a
|
|
26
|
+
malformed "reports#" that a loose split here would silently turn into a
|
|
27
|
+
believable wrong action name. (#79 review, qodo) %>
|
|
28
|
+
<% bypass_action = CurrentScope.config.allow_sod_bypass ? CurrentScope.catalog.bypass_action : nil %>
|
|
23
29
|
<p class="cs-hint">
|
|
24
30
|
One row per controller, one checkbox per action group — derived from the
|
|
25
31
|
app's routes, so new controllers appear here automatically. Columns are
|
|
26
32
|
fixed: a blank cell means the controller doesn't route that action.
|
|
33
|
+
<%# The break-glass column is the one thing here that is NOT an action you
|
|
34
|
+
can route, so the "derived from the app's routes" line above would
|
|
35
|
+
otherwise make it look like a mistake. %>
|
|
36
|
+
<% if CurrentScope.config.allow_sod_bypass %>
|
|
37
|
+
Break-glass is on, so controllers with a separation-of-duties action also
|
|
38
|
+
show a <code><%= bypass_action %></code>
|
|
39
|
+
column: granting it lets that role's holder override the four-eyes veto on
|
|
40
|
+
their own record, where the record allows it. Every use is audited.
|
|
41
|
+
<% end %>
|
|
42
|
+
</p>
|
|
43
|
+
<%# Unconditional on purpose (R9): the limit of the mark must be stated even
|
|
44
|
+
when nothing is marked — an unmarked row is a non-answer, not a proof. %>
|
|
45
|
+
<p class="cs-hint cs-ungated-hint">
|
|
46
|
+
Rows tagged "gate not run" are controllers provably never gated — the tag
|
|
47
|
+
appears only when the callback chain proves it. An unmarked row is not
|
|
48
|
+
proof the gate runs there (a conditional skip, for example, is unprovable
|
|
49
|
+
and renders unmarked).
|
|
27
50
|
</p>
|
|
28
51
|
|
|
29
52
|
<% grid = CurrentScope::PermissionGrid.new %>
|
|
@@ -42,15 +65,58 @@
|
|
|
42
65
|
</thead>
|
|
43
66
|
<tbody>
|
|
44
67
|
<% grid.controllers.each do |controller| %>
|
|
68
|
+
<%# A broken controller body's NameError deliberately propagates out
|
|
69
|
+
of the reflection (KTD-2) — but ONE broken controller must not
|
|
70
|
+
take down the whole role editor for every other row. The view
|
|
71
|
+
renders that row as explicitly uninspectable (a visible state,
|
|
72
|
+
never silence, never "gated"); the rake task keeps propagating,
|
|
73
|
+
because its output makes proof claims a partial walk can't. %>
|
|
74
|
+
<% ungated = begin
|
|
75
|
+
grid.ungated?(controller)
|
|
76
|
+
rescue NameError
|
|
77
|
+
:uninspectable
|
|
78
|
+
end %>
|
|
79
|
+
<% uninspectable = ungated == :uninspectable
|
|
80
|
+
ungated = ungated == true %>
|
|
81
|
+
<% badge_id = "cs_ungated_#{controller.parameterize(separator: '_')}" if ungated %>
|
|
45
82
|
<tr>
|
|
46
83
|
<th scope="row">
|
|
47
84
|
<%# Per-row master: enable/disable every action in this controller at once.
|
|
48
85
|
Progressive enhancement — with JS off, each cell checkbox still works. %>
|
|
49
86
|
<label class="cs-row-all" title="Enable all <%= controller %> permissions">
|
|
50
87
|
<input type="checkbox" data-cs-row-all
|
|
51
|
-
aria-label="Enable all <%= controller %> permissions"
|
|
88
|
+
aria-label="Enable all <%= controller %> permissions"<% if ungated %>
|
|
89
|
+
aria-describedby="<%= badge_id %>"<% end %>>
|
|
52
90
|
<span><%= controller %></span>
|
|
53
91
|
</label>
|
|
92
|
+
<% if uninspectable %>
|
|
93
|
+
<%# Not a badge and not silence: the gate state is UNKNOWN here,
|
|
94
|
+
and rendering nothing would read as "checked, and gated"
|
|
95
|
+
under the hint's own rules. %>
|
|
96
|
+
<span class="cs-uninspectable-note" id="cs_uninspectable_<%= controller.parameterize(separator: '_') %>">
|
|
97
|
+
could not inspect — this controller raised while loading, so its
|
|
98
|
+
gate state is unknown. Fix the load error, then reload.
|
|
99
|
+
</span>
|
|
100
|
+
<% end %>
|
|
101
|
+
<% if ungated %>
|
|
102
|
+
<%# Advisory only — marking is not disabling (KTD-8/R5): nothing
|
|
103
|
+
here is grayed or disabled. Same remediation vocabulary as
|
|
104
|
+
GatingTripwire's message. The claim is scoped to ROUTED
|
|
105
|
+
actions because the injected break-glass cell is live even
|
|
106
|
+
on a marked row (KTD-9) and carries its own note below. %>
|
|
107
|
+
<%# Each remedy names its cause: re-including Guard on a subclass
|
|
108
|
+
that inherited a skip is a NO-OP (the Concern short-circuits;
|
|
109
|
+
the skip already removed the callback the include would add),
|
|
110
|
+
so the inherited-skip host — the #62 shape this badge exists
|
|
111
|
+
for — must be pointed at re-assertion, not at include. %>
|
|
112
|
+
<span class="cs-ungated-badge" id="<%= badge_id %>">
|
|
113
|
+
<strong>gate not run</strong> — this controller does not run the
|
|
114
|
+
gate; ticking its routed actions grants nothing until it does.
|
|
115
|
+
If it inherited a skip, re-assert
|
|
116
|
+
<code>before_action :current_scope_check!</code>; if it never had
|
|
117
|
+
the gate, include CurrentScope::Guard.
|
|
118
|
+
</span>
|
|
119
|
+
<% end %>
|
|
54
120
|
</th>
|
|
55
121
|
<% grid.columns.each do |column| %>
|
|
56
122
|
<% cell = grid.cell(controller, column, granted) %>
|
|
@@ -60,13 +126,35 @@
|
|
|
60
126
|
readers; the "·" marker is decorative CSS. %>
|
|
61
127
|
<td class="cs-cell-blank"></td>
|
|
62
128
|
<% else %>
|
|
63
|
-
|
|
129
|
+
<%# column.actions covers both shapes — an ungrouped column is
|
|
130
|
+
[action], and a permission_grid_groups entry containing the
|
|
131
|
+
bypass action must not defeat the exemption. The two guards:
|
|
132
|
+
the INJECTED key must actually sit on this controller (a
|
|
133
|
+
grouped cell can be non-blank via its other actions), and
|
|
134
|
+
routed? excludes the collision — a REAL routed action merely
|
|
135
|
+
named like the bypass permission is ordinary and inert on a
|
|
136
|
+
marked row; only the catalog-injected key stays live. (#79) %>
|
|
137
|
+
<% bypass_exempt = ungated && bypass_action &&
|
|
138
|
+
column.actions.include?(bypass_action) &&
|
|
139
|
+
CurrentScope.catalog.grouped[controller]&.include?(bypass_action) &&
|
|
140
|
+
!CurrentScope.catalog.routed?("#{controller}##{bypass_action}") %>
|
|
141
|
+
<% note_id = "cs_bypass_exempt_#{controller.parameterize(separator: '_')}" if bypass_exempt %>
|
|
142
|
+
<td<% if bypass_exempt %> class="cs-cell-bypass"<% end %>>
|
|
64
143
|
<label>
|
|
65
144
|
<%= check_box_tag cell.name, cell.value, cell.checked,
|
|
66
145
|
id: "perm_#{cell.value.parameterize(separator: '_')}",
|
|
67
146
|
data: { cs_partial: cell.partial },
|
|
68
|
-
"aria-label": "#{controller} #{column.label}"
|
|
147
|
+
"aria-label": "#{controller} #{column.label}",
|
|
148
|
+
"aria-describedby": (bypass_exempt ? "#{badge_id} #{note_id}" : badge_id) %>
|
|
69
149
|
</label>
|
|
150
|
+
<% if bypass_exempt %>
|
|
151
|
+
<%# KTD-9: break-glass is decided by GATED controllers acting
|
|
152
|
+
on the record type, so this grant is live despite the
|
|
153
|
+
row's badge — the one cell exempt from its claim. The
|
|
154
|
+
class is also the CSS hook that keeps the real granted
|
|
155
|
+
wash here while sibling cells are muted. %>
|
|
156
|
+
<span class="cs-bypass-exempt" id="<%= note_id %>">exempt — stays live</span>
|
|
157
|
+
<% end %>
|
|
70
158
|
<%# Partial group: preserve the exact granted actions so a no-op
|
|
71
159
|
save can't promote the group to a full grant. Interacting with
|
|
72
160
|
the checkbox disables these (JS) so the group control takes over. %>
|
|
@@ -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 %>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<h2>Add org-wide members</h2>
|
|
13
13
|
<% if @candidates.any? %>
|
|
14
|
-
<%= form_with url:
|
|
14
|
+
<%= form_with url: role_assignments_path, method: :post do %>
|
|
15
15
|
<%= hidden_field_tag :role_id, @role.id %>
|
|
16
16
|
<p>
|
|
17
17
|
<%= label_tag "subject_gids", "Subjects" %>
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<td>
|
|
47
47
|
<% if subject_gid %>
|
|
48
48
|
<%# Removing = clearing this subject's org-wide role (blank role_id). %>
|
|
49
|
-
<%= form_with url:
|
|
49
|
+
<%= form_with url: role_assignments_path, method: :post,
|
|
50
50
|
data: { cs_confirm: "Remove #{who} from #{@role.name}?" } do %>
|
|
51
51
|
<%= hidden_field_tag :subject_gid, subject_gid %>
|
|
52
52
|
<%= hidden_field_tag :role_id, "" %>
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
<% end %>
|
|
55
55
|
<% else %>
|
|
56
56
|
<%# Orphaned assignment (subject deleted): remove it by id. %>
|
|
57
|
-
<%= button_to "Remove",
|
|
57
|
+
<%= button_to "Remove", role_assignment_path(assignment), method: :delete,
|
|
58
58
|
class: "cs-btn", form: { data: { cs_confirm: "Remove this orphaned org-wide assignment?" } } %>
|
|
59
59
|
<span class="cs-subtle">subject unavailable</span>
|
|
60
60
|
<% end %>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<%= form_with model: @role do |f| %>
|
|
4
4
|
<% if @role.errors.any? %>
|
|
5
|
-
<p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
5
|
+
<p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
6
6
|
<% end %>
|
|
7
7
|
|
|
8
8
|
<p><%= f.label :name %> <%= f.text_field :name %></p>
|
|
@@ -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
|
|
@@ -102,9 +102,18 @@
|
|
|
102
102
|
include_blank: "— choose a record —", form: "cs-cascade",
|
|
103
103
|
data: { current_scope_autosubmit: true } %>
|
|
104
104
|
<% if @searchable && params[:q].present? %>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
<%# Keyed off the raw SEARCH results, not record_options — a
|
|
106
|
+
deep-linked @resource is prepended to the options even when the
|
|
107
|
+
query matched nothing, and must not fake a "matches shown". %>
|
|
108
|
+
<% if @records.blank? %>
|
|
109
|
+
<span class="cs-hint cs-picker-empty">
|
|
110
|
+
No records match “<%= params[:q] %>” — try a different search.
|
|
111
|
+
</span>
|
|
112
|
+
<% else %>
|
|
113
|
+
<span class="cs-hint">
|
|
114
|
+
Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches — refine the search to narrow.
|
|
115
|
+
</span>
|
|
116
|
+
<% end %>
|
|
108
117
|
<% end %>
|
|
109
118
|
</p>
|
|
110
119
|
<% end %>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<%# The engine's front-door 403. Deliberately NOT rendered in the console
|
|
2
|
+
layout: that layout is a sidebar of links to Roles / Subjects / Events, and
|
|
3
|
+
showing the console chrome to someone who cannot open any of it reads as
|
|
4
|
+
"you're in" while every link refuses. Standalone page, engine stylesheet, no
|
|
5
|
+
navigation offered that isn't there. %>
|
|
6
|
+
<!DOCTYPE html>
|
|
7
|
+
<html lang="en"<%= " data-cs-theme=\"#{cookies[:current_scope_theme]}\"".html_safe if %w[light dark].include?(cookies[:current_scope_theme]) %>>
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>Access denied — CurrentScope</title>
|
|
12
|
+
<%= csp_meta_tag %>
|
|
13
|
+
<%= stylesheet_link_tag "current_scope/application", media: "all" %>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main class="cs-card cs-denied">
|
|
17
|
+
<h1>This area needs a full-access role</h1>
|
|
18
|
+
<p>
|
|
19
|
+
The authorization console is where roles and permissions are granted, so it
|
|
20
|
+
can't be gated by a permission you could be granted — it's open only to
|
|
21
|
+
subjects holding a role marked <strong>full access</strong>.
|
|
22
|
+
</p>
|
|
23
|
+
<p class="cs-hint">
|
|
24
|
+
If you should have it, ask someone who already does to grant you a
|
|
25
|
+
full-access role. If nobody has one yet, it's granted from the console or a
|
|
26
|
+
rake task — see the CurrentScope README's bootstrap section.
|
|
27
|
+
</p>
|
|
28
|
+
</main>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
</div>
|
|
15
15
|
<% if @query.present? %>
|
|
16
16
|
<p class="cs-hint">Showing matches for “<%= @query %>” across all subjects. Narrow the list below per page; selection applies to the current page.</p>
|
|
17
|
-
<%
|
|
17
|
+
<% else %>
|
|
18
|
+
<%# Always shown (not just on multi-page lists): the one input drives TWO
|
|
19
|
+
mechanisms — typing live-filters this page, Search queries every subject —
|
|
20
|
+
and nothing but this line says so. %>
|
|
18
21
|
<p class="cs-hint">Type to filter this page, or Search to find anyone by email or name across every subject.</p>
|
|
19
22
|
<% end %>
|
|
20
23
|
|
|
@@ -23,8 +26,11 @@
|
|
|
23
26
|
<span class="cs-spacer"></span>
|
|
24
27
|
<%# Org-wide role for the selection. JS injects the checked subjects as
|
|
25
28
|
subject_gids[] on submit; blank role clears it for all. %>
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
<%# Setting REPLACES each subject's current org-wide role (blank clears it) —
|
|
30
|
+
as consequential as Remove, which confirms, so this does too. %>
|
|
31
|
+
<% bulk_prompt = "Change the org-wide role for the selected subjects? This replaces their current role." %>
|
|
32
|
+
<%= form_with url: role_assignments_path, method: :post, class: "cs-bulk-org",
|
|
33
|
+
data: { cs_bulk_org: true, cs_confirm: bulk_prompt, turbo_confirm: bulk_prompt } do %>
|
|
28
34
|
<%= select_tag :role_id, options_from_collection_for_select(@roles, :id, :name),
|
|
29
35
|
include_blank: "org role…", aria: { label: "Org-wide role for selected" } %>
|
|
30
36
|
<%= submit_tag "Set for selected", name: nil, class: "cs-btn" %>
|
|
@@ -46,7 +52,11 @@
|
|
|
46
52
|
</thead>
|
|
47
53
|
<tbody>
|
|
48
54
|
<% @subjects.each do |subject| %>
|
|
49
|
-
|
|
55
|
+
<%# Key by what the polymorphic association STORES (the base_class name),
|
|
56
|
+
not subject.class.name — an STI subject (AdminUser < User) is saved as
|
|
57
|
+
"User", and keying on the subclass name would show its roles as
|
|
58
|
+
"— none —" while the resolver happily enforces them. %>
|
|
59
|
+
<% key = [ subject.class.polymorphic_name, subject.id ] %>
|
|
50
60
|
<% label = current_scope_subject_label(subject) %>
|
|
51
61
|
<%# Filter against a precise text set — subject label, current org role, and
|
|
52
62
|
scoped roles/records — NOT the row's textContent: every row embeds a
|
|
@@ -61,12 +71,16 @@
|
|
|
61
71
|
</td>
|
|
62
72
|
<td><%= label %></td>
|
|
63
73
|
<td>
|
|
64
|
-
|
|
74
|
+
<% set_prompt = "Change the org-wide role for #{label}? This replaces their current role." %>
|
|
75
|
+
<%= form_with url: role_assignments_path, method: :post,
|
|
76
|
+
data: { cs_confirm: set_prompt, turbo_confirm: set_prompt } do %>
|
|
65
77
|
<%= hidden_field_tag :subject_gid, subject.to_gid %>
|
|
66
78
|
<%= select_tag :role_id,
|
|
67
79
|
options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
|
|
68
|
-
include_blank: "— none —"
|
|
69
|
-
|
|
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}" } %>
|
|
70
84
|
<% end %>
|
|
71
85
|
</td>
|
|
72
86
|
<td>
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
<%= javascript_include_tag "current_scope/application", defer: true %>
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
|
+
<%# WCAG 2.4.1: every page repeats the sidebar + topbar before its content —
|
|
19
|
+
keyboard users get a bypass. Visually hidden until focused. %>
|
|
20
|
+
<a class="cs-skip-link" href="#cs-main-content">Skip to content</a>
|
|
18
21
|
<div class="cs-shell">
|
|
19
22
|
<aside class="cs-sidebar">
|
|
20
23
|
<div class="cs-brand"><span class="cs-brand-mark" aria-hidden="true"></span> CurrentScope</div>
|
|
@@ -44,7 +47,7 @@
|
|
|
44
47
|
<% if notice.present? %><p class="cs-flash cs-flash--notice" role="status"><%= notice %></p><% end %>
|
|
45
48
|
<% if alert.present? %><p class="cs-flash cs-flash--alert" role="alert"><%= alert %></p><% end %>
|
|
46
49
|
|
|
47
|
-
<main class="cs-content">
|
|
50
|
+
<main class="cs-content" id="cs-main-content" tabindex="-1">
|
|
48
51
|
<%= yield %>
|
|
49
52
|
</main>
|
|
50
53
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -6,9 +6,8 @@ CurrentScope::Engine.routes.draw do
|
|
|
6
6
|
end
|
|
7
7
|
resources :subjects, only: :index
|
|
8
8
|
resources :events, only: :index
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
delete "role_assignments/:id" => "role_assignments#destroy", as: :remove_role_assignment
|
|
9
|
+
# create is subject-keyed (no id); destroy removes one assignment by id (the
|
|
10
|
+
# members page's cleanup path for an orphan whose subject was deleted).
|
|
11
|
+
resources :role_assignments, only: [ :create, :destroy ]
|
|
13
12
|
resources :scoped_role_assignments, only: [ :new, :create, :destroy ]
|
|
14
13
|
end
|