Skip to content

All Companies Ticket View Developer Guide

Ed Mozley edited this page Sep 24, 2026 · 2 revisions

πŸ› οΈ All companies ticket view β€” Developer Guide

The under-the-hood work behind the consolidated ticket board, and the one shortcut that would have turned it into a data leak. Shipped as #1554–#1558 in 1.5.0.

The user-facing page is One board across every company.


1. ⭐ The reframing that made this tractable

FreeITSM already had two separate company concepts, and they were already correctly separated. That is the whole reason this was a contained change rather than a rewrite.

Answers Type Where
ActorContext::companyScope What am I allowed to see? ?array<int> β€” null = all includes/service_context.php
getActiveTenantId() Which one am I looking at? int includes/tenancy.php

The permission side was already multi-company, already enforced in the service layer, and already exercised by the REST API β€” which has always been able to return tickets across companies.

So this was never "build cross-company tickets". It was "let the VIEW say all, where the PERMISSION already can."


2. πŸ”΄ The shortcut that would have been a leak

The tempting implementation is to put a reserved value β€” 0, or -1 β€” in $_SESSION['active_tenant_id'] to mean "all". Do not.

getActiveTenantId() is declared : int, and it is read by:

  • 28 ticketTenantFilter() call sites
  • ~61 files through activeTenantFilter()

Every one of them would have silently changed meaning at once β€” and the direction they would have failed in is showing more than they should.

So "all" is its own session flag:

function isActiveTenantAll(PDO $conn): bool {
    if (!isMultiTenant($conn)) return false;
    return !empty($_SESSION['active_tenant_all']);
}

getActiveTenantId() is completely unchanged. It still answers "which one company am I working in" β€” which is what a write resolves against, and it has to keep naming a real company while the view is widened. Only readers that opt in widen. At first that was exactly one function; see Β§10 for the readers that have opted in since.

setActiveTenantId() clears the flag, so picking a company always leaves the combined view. Without that, choosing a school from the switcher would appear to do nothing.


3. πŸ”΄ "All" is an explicit id list, never an absent filter

if (!$forceSingle && isActiveTenantAll($conn)) {
    $ids = array_values(array_unique(array_map('intval',
        getAccessibleTenantIds($conn, $analystId))));

    // πŸ”΄ FAIL CLOSED. An empty scope is an analyst with no companies β€”
    // not "everything" β€” and `IN ()` is not even valid SQL.
    if (!$ids) return [" AND 1 = 0", []];

    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    if (in_array(getDefaultTenantId($conn), $ids, true)) {
        return [" AND ($col IN ($placeholders) OR $col IS NULL)", $ids];
    }
    return [" AND $col IN ($placeholders)", $ids];
}

Returning ['', []] here would have been the one-line disaster: that is the value the function returns when multi-tenancy is dormant, so every caller would read it as "no filtering needed" and hand every company's tickets to everybody.

That is the same shape as an earlier live leak in the requester picker, where the count was scoped and the list was not.

The NULL rule is preserved, not relaxed. An unrouted ticket (tenant_id IS NULL) belongs to Default by convention, so it is included only when Default is actually in scope β€” exactly as the single-company branch has always done.


4. πŸ—‘οΈ $forceSingle, and why deleting is different

api/tickets/empty_trash.php shares ticketTenantFilter() with every ticket list. Widening that filter for the consolidated view would have turned one button into a permanent deletion across every company at once.

list($ttSql, $ttParams) = ticketTenantFilter($conn, $analystId, 't', true);  // true = one company only

An explicit parameter, not a session toggle around the call. The first attempt flipped setActiveTenantAll(false) before the call and back afterwards β€” which happens to work, because these endpoints use read_and_close sessions so nothing persists, but relying on that is far too clever for a destructive path.

Reading wider is the point of a combined board. Deleting wider is not, and it cannot be undone.


5. 🧩 The lookup lists stop being a property of the page

This was the largest single piece of work, and the one the requester's own list of worries correctly predicted.

Ticket types, origins, categories and resolution codes are per-company β€” the "global default + the company's own + the company may hide a global" model. Loaded once for the active company, as they always were, they are correct in every view except a combined board, where the page holds several companies' tickets at once.

The failure was not a leak. It was worse in a subtler way: the reading pane would have offered School A's type list on School B's ticket, and let you save it.

The fix

In the combined view the three endpoints also return a map keyed by company id, and every picker resolves against the ticket's company:

function listForTicket(byCompany, fallback, email) {
    if (!allCompaniesView || !byCompany) return fallback;
    const id = ticketCompanyId(email);
    if (id == null) return fallback;
    return byCompany[id] || byCompany[String(id)] || fallback;
}

⚠️ Resolved per company, never flattened

getTenantConfigRowsByCompany() calls getTenantConfigRows() once per company rather than building one union. That is not laziness β€” a company can HIDE a global default, so the same row belongs in one company's list and not another's. A flattened union would quietly re-offer what somebody deliberately hid.

The switches travel too

Whether the category field appears at all is a per-company answer, so a board holding three schools' tickets can legitimately show it on one and not the next one down the list. settings_by_company carries that.

What needed nothing

Statuses, priorities and departments are install-wide β€” no tenant_id, no getTenantConfigRows(). Checking that first cut the problem from five lookups to three.


6. βž• Creating a ticket

The form asks which company, and api/tickets/create_ticket.php re-checks the answer:

if (isActiveTenantAll($conn) && !empty($input['tenant_id'])) {
    $wanted = (int) $input['tenant_id'];
    if (!analystCanAccessTenant($conn, $analystId, $wanted)) {
        echo json_encode(['success' => false, 'error' => 'You do not have access to that company']);
        exit;
    }
    $tenantId = $wanted;
}

πŸ”΄ A dropdown is not a check. tenant_id arrives in a JSON body and can be any integer. Without this an analyst scoped to one school could file a ticket into another's queue β€” and it would look entirely legitimate once it landed. The same reasoning as the requester guard directly above it in that file.


7. πŸ“š Knowledge and Ask AI

KnowledgeViewer::forAnalyst() resolved the active company and wrapped it in scopeForOneCompany(). On a combined board that would have answered a question about one school's ticket out of another school's articles, silently.

It now passes the full accessible list, reusing the array shape forApiKey() has always had β€” so it is not a new privilege, just the same set the switcher would have given one company at a time.

πŸ”‘ In Knowledge, tenant_id IS NULL means shared with every company β€” unlike tickets, where NULL means Default's. includes/tenancy.php carries a "READ THIS BEFORE SIMPLIFYING IT" warning about exactly that difference. An installation that keeps all its knowledge global is unaffected either way.

⚠️ api/knowledge/ai_chat.php still carries its own copy of the embedding and cosine code; only the visibility half was ever consolidated. Two retrieval paths, one shared scope.


8. πŸ“¬ What needed nothing (and why that was surprising)

The obvious worry is mail: if you are viewing all companies, do all mailboxes need polling?

No β€” the poller was already company-blind. api/tickets/get_mailboxes.php has no tenant filter at all, so the browser poll in tickets/index.php has always fetched every company's mailbox regardless of the active company.

That does surface a genuine, separate problem, which is not about multi-company at all:

  • Mail only arrives while somebody has a browser open. Nobody in on a Sunday, no tickets.
  • Every open browser runs the whole loop independently β€” five analysts is five times the IMAP/Graph traffic per minute against the same mailboxes.
  • There is no mail cron. cron/ has eight jobs and none of them touch mail.

Parked for a later release as its own feature. The framework to copy is cron/sla_breach_check.php β€” shared-secret ?token= with per-IP lockout β€” plus scripts/cron_token.php.

Outbound mail needed nothing either. getMailboxForTicket() resolves from the ticket's own initial email (ORDER BY is_initial DESC, received_datetime ASC), never from session state, so a reply already follows the ticket.


9. βœ… How it was verified

A feature like this cannot be signed off on "it didn't error". Every "cannot see X" was paired with a positive control proving the thing still works.

Check Result
Restricted analyst (1 company of 4), combined view 3 tickets of 117
…and a positive control They do see their own three
NULL rows for that analyst Excluded β€” Default is not in their scope
Single-company views sum to the combined one 105 + 2 + 3 + 2 = 112 exactly
Create into a company out of scope Refused
Create into their own company Accepted
$forceSingle on the destructive path Binds exactly one id
getActiveTenantId() in combined view Still returns a real company id

The lookup-list fix was proved by giving one company a ticket type of its own, then opening two tickets from different companies on the same screen: the type appears on one and not the other.

⚠️ Note for future testing: inbox.js state lives in top-level lets, which are not properties of window. A harness reading w.ticketTypesByCompany gets undefined while the data is perfectly fine. Verify through the API or the DOM.


10. πŸ“‹ Deliberately out of scope

  • ticketTenantFilter() widens, and since 2.6.0 so does activeTenantReadFilter() β€” the opt-in for other LIST READS. It shares one copy of the "All" predicate with the ticket filter (allAccessibleTenantsFilter(): an explicit id list, fail closed, NULL only when Default is in scope). Opted in: api/tickets/get_users.php (Tickets β†’ Users) and api/tasks/list.php (the Tasks board), the two a customer reported. πŸ”΄ Reads only: activeTenantFilter() is also used by updates, deletes and the task board reorder, which must keep acting on one company, so it is not widened. Board reorder under All companies therefore saves positions only for the last-selected company's tasks. The other ~70 calls have not been reviewed for opt-in; do them one at a time.
  • The asset list does not widen: its lookups are single-company. See Moving an asset between companies - Developer Guide.
  • Bulk actions across companies are not specifically hardened beyond the per-ticket service rules that already apply.
  • The mailbox cron (Β§8).

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally