Class: CurrentScope::Configuration

Inherits:
Object
  • Object
show all
Defined in:
lib/current_scope/configuration.rb

Constant Summary collapse

SOD_IDENTITY_MODES =
%i[either subject].freeze
PROD_MUTATIONS_ENV =

Env var that opts a PRODUCTION deploy into impersonated writes. The VALUE is what counts: a truthy value ("1", "true", "yes"…) lifts the production refusal; unset, "", "false", "0", "off" all mean "not opted in" — an operator writing …=false in a deploy manifest gets what they said, not the opposite. development/test/staging never consult it.

"CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS"
MUTATING_ACTION_NAMES =

Canonical mutating / bulk-write action names the collection_read writer warns about. Includes destroy_all/update_all — the docs' escalation examples — so those cannot reintroduce #49 via config with no signal. Custom names still evade any partial blocklist; that ceiling is deliberate.

%w[create update destroy destroy_all update_all].freeze
AUDIT_MODES =
[ false, true, :strict ].freeze
ENFORCEMENT_MODES =
%i[enforce report].freeze
GATING_TRIPWIRE_MODES =
%i[raise warn].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConfiguration

Returns a new instance of Configuration.



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/current_scope/configuration.rb', line 438

def initialize
  @user_method = :current_user
  @actor_method = nil
  @sod_actions = [].freeze
  @sod_identity = :either
  @allow_sod_bypass = false
  @sod_bypass_permission = "bypass_sod"
  @collection_read_actions = [ "index" ].freeze
  @allow_mutations_while_impersonating = false
  @excluded_controllers = [
    %r{\Arails/}, %r{\Aactive_storage/}, %r{\Aaction_mailbox/},
    %r{\Aturbo/}, %r{\Acurrent_scope/}
  ]
  @parent_controller = "::ApplicationController"
  @subject_class = "User"
  @audit = true
  @enforcement = :enforce
  # Reuses diagnostics_default_on? for its env split and bare-Ruby safety
  # ONLY — do NOT carry over the diagnostics flags' emit/silence reading.
  # There, false means stay quiet; here, false means :warn, which EMITS.
  # The inversion is deliberate: the tripwire mixin is opt-in, so a
  # production host that included it is asking for the ungated inventory —
  # the env decides only whether a hit 500s (dev/test) or logs (elsewhere).
  @gating_tripwire = diagnostics_default_on? ? :raise : :warn
  @warn_on_nil_sod_record = diagnostics_default_on?
  @warn_on_inert_scoped_grant = diagnostics_default_on?
  @warn_on_cross_controller_derivation = diagnostics_default_on?
  @warn_on_undeclared_collection_model = diagnostics_default_on?
  @permission_grid_groups = {
    "read"    => %w[index show],
    "create"  => %w[new create],
    "update"  => %w[edit update],
    "destroy" => %w[destroy]
  }
end

Instance Attribute Details

#actor_methodObject

Host controller method returning the REAL actor while impersonating (the pretender's true_user). nil means actor == user (no impersonation), so the actor is never resolved and falls back to the subject.



9
10
11
# File 'lib/current_scope/configuration.rb', line 9

def actor_method
  @actor_method
end

#allow_mutations_while_impersonatingObject

When false (the default), an impersonated session is read-only: any non-GET/HEAD request is denied while a real actor acts as a different subject — INCLUDING the engine's own management UI. The host's stop-impersonation, sign-out, and sign-in endpoints must opt out with skip_before_action :current_scope_mutation_guard!, or impersonation can never end. The gate runs BEFORE the permission check, so sod_identity is only observable once mutations are allowed (or on a GET-listed sod_action).

Setting this true is refused in production unless the env opt-in below is set — letting a real actor write as someone else is a privilege-escalation and audit-integrity risk, so a prod deploy must acknowledge it explicitly. See the custom writer.



96
97
98
# File 'lib/current_scope/configuration.rb', line 96

def allow_mutations_while_impersonating
  @allow_mutations_while_impersonating
end

#allow_sod_bypassObject

Break-glass override for the SoD veto. Default false — OFF preserves v0.1 exactly: the separation-of-duties veto is absolute and this hook is never consulted. When true, the veto is lifted for a record ONLY when all three hold, re-checked live at decision time: this flag is on, the record's current_scope_sod_bypassed? hook returns true, AND the record's initiator holds the bypass permission (sod_bypass_permission). Every lifted veto is recorded by the engine (sod.bypassed) and surfaced on X-Current-Scope-Reason.

HONEST FRAMING: this converts SoD from a structural guarantee into an audited policy override — it's break-glass, not SoD. Its legitimacy rests on being default-off, privilege-gated, and always audited. Unlike allow_mutations_while_impersonating there is NO production env-gate: the feature is per-record, privilege-scoped, and audited-by-construction, so production is its intended home.



152
153
154
# File 'lib/current_scope/configuration.rb', line 152

def allow_sod_bypass
  @allow_sod_bypass
end

#auditObject

Tri-state: false | true (default) | :strict — controls CurrentScope::Event.record!. false — record! is a silent no-op; hosts that don't want the ledger set this and skip the events migration. true — append a row for every event; if the current_scope_events table hasn't been migrated yet, degrade gracefully (skip + warn once), so an existing host never breaks on first mutation. :strict — an audit-mandatory host: a missing events table RAISES instead of degrading, so a mutation-wrapping transaction rolls back rather than committing an unaudited grant. (Impersonation- boundary events have no mutation to roll back — a raise there is a loud 500 on a mis-migrated host.) Read as == :strict, never == true — don't flatten the tri-state. See the validating writer below — a misspelled :strict must not silently downgrade an audit-mandatory host to best-effort.



266
267
268
# File 'lib/current_scope/configuration.rb', line 266

def audit
  @audit
end

#collection_read_actionsObject

Action names whose RECORD-LESS gate derives its answer from the scoped list (#65): for these, the check asks scope_for — the same id-narrowed query the list renders from — so a scoped full_access grant opens exactly the collections that would show it records, and gate and list agree by construction. Matched like sod_actions, on the action segment of the key ("index" in "reports#index").

DEFAULT ["index"] — the #65 fix is on out of the box. Set [] to restore the pre-#65 record-less semantics (never a released posture on their own — #19/#50/#65 ship together): explicit ticks still open type-bound record-less gates, a scoped full_access role opens none, and the gate/list disagreement #65 describes comes back with it.

LIST-NARROWING READS ONLY. The full_access safety argument is that the answer is derived from record ids — WHICH records the subject holds — so it is only sound for actions with a list side. Naming a mutating or non-idempotent action here ("create", "destroy_all") hands scoped full_access holders that action on the whole TYPE off a grant on one record: the #49 escalation, reintroduced by config. Custom read-shaped actions (export, search) are the intended additions.

The declared current_scope_model is trusted here the way current_scope_record already is — a wrong declaration plus a scoped full_access grant of the declared type opens that controller's listed reads, so review the declaration like the record hook.



192
193
194
# File 'lib/current_scope/configuration.rb', line 192

def collection_read_actions
  @collection_read_actions
end

#enforcementObject

:enforce (default) | :report — what the gate DOES with a denial. (#37)

The adoption ramp. A host retrofitting this engine onto an existing app has a controller suite that goes red the moment the gate is mounted: it is fail-closed, and nothing is granted yet. Report mode lets them mount the gate, run their app, and read what WOULD have been denied out of the ledger — turning a big-bang cutover into a list of grants to seed.

:enforce — deny means 403. The only production posture.
:report  — a MISSING GRANT is logged and allowed through instead. Every
         other denial still refuses.

Report mode is NOT an off switch and never reaches the things that would make it one: the separation-of-duties veto still refuses, and the management console answers to its own full-access check rather than this gate, so no enforcement setting can hand out the UI where grants are made.



376
377
378
# File 'lib/current_scope/configuration.rb', line 376

def enforcement
  @enforcement
end

#excluded_controllersObject

Regexps matched against controller paths to keep infrastructure controllers out of the permission grid. An excluded controller cannot be granted, so it must also skip the gate (skip_before_action :current_scope_check!) — Guard raises otherwise.



109
110
111
# File 'lib/current_scope/configuration.rb', line 109

def excluded_controllers
  @excluded_controllers
end

#gating_tripwireObject

:raise | :warn — what the opt-in GatingTripwire mixin (A4) does when it catches an action that completed without running the gate.

:raise — fail loudly. Default in development/test: an ungated action is
       a hole you want CI to go red on, not one to discover in an audit.
:warn  — log once per controller#action and let the response through, so
       a real app can inventory its ungated surface without 500ing.

A closed two-mode set, like enforcement — not a boolean, and no :off: not including the mixin is off.



416
417
418
# File 'lib/current_scope/configuration.rb', line 416

def gating_tripwire
  @gating_tripwire
end

#parent_controllerObject

Class the management UI's controllers inherit from, so they pick up the host's authentication and layout.



113
114
115
# File 'lib/current_scope/configuration.rb', line 113

def parent_controller
  @parent_controller
end

#permission_grid_groupsObject

How the role-editor grid folds RESTful actions into columns. An ordered Hash of { column_label => [action names] }: ticking a group column grants every routed action in it. The default collapses the seven RESTful verbs into CRUD — new/create and edit/update pair up (the "new"/"edit" actions just render the form for their mutation), index+show read as one. Actions not in any group (e.g. "approve") get their own column. Set to nil (or {}) to show every raw action as its own column instead. Either way the grid renders ALIGNED columns — a controller that doesn't route a column's actions shows a blank cell, never a shifted one.



358
359
360
# File 'lib/current_scope/configuration.rb', line 358

def permission_grid_groups
  @permission_grid_groups
end

#sod_actionsObject

Action names subject to the separation-of-duties veto: whoever initiated a record can never perform these actions on it. Not editable in the UI by design — SoD is a structural guarantee, not a preference. Records hit by these actions must define current_scope_initiator (return nil to exempt a record type).

EMPTY BY DEFAULT — SoD is opt-in. The engine's baseline is scoped RBAC; many hosts want nothing to do with four-eyes. Enable it by listing the actions to gate, e.g. config.sod_actions = %w[approve].

Matched as STRINGS against the key's action segment (see Resolver#sod_action?). Use the writer below — a plain Symbol list ([:approve]) used to silently disable the veto (#91). Same shape as collection_read_actions=.



24
25
26
# File 'lib/current_scope/configuration.rb', line 24

def sod_actions
  @sod_actions
end

#sod_bypass_permissionObject

The grantable permission the record's initiator must hold for a break-glass bypass to lift the veto (default "bypass_sod"). Resolved against the record's route key like any permission, so it's editable in the role grid — never a hardcoded role. Must NOT be listed in sod_actions (it isn't an SoD action; keeping it out also bounds the bypass re-entrancy).

It isn't a routable action, so the catalog injects it rather than deriving it: the grid shows a column for it ONLY when allow_sod_bypass is on, and only on controllers that route an action listed in sod_actions. With the flag off it isn't grantable at all — nothing to tick, and the key is rejected if assigned. (#21)



165
166
167
# File 'lib/current_scope/configuration.rb', line 165

def sod_bypass_permission
  @sod_bypass_permission
end

#sod_identityObject

Which identities the separation-of-duties veto weighs:

:either  (default) — veto if the effective subject OR (while
                   impersonating) the REAL actor initiated the record,
                   so impersonation can never approve the actor's own
                   record.
:subject           — veto only on the effective subject.

The two are identical when not impersonating (actor == subject). See the validating writer below — an unknown value must not silently narrow the veto.



35
36
37
# File 'lib/current_scope/configuration.rb', line 35

def sod_identity
  @sod_identity
end

#subject_classObject

Host class acting as the subject, used by the management UI to list assignable subjects.



117
118
119
# File 'lib/current_scope/configuration.rb', line 117

def subject_class
  @subject_class
end

#subject_labelObject

How a subject is identified in the management UI (subjects table, picker, bulk bar). A subject id is meaningless with UUID keys, so pick something human. Accepts:

- a Symbol — a method on the subject, e.g. :email or :name
- a Proc   — subject -> String, e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
- nil (default) — best-effort, people-first: email, else name, else
first+last, else the generic current_scope_label / "Class #id".

A Proc should be TOTAL — it runs for every subject the admin can see, including ones with nil or blank attributes, so ->(u) { u.email.upcase } is a trap the first time someone is invited but hasn't filled in an email. Nothing here is load-bearing enough to break a page over, so a label that raises (or a Symbol the subject can't answer) degrades to the default chain for that subject and logs once — it never errors the page and never affects an authorization decision. If subject labels look wrong, check the log: a silent fallback is exactly what a broken label looks like from the UI.



135
136
137
# File 'lib/current_scope/configuration.rb', line 135

def subject_label
  @subject_label
end

#user_methodObject

Host controller method that returns the authenticated subject.



4
5
6
# File 'lib/current_scope/configuration.rb', line 4

def user_method
  @user_method
end

#warn_on_cross_controller_derivationObject

CurrentScope.permission_key logs a nudge when short-form derivation (allowed_to?(:show, report)) resolves to a DIFFERENT key than the one the current controller's gate enforces — the documented namespaced/custom-named controller foot-gun. The view then shows a link that 403s, or hides one that would have worked: the gate and the view disagree, silently, and the symptom shows up nowhere near the cause.



337
338
339
# File 'lib/current_scope/configuration.rb', line 337

def warn_on_cross_controller_derivation
  @warn_on_cross_controller_derivation
end

#warn_on_inert_scoped_grantObject

The gate logs a nudge when a request is DENIED :no_grant, the controller declared no current_scope_record hook at all, and the subject holds a scoped grant that WOULD have applied had the hook returned the record.

That combination is a controller with member actions that forgot the hook. It fails closed — correctly — but the 403 is indistinguishable from "you were never granted this", so the person debugging it goes looking at their grants, which are fine, instead of at their controller, which isn't.



329
330
331
# File 'lib/current_scope/configuration.rb', line 329

def warn_on_inert_scoped_grant
  @warn_on_inert_scoped_grant
end

#warn_on_nil_sod_recordObject

The gate logs a nudge when an SoD action is gated with no record and the request is ALLOWED — i.e. the veto was skipped because current_scope_record returned nil (or was never declared) on a member action. The gem's #1 foot-gun: the veto silently not running looks identical to the veto passing.

Emitted from the Guard seam (not the shared resolver), so it never fires on advisory allowed_to?/scope_for calls.



319
320
321
# File 'lib/current_scope/configuration.rb', line 319

def warn_on_nil_sod_record
  @warn_on_nil_sod_record
end

#warn_on_undeclared_collection_modelObject

The gate logs a nudge when a request is DENIED :model_undeclared — a declared collection action (current_scope_record returned nil) whose controller names no current_scope_model, while the subject holds a scoped grant ticking the key. The record-less branch had no type to bind that grant to, so it failed closed (#50) — correctly, but the 403 looks like "never granted" while the fix is one line in the controller. The same flag gates the :model_invalid nudge — the hook was declared but returned something unusable ("Report" for Report); one failure family, one knob.



347
348
349
# File 'lib/current_scope/configuration.rb', line 347

def warn_on_undeclared_collection_model
  @warn_on_undeclared_collection_model
end

Instance Method Details

#report_only?Boolean

True only in report mode. A predicate, so callers can't express "not enforcing" — the modes are a closed set, not a boolean.

Returns:

  • (Boolean)


404
# File 'lib/current_scope/configuration.rb', line 404

def report_only? = @enforcement == :report