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,74 @@
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
+ # #50: the collection type the request's controller declared via
18
+ # current_scope_model, and the controller_path it was declared for. The
19
+ # Guard stashes both once per request so the advisory path (allowed_to? in
20
+ # a view) can bind the record-less gate the same way the controller gate
21
+ # did — but the PATH is stored so a cross-controller advisory question
22
+ # (allowed_to?(:index, controller: "reports") from a projects view) does
23
+ # NOT borrow the wrong controller's type (KTD-6). Request-scoped, reset
24
+ # with everything else, so it never leaks between requests.
25
+ attribute :collection_model, :collection_model_path
26
+
27
+ # Per-request memo for the resolver's org-role lookup. A view with N
28
+ # permission-gated elements calls the gate N times, each otherwise re-running
29
+ # the same `RoleAssignment.find_by(subject:)`; caching it here collapses that
30
+ # to one query. Request/job-scoped like everything on CurrentAttributes, so
31
+ # it never leaks across requests, and invalidated on any org-role write (see
32
+ # RoleAssignment) so a grant-then-check within one request is never stale.
33
+ attribute :org_role_cache
34
+
35
+ # Falls back to the effective subject, so actor is never nil when user is
36
+ # set — callers attribute to `actor` without a nil branch.
37
+ #
38
+ # ROUND-TRIP HAZARD: the reader answers with the fallback, not stored
39
+ # state. Snapshot/restore code (Current.set, Object#with, anything that
40
+ # reads #actor to write it back) would pin the fallback as an explicit
41
+ # actor and restore a stale identity once user changes — read the raw
42
+ # `attributes` hash instead, as TestHelpers#with_current_user does.
43
+ def actor
44
+ super || user
45
+ end
46
+
47
+ # True only while a distinct real actor stands behind the effective
48
+ # subject (act-as). THE definition of "impersonating" — the Permissions
49
+ # mixin and the mutation guard both delegate here, so the view-level
50
+ # read-only signal and the write gate can never drift apart.
51
+ def impersonating?
52
+ user.present? && actor != user
53
+ end
54
+
55
+ # Memoize the org-role lookup for `subject` for the rest of this request/job.
56
+ # Keyed by subject so a check that spans several subjects stays correct.
57
+ # Caches nil (no role) too, so a repeated "no grant" check is one query, not N.
58
+ def memoized_org_role(subject)
59
+ return yield if subject.nil?
60
+
61
+ cache = (self.org_role_cache ||= {})
62
+ key = [ subject.class.name, subject.id ]
63
+ return cache[key] if cache.key?(key)
64
+
65
+ cache[key] = yield
66
+ end
67
+
68
+ # Drop the memo — called on any org-role write so a later check in the same
69
+ # request sees the change.
70
+ def reset_org_role_cache
71
+ self.org_role_cache = nil
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,136 @@
1
+ module CurrentScope
2
+ # Append-only authorization event ledger. Design rules:
3
+ #
4
+ # - Append-only: there is no updated_at; persisted rows are never mutated.
5
+ # - Identities and target are stored as GlobalID strings, alongside a
6
+ # denormalized `target_label`, so the history outlives the records it
7
+ # names (a deleted role still renders in the ledger).
8
+ # - `actor` and `subject` are ALWAYS present. subject == actor unless
9
+ # impersonating; impersonated rows are exactly the ones where
10
+ # subject <> actor.
11
+ # - NORMATIVE target mapping: assignment events (org_role.*, scoped_role.*)
12
+ # target the GRANTEE (the subject being granted); role.* events target the
13
+ # role. Role/resource ride in `details`.
14
+ #
15
+ # The AR-level `readonly?` ceiling is honest but NOT total. These BYPASS it:
16
+ # update_all / delete_all / update_column(s) / insert_all / raw SQL.
17
+ # The sandbox reset job (a later unit) is the one sanctioned `delete_all`
18
+ # caller. DB-level hardening is adapter-honest: REVOKE UPDATE/DELETE covers
19
+ # PostgreSQL and MySQL hosts; SQLite hosts get file permissions only.
20
+ # Hash-chain tamper-evidence is deferred.
21
+ class Event < ApplicationRecord
22
+ # Once written, a row is immutable — blocks update / save / destroy at the
23
+ # AR layer. See the class header for the operations that bypass this.
24
+ def readonly? = persisted?
25
+
26
+ class << self
27
+ # The ONE recording entry point. Reads the ambient actor/subject from
28
+ # CurrentScope::Current, serializes actor/subject/target as GlobalID
29
+ # strings, denormalizes a human label for the target, and — when
30
+ # config.audit is on — appends exactly one row.
31
+ #
32
+ # CurrentScope::Event.record!(event: "role.created", target: role,
33
+ # details: { name: "Owner" })
34
+ #
35
+ # Raises ConfigurationError (loud, matching the SoD posture) when there is
36
+ # no ambient actor and no explicit actor: override. Silent no-op (returns
37
+ # nil) when config.audit is false.
38
+ #
39
+ # Optional actor:/subject: overrides (#30): when non-nil they replace the
40
+ # ambient reads so bootstrap paths (grant!, rake, seeds) can self-attribute
41
+ # without a controller. Omit both for byte-for-byte ambient behavior.
42
+ # Pin BOTH on a self-attributed grant so an ambient Current.user cannot
43
+ # leak into subject and mis-record the row as impersonation.
44
+ def record!(event:, target:, details: nil, actor: nil, subject: nil)
45
+ return unless CurrentScope.config.audit
46
+
47
+ actor ||= CurrentScope::Current.actor
48
+ if actor.nil?
49
+ raise CurrentScope::ConfigurationError,
50
+ "CurrentScope::Event.record! has no actor — CurrentScope::Current.actor is nil. " \
51
+ "Set the ambient context (the controller hook, or with_current_user in tests) before recording."
52
+ end
53
+
54
+ # Explicit subject wins; else Current.user; else actor (never nil when
55
+ # actor is set, equals actor when not impersonating).
56
+ subject ||= CurrentScope::Current.user || actor
57
+
58
+ # requires_new: on PostgreSQL a StatementInvalid aborts the *whole*
59
+ # open transaction even if rescued — so a missing events table would
60
+ # poison grant!/controller mutation transactions that wrap record!.
61
+ # A savepoint isolates the audit write: default audit=true degrades
62
+ # and the assignment commits; :strict re-raises and rolls the outer
63
+ # mutation back (PR #102 review).
64
+ Event.transaction(requires_new: true) do
65
+ create!(
66
+ event: event.to_s,
67
+ actor: actor.to_gid.to_s,
68
+ subject: subject.to_gid.to_s,
69
+ target: target.to_gid.to_s,
70
+ target_label: label_for(target),
71
+ details: details,
72
+ request_id: CurrentScope::Current.request_id
73
+ )
74
+ end
75
+ rescue ActiveRecord::StatementInvalid => e
76
+ raise unless missing_events_table?(e)
77
+
78
+ # :strict — an audit-mandatory host refuses to commit a mutation with no
79
+ # audit row. Re-raise so the enclosing (mutation-wrapping) transaction
80
+ # rolls back rather than silently degrading. Checked as `== :strict`, not
81
+ # `!= true`, so the tri-state can't be flattened by a future refactor.
82
+ # (Impersonation-boundary events have no DB mutation to roll back, so a
83
+ # raise there is simply a loud 500 on a mis-migrated host.)
84
+ raise if CurrentScope.config.audit == :strict
85
+
86
+ # true (default) — an existing host that upgrades the gem without running
87
+ # the new migration must not break on its first mutation. Degrade
88
+ # gracefully: skip recording and warn once (not on every call), naming
89
+ # the fix. A host that wants audit runs the migration; one that doesn't
90
+ # can set config.audit = false to silence it, or :strict to fail loud.
91
+ warn_missing_events_table_once
92
+ nil
93
+ end
94
+
95
+ private
96
+
97
+ def warn_missing_events_table_once
98
+ return if @missing_events_table_warned
99
+
100
+ @missing_events_table_warned = true
101
+ Rails.logger&.warn(
102
+ "[CurrentScope] audit is on but the current_scope_events table is missing — " \
103
+ "skipping audit recording. Run `rails current_scope:install:migrations` and " \
104
+ "migrate to enable it, or set `CurrentScope.config.audit = false` to silence this."
105
+ )
106
+ end
107
+
108
+ # The shared chain (CurrentScope.label_for) — the same label the UI
109
+ # renders is the one denormalized into target_label, so the ledger and
110
+ # the screen can't disagree about what a record was called.
111
+ def label_for(record)
112
+ CurrentScope.label_for(record)
113
+ end
114
+
115
+ # Recognizes "table is missing" across adapters and Rails' own schema
116
+ # reflection: SQLite ("no such table" / "Could not find table"),
117
+ # PostgreSQL ("relation ... does not exist"), MySQL ("Unknown table" /
118
+ # "doesn't exist"). The `column` exclusion keeps a missing-COLUMN error
119
+ # (e.g. "column ... of relation current_scope_events does not exist" after
120
+ # a partial migration) from being misreported as a missing table, which
121
+ # would point operators at the wrong fix.
122
+ def missing_events_table?(error)
123
+ message = error.message
124
+ message.include?("current_scope_events") &&
125
+ !message.match?(/\bcolumn\b/i) &&
126
+ message.match?(/no such table|could not find table|does(?:n't| not) exist|unknown table|undefined table/i)
127
+ end
128
+ # PUBLIC: report mode's ledger warning asks this too (#37). It is one
129
+ # question — "is this the un-migrated-table case?" — and one adapter-shaped
130
+ # answer, so it gets one definition. A second opinion elsewhere is a second
131
+ # opinion that drifts, and this one decides which fix an operator is sent
132
+ # after. (#59 review)
133
+ public :missing_events_table?
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,106 @@
1
+ module CurrentScope
2
+ # A named, editable bundle of permissions — a row, not a class. The same
3
+ # role means the same permission set whether held org-wide or scoped to a
4
+ # single record; only the reach differs.
5
+ class Role < ApplicationRecord
6
+ has_many :role_permissions, dependent: :delete_all
7
+ has_many :role_assignments, dependent: :destroy
8
+ has_many :scoped_role_assignments, dependent: :destroy
9
+
10
+ validates :name, presence: true, uniqueness: true
11
+ validate :permission_keys_in_catalog
12
+
13
+ after_save :persist_permission_keys
14
+
15
+ # The grid diff computed by the last save:
16
+ # { added: [...], removed: [...], rejected: [...] }. Empty arrays on a no-op
17
+ # save; nil when no grid was staged (a programmatic save that never set
18
+ # permission_keys). `added`/`removed` describe what actually persisted;
19
+ # `rejected` names keys the scrub path threw away, so a caller that opted
20
+ # into silence can still log it. Controllers read this to record the change
21
+ # — the model itself records nothing (seeds must stay silent).
22
+ attr_reader :permission_keys_change
23
+
24
+ def grants?(permission_key)
25
+ role_permissions.exists?(permission_key: permission_key)
26
+ end
27
+
28
+ def permission_keys
29
+ @pending_permission_keys || role_permissions.pluck(:permission_key)
30
+ end
31
+
32
+ # Stages a replacement permission set. STRICT: a key that isn't in the
33
+ # route-derived catalog makes the record invalid rather than disappearing.
34
+ # This is the mass-assignment writer (update!, strong params, the role
35
+ # grid), so it is deliberately the strict one — the scrub escape hatch must
36
+ # never be reachable from form params.
37
+ def permission_keys=(keys)
38
+ assign_permission_keys(keys, scrub: false)
39
+ end
40
+
41
+ # ponytail: one implementation, two intents — the setter is the strict
42
+ # front door, this is the same staging with the scrub opt-in named at the
43
+ # call site.
44
+ #
45
+ # `scrub: true` is the ONLY sanctioned silent drop, and it exists for one
46
+ # real case: a controller was removed, so a role still holds keys that no
47
+ # longer route. Cleaning those up is legitimate and shouldn't require the
48
+ # operator to name every dead key. Everything else — typos, unrouted
49
+ # programmatic grants, the never-routed break-glass permission — is a
50
+ # mistake, and a security-grant API must not swallow mistakes.
51
+ def assign_permission_keys(keys, scrub: false)
52
+ # Literal `true` only. Ruby truthiness would let the string "false" — or
53
+ # any params/config value that found its way here — silently disable the
54
+ # strict path, which is the exact hole this API exists to close. The
55
+ # escape hatch opens for a caller that means it, not for one that passed
56
+ # something along.
57
+ @scrub_permission_keys = scrub == true
58
+ # Blank entries are the grid's hidden-field padding, not typos (R2).
59
+ @pending_permission_keys = Array(keys).map(&:to_s).reject(&:blank?).uniq
60
+ end
61
+
62
+ def reload(...)
63
+ @pending_permission_keys = nil
64
+ @scrub_permission_keys = false
65
+ super
66
+ end
67
+
68
+ private
69
+
70
+ def permission_keys_in_catalog
71
+ return if @pending_permission_keys.nil? || @scrub_permission_keys
72
+
73
+ unknown = @pending_permission_keys.reject { |k| CurrentScope.catalog.include?(k) }
74
+ return if unknown.empty?
75
+
76
+ errors.add(
77
+ :permission_keys,
78
+ "not in the permission catalog: #{unknown.join(', ')} — check for typos, or use " \
79
+ "assign_permission_keys(..., scrub: true) to drop stale keys deliberately"
80
+ )
81
+ end
82
+
83
+ def persist_permission_keys
84
+ return if @pending_permission_keys.nil?
85
+
86
+ # Capture the prior keys BEFORE delete_all so the diff survives the swap.
87
+ previous = role_permissions.pluck(:permission_key)
88
+ # Defense in depth: on the strict path validation already proved every key
89
+ # is in the catalog, so this filter is a no-op. It is what the scrub path
90
+ # relies on, and it means no future code path that skips validations
91
+ # (insert_all, update_column, a bare `save(validate: false)`) can smuggle
92
+ # an unknown key into the table.
93
+ staged = @pending_permission_keys.select { |k| CurrentScope.catalog.include?(k) }
94
+ @permission_keys_change = {
95
+ added: staged - previous,
96
+ removed: previous - staged,
97
+ rejected: @pending_permission_keys - staged
98
+ }
99
+
100
+ role_permissions.delete_all
101
+ role_permissions.insert_all(staged.map { |k| { permission_key: k } }) if staged.any?
102
+ @pending_permission_keys = nil
103
+ @scrub_permission_keys = false
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,42 @@
1
+ module CurrentScope
2
+ # A subject's single org-wide role. One per subject by design — the "which
3
+ # role granted this?" ambiguity of multi-role systems is deliberately
4
+ # avoided; per-record needs are covered by scoped roles instead.
5
+ class RoleAssignment < ApplicationRecord
6
+ belongs_to :role
7
+ belongs_to :subject, polymorphic: true
8
+
9
+ # One org-wide role per subject. Name the rule and the upsert alternative
10
+ # so a double-grant is a one-line fix, not a trip through gem source (#44).
11
+ # Base error (not :subject_id) so create! does not prefix "Subject id …".
12
+ validate :one_org_role_per_subject
13
+ # #151: a grant must name exactly one record.
14
+ include CurrentScope::StorableKeys
15
+ validates_storable_polymorphic_keys "subject"
16
+
17
+ def one_org_role_per_subject
18
+ return if subject_type.blank? || subject_id.blank?
19
+
20
+ held = RoleAssignment.where(subject_type: subject_type, subject_id: subject_id)
21
+ held = held.where.not(id: id) if persisted?
22
+ existing = held.includes(:role).first
23
+ return unless existing
24
+
25
+ label = existing.role ? %("#{existing.role.name}") : "another role"
26
+ errors.add(:base,
27
+ "Subject already holds org-wide role #{label}; use CurrentScope.grant! " \
28
+ "to replace, or scoped roles for additive access")
29
+ end
30
+ private :one_org_role_per_subject
31
+
32
+ # Bust the per-request org-role memo (CurrentScope::Current) whenever an
33
+ # assignment changes, so a grant/clear and a later gate check in the SAME
34
+ # request never disagree. after_save/after_destroy fire inside the
35
+ # transaction, so this is correct under transactional tests too. The role's
36
+ # own permission edits don't route through here, but they can't change which
37
+ # role a subject holds — only the role_permissions, which the memo doesn't
38
+ # cache (org_role caches the role object, whose grants? reads live).
39
+ after_save { CurrentScope::Current.reset_org_role_cache }
40
+ after_destroy { CurrentScope::Current.reset_org_role_cache }
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ module CurrentScope
2
+ # One granted controller#action for a role. The key mirrors a permission
3
+ # auto-derived from the host's routes — there is no permissions table.
4
+ class RolePermission < ApplicationRecord
5
+ belongs_to :role
6
+
7
+ validates :permission_key, presence: true, uniqueness: { scope: :role_id }
8
+ end
9
+ end
@@ -0,0 +1,98 @@
1
+ module CurrentScope
2
+ # A role held on ONE specific record: "Editor of Project #7" grants nothing
3
+ # on Project #8. Never touches the subject's org-wide role — the two are
4
+ # independent axes.
5
+ #
6
+ # Rows survive host resource destruction by design (polymorphic, no
7
+ # dependent:). Since #65 those orphan grants open nothing (empty list = 403)
8
+ # but still rendered like live access until labeled (#90).
9
+ class ScopedRoleAssignment < ApplicationRecord
10
+ belongs_to :role
11
+ belongs_to :subject, polymorphic: true
12
+ belongs_to :resource, polymorphic: true
13
+
14
+ validates :role_id, uniqueness: {
15
+ scope: [ :subject_type, :subject_id, :resource_type, :resource_id ]
16
+ }
17
+
18
+ # #151: a grant must name exactly one record on BOTH sides.
19
+ include CurrentScope::StorableKeys
20
+ validates_storable_polymorphic_keys "subject", "resource"
21
+
22
+ # Batch-load polymorphic resources for resolvable types only. A global
23
+ # includes(:resource) NameErrors when any resource_type is stale; this
24
+ # constantizes per type and skips unresolvable ones so they stay lazy
25
+ # and orphaned_resource? labels them inert (#90 / PR #104 review).
26
+ def self.preload_resolvable_resources!(assignments)
27
+ list = Array(assignments)
28
+ return list if list.empty?
29
+
30
+ list.group_by(&:resource_type).each do |type, rows|
31
+ next if type.blank?
32
+
33
+ # Same canonical resolver the key guard uses: a namespaced, shortened or
34
+ # custom polymorphic token must not read as missing here while passing
35
+ # validation there.
36
+ klass = CurrentScope.polymorphic_class(type, owner: self)
37
+ next if klass.nil?
38
+ next unless klass.respond_to?(:where)
39
+
40
+ # Look up by the DECLARED primary key, and index on the string form of it.
41
+ # resource_id is a string column (#151), so a record keyed on an integer
42
+ # yields 1 while the grant holds "1"; matching them raw silently misses and
43
+ # labels a live grant inert. `where` casts the strings back to the column's
44
+ # own type, so the query is still correct on every adapter.
45
+ key = klass.primary_key
46
+ unless key.is_a?(String)
47
+ mark_resources_loaded(rows, {})
48
+ next
49
+ end
50
+
51
+ # Only ids that are legal keys for THIS model reach the query. A legacy
52
+ # collapsed value ("7", left by the pre-#151 integer column) sent at a
53
+ # PostgreSQL uuid column does not come back empty — it RAISES
54
+ # `invalid input syntax for type uuid`, and the console page an
55
+ # administrator opens to find these very grants 500s instead of listing
56
+ # them as inert. Same rule the resolver applies (CurrentScope
57
+ # .canonical_key?); a dropped id simply stays unloaded, which is exactly
58
+ # what orphaned_resource? then labels.
59
+ ids = rows.map(&:resource_id).uniq.select { |id| CurrentScope.canonical_key?(klass, id) }
60
+
61
+ # When NOTHING in the group is a legal key, skip the query but still mark
62
+ # every row loaded-as-nil below. Returning early instead would leave the
63
+ # associations lazy, so the first orphaned_resource? call would go and do
64
+ # per-row exactly the load this method exists to do safely and in bulk.
65
+ records = ids.empty? ? {} : klass.where(key => ids).index_by { |r| r[key].to_s }
66
+ mark_resources_loaded(rows, records)
67
+ end
68
+
69
+ list
70
+ end
71
+
72
+ # True when the pointed-at resource is gone (deleted row or unresolvable
73
+ # type). The grant is inert for authorization (#65) but still a console row.
74
+ # Memoized: views call this plus the label helper once each; a reset-every-
75
+ # call would re-query the resource twice per row (PR #104 cubic follow-up).
76
+ def orphaned_resource?
77
+ return @orphaned_resource if defined?(@orphaned_resource)
78
+
79
+ @orphaned_resource =
80
+ if resource_id.blank?
81
+ false
82
+ else
83
+ current_scope_resolved_record("resource").nil?
84
+ end
85
+ rescue NameError, ActiveRecord::RecordNotFound
86
+ @orphaned_resource = true
87
+ end
88
+
89
+ def self.mark_resources_loaded(rows, records)
90
+ rows.each do |row|
91
+ assoc = row.association(:resource)
92
+ assoc.target = records[row.resource_id.to_s]
93
+ assoc.loaded!
94
+ end
95
+ end
96
+ private_class_method :mark_resources_loaded
97
+ end
98
+ end
@@ -0,0 +1,41 @@
1
+ <h1>Events</h1>
2
+ <p class="cs-lede">
3
+ The append-only authorization ledger, newest first. Every string here is
4
+ host- or visitor-controlled, so it renders escaped.
5
+ </p>
6
+
7
+ <div class="cs-card cs-card--flush">
8
+ <table class="cs-table">
9
+ <thead>
10
+ <tr>
11
+ <th>When</th><th>Event</th><th>Actor</th><th>Target</th><th>Details</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody>
15
+ <% @events.each do |event| %>
16
+ <tr>
17
+ <td><%= event.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
18
+ <td><%= event.event %></td>
19
+ <td>
20
+ <%= current_scope_gid_label(event.actor) %>
21
+ <% if event.subject != event.actor %>
22
+ <span class="cs-chip">as <%= current_scope_gid_label(event.subject) %></span>
23
+ <% end %>
24
+ </td>
25
+ <td><%= event.target_label %></td>
26
+ <td>
27
+ <% (event.details || {}).each do |key, value| %>
28
+ <span class="cs-chip"><%= key %>: <%= Array(value).join(", ") %></span>
29
+ <% end %>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+
37
+ <nav class="cs-pagination" aria-label="Events pagination">
38
+ <% if @page > 1 %><%= link_to "← Previous", events_path(page: @page - 1) %><% end %>
39
+ <span>Page <%= @page %></span>
40
+ <% if @has_next_page %><%= link_to "Next →", events_path(page: @page + 1) %><% end %>
41
+ </nav>