Class: Plutonium::Interaction::Async::Context
- Inherits:
-
Object
- Object
- Plutonium::Interaction::Async::Context
- 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.roleoruser.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
-
#run ⇒ Object
readonly
Returns the value of attribute run.
-
#subjects_read_at ⇒ Time
readonly
When the two authorization subjects were last read from the database.
Instance Method Summary collapse
-
#authorization_namespace ⇒ Module?
The dispatching controller's ActionPolicy namespace, as a Module.
-
#authorized_scope(relation = target_class.all) ⇒ ActiveRecord::Relation
The target resource, narrowed to what the initiator may see in this tenant.
-
#initialize(run) ⇒ Context
constructor
A new instance of Context.
-
#initiator ⇒ ActiveRecord::Base
The user the run authorizes as.
-
#parent ⇒ ActiveRecord::Base?
The nested-route parent, nil if the dispatch was not nested.
-
#parent_association ⇒ Symbol?
The association the child hangs off #parent.
-
#permitted?(record) ⇒ Boolean
Asks the record's own policy the question dispatch asked.
-
#policy_action ⇒ Symbol?
The policy predicate dispatch checked per record, e.g.
-
#policy_context ⇒ Hash
What Plutonium authorizes on, shaped for a policy constructor.
-
#policy_for(record) ⇒ Plutonium::Resource::Policy
Builds the policy for a record or resource class, under the run's namespace.
-
#refresh_subjects! ⇒ self
Re-reads the two authorization subjects from the database.
-
#scoped_entity ⇒ ActiveRecord::Base?
The tenant, or nil for an unscoped portal.
- #target_class ⇒ Class?
-
#target_policy_class ⇒ Class
The policy for the target resource, resolved the way the dispatching controller resolved it.
-
#targets ⇒ Targets
Resolves the stored target ids through the policy scope, then through the policy predicate.
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
#run ⇒ Object (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_at ⇒ Time (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.
95 96 97 |
# File 'lib/plutonium/interaction/async/context.rb', line 95 def subjects_read_at @subjects_read_at end |
Instance Method Details
#authorization_namespace ⇒ Module?
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.
128 |
# File 'lib/plutonium/interaction/async/context.rb', line 128 def = resolve_constant(run., "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.
200 201 202 |
# File 'lib/plutonium/interaction/async/context.rb', line 200 def (relation = target_class.all) policy_for(relation.klass).apply_scope(relation, type: :active_record_relation) end |
#initiator ⇒ ActiveRecord::Base
Returns the user the run authorizes as.
106 |
# File 'lib/plutonium/interaction/async/context.rb', line 106 def initiator = run.initiator |
#parent ⇒ ActiveRecord::Base?
Returns 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_association ⇒ Symbol?
Returns 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.
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_action ⇒ Symbol?
The policy predicate dispatch checked per record, e.g. :archive?.
140 |
# File 'lib/plutonium/interaction/async/context.rb', line 140 def policy_action = run.policy_action&.to_sym |
#policy_context ⇒ Hash
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.
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".
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: ).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.
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_entity ⇒ ActiveRecord::Base?
Returns 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_class ⇒ Class?
119 |
# File 'lib/plutonium/interaction/async/context.rb', line 119 def target_class = resolve_constant(run.target_type, "target_type") |
#target_policy_class ⇒ Class
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.
135 |
# File 'lib/plutonium/interaction/async/context.rb', line 135 def target_policy_class = ::ActionPolicy.lookup(target_class, namespace: ) |
#targets ⇒ Targets
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.
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 = .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 = [] = [] ids.each do |id| record = found[id.to_s] if record.nil? missing_ids << id elsif permitted?(record) records << record else << id end end Targets.new(records: records, missing_ids: missing_ids, unauthorized_ids: ) end |