Tie workflows to the board owner instead of their creator
Build package / php-lint (8.4) (push) Successful in 2m22s
Build package / php-lint (8.5) (push) Successful in 57s
Build package / xml-lint (push) Successful in 18s
Build package / unit-tests (push) Successful in 1m5s
Build package / package (push) Successful in 1m21s

A workflow now belongs to whoever owns its board. The settings page
lists, edits and deletes workflows by board ownership alone, using
Deck's BoardMapper::findAllByOwner(), a single owner query that never
returns shared boards and carries no frozen uid. A transferred board's
workflows therefore show up for the new owner immediately.

The background job still needs one user to run as and mail, so it
looks up the board owner before every run and stores it in user_id
when it changed. Archived boards stay listed so their workflows keep
a name, but are no longer offered or accepted as a target.
This commit is contained in:
Patrick Niebeling committed 2026-09-30 09:38:11 +02:00
1 parent ada16cb101
commit e84123654e
6 files changed
+185 -72

No files matched your search

+10 -5
View File
@@ -25,7 +25,7 @@ Both are undone in a `finally` (`WorkflowRunner::clearImpersonation()`).
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.
Consequence for board lists: the app does not use `BoardService::getUserBoards()` at all (it reads that frozen field). `listOwnedBoards()` goes through `BoardMapper::findAllByOwner($uid)` instead, which takes the uid explicitly; the job still has no use for a board *list* and 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 logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`, `::findDeadFilters()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerFilterTest.php`. Keep new pure logic in that shape; it is the only part of this app that has tests at all.
@@ -40,11 +40,16 @@ The filter logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`, `::fi
Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns.
## Workflows only on boards the user owns
## A workflow belongs to its board's owner, not its creator
A workflow can only be created or saved on a board whose Deck owner is the current user. `DeckIntegrationService::listBoardsForCurrentUser($uid)` returns **only owned boards** (`unwrapUid($board->getOwner()) === $uid`); boards merely shared with the user are dropped. Since `WorkflowController::validate()` checks the board id against that list, the same filter enforces the rule server-side — there is no separate ownership check.
The relation is **board owner ↔ workflow**, not creator ↔ workflow. When Deck transfers a board, its workflows move to the new owner.
Workflows created before this rule may still sit on a shared board. The runner does not check ownership, so they keep running; in the settings table their board is missing from the list, so the row is marked "Board nicht mehr vorhanden oder nicht dein eigenes" and saving it requires picking an owned board.
- **Which boards count as "mine"**: `DeckIntegrationService::listOwnedBoards($uid)` → Deck's `BoardMapper::findAllByOwner($uid)`, one `WHERE owner = :uid` query — shared boards never come back, and as a plain QBMapper it carries no frozen uid. Trashed boards are dropped; archived ones are kept with `archived: true` so their workflows stay visible, but they're hidden from the board dropdown and rejected by `validate()`. It *throws* rather than returning `[]`, since `[]` would silently hide all of the user's workflows.
- **Listing/editing/deleting** is purely `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` plays no part, so a transferred board's workflows show up for the new owner immediately — no cron pass needed. Workflows whose board is gone disappear from the UI; the runner disables them and mails the last owner.
- **Creating/saving** validates the board against the same list. `create()`/`update()` set `user_id` to the current user.
- **The runner** needs a single user to impersonate, so it calls `WorkflowRunner::followBoardOwner()` → `DeckIntegrationService::findBoardOwner()` (plain `BoardMapper::find()`) before grouping: a known owner different from `user_id` rewrites and persists it, so the run is impersonated as — and mails go to — the current owner. A gone board keeps its last owner, so `findBrokenTarget()` can still disable it and mail them.
`user_id` is therefore only "who the job runs as / who gets mail", refreshed from the board owner on every run — not the source of truth for who sees a workflow. Don't reintroduce `user_id` lookups in the controller.
## Dead filters are surfaced, never disabled
@@ -188,7 +193,7 @@ Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\Relationa
Two more Deck facts that bit us in the same pass: a board's ACL does **not** contain its owner (a private board has an empty ACL, so participants must be seeded with `$board->getOwner()`), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`.
**`BoardService::getUserBoards()` defaults to `$includeArchived = true`, and that flag also gates the `deleted_at = 0` condition** — with the default you get archived *and* trashed boards back. We call `getUserBoards(null, false)` (with a fallback to the no-arg form if Deck ever changes the signature) and still dedupe by id afterwards, because it merges own/group/circle boards and can return the same board twice. Stacks need no such handling: `StackMapper::findAll()` filters `deleted_at = 0` unconditionally and stacks have no archived state.
**`BoardMapper::findAllByOwner()` has no archived/`deleted_at` filter at all** — trashed and archived boards come back too, which is why `listOwnedBoards()` drops trashed ones and flags archived ones itself. (`BoardService::getUserBoards()`, used earlier, merges own/shared/group/circle boards and is no longer used.) Stacks need no such handling: `StackMapper::findAll()` filters `deleted_at = 0` unconditionally and stacks have no archived state.
## Local-only notes