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,229 @@
1
+ <h1>Edit role: <%= @role.name %></h1>
2
+
3
+ <p><%= link_to "See who holds this role →", members_role_path(@role) %></p>
4
+
5
+ <%= form_with model: @role do |f| %>
6
+ <% if @role.errors.any? %>
7
+ <p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
8
+ <% end %>
9
+
10
+ <p><%= f.label :name %> <%= f.text_field :name %></p>
11
+ <p>
12
+ <%= f.label :description %>
13
+ <%= f.text_area :description, rows: 2, placeholder: "What is this role for? (optional)" %>
14
+ </p>
15
+ <p>
16
+ <%= f.label :full_access do %>
17
+ <%= f.check_box :full_access %> Full access — every permission, present and future. Does not cascade to child records through current_scope_parent.
18
+ (the grid below is ignored while this is on)
19
+ <% end %>
20
+ </p>
21
+
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 %>
29
+ <p class="cs-hint">
30
+ One row per controller, one checkbox per action group — derived from the
31
+ app's routes, so new controllers appear here automatically. Columns are
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).
50
+ </p>
51
+
52
+ <% grid = CurrentScope::PermissionGrid.new %>
53
+ <% granted = @role.permission_keys.to_set %>
54
+ <%= hidden_field_tag "role[permission_keys][]", "" %>
55
+ <%= hidden_field_tag "role[permission_groups][]", "" %>
56
+ <div class="cs-grid-wrap">
57
+ <table class="cs-grid">
58
+ <thead>
59
+ <tr>
60
+ <th scope="col" class="cs-grid-corner">Controller</th>
61
+ <% grid.columns.each do |column| %>
62
+ <th scope="col"><%= column.label %></th>
63
+ <% end %>
64
+ </tr>
65
+ </thead>
66
+ <tbody>
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
+ <%# #76: a declared skip carries its reason. Only ungated rows can be
80
+ declared, so this is the one constantize an ungated row needs. %>
81
+ <% skip_reason = begin
82
+ grid.declared_skip_reason(controller)
83
+ rescue NameError
84
+ nil
85
+ end if ungated == true %>
86
+ <%# #43: only badge missing controllers on ROUTE-backed rows.
87
+ Break-glass can inject a synthetic "reports" row that no path
88
+ routes (admin-only SoD); that row is not a 500 risk and must not
89
+ wear the phantom badge. Also skip the constantize when the row
90
+ is already proven ungated (qodo). %>
91
+ <% uninspectable = ungated == :uninspectable
92
+ ungated = ungated == true
93
+ declared = ungated && skip_reason.present?
94
+ has_routed = grid.actions_for(controller).any? { |a|
95
+ CurrentScope.catalog.routed?("#{controller}##{a}")
96
+ }
97
+ missing = if uninspectable || ungated || !has_routed
98
+ false
99
+ else
100
+ begin
101
+ grid.missing_controller?(controller)
102
+ rescue NameError
103
+ false
104
+ end
105
+ end %>
106
+ <%# Mutually exclusive by construction: `declared` implies ungated,
107
+ `missing` is only computed when NOT ungated. Order states from
108
+ most specific to least. %>
109
+ <% badge_id = if declared
110
+ "cs_declared_skip_#{controller.parameterize(separator: '_')}"
111
+ elsif ungated
112
+ "cs_ungated_#{controller.parameterize(separator: '_')}"
113
+ elsif missing
114
+ "cs_missing_controller_#{controller.parameterize(separator: '_')}"
115
+ end %>
116
+ <tr>
117
+ <th scope="row">
118
+ <%# Per-row master: enable/disable every action in this controller at once.
119
+ Progressive enhancement — with JS off, each cell checkbox still works. %>
120
+ <label class="cs-row-all" title="Enable all <%= controller %> permissions">
121
+ <input type="checkbox" data-cs-row-all
122
+ aria-label="Enable all <%= controller %> permissions"<% if badge_id %>
123
+ aria-describedby="<%= badge_id %>"<% end %>>
124
+ <span><%= controller %></span>
125
+ </label>
126
+ <% if uninspectable %>
127
+ <%# Not a badge and not silence: the gate state is UNKNOWN here,
128
+ and rendering nothing would read as "checked, and gated"
129
+ under the hint's own rules. %>
130
+ <span class="cs-uninspectable-note" id="cs_uninspectable_<%= controller.parameterize(separator: '_') %>">
131
+ could not inspect — this controller raised while loading, so its
132
+ gate state is unknown. Fix the load error, then reload.
133
+ </span>
134
+ <% end %>
135
+ <% if missing %>
136
+ <%# #43: route exists, controller class does not. Granting still
137
+ works (catalog is a route mirror); hitting the path is a
138
+ MissingController 500. Flag, do not remove. %>
139
+ <span class="cs-missing-controller-badge" id="<%= badge_id %>">
140
+ <strong>no controller</strong> — this route has no controller
141
+ class (stale or typo route). Granting is allowed, but a request
142
+ will 500 with <code>ActionDispatch::MissingController</code>.
143
+ Remove the route or add the controller.
144
+ </span>
145
+ <% end %>
146
+ <% if declared %>
147
+ <%# #76 — intentional skip with a reason. Not alarming: a reviewer
148
+ can read why the gate is off. Bare skips keep the warning. %>
149
+ <span class="cs-declared-skip-badge" id="<%= badge_id %>">
150
+ <strong>skipped</strong> — <%= skip_reason %>.
151
+ Ticking its routed actions still grants nothing until the gate
152
+ runs; this declaration only records intent.
153
+ </span>
154
+ <% elsif ungated %>
155
+ <%# Advisory only — marking is not disabling (KTD-8/R5): nothing
156
+ here is grayed or disabled. Same remediation vocabulary as
157
+ GatingTripwire's message. The claim is scoped to ROUTED
158
+ actions because the injected break-glass cell is live even
159
+ on a marked row (KTD-9) and carries its own note below. %>
160
+ <%# Each remedy names its cause: re-including Guard on a subclass
161
+ that inherited a skip is a NO-OP (the Concern short-circuits;
162
+ the skip already removed the callback the include would add),
163
+ so the inherited-skip host — the #62 shape this badge exists
164
+ for — must be pointed at re-assertion, not at include. %>
165
+ <span class="cs-ungated-badge" id="<%= badge_id %>">
166
+ <strong>gate not run</strong> — this controller does not run the
167
+ gate; ticking its routed actions grants nothing until it does.
168
+ If it inherited a skip, re-assert
169
+ <code>before_action :current_scope_check!</code>; if it never had
170
+ the gate, include CurrentScope::Guard. Prefer
171
+ <code>current_scope_skip_gate!(reason: "…")</code> for deliberate
172
+ skips so the reason shows here.
173
+ </span>
174
+ <% end %>
175
+ </th>
176
+ <% grid.columns.each do |column| %>
177
+ <% cell = grid.cell(controller, column, granted) %>
178
+ <% if cell.blank %>
179
+ <%# Not routed here — an empty cell. Kept in the a11y tree (no
180
+ aria-hidden) so column/row alignment stays intact for screen
181
+ readers; the "·" marker is decorative CSS. %>
182
+ <td class="cs-cell-blank"></td>
183
+ <% else %>
184
+ <%# column.actions covers both shapes — an ungrouped column is
185
+ [action], and a permission_grid_groups entry containing the
186
+ bypass action must not defeat the exemption. The two guards:
187
+ the INJECTED key must actually sit on this controller (a
188
+ grouped cell can be non-blank via its other actions), and
189
+ routed? excludes the collision — a REAL routed action merely
190
+ named like the bypass permission is ordinary and inert on a
191
+ marked row; only the catalog-injected key stays live. (#79) %>
192
+ <% bypass_exempt = ungated && bypass_action &&
193
+ column.actions.include?(bypass_action) &&
194
+ CurrentScope.catalog.grouped[controller]&.include?(bypass_action) &&
195
+ !CurrentScope.catalog.routed?("#{controller}##{bypass_action}") %>
196
+ <% note_id = "cs_bypass_exempt_#{controller.parameterize(separator: '_')}" if bypass_exempt %>
197
+ <td<% if bypass_exempt %> class="cs-cell-bypass"<% end %>>
198
+ <label>
199
+ <%= check_box_tag cell.name, cell.value, cell.checked,
200
+ id: "perm_#{cell.value.parameterize(separator: '_')}",
201
+ data: { cs_partial: cell.partial },
202
+ "aria-label": "#{controller} #{column.label}",
203
+ "aria-describedby": (bypass_exempt ? "#{badge_id} #{note_id}" : badge_id) %>
204
+ </label>
205
+ <% if bypass_exempt %>
206
+ <%# KTD-9: break-glass is decided by GATED controllers acting
207
+ on the record type, so this grant is live despite the
208
+ row's badge — the one cell exempt from its claim. The
209
+ class is also the CSS hook that keeps the real granted
210
+ wash here while sibling cells are muted. %>
211
+ <span class="cs-bypass-exempt" id="<%= note_id %>">exempt — stays live</span>
212
+ <% end %>
213
+ <%# Partial group: preserve the exact granted actions so a no-op
214
+ save can't promote the group to a full grant. Interacting with
215
+ the checkbox disables these (JS) so the group control takes over. %>
216
+ <% cell.granted_keys.each do |key| %>
217
+ <%= hidden_field_tag "role[permission_keys][]", key, id: nil, data: { cs_preserve: true } %>
218
+ <% end %>
219
+ </td>
220
+ <% end %>
221
+ <% end %>
222
+ </tr>
223
+ <% end %>
224
+ </tbody>
225
+ </table>
226
+ </div>
227
+
228
+ <p><%= f.submit "Save role" %></p>
229
+ <% end %>
@@ -0,0 +1,55 @@
1
+ <h1>Roles</h1>
2
+ <p class="cs-lede">A role is an editable bundle of permissions. Edit one to tick
3
+ its controller&times;action grid; grants take effect on the subject's next request.</p>
4
+
5
+ <div class="cs-toolbar">
6
+ <span class="cs-spacer"></span>
7
+ <%= link_to "New role", new_role_path, class: "cs-btn cs-btn-primary" %>
8
+ </div>
9
+
10
+ <div class="cs-card cs-card--flush">
11
+ <table class="cs-table">
12
+ <thead>
13
+ <tr><th>Name</th><th>Access</th><th></th></tr>
14
+ </thead>
15
+ <tbody>
16
+ <% @roles.each do |role| %>
17
+ <tr>
18
+ <td>
19
+ <%= link_to role.name, edit_role_path(role) %>
20
+ <% if role.description.present? %>
21
+ <div class="cs-subtle"><%= role.description %></div>
22
+ <% end %>
23
+ </td>
24
+ <td>
25
+ <% if role.full_access? %>
26
+ full access (all current and future permissions)
27
+ <% else %>
28
+ <%= pluralize(role.role_permissions.size, "permission") %>
29
+ <% end %>
30
+ </td>
31
+ <td>
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
+ %>
45
+ <%= button_to "Delete", role_path(role), method: :delete,
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 } %>
50
+ </td>
51
+ </tr>
52
+ <% end %>
53
+ </tbody>
54
+ </table>
55
+ </div>
@@ -0,0 +1,114 @@
1
+ <h1>Members: <%= @role.name %></h1>
2
+ <p class="cs-lede">Who holds <strong><%= @role.name %></strong> — as their one org-wide
3
+ role, or on a specific record. Assigning the org-wide role here replaces a
4
+ subject's current one (each subject has exactly one org-wide role).</p>
5
+
6
+ <div class="cs-toolbar">
7
+ <%= link_to "← All roles", roles_path %>
8
+ <span class="cs-spacer"></span>
9
+ <%= link_to "Edit permissions", edit_role_path(@role), class: "cs-btn" %>
10
+ </div>
11
+
12
+ <h2>Add org-wide members</h2>
13
+ <% if @candidates.any? %>
14
+ <%= form_with url: role_assignments_path, method: :post do %>
15
+ <%= hidden_field_tag :role_id, @role.id %>
16
+ <p>
17
+ <%= label_tag "subject_gids", "Subjects" %>
18
+ <select name="subject_gids[]" id="subject_gids" multiple size="8"
19
+ aria-label="Subjects to grant this role org-wide">
20
+ <% @candidates.each do |subject| %>
21
+ <option value="<%= subject.to_gid %>"><%= current_scope_subject_label(subject) %></option>
22
+ <% end %>
23
+ </select>
24
+ </p>
25
+ <% if @more_candidates %>
26
+ <p class="cs-hint">Showing the first <%= @candidates.size %> subjects who don't
27
+ already hold this role. Use the Subjects page to reach the rest.</p>
28
+ <% end %>
29
+ <p><%= submit_tag "Add selected as org-wide members" %></p>
30
+ <% end %>
31
+ <% else %>
32
+ <p class="cs-subtle">Every subject already holds this role org-wide.</p>
33
+ <% end %>
34
+
35
+ <h2>Org-wide holders <span class="cs-subtle">(<%= @org_holders.size %>)</span></h2>
36
+ <% if @org_holders.any? %>
37
+ <div class="cs-card cs-card--flush">
38
+ <table class="cs-table">
39
+ <thead><tr><th>Subject</th><th></th></tr></thead>
40
+ <tbody>
41
+ <% @org_holders.each do |assignment| %>
42
+ <% who = current_scope_holder_subject_label(assignment) %>
43
+ <%# Through the canonical guard: a non-canonical stored subject_id must
44
+ not resolve to an unrelated live subject whose GID the Remove button
45
+ would then post to the org-clear path (#151). nil -> orphaned branch. %>
46
+ <% subject_gid = (assignment.current_scope_resolved_record("subject")&.to_gid rescue nil) %>
47
+ <tr>
48
+ <td><%= who %></td>
49
+ <td>
50
+ <% if subject_gid %>
51
+ <%# Removing = clearing this subject's org-wide role (blank role_id). %>
52
+ <%= form_with url: role_assignments_path, method: :post,
53
+ data: { cs_confirm: "Remove #{who} from #{@role.name}?" } do %>
54
+ <%= hidden_field_tag :subject_gid, subject_gid %>
55
+ <%= hidden_field_tag :role_id, "" %>
56
+ <%= submit_tag "Remove", name: nil, class: "cs-btn" %>
57
+ <% end %>
58
+ <% else %>
59
+ <%# Orphaned assignment (subject deleted): remove it by id. %>
60
+ <%= button_to "Remove", role_assignment_path(assignment), method: :delete,
61
+ class: "cs-btn", form: { data: { cs_confirm: "Remove this orphaned org-wide assignment?" } } %>
62
+ <span class="cs-subtle">subject unavailable</span>
63
+ <% end %>
64
+ </td>
65
+ </tr>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
69
+ </div>
70
+ <% else %>
71
+ <p class="cs-subtle">No subject holds this as their org-wide role yet.</p>
72
+ <% end %>
73
+
74
+ <h2>Scoped holders <span class="cs-subtle">(<%= @scoped_holders.size %>)</span></h2>
75
+ <% if @scoped_holders.any? %>
76
+ <div class="cs-card cs-card--flush">
77
+ <table class="cs-table">
78
+ <thead><tr><th>Subject</th><th>Record</th><th></th></tr></thead>
79
+ <tbody>
80
+ <% @scoped_holders.each do |sra| %>
81
+ <% who = current_scope_holder_subject_label(sra) %>
82
+ <% what = current_scope_holder_resource_label(sra) %>
83
+ <% orphaned = sra.orphaned_resource? %>
84
+ <tr id="scoped_holder_<%= sra.id %>"
85
+ class="cs-scoped-holder<%= ' cs-row--inert' if orphaned %>">
86
+ <td><%= who %></td>
87
+ <td>
88
+ <%= what %>
89
+ <% if orphaned %>
90
+ <span class="cs-inert-badge"
91
+ title="This resource no longer resolves; the grant opens nothing on collection reads">inert</span>
92
+ <% end %>
93
+ <% if (diag = current_scope_grant_diagnosis_badge(sra)) %>
94
+ <span class="<%= diag[0] %>" title="<%= diag[2] %>"><%= diag[1] %><span class="cs-badge-note"> — <%= diag[2] %></span></span>
95
+ <% end %>
96
+ </td>
97
+ <td>
98
+ <% revoke_prompt = orphaned ?
99
+ "Remove inert scoped grant (resource unavailable) for #{who}?" :
100
+ "Revoke #{@role.name} on #{what} from #{who}?" %>
101
+ <%= button_to (orphaned ? "Remove inert" : "Revoke"),
102
+ scoped_role_assignment_path(sra), method: :delete,
103
+ id: "scoped_revoke_#{sra.id}",
104
+ class: "cs-btn", form: { data: { cs_confirm: revoke_prompt } },
105
+ data: { turbo_confirm: revoke_prompt } %>
106
+ </td>
107
+ </tr>
108
+ <% end %>
109
+ </tbody>
110
+ </table>
111
+ </div>
112
+ <% else %>
113
+ <p class="cs-subtle">No one holds this role on a specific record.</p>
114
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <h1>New role</h1>
2
+
3
+ <%= form_with model: @role do |f| %>
4
+ <% if @role.errors.any? %>
5
+ <p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
6
+ <% end %>
7
+
8
+ <p><%= f.label :name %> <%= f.text_field :name %></p>
9
+ <p>
10
+ <%= f.label :description %>
11
+ <%= f.text_area :description, rows: 2, placeholder: "What is this role for? (optional)" %>
12
+ </p>
13
+ <p>
14
+ <%= f.label :full_access do %>
15
+ <%= f.check_box :full_access %> Full access — every permission, present and future. Does not cascade to child records through current_scope_parent.
16
+ <% end %>
17
+ </p>
18
+ <p><%= f.submit "Create role" %></p>
19
+ <% end %>
@@ -0,0 +1,144 @@
1
+ <h1><%= @bulk_subjects.any? ? "Grant a scoped role to #{@bulk_subjects.size} #{'subject'.pluralize(@bulk_subjects.size)}" : "Grant a scoped role" %></h1>
2
+
3
+ <p class="cs-hint">
4
+ A scoped role applies only when acting on the chosen record — it never
5
+ changes the subject's org-wide role.
6
+ </p>
7
+
8
+ <%#
9
+ Two forms, on purpose:
10
+
11
+ * The cascade is a GET form (id "cs-cascade"). Each control re-renders the
12
+ downstream steps: with Turbo it swaps just the #cascade frame; without JS
13
+ or Turbo the "Show records" submit does a full-page GET (every selection is
14
+ preserved from params). A GET form carries NO CSRF token — a token in a GET
15
+ would leak into the URL (server logs, history, Referer).
16
+
17
+ * The grant is a state change, so it is a SEPARATE POST form (button_to)
18
+ rendered inside the frame once the selection is complete. button_to puts
19
+ the CSRF token in the request body, never a URL. It lives OUTSIDE the GET
20
+ form (nesting <form>s is invalid) and reaches the cascade's inputs via the
21
+ HTML `form:` attribute, which also lets the GET stay idempotent so the
22
+ picker still works while impersonating (read-only), where a POST would 403.
23
+ %>
24
+ <%= form_with url: new_scoped_role_assignment_path, method: :get, id: "cs-cascade",
25
+ data: { turbo_frame: "cascade" }, class: "cs-picker" do %>
26
+ <p>
27
+ <%= label_tag :role_id, "Role" %>
28
+ <%= select_tag :role_id,
29
+ options_from_collection_for_select(@roles, :id, :name, params[:role_id]),
30
+ data: { current_scope_autosubmit: true } %>
31
+ </p>
32
+ <% if @bulk_subjects.any? %>
33
+ <p>
34
+ <span class="cs-label" id="cs_bulk_subjects_label">Subjects</span>
35
+ <span class="cs-hint" aria-labelledby="cs_bulk_subjects_label">
36
+ Granting to <strong><%= @bulk_subjects.size %></strong>:
37
+ <%= @bulk_subjects.map { |s| current_scope_subject_label(s) }.to_sentence %>
38
+ </span>
39
+ <% @bulk_subjects.each do |s| %>
40
+ <%= hidden_field_tag "subject_gids[]", s.to_gid.to_s %>
41
+ <% end %>
42
+ </p>
43
+ <% else %>
44
+ <p>
45
+ <%= label_tag :subject_gid, "Subject" %>
46
+ <%= select_tag :subject_gid,
47
+ options_for_select(@subjects.map { |s| [ current_scope_subject_label(s), s.to_gid.to_s ] },
48
+ params[:subject_gid]),
49
+ data: { current_scope_autosubmit: true } %>
50
+ </p>
51
+ <% end %>
52
+ <% end %>
53
+
54
+ <turbo-frame id="cascade">
55
+ <% if @scopeable.empty? %>
56
+ <p class="cs-alert cs-picker-empty">
57
+ No pickable resource types yet. Add
58
+ <code>include CurrentScope::Scopeable</code> to a host model to make its
59
+ records selectable here.
60
+ </p>
61
+ <% else %>
62
+ <%
63
+ type_options = @scopeable.map { |model| [ model.model_name.human, model.name ] }
64
+ # A deep-linked type may not itself be Scopeable — keep it selectable.
65
+ if @resource_type && type_options.none? { |(_, name)| name == @resource_type.name }
66
+ type_options.unshift([ @resource_type.model_name.human, @resource_type.name ])
67
+ end
68
+ %>
69
+ <p>
70
+ <%= label_tag :resource_type, "Resource type" %>
71
+ <%= select_tag :resource_type,
72
+ options_for_select(type_options, @resource_type&.name),
73
+ include_blank: "— choose a type —", form: "cs-cascade",
74
+ data: { current_scope_autosubmit: true } %>
75
+ </p>
76
+
77
+ <% if @resource_type %>
78
+ <% if @records.blank? && params[:q].blank? %>
79
+ <p class="cs-hint cs-picker-empty">
80
+ No <%= @resource_type.model_name.human.pluralize.downcase %> to pick from yet.
81
+ </p>
82
+ <% else %>
83
+ <% if @searchable %>
84
+ <p>
85
+ <%= label_tag :q, "Search records" %>
86
+ <%= search_field_tag :q, params[:q], placeholder: "Filter by label",
87
+ form: "cs-cascade", data: { current_scope_autosubmit: true } %>
88
+ </p>
89
+ <% end %>
90
+ <p>
91
+ <%= label_tag :resource_gid, "Record" %>
92
+ <%
93
+ record_options = (@records || []).map { |record| [ current_scope_label(record), record.to_gid.to_s ] }
94
+ # Keep a deep-linked record selectable even if it fell outside the
95
+ # scanned window.
96
+ if @resource && record_options.none? { |(_, gid)| gid == @resource.to_gid.to_s }
97
+ record_options.unshift([ current_scope_label(@resource), @resource.to_gid.to_s ])
98
+ end
99
+ %>
100
+ <%= select_tag :resource_gid,
101
+ options_for_select(record_options, params[:resource_gid] || @resource&.to_gid&.to_s),
102
+ include_blank: "— choose a record —", form: "cs-cascade",
103
+ data: { current_scope_autosubmit: true } %>
104
+ <% if @searchable && params[:q].present? %>
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 %>
117
+ <% end %>
118
+ </p>
119
+ <% end %>
120
+ <% end %>
121
+
122
+ <p class="cs-picker-actions">
123
+ <%# No-JS / no-Turbo fallback: reload the cascade with the current picks. %>
124
+ <%= submit_tag "Show records", name: nil, form: "cs-cascade", class: "cs-picker-refresh" %>
125
+ <%# Grant only a complete selection. Every cascade control autosubmits, so
126
+ params mirror the live picks — the button never posts a stale value. %>
127
+ <% ready = params[:role_id].present? && params[:resource_gid].present? %>
128
+ <% if @bulk_subjects.any? %>
129
+ <% if ready %>
130
+ <%= button_to "Grant to #{@bulk_subjects.size} #{'subject'.pluralize(@bulk_subjects.size)}",
131
+ scoped_role_assignments_path, method: :post,
132
+ params: { role_id: params[:role_id], resource_gid: params[:resource_gid],
133
+ subject_gids: @bulk_subjects.map { |s| s.to_gid.to_s } },
134
+ data: { turbo_frame: "_top" }, class: "cs-btn-primary" %>
135
+ <% end %>
136
+ <% elsif ready && params[:subject_gid].present? %>
137
+ <%= button_to "Grant scoped role", scoped_role_assignments_path, method: :post,
138
+ params: { role_id: params[:role_id], subject_gid: params[:subject_gid],
139
+ resource_gid: params[:resource_gid] },
140
+ data: { turbo_frame: "_top" }, class: "cs-btn-primary" %>
141
+ <% end %>
142
+ </p>
143
+ <% end %>
144
+ </turbo-frame>
@@ -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>