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

Returns:

  • (Boolean)


32
33
34
# File 'lib/current_scope/resolver.rb', line 32

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

#decide(subject:, permission:, record: nil, actor: nil, model: nil) ⇒ 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.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/current_scope/resolver.rb', line 47

def decide(subject:, permission:, record: nil, actor: nil, model: nil)
  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)
  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)


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

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.



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

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) and flat — no parent/child cascade. 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.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/current_scope/resolver.rb', line 101

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 subquery yields an empty
  # (still chainable) relation.
  model.where(
    id: ScopedRoleAssignment
          .where(subject: subject, resource_type: model.base_class.name, role_id: roles_granting(permission))
          .select(:resource_id)
  )
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)


175
176
177
178
179
# File 'lib/current_scope/resolver.rb', line 175

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

  ScopedRoleAssignment.where(subject: subject, role_id: roles_granting(permission)).exists?
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)


139
140
141
# File 'lib/current_scope/resolver.rb', line 139

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)


154
155
156
# File 'lib/current_scope/resolver.rb', line 154

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