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,54 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Inherits from the host's controller (config.parent_controller) so the
|
|
3
|
+
# host's authentication — and its Context before_action — run first.
|
|
4
|
+
# The management UI is the place permissions are granted, so it cannot be
|
|
5
|
+
# gated by grantable permissions: only full_access subjects get in.
|
|
6
|
+
class ApplicationController < CurrentScope.config.parent_controller.constantize
|
|
7
|
+
# The read-only-while-impersonating gate, installed directly (not via Guard):
|
|
8
|
+
# this controller SKIPS the permission check, and mutations here — role,
|
|
9
|
+
# grant, and grid edits — are the highest-value surface to keep read-only
|
|
10
|
+
# while impersonating. Host stop-impersonation/sign-out/sign-in endpoints
|
|
11
|
+
# skip it with skip_before_action :current_scope_mutation_guard!.
|
|
12
|
+
include CurrentScope::MutationGuard
|
|
13
|
+
|
|
14
|
+
layout "current_scope/application"
|
|
15
|
+
|
|
16
|
+
# The engine's controllers are excluded from the grantable catalog; they
|
|
17
|
+
# answer to require_full_access! instead of the host's Guard gate.
|
|
18
|
+
skip_before_action :current_scope_check!, raise: false
|
|
19
|
+
|
|
20
|
+
before_action :require_full_access!
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def require_full_access!
|
|
25
|
+
head :forbidden unless CurrentScope.resolver.full_access?(CurrentScope::Current.user)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def subject_class
|
|
29
|
+
@subject_class ||= CurrentScope.config.subject_class.constantize
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The submitted subject GIDs for a bulk-or-single action: the multi-select
|
|
33
|
+
# subject_gids[] when present, else the single subject_gid. Raw strings —
|
|
34
|
+
# pass through locate_subjects to resolve and enforce the subject boundary.
|
|
35
|
+
def submitted_subject_gids
|
|
36
|
+
gids = Array(params[:subject_gids]).select(&:present?)
|
|
37
|
+
gids = [ params[:subject_gid] ].compact if gids.empty?
|
|
38
|
+
gids
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve subject GIDs to records, keeping ONLY instances of the configured
|
|
42
|
+
# subject_class. A crafted subject_gids[] pointing at some other model must
|
|
43
|
+
# never create an assignment row for a non-subject — the picker offers only
|
|
44
|
+
# subjects, so anything else is out of bounds. Dead/unknown GIDs drop out.
|
|
45
|
+
def locate_subjects(gids)
|
|
46
|
+
Array(gids).select(&:present?).filter_map do |gid|
|
|
47
|
+
record = GlobalID::Locator.locate(gid)
|
|
48
|
+
record if record.is_a?(subject_class)
|
|
49
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
50
|
+
nil
|
|
51
|
+
end.uniq # duplicate subject_gids[] must count once (notice + audit accuracy)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Read-only view of the append-only audit ledger. Inherits the full-access
|
|
3
|
+
# gate and mutation guard from ApplicationController; there is no write path.
|
|
4
|
+
class EventsController < ApplicationController
|
|
5
|
+
PER_PAGE = 50
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@page = [ params[:page].to_i, 1 ].max
|
|
9
|
+
scope = Event.order(id: :desc)
|
|
10
|
+
@events = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
|
|
11
|
+
@has_next_page = scope.offset(@page * PER_PAGE).exists?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Sets (or clears) a subject's single org-wide role.
|
|
3
|
+
class RoleAssignmentsController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
subjects = locate_subjects(submitted_subject_gids)
|
|
6
|
+
if subjects.empty?
|
|
7
|
+
redirect_back_or_to subjects_path, alert: "No subjects selected."
|
|
8
|
+
return
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
clearing = params[:role_id].blank?
|
|
12
|
+
|
|
13
|
+
# One transaction for the whole bulk action: the UI presents it as a single
|
|
14
|
+
# operation, so a failure partway must not leave only the first subjects
|
|
15
|
+
# changed. All-or-nothing across assignments and their audit events. Count
|
|
16
|
+
# only the subjects that ACTUALLY changed so the notice can't over-report
|
|
17
|
+
# (a re-set to the same role, or a clear on a subject with no role, is a
|
|
18
|
+
# no-op and shouldn't be counted).
|
|
19
|
+
changed = 0
|
|
20
|
+
RoleAssignment.transaction do
|
|
21
|
+
subjects.each do |subject|
|
|
22
|
+
assignment = RoleAssignment.find_or_initialize_by(subject: subject)
|
|
23
|
+
prior_role = assignment.role # nil for a brand-new assignment
|
|
24
|
+
did = clearing ? clear_org_role(subject, assignment, prior_role) : set_org_role(subject, assignment, prior_role)
|
|
25
|
+
changed += 1 if did
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Return to wherever the action was invoked (the subjects page or a role's
|
|
30
|
+
# members page); falls back to subjects when there's no referrer.
|
|
31
|
+
redirect_back_or_to subjects_path, notice: org_notice(clearing, changed)
|
|
32
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
33
|
+
redirect_back_or_to subjects_path, alert: "Couldn't set the org-wide role — a subject or role is no longer available."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove ONE org-wide assignment by id — the path the members page uses to
|
|
37
|
+
# clean up an orphaned assignment whose subject was deleted (the subject-keyed
|
|
38
|
+
# clear on `create` can't target a subject that no longer resolves).
|
|
39
|
+
def destroy
|
|
40
|
+
assignment = RoleAssignment.find(params[:id])
|
|
41
|
+
subject = resolve_subject(assignment)
|
|
42
|
+
role_name = assignment.role.name
|
|
43
|
+
|
|
44
|
+
RoleAssignment.transaction do
|
|
45
|
+
assignment.destroy!
|
|
46
|
+
Event.record!(event: "org_role.removed", target: subject || assignment, details: { role: role_name })
|
|
47
|
+
end
|
|
48
|
+
redirect_back_or_to subjects_path, notice: "Org-wide role removed."
|
|
49
|
+
rescue ActiveRecord::RecordNotFound
|
|
50
|
+
redirect_back_or_to subjects_path, notice: "That org-wide role was already removed."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The grantee, or nil when the subject was deleted or its type no longer
|
|
56
|
+
# resolves (an orphaned assignment) — the ledger row then targets the
|
|
57
|
+
# assignment itself rather than 500ing.
|
|
58
|
+
def resolve_subject(assignment)
|
|
59
|
+
assignment.subject
|
|
60
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def org_notice(clearing, count)
|
|
65
|
+
return "No org-wide role changes." if count.zero?
|
|
66
|
+
|
|
67
|
+
verb = clearing ? "cleared" : "set"
|
|
68
|
+
count == 1 ? "Org-wide role #{verb}." : "Org-wide role #{verb} for #{count} subjects."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns true when a role was actually cleared, false when there was nothing
|
|
72
|
+
# to clear (so the caller's count stays accurate).
|
|
73
|
+
def clear_org_role(subject, assignment, prior_role)
|
|
74
|
+
return false unless assignment.persisted? # nothing to clear ⇒ no event
|
|
75
|
+
|
|
76
|
+
RoleAssignment.transaction do
|
|
77
|
+
assignment.destroy!
|
|
78
|
+
Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
|
|
79
|
+
end
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns true when the subject's role actually changed, false on a no-op
|
|
84
|
+
# re-set to the same role.
|
|
85
|
+
def set_org_role(subject, assignment, prior_role)
|
|
86
|
+
# Fetch the new role as its own object so `prior_role` (already loaded via
|
|
87
|
+
# the association) isn't mistaken for it after the update.
|
|
88
|
+
new_role = Role.find(params.expect(:role_id))
|
|
89
|
+
changed = prior_role.nil? || prior_role.id != new_role.id
|
|
90
|
+
|
|
91
|
+
RoleAssignment.transaction do
|
|
92
|
+
assignment.update!(role: new_role)
|
|
93
|
+
if prior_role.nil?
|
|
94
|
+
Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
|
|
95
|
+
elsif prior_role.id != new_role.id
|
|
96
|
+
Event.record!(event: "org_role.changed", target: subject,
|
|
97
|
+
details: { from: prior_role.name, to: new_role.name })
|
|
98
|
+
end
|
|
99
|
+
# same role re-set ⇒ no change ⇒ no event
|
|
100
|
+
end
|
|
101
|
+
changed
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
class RolesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@roles = Role.order(:name)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def new
|
|
8
|
+
@role = Role.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
@role = Role.new(role_params)
|
|
13
|
+
saved = false
|
|
14
|
+
Role.transaction do
|
|
15
|
+
saved = @role.save
|
|
16
|
+
# Fold the initial permission set into the create event — no separate
|
|
17
|
+
# grid-diff event for a brand-new role.
|
|
18
|
+
if saved
|
|
19
|
+
Event.record!(event: "role.created", target: @role,
|
|
20
|
+
details: { name: @role.name, full_access: @role.full_access?,
|
|
21
|
+
permission_keys: @role.permission_keys })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if saved
|
|
26
|
+
redirect_to edit_role_path(@role), notice: "Role created."
|
|
27
|
+
else
|
|
28
|
+
render :new, status: :unprocessable_entity
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def edit
|
|
33
|
+
@role = Role.find(params[:id])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Who holds this role — the role-side complement to the subjects page. Org-wide
|
|
37
|
+
# holders (this role IS their one org-wide role) and per-record scoped holders,
|
|
38
|
+
# plus a capped list of subjects to add from here.
|
|
39
|
+
ADD_LIMIT = 100
|
|
40
|
+
|
|
41
|
+
def members
|
|
42
|
+
@role = Role.find(params[:id])
|
|
43
|
+
# No polymorphic includes: eager-loading a stale/renamed subject_type or
|
|
44
|
+
# resource_type raises NameError. Lazy-load per row and label defensively
|
|
45
|
+
# in the view (current_scope_holder_* helpers), like the audit ledger does.
|
|
46
|
+
@org_holders = RoleAssignment.where(role: @role).to_a
|
|
47
|
+
@scoped_holders = ScopedRoleAssignment.where(role: @role).to_a
|
|
48
|
+
|
|
49
|
+
# Exclude via a subquery, not a plucked Ruby array, so a role with many
|
|
50
|
+
# holders doesn't build a huge NOT IN bind list.
|
|
51
|
+
held = RoleAssignment.where(role: @role, subject_type: subject_class.name).select(:subject_id)
|
|
52
|
+
remaining = subject_class.where.not(id: held).order(:id)
|
|
53
|
+
@candidates = remaining.limit(ADD_LIMIT).to_a
|
|
54
|
+
@more_candidates = remaining.offset(ADD_LIMIT).exists?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update
|
|
58
|
+
@role = Role.find(params[:id])
|
|
59
|
+
previous_name = @role.name
|
|
60
|
+
saved = false
|
|
61
|
+
Role.transaction do
|
|
62
|
+
saved = @role.update(role_params)
|
|
63
|
+
record_role_update(@role, previous_name) if saved
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if saved
|
|
67
|
+
redirect_to roles_path, notice: "Role updated."
|
|
68
|
+
else
|
|
69
|
+
render :edit, status: :unprocessable_entity
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def destroy
|
|
74
|
+
role = Role.find(params[:id])
|
|
75
|
+
|
|
76
|
+
if last_full_access?(role)
|
|
77
|
+
redirect_to roles_path,
|
|
78
|
+
alert: "Refusing to delete the last full-access role — it would lock everyone out of this UI."
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Snapshot the cascade BEFORE destroy! — dependent: :destroy takes the
|
|
83
|
+
# assignments with the role, so they can't be read afterwards.
|
|
84
|
+
org_removed = role.role_assignments.includes(:subject).to_a
|
|
85
|
+
scoped_revoked = role.scoped_role_assignments.includes(:subject, :resource).to_a
|
|
86
|
+
|
|
87
|
+
Role.transaction do
|
|
88
|
+
role.destroy!
|
|
89
|
+
Event.record!(event: "role.deleted", target: role, details: { name: role.name })
|
|
90
|
+
org_removed.each do |a|
|
|
91
|
+
Event.record!(event: "org_role.removed", target: a.subject, details: { role: role.name })
|
|
92
|
+
end
|
|
93
|
+
scoped_revoked.each do |a|
|
|
94
|
+
Event.record!(event: "scoped_role.revoked", target: a.subject,
|
|
95
|
+
details: { role: role.name, resource: helpers.current_scope_label(a.resource) })
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
redirect_to roles_path, notice: "Role deleted."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# One event per save: role.renamed when the name changed (carries old/new
|
|
104
|
+
# name AND the grid diff), else role.updated for a pure grid change. Emits
|
|
105
|
+
# nothing when neither the name nor the grid moved.
|
|
106
|
+
def record_role_update(role, previous_name)
|
|
107
|
+
diff = role.permission_keys_change || { added: [], removed: [] }
|
|
108
|
+
renamed = previous_name != role.name
|
|
109
|
+
return unless renamed || diff[:added].any? || diff[:removed].any?
|
|
110
|
+
|
|
111
|
+
details = { added: diff[:added], removed: diff[:removed] }
|
|
112
|
+
event = "role.updated"
|
|
113
|
+
if renamed
|
|
114
|
+
event = "role.renamed"
|
|
115
|
+
details.merge!(old_name: previous_name, new_name: role.name)
|
|
116
|
+
end
|
|
117
|
+
Event.record!(event: event, target: role, details: details)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def last_full_access?(role)
|
|
121
|
+
role.full_access? && !Role.where(full_access: true).where.not(id: role.id).exists?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def role_params
|
|
125
|
+
permitted = params.expect(role: [ :name, :description, :full_access, permission_keys: [] ])
|
|
126
|
+
# Grid group columns (CRUD checkboxes) submit "controller:group" tokens on
|
|
127
|
+
# a separate, optional channel — permitted leniently so a raw permission_keys
|
|
128
|
+
# post (no groups) still works. Expand them into action keys; the model's
|
|
129
|
+
# permission_keys= dedups and drops anything not in the catalog.
|
|
130
|
+
groups = params.fetch(:role, {}).permit(permission_groups: [])[:permission_groups]
|
|
131
|
+
permitted[:permission_keys] = Array(permitted[:permission_keys]) + PermissionGrid.new.expand(groups)
|
|
132
|
+
permitted
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Grants/revokes a role on ONE specific record. `new` is a guided cascade
|
|
3
|
+
# (Role → Subject → Resource type → Record); `create` grants; `destroy`
|
|
4
|
+
# revokes. A record page can still deep-link straight to a target with:
|
|
5
|
+
#
|
|
6
|
+
# current_scope.new_scoped_role_assignment_path(resource_gid: record.to_gid)
|
|
7
|
+
class ScopedRoleAssignmentsController < ApplicationController
|
|
8
|
+
# ponytail: record search scans only the first SCAN_CAP rows of a type and
|
|
9
|
+
# renders at most DISPLAY_LIMIT matches. current_scope_label is a Ruby
|
|
10
|
+
# method with no backing column, so the filter runs in Ruby (not SQL LIKE);
|
|
11
|
+
# a dedicated indexed label column is the upgrade path for large tables.
|
|
12
|
+
SCAN_CAP = 500
|
|
13
|
+
DISPLAY_LIMIT = 50
|
|
14
|
+
# Offer a search box (instead of listing every record) past this many.
|
|
15
|
+
SEARCH_THRESHOLD = 20
|
|
16
|
+
|
|
17
|
+
def new
|
|
18
|
+
@assignment = ScopedRoleAssignment.new
|
|
19
|
+
@roles = Role.order(:name)
|
|
20
|
+
@subjects = subject_class.order(:id)
|
|
21
|
+
@scopeable = CurrentScope.scopeable_resources
|
|
22
|
+
@bulk_subjects = resolve_bulk_subjects # [] unless a multi-select bulk grant
|
|
23
|
+
|
|
24
|
+
@resource = deep_linked_resource
|
|
25
|
+
@resource_type = resolve_type(params[:resource_type]) || @resource&.class
|
|
26
|
+
@searchable = searchable?(@resource_type)
|
|
27
|
+
@records = candidate_records(@resource_type, params[:q])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create
|
|
31
|
+
resource = GlobalID::Locator.locate(params.expect(:resource_gid))
|
|
32
|
+
role = Role.find(params.expect(:role_id))
|
|
33
|
+
subjects = locate_subjects(submitted_subject_gids)
|
|
34
|
+
if subjects.empty?
|
|
35
|
+
redirect_to subjects_path, alert: "No subjects selected."
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
granted = 0
|
|
40
|
+
# One transaction for the whole bulk grant — all-or-nothing, like the
|
|
41
|
+
# org-wide sibling. A per-subject savepoint (grant_one) absorbs the
|
|
42
|
+
# concurrent-duplicate race without poisoning the outer transaction, while
|
|
43
|
+
# a genuine RecordInvalid rolls the entire batch back.
|
|
44
|
+
ScopedRoleAssignment.transaction do
|
|
45
|
+
subjects.each { |subject| granted += 1 if grant_one(subject, resource, role) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
redirect_to subjects_path, notice: grant_notice(granted, subjects.size)
|
|
49
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
50
|
+
redirect_to subjects_path, alert: e.message
|
|
51
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
52
|
+
redirect_to subjects_path,
|
|
53
|
+
alert: "Couldn't grant that scoped role — the subject, role, or record is no longer available."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def destroy
|
|
57
|
+
assignment = ScopedRoleAssignment.find(params[:id])
|
|
58
|
+
subject, role = assignment.subject, assignment.role
|
|
59
|
+
|
|
60
|
+
ScopedRoleAssignment.transaction do
|
|
61
|
+
assignment.destroy!
|
|
62
|
+
Event.record!(event: "scoped_role.revoked", target: subject,
|
|
63
|
+
details: { role: role.name, resource: assignment_resource_label(assignment) })
|
|
64
|
+
end
|
|
65
|
+
redirect_to subjects_path, notice: "Scoped role revoked."
|
|
66
|
+
rescue ActiveRecord::RecordNotFound
|
|
67
|
+
redirect_to subjects_path, notice: "That scoped role was already revoked."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# The scoped record may be deleted (nil) or its class renamed (NameError) by
|
|
73
|
+
# the time we revoke — label from the record when it's still there, else from
|
|
74
|
+
# the stored type/id, so the audit event never 500s on a stale reference.
|
|
75
|
+
def assignment_resource_label(assignment)
|
|
76
|
+
resource = assignment.resource
|
|
77
|
+
resource ? helpers.current_scope_label(resource) : "#{assignment.resource_type} ##{assignment.resource_id}"
|
|
78
|
+
rescue NameError, ActiveRecord::RecordNotFound
|
|
79
|
+
"#{assignment.resource_type} ##{assignment.resource_id}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Deep-link prefill: a record page links here with resource_gid. A stale
|
|
83
|
+
# link (deleted record → RecordNotFound, renamed class → NameError) must
|
|
84
|
+
# not 500 — fall back to the blank picker with a friendly alert.
|
|
85
|
+
def deep_linked_resource
|
|
86
|
+
GlobalID::Locator.locate(params[:resource_gid]) if params[:resource_gid].present?
|
|
87
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
88
|
+
flash.now[:alert] = "That linked record is no longer available — pick one below."
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Grant one scoped role inside its own savepoint. Returns true when a new
|
|
93
|
+
# grant was recorded, false when the subject already had it (or a concurrent
|
|
94
|
+
# grant won the race). A RecordInvalid propagates to roll the whole bulk back.
|
|
95
|
+
def grant_one(subject, resource, role)
|
|
96
|
+
ScopedRoleAssignment.transaction(requires_new: true) do
|
|
97
|
+
assignment = ScopedRoleAssignment.find_or_create_by!(subject: subject, resource: resource, role: role)
|
|
98
|
+
if assignment.previously_new_record?
|
|
99
|
+
Event.record!(event: "scoped_role.granted", target: subject,
|
|
100
|
+
details: { role: role.name, resource: helpers.current_scope_label(resource) })
|
|
101
|
+
true
|
|
102
|
+
else
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
rescue ActiveRecord::RecordNotUnique
|
|
107
|
+
false # a concurrent grant slipped past the validation to the DB index — already done
|
|
108
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
109
|
+
# A concurrent grant of the same triple usually trips the uniqueness
|
|
110
|
+
# VALIDATION first (RecordInvalid, not RecordNotUnique). Absorb only that
|
|
111
|
+
# case per-subject; any other validation failure rolls the whole batch back.
|
|
112
|
+
raise unless e.record.errors.of_kind?(:role_id, :taken)
|
|
113
|
+
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Resolve the bulk subject_gids for display (dead links and non-subject GIDs
|
|
118
|
+
# drop out — same boundary the grant enforces).
|
|
119
|
+
def resolve_bulk_subjects
|
|
120
|
+
locate_subjects(params[:subject_gids])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def grant_notice(granted, attempted)
|
|
124
|
+
return "Those subjects already have that scoped role." if granted.zero?
|
|
125
|
+
return "Scoped role granted." if granted == 1 && attempted == 1
|
|
126
|
+
|
|
127
|
+
"Scoped role granted to #{granted} #{'subject'.pluralize(granted)}."
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Only registered Scopeable types are resolvable from params — never
|
|
131
|
+
# constantize arbitrary visitor input.
|
|
132
|
+
def resolve_type(name)
|
|
133
|
+
CurrentScope.scopeable_resources.find { |model| model.name == name } if name.present?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def searchable?(klass)
|
|
137
|
+
klass.respond_to?(:count) && klass.count > SEARCH_THRESHOLD
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def candidate_records(klass, query)
|
|
141
|
+
return unless klass.respond_to?(:limit) # tableless / nil type ⇒ nothing to pick
|
|
142
|
+
|
|
143
|
+
# A14: if the host model opts in with a class-level current_scope_searchable_scope,
|
|
144
|
+
# search via that indexed relation — no SCAN_CAP, no in-Ruby label filter.
|
|
145
|
+
if query.present? && klass.respond_to?(:current_scope_searchable_scope)
|
|
146
|
+
return klass.current_scope_searchable_scope(query).limit(DISPLAY_LIMIT).to_a
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Fallback: current_scope_label is a Ruby method with no backing column, so
|
|
150
|
+
# scan the first SCAN_CAP rows and filter the label in Ruby.
|
|
151
|
+
records = klass.limit(SCAN_CAP).to_a
|
|
152
|
+
if query.present?
|
|
153
|
+
needle = query.downcase
|
|
154
|
+
records = records.select { |record| helpers.current_scope_label(record).downcase.include?(needle) }
|
|
155
|
+
end
|
|
156
|
+
records.first(DISPLAY_LIMIT)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
class SubjectsController < ApplicationController
|
|
3
|
+
PER_PAGE = 50
|
|
4
|
+
|
|
5
|
+
# Identity columns searched by ?q=, in the same preference order as
|
|
6
|
+
# current_scope_subject_label's default chain. Intersected with the subject
|
|
7
|
+
# table's real column_names before use, so a query never spans a page.
|
|
8
|
+
SEARCH_COLUMNS = %w[email email_address name first_name last_name].freeze
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
klass = CurrentScope.config.subject_class.constantize
|
|
12
|
+
@query = params[:q].to_s.strip
|
|
13
|
+
scope = filter_subjects(klass.order(:id), @query)
|
|
14
|
+
|
|
15
|
+
@page = [ params[:page].to_i, 1 ].max
|
|
16
|
+
@subjects = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
|
|
17
|
+
@has_next_page = scope.offset(@page * PER_PAGE).exists?
|
|
18
|
+
|
|
19
|
+
@roles = Role.order(:name)
|
|
20
|
+
@assignments = RoleAssignment.where(subject: @subjects)
|
|
21
|
+
.index_by { |a| [ a.subject_type, a.subject_id ] }
|
|
22
|
+
@scoped = ScopedRoleAssignment.where(subject: @subjects)
|
|
23
|
+
.includes(:role, :resource)
|
|
24
|
+
.group_by { |a| [ a.subject_type, a.subject_id ] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Server-side search across the subject's human-identity columns, so a query
|
|
30
|
+
# matches EVERY subject rather than only the current page's client-side
|
|
31
|
+
# filter. Columns come from the table's real column_names (never interpolate
|
|
32
|
+
# user input as a column name), so this is injection-safe. A Proc
|
|
33
|
+
# subject_label can't be expressed in SQL; when the model exposes none of the
|
|
34
|
+
# searchable columns this returns the scope unfiltered and the per-page
|
|
35
|
+
# client filter remains the only narrowing.
|
|
36
|
+
def filter_subjects(scope, query)
|
|
37
|
+
return scope if query.blank?
|
|
38
|
+
|
|
39
|
+
columns = subject_search_columns(scope.klass)
|
|
40
|
+
return scope if columns.empty?
|
|
41
|
+
|
|
42
|
+
conn = scope.klass.connection
|
|
43
|
+
clause = columns.map { |c| "LOWER(#{conn.quote_column_name(c)}) LIKE ?" }.join(" OR ")
|
|
44
|
+
# ponytail: % / _ in the query pass through as LIKE wildcards — fine for an
|
|
45
|
+
# admin search; add ESCAPE handling if that ever surprises someone.
|
|
46
|
+
scope.where(clause, *([ "%#{query.downcase}%" ] * columns.size))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def subject_search_columns(klass)
|
|
50
|
+
configured = CurrentScope.config.subject_label
|
|
51
|
+
candidates = []
|
|
52
|
+
candidates << configured.to_s if configured.is_a?(Symbol)
|
|
53
|
+
candidates.concat(SEARCH_COLUMNS)
|
|
54
|
+
candidates.uniq.select { |c| klass.column_names.include?(c) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
# Best-effort human label for any host subject/resource. Prefers a record's
|
|
4
|
+
# own current_scope_label (the Scopeable mixin gives every pickable model
|
|
5
|
+
# one) so the label a model declares is the label shown everywhere — the
|
|
6
|
+
# picker, the chips, and the ledger (see Event.label_for) all agree.
|
|
7
|
+
def current_scope_label(record)
|
|
8
|
+
return "(none)" if record.nil?
|
|
9
|
+
return record.current_scope_label if record.respond_to?(:current_scope_label)
|
|
10
|
+
|
|
11
|
+
name = record.try(:name) || record.try(:email) || record.try(:title)
|
|
12
|
+
name || "#{record.class.name} ##{record.id}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Human label for a subject (user/account), honouring config.subject_label
|
|
16
|
+
# so a host on UUID keys can show email or a full name instead of an
|
|
17
|
+
# opaque id. Falls back to the best-effort current_scope_label.
|
|
18
|
+
def current_scope_subject_label(subject)
|
|
19
|
+
return "(none)" if subject.nil?
|
|
20
|
+
|
|
21
|
+
# A configured label that resolves to nil/blank (e.g. :email on a subject
|
|
22
|
+
# with no email yet) must not render as an empty row — fall through to the
|
|
23
|
+
# default human-identifier chain rather than showing "".
|
|
24
|
+
configured = configured_subject_label(subject)
|
|
25
|
+
return configured if configured
|
|
26
|
+
|
|
27
|
+
# Default: a subject is a person, so prefer human identifiers over a
|
|
28
|
+
# generic "Class #id" — including when the model includes Scopeable, whose
|
|
29
|
+
# current_scope_label is id-based. Covers `email` and Rails 8 auth's
|
|
30
|
+
# `email_address`. Config overrides this entirely.
|
|
31
|
+
full_name = [ subject.try(:first_name), subject.try(:last_name) ].compact.join(" ").presence
|
|
32
|
+
# .presence on each identifier: a stored empty/whitespace email ("") is
|
|
33
|
+
# truthy in Ruby and would otherwise short-circuit to a blank label.
|
|
34
|
+
subject.try(:email).presence || subject.try(:email_address).presence ||
|
|
35
|
+
subject.try(:name).presence || full_name || current_scope_label(subject)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The configured subject_label (Symbol or Proc) applied to a subject, or nil
|
|
39
|
+
# when unset or when it resolves to a blank value.
|
|
40
|
+
def configured_subject_label(subject)
|
|
41
|
+
label = CurrentScope.config.subject_label
|
|
42
|
+
if label.respond_to?(:call)
|
|
43
|
+
label.call(subject).to_s.presence
|
|
44
|
+
elsif label && subject.respond_to?(label)
|
|
45
|
+
subject.public_send(label).to_s.presence
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Members-list labels that survive a stale/renamed polymorphic type — a
|
|
50
|
+
# removed subject or resource class must not 500 the page; fall back to
|
|
51
|
+
# "Type #id" the way the audit ledger does.
|
|
52
|
+
def current_scope_holder_subject_label(assignment)
|
|
53
|
+
current_scope_subject_label(assignment.subject)
|
|
54
|
+
rescue NameError, ActiveRecord::RecordNotFound
|
|
55
|
+
"#{assignment.subject_type} ##{assignment.subject_id}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def current_scope_holder_resource_label(scoped_assignment)
|
|
59
|
+
current_scope_label(scoped_assignment.resource)
|
|
60
|
+
rescue NameError, ActiveRecord::RecordNotFound
|
|
61
|
+
"#{scoped_assignment.resource_type} ##{scoped_assignment.resource_id}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Best-effort label for a stored GID string (event actor/subject). Falls
|
|
65
|
+
# back to the raw GID when the record is gone — the ledger outlives the
|
|
66
|
+
# identities it names.
|
|
67
|
+
def current_scope_gid_label(gid)
|
|
68
|
+
record = GlobalID::Locator.locate(gid)
|
|
69
|
+
record ? current_scope_label(record) : gid
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The ambient authorization context. Request- and job-scoped: Rails resets
|
|
3
|
+
# CurrentAttributes around every unit of execution, so the subject can never
|
|
4
|
+
# leak between requests, jobs, or test examples.
|
|
5
|
+
#
|
|
6
|
+
# Two identities live here:
|
|
7
|
+
# - user — the EFFECTIVE subject every permission check reads.
|
|
8
|
+
# - actor — the REAL principal behind the request (the pretender's
|
|
9
|
+
# `true_user`). It falls back to `user`, so attribution always
|
|
10
|
+
# reads `actor` with no nil branch. They differ only while
|
|
11
|
+
# impersonating: actor = the admin, user = the impersonated subject.
|
|
12
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
13
|
+
# request_id is correlation-only metadata the audit recorder stamps onto
|
|
14
|
+
# each event; nil when the host hasn't set it. Additive — no reader override.
|
|
15
|
+
attribute :user, :actor, :request_id
|
|
16
|
+
|
|
17
|
+
# Per-request memo for the resolver's org-role lookup. A view with N
|
|
18
|
+
# permission-gated elements calls the gate N times, each otherwise re-running
|
|
19
|
+
# the same `RoleAssignment.find_by(subject:)`; caching it here collapses that
|
|
20
|
+
# to one query. Request/job-scoped like everything on CurrentAttributes, so
|
|
21
|
+
# it never leaks across requests, and invalidated on any org-role write (see
|
|
22
|
+
# RoleAssignment) so a grant-then-check within one request is never stale.
|
|
23
|
+
attribute :org_role_cache
|
|
24
|
+
|
|
25
|
+
# Falls back to the effective subject, so actor is never nil when user is
|
|
26
|
+
# set — callers attribute to `actor` without a nil branch.
|
|
27
|
+
def actor
|
|
28
|
+
super || user
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Memoize the org-role lookup for `subject` for the rest of this request/job.
|
|
32
|
+
# Keyed by subject so a check that spans several subjects stays correct.
|
|
33
|
+
# Caches nil (no role) too, so a repeated "no grant" check is one query, not N.
|
|
34
|
+
def memoized_org_role(subject)
|
|
35
|
+
return yield if subject.nil?
|
|
36
|
+
|
|
37
|
+
cache = (self.org_role_cache ||= {})
|
|
38
|
+
key = [ subject.class.name, subject.id ]
|
|
39
|
+
return cache[key] if cache.key?(key)
|
|
40
|
+
|
|
41
|
+
cache[key] = yield
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Drop the memo — called on any org-role write so a later check in the same
|
|
45
|
+
# request sees the change.
|
|
46
|
+
def reset_org_role_cache
|
|
47
|
+
self.org_role_cache = nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|