Class: CurrentScope::Resolver
- Inherits:
-
Object
- Object
- CurrentScope::Resolver
- 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
-
#allow?(subject:, permission:, record: nil, actor: nil, model: nil) ⇒ Boolean
Public contract: boolean.
-
#decide(subject:, permission:, record: nil, actor: nil, model: nil) ⇒ Object
Internal decision: returns [allowed_bool, reason_or_nil].
- #full_access?(subject) ⇒ Boolean
-
#org_role(subject) ⇒ Object
The subject's one org-wide role.
-
#scope_for(subject:, model:, permission:) ⇒ Object
The list-side complement to allow?: "which records of
modelmay this subject act on?". -
#scoped_grant_exists?(subject:, permission:) ⇒ Boolean
Does this subject hold a scoped grant that satisfies
permissionon 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. -
#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.
-
#sod_veto_skipped?(permission:, record:) ⇒ Boolean
The blind spot: an SoD-listed action whose veto could NOT run.
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.
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: , 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: , 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?() return [ true, nil ] if scoped_grant?(subject: subject, permission: , record: record) return [ true, nil ] if record_less_scoped_grant?(subject: subject, 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: , record: record, model: model) return [ false, model.nil? ? :model_undeclared : :model_invalid ] end [ false, :no_grant ] end |
#full_access?(subject) ⇒ 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?() # 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()) .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.
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()).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)
139 140 141 |
# File 'lib/current_scope/resolver.rb', line 139 def sod_veto_applies?(permission:, record:) sod_action?() && 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)
154 155 156 |
# File 'lib/current_scope/resolver.rb', line 154 def sod_veto_skipped?(permission:, record:) sod_action?() && !sod_veto_applies?(permission: , record: record) end |