current_scope 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +377 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +229 -0
- data/app/assets/stylesheets/current_scope/application.css +917 -0
- data/app/controllers/current_scope/application_controller.rb +96 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
- data/app/controllers/current_scope/roles_controller.rb +256 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
- data/app/controllers/current_scope/subjects_controller.rb +75 -0
- data/app/helpers/current_scope/application_helper.rb +261 -0
- data/app/models/concerns/current_scope/storable_keys.rb +101 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +74 -0
- data/app/models/current_scope/event.rb +136 -0
- data/app/models/current_scope/role.rb +106 -0
- data/app/models/current_scope/role_assignment.rb +42 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +98 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +229 -0
- data/app/views/current_scope/roles/index.html.erb +55 -0
- data/app/views/current_scope/roles/members.html.erb +114 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +124 -0
- data/app/views/layouts/current_scope/application.html.erb +56 -0
- data/config/routes.rb +13 -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/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
- data/lib/current_scope/configuration.rb +613 -0
- data/lib/current_scope/context.rb +41 -0
- data/lib/current_scope/engine.rb +202 -0
- data/lib/current_scope/gating_reflection.rb +113 -0
- data/lib/current_scope/gating_tripwire.rb +84 -0
- data/lib/current_scope/grant_diagnosis.rb +216 -0
- data/lib/current_scope/guard.rb +750 -0
- data/lib/current_scope/mutation_guard.rb +90 -0
- data/lib/current_scope/parent_chain.rb +397 -0
- data/lib/current_scope/permission_catalog.rb +146 -0
- data/lib/current_scope/permission_grid.rb +132 -0
- data/lib/current_scope/permissions.rb +94 -0
- data/lib/current_scope/resolver.rb +669 -0
- data/lib/current_scope/schema_guard.rb +223 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/sod_preflight.rb +380 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +432 -0
- data/lib/generators/current_scope/install/install_generator.rb +114 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
- data/lib/tasks/current_scope_tasks.rake +454 -0
- metadata +123 -0
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
# Resolve the subject through the canonical guard, else fall back to the
|
|
59
|
+
# assignment itself as the audit target — never the unrelated live record a
|
|
60
|
+
# non-canonical stored id would cast into (#151). Mirrors cascade_subject.
|
|
61
|
+
subject = assignment.current_scope_resolved_record("subject") || assignment
|
|
62
|
+
role = assignment.role
|
|
63
|
+
|
|
64
|
+
ScopedRoleAssignment.transaction do
|
|
65
|
+
assignment.destroy!
|
|
66
|
+
Event.record!(event: "scoped_role.revoked", target: subject,
|
|
67
|
+
details: { role: role.name, resource: assignment_resource_label(assignment) })
|
|
68
|
+
end
|
|
69
|
+
redirect_to subjects_path, notice: "Scoped role revoked."
|
|
70
|
+
rescue ActiveRecord::RecordNotFound
|
|
71
|
+
redirect_to subjects_path, notice: "That scoped role was already revoked."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# The scoped record may be deleted (nil) or its class renamed (NameError) by
|
|
77
|
+
# the time we revoke — label from the record when it's still there, else from
|
|
78
|
+
# the stored type/id, so the audit event never 500s on a stale reference.
|
|
79
|
+
def assignment_resource_label(assignment)
|
|
80
|
+
resource = assignment.current_scope_resolved_record("resource")
|
|
81
|
+
resource ? helpers.current_scope_label(resource) : "#{assignment.resource_type} ##{assignment.resource_id}"
|
|
82
|
+
rescue StandardError
|
|
83
|
+
# A host current_scope_label that raises must not 500 the revoke audit —
|
|
84
|
+
# fall back to the stored type/id, matching cascade_resource_label.
|
|
85
|
+
"#{assignment.resource_type} ##{assignment.resource_id}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Deep-link prefill: a record page links here with resource_gid. A stale
|
|
89
|
+
# link (deleted record → RecordNotFound, renamed class → NameError) must
|
|
90
|
+
# not 500 — fall back to the blank picker with a friendly alert.
|
|
91
|
+
def deep_linked_resource
|
|
92
|
+
GlobalID::Locator.locate(params[:resource_gid]) if params[:resource_gid].present?
|
|
93
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
94
|
+
flash.now[:alert] = "That linked record is no longer available — pick one below."
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Grant one scoped role inside its own savepoint. Returns true when a new
|
|
99
|
+
# grant was recorded, false when the subject already had it (or a concurrent
|
|
100
|
+
# grant won the race). A RecordInvalid propagates to roll the whole bulk back.
|
|
101
|
+
def grant_one(subject, resource, role)
|
|
102
|
+
ScopedRoleAssignment.transaction(requires_new: true) do
|
|
103
|
+
assignment = ScopedRoleAssignment.find_or_create_by!(subject: subject, resource: resource, role: role)
|
|
104
|
+
if assignment.previously_new_record?
|
|
105
|
+
Event.record!(event: "scoped_role.granted", target: subject,
|
|
106
|
+
details: { role: role.name, resource: helpers.current_scope_label(resource) })
|
|
107
|
+
true
|
|
108
|
+
else
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
rescue ActiveRecord::RecordNotUnique
|
|
113
|
+
false # a concurrent grant slipped past the validation to the DB index — already done
|
|
114
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
115
|
+
# A concurrent grant of the same triple usually trips the uniqueness
|
|
116
|
+
# VALIDATION first (RecordInvalid, not RecordNotUnique). Absorb only that
|
|
117
|
+
# case per-subject; any other validation failure rolls the whole batch back.
|
|
118
|
+
raise unless e.record.errors.of_kind?(:role_id, :taken)
|
|
119
|
+
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resolve the bulk subject_gids for display (dead links and non-subject GIDs
|
|
124
|
+
# drop out — same boundary the grant enforces).
|
|
125
|
+
def resolve_bulk_subjects
|
|
126
|
+
locate_subjects(params[:subject_gids])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def grant_notice(granted, attempted)
|
|
130
|
+
return "Those subjects already have that scoped role." if granted.zero?
|
|
131
|
+
return "Scoped role granted." if granted == 1 && attempted == 1
|
|
132
|
+
|
|
133
|
+
"Scoped role granted to #{granted} #{'subject'.pluralize(granted)}."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Only registered Scopeable types are resolvable from params — never
|
|
137
|
+
# constantize arbitrary visitor input.
|
|
138
|
+
def resolve_type(name)
|
|
139
|
+
CurrentScope.scopeable_resources.find { |model| model.name == name } if name.present?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def searchable?(klass)
|
|
143
|
+
klass.respond_to?(:count) && klass.count > SEARCH_THRESHOLD
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def candidate_records(klass, query)
|
|
147
|
+
return unless klass.respond_to?(:limit) # tableless / nil type ⇒ nothing to pick
|
|
148
|
+
|
|
149
|
+
# A14: if the host model opts in with a class-level current_scope_searchable_scope,
|
|
150
|
+
# search via that indexed relation — no SCAN_CAP, no in-Ruby label filter.
|
|
151
|
+
if query.present? && klass.respond_to?(:current_scope_searchable_scope)
|
|
152
|
+
return klass.current_scope_searchable_scope(query).limit(DISPLAY_LIMIT).to_a
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Fallback: current_scope_label is a Ruby method with no backing column, so
|
|
156
|
+
# scan the first SCAN_CAP rows and filter the label in Ruby.
|
|
157
|
+
records = klass.limit(SCAN_CAP).to_a
|
|
158
|
+
if query.present?
|
|
159
|
+
needle = query.downcase
|
|
160
|
+
records = records.select { |record| helpers.current_scope_label(record).downcase.include?(needle) }
|
|
161
|
+
end
|
|
162
|
+
records.first(DISPLAY_LIMIT)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
@query = params[:q].to_s.strip
|
|
12
|
+
scope = filter_subjects(subject_class.order(:id), @query)
|
|
13
|
+
|
|
14
|
+
@page = [ params[:page].to_i, 1 ].max
|
|
15
|
+
@subjects = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
|
|
16
|
+
@has_next_page = scope.offset(@page * PER_PAGE).exists?
|
|
17
|
+
|
|
18
|
+
@roles = Role.order(:name)
|
|
19
|
+
# Match on the ids as strings: `where(subject: @subjects)` would build
|
|
20
|
+
# `subject_id IN (SELECT id ...)`, comparing a varchar column to a bigint
|
|
21
|
+
# subquery, which PostgreSQL refuses (#151).
|
|
22
|
+
# Group by each subject's OWN polymorphic_name, not the configured class's:
|
|
23
|
+
# an STI subclass that overrides it stores grants under a different token, so
|
|
24
|
+
# a single where(subject_type:) would omit them (#151). One type in the
|
|
25
|
+
# common case; the per-type OR keeps the STI case correct for one extra WHERE.
|
|
26
|
+
subject_ids_by_type = @subjects.group_by { |subject| subject.class.polymorphic_name }
|
|
27
|
+
.transform_values { |subjects| subjects.map { |subject| subject.id.to_s } }
|
|
28
|
+
assignment_scope = subject_ids_by_type.reduce(RoleAssignment.none) do |relation, (type, ids)|
|
|
29
|
+
relation.or(RoleAssignment.where(subject_type: type, subject_id: ids))
|
|
30
|
+
end
|
|
31
|
+
@assignments = assignment_scope
|
|
32
|
+
.index_by { |a| [ a.subject_type, a.subject_id.to_s ] }
|
|
33
|
+
# Safe polymorphic resource preload (resolvable types only) — full
|
|
34
|
+
# includes(:resource) NameErrors on a stale resource_type and 500s the
|
|
35
|
+
# page; skip-unresolvable + label as inert instead (#90 / PR #104).
|
|
36
|
+
scoped_scope = subject_ids_by_type.reduce(ScopedRoleAssignment.none) do |relation, (type, ids)|
|
|
37
|
+
relation.or(ScopedRoleAssignment.where(subject_type: type, subject_id: ids))
|
|
38
|
+
end
|
|
39
|
+
scoped_rows = scoped_scope.includes(:role).to_a
|
|
40
|
+
ScopedRoleAssignment.preload_resolvable_resources!(scoped_rows)
|
|
41
|
+
# to_s to match the view's key: subject_id is a string column (#151).
|
|
42
|
+
@scoped = scoped_rows.group_by { |a| [ a.subject_type, a.subject_id.to_s ] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Server-side search across the subject's human-identity columns, so a query
|
|
48
|
+
# matches EVERY subject rather than only the current page's client-side
|
|
49
|
+
# filter. Columns come from the table's real column_names (never interpolate
|
|
50
|
+
# user input as a column name), so this is injection-safe. A Proc
|
|
51
|
+
# subject_label can't be expressed in SQL; when the model exposes none of the
|
|
52
|
+
# searchable columns this returns the scope unfiltered and the per-page
|
|
53
|
+
# client filter remains the only narrowing.
|
|
54
|
+
def filter_subjects(scope, query)
|
|
55
|
+
return scope if query.blank?
|
|
56
|
+
|
|
57
|
+
columns = subject_search_columns(scope.klass)
|
|
58
|
+
return scope if columns.empty?
|
|
59
|
+
|
|
60
|
+
conn = scope.klass.connection
|
|
61
|
+
clause = columns.map { |c| "LOWER(#{conn.quote_column_name(c)}) LIKE ?" }.join(" OR ")
|
|
62
|
+
# ponytail: % / _ in the query pass through as LIKE wildcards — fine for an
|
|
63
|
+
# admin search; add ESCAPE handling if that ever surprises someone.
|
|
64
|
+
scope.where(clause, *([ "%#{query.downcase}%" ] * columns.size))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def subject_search_columns(klass)
|
|
68
|
+
configured = CurrentScope.config.subject_label
|
|
69
|
+
candidates = []
|
|
70
|
+
candidates << configured.to_s if configured.is_a?(Symbol)
|
|
71
|
+
candidates.concat(SEARCH_COLUMNS)
|
|
72
|
+
candidates.uniq.select { |c| klass.column_names.include?(c) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
# Best-effort human label for any host subject/resource. Delegates to the
|
|
4
|
+
# one shared chain (CurrentScope.label_for) — the same definition the audit
|
|
5
|
+
# ledger denormalizes (see Event.label_for), so the picker, the chips, and
|
|
6
|
+
# the ledger agree by construction, not by parallel maintenance.
|
|
7
|
+
def current_scope_label(record)
|
|
8
|
+
return "(none)" if record.nil?
|
|
9
|
+
|
|
10
|
+
CurrentScope.label_for(record)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Human label for a subject (user/account), honouring config.subject_label
|
|
14
|
+
# so a host on UUID keys can show email or a full name instead of an
|
|
15
|
+
# opaque id. Falls back to the best-effort current_scope_label.
|
|
16
|
+
def current_scope_subject_label(subject)
|
|
17
|
+
return "(none)" if subject.nil?
|
|
18
|
+
|
|
19
|
+
# A configured label that resolves to nil/blank (e.g. :email on a subject
|
|
20
|
+
# with no email yet) must not render as an empty row — fall through to the
|
|
21
|
+
# default human-identifier chain rather than showing "".
|
|
22
|
+
configured = configured_subject_label(subject)
|
|
23
|
+
return configured if configured
|
|
24
|
+
|
|
25
|
+
# Default: a subject is a person, so prefer human identifiers over a
|
|
26
|
+
# generic "Class #id" — including when the model includes Scopeable, whose
|
|
27
|
+
# current_scope_label is id-based. Covers `email` and Rails 8 auth's
|
|
28
|
+
# `email_address`. Config overrides this entirely.
|
|
29
|
+
full_name = [ subject.try(:first_name), subject.try(:last_name) ].compact.join(" ").presence
|
|
30
|
+
# .presence on each identifier: a stored empty/whitespace email ("") is
|
|
31
|
+
# truthy in Ruby and would otherwise short-circuit to a blank label.
|
|
32
|
+
subject.try(:email).presence || subject.try(:email_address).presence ||
|
|
33
|
+
subject.try(:name).presence || full_name || current_scope_label(subject)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The configured subject_label (Symbol or Proc) applied to a subject, or nil
|
|
37
|
+
# when unset, when it resolves to a blank value, or when it fails.
|
|
38
|
+
#
|
|
39
|
+
# subject_label is arbitrary host code running once per row on the admin's
|
|
40
|
+
# main tool for granting and reviewing roles. One subject with incomplete
|
|
41
|
+
# data (a Proc doing `u.email.upcase` on a subject whose email is nil) must
|
|
42
|
+
# not take the whole page down — the same intent the holder-label helpers
|
|
43
|
+
# below already encode for stale polymorphic types. A failure here costs one
|
|
44
|
+
# label; a raise costs the page.
|
|
45
|
+
def configured_subject_label(subject)
|
|
46
|
+
label = CurrentScope.config.subject_label
|
|
47
|
+
return if label.nil?
|
|
48
|
+
return resolve_subject_label(label) { label.call(subject) } if label.respond_to?(:call)
|
|
49
|
+
|
|
50
|
+
# Only a Symbol or String names a method. Checked BEFORE respond_to?,
|
|
51
|
+
# which raises TypeError on anything else ("false is not a symbol nor a
|
|
52
|
+
# string") — and that raise would land outside the rescue below and 500
|
|
53
|
+
# the page, which is the bug this method exists to prevent, reached
|
|
54
|
+
# through a different door.
|
|
55
|
+
unless label.is_a?(Symbol) || label.is_a?(String)
|
|
56
|
+
CurrentScope::ApplicationHelper.warn_unusable_subject_label_once(label)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if subject.respond_to?(label)
|
|
61
|
+
resolve_subject_label(label) { subject.public_send(label) }
|
|
62
|
+
else
|
|
63
|
+
# Not a mistake we can render around: this config does nothing for EVERY
|
|
64
|
+
# subject, silently, which is why it needs saying out loud once.
|
|
65
|
+
CurrentScope::ApplicationHelper.warn_unknown_subject_label_once(label)
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
# Last resort, and the reason it exists: every branch above calls a method
|
|
70
|
+
# ON the host's config value to classify it — `nil?`, `respond_to?`,
|
|
71
|
+
# `is_a?`. A pathological value breaks the classification itself, before
|
|
72
|
+
# any specific guard can classify it, so the guards cannot be where this
|
|
73
|
+
# is caught. A BasicObject has no #nil?; a delegator can raise from
|
|
74
|
+
# respond_to?.
|
|
75
|
+
#
|
|
76
|
+
# Deliberately does NOT touch `label`: it is the thing that just proved it
|
|
77
|
+
# cannot be touched. The warning is keyed off the error alone.
|
|
78
|
+
CurrentScope::ApplicationHelper.warn_subject_label_broken_once(e)
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Members-list labels that survive a stale/renamed polymorphic type — a
|
|
83
|
+
# removed subject or resource class must not 500 the page; fall back to
|
|
84
|
+
# "Type #id" the way the audit ledger does.
|
|
85
|
+
def current_scope_holder_subject_label(assignment)
|
|
86
|
+
# Through the canonical guard, not assignment.subject: a non-canonical
|
|
87
|
+
# stored subject_id (a pre-#151 collapsed value, or a host insert_all) would
|
|
88
|
+
# otherwise cast "7f00…" to integer record 7 and label the row a live,
|
|
89
|
+
# unrelated subject. nil falls back to "Type #id" like the audit ledger.
|
|
90
|
+
subject = assignment.current_scope_resolved_record("subject")
|
|
91
|
+
subject ? current_scope_subject_label(subject) : "#{assignment.subject_type} ##{assignment.subject_id}"
|
|
92
|
+
rescue NameError, ActiveRecord::RecordNotFound
|
|
93
|
+
"#{assignment.subject_type} ##{assignment.subject_id}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# #134: the console's one read of GrantDiagnosis. Returns
|
|
97
|
+
# [css_class, badge_text, caveat] or nil. Never the word "inert" (#90's
|
|
98
|
+
# badge, different state, different fix).
|
|
99
|
+
def current_scope_grant_diagnosis_badge(scoped_assignment)
|
|
100
|
+
verdict = CurrentScope::GrantDiagnosis.verdict_for(scoped_assignment)
|
|
101
|
+
if verdict
|
|
102
|
+
return [
|
|
103
|
+
"cs-dead-badge", "cannot match",
|
|
104
|
+
"#{CurrentScope::GrantDiagnosis.verdict_label(verdict).capitalize}. " \
|
|
105
|
+
"#{CurrentScope::GrantDiagnosis.verdict_fix(verdict)}"
|
|
106
|
+
]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
return nil unless CurrentScope::GrantDiagnosis.type_untargeted?(scoped_assignment, verdict: verdict)
|
|
110
|
+
|
|
111
|
+
[
|
|
112
|
+
"cs-check-badge", "check hooks",
|
|
113
|
+
"No permission on this role names a controller for this record's type. " +
|
|
114
|
+
CurrentScope::GrantDiagnosis.untargeted_caveat
|
|
115
|
+
]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def current_scope_holder_resource_label(scoped_assignment)
|
|
119
|
+
if scoped_assignment.respond_to?(:orphaned_resource?) && scoped_assignment.orphaned_resource?
|
|
120
|
+
return "#{scoped_assignment.resource_type} ##{scoped_assignment.resource_id} (unavailable — inert)"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
current_scope_label(scoped_assignment.resource)
|
|
124
|
+
rescue NameError, ActiveRecord::RecordNotFound
|
|
125
|
+
# Labeler failed — not proof the resource is orphaned. Raw type#id only
|
|
126
|
+
# (orphaned_resource? above owns the inert wording) — PR #104 review.
|
|
127
|
+
"#{scoped_assignment.resource_type} ##{scoped_assignment.resource_id}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Best-effort label for a stored GID string (event actor/subject). Falls
|
|
131
|
+
# back to the raw GID when the record is gone — the ledger outlives the
|
|
132
|
+
# identities it names.
|
|
133
|
+
def current_scope_gid_label(gid)
|
|
134
|
+
record = GlobalID::Locator.locate(gid)
|
|
135
|
+
record ? current_scope_label(record) : gid
|
|
136
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
137
|
+
# locate RAISES for a deleted record or renamed class (nil is only for
|
|
138
|
+
# unparseable strings) — same contract the holder helpers rescue above.
|
|
139
|
+
gid
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# ponytail: display fallback only — NEVER a decision path. This rescue makes
|
|
145
|
+
# a label degrade instead of erroring; the resolver, Guard and catalog are
|
|
146
|
+
# untouched, so it relaxes no fail-closed guarantee. (The same `rescue
|
|
147
|
+
# StandardError` on a decision path would be a fail-open bug.)
|
|
148
|
+
#
|
|
149
|
+
# StandardError rather than the NameError family the holder helpers catch:
|
|
150
|
+
# those wrap OUR call into a known-shaped record, while this runs a host's
|
|
151
|
+
# arbitrary Proc, which can raise anything. Catching only NameError would
|
|
152
|
+
# fix the reported NoMethodError and leave the same page-down bug for a
|
|
153
|
+
# differently-broken Proc.
|
|
154
|
+
#
|
|
155
|
+
# nil rejoins the existing "resolved blank -> default chain" path in
|
|
156
|
+
# current_scope_subject_label, so no caller learns a new branch.
|
|
157
|
+
def resolve_subject_label(label)
|
|
158
|
+
yield.to_s.presence
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
CurrentScope::ApplicationHelper.warn_subject_label_raised_once(label, e)
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class << self
|
|
165
|
+
# Warn ONCE per (reason, label): a misconfigured subject_label is the same
|
|
166
|
+
# mistake on every row, so one line is a signal and one-per-subject is
|
|
167
|
+
# noise the admin will scroll past. Keyed by the label value, so changing
|
|
168
|
+
# the config in dev warns again for the new value.
|
|
169
|
+
#
|
|
170
|
+
# Always-on rather than behind a warn_on_* flag (cf. warn_on_nil_sod_record,
|
|
171
|
+
# which defaults off because a nil record is often legitimate): a label the
|
|
172
|
+
# subject can't answer, or one that raises, is unambiguously a mistake.
|
|
173
|
+
# Mirrors Event.warn_missing_events_table_once.
|
|
174
|
+
#
|
|
175
|
+
# Says "this subject" rather than "subjects": under STI some subclasses may
|
|
176
|
+
# answer and others not, and warn-once means the first non-responder would
|
|
177
|
+
# otherwise speak for all of them.
|
|
178
|
+
def warn_unknown_subject_label_once(label)
|
|
179
|
+
return unless new_subject_label_warning?(:unknown, label)
|
|
180
|
+
|
|
181
|
+
Rails.logger&.warn(
|
|
182
|
+
"[CurrentScope] config.subject_label is #{label.inspect}, but this subject does not " \
|
|
183
|
+
"respond to it — it is falling back to the default label. If no subject responds to it, " \
|
|
184
|
+
"this config does nothing. Set it to a method the subject responds to (e.g. :email), a " \
|
|
185
|
+
"Proc, or nil."
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Not a Symbol, String, Proc, or nil — so it can't name a method and can't
|
|
190
|
+
# be called. Reports the TYPE, not the value: this is the one warning whose
|
|
191
|
+
# subject is an arbitrary host object, and calling `inspect` on it could
|
|
192
|
+
# itself raise — inside the very path that exists to stop a raise.
|
|
193
|
+
def warn_unusable_subject_label_once(label)
|
|
194
|
+
return unless new_subject_label_warning?(:unusable, label.class)
|
|
195
|
+
|
|
196
|
+
Rails.logger&.warn(
|
|
197
|
+
"[CurrentScope] config.subject_label is a #{label.class} — it can't name a method and " \
|
|
198
|
+
"can't be called, so every subject is falling back to the default label. Set it to a " \
|
|
199
|
+
"Symbol (e.g. :email), a Proc taking the subject, or nil."
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def warn_subject_label_raised_once(label, error)
|
|
204
|
+
return unless new_subject_label_warning?(:raised, label)
|
|
205
|
+
|
|
206
|
+
Rails.logger&.warn(
|
|
207
|
+
"[CurrentScope] config.subject_label raised #{safe_class(error)}: #{safe_message(error)} " \
|
|
208
|
+
"— that subject fell back to the default label rather than erroring the page. A " \
|
|
209
|
+
"subject_label must be total: it runs for every subject, including ones with nil or " \
|
|
210
|
+
"blank attributes."
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The configured value could not even be classified — see the rescue in
|
|
215
|
+
# configured_subject_label. Keyed by the error's class, and says nothing
|
|
216
|
+
# about the label itself, because reading the label is what failed.
|
|
217
|
+
def warn_subject_label_broken_once(error)
|
|
218
|
+
return unless new_subject_label_warning?(:broken, safe_class(error))
|
|
219
|
+
|
|
220
|
+
Rails.logger&.warn(
|
|
221
|
+
"[CurrentScope] config.subject_label could not be read — #{safe_class(error)}: " \
|
|
222
|
+
"#{safe_message(error)}. Every subject is falling back to the default label. Set it to " \
|
|
223
|
+
"a Symbol (e.g. :email), a Proc taking the subject, or nil."
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
# A host exception's message is arbitrary text produced by arbitrary code:
|
|
230
|
+
# it may carry newlines that split one warning across several log records,
|
|
231
|
+
# run to any length, or raise from #message itself. All three would land
|
|
232
|
+
# in the rescue this runs inside — reporting the failure must not become
|
|
233
|
+
# the failure.
|
|
234
|
+
def safe_message(error)
|
|
235
|
+
error.message.to_s.gsub(/\s+/, " ").strip.truncate(200)
|
|
236
|
+
rescue StandardError
|
|
237
|
+
"(message unavailable)"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Same reason as safe_message, one step earlier: `#class` is interpolated
|
|
241
|
+
# before safe_message is even called, and an exception raised by a host
|
|
242
|
+
# Proc can override it. The method-level rescue in configured_subject_label
|
|
243
|
+
# does stop that reaching the page — but at the cost of the accurate
|
|
244
|
+
# warning, replaced by a misattributed one about the secondary failure.
|
|
245
|
+
# The diagnostic is the whole point of warning at all, so protect it.
|
|
246
|
+
def safe_class(error)
|
|
247
|
+
error.class.name.presence || "an exception"
|
|
248
|
+
rescue StandardError
|
|
249
|
+
"an exception"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Set is a core built-in on this gem's Ruby floor (>= 3.2, no require
|
|
253
|
+
# needed — the codebase already uses bare Set elsewhere), so nothing here
|
|
254
|
+
# can raise inside the rescue this runs under.
|
|
255
|
+
def new_subject_label_warning?(reason, label)
|
|
256
|
+
@subject_label_warnings ||= Set.new
|
|
257
|
+
!!@subject_label_warnings.add?([ reason, label ])
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# One definition of the #151 key policy, shared by both assignment models.
|
|
3
|
+
#
|
|
4
|
+
# Since the ids are string columns, an integer key and a UUID both store whole.
|
|
5
|
+
# What remains unstorable is a key that is not ONE value: a composite key is an
|
|
6
|
+
# array, and a model with no primary key names no record. A grant on either
|
|
7
|
+
# would identify the wrong row or none, so it is refused.
|
|
8
|
+
module StorableKeys
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
# `sides` are the polymorphic association names ("subject", "resource").
|
|
13
|
+
def validates_storable_polymorphic_keys(*sides)
|
|
14
|
+
validate { current_scope_check_storable_keys(sides) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Associations cast their stored id through the target model's key type.
|
|
19
|
+
# For a legacy collapsed row that can turn a UUID-shaped string into integer
|
|
20
|
+
# record 7. Every engine path that labels or audits an existing grant uses
|
|
21
|
+
# this checked reader so an inert grant never names an unrelated live record.
|
|
22
|
+
def current_scope_resolved_record(side)
|
|
23
|
+
klass = CurrentScope.polymorphic_class(public_send("#{side}_type"), owner: self.class)
|
|
24
|
+
return if klass.nil?
|
|
25
|
+
return unless CurrentScope.canonical_key?(klass, public_send("#{side}_id"))
|
|
26
|
+
|
|
27
|
+
public_send(side)
|
|
28
|
+
rescue ActiveRecord::RecordNotFound, NameError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def current_scope_check_storable_keys(sides)
|
|
35
|
+
# db:setup/reset/prepare must boot before a schema exists, but they later
|
|
36
|
+
# run host seeds in that same process. Re-check at the actual write so a
|
|
37
|
+
# schema-loaded MySQL database cannot create grants before its collation is
|
|
38
|
+
# repaired. Database-task exemptions are only for bootstrapping tooling.
|
|
39
|
+
CurrentScope::SchemaGuard.check!(allow_database_task: false)
|
|
40
|
+
|
|
41
|
+
sides.each do |side|
|
|
42
|
+
# LENGTH FIRST, and it ends this side. An id too long for the column is
|
|
43
|
+
# also, necessarily, not a canonical key for its model — checking both
|
|
44
|
+
# would hand the reader two errors for one value, the second one
|
|
45
|
+
# explaining a cast when the real problem is the width.
|
|
46
|
+
next if too_long?(side)
|
|
47
|
+
|
|
48
|
+
# The stored TYPE column, not the association: on an already-collapsed row
|
|
49
|
+
# the association resolves to nothing, and reading it would skip exactly
|
|
50
|
+
# the rows this guard exists for. Resolved through this model, which is
|
|
51
|
+
# what wrote the token, so an overridden polymorphic_name still matches.
|
|
52
|
+
klass = CurrentScope.polymorphic_class(public_send("#{side}_type"), owner: self.class)
|
|
53
|
+
next if klass.nil?
|
|
54
|
+
|
|
55
|
+
# A write fails CLOSED either way, but the two causes get different
|
|
56
|
+
# messages: claiming a model has a composite key when the truth is "its
|
|
57
|
+
# table is missing" sends the reader hunting the wrong problem. Only
|
|
58
|
+
# ActiveRecord errors are converted — a NoMethodError here is a bug in
|
|
59
|
+
# this gem and must surface as one, not as a validation message.
|
|
60
|
+
begin
|
|
61
|
+
unless CurrentScope.storable_key?(klass)
|
|
62
|
+
errors.add(:base, CurrentScope.unstorable_key_error(klass, role: side))
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
next if CurrentScope.canonical_key?(klass, public_send("#{side}_id"))
|
|
67
|
+
|
|
68
|
+
# The column takes any string, so a value that is not a legal key for
|
|
69
|
+
# this model stores happily — and the resolver then casts it back into
|
|
70
|
+
# that model's key type, where "7f00aaaa-…" becomes 7 and the grant
|
|
71
|
+
# reaches record 7. The resolver drops such ids too; refusing the WRITE
|
|
72
|
+
# is what stops them existing at all.
|
|
73
|
+
errors.add(:base,
|
|
74
|
+
"#{public_send("#{side}_id").inspect} is not a valid #{klass.name} primary key " \
|
|
75
|
+
"(#{klass.name}.#{klass.primary_key} is #{klass.type_for_attribute(klass.primary_key).type}). " \
|
|
76
|
+
"Stored as-is it would be cast back to a DIFFERENT record's id, so the grant would " \
|
|
77
|
+
"open a record it does not name (#151).")
|
|
78
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
79
|
+
errors.add(:base,
|
|
80
|
+
"#{klass.name}'s primary key could not be read (#{e.class}: #{e.message}), so " \
|
|
81
|
+
"CurrentScope cannot prove this #{side} names one record. Refusing the grant " \
|
|
82
|
+
"rather than storing something unverified.")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Length is a correctness guard, not cosmetics: a truncated key is a key that
|
|
88
|
+
# names the wrong record (#151). Returns whether it failed, so the caller can
|
|
89
|
+
# stop looking at a side whose value is already disqualified.
|
|
90
|
+
def too_long?(side)
|
|
91
|
+
value = public_send("#{side}_id")
|
|
92
|
+
return false unless CurrentScope.key_too_long?(value)
|
|
93
|
+
|
|
94
|
+
errors.add(:base,
|
|
95
|
+
"#{side} id is #{value.to_s.length} characters; CurrentScope stores it in a " \
|
|
96
|
+
"#{CurrentScope::KEY_LIMIT}-character column, and a truncated key would name the " \
|
|
97
|
+
"wrong record. Use a shorter primary key.")
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|