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


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

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

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



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/current_scope/resolver.rb', line 33

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

  [ false, :no_grant ]
end

#full_access?(subject) ⇒ Boolean

Returns:

  • (Boolean)


59
60
61
# File 'lib/current_scope/resolver.rb', line 59

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.



53
54
55
56
57
# File 'lib/current_scope/resolver.rb', line 53

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.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/current_scope/resolver.rb', line 68

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