-
-
Notifications
You must be signed in to change notification settings - Fork 27
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.
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."
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.
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.
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 onlyAn 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.
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.
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;
}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.
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.
Statuses, priorities and departments are install-wide β no tenant_id, no getTenantConfigRows(). Checking that first cut the problem from five lookups to three.
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.
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 NULLmeans shared with every company β unlike tickets, where NULL means Default's.includes/tenancy.phpcarries 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.
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.
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.
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.
-
ticketTenantFilter()widens, and since 2.6.0 so doesactiveTenantReadFilter()β 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) andapi/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).
- One board across every company β the user-facing page
- Multi-Tenancy β Scope Β· Isolation Β· Pitfalls
- Ticket categories β Developer Guide
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96