current_scope 0.2.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +577 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +225 -0
  6. data/app/assets/stylesheets/current_scope/application.css +707 -0
  7. data/app/controllers/current_scope/application_controller.rb +54 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
  10. data/app/controllers/current_scope/roles_controller.rb +135 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +57 -0
  13. data/app/helpers/current_scope/application_helper.rb +72 -0
  14. data/app/models/current_scope/application_record.rb +5 -0
  15. data/app/models/current_scope/current.rb +50 -0
  16. data/app/models/current_scope/event.rb +117 -0
  17. data/app/models/current_scope/role.rb +55 -0
  18. data/app/models/current_scope/role_assignment.rb +21 -0
  19. data/app/models/current_scope/role_permission.rb +9 -0
  20. data/app/models/current_scope/scoped_role_assignment.rb +14 -0
  21. data/app/views/current_scope/events/index.html.erb +41 -0
  22. data/app/views/current_scope/roles/edit.html.erb +86 -0
  23. data/app/views/current_scope/roles/index.html.erb +41 -0
  24. data/app/views/current_scope/roles/members.html.erb +96 -0
  25. data/app/views/current_scope/roles/new.html.erb +19 -0
  26. data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
  27. data/app/views/current_scope/subjects/index.html.erb +100 -0
  28. data/app/views/layouts/current_scope/application.html.erb +53 -0
  29. data/config/routes.rb +14 -0
  30. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  31. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  32. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  33. data/lib/current_scope/configuration.rb +181 -0
  34. data/lib/current_scope/context.rb +36 -0
  35. data/lib/current_scope/engine.rb +14 -0
  36. data/lib/current_scope/gating_tripwire.rb +53 -0
  37. data/lib/current_scope/guard.rb +97 -0
  38. data/lib/current_scope/mutation_guard.rb +55 -0
  39. data/lib/current_scope/permission_catalog.rb +33 -0
  40. data/lib/current_scope/permission_grid.rb +90 -0
  41. data/lib/current_scope/permissions.rb +57 -0
  42. data/lib/current_scope/resolver.rb +171 -0
  43. data/lib/current_scope/scopeable.rb +38 -0
  44. data/lib/current_scope/test_helpers.rb +53 -0
  45. data/lib/current_scope/version.rb +3 -0
  46. data/lib/current_scope.rb +168 -0
  47. data/lib/generators/current_scope/install/install_generator.rb +33 -0
  48. data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
  49. data/lib/tasks/current_scope_tasks.rake +15 -0
  50. metadata +113 -0
@@ -0,0 +1,117 @@
1
+ module CurrentScope
2
+ # Append-only authorization event ledger. Design rules:
3
+ #
4
+ # - Append-only: there is no updated_at; persisted rows are never mutated.
5
+ # - Identities and target are stored as GlobalID strings, alongside a
6
+ # denormalized `target_label`, so the history outlives the records it
7
+ # names (a deleted role still renders in the ledger).
8
+ # - `actor` and `subject` are ALWAYS present. subject == actor unless
9
+ # impersonating; impersonated rows are exactly the ones where
10
+ # subject <> actor.
11
+ # - NORMATIVE target mapping: assignment events (org_role.*, scoped_role.*)
12
+ # target the GRANTEE (the subject being granted); role.* events target the
13
+ # role. Role/resource ride in `details`.
14
+ #
15
+ # The AR-level `readonly?` ceiling is honest but NOT total. These BYPASS it:
16
+ # update_all / delete_all / update_column(s) / insert_all / raw SQL.
17
+ # The sandbox reset job (a later unit) is the one sanctioned `delete_all`
18
+ # caller. DB-level hardening is adapter-honest: REVOKE UPDATE/DELETE covers
19
+ # PostgreSQL and MySQL hosts; SQLite hosts get file permissions only.
20
+ # Hash-chain tamper-evidence is deferred.
21
+ class Event < ApplicationRecord
22
+ # Once written, a row is immutable — blocks update / save / destroy at the
23
+ # AR layer. See the class header for the operations that bypass this.
24
+ def readonly? = persisted?
25
+
26
+ class << self
27
+ # The ONE recording entry point. Reads the ambient actor/subject from
28
+ # CurrentScope::Current, serializes actor/subject/target as GlobalID
29
+ # strings, denormalizes a human label for the target, and — when
30
+ # config.audit is on — appends exactly one row.
31
+ #
32
+ # CurrentScope::Event.record!(event: "role.created", target: role,
33
+ # details: { name: "Owner" })
34
+ #
35
+ # Raises ConfigurationError (loud, matching the SoD posture) when there is
36
+ # no ambient actor. Silent no-op (returns nil) when config.audit is false.
37
+ def record!(event:, target:, details: nil)
38
+ return unless CurrentScope.config.audit
39
+
40
+ actor = CurrentScope::Current.actor
41
+ if actor.nil?
42
+ raise CurrentScope::ConfigurationError,
43
+ "CurrentScope::Event.record! has no actor — CurrentScope::Current.actor is nil. " \
44
+ "Set the ambient context (the controller hook, or with_current_user in tests) before recording."
45
+ end
46
+
47
+ # Current.user is the effective subject; fall back to actor so subject
48
+ # is never nil (it equals actor whenever not impersonating).
49
+ subject = CurrentScope::Current.user || actor
50
+
51
+ create!(
52
+ event: event.to_s,
53
+ actor: actor.to_gid.to_s,
54
+ subject: subject.to_gid.to_s,
55
+ target: target.to_gid.to_s,
56
+ target_label: label_for(target),
57
+ details: details,
58
+ request_id: CurrentScope::Current.request_id
59
+ )
60
+ rescue ActiveRecord::StatementInvalid => e
61
+ raise unless missing_events_table?(e)
62
+
63
+ # :strict — an audit-mandatory host refuses to commit a mutation with no
64
+ # audit row. Re-raise so the enclosing (mutation-wrapping) transaction
65
+ # rolls back rather than silently degrading. Checked as `== :strict`, not
66
+ # `!= true`, so the tri-state can't be flattened by a future refactor.
67
+ # (Impersonation-boundary events have no DB mutation to roll back, so a
68
+ # raise there is simply a loud 500 on a mis-migrated host.)
69
+ raise if CurrentScope.config.audit == :strict
70
+
71
+ # true (default) — an existing host that upgrades the gem without running
72
+ # the new migration must not break on its first mutation. Degrade
73
+ # gracefully: skip recording and warn once (not on every call), naming
74
+ # the fix. A host that wants audit runs the migration; one that doesn't
75
+ # can set config.audit = false to silence it, or :strict to fail loud.
76
+ warn_missing_events_table_once
77
+ nil
78
+ end
79
+
80
+ private
81
+
82
+ def warn_missing_events_table_once
83
+ return if @missing_events_table_warned
84
+
85
+ @missing_events_table_warned = true
86
+ Rails.logger&.warn(
87
+ "[CurrentScope] audit is on but the current_scope_events table is missing — " \
88
+ "skipping audit recording. Run `rails current_scope:install:migrations` and " \
89
+ "migrate to enable it, or set `CurrentScope.config.audit = false` to silence this."
90
+ )
91
+ end
92
+
93
+ # Prefer a record's own view-agnostic label; then the AR default; then
94
+ # a plain to_s for anything else.
95
+ def label_for(record)
96
+ return record.current_scope_label if record.respond_to?(:current_scope_label)
97
+ return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
98
+
99
+ record.to_s
100
+ end
101
+
102
+ # Recognizes "table is missing" across adapters and Rails' own schema
103
+ # reflection: SQLite ("no such table" / "Could not find table"),
104
+ # PostgreSQL ("relation ... does not exist"), MySQL ("Unknown table" /
105
+ # "doesn't exist"). The `column` exclusion keeps a missing-COLUMN error
106
+ # (e.g. "column ... of relation current_scope_events does not exist" after
107
+ # a partial migration) from being misreported as a missing table, which
108
+ # would point operators at the wrong fix.
109
+ def missing_events_table?(error)
110
+ message = error.message
111
+ message.include?("current_scope_events") &&
112
+ !message.match?(/\bcolumn\b/i) &&
113
+ message.match?(/no such table|could not find table|does(?:n't| not) exist|unknown table|undefined table/i)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,55 @@
1
+ module CurrentScope
2
+ # A named, editable bundle of permissions — a row, not a class. The same
3
+ # role means the same permission set whether held org-wide or scoped to a
4
+ # single record; only the reach differs.
5
+ class Role < ApplicationRecord
6
+ has_many :role_permissions, dependent: :delete_all
7
+ has_many :role_assignments, dependent: :destroy
8
+ has_many :scoped_role_assignments, dependent: :destroy
9
+
10
+ validates :name, presence: true, uniqueness: true
11
+
12
+ after_save :persist_permission_keys
13
+
14
+ # The grid diff computed by the last save: { added: [...], removed: [...] }.
15
+ # Empty arrays on a no-op save; nil when no grid was staged (a programmatic
16
+ # save that never set permission_keys). Controllers read this to record the
17
+ # change — the model itself records nothing (seeds must stay silent).
18
+ attr_reader :permission_keys_change
19
+
20
+ def grants?(permission_key)
21
+ role_permissions.exists?(permission_key: permission_key)
22
+ end
23
+
24
+ def permission_keys
25
+ @pending_permission_keys || role_permissions.pluck(:permission_key)
26
+ end
27
+
28
+ # Stages a replacement permission set, dropping keys that aren't in the
29
+ # catalog (stale keys from removed controllers). Persisted on save, like
30
+ # any other attribute — never before validations pass.
31
+ def permission_keys=(keys)
32
+ @pending_permission_keys = Array(keys).uniq.select { |k| CurrentScope.catalog.include?(k) }
33
+ end
34
+
35
+ def reload(...)
36
+ @pending_permission_keys = nil
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ def persist_permission_keys
43
+ return if @pending_permission_keys.nil?
44
+
45
+ # Capture the prior keys BEFORE delete_all so the diff survives the swap.
46
+ previous = role_permissions.pluck(:permission_key)
47
+ staged = @pending_permission_keys
48
+ @permission_keys_change = { added: staged - previous, removed: previous - staged }
49
+
50
+ role_permissions.delete_all
51
+ role_permissions.insert_all(staged.map { |k| { permission_key: k } }) if staged.any?
52
+ @pending_permission_keys = nil
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ module CurrentScope
2
+ # A subject's single org-wide role. One per subject by design — the "which
3
+ # role granted this?" ambiguity of multi-role systems is deliberately
4
+ # avoided; per-record needs are covered by scoped roles instead.
5
+ class RoleAssignment < ApplicationRecord
6
+ belongs_to :role
7
+ belongs_to :subject, polymorphic: true
8
+
9
+ validates :subject_id, uniqueness: { scope: :subject_type }
10
+
11
+ # Bust the per-request org-role memo (CurrentScope::Current) whenever an
12
+ # assignment changes, so a grant/clear and a later gate check in the SAME
13
+ # request never disagree. after_save/after_destroy fire inside the
14
+ # transaction, so this is correct under transactional tests too. The role's
15
+ # own permission edits don't route through here, but they can't change which
16
+ # role a subject holds — only the role_permissions, which the memo doesn't
17
+ # cache (org_role caches the role object, whose grants? reads live).
18
+ after_save { CurrentScope::Current.reset_org_role_cache }
19
+ after_destroy { CurrentScope::Current.reset_org_role_cache }
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ module CurrentScope
2
+ # One granted controller#action for a role. The key mirrors a permission
3
+ # auto-derived from the host's routes — there is no permissions table.
4
+ class RolePermission < ApplicationRecord
5
+ belongs_to :role
6
+
7
+ validates :permission_key, presence: true, uniqueness: { scope: :role_id }
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ module CurrentScope
2
+ # A role held on ONE specific record: "Editor of Project #7" grants nothing
3
+ # on Project #8. Never touches the subject's org-wide role — the two are
4
+ # independent axes.
5
+ class ScopedRoleAssignment < ApplicationRecord
6
+ belongs_to :role
7
+ belongs_to :subject, polymorphic: true
8
+ belongs_to :resource, polymorphic: true
9
+
10
+ validates :role_id, uniqueness: {
11
+ scope: [ :subject_type, :subject_id, :resource_type, :resource_id ]
12
+ }
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ <h1>Events</h1>
2
+ <p class="cs-lede">
3
+ The append-only authorization ledger, newest first. Every string here is
4
+ host- or visitor-controlled, so it renders escaped.
5
+ </p>
6
+
7
+ <div class="cs-card cs-card--flush">
8
+ <table class="cs-table">
9
+ <thead>
10
+ <tr>
11
+ <th>When</th><th>Event</th><th>Actor</th><th>Target</th><th>Details</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody>
15
+ <% @events.each do |event| %>
16
+ <tr>
17
+ <td><%= event.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
18
+ <td><%= event.event %></td>
19
+ <td>
20
+ <%= current_scope_gid_label(event.actor) %>
21
+ <% if event.subject != event.actor %>
22
+ <span class="cs-chip">as <%= current_scope_gid_label(event.subject) %></span>
23
+ <% end %>
24
+ </td>
25
+ <td><%= event.target_label %></td>
26
+ <td>
27
+ <% (event.details || {}).each do |key, value| %>
28
+ <span class="cs-chip"><%= key %>: <%= Array(value).join(", ") %></span>
29
+ <% end %>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+
37
+ <nav class="cs-pagination" aria-label="Events pagination">
38
+ <% if @page > 1 %><%= link_to "← Previous", events_path(page: @page - 1) %><% end %>
39
+ <span>Page <%= @page %></span>
40
+ <% if @has_next_page %><%= link_to "Next →", events_path(page: @page + 1) %><% end %>
41
+ </nav>
@@ -0,0 +1,86 @@
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.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
18
+ (the grid below is ignored while this is on)
19
+ <% end %>
20
+ </p>
21
+
22
+ <h2>Permission grid</h2>
23
+ <p class="cs-hint">
24
+ One row per controller, one checkbox per action group — derived from the
25
+ app's routes, so new controllers appear here automatically. Columns are
26
+ fixed: a blank cell means the controller doesn't route that action.
27
+ </p>
28
+
29
+ <% grid = CurrentScope::PermissionGrid.new %>
30
+ <% granted = @role.permission_keys.to_set %>
31
+ <%= hidden_field_tag "role[permission_keys][]", "" %>
32
+ <%= hidden_field_tag "role[permission_groups][]", "" %>
33
+ <div class="cs-grid-wrap">
34
+ <table class="cs-grid">
35
+ <thead>
36
+ <tr>
37
+ <th scope="col" class="cs-grid-corner">Controller</th>
38
+ <% grid.columns.each do |column| %>
39
+ <th scope="col"><%= column.label %></th>
40
+ <% end %>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ <% grid.controllers.each do |controller| %>
45
+ <tr>
46
+ <th scope="row">
47
+ <%# Per-row master: enable/disable every action in this controller at once.
48
+ Progressive enhancement — with JS off, each cell checkbox still works. %>
49
+ <label class="cs-row-all" title="Enable all <%= controller %> permissions">
50
+ <input type="checkbox" data-cs-row-all
51
+ aria-label="Enable all <%= controller %> permissions">
52
+ <span><%= controller %></span>
53
+ </label>
54
+ </th>
55
+ <% grid.columns.each do |column| %>
56
+ <% cell = grid.cell(controller, column, granted) %>
57
+ <% if cell.blank %>
58
+ <%# Not routed here — an empty cell. Kept in the a11y tree (no
59
+ aria-hidden) so column/row alignment stays intact for screen
60
+ readers; the "·" marker is decorative CSS. %>
61
+ <td class="cs-cell-blank"></td>
62
+ <% else %>
63
+ <td>
64
+ <label>
65
+ <%= check_box_tag cell.name, cell.value, cell.checked,
66
+ id: "perm_#{cell.value.parameterize(separator: '_')}",
67
+ data: { cs_partial: cell.partial },
68
+ "aria-label": "#{controller} #{column.label}" %>
69
+ </label>
70
+ <%# Partial group: preserve the exact granted actions so a no-op
71
+ save can't promote the group to a full grant. Interacting with
72
+ the checkbox disables these (JS) so the group control takes over. %>
73
+ <% cell.granted_keys.each do |key| %>
74
+ <%= hidden_field_tag "role[permission_keys][]", key, id: nil, data: { cs_preserve: true } %>
75
+ <% end %>
76
+ </td>
77
+ <% end %>
78
+ <% end %>
79
+ </tr>
80
+ <% end %>
81
+ </tbody>
82
+ </table>
83
+ </div>
84
+
85
+ <p><%= f.submit "Save role" %></p>
86
+ <% end %>
@@ -0,0 +1,41 @@
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
+ <%= button_to "Delete", role_path(role), method: :delete,
34
+ form: { data: { cs_confirm: "Delete role #{role.name}?" } },
35
+ data: { turbo_confirm: "Delete role #{role.name}?" } %>
36
+ </td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
@@ -0,0 +1,96 @@
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_assignment_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
+ <% subject_gid = (assignment.subject&.to_gid rescue nil) %>
44
+ <tr>
45
+ <td><%= who %></td>
46
+ <td>
47
+ <% if subject_gid %>
48
+ <%# Removing = clearing this subject's org-wide role (blank role_id). %>
49
+ <%= form_with url: role_assignment_path, method: :post,
50
+ data: { cs_confirm: "Remove #{who} from #{@role.name}?" } do %>
51
+ <%= hidden_field_tag :subject_gid, subject_gid %>
52
+ <%= hidden_field_tag :role_id, "" %>
53
+ <%= submit_tag "Remove", name: nil, class: "cs-btn" %>
54
+ <% end %>
55
+ <% else %>
56
+ <%# Orphaned assignment (subject deleted): remove it by id. %>
57
+ <%= button_to "Remove", remove_role_assignment_path(assignment), method: :delete,
58
+ class: "cs-btn", form: { data: { cs_confirm: "Remove this orphaned org-wide assignment?" } } %>
59
+ <span class="cs-subtle">subject unavailable</span>
60
+ <% end %>
61
+ </td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+ <% else %>
68
+ <p class="cs-subtle">No subject holds this as their org-wide role yet.</p>
69
+ <% end %>
70
+
71
+ <h2>Scoped holders <span class="cs-subtle">(<%= @scoped_holders.size %>)</span></h2>
72
+ <% if @scoped_holders.any? %>
73
+ <div class="cs-card cs-card--flush">
74
+ <table class="cs-table">
75
+ <thead><tr><th>Subject</th><th>Record</th><th></th></tr></thead>
76
+ <tbody>
77
+ <% @scoped_holders.each do |sra| %>
78
+ <% who = current_scope_holder_subject_label(sra) %>
79
+ <% what = current_scope_holder_resource_label(sra) %>
80
+ <tr>
81
+ <td><%= who %></td>
82
+ <td><%= what %></td>
83
+ <td>
84
+ <% revoke_prompt = "Revoke #{@role.name} on #{what} from #{who}?" %>
85
+ <%= button_to "Revoke", scoped_role_assignment_path(sra), method: :delete,
86
+ class: "cs-btn", form: { data: { cs_confirm: revoke_prompt } },
87
+ data: { turbo_confirm: revoke_prompt } %>
88
+ </td>
89
+ </tr>
90
+ <% end %>
91
+ </tbody>
92
+ </table>
93
+ </div>
94
+ <% else %>
95
+ <p class="cs-subtle">No one holds this role on a specific record.</p>
96
+ <% 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.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
16
+ <% end %>
17
+ </p>
18
+ <p><%= f.submit "Create role" %></p>
19
+ <% end %>
@@ -0,0 +1,135 @@
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>Role</label>
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
+ <label>Subjects</label>
35
+ <span class="cs-hint">
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>Subject</label>
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>Resource type</label>
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>Search records</label>
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>Record</label>
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
+ <span class="cs-hint">
106
+ Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches — refine the search to narrow.
107
+ </span>
108
+ <% end %>
109
+ </p>
110
+ <% end %>
111
+ <% end %>
112
+
113
+ <p class="cs-picker-actions">
114
+ <%# No-JS / no-Turbo fallback: reload the cascade with the current picks. %>
115
+ <%= submit_tag "Show records", name: nil, form: "cs-cascade", class: "cs-picker-refresh" %>
116
+ <%# Grant only a complete selection. Every cascade control autosubmits, so
117
+ params mirror the live picks — the button never posts a stale value. %>
118
+ <% ready = params[:role_id].present? && params[:resource_gid].present? %>
119
+ <% if @bulk_subjects.any? %>
120
+ <% if ready %>
121
+ <%= button_to "Grant to #{@bulk_subjects.size} #{'subject'.pluralize(@bulk_subjects.size)}",
122
+ scoped_role_assignments_path, method: :post,
123
+ params: { role_id: params[:role_id], resource_gid: params[:resource_gid],
124
+ subject_gids: @bulk_subjects.map { |s| s.to_gid.to_s } },
125
+ data: { turbo_frame: "_top" }, class: "cs-btn-primary" %>
126
+ <% end %>
127
+ <% elsif ready && params[:subject_gid].present? %>
128
+ <%= button_to "Grant scoped role", scoped_role_assignments_path, method: :post,
129
+ params: { role_id: params[:role_id], subject_gid: params[:subject_gid],
130
+ resource_gid: params[:resource_gid] },
131
+ data: { turbo_frame: "_top" }, class: "cs-btn-primary" %>
132
+ <% end %>
133
+ </p>
134
+ <% end %>
135
+ </turbo-frame>