Class: CurrentScope::Resolver

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

Overview

The decision point. Every allow/deny question in the system routes through here, in a fixed order:

1. SoD veto      — the record's initiator can never perform an SoD action
                 on it. Reads two identities: the effective subject and
                 (under sod_identity :either) the real actor behind an
                 impersonated session. Overrides everything, incl. full_access.
2. full_access   — the subject's org-wide role grants all permissions,
                 present and future.
3. org-wide role — the role's permission set includes this permission.
4. scoped role   — a role held on THIS record grants the permission; or,
                 when the record's model declared current_scope_parent
                 (#108, opt-in), a role held on one of its declared
                 ancestors and EXPLICITLY ticking the key. full_access
                 does not cascade up a chain — see scoped_grant?.
5. scoped role,  — the target is record-less (nil for a collection action,
 record-less     a Class for a class-form check), so no specific record
                 can be named. An action in config.collection_read_actions
                 asks scope_for — the id-narrowed list query — so the gate
                 opens exactly when the list would show records
                 (full_access included, #65); any other action needs a
                 scoped grant EXPLICITLY ticking the key. scope_for then
                 narrows the list to those records.
6. default-deny  — nothing granted means denied.

Constant Summary collapse

INITIATOR_METHOD =
:current_scope_initiator
BYPASS_METHOD =

Host-defined per-record opt-in for break-glass. Absent ⇒ never bypassed (fail-closed, no raise — unlike a missing initiator, absence here is unambiguous).

:current_scope_sod_bypassed?

Instance Method Summary collapse

Instance Method Details

#allow?(subject:, permission:, record: nil, actor: nil, model: nil, cascade: true) ⇒ Boolean

Public contract: boolean. actor is the REAL principal behind the request (defaults to the subject — no impersonation); it only widens the SoD veto under config.sod_identity == :either. cascade: (default true) controls ONLY the #108 ancestor arm of the record-bound scoped-grant check. false matches grants on THIS record alone; it does not change org-wide, full_access, or record-less behaviour, and it does not reach scope_for (the record-less read arm always cascades). Break-glass is its one caller — see sod_bypassed?.

Returns:

  • (Boolean)


41
42
43
44
# File 'lib/current_scope/resolver.rb', line 41

def allow?(subject:, permission:, record: nil, actor: nil, model: nil, cascade: true)
  decide(subject: subject, permission: permission, record: record, actor: actor, model: model,
         cascade: cascade).first
end

#collection_type?(type) ⇒ Boolean

A usable collection type: an actual concrete ActiveRecord subclass — the one shape whose base_class/where the two record-less arms may call. The full rationale lives on the shape guard in record_less_scoped_grant? above; this predicate exists so the diagnostic labeler below refuses the exact same shapes the gate refused, and cannot drift from it.

Returns:

  • (Boolean)


602
603
604
# File 'lib/current_scope/resolver.rb', line 602

def collection_type?(type)
  type.is_a?(Class) && type < ActiveRecord::Base && !type.abstract_class?
end

#decide(subject:, permission:, record: nil, actor: nil, model: nil, cascade: true) ⇒ Object

Internal decision: returns [allowed_bool, reason_or_nil]. The reason is a machine-readable cause the Guard surfaces: :sod_veto / :no_grant on a denial, and :sod_bypassed on the one AUDITED allow (break-glass). Ordinary allows carry nil. The resolver — shared across threads — holds no per-decision state; the reason rides in the return tuple, not on self.

model: (#50) is the type the controller's current_scope_model hook declared for its collection actions, or nil when unknown. Only the record-less branch reads it, to bind that otherwise type-unbound grant check; every other branch ignores it. It stays a parameter, never state, per the purity rule above.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/current_scope/resolver.rb', line 57

def decide(subject:, permission:, record: nil, actor: nil, model: nil, cascade: true)
  return [ false, :no_grant ] if subject.nil?

  case sod_decision(subject: subject, actor: actor, permission: permission, record: record)
  when :veto   then return [ false, :sod_veto ]
  when :bypass then return [ true, :sod_bypassed ] # break-glass: privileged, audited override
  end

  role = org_role(subject)
  return [ true, nil ] if role&.full_access?
  return [ true, nil ] if role&.grants?(permission)
  return [ true, nil ] if scoped_grant?(subject: subject, permission: permission, record: record,
                                        cascade: cascade)
  return [ true, nil ] if record_less_scoped_grant?(subject: subject, permission: permission, record: record, model: model)

  # A LABEL, not a decision (R7a): this deny would have been an ALLOW had
  # the controller declared current_scope_model and the grant ticked the
  # key of that type. Without the distinct reason it is indistinguishable
  # from an ordinary :no_grant, and the dev nudge that explains it is
  # dev/test-only — so the production host who most needs the cause
  # (403s after an upgrade) would be the one who cannot see it. Two labels
  # for the two ways the declaration can be wrong: absent
  # (:model_undeclared) vs present-but-unusable (:model_invalid — a
  # String, PORO, or abstract class the shape guard refused). Same cell,
  # different fix, so the label says which.
  if record_less_denied_for_unknown_type?(subject: subject, permission: permission, record: record, model: model)
    return [ false, model.nil? ? :model_undeclared : :model_invalid ]
  end

  [ false, :no_grant ]
end

#full_access?(subject) ⇒ Boolean

Returns:

  • (Boolean)


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

def full_access?(subject)
  !!(subject && org_role(subject)&.full_access?)
end

#org_role(subject) ⇒ Object

The subject's one org-wide role. Memoized per request (via Current) so the many gate checks a single request makes don't each re-query — the decision is identical, only the lookup is cached, keeping the resolver a pure decision function over its inputs.



93
94
95
96
97
# File 'lib/current_scope/resolver.rb', line 93

def org_role(subject)
  CurrentScope::Current.memoized_org_role(subject) do
    RoleAssignment.find_by(subject: subject)&.role
  end
end

#scope_for(subject:, model:, permission:) ⇒ Object

The list-side complement to allow?: "which records of model may this subject act on?". Reads the SAME org + scoped grants the gate reads, so a host list can never drift from the per-record decision. Fail-closed (nil subject / no grant → none). Flat UNLESS the model declared current_scope_parent (#108), in which case the ancestor arm unions in the children of granted parents under roles_ticking. SoD does NOT apply: it vetoes record-targeted actions, not list membership.

Since #65 the record-less gate asks this same query (.exists?) for actions in config.collection_read_actions, so gate and list cannot drift for listed reads — they are one query.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/current_scope/resolver.rb', line 114

def scope_for(subject:, model:, permission:)
  return model.none if subject.nil?

  role = org_role(subject)
  return model.all if role&.full_access? || role&.grants?(permission)

  # Records on which the subject holds a scoped role that grants the key.
  # Query the polymorphic base_class (what scoped grants store), not the
  # passed model's name — otherwise scope_for(STISubclass) returns nothing
  # while the per-record gate (also keyed on base_class) would allow it. The
  # `model.where` still applies STI's own type predicate, so a subclass query
  # can't over-list sibling-subclass rows. An empty id list yields an empty
  # (still chainable) relation.
  #
  # NOTE scope_for is EAGER since #151: granted_ids runs its query when the
  # relation is BUILT, not when it is enumerated, so the result is a snapshot
  # of the grants at call time and the method needs a connection. The returned
  # relation is still lazy and chainable.
  direct = model.where(model.primary_key => granted_ids(
    subject: subject, type: model.base_class.name, model: model,
    roles: roles_granting(permission)
  ))

  ancestor_scope_for(subject: subject, model: model, permission: permission)
    .reduce(direct) { |relation, arm| relation.or(arm) }
end

#scoped_grant_exists?(subject:, permission:) ⇒ Boolean

Does this subject hold a scoped grant that satisfies permission on ANY record? Read-only, and deliberately unfiltered by resource — that absence IS the question: the grant exists, but the gate never got a record for it to apply to.

DIAGNOSTICS ONLY (#41). It answers a COUNTERFACTUAL — "had the controller declared its record hook, would a scoped grant have allowed this?" — and must never decide anything. The record it would need in order to BE a decision is exactly what's missing; that's the bug it reports.

Reads roles_granting (full_access ∪ ticking), and that is right BECAUSE the counterfactual binds to a record: with a real record, scoped_grant? honors a scoped full_access role, so an honest "would it have been allowed?" must honor it too. The same reuse in a branch that binds to NO record was #49's P0 escalation — one scoped grant passing every collection gate in the app. The binding is the entire difference, which is why this is a question and not a gate.

Returns:

  • (Boolean)


212
213
214
215
216
# File 'lib/current_scope/resolver.rb', line 212

def scoped_grant_exists?(subject:, permission:)
  return false if subject.nil?

  ScopedRoleAssignment.where(subject: subject, role_id: roles_granting(permission)).exists?
end

#sod_initiator_missing?(permission:, record:) ⇒ Boolean

The misconfiguration behind the ConfigurationError below: the veto IS in a position to decide about this pair, and the record cannot name an initiator. Every request to such an action raises — in :report mode as well as :enforce (#133).

PUBLIC for the same reason sod_veto_skipped? is: report mode's diagnosis must be able to ASK what the cause was. The alternative is inspecting the raised exception's message, which is the pattern-match-the-error-string mistake Event.missing_events_table? already exists to prevent — and here it would decide whether a host is sent after their record hook or their model. This is the single definition; sod_decision reads it too, so the diagnosis and the raise cannot drift apart.

Returns:

  • (Boolean)


190
191
192
193
# File 'lib/current_scope/resolver.rb', line 190

def sod_initiator_missing?(permission:, record:)
  sod_veto_applies?(permission: permission, record: record) &&
    !record.respond_to?(INITIATOR_METHOD, true)
end

#sod_veto_applies?(permission:, record:) ⇒ Boolean

Whether the separation-of-duties veto is in a position to decide about this pair at all: the action must be SoD-listed AND there must be an actual record instance to name an initiator from. The veto is defined in terms of who raised a record, so with no record it has nothing to measure and is skipped — that covers a collection action's nil, a class-form check like allowed_to?(:approve, Report), and equally a host hook that handed back something that isn't a record at all (params[:id], a String).

PUBLIC because "the veto did not run" and "the veto passed" are different facts, and a caller that acts on the difference must be able to ASK rather than infer it. It is not inferable from the decision: a skipped veto falls through to the ordinary grant check, so it surfaces as :no_grant or an ordinary allow — never as anything that says "SoD abstained". Report mode (#37) has to know, because :no_grant is the one reason it downgrades.

This is the single definition of the condition, deliberately: sod_decision reads it too. Two copies of "did the veto run" is two copies that drift, and the copy that drifts is the one guarding the fraud control. (#59 review)

Returns:

  • (Boolean)


159
160
161
# File 'lib/current_scope/resolver.rb', line 159

def sod_veto_applies?(permission:, record:)
  sod_action?(permission) && record.respond_to?(:new_record?)
end

#sod_veto_skipped?(permission:, record:) ⇒ Boolean

The blind spot: an SoD-listed action whose veto could NOT run. The decision that comes back for one of these is :no_grant or an ordinary allow — the veto contributed nothing to it. Anyone treating that :no_grant as "SoD was fine with this" is reading a silence as an answer.

The same hazard the record-less scoped branch already refuses (see sod_action? below) and that config.warn_on_nil_sod_record surfaces on the allow path: a host mis-gating a member SoD action. In :enforce this costs nothing — :no_grant is a 403 regardless, so the skipped veto never decides anything. Report mode is what makes it matter, because :no_grant is exactly what it downgrades. (#37, #59 review)

Returns:

  • (Boolean)


174
175
176
# File 'lib/current_scope/resolver.rb', line 174

def sod_veto_skipped?(permission:, record:)
  sod_action?(permission) && !sod_veto_applies?(permission: permission, record: record)
end