plutonium 0.62.2 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +29 -11
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Interaction
|
|
5
|
+
module Async
|
|
6
|
+
# Rebuilds the authorization context a run was dispatched under, and
|
|
7
|
+
# resolves the run's targets through it.
|
|
8
|
+
#
|
|
9
|
+
# A run performs inside an ActiveJob: no controller, no request, no
|
|
10
|
+
# +current_user+. But Plutonium does not authorize on a user — it
|
|
11
|
+
# authorizes on the PAIR (user, entity_scope). See
|
|
12
|
+
# Plutonium::Core::Controllers::Authorizable, which registers both
|
|
13
|
+
# (`authorize :user` / `authorize :entity_scope`) and whose
|
|
14
|
+
# +current_policy_context+ carries the entity scope into every policy it
|
|
15
|
+
# builds. Rebuilding only the user would resolve targets under the WRONG
|
|
16
|
+
# TENANT, and that direction fails OPEN: a broader scope silently includes
|
|
17
|
+
# records the initiator could not see when they dispatched the run.
|
|
18
|
+
#
|
|
19
|
+
# Nothing about what the initiator was ALLOWED to do is carried over from
|
|
20
|
+
# dispatch — only who they are and which tenant they were in. Permissions
|
|
21
|
+
# are re-derived here. That is deliberate: if a persisted run replayed the
|
|
22
|
+
# permissions it was created with, it would be a way to launder access that
|
|
23
|
+
# has since been revoked.
|
|
24
|
+
#
|
|
25
|
+
# == What is fresh, and when
|
|
26
|
+
#
|
|
27
|
+
# Be precise about this, because the guarantee is narrower than "fresh at
|
|
28
|
+
# perform time" and a vaguer claim would invite exactly the bug it is meant
|
|
29
|
+
# to prevent:
|
|
30
|
+
#
|
|
31
|
+
# * The initiator and scoped entity are loaded from the database when this
|
|
32
|
+
# object is CONSTRUCTED — so they reflect job start, not dispatch.
|
|
33
|
+
# * They are then CACHED, because they are ordinary belongs_to associations.
|
|
34
|
+
# A long-running executor holding one Context therefore keeps evaluating
|
|
35
|
+
# policies against the subjects as they were at job start. A predicate
|
|
36
|
+
# reading +user.role+ or +user.active?+ will NOT see a mid-run
|
|
37
|
+
# revocation.
|
|
38
|
+
# * {#refresh_subjects!} re-reads just those two, so a caller that needs a
|
|
39
|
+
# genuinely current answer can get one. It is the caller's job to decide
|
|
40
|
+
# how often; see that method.
|
|
41
|
+
# * Target RECORDS are loaded per {#targets} call and are likewise a
|
|
42
|
+
# snapshot. A predicate reading record state (+record.published?+) is only
|
|
43
|
+
# as current as the record instance handed to {#permitted?}.
|
|
44
|
+
#
|
|
45
|
+
# == Which policy applies
|
|
46
|
+
#
|
|
47
|
+
# Policy lookup in a controller passes +namespace: authorization_namespace+,
|
|
48
|
+
# which ActionPolicy derives from the CONTROLLER's module nesting (see
|
|
49
|
+
# ActionPolicy::Behaviours::Namespaced). That is how a portal gets its own
|
|
50
|
+
# policy — +StorefrontPortal::Blogging::PostPolicy+ rather than
|
|
51
|
+
# +Blogging::PostPolicy+. A job has no controller, so the namespace cannot
|
|
52
|
+
# be derived here; the run carries it, and lookup replays it.
|
|
53
|
+
#
|
|
54
|
+
# The run ALSO carries the policy that dispatch actually resolved, and this
|
|
55
|
+
# class refuses to run if today's lookup disagrees with it. The namespace
|
|
56
|
+
# alone only fixes lookup as the constants stand right now — a policy
|
|
57
|
+
# renamed, deleted or re-parented between enqueue and perform would resolve
|
|
58
|
+
# to something else, which is the same silent widening moved later in time.
|
|
59
|
+
class Context
|
|
60
|
+
# Raised when the stored context cannot be rebuilt. Deliberately fatal:
|
|
61
|
+
# every alternative to raising here resolves targets under a context
|
|
62
|
+
# that is wider than the one the run was dispatched under.
|
|
63
|
+
class UnresolvableError < StandardError; end
|
|
64
|
+
|
|
65
|
+
# Raised when the policy resolved now is not the policy dispatch
|
|
66
|
+
# resolved. A subclass so a caller can rescue either the specific case or
|
|
67
|
+
# every "this run cannot be trusted" case with one rescue.
|
|
68
|
+
class PolicyMismatchError < UnresolvableError; end
|
|
69
|
+
|
|
70
|
+
# Resolved targets, split three ways.
|
|
71
|
+
#
|
|
72
|
+
# +records+ passed BOTH checks: visible in the scope, and permitted by the
|
|
73
|
+
# policy predicate. +missing_ids+ was deleted or fell out of scope.
|
|
74
|
+
# +unauthorized_ids+ is still visible but the initiator may no longer act
|
|
75
|
+
# on it.
|
|
76
|
+
#
|
|
77
|
+
# The two failure buckets are surfaced rather than swallowed, and kept
|
|
78
|
+
# apart because they mean different things to an operator: "that record is
|
|
79
|
+
# gone" and "you may no longer archive that record" call for different
|
|
80
|
+
# responses. Silence in either is how bulk operations quietly
|
|
81
|
+
# under-apply — the run reports success having touched half the records.
|
|
82
|
+
Targets = Struct.new(:records, :missing_ids, :unauthorized_ids)
|
|
83
|
+
|
|
84
|
+
attr_reader :run
|
|
85
|
+
|
|
86
|
+
# When the two authorization subjects were last read from the database.
|
|
87
|
+
#
|
|
88
|
+
# Exposed because the subjects go stale (see the class comment) and only
|
|
89
|
+
# the caller knows how stale is too stale — so the caller needs to be able
|
|
90
|
+
# to ASK, without keeping a shadow copy of this object's state that can
|
|
91
|
+
# drift from it. The cadence policy stays with the caller; the clock stays
|
|
92
|
+
# with the state it describes.
|
|
93
|
+
#
|
|
94
|
+
# @return [Time]
|
|
95
|
+
attr_reader :subjects_read_at
|
|
96
|
+
|
|
97
|
+
def initialize(run)
|
|
98
|
+
@run = run
|
|
99
|
+
verify_resolvable!
|
|
100
|
+
# verify_resolvable! is what loads both subjects, so the clock starts
|
|
101
|
+
# once they are known good — not before.
|
|
102
|
+
@subjects_read_at = Time.current
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [ActiveRecord::Base] the user the run authorizes as
|
|
106
|
+
def initiator = run.initiator
|
|
107
|
+
|
|
108
|
+
# @return [ActiveRecord::Base, nil] the tenant, or nil for an unscoped portal
|
|
109
|
+
def scoped_entity = run.scoped_entity
|
|
110
|
+
|
|
111
|
+
# @return [ActiveRecord::Base, nil] the nested-route parent, nil if the
|
|
112
|
+
# dispatch was not nested
|
|
113
|
+
def parent = run.parent
|
|
114
|
+
|
|
115
|
+
# @return [Symbol, nil] the association the child hangs off {#parent}
|
|
116
|
+
def parent_association = run.parent_association&.to_sym
|
|
117
|
+
|
|
118
|
+
# @return [Class, nil]
|
|
119
|
+
def target_class = resolve_constant(run.target_type, "target_type")
|
|
120
|
+
|
|
121
|
+
# The dispatching controller's ActionPolicy namespace, as a Module.
|
|
122
|
+
#
|
|
123
|
+
# ActionPolicy.lookup calls +namespace.name+ and walks +namespace.namespace+,
|
|
124
|
+
# so it needs the Module itself — handed a String it raises NoMethodError.
|
|
125
|
+
# nil is a legitimate value meaning "top level", not a missing one.
|
|
126
|
+
#
|
|
127
|
+
# @return [Module, nil]
|
|
128
|
+
def authorization_namespace = resolve_constant(run.authorization_namespace, "authorization_namespace")
|
|
129
|
+
|
|
130
|
+
# The policy for the target resource, resolved the way the dispatching
|
|
131
|
+
# controller resolved it. Verified against the run's recorded policy in
|
|
132
|
+
# the constructor, so by the time anyone calls this it is known to match.
|
|
133
|
+
#
|
|
134
|
+
# @return [Class]
|
|
135
|
+
def target_policy_class = ::ActionPolicy.lookup(target_class, namespace: authorization_namespace)
|
|
136
|
+
|
|
137
|
+
# The policy predicate dispatch checked per record, e.g. :archive?.
|
|
138
|
+
#
|
|
139
|
+
# @return [Symbol, nil] nil only for opaque (untargeted) work
|
|
140
|
+
def policy_action = run.policy_action&.to_sym
|
|
141
|
+
|
|
142
|
+
# What Plutonium authorizes on, shaped for a policy constructor.
|
|
143
|
+
#
|
|
144
|
+
# The parent pair is included for the same reason the tenant is. Omitting
|
|
145
|
+
# it does not merely lose a filter: Policy#default_relation_scope picks
|
|
146
|
+
# ONE branch, parent or entity, so a nested run without its parent
|
|
147
|
+
# re-derives targets under the TENANT where dispatch used the parent.
|
|
148
|
+
# Wider, and not the scope the initiator was shown. It also leaves a host
|
|
149
|
+
# predicate reading +parent+ looking at nil — legal, since the policy
|
|
150
|
+
# declares it optional, so instead of raising it quietly answers false
|
|
151
|
+
# and every target is refused for a reason that names the predicate
|
|
152
|
+
# rather than the missing context.
|
|
153
|
+
#
|
|
154
|
+
# Both halves or neither: Policy#default_relation_scope raises on one
|
|
155
|
+
# without the other, and the migration records them together.
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash]
|
|
158
|
+
def policy_context
|
|
159
|
+
{user: initiator, entity_scope: scoped_entity,
|
|
160
|
+
parent: parent, parent_association: parent_association}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Builds the policy for a record or resource class, under the run's
|
|
164
|
+
# namespace.
|
|
165
|
+
#
|
|
166
|
+
# The leading :: is REQUIRED — Plutonium::ActionPolicy exists, so a bare
|
|
167
|
+
# ActionPolicy resolves to that namespace instead of the gem's.
|
|
168
|
+
#
|
|
169
|
+
# +lookup+ raises ActionPolicy::NotFound when nothing matches, which is
|
|
170
|
+
# the correct direction to fail: no policy must never mean no check.
|
|
171
|
+
#
|
|
172
|
+
# Deliberately NOT asserted against the run's recorded policy. That
|
|
173
|
+
# assertion is about the TARGET RESOURCE and is made once, up front. A
|
|
174
|
+
# per-record check would break legitimate STI runs: a run over
|
|
175
|
+
# Blogging::Post whose rows include Blogging::Article resolves
|
|
176
|
+
# ArticlePolicy for those rows, which correctly differs from the run's
|
|
177
|
+
# PostPolicy.
|
|
178
|
+
#
|
|
179
|
+
# Not a silent hole: a subtype policy missing +policy_action+ entirely
|
|
180
|
+
# raises via +send_with_report+ (see {#permitted?}), not "permitted".
|
|
181
|
+
#
|
|
182
|
+
# @param record [ActiveRecord::Base, Class]
|
|
183
|
+
# @return [Plutonium::Resource::Policy]
|
|
184
|
+
def policy_for(record)
|
|
185
|
+
# The record is POSITIONAL. ActionPolicy::Policy::Core#initialize is
|
|
186
|
+
# `def initialize(record = nil, *)`, so a `record:` KEYWORD is swallowed
|
|
187
|
+
# and the policy's own `record` stays nil — every predicate written as
|
|
188
|
+
# `record.published?` then blows up (or, worse, a predicate that guards
|
|
189
|
+
# on record state stops guarding). This is how ActionPolicy builds
|
|
190
|
+
# policies internally too; see Behaviours::PolicyFor#policy_for.
|
|
191
|
+
::ActionPolicy.lookup(record, namespace: authorization_namespace).new(record, **policy_context)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The target resource, narrowed to what the initiator may see in this
|
|
195
|
+
# tenant. Policy#apply_scope also enforces that the policy actually
|
|
196
|
+
# applied Plutonium's default (parent/entity) scoping, so a custom
|
|
197
|
+
# relation_scope that forgets it raises rather than leaking.
|
|
198
|
+
#
|
|
199
|
+
# @return [ActiveRecord::Relation]
|
|
200
|
+
def authorized_scope(relation = target_class.all)
|
|
201
|
+
policy_for(relation.klass).apply_scope(relation, type: :active_record_relation)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Resolves the stored target ids through the policy scope, then through
|
|
205
|
+
# the policy predicate.
|
|
206
|
+
#
|
|
207
|
+
# BOTH checks are needed and they catch different revocations. The scope
|
|
208
|
+
# catches lost VISIBILITY — the record is no longer the initiator's to
|
|
209
|
+
# see. The predicate catches lost PERMISSION — the record is still
|
|
210
|
+
# visible but +archive?+ now returns false. Resolving by scope alone
|
|
211
|
+
# would let a run whose permission was revoked after enqueue proceed
|
|
212
|
+
# anyway, which is the whole failure this class exists to prevent, and
|
|
213
|
+
# is what dispatch itself checks per record (see
|
|
214
|
+
# Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!).
|
|
215
|
+
#
|
|
216
|
+
# The check lives here rather than in the executor so that no caller can
|
|
217
|
+
# perform work without it having happened.
|
|
218
|
+
#
|
|
219
|
+
# One query for the whole set, not one per id: a bulk run over a few
|
|
220
|
+
# thousand records is the normal case, and per-id lookups would also make
|
|
221
|
+
# the scope check easy to accidentally skip on the "just fetch this one"
|
|
222
|
+
# path. The predicates are then evaluated in memory, per record — that is
|
|
223
|
+
# N policy objects but no extra queries, unless a host's own predicate
|
|
224
|
+
# queries, which is the host's choice.
|
|
225
|
+
#
|
|
226
|
+
# @return [Targets]
|
|
227
|
+
def targets
|
|
228
|
+
unless run.target_type
|
|
229
|
+
raise UnresolvableError,
|
|
230
|
+
"run #{run.id} (#{run.class}) has no target_type; it is not a targeted run"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
key = target_class.primary_key
|
|
234
|
+
# unhandled_target_ids, not target_ids: a run resumed after an
|
|
235
|
+
# interruption (see Async::ReapJob) must not redo — or re-record as
|
|
236
|
+
# missing/unauthorized — targets it already dispositioned.
|
|
237
|
+
ids = run.unhandled_target_ids
|
|
238
|
+
found = authorized_scope.where(key => ids).index_by { |record| record.public_send(key).to_s }
|
|
239
|
+
|
|
240
|
+
# Compared as strings because the two sides cross a type boundary:
|
|
241
|
+
# target_ids comes back out of a JSON column (and may have gone in as
|
|
242
|
+
# request params), while the ids on the loaded records are whatever the
|
|
243
|
+
# host's primary key is — bigint or uuid. Matching on raw values would
|
|
244
|
+
# report every target as missing the moment those disagree.
|
|
245
|
+
records = []
|
|
246
|
+
missing_ids = []
|
|
247
|
+
unauthorized_ids = []
|
|
248
|
+
|
|
249
|
+
ids.each do |id|
|
|
250
|
+
record = found[id.to_s]
|
|
251
|
+
if record.nil?
|
|
252
|
+
missing_ids << id
|
|
253
|
+
elsif permitted?(record)
|
|
254
|
+
records << record
|
|
255
|
+
else
|
|
256
|
+
unauthorized_ids << id
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
Targets.new(records: records, missing_ids: missing_ids, unauthorized_ids: unauthorized_ids)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Asks the record's own policy the question dispatch asked.
|
|
264
|
+
#
|
|
265
|
+
# PUBLIC because it is exactly what an executor needs immediately before
|
|
266
|
+
# acting on a record. {#targets} answers this once, up front, which makes
|
|
267
|
+
# a good operator report but is only true as of that moment; a run over
|
|
268
|
+
# thousands of records acts long after it. Re-asking per record right
|
|
269
|
+
# before {Run#perform_on} is the caller's decision, and this is the
|
|
270
|
+
# sanctioned way to do it — reimplementing it would duplicate the
|
|
271
|
+
# +send_with_report+ behaviour below rather than share it.
|
|
272
|
+
#
|
|
273
|
+
# NOT named +permitted_now?+: on its own it answers from the subjects
|
|
274
|
+
# cached at construction, so a name promising currency would overstate it.
|
|
275
|
+
# Pair it with {#refresh_subjects!} when currency actually matters.
|
|
276
|
+
#
|
|
277
|
+
# send_with_report raises NotImplementedError when the predicate is
|
|
278
|
+
# missing, so an action renamed between enqueue and perform surfaces as a
|
|
279
|
+
# loud failure rather than a silent false.
|
|
280
|
+
#
|
|
281
|
+
# @param record [ActiveRecord::Base]
|
|
282
|
+
# @return [Boolean]
|
|
283
|
+
def permitted?(record)
|
|
284
|
+
policy_for(record).send_with_report(policy_action)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Re-reads the two authorization subjects from the database.
|
|
288
|
+
#
|
|
289
|
+
# Policies are built from (initiator, scoped_entity), and both are cached
|
|
290
|
+
# belongs_to associations — so without this every check for the whole run
|
|
291
|
+
# evaluates against the subjects as they were when this object was built.
|
|
292
|
+
# A predicate reading +user.role+ would then look like a guard while
|
|
293
|
+
# guarding nothing. Reloading also drops association caches on the
|
|
294
|
+
# subject, so +user.organizations+ is re-read too.
|
|
295
|
+
#
|
|
296
|
+
# Two queries. Deliberately NOT called automatically: per-record reload is
|
|
297
|
+
# two queries per target, which is untenable for a large run, and the
|
|
298
|
+
# right cadence (per record, per batch, per interval) depends on the
|
|
299
|
+
# executor's shape. This provides the capability; the policy is the
|
|
300
|
+
# caller's.
|
|
301
|
+
#
|
|
302
|
+
# Re-runs the subject existence checks, so a tenant or initiator deleted
|
|
303
|
+
# MID-run stops the work the same way one deleted before it started does.
|
|
304
|
+
# Does not re-verify the recorded policy: constants do not change under a
|
|
305
|
+
# running process.
|
|
306
|
+
#
|
|
307
|
+
# @raise [UnresolvableError] if a subject has since been deleted
|
|
308
|
+
# @return [self]
|
|
309
|
+
def refresh_subjects!
|
|
310
|
+
run.reload_initiator
|
|
311
|
+
run.reload_scoped_entity if run.scoped_entity_type
|
|
312
|
+
run.reload_parent if run.parent_type
|
|
313
|
+
verify_subjects!
|
|
314
|
+
# Stamped only after the check passes: a refresh that raised did not
|
|
315
|
+
# produce a usable answer, so nothing may treat it as a fresh read.
|
|
316
|
+
@subjects_read_at = Time.current
|
|
317
|
+
self
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
# Constantizes a stored class/module name, converting the raw NameError
|
|
323
|
+
# into this class's domain error. A model or module renamed between
|
|
324
|
+
# enqueue and perform is a stored-context failure like any other here, and
|
|
325
|
+
# should name the run and the diagnosis rather than surfacing as an
|
|
326
|
+
# unattributed NameError from deep inside a job.
|
|
327
|
+
def resolve_constant(name, field)
|
|
328
|
+
return nil if name.nil?
|
|
329
|
+
|
|
330
|
+
name.constantize
|
|
331
|
+
rescue NameError
|
|
332
|
+
raise UnresolvableError,
|
|
333
|
+
"run #{run.id} recorded #{field} #{name.inspect}, which no longer resolves to a constant; " \
|
|
334
|
+
"refusing to guess what was meant"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def verify_resolvable!
|
|
338
|
+
verify_subjects!
|
|
339
|
+
verify_policy!
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Both checks below distinguish "the run carries no X" from "the run
|
|
343
|
+
# carries an X that has since been deleted". The association returns nil
|
|
344
|
+
# either way; only the *_type column tells them apart.
|
|
345
|
+
#
|
|
346
|
+
# Split out from verify_resolvable! so {#refresh_subjects!} can re-apply
|
|
347
|
+
# exactly these — a subject deleted mid-run must fail the same way one
|
|
348
|
+
# deleted before the run started does.
|
|
349
|
+
def verify_subjects!
|
|
350
|
+
# The initiator column is NOT NULL, so a nil association here means the
|
|
351
|
+
# user row is gone. Refusing is the whole point: a run whose initiator
|
|
352
|
+
# no longer exists has no one left to authorize as.
|
|
353
|
+
if run.initiator.nil?
|
|
354
|
+
raise UnresolvableError,
|
|
355
|
+
"run #{run.id} has no initiator (#{run.initiator_type}##{run.initiator_id} no longer exists)"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# nil scoped_entity is LEGITIMATE — an unscoped portal has no tenant,
|
|
359
|
+
# and the schema is nullable by design. But that is only true when the
|
|
360
|
+
# run carries no tenant at all. If the columns are populated and the row
|
|
361
|
+
# is gone, nil means "tenant unknown", and treating it as "no tenant"
|
|
362
|
+
# drops the entity filter entirely, returning records from EVERY tenant.
|
|
363
|
+
# That is precisely the fail-open case this class exists to prevent.
|
|
364
|
+
if run.scoped_entity_type.present? && run.scoped_entity.nil?
|
|
365
|
+
raise UnresolvableError,
|
|
366
|
+
"run #{run.id} was scoped to #{run.scoped_entity_type}##{run.scoped_entity_id}, " \
|
|
367
|
+
"which no longer exists; refusing to resolve targets without a tenant"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Same distinction one more time, and the same direction of failure. A
|
|
371
|
+
# deleted parent nils the association, which reads as "not a nested
|
|
372
|
+
# dispatch" — and that hands the run entity scoping instead of parent
|
|
373
|
+
# scoping, which is wider.
|
|
374
|
+
if run.parent_type.present? && run.parent.nil?
|
|
375
|
+
raise UnresolvableError,
|
|
376
|
+
"run #{run.id} was nested under #{run.parent_type}##{run.parent_id}, " \
|
|
377
|
+
"which no longer exists; refusing to resolve targets without its parent"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Checks that the policy resolving now is the one dispatch resolved.
|
|
382
|
+
#
|
|
383
|
+
# Only meaningful for a targeted run — untargeted work has no target
|
|
384
|
+
# resource and therefore no policy to pin.
|
|
385
|
+
def verify_policy!
|
|
386
|
+
return if run.target_type.nil?
|
|
387
|
+
|
|
388
|
+
# A targeted run with no recorded policy is not "unasserted", it is
|
|
389
|
+
# unverifiable: there is nothing to compare today's lookup against, and
|
|
390
|
+
# accepting it would silently disable the check for exactly the runs
|
|
391
|
+
# that need it. The dispatcher's job is to record this.
|
|
392
|
+
if run.policy_class_name.blank?
|
|
393
|
+
raise UnresolvableError,
|
|
394
|
+
"run #{run.id} targets #{run.target_type} but recorded no policy_class_name; " \
|
|
395
|
+
"refusing to resolve targets against an unverifiable policy"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# A targeted run with no predicate is the same hole one level down:
|
|
399
|
+
# there would be nothing to ask the policy, and "nothing to ask" must
|
|
400
|
+
# never collapse into "allowed".
|
|
401
|
+
if run.policy_action.blank?
|
|
402
|
+
raise UnresolvableError,
|
|
403
|
+
"run #{run.id} targets #{run.target_type} but recorded no policy_action; " \
|
|
404
|
+
"refusing to act on targets without a permission check"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
resolved = target_policy_class
|
|
408
|
+
return if resolved.name == run.policy_class_name
|
|
409
|
+
|
|
410
|
+
raise PolicyMismatchError,
|
|
411
|
+
"run #{run.id} was dispatched under #{run.policy_class_name}, but " \
|
|
412
|
+
"#{run.target_type} now resolves to #{resolved.name} " \
|
|
413
|
+
"(namespace: #{run.authorization_namespace || "top level"}); " \
|
|
414
|
+
"refusing to run under a policy the initiator was never subject to"
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|