Class: Plutonium::Interaction::Async::Context

Inherits:
Object
  • Object
show all
Defined in:
lib/plutonium/interaction/async/context.rb

Overview

Rebuilds the authorization context a run was dispatched under, and resolves the run's targets through it.

A run performs inside an ActiveJob: no controller, no request, no current_user. But Plutonium does not authorize on a user — it authorizes on the PAIR (user, entity_scope). See Plutonium::Core::Controllers::Authorizable, which registers both (authorize :user / authorize :entity_scope) and whose current_policy_context carries the entity scope into every policy it builds. Rebuilding only the user would resolve targets under the WRONG TENANT, and that direction fails OPEN: a broader scope silently includes records the initiator could not see when they dispatched the run.

Nothing about what the initiator was ALLOWED to do is carried over from dispatch — only who they are and which tenant they were in. Permissions are re-derived here. That is deliberate: if a persisted run replayed the permissions it was created with, it would be a way to launder access that has since been revoked.

What is fresh, and when

Be precise about this, because the guarantee is narrower than "fresh at perform time" and a vaguer claim would invite exactly the bug it is meant to prevent:

  • The initiator and scoped entity are loaded from the database when this object is CONSTRUCTED — so they reflect job start, not dispatch.
  • They are then CACHED, because they are ordinary belongs_to associations. A long-running executor holding one Context therefore keeps evaluating policies against the subjects as they were at job start. A predicate reading user.role or user.active? will NOT see a mid-run revocation.
  • #refresh_subjects! re-reads just those two, so a caller that needs a genuinely current answer can get one. It is the caller's job to decide how often; see that method.
  • Target RECORDS are loaded per #targets call and are likewise a snapshot. A predicate reading record state (+record.published?+) is only as current as the record instance handed to #permitted?.

Which policy applies

Policy lookup in a controller passes namespace: authorization_namespace, which ActionPolicy derives from the CONTROLLER's module nesting (see ActionPolicy::Behaviours::Namespaced). That is how a portal gets its own policy — StorefrontPortal::Blogging::PostPolicy rather than Blogging::PostPolicy. A job has no controller, so the namespace cannot be derived here; the run carries it, and lookup replays it.

The run ALSO carries the policy that dispatch actually resolved, and this class refuses to run if today's lookup disagrees with it. The namespace alone only fixes lookup as the constants stand right now — a policy renamed, deleted or re-parented between enqueue and perform would resolve to something else, which is the same silent widening moved later in time.

Defined Under Namespace

Classes: PolicyMismatchError, Targets, UnresolvableError

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(run) ⇒ Context

Returns a new instance of Context.



97
98
99
100
101
102
103
# File 'lib/plutonium/interaction/async/context.rb', line 97

def initialize(run)
  @run = run
  verify_resolvable!
  # verify_resolvable! is what loads both subjects, so the clock starts
  # once they are known good — not before.
  @subjects_read_at = Time.current
end

Instance Attribute Details

#runObject (readonly)

Returns the value of attribute run.



84
85
86
# File 'lib/plutonium/interaction/async/context.rb', line 84

def run
  @run
end

#subjects_read_atTime (readonly)

When the two authorization subjects were last read from the database.

Exposed because the subjects go stale (see the class comment) and only the caller knows how stale is too stale — so the caller needs to be able to ASK, without keeping a shadow copy of this object's state that can drift from it. The cadence policy stays with the caller; the clock stays with the state it describes.

Returns:

  • (Time)


95
96
97
# File 'lib/plutonium/interaction/async/context.rb', line 95

def subjects_read_at
  @subjects_read_at
end

Instance Method Details

#authorization_namespaceModule?

The dispatching controller's ActionPolicy namespace, as a Module.

ActionPolicy.lookup calls namespace.name and walks namespace.namespace, so it needs the Module itself — handed a String it raises NoMethodError. nil is a legitimate value meaning "top level", not a missing one.

Returns:

  • (Module, nil)


128
# File 'lib/plutonium/interaction/async/context.rb', line 128

def authorization_namespace = resolve_constant(run.authorization_namespace, "authorization_namespace")

#authorized_scope(relation = target_class.all) ⇒ ActiveRecord::Relation

The target resource, narrowed to what the initiator may see in this tenant. Policy#apply_scope also enforces that the policy actually applied Plutonium's default (parent/entity) scoping, so a custom relation_scope that forgets it raises rather than leaking.

Returns:

  • (ActiveRecord::Relation)


200
201
202
# File 'lib/plutonium/interaction/async/context.rb', line 200

def authorized_scope(relation = target_class.all)
  policy_for(relation.klass).apply_scope(relation, type: :active_record_relation)
end

#initiatorActiveRecord::Base

Returns the user the run authorizes as.

Returns:

  • (ActiveRecord::Base)

    the user the run authorizes as



106
# File 'lib/plutonium/interaction/async/context.rb', line 106

def initiator = run.initiator

#parentActiveRecord::Base?

Returns the nested-route parent, nil if the dispatch was not nested.

Returns:

  • (ActiveRecord::Base, nil)

    the nested-route parent, nil if the dispatch was not nested



113
# File 'lib/plutonium/interaction/async/context.rb', line 113

def parent = run.parent

#parent_associationSymbol?

Returns the association the child hangs off #parent.

Returns:

  • (Symbol, nil)

    the association the child hangs off #parent



116
# File 'lib/plutonium/interaction/async/context.rb', line 116

def parent_association = run.parent_association&.to_sym

#permitted?(record) ⇒ Boolean

Asks the record's own policy the question dispatch asked.

PUBLIC because it is exactly what an executor needs immediately before acting on a record. #targets answers this once, up front, which makes a good operator report but is only true as of that moment; a run over thousands of records acts long after it. Re-asking per record right before Run#perform_on is the caller's decision, and this is the sanctioned way to do it — reimplementing it would duplicate the send_with_report behaviour below rather than share it.

NOT named permitted_now?: on its own it answers from the subjects cached at construction, so a name promising currency would overstate it. Pair it with #refresh_subjects! when currency actually matters.

send_with_report raises NotImplementedError when the predicate is missing, so an action renamed between enqueue and perform surfaces as a loud failure rather than a silent false.

Parameters:

  • record (ActiveRecord::Base)

Returns:

  • (Boolean)


283
284
285
# File 'lib/plutonium/interaction/async/context.rb', line 283

def permitted?(record)
  policy_for(record).send_with_report(policy_action)
end

#policy_actionSymbol?

The policy predicate dispatch checked per record, e.g. :archive?.

Returns:

  • (Symbol, nil)

    nil only for opaque (untargeted) work



140
# File 'lib/plutonium/interaction/async/context.rb', line 140

def policy_action = run.policy_action&.to_sym

#policy_contextHash

What Plutonium authorizes on, shaped for a policy constructor.

The parent pair is included for the same reason the tenant is. Omitting it does not merely lose a filter: Policy#default_relation_scope picks ONE branch, parent or entity, so a nested run without its parent re-derives targets under the TENANT where dispatch used the parent. Wider, and not the scope the initiator was shown. It also leaves a host predicate reading parent looking at nil — legal, since the policy declares it optional, so instead of raising it quietly answers false and every target is refused for a reason that names the predicate rather than the missing context.

Both halves or neither: Policy#default_relation_scope raises on one without the other, and the migration records them together.

Returns:

  • (Hash)


158
159
160
161
# File 'lib/plutonium/interaction/async/context.rb', line 158

def policy_context
  {user: initiator, entity_scope: scoped_entity,
   parent: parent, parent_association: parent_association}
end

#policy_for(record) ⇒ Plutonium::Resource::Policy

Builds the policy for a record or resource class, under the run's namespace.

The leading

is REQUIRED — Plutonium::ActionPolicy exists, so a bare

ActionPolicy resolves to that namespace instead of the gem's.

lookup raises ActionPolicy::NotFound when nothing matches, which is the correct direction to fail: no policy must never mean no check.

Deliberately NOT asserted against the run's recorded policy. That assertion is about the TARGET RESOURCE and is made once, up front. A per-record check would break legitimate STI runs: a run over Blogging::Post whose rows include Blogging::Article resolves ArticlePolicy for those rows, which correctly differs from the run's PostPolicy.

Not a silent hole: a subtype policy missing policy_action entirely raises via send_with_report (see #permitted?), not "permitted".

Parameters:

  • record (ActiveRecord::Base, Class)

Returns:



184
185
186
187
188
189
190
191
192
# File 'lib/plutonium/interaction/async/context.rb', line 184

def policy_for(record)
  # The record is POSITIONAL. ActionPolicy::Policy::Core#initialize is
  # `def initialize(record = nil, *)`, so a `record:` KEYWORD is swallowed
  # and the policy's own `record` stays nil — every predicate written as
  # `record.published?` then blows up (or, worse, a predicate that guards
  # on record state stops guarding). This is how ActionPolicy builds
  # policies internally too; see Behaviours::PolicyFor#policy_for.
  ::ActionPolicy.lookup(record, namespace: authorization_namespace).new(record, **policy_context)
end

#refresh_subjects!self

Re-reads the two authorization subjects from the database.

Policies are built from (initiator, scoped_entity), and both are cached belongs_to associations — so without this every check for the whole run evaluates against the subjects as they were when this object was built. A predicate reading user.role would then look like a guard while guarding nothing. Reloading also drops association caches on the subject, so user.organizations is re-read too.

Two queries. Deliberately NOT called automatically: per-record reload is two queries per target, which is untenable for a large run, and the right cadence (per record, per batch, per interval) depends on the executor's shape. This provides the capability; the policy is the caller's.

Re-runs the subject existence checks, so a tenant or initiator deleted MID-run stops the work the same way one deleted before it started does. Does not re-verify the recorded policy: constants do not change under a running process.

Returns:

  • (self)

Raises:



309
310
311
312
313
314
315
316
317
318
# File 'lib/plutonium/interaction/async/context.rb', line 309

def refresh_subjects!
  run.reload_initiator
  run.reload_scoped_entity if run.scoped_entity_type
  run.reload_parent if run.parent_type
  verify_subjects!
  # Stamped only after the check passes: a refresh that raised did not
  # produce a usable answer, so nothing may treat it as a fresh read.
  @subjects_read_at = Time.current
  self
end

#scoped_entityActiveRecord::Base?

Returns the tenant, or nil for an unscoped portal.

Returns:

  • (ActiveRecord::Base, nil)

    the tenant, or nil for an unscoped portal



109
# File 'lib/plutonium/interaction/async/context.rb', line 109

def scoped_entity = run.scoped_entity

#target_classClass?

Returns:

  • (Class, nil)


119
# File 'lib/plutonium/interaction/async/context.rb', line 119

def target_class = resolve_constant(run.target_type, "target_type")

#target_policy_classClass

The policy for the target resource, resolved the way the dispatching controller resolved it. Verified against the run's recorded policy in the constructor, so by the time anyone calls this it is known to match.

Returns:

  • (Class)


135
# File 'lib/plutonium/interaction/async/context.rb', line 135

def target_policy_class = ::ActionPolicy.lookup(target_class, namespace: authorization_namespace)

#targetsTargets

Resolves the stored target ids through the policy scope, then through the policy predicate.

BOTH checks are needed and they catch different revocations. The scope catches lost VISIBILITY — the record is no longer the initiator's to see. The predicate catches lost PERMISSION — the record is still visible but archive? now returns false. Resolving by scope alone would let a run whose permission was revoked after enqueue proceed anyway, which is the whole failure this class exists to prevent, and is what dispatch itself checks per record (see Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!).

The check lives here rather than in the executor so that no caller can perform work without it having happened.

One query for the whole set, not one per id: a bulk run over a few thousand records is the normal case, and per-id lookups would also make the scope check easy to accidentally skip on the "just fetch this one" path. The predicates are then evaluated in memory, per record — that is N policy objects but no extra queries, unless a host's own predicate queries, which is the host's choice.

Returns:



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/plutonium/interaction/async/context.rb', line 227

def targets
  unless run.target_type
    raise UnresolvableError,
      "run #{run.id} (#{run.class}) has no target_type; it is not a targeted run"
  end

  key = target_class.primary_key
  # unhandled_target_ids, not target_ids: a run resumed after an
  # interruption (see Async::ReapJob) must not redo — or re-record as
  # missing/unauthorized — targets it already dispositioned.
  ids = run.unhandled_target_ids
  found = authorized_scope.where(key => ids).index_by { |record| record.public_send(key).to_s }

  # Compared as strings because the two sides cross a type boundary:
  # target_ids comes back out of a JSON column (and may have gone in as
  # request params), while the ids on the loaded records are whatever the
  # host's primary key is — bigint or uuid. Matching on raw values would
  # report every target as missing the moment those disagree.
  records = []
  missing_ids = []
  unauthorized_ids = []

  ids.each do |id|
    record = found[id.to_s]
    if record.nil?
      missing_ids << id
    elsif permitted?(record)
      records << record
    else
      unauthorized_ids << id
    end
  end

  Targets.new(records: records, missing_ids: missing_ids, unauthorized_ids: unauthorized_ids)
end