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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +577 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +225 -0
- data/app/assets/stylesheets/current_scope/application.css +707 -0
- data/app/controllers/current_scope/application_controller.rb +54 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
- data/app/controllers/current_scope/roles_controller.rb +135 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
- data/app/controllers/current_scope/subjects_controller.rb +57 -0
- data/app/helpers/current_scope/application_helper.rb +72 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +50 -0
- data/app/models/current_scope/event.rb +117 -0
- data/app/models/current_scope/role.rb +55 -0
- data/app/models/current_scope/role_assignment.rb +21 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +14 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +86 -0
- data/app/views/current_scope/roles/index.html.erb +41 -0
- data/app/views/current_scope/roles/members.html.erb +96 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
- data/app/views/current_scope/subjects/index.html.erb +100 -0
- data/app/views/layouts/current_scope/application.html.erb +53 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/lib/current_scope/configuration.rb +181 -0
- data/lib/current_scope/context.rb +36 -0
- data/lib/current_scope/engine.rb +14 -0
- data/lib/current_scope/gating_tripwire.rb +53 -0
- data/lib/current_scope/guard.rb +97 -0
- data/lib/current_scope/mutation_guard.rb +55 -0
- data/lib/current_scope/permission_catalog.rb +33 -0
- data/lib/current_scope/permission_grid.rb +90 -0
- data/lib/current_scope/permissions.rb +57 -0
- data/lib/current_scope/resolver.rb +171 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +168 -0
- data/lib/generators/current_scope/install/install_generator.rb +33 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
- data/lib/tasks/current_scope_tasks.rake +15 -0
- 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×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>
|