Module: CurrentScope::Guard

Extended by:
ActiveSupport::Concern
Includes:
MutationGuard
Defined in:
lib/current_scope/guard.rb

Overview

The enforcement point. Include after Context to gate every action behind its own permission: the current controller#action IS the permission key, so new controllers are gated (fail-closed) the moment they exist.

Any controller whose actions take part in record-level decisions (scoped roles, SoD) declares a private current_scope_record method returning the record. Three rules for the hook:

- it runs for EVERY gated action, collection actions included — return
nil when there is no record
- it runs BEFORE the controller's own before_actions, so it must load
the record itself (memoize so set_* callbacks reuse it)
- key off request.path_parameters, NEVER params: a query-string ?id=
must not let a scoped role on one record unlock a collection action

  def current_scope_record
    set_report if request.path_parameters[:id]
  end

The hook is a DECLARATION, and the gate reads it as one. Returning nil says "this action has no record" — that is what lets a subject holding only scoped grants through a collection gate, with scope_for narrowing the list (#19). Declaring no hook at all says nothing, so the gate assumes nothing and scoped grants cannot open it (NO_RECORD below) — otherwise a controller that simply forgot the hook would hand a scoped subject every record of its type. Nothing is lost by silence: without a hook, scoped grants could never open a collection gate anyway. A collection-only controller that wants them to says so in one line:

  def current_scope_record = nil

A controller may ALSO declare a private current_scope_model naming the type its collection actions deal in:

  def current_scope_model = Report

Same discovery rules as current_scope_record (private, fixed name, optional). The Guard threads it to the resolver so the record-less scoped branch can bind to that type instead of matching a scoped grant on ANY type (#50); absent means the type is unknown. A plain method, so a host may branch on action_name for a per-action answer.

The declaration is TRUSTED, like current_scope_record: since #65 a listed collection-read gate derives its answer from the declared type's scoped list — full_access included — so declaring the WRONG type opens this controller's reads to subjects holding scoped full_access grants of that type. Review the declaration the way you review the record hook.

The two hooks PAIR, they don't substitute: current_scope_model WITHOUT current_scope_record is inert, because declaring no record hook passes NO_RECORD (below) and the record-less branch never runs — the declared type is never consulted. A collection controller opting scoped grants in declares BOTH: def current_scope_record = nil plus the model.

Skip the gate for public endpoints with skip_before_action :current_scope_check!. MutationGuard (included here) adds the read-only-while-impersonating gate as its OWN before_action, so it runs first and survives that skip.

Constant Summary collapse

NO_RECORD =

"This controller never said whether there is a record here." Passed to the resolver instead of nil when the controller declares no current_scope_record hook at all.

The distinction matters because the resolver honors a scoped grant on a record-less target — that is how a scoped-only subject reaches their index (#19). A declared hook returning nil is the host stating "there is no record here", which is exactly what the contract above asks for, and the resolver can trust it. No hook is not that statement: it is silence, and reading silence as "collection action" lets a controller with member actions hand a scoped subject every record of its type — strictly worse than the 403 it gave before this path existed.

Neither nil nor a Class, so the resolver's record-less branch skips it and the decision falls to deny. Org-wide and full_access are unaffected — they never read the record — so silence costs a host nothing it had before: scoped grants could never open a collection gate anyway. Declaring the hook is how you opt in.

Object.new.freeze

Class Method Summary collapse

Class Method Details

.ledger_warning_emitted!Object



92
# File 'lib/current_scope/guard.rb', line 92

def ledger_warning_emitted! = @ledger_warning_emitted = true

.ledger_warning_emitted?Boolean

Warn-once latch for a failed would-be-denial recording, mirroring Event.warn_missing_events_table_once. Lives on the module, not the controller: the failure is per-process (a missing table, a dead connection), so per-instance state would warn once per request and defeat the point.

ponytail: a plain ivar, not a Mutex. Worst case under a race is a second warning line — the thing being prevented is a flood, not a duplicate.

Returns:

  • (Boolean)


91
# File 'lib/current_scope/guard.rb', line 91

def ledger_warning_emitted? = @ledger_warning_emitted

.reset_ledger_warning!Object

Test seam: the latch would otherwise leak across examples, silently disarming the warning for every test after the first and making the suite order-dependent.



97
# File 'lib/current_scope/guard.rb', line 97

def reset_ledger_warning! = @ledger_warning_emitted = false