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,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,5 @@
1
+ module CurrentScope
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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