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/test_helpers.rb,
lib/current_scope/configuration.rb,
app/models/current_scope/current.rb,
lib/current_scope/mutation_guard.rb,
lib/current_scope/gating_tripwire.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/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, Guard, MutationGuard, Permissions, Scopeable, 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

VERSION =
"0.4.0"

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)


119
120
121
122
123
124
125
126
127
# File 'lib/current_scope.rb', line 119

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

.catalogObject



79
80
81
# File 'lib/current_scope.rb', line 79

def catalog
  @catalog ||= PermissionCatalog.new
end

.configObject



67
68
69
# File 'lib/current_scope.rb', line 67

def config
  @config ||= Configuration.new
end

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

Yields:



71
72
73
# File 'lib/current_scope.rb', line 71

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).



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/current_scope.rb', line 215

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

.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).



144
145
146
147
148
149
150
151
152
153
# File 'lib/current_scope.rb', line 144

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

.permission_key(action, record: nil, controller_path: nil) ⇒ Object

Raises:

  • (ArgumentError)


155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/current_scope.rb', line 155

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

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



182
183
184
185
# File 'lib/current_scope.rb', line 182

def record_impersonation_started!(subject)
  require_actor_method!
  Event.record!(event: "impersonation.started", target: subject)
end

.record_impersonation_stopped!(subject) ⇒ Object



187
188
189
190
# File 'lib/current_scope.rb', line 187

def record_impersonation_stopped!(subject)
  require_actor_method!
  Event.record!(event: "impersonation.stopped", target: subject)
end

.register_scopeable(model_name) ⇒ Object



103
104
105
# File 'lib/current_scope.rb', line 103

def register_scopeable(model_name)
  scopeable_registry << model_name.to_s
end

.reset_catalog!Object



83
84
85
# File 'lib/current_scope.rb', line 83

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.



91
92
93
# File 'lib/current_scope.rb', line 91

def reset_cross_controller_warnings!
  @cross_controller_warned = nil
end

.reset_scopeable_registry!Object



111
112
113
# File 'lib/current_scope.rb', line 111

def reset_scopeable_registry!
  @scopeable_registry = Set.new
end

.resolverObject



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

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.



133
134
135
# File 'lib/current_scope.rb', line 133

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!).



99
100
101
# File 'lib/current_scope.rb', line 99

def scopeable_registry
  @scopeable_registry ||= Set.new
end

.scopeable_resourcesObject



107
108
109
# File 'lib/current_scope.rb', line 107

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.



195
196
197
198
# File 'lib/current_scope.rb', line 195

def seed_defaults!
  Role.find_or_create_by!(name: "Owner") { |r| r.full_access = true }
  Role.find_or_create_by!(name: "Member")
end