Plugin policy: a plugin switched off for a person, by an administrator or by themselves, still runs for them #362
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#362
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, following every path that runs a plugin for a person back toplugins/policy.py, and trying each with a policy row set.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atplugins/models.py:246-249(Connection.plugin_instance);plugins/views.py:44-50(_plugin_or_404),:205-212(ConnectionFormView._load),:271-461(the consent, test, backfill and sync-now views) and:97-100(the logo view'sdecide);plugins/registry.py:344-369(parse_page) and:328-330(available_sources);plugins/policy.py:51(GOVERNED_KINDS),:61(UNGOVERNED_KINDS),:277(the person's overview) and:318-319(plugins_forreturning an ungoverned kind unfiltered); andcore/server_views.py:1021-1029(_governed_plugins).connected_plugins, which fills the connection pick list, moved from the registry toplugins/policy.py:323and still only lists. The notifier, store, sync, consent, importer and outbox locations have not moved.policy.plugins_for()calls itself "the one place the rest of the application asks", and the pages do ask it: Settings → Plugins, the connection pick list and the capture form's list of sources hide or mark a plugin that was switched off. The code that runs a plugin does not ask. An administrator's unavailable or always off, for one person or for everybody, and a person's own off switch (profile.plugins_off), change what is shown and not what the account does: notifiers keep delivering, stores keep receiving documents, syncs keep running, and a connection to an unavailable plugin can still be created. Only a plugin switched off for the whole instance stops, because the registry does not load it.Related to #95 (closed), which introduced the policy, promised that "the runtime asks the policy before a plugin acts for somebody, in one place rather than at every call site" and asked for a
tests/security/test that "a forced-off plugin does not run for that person". This issue adds the runtime check that was left out and the test: the only test of that name,test_a_plugin_switched_off_does_not_act_for_that_personintests/test_plugin_policy.py:189, checks the listplugins_forreturns and runs nothing.plugins/models.py:245-248,Connection.plugin_instanceis a plainfind_plugin(self.kind, self.plugin), and every path below uses it or the registry directly;notifications/service.py:58-93,_deliveriteratesConnection.objects.for_user(user).enabled().of_kind("notifier");notifications/slow.py:120-136,anybody_wantsdoes the same, so errands are still queued;notifications/webhooks.py:77-90(pending) and:103(deliver) send rows already queued and look only atconnection.enabled;documents/archiving.py:57-58(store_connections, used byschedule_copiesat:77),:108-129(send_copy) and:173-190(pending_copies);plugins/syncing.py:99-107,due_connectionstakesConnection.objects.filter(kind="sync", enabled=True)for the whole instance, and:70-73,sync_connection;plugins/consent.py:136(finish, plugin looked up at:158) and:176(access_token, at:189);plugins/views.py:43-49,_plugin_or_404checks only the kind and that the plugin is installed, and:204-211,ConnectionFormView._load, relies on it for create and onplugin_instancefor edit.ConnectionConsentView,ConnectionTestView,ConnectionBackfillViewandConnectionSyncNowView(:270-460) run without the policy. The onedecidecall in the module is the logo view (:96-99);plugins/registry.py:362-387,parse_pagewalksavailable_sources()(:346-348), the plain registry, and is called fromjobs/remembered.py:89without the person;resume/views.py:443-457, the career-file import picks fromregistry.plugins("importer");core/correspondence.py:52-63,outbox_fordoes askplugins_for(person, "outbox"), butoutboxis in neitherGOVERNED_KINDSnorUNGOVERNED_KINDS(plugins/policy.py:46,:56), soplugins_forreturns every outbox unfiltered (:327-328). The administrator's rows are built fromGOVERNED_KINDSonly (core/server_views.py:1020-1028), as is the person's overview (plugins/policy.py:286), so an outbox cannot be decided for anybody, and the docstring's "an administrator may switch it off, and then this returns nothing" is not true;templates/settings/plugins.html:119, "Switching one off stops it being used".policy.decide(...).onisFalse, andnotify()still returns 1 and the message is sent. With the webhook notifier, aWebhookDeliveryrow is written and the scheduler posts it. The same holds with the plugin in the person's ownplugins_off.GET /settings/connections/add/notifier/<name>/answers 200, thePOSTanswers 302 and saves theConnection, and Test sends a message through it.plugins_for(person, "source")and still reads the person's captures. The importer choice has the same shape: it never consults the policy.Connectionone method that answers whether it may act now, for exampleactive_plugin(), returning the plugin only whenpolicy.decide(self.plugin, self.owner).onis true. Use it instead ofplugin_instancein_deliver,anybody_wants,store_connections/schedule_copies/send_copy,due_connections/sync_connection,webhooks.pending/deliver,consent.finish/access_tokenand the Test, Send everything, Sync now and Consent views. Keepplugin_instancefor the list page, which must still show the row._plugin_or_404andConnectionFormView._load, answer 404 whendecide(...).offeredis false and refuse to save when.onis false.parse_pageand take its sources, and the importer choice inresume/views.py, fromplugins_for(person, kind).outboxtoGOVERNED_KINDS(or govern every kind inCONNECTED_KINDS), so that the administrator's page lists it andplugins_forfilters it.Done when
tests/security/test_plugin_policy_runtime.py, for each of notifier (including a queued webhook row), store, sync, source, importer and outbox: with aFORCED_OFFrow, with anUNAVAILABLErow and, for an installed plugin, with its name in the person'splugins_off, nothing of that plugin runs for the person (notify()delivers nothing,send_pending/send_copyandrun_syncsdo nothing, the capture and the import do not use it); removing the decision resumes the waiting deliveries and copies; and nothing was deleted.POSTfor one forced off saves nothing, and Test, Sync now, Send everything and Consent do not call a plugin that is off for the connection's owner.