Skip to content

Moving a Task Between Companies Developer Guide

Ed Mozley edited this page Sep 24, 2026 · 1 revision

Moving a task between companies β€” developer guide

Tasks were already a completed Phase-3 module (progress tracker); this is a move operation built on top of that tenancy, not new scoping.

User-facing page: Moving a task between companies.


The shape, and where it came from

The progress tracker already prescribes what a move should look like, in its note on why moving a CMDB item was deferred:

move the CI and its descendants as a unit, refuse (with a clear list) if any relationship or object_ref would end up straddling companies, and audit it

TasksService::moveTaskToCompany() is that, for tasks:

  • the task and every task beneath it move in one transaction;
  • a move that would leave a link straddling companies is refused, naming the link;
  • every task that moved gets its own task_audit row, so opening a subtask later explains why its company changed without having to know the parent was the thing moved.

taskAndDescendantIds() walks the tree iteratively rather than with a recursive CTE β€” MySQL 5.7 has none and this product still supports it. The visited set is not decoration: a parent chain that somehow looped would otherwise spin for ever, and a bad row should not be able to hang a request.


πŸ”΄ The scope check that looked correct indefinitely

The first version checked analystCanAccessTenant($conn, $ctx->actorId, $target) and nothing else. That is what the person can reach, not what this caller may touch.

For a signed-in analyst the two always agree, because ActorContext::fromSession() builds the scope from that same access β€” so it would have looked correct for ever. They come apart for an API key issued for one company, whose holder may well have wider access: that key could have moved work into a company it was deliberately not given.

Both checks now, scope first:

if ($ctx->companyScope !== null && !in_array($targetTenantId, $ctx->companyScope, true)) { ... }
if (!analystCanAccessTenant($conn, $ctx->actorId, $targetTenantId)) { ... }

createTask() already checked the scope this way in resolveNewTaskTenant(); leaving move to check only the other made the two disagree about the same question.

Found only because the test ran with a deliberately narrowed companyScope rather than the admin context everything else used. It was the one case that failed.

πŸ”΄ Not every linked table has a company

contracts has no tenant_id column at all β€” contracts are deliberately not company-scoped. Asking one which company it is in threw Unknown column 'tenant_id' in 'field list' and the move failed outright.

That was not hypothetical. On a real install 19 of the top-level tasks were contract-linked and 2 were ticket-linked, so the contract path was the common one. The first test picked a ticket-linked task, passed, and left the common case broken β€” "it passed" meant "it passed on 2 rows out of 21".

tableHasTenantColumn() asks information_schema at runtime rather than hardcoding "contracts have no company", so if they ever gain one this starts enforcing it with nothing for anybody to remember. A record with no company cannot constrain the move, so the link is simply not a reason to refuse.

A subtask cannot be moved alone

Refused in the service. The menu also hides the item on a subtask β€” but note that no view that shows subtasks currently has a right-click menu: the board, table and timeline all exclude subtasks, and the tasks calendar, which can show them, has no menu. So the menu-hiding is defensive and currently unreachable; the service refusal is what does the work.

The two entry points

  • The detail panel (assets/js/tasks.js) β€” a Company select beside Team. Not a saveField() call: this is its own endpoint, it moves subtasks, and it can be refused, so the select restores its previous value when the server says no. data-previous is seeded in the markup, or the first refusal β€” the one anybody hits first β€” would leave it showing a company the task was never moved to.
  • The context menu (assets/js/tasks-ctx-menu.js) β€” intercepted before the ordinary field-save path.

Tickets settle the question of where this belongs: Pitfalls tells somebody to re-file a misrouted ticket using the Company picker in the reading-pane properties panel. Right-click alone follows Change Management, which is the other convention.


Still open

api/tasks/projected.php β†’ TaskRecurrence::project() has no company filter, so the calendar's projected occurrences of fixed-schedule repeats return every company's task titles. It is the perimeter case β€” a UI-only direct query the service gate never covers. Latent rather than live: it needs repeating tasks in two companies.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally