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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. 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
@@ -0,0 +1,5 @@
1
+ module CurrentScope
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end