diff --git a/CLAUDE.md b/CLAUDE.md index d2967dd..2f8b3e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`) targeting **Nextcloud Hub 26 Spring (server 34.x)**. It lets each user configure, from their personal settings, automation "workflows" that move overdue [Deck](https://github.com/nextcloud/deck) cards from one stack to another, with optional assigned-user/label filters and an email notification. A background `TimedJob` evaluates all enabled workflows every 5 minutes. +A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`) targeting **Nextcloud Hub 26 Spring (server 34.x)**. It lets each user configure, from their personal settings, automation "workflows" that move overdue [Deck](https://github.com/nextcloud/deck) cards from one stack to another, with optional assigned-user/label filters and an email notification. A background `Job` evaluates all enabled workflows on every cron pass. ## Non-negotiable architectural constraint @@ -14,28 +14,39 @@ All Deck access is funneled through `lib/Service/DeckIntegrationService.php` — ## Per-user impersonation in the background job -Deck's ACL/permission checks read the *live* `IUserSession`, not a value frozen at construction. `RunWorkflowsJob` (a `TimedJob`) has no logged-in user by default and must evaluate workflows belonging to many different users within one PHP process. `lib/Service/WorkflowRunner.php` therefore groups workflows by owner and, per owner, temporarily impersonates them (`IUserSession::setUser()`, restored in a `finally`) before touching Deck. Don't remove this — without it, Deck's permission checks inside the cron run would be evaluated against no user (or the wrong user). +`RunWorkflowsJob` has no logged-in user by default and must evaluate workflows belonging to many different users within one PHP process. `lib/Service/WorkflowRunner.php` therefore groups workflows by owner and impersonates each owner in turn. **Impersonation here is two steps, and `IUserSession::setUser()` is the one that barely matters:** + +1. `IUserSession::setUser()` — for the handful of Nextcloud services that do read the live session, plus `IRootFolder::getUserFolder()` for Deck helpers that expect an initialised user FS. +2. `DeckIntegrationService::beginUserContext()` — pins **Deck itself** to that user. This is the load-bearing half. + +Both are undone in a `finally` (`WorkflowRunner::clearImpersonation()`). + +**Deck does not read the session for permissions. At all.** `PermissionService` — the class behind every permission check, including the ones inside `CardService::reorder()` — takes the current user as `private ?string $userId`, and so do `CardService`, `BoardService` and `ActivityManager`. That string comes from the app container's `userId` service, which is `ISession::get('user_id')` registered via `registerService()`, i.e. **shared**: Pimple resolves it once and caches it for the whole process, and `ServerContainer` caches Deck's app container just as long. Whoever was active when Deck's container first built `PermissionService` is who every later check is evaluated against. + +In cron that is *never* the user being impersonated: `null` if any Deck-owned background job ran earlier in the same `cron.php` pass, otherwise the first workflow owner touched. `null` fails every check, so Deck answers `NoPermissionException` for perfectly intact boards. This produced a real bug — see the next section. `beginUserContext()` therefore swaps that private field directly (there is no API), records the old value, and `endUserContext()` puts it back. `PermissionService` is mandatory: if it can't be pinned, `beginUserContext()` throws and the runner **skips that user entirely** rather than acting under someone else's permissions. The other three are best-effort (they only affect activity attribution and unread counts). + +Consequence for `getUserBoards()`: `listBoardsForCurrentUser()` stays **request-only** anyway — Deck may already have cached board lists behind that field, and the job has no use for a board *list*; it asks `findStackIds()` about one known board id. Before using any other `OCA\Deck\*` service from the runner, check whether it carries a frozen `$userId` — the failure mode is silent and user-crossing, not an error. The filter-matching logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. -**Not everything in Deck reads the live session, though.** `BoardService` takes the current user as `private ?string $userId` — a plain string frozen when the container builds it. `getUserBoards()` uses exactly that, so in the background job (one process, many users, a container-cached `BoardService`) it answers for whoever happened to be impersonated at first resolution, or for `null`. `listBoardsForCurrentUser()` is therefore **request-only**; the job asks `findStackIds()` instead, which goes through `StackService::findAll()` → `PermissionService` → live session. Before using any other `OCA\Deck\*` service from the runner, check which of the two kinds it is — the failure mode is silent and user-crossing, not an error. - ## Workflows are auto-disabled when their Deck targets vanish `WorkflowRunner::findBrokenTarget()` checks board and stacks before every run and, if something is gone, switches the workflow off (`enabled = false`) and sends a one-off mail. Two invariants hold this together and must survive refactors: -- **Only positive knowledge disables.** `DeckIntegrationService::findStackIds()` returns `null` only for "board missing/not readable" (`DoesNotExistException`, Deck's `NoPermissionException`/`NotFoundException`) and *throws* for everything else. The runner skips the workflow on a throw. If you make that method degrade to `[]`/`null` on generic errors, a Deck outage will disable every workflow on the instance and mail every user about it. +- **Only positive knowledge disables.** `DeckIntegrationService::findStackIds()` returns `null` only for "board row gone" (`DoesNotExistException`, Deck's `NotFoundException`), "board in the trash" (`deleted_at > 0`), or "this uid has no read permission" — and *throws* for everything else. The runner skips the workflow on a throw. If you make that method degrade to `[]`/`null` on generic errors, a Deck outage will disable every workflow on the instance and mail every user about it. +- **`findStackIds()` must never ask a Deck service to figure out *who* is asking.** It takes the uid as an argument and is assembled from pieces that cannot answer for the wrong user: `BoardMapper`/`StackMapper` are plain QBMappers with no user state, and `PermissionService::getPermissions($boardId, $userId)` is handed the uid explicitly instead of falling back to the frozen field. Do not "simplify" it back to a single `StackService::findAll($boardId)` — that is precisely what broke. `StackService::findAll()` runs `checkPermission()` with no uid, which falls through to the construction-time `$userId`; with that frozen to `null` in cron, Deck threw `NoPermissionException` for an untouched board, the old code read that as "board is gone", and users got "your workflow was deactivated" mails for boards sitting right in front of them. This layer stays correct even if `beginUserContext()` silently degrades. +- **`NoPermissionException` is not evidence of anything.** It is deliberately *not* in `isMissing()`. Deck raises it for every failed check regardless of cause. - **The mail ignores the `notifyEmail` flag** — that flag is about moved cards; this is a notice that the user's configuration stopped working. It stays a one-off simply because a disabled workflow is not picked up by `findAllEnabled()` again. Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns. -## Job interval comes from config.php +## The job has no interval — one run per cron pass -`RunWorkflowsJob` reads `workflow_deck_automation.interval` (seconds, default 300, clamped to a 60s minimum) via `IConfig::getSystemValueInt()` in its constructor. That works *because* `TimedJob` re-reads `$this->interval` on every cron pass from the freshly constructed job — so editing `config.php` takes effect immediately, with no `occ` command and no re-registration. Don't move this into `appinfo/info.xml` or a stored app config; the constants (`INTERVAL_CONFIG_KEY`, `DEFAULT_INTERVAL`, `MINIMUM_INTERVAL`) on the job are the single source of truth and are referenced from the README. +`RunWorkflowsJob` extends `OCP\BackgroundJob\Job`, **not** `TimedJob`, so it runs every time cron picks it up. There is no interval and no knob: the cadence *is* the instance's cron cadence. Don't reintroduce one — an earlier version read `workflow_deck_automation.interval` from `config.php` (default 300, 60s floor), and that config key is gone as of v0.2.0. `TimedJob` with `setInterval(0)` would behave nearly identically but keeps an interval concept nothing uses. -**The job must stay `IJob::TIME_SENSITIVE`.** It was `TIME_INSENSITIVE` at first, which looked harmless and was not: `OC\Core\Service\CronService::runCli()` reads `maintenance_window_start` (default `100`, i.e. unset) and, whenever the current UTC hour is outside `[start, start+4]`, calls `jobList->getNext($onlyTimeSensitive = true)` — time-insensitive jobs are then skipped for the other 20 hours of the day. On an instance with a maintenance window configured (Nextcloud's admin overview nags admins into setting one) the job simply never ran, showing `last_run = 1970-01-01` in `occ background-job:list` indefinitely. Note this gate only exists in `runCli()`, so it doesn't apply to ajax/webcron mode, and passing job classes explicitly (`occ background-job:worker ''`) bypasses it — which is exactly why manual testing can look fine while cron does nothing. +**Time sensitivity is what actually decides whether the job runs at all.** `OC\Core\Service\CronService::runCli()` reads `maintenance_window_start` (default `100`, i.e. unset) and, whenever the current UTC hour is outside `[start, start+4]`, calls `jobList->getNext($onlyTimeSensitive = true)` — time-insensitive jobs are then skipped for the other 20 hours of the day. This app shipped `TIME_INSENSITIVE` once: on an instance with a maintenance window configured (Nextcloud's admin overview nags admins into setting one) the job simply never ran, showing `last_run = 1970-01-01` in `occ background-job:list` indefinitely. Note this gate only exists in `runCli()`, so it doesn't apply to ajax/webcron mode, and passing job classes explicitly (`occ background-job:worker ''`) bypasses it — which is exactly why manual testing can look fine while cron does nothing. -**Changing the sensitivity in code does not fix an already-registered job.** `getNext()` filters on the `time_sensitive` *column* of `oc_jobs`, and `JobList::setLastRun()` is a one-way ratchet: +Being a plain `Job` makes that unreachable rather than merely declared. `getNext()` filters on the `time_sensitive` *column* of `oc_jobs`; `JobList::add()` doesn't write the column at all (it takes the `TIME_SENSITIVE` default), and the only place that ever lowers it is a one-way ratchet in `JobList::setLastRun()`: ```php if ($job instanceof TimedJob && !$job->isTimeSensitive()) { @@ -43,7 +54,9 @@ if ($job instanceof TimedJob && !$job->isTimeSensitive()) { } ``` -There is no `else`. Once a job has run while declaring itself insensitive, the row is stamped `TIME_INSENSITIVE` for good — and nothing resets it: `JobList::add()` only writes the column in its *insert* branch, and re-registering an existing job (app update, `app:enable`) takes the *update* branch, which leaves it alone. So after deploying the `TIME_SENSITIVE` change, an existing instance still needs its row fixed once: +There is no `else`, and a non-`TimedJob` never enters it. Note `setTimeSensitivity()`/`isTimeSensitive()` live on `TimedJob`, not on `Job` — so there is nothing to declare here, and nothing that can go wrong. + +**Rows written by an older version keep their stamp.** Once stamped `TIME_INSENSITIVE` it stays that way: re-registering an existing job (app update, `app:enable`) takes `add()`'s update branch, which leaves the column alone. An instance that ran the pre-v0.1.0-fix build still needs its row fixed once: ```sql UPDATE oc_jobs SET time_sensitive = 0 @@ -52,7 +65,7 @@ UPDATE oc_jobs SET time_sensitive = 0 or, without DB access, `occ background-job:delete ` followed by `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation` to get a freshly inserted row. Fresh installs are unaffected. -To debug this job on a server: `occ background-job:list --class ''` for its id and last run, then `occ background-job:execute --force-execute` to run it right now regardless of the interval. `occ background-job:worker` takes **job classes**, not an app id. +To debug this job on a server: `occ background-job:list --class ''` for its id and last run, then `occ background-job:execute --force-execute` to run it right now without waiting for cron. `occ background-job:worker` takes **job classes**, not an app id. ## Registration is declarative, not Bootstrap-based @@ -116,7 +129,7 @@ This replaced an earlier hand-made `RELEASE_TOKEN` repo secret. If the publish s - **UI**: `src/PersonalSettings.vue` (Vue 3, mounted from `templates/settings/personal.php` into the section registered by `lib/Settings/PersonalSection.php` + `lib/Settings/Personal.php`) talks to `lib/Controller/WorkflowController.php` (an `OCSController`) via `src/api.js`, hitting OCS routes declared in `appinfo/routes.php` (`/ocs/v2.php/apps/workflow_deck_automation/api/v1/...`). - **Storage**: `lib/Db/Workflow.php` (Entity) / `lib/Db/WorkflowMapper.php` (QBMapper) over the `wfda_workflows` table, created in `lib/Migration/Version1000Date20260813120000.php`. Filter fields (`filter_user_ids`, `filter_label_ids`) are stored as JSON-encoded arrays in text columns, not join tables. -- **Automation**: `lib/BackgroundJob/RunWorkflowsJob.php` (5 min `TimedJob`) → `lib/Service/WorkflowRunner.php` (per-user impersonation + filtering, both filters OR-within-themselves and AND-between-each-other) → `lib/Service/DeckIntegrationService.php` (all actual Deck class calls) and `lib/Service/NotificationMailer.php` (email via `OCP\Mail\IMailer` to the workflow owner's account address, never a manually-entered address). +- **Automation**: `lib/BackgroundJob/RunWorkflowsJob.php` (plain `Job`, one run per cron pass) → `lib/Service/WorkflowRunner.php` (per-user impersonation + filtering, both filters OR-within-themselves and AND-between-each-other) → `lib/Service/DeckIntegrationService.php` (all actual Deck class calls) and `lib/Service/NotificationMailer.php` (email via `OCP\Mail\IMailer` to the workflow owner's account address, never a manually-entered address). - Both the controller's board/stack/label/participant lookups (for populating the settings UI dropdowns) and the runner's card reads/moves go through the same `DeckIntegrationService` — there is no separate read path. ## Composer/npm lockfiles are intentionally not committed diff --git a/README.md b/README.md index 49e2bd4..b060f12 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder ## Architektur - **Keine HTTP/OCS-Aufrufe gegen Deck.** Alles Lesen (Boards, Stapel, Karten, Labels, zugewiesene Benutzer) und das Verschieben von Karten läuft ausschließlich über Decks eigene interne PHP-Klassen (`OCA\Deck\Service\CardService`, `StackService`, `BoardService`, `OCA\Deck\Db\CardMapper`, …), aufgelöst per Dependency Injection direkt im selben PHP-Prozess. Sämtlicher Deck-Zugriff ist in [`lib/Service/DeckIntegrationService.php`](lib/Service/DeckIntegrationService.php) gebündelt. -- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein `TimedJob`, der standardmäßig alle 5 Minuten läuft (abhängig vom Nextcloud-Cron-Intervall) und [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) aufruft. Das Intervall ist über die `config.php` einstellbar, siehe unten. -- **Rechte-Kontext je Nutzer.** Da Decks Berechtigungsprüfungen die aktuell eingeloggte Session lesen, ein Hintergrundjob aber standardmäßig keinen eingeloggten Nutzer hat und Regeln mehrerer Nutzer in einem einzigen Lauf auswerten muss, "verkörpert" der `WorkflowRunner` für die Dauer der jeweiligen Workflows kurzzeitig den entsprechenden Besitzer (`IUserSession::setUser()`), bevor die Deck-Klassen aufgerufen werden. +- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein einfacher `Job` ohne eigenes Intervall: Er läuft bei jedem Cron-Durchlauf von Nextcloud (üblicherweise alle 5 Minuten) und ruft [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) auf. +- **Rechte-Kontext je Nutzer.** Ein Hintergrundjob hat standardmäßig keinen eingeloggten Nutzer, muss aber die Regeln mehrerer Nutzer in einem einzigen Lauf auswerten. Der `WorkflowRunner` "verkörpert" deshalb für die Dauer der jeweiligen Workflows kurzzeitig den entsprechenden Besitzer. Dazu gehört mehr als `IUserSession::setUser()`: Decks Berechtigungsprüfungen lesen die Session gar nicht, sondern eine Benutzer-ID, die beim Bau des Deck-Containers einmal pro Prozess eingefroren wird. `DeckIntegrationService::beginUserContext()` setzt diesen Wert für die Dauer der Verkörperung auf den Workflow-Besitzer und stellt ihn danach wieder her; schlägt das fehl, wird der Nutzer in diesem Lauf übersprungen, statt unter fremden Rechten zu arbeiten. - **E-Mail** läuft über Nextclouds eigenen `IMailer` (nutzt also den in der Nextcloud-Administration hinterlegten Mailserver) und geht an die im Profil des Workflow-Besitzers hinterlegte Adresse. ### Wichtiger Hinweis zu Decks internen Klassen @@ -48,23 +48,15 @@ Ein Workflow verweist per ID auf ein Board und zwei Stapel. Wird eines davon in Ist Deck vorübergehend nicht erreichbar, wird **nicht** deaktiviert — der Job überspringt den Workflow und versucht es beim nächsten Lauf erneut. Archivierte Boards lösen die Deaktivierung ebenfalls nicht aus, sie sind nur in den Auswahlfeldern ausgeblendet. -## Konfiguration +## Ausführungstakt -Das Prüfintervall des Hintergrundjobs lässt sich in der `config.php` setzen (Wert in **Sekunden**): +Der Hintergrundjob hat **kein eigenes Intervall** und ist auch nicht konfigurierbar: Er läuft bei **jedem** Cron-Durchlauf von Nextcloud. Der Takt ist damit exakt der des System-Crons — üblicherweise alle 5 Minuten, bei einem häufiger eingerichteten Cron entsprechend öfter. -```php -'workflow_deck_automation.interval' => 300, -``` - -- Standard ohne Eintrag: `300` (5 Minuten). -- Minimum: `60` — kleinere Werte werden auf 60 Sekunden angehoben. -- Die Änderung greift beim nächsten Cron-Durchlauf; ein `occ`-Befehl oder eine Neuinstallation der App ist nicht nötig. - -Zu beachten: Das ist eine *Untergrenze* für den Abstand zwischen zwei Läufen, keine Garantie. Nextclouds Cron selbst läuft üblicherweise nur alle 5 Minuten — ein Intervall von 60 Sekunden führt also nur dann zu minütlichen Läufen, wenn der System-Cron entsprechend häufig ausgeführt wird. +> Bis einschließlich v0.1.0 gab es dafür die `config.php`-Option `workflow_deck_automation.interval`. Sie ist entfallen und wird ignoriert; ein vorhandener Eintrag kann aus der `config.php` entfernt werden. ### Der Job läuft nur nachts bzw. gar nicht -Der Job ist als `TIME_SENSITIVE` deklariert und läuft damit rund um die Uhr. Nextcloud merkt sich die Zeitsensitivität aber zusätzlich in der Spalte `time_sensitive` der Tabelle `oc_jobs`, und `JobList::setLastRun()` setzt sie nur in eine Richtung — von „zeitkritisch" auf „unkritisch", nie zurück. Wer eine ältere Version dieser App installiert hatte (bis einschließlich v0.1.0 war der Job `TIME_INSENSITIVE`), hat diesen Stempel noch in der Datenbank. Er wird weder durch ein App-Update noch durch `app:enable` zurückgesetzt. +Nextcloud merkt sich in der Spalte `time_sensitive` der Tabelle `oc_jobs`, ob ein Job zeitkritisch ist, und überspringt „unkritische" Jobs außerhalb des Wartungsfensters. `JobList::setLastRun()` setzt diese Spalte nur in eine Richtung — von „zeitkritisch" auf „unkritisch", nie zurück. Wer eine ältere Version dieser App installiert hatte (bis einschließlich v0.1.0 war der Job `TIME_INSENSITIVE`), hat diesen Stempel noch in der Datenbank. Er wird weder durch ein App-Update noch durch `app:enable` zurückgesetzt. Symptom: Ist in der `config.php` ein `maintenance_window_start` gesetzt, läuft der Job nur in diesem 4-Stunden-Fenster und den Rest des Tages gar nicht. @@ -111,7 +103,7 @@ Ein echter End-to-End-Test (Karte anlegen, Workflow konfigurieren, Hintergrundjo # Job-ID und letzten Lauf anzeigen php occ background-job:list --class 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob' -# Sofort ausführen, unabhängig vom Intervall +# Sofort ausführen, ohne auf den nächsten Cron-Durchlauf zu warten php occ background-job:execute --force-execute # Oder dauerhaft als Worker laufen lassen (Argument ist die Job-Klasse, keine App-ID) diff --git a/appinfo/info.xml b/appinfo/info.xml index 09404aa..edd380d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -28,7 +28,7 @@ This app lets every user configure their own Deck automation workflows from Pers A background job periodically evaluates all enabled workflows and moves cards whose due date has passed, using Deck's own internal PHP classes only (no HTTP/OCS calls). ]]> - 0.1.0 + 0.2.0 agpl Patrick Niebeling WorkflowDeckAutomation diff --git a/lib/BackgroundJob/RunWorkflowsJob.php b/lib/BackgroundJob/RunWorkflowsJob.php index bf17174..6f33b28 100644 --- a/lib/BackgroundJob/RunWorkflowsJob.php +++ b/lib/BackgroundJob/RunWorkflowsJob.php @@ -6,51 +6,47 @@ namespace OCA\WorkflowDeckAutomation\BackgroundJob; use OCA\WorkflowDeckAutomation\Service\WorkflowRunner; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJob; -use OCP\BackgroundJob\TimedJob; -use OCP\IConfig; - -class RunWorkflowsJob extends TimedJob { - /** - * config.php key holding how often (in seconds) workflows are checked. - */ - public const INTERVAL_CONFIG_KEY = 'workflow_deck_automation.interval'; - - public const DEFAULT_INTERVAL = 5 * 60; - - /** - * Nextcloud's cron itself only ticks every 5 minutes by default, so - * anything below a minute would only add load without running more - * often. Values under this are clamped rather than rejected. - */ - public const MINIMUM_INTERVAL = 60; +use OCP\BackgroundJob\Job; +/** + * Evaluates all enabled workflows, once per cron pass. + * + * Deliberately a plain `Job`, not a `TimedJob`: there is no interval to + * configure and none to compare against, so the cadence is exactly + * Nextcloud's cron cadence — every pass, no matter how often cron is set + * up to run. `TimedJob` with `setInterval(0)` would behave almost the + * same, but it keeps an interval concept around that nothing uses. + * + * **Time sensitivity still matters, and it comes for free here.** + * `OC\Core\Service\CronService::runCli()` reads `maintenance_window_start` + * (default `100`, i.e. unset) and, whenever the current UTC hour is outside + * `[start, start+4]`, asks `JobList::getNext($onlyTimeSensitive = true)`, + * which filters on the `time_sensitive` *column* of `oc_jobs`. A job stamped + * insensitive is then skipped for the other 20 hours of the day — this app + * shipped that way once and the job looked completely dead, showing + * `last_run = 1970-01-01` in `occ background-job:list` indefinitely. Note + * the gate only exists in `runCli()`, so ajax/webcron are unaffected and + * `occ background-job:worker ''` bypasses it, which is why manual + * testing can look fine while cron does nothing. + * + * A plain `Job` can never acquire that stamp: `JobList::add()` leaves the + * column at its `TIME_SENSITIVE` default, and the one place that ever + * downgrades it, `JobList::setLastRun()`, is guarded by + * `$job instanceof TimedJob && !$job->isTimeSensitive()` — with no `else`. + * Rows written by an older version of this app keep whatever they were + * stamped with, though, so an instance that ran the `TIME_INSENSITIVE` + * version still needs the one-off `UPDATE oc_jobs SET time_sensitive = 0` + * documented in the README. + */ +class RunWorkflowsJob extends Job { public function __construct( ITimeFactory $time, - IConfig $config, private WorkflowRunner $runner, ) { parent::__construct($time); - // TimedJob compares against this value on every cron pass, so a - // changed config.php takes effect on the next pass — no occ command - // and no re-registration of the job needed. - $this->setInterval(self::resolveInterval($config)); - // Must stay TIME_SENSITIVE. If `maintenance_window_start` is set in - // config.php — and Nextcloud's admin overview actively nags admins - // to set it — CronService only picks up TIME_INSENSITIVE jobs during - // those 4 hours and skips them for the rest of the day. A job whose - // whole point is a 5-minute (configurable down to 60s) reaction time - // would then run a handful of times per night and look completely - // dead in between. - $this->setTimeSensitivity(IJob::TIME_SENSITIVE); $this->setAllowParallelRuns(false); } - private static function resolveInterval(IConfig $config): int { - $interval = $config->getSystemValueInt(self::INTERVAL_CONFIG_KEY, self::DEFAULT_INTERVAL); - return max(self::MINIMUM_INTERVAL, $interval); - } - protected function run($argument): void { $this->runner->run(); } diff --git a/lib/Service/DeckIntegrationService.php b/lib/Service/DeckIntegrationService.php index e8be7ce..539bb7a 100644 --- a/lib/Service/DeckIntegrationService.php +++ b/lib/Service/DeckIntegrationService.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace OCA\WorkflowDeckAutomation\Service; +use OCA\Deck\Db\BoardMapper; use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\StackMapper; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\CardService; +use OCA\Deck\Service\PermissionService; use OCA\Deck\Service\StackService; use OCP\App\IAppManager; use OCP\AppFramework\Db\DoesNotExistException; @@ -16,6 +19,7 @@ use OCP\IUser; use OCP\IUserManager; use OCP\Server; use Psr\Log\LoggerInterface; +use ReflectionProperty; use Throwable; /** @@ -39,6 +43,36 @@ class DeckIntegrationService { private const ACL_TYPE_USER = 0; private const ACL_TYPE_GROUP = 1; + /** + * Deck's Acl::PERMISSION_READ, inlined for the same reason. It is the key + * under which PermissionService::getPermissions() reports read access. + */ + private const ACL_PERMISSION_READ = 0; + + /** + * The property through which Deck carries "the current user" — a plain + * string frozen at construction rather than a session lookup. See + * beginUserContext(). + */ + private const IDENTITY_PROPERTY = 'userId'; + + /** + * Further Deck classes holding that same frozen uid. Unlike + * PermissionService these do not decide access, only whose name ends up + * on an activity entry or whose unread-comment counts are computed, so + * failing to pin them is logged and shrugged off. + */ + private const OPTIONAL_IDENTITY_CLASSES = [ + CardService::class, + BoardService::class, + 'OCA\Deck\Activity\ActivityManager', + ]; + + /** + * @var list + */ + private array $identityRestore = []; + public function __construct( private IAppManager $appManager, private IUserManager $userManager, @@ -47,6 +81,87 @@ class DeckIntegrationService { ) { } + /** + * Pins Deck to $user until endUserContext() — mandatory before any Deck + * call made on behalf of someone who is not the logged-in web user. + * + * IUserSession::setUser() alone is not enough, and that is not obvious: + * Deck's PermissionService — the class behind *every* permission check, + * including the ones inside CardService::reorder() — receives the current + * user as `private ?string $userId`. The app container fills that from + * `ISession::get('user_id')` through a **shared** service, so Pimple + * computes it once and caches it for the rest of the process, and + * ServerContainer keeps the app container just as long. Whoever was + * active when Deck's container first built PermissionService is therefore + * the user every later check is evaluated against. + * + * In the background job that is never the user we are impersonating: it + * is `null` whenever a Deck-owned job ran earlier in the same cron pass + * (cron.php works through many jobs in one process), and otherwise the + * first workflow owner we happened to touch. `null` fails every check, + * which is how intact boards started reporting NoPermissionException and + * got their workflows switched off. + * + * Deck exposes no API for this, so the frozen value is swapped directly + * and put back in endUserContext(). PermissionService is required: if it + * cannot be pinned this throws, and the caller has to skip the user + * rather than run them under someone else's permissions. + * + * @throws DeckUnavailableException + */ + public function beginUserContext(IUser $user): void { + if ($this->identityRestore !== []) { + throw new DeckUnavailableException('A Deck user context is already active'); + } + + $uid = $user->getUID(); + + try { + $this->pinDeckIdentity($this->resolve(PermissionService::class), $uid); + } catch (Throwable $e) { + $this->endUserContext(); + throw new DeckUnavailableException( + 'Could not run Deck as ' . $uid . ': ' . $e->getMessage(), + 0, + $e, + ); + } + + foreach (self::OPTIONAL_IDENTITY_CLASSES as $class) { + try { + $this->pinDeckIdentity($this->resolve($class), $uid); + } catch (Throwable $e) { + $this->logger->debug('Could not pin ' . $class . ' to ' . $uid . ': ' . $e->getMessage(), [ + 'app' => 'workflow_deck_automation', + ]); + } + } + } + + /** + * Restores what beginUserContext() replaced. Idempotent, so it can be + * called unconditionally from a finally block. + */ + public function endUserContext(): void { + foreach (array_reverse($this->identityRestore) as [$property, $service, $previous]) { + try { + $property->setValue($service, $previous); + } catch (Throwable $e) { + $this->logger->warning('Could not restore Deck identity on ' . $service::class . ': ' . $e->getMessage(), [ + 'app' => 'workflow_deck_automation', + ]); + } + } + $this->identityRestore = []; + } + + private function pinDeckIdentity(object $service, string $uid): void { + $property = new ReflectionProperty($service, self::IDENTITY_PROPERTY); + $previous = $property->isInitialized($service) ? $property->getValue($service) : null; + $property->setValue($service, $uid); + $this->identityRestore[] = [$property, $service, is_string($previous) ? $previous : null]; + } + public function assertDeckAvailable(IUser $user): void { if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) { throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID()); @@ -59,12 +174,11 @@ class DeckIntegrationService { /** * @return array * - * Only ever call this from a *request*: Deck injects the current user id - * into BoardService as a plain string frozen at construction time, so in - * the background job (one process, many users, a container-cached - * BoardService) this would answer for the wrong user — or for none at - * all. The job uses findStackIds() instead, which goes through Deck's - * PermissionService and therefore reads the live session. + * Only ever call this from a *request*: getUserBoards() reads the uid + * BoardService froze at construction time, and beginUserContext() cannot + * help here — it repoints that field, but Deck may already have cached + * board lists behind it, and the job has no use for a board *list* + * anyway. It asks findStackIds() about one known board id instead. */ public function listBoardsForCurrentUser(): array { $boards = $this->call(function () { @@ -127,23 +241,46 @@ class DeckIntegrationService { * Ids of the board's stacks, for deciding whether a stored workflow still * points at anything real. * - * Returns `null` when the board itself is gone or not readable for the - * current user — `StackService::findAll()` runs a Deck permission check - * first, and that one reads the *live* session, which is what makes this - * usable from the impersonating background job. Any other failure throws, + * Returns `null` only on positive knowledge that the board is gone, in the + * trash, or no longer readable for `$userId`. Every other failure throws, * because "Deck is broken right now" must never be mistaken for "the user - * deleted this board". + * deleted this board" — the caller reacts by switching the workflow off + * and mailing its owner. + * + * Deliberately assembled from pieces that cannot answer for the wrong + * user. BoardMapper and StackMapper are plain QBMappers with no user + * state (StackMapper::findAll() filters `deleted_at = 0` itself), and + * getPermissions() is handed the uid explicitly instead of falling back + * to PermissionService's construction-time `$userId`. Leaning on that + * frozen value — which is exactly what `StackService::findAll()` does + * internally — is what disabled workflows whose board was perfectly + * intact, so this must not be "simplified" back to one service call even + * once beginUserContext() has pinned the identity. * * @return int[]|null * @throws DeckUnavailableException */ - public function findStackIds(int $boardId): ?array { + public function findStackIds(int $boardId, string $userId): ?array { try { - $stacks = $this->resolve(StackService::class)->findAll($boardId); + $board = $this->resolve(BoardMapper::class)->find($boardId); + if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) { + return null; + } + + $permissions = $this->resolve(PermissionService::class)->getPermissions($boardId, $userId); + if (!array_key_exists(self::ACL_PERMISSION_READ, $permissions)) { + // Unknown shape: say nothing rather than something wrong. + throw new DeckUnavailableException('Deck reported no read permission for board ' . $boardId); + } + if ($permissions[self::ACL_PERMISSION_READ] !== true) { + return null; + } + + $stacks = $this->resolve(StackMapper::class)->findAll($boardId); } catch (DeckUnavailableException $e) { throw $e; } catch (Throwable $e) { - if ($this->isMissingOrForbidden($e)) { + if ($this->isMissing($e)) { return null; } $this->logger->error('Could not list stacks of board ' . $boardId . ': ' . $e->getMessage(), [ @@ -160,17 +297,18 @@ class DeckIntegrationService { * Deck's own exception classes are referenced by name: `is_a()` with a * string simply returns false when the class does not exist, so a missing * or renamed Deck degrades to "unknown error" instead of fataling. + * + * `OCA\Deck\NoPermissionException` is deliberately *not* treated as "the + * board is gone" here. It used to be, and that is what switched intact + * workflows off: Deck raises it for any check that fails, including every + * check evaluated against the wrong frozen uid — which says nothing at + * all about whether the board still exists. */ - private function isMissingOrForbidden(Throwable $e): bool { + private function isMissing(Throwable $e): bool { if ($e instanceof DoesNotExistException) { return true; } - foreach (['OCA\Deck\NoPermissionException', 'OCA\Deck\NotFoundException'] as $class) { - if (is_a($e, $class)) { - return true; - } - } - return false; + return is_a($e, 'OCA\Deck\NotFoundException'); } /** diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php index 65b0617..016551d 100644 --- a/lib/Service/WorkflowRunner.php +++ b/lib/Service/WorkflowRunner.php @@ -18,12 +18,15 @@ use Throwable; /** * Evaluates all enabled workflows and moves overdue cards. * - * Deck's permission checks read the *live* user session, not a value - * frozen at construction, and this runner has to evaluate rules for many - * different users within a single background-job process. So workflows - * are grouped by owner and, for each owner, we temporarily impersonate - * that user (push their IUser onto the session, restore afterwards) — - * the standard Nextcloud pattern for multi-user cron jobs. + * This runner has to evaluate rules for many different users within a + * single background-job process, so workflows are grouped by owner and + * each owner is impersonated in turn. Impersonation here means two + * things, and the second one is the load-bearing half: pushing the IUser + * onto the session (the standard Nextcloud cron pattern), *and* pinning + * Deck itself to that user via + * DeckIntegrationService::beginUserContext(), because Deck's permission + * checks never consult the session — they read a uid frozen once per + * process. Both are undone in a finally. */ class WorkflowRunner { public const TARGET_BOARD = 'board'; @@ -93,7 +96,20 @@ class WorkflowRunner { return; } - $this->impersonate($user); + try { + $this->impersonate($user); + } catch (DeckUnavailableException $e) { + // Deck could not be pinned to this user, so every permission + // check would be answered for somebody else. Skipping costs one + // run; continuing would move the wrong cards or, worse, read a + // denial as "the board is gone" and disable a working workflow. + $this->logger->error('Could not impersonate {user} towards Deck: ' . $e->getMessage(), [ + 'app' => 'workflow_deck_automation', + 'user' => $userId, + ]); + return; + } + try { foreach ($workflows as $workflow) { // Same reasoning as in run(): one broken workflow must not @@ -115,7 +131,7 @@ class WorkflowRunner { private function runWorkflow(IUser $user, Workflow $workflow): void { try { - $brokenTarget = $this->findBrokenTarget($workflow); + $brokenTarget = $this->findBrokenTarget($user, $workflow); } catch (DeckUnavailableException $e) { // Could not determine it either way — leave the workflow alone // rather than disabling it over a temporary Deck problem. @@ -173,8 +189,8 @@ class WorkflowRunner { * @return self::TARGET_*|null * @throws DeckUnavailableException if Deck could not be asked */ - private function findBrokenTarget(Workflow $workflow): ?string { - $stackIds = $this->deckService->findStackIds($workflow->getBoardId()); + private function findBrokenTarget(IUser $user, Workflow $workflow): ?string { + $stackIds = $this->deckService->findStackIds($workflow->getBoardId(), $user->getUID()); if ($stackIds === null) { return self::TARGET_BOARD; } @@ -257,6 +273,9 @@ class WorkflowRunner { return true; } + /** + * @throws DeckUnavailableException if Deck cannot be pinned to $user + */ private function impersonate(IUser $user): void { $this->impersonationRestore = $this->userSession->getUser(); $this->userSession->setUser($user); @@ -269,9 +288,20 @@ class WorkflowRunner { 'app' => 'workflow_deck_automation', ]); } + + try { + // The session switch above is necessary but nowhere near + // sufficient — Deck's permission checks never look at it. See + // DeckIntegrationService::beginUserContext(). + $this->deckService->beginUserContext($user); + } catch (DeckUnavailableException $e) { + $this->clearImpersonation(); + throw $e; + } } private function clearImpersonation(): void { + $this->deckService->endUserContext(); $this->userSession->setUser($this->impersonationRestore); $this->impersonationRestore = null; } diff --git a/package.json b/package.json index 42fc7c1..d6b05fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workflow_deck_automation", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "description": "Automates moving overdue Deck cards between stacks, configurable per user.",