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

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.

Returns:

  • (Boolean)


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: permission_key(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.

Returns:

  • (Boolean)


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

.catalogObject



88
89
90
# File 'lib/current_scope.rb', line 88

def catalog
  @catalog ||= PermissionCatalog.new
end

.configObject



76
77
78
# File 'lib/current_scope.rb', line 76

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

Yields:



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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Raises:

  • (ArgumentError)


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 permission_key(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

.resolverObject



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: permission)
end

.scopeable_registryObject

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_resourcesObject



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.

Returns:

  • (Boolean)


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