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,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>
|