Module: CurrentScope
- Defined in:
- lib/current_scope.rb,
lib/current_scope/guard.rb,
lib/current_scope/engine.rb,
lib/current_scope/context.rb,
lib/current_scope/version.rb,
lib/current_scope/resolver.rb,
lib/current_scope/scopeable.rb,
app/models/current_scope/role.rb,
lib/current_scope/permissions.rb,
app/models/current_scope/event.rb,
lib/current_scope/parent_chain.rb,
lib/current_scope/schema_guard.rb,
lib/current_scope/test_helpers.rb,
lib/current_scope/configuration.rb,
lib/current_scope/sod_preflight.rb,
app/models/current_scope/current.rb,
lib/current_scope/mutation_guard.rb,
lib/current_scope/gating_tripwire.rb,
lib/current_scope/grant_diagnosis.rb,
lib/current_scope/permission_grid.rb,
lib/current_scope/gating_reflection.rb,
lib/current_scope/permission_catalog.rb,
app/models/current_scope/role_assignment.rb,
app/models/current_scope/role_permission.rb,
app/models/current_scope/application_record.rb,
app/helpers/current_scope/application_helper.rb,
app/controllers/current_scope/roles_controller.rb,
app/controllers/current_scope/events_controller.rb,
app/models/concerns/current_scope/storable_keys.rb,
app/models/current_scope/scoped_role_assignment.rb,
app/controllers/current_scope/subjects_controller.rb,
app/controllers/current_scope/application_controller.rb,
lib/generators/current_scope/install/install_generator.rb,
app/controllers/current_scope/role_assignments_controller.rb,
app/controllers/current_scope/scoped_role_assignments_controller.rb
Defined Under Namespace
Modules: ApplicationHelper, Context, GatingTripwire, Generators, GrantDiagnosis, Guard, MutationGuard, ParentChain, Permissions, SchemaGuard, Scopeable, SodPreflight, StorableKeys, TestHelpers Classes: AccessDenied, ApplicationController, ApplicationRecord, Configuration, ConfigurationError, Current, Engine, Event, EventsController, GatingReflection, PermissionCatalog, PermissionGrid, Resolver, Role, RoleAssignment, RoleAssignmentsController, RolePermission, RolesController, ScopedRoleAssignment, ScopedRoleAssignmentsController, SubjectsController
Constant Summary collapse
- KEY_LIMIT =
The width of the polymorphic grant id columns (#151). Named here so the length guard and the migration cannot drift apart; the migration keeps its own copy because a migration must not depend on the gem's runtime constants.
64- VERSION =
"0.5.1"
Class Method Summary collapse
-
.allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil) ⇒ Boolean
The single entry point behind every allowed_to? call.
-
.canonical_key?(klass, value) ⇒ Boolean
#151, VALUE side.
- .catalog ⇒ Object
- .config ⇒ Object
- .configure {|config| ... } ⇒ Object
-
.grant!(subject, role: nil) ⇒ Object
Bootstrap the first admin: assign a role (default: the full_access Owner) to
subjectas its one org-wide role. -
.key_too_long?(value) ⇒ Boolean
A key that does not FIT is as dangerous as one that is not a single value.
-
.label_for(record) ⇒ Object
THE human-label fallback chain, shared by the UI helpers (ApplicationHelper#current_scope_label) and the audit ledger (Event.label_for) — one definition, so a record can never render as "Apollo" on screen while being frozen into the ledger as "Project #7".
-
.mysql?(connection = ActiveRecord::Base.connection) ⇒ Boolean
Whether a connection speaks MySQL, in one place.
- .permission_key(action, record: nil, controller_path: nil) ⇒ Object
-
.polymorphic_class(type, owner: ActiveRecord::Base) ⇒ Object
Resolve a stored polymorphic type token to its class.
-
.record_impersonation_started!(subject) ⇒ Object
Impersonation boundary events.
- .record_impersonation_stopped!(subject) ⇒ Object
- .register_scopeable(model_name) ⇒ Object
- .reset_catalog! ⇒ Object
-
.reset_cross_controller_warnings! ⇒ Object
The cross-controller nudge warns once per site (see below).
- .reset_scopeable_registry! ⇒ Object
- .resolver ⇒ Object
-
.scope_for(subject:, model:, permission:) ⇒ Object
The list-side companion to allowed?.
-
.scopeable_registry ⇒ Object
Models that opted into the scoped-role picker via CurrentScope::Scopeable.
- .scopeable_resources ⇒ Object
-
.seed_defaults! ⇒ Object
Creates the two baseline roles every install needs: an Owner with full_access (present and future permissions) and a Member baseline.
-
.storable_key?(klass) ⇒ Boolean
#151.
- .unstorable_key_error(klass, role: "subject") ⇒ Object
Class Method Details
.allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil) ⇒ Boolean
The single entry point behind every allowed_to? call.
action is either a full permission key ("admin/reports#approve") or a
bare action name resolved against record's route key, falling back to
controller_path.
128 129 130 131 132 133 134 135 136 |
# File 'lib/current_scope.rb', line 128 def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil) resolver.allow?( subject: subject, permission: (action, record: record, controller_path: controller_path), record: record, actor: actor, model: model ) end |
.canonical_key?(klass, value) ⇒ Boolean
#151, VALUE side. storable_key? asks whether the CLASS can be named by one id; this asks whether THIS id is a legal one for that class.
The columns hold any string now, so a grant can be written naming a bigint-keyed model with a UUID. Nothing about the write looks wrong — and the read path then casts that string back into the model's own key type, where String#to_i turns "7f00aaaa-…" into 7 and the grant reaches record 7, which it never named. That is #151 again, moved from the write side to the read side by the very widening that fixed the write side.
A canonical id is one that survives a round trip through its own key type.
Every id the engine itself writes is canonical by construction (it stores
record.id through exactly this cast), so this rejects only ids that could
not have come from a real record: "7f00aaaa-…" for a bigint key, "007" for
any key (it would match record 7), "7" for a Postgres uuid key.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/current_scope.rb', line 315 def canonical_key?(klass, value) # A blank id names no record. The column accepts "", but "" is a canonical # key for nothing — bless it and a directly-inserted grant with an empty id # would resolve to whatever "" casts to for the key type. Fail closed here, # in the guard, rather than relying on a caller or the adapter to drop it. return false if value.to_s.empty? key = klass.primary_key return false unless key.is_a?(String) type = klass.type_for_attribute(key) cast = type.cast(value) type.serialize(cast) # integer types raise when the value exceeds the column range cast.to_s == value.to_s rescue StandardError # Cannot introspect the key type (no connection, no table, exotic type): # refuse rather than guess. Callers use this to DENY, so failing here # fails closed. false end |
.catalog ⇒ Object
88 89 90 |
# File 'lib/current_scope.rb', line 88 def catalog @catalog ||= PermissionCatalog.new end |
.config ⇒ Object
76 77 78 |
# File 'lib/current_scope.rb', line 76 def config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
80 81 82 |
# File 'lib/current_scope.rb', line 80 def configure yield config end |
.grant!(subject, role: nil) ⇒ Object
Bootstrap the first admin: assign a role (default: the full_access Owner)
to subject as its one org-wide role. Idempotent — re-running sets the
same subject's org role to role rather than creating a duplicate (which
the one-role-per-subject uniqueness would reject anyway). Backs the
current_scope:grant rake task, so a fresh install doesn't need a console.
Seeds the default Owner/Member roles ONLY on the default path — the name promises "assign a role", so a caller granting an explicit role must not get a full-access Owner row created in their roles table as a side effect.
Audit (#30): when the org role actually changes, records one
org_role.assigned / org_role.changed event, self-attributed to the
grantee with details.source = "bootstrap". Same-role re-grants are a
no-op event-wise. Direct model writes and TestHelpers do not go through
this path and are not recorded (documented intentionally).
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/current_scope.rb', line 224 def grant!(subject, role: nil) role ||= begin seed_defaults! Role.find_by!(name: "Owner") end RoleAssignment.transaction do assignment = RoleAssignment.find_or_initialize_by(subject: subject) prior_role = assignment.persisted? ? assignment.role : nil assignment.update!(role: role) if prior_role.nil? Event.record!( event: "org_role.assigned", target: subject, details: { role: role.name, source: "bootstrap" }, actor: subject, subject: subject ) elsif prior_role.id != role.id Event.record!( event: "org_role.changed", target: subject, details: { from: prior_role.name, to: role.name, source: "bootstrap" }, actor: subject, subject: subject ) end assignment end end |
.key_too_long?(value) ⇒ Boolean
A key that does not FIT is as dangerous as one that is not a single value. MySQL outside strict mode truncates silently, so two keys sharing a 64-char prefix would collapse into one identity — #151 by another route. Checked in Ruby so every adapter fails the same way instead of depending on sql_mode.
340 341 342 |
# File 'lib/current_scope.rb', line 340 def key_too_long?(value) value.to_s.length > KEY_LIMIT end |
.label_for(record) ⇒ Object
THE human-label fallback chain, shared by the UI helpers (ApplicationHelper#current_scope_label) and the audit ledger (Event.label_for) — one definition, so a record can never render as "Apollo" on screen while being frozen into the ledger as "Project #7". Chain: the record's own current_scope_label (Scopeable provides one) → human identifiers (name/email/title) → "Model #id" → to_s. Returns nil for nil; callers choose their own nil presentation ("(none)" in views).
153 154 155 156 157 158 159 160 161 162 |
# File 'lib/current_scope.rb', line 153 def label_for(record) return if record.nil? return record.current_scope_label if record.respond_to?(:current_scope_label) name = record.try(:name).presence || record.try(:email).presence || record.try(:title).presence return name if name return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name) record.to_s end |
.mysql?(connection = ActiveRecord::Base.connection) ⇒ Boolean
Whether a connection speaks MySQL, in one place. MySQL is the only adapter #151 has to treat specially (its default collation folds case and accents, and it needs CHAR rather than TEXT in a cast), and three separate copies of this regex would be three chances to disagree.
Takes a CONNECTION rather than reading ActiveRecord::Base's: in a host that puts the grant tables on a different database from its subject models, the answer differs per connection, and asking the wrong one produces a cast or a collation the target server rejects.
296 297 298 |
# File 'lib/current_scope.rb', line 296 def mysql?(connection = ActiveRecord::Base.connection) connection.adapter_name.match?(/mysql|trilogy|maria/i) end |
.permission_key(action, record: nil, controller_path: nil) ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/current_scope.rb', line 164 def (action, record: nil, controller_path: nil) action = action.to_s return action if action.include?("#") if record.respond_to?(:model_name) route_key = record.model_name.route_key # When the current controller handles this record type (possibly under # a namespace — admin/reports for a Report), its path is the key the # Guard enforces, so prefer it: the view must agree with the gate. return "#{controller_path}##{action}" if controller_path&.split("/")&.last == route_key warn_on_cross_controller_derivation(action, route_key, controller_path) return "#{route_key}##{action}" end return "#{controller_path}##{action}" if controller_path raise ArgumentError, "cannot derive a permission key for #{action.inspect} — pass a record, " \ "a full \"controller#action\" string, or call from a controller/view" end |
.polymorphic_class(type, owner: ActiveRecord::Base) ⇒ Object
Resolve a stored polymorphic type token to its class. *_type is a Rails
STORAGE TOKEN, not necessarily a constant name: polymorphic_name can be
overridden and store_full_class_name = false shortens it, so
safe_constantize would return nil or resolve the wrong class.
Returns nil for a token that no longer resolves, which callers treat as
"nothing to check" — a stale type is #90's inert grant, not a key problem.
263 264 265 266 267 268 269 270 271 272 273 274 275 |
# File 'lib/current_scope.rb', line 263 def polymorphic_class(type, owner: ActiveRecord::Base) return if type.blank? owner.polymorphic_class_for(type) rescue NameError # A token Rails cannot reverse by constantizing (an overridden # polymorphic_name, or store_full_class_name = false) stays INERT. Inferring # the owner from the current descendant set is a guess that goes wrong when a # token is reused after its original model was removed or renamed: it would # attach an old grant to a different model's records, the exact #151 harm. # Safe reverse-resolution needs an explicit persisted mapping, tracked in #155. nil end |
.record_impersonation_started!(subject) ⇒ Object
Impersonation boundary events. The impersonated identity is an EXPLICIT argument (not read from the ambient pair): at act-as START the ambient actor still equals the effective user — Current re-resolves next request — so an ambient-only recorder would lose who was impersonated. Call these from the host's start/stop-impersonation endpoints.
191 192 193 194 |
# File 'lib/current_scope.rb', line 191 def record_impersonation_started!(subject) require_actor_method! Event.record!(event: "impersonation.started", target: subject) end |
.record_impersonation_stopped!(subject) ⇒ Object
196 197 198 199 |
# File 'lib/current_scope.rb', line 196 def record_impersonation_stopped!(subject) require_actor_method! Event.record!(event: "impersonation.stopped", target: subject) end |
.register_scopeable(model_name) ⇒ Object
112 113 114 |
# File 'lib/current_scope.rb', line 112 def register_scopeable(model_name) scopeable_registry << model_name.to_s end |
.reset_catalog! ⇒ Object
92 93 94 |
# File 'lib/current_scope.rb', line 92 def reset_catalog! @catalog = nil end |
.reset_cross_controller_warnings! ⇒ Object
The cross-controller nudge warns once per site (see below). That latch is per-process, so it must be clearable: a leaked one silently disarms the warning for every later test and makes the suite order-dependent. Also cleared on engine to_prepare, since a reload can change what's routed.
100 101 102 |
# File 'lib/current_scope.rb', line 100 def reset_cross_controller_warnings! @cross_controller_warned = nil end |
.reset_scopeable_registry! ⇒ Object
120 121 122 |
# File 'lib/current_scope.rb', line 120 def reset_scopeable_registry! @scopeable_registry = Set.new end |
.resolver ⇒ Object
84 85 86 |
# File 'lib/current_scope.rb', line 84 def resolver @resolver ||= Resolver.new end |
.scope_for(subject:, model:, permission:) ⇒ Object
The list-side companion to allowed?. Returns a chainable relation of the
records of model the subject may act on under permission — same
grants, same fail-closed rules as the per-record gate. permission is a
resolved key ("projects#index"); the mixin derives the default.
142 143 144 |
# File 'lib/current_scope.rb', line 142 def scope_for(subject:, model:, permission:) resolver.scope_for(subject: subject, model: model, permission: ) end |
.scopeable_registry ⇒ Object
Models that opted into the scoped-role picker via CurrentScope::Scopeable. Stored as class-name strings and resolved lazily so dev-mode reloading never pins a stale constant. Rebuilt from scratch on every engine to_prepare (see reset_scopeable_registry!).
108 109 110 |
# File 'lib/current_scope.rb', line 108 def scopeable_registry @scopeable_registry ||= Set.new end |
.scopeable_resources ⇒ Object
116 117 118 |
# File 'lib/current_scope.rb', line 116 def scopeable_resources scopeable_registry.map(&:constantize).sort_by(&:name) end |
.seed_defaults! ⇒ Object
Creates the two baseline roles every install needs: an Owner with full_access (present and future permissions) and a Member baseline. Call from db/seeds.rb.
204 205 206 207 |
# File 'lib/current_scope.rb', line 204 def seed_defaults! Role.find_or_create_by!(name: "Owner") { |r| r.full_access = true } Role.find_or_create_by!(name: "Member") end |
.storable_key?(klass) ⇒ Boolean
#151. subject_id and resource_id are string columns, so ANY single-value
primary key stores whole — an integer as "1", a UUID as "7f00aaaa-…". What
still cannot be stored is a key that is not one value: a composite key is an
array, and a model with no primary key names no record at all. Grants on
those are refused rather than written as something that identifies the wrong
row, or nothing.
283 284 285 |
# File 'lib/current_scope.rb', line 283 def storable_key?(klass) klass.primary_key.is_a?(String) end |
.unstorable_key_error(klass, role: "subject") ⇒ Object
344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/current_scope.rb', line 344 def unstorable_key_error(klass, role: "subject") key = begin klass.primary_key rescue StandardError nil end shape = key.is_a?(Array) ? "a composite primary key (#{key.inspect})" : "no primary key" "#{klass.name} has #{shape}, and CurrentScope stores a #{role} id as one value. " \ "A grant needs to name exactly one record. Use a model with a single-column " \ "primary key — integer or UUID both work." end |