Plugin connections: per-person configuration and secrets for plugins that talk to another service #11

Closed
opened 2026-09-05 12:29:05 +00:00 by tiagoagueda · 0 comments
Owner

Why this exists

Three of the plugins asked for on 2026-09-05 — postulo-paperless (#15), postulo-dav (#16) and, already, postulo-apprise (#5) — share one first need: a place where a person enters where the other service is and how to authenticate, which a plugin can read and Postulo can render a form for without knowing the plugin. #4 sketched it for notifiers as a NotificationChannel. This issue makes it one thing for every plugin kind, so it is designed once and #4 builds on it.

What exists today

  • Plugins are discovered through a single entry-point group (plugins/registry.py, ENTRY_POINT_GROUP = "postulo.sources") and are stateless: a source has no configuration and no secrets.
  • The only per-person credential in the system is the capture token (api/models.py), stored as a SHA-256 hash because Postulo only ever needs to compare it. A connection is the opposite case: Postulo must present the secret to another server, so it has to be readable — encryption at rest, not hashing.
  • Outbound requests go through plugins/fetching.py, whose validate_public_url refuses any hostname that resolves to a private, loopback or link-local address. Right for a URL a stranger pasted into a capture; wrong for a person's own Paperless on the same LAN or in the same Compose network, which is exactly where self-hosted services live.

Shape

  1. A Connection model, owned like everything else: plugin name, kind (notifier, store, sync…), label, config (JSON, not secret), secrets (encrypted), enabled, last_ok_at, last_error. A person may hold several connections to the same plugin.
  2. The plugin describes its own fields. config_fields() returns field specifications — name, type, label, help, required, secret flag — and Postulo builds the form. Same idea as #4's notifier contract; this issue owns it, and #4 inherits it.
  3. test(connection). Every plugin with a connection implements it; the form has a Test button and the outcome is stored and shown.
  4. Encryption. Secrets encrypted with a key derived from SECRET_KEY by default, overridable with POSTULO_FIELD_KEY, so that rotating Django's key does not silently lock every connection. Never rendered back in full: a masked tail. Left out of the export by default, because an export is a file that travels (core/export.py gains an explicit flag if anyone needs them included).
  5. Outbound policy. The SSRF guard stays exactly as it is for captures. For connections the destination is what the person typed, and the operator decides whether private destinations are reachable: POSTULO_CONNECTIONS_ALLOW_PRIVATE, default false, switched on in the Compose documentation with a sentence on why. Plugins get one shared httpx client factory — timeouts, size caps, no redirects to a different host — rather than each rolling its own.
  6. Registry. plugins/registry.py learns several groups: postulo.sources, postulo.notifiers (#4), postulo.stores (#13), postulo.syncs (#16). One package may register in more than one.
  7. Interface. Your details → Connections: one page listing every connection across plugins, each editable through its plugin's form. #4's channels are the first entries on it.

Classification

Enhancement. Not breaking: new tables, nothing existing changes. It does reconcile #4, which should build on this rather than on a channel model of its own.

Open questions

  1. A key derived from SECRET_KEY, or a dedicated key required from day one? Derived is zero-configuration; dedicated is cleaner when a key has to be rotated.
  2. Include connection configuration in the export at all, even behind a flag?
  3. Instance-wide connections (an operator's Paperless offered to everyone)? Proposal: no; everything is per person, and an "instance connection" can be its own issue if someone asks.
## Why this exists Three of the plugins asked for on 2026-09-05 — postulo-paperless (#15), postulo-dav (#16) and, already, postulo-apprise (#5) — share one first need: a place where a person enters *where the other service is and how to authenticate*, which a plugin can read and Postulo can render a form for without knowing the plugin. #4 sketched it for notifiers as a `NotificationChannel`. This issue makes it one thing for every plugin kind, so it is designed once and #4 builds on it. ## What exists today - Plugins are discovered through a single entry-point group (`plugins/registry.py`, `ENTRY_POINT_GROUP = "postulo.sources"`) and are stateless: a source has no configuration and no secrets. - The only per-person credential in the system is the capture token (`api/models.py`), stored as a SHA-256 hash because Postulo only ever needs to *compare* it. A connection is the opposite case: Postulo must *present* the secret to another server, so it has to be readable — encryption at rest, not hashing. - Outbound requests go through `plugins/fetching.py`, whose `validate_public_url` refuses any hostname that resolves to a private, loopback or link-local address. Right for a URL a stranger pasted into a capture; wrong for a person's own Paperless on the same LAN or in the same Compose network, which is exactly where self-hosted services live. ## Shape 1. **A `Connection` model**, owned like everything else: plugin name, kind (notifier, store, sync…), label, `config` (JSON, not secret), `secrets` (encrypted), enabled, `last_ok_at`, `last_error`. A person may hold several connections to the same plugin. 2. **The plugin describes its own fields.** `config_fields()` returns field specifications — name, type, label, help, required, *secret* flag — and Postulo builds the form. Same idea as #4's notifier contract; this issue owns it, and #4 inherits it. 3. **`test(connection)`.** Every plugin with a connection implements it; the form has a *Test* button and the outcome is stored and shown. 4. **Encryption.** Secrets encrypted with a key derived from `SECRET_KEY` by default, overridable with `POSTULO_FIELD_KEY`, so that rotating Django's key does not silently lock every connection. Never rendered back in full: a masked tail. Left out of the export by default, because an export is a file that travels (`core/export.py` gains an explicit flag if anyone needs them included). 5. **Outbound policy.** The SSRF guard stays exactly as it is for captures. For connections the destination is what the person typed, and the *operator* decides whether private destinations are reachable: `POSTULO_CONNECTIONS_ALLOW_PRIVATE`, default false, switched on in the Compose documentation with a sentence on why. Plugins get one shared `httpx` client factory — timeouts, size caps, no redirects to a different host — rather than each rolling its own. 6. **Registry.** `plugins/registry.py` learns several groups: `postulo.sources`, `postulo.notifiers` (#4), `postulo.stores` (#13), `postulo.syncs` (#16). One package may register in more than one. 7. **Interface.** *Your details → Connections*: one page listing every connection across plugins, each editable through its plugin's form. #4's channels are the first entries on it. ## Classification Enhancement. Not breaking: new tables, nothing existing changes. It does reconcile #4, which should build on this rather than on a channel model of its own. ## Open questions 1. A key derived from `SECRET_KEY`, or a dedicated key required from day one? Derived is zero-configuration; dedicated is cleaner when a key has to be rotated. 2. Include connection configuration in the export at all, even behind a flag? 3. Instance-wide connections (an operator's Paperless offered to everyone)? Proposal: no; everything is per person, and an "instance connection" can be its own issue if someone asks.
tiagoagueda added this to the 0.2.0 milestone 2026-09-05 12:29:05 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#11
No description provided.