-
Notifications
You must be signed in to change notification settings - Fork 15
Multi Tenancy Isolation
Part of the Multi-tenancy section. Built β on
main. Covers how one company's data is kept separate from another's, and the audit that closed the gaps before it was trusted for real multi-company use.
The single biggest risk in any multi-tenant system is one client seeing another client's data. This page explains exactly how FreeITSM prevents that at the tickets layer, the class of bug we were guarding against, and the three-pass audit that found and fixed the holes.
The boundary between companies is not encryption β it's a tenant_id filter applied to every query that touches company-scoped data, routed through one shared choke-point (includes/tenancy.php) so there's a single place to get it right. Two primitives do the work:
Returns a SQL fragment that scopes a tickets query to the analyst's active company:
- Multi-company:
AND (t.tenant_id = ? OR t.tenant_id IS NULL)for the Default company (which also owns un-routed tickets), orAND t.tenant_id = ?for a client company. - Single-company install (N=1): returns an empty string and no parameters β a complete no-op. This is the load-bearing guarantee: with one company, none of this machinery does anything, so single-company installs behave exactly as before.
Used by the ticket list, the folder counts, search, the scheduled-work calendar, the dashboard widgets, and the per-user ticket count.
Some endpoints act on one specific ticket chosen by id (open a ticket, send a reply, add a note, delete it). A list filter doesn't help here β you need an access check. This helper answers "may this analyst touch this ticket?":
-
trueon a single-company install (nothing to isolate). - otherwise
trueonly if the ticket's company is in the analyst's accessible set (an all-access analyst sees every company; others only those granted to them). - unknown ticket id β
false(so a probe can't even confirm a ticket exists in another company).
A ticket with tenant_id NULL (an un-routed ticket) is treated as Default-owned, so access follows the Default company.
List view β
ticketTenantFilter(active company). Act on one record by id βanalystCanAccessTicket(accessible set). Anything reached by a child id (an email, an attachment, a note, a time entry) is resolved back to its parent ticket first, then access-checked.
The feature was rolled out in slices: the ticket list and counts were scoped first. But a service desk reaches a ticket's data through many endpoints β the email thread, attachments, notes, time entries, the audit trail, SLA state, CSAT, plus every action (assign, reply, schedule, deleteβ¦). Any one of those that took a ticket id (or a child id) without an access check was an IDOR β Insecure Direct Object Reference: an analyst in Company A could, by changing an id in a request, read or modify Company B's data.
Crucially, none of these are visible or exploitable on a single-company install (there's only one company, so nothing to leak). They only matter the moment a second company exists β which is precisely why they had to be found and closed before anyone runs a real multi-company setup, not after.
Rather than assume the rollout was complete, the change was audited in independent passes before merging to main. Each pass deliberately cast a wider net than the last β and each one found real gaps the previous sweep had missed. That progression is the interesting part:
Two independent reviews of the whole branch confirmed two things: (a) single-company (N=1) behaviour was byte-for-byte unchanged, and (b) the already-scoped paths (list, counts, triage) had no holes β including the trickiest query, the folder-count aggregation, where the tenant fragment lands in a LEFT JOIN ... ON clause and the bound parameters must be ordered ON-clause-before-WHERE. It also surfaced the category of remaining work: older ticket endpoints that predated the feature and weren't yet scoped.
After scoping the obvious list reads and the first batch of by-id endpoints, a verification pass swept all of api/tickets and found five more mutation-by-id endpoints with no guard β most seriously delete_ticket, which would let an analyst delete another company's ticket (and cascade-delete its emails, attachments, notes and audit). Also unguarded: schedule_ticket, save_note, save_time_entry, log_ticket_audit.
The id-based sweeps keyed on ticket_id. A third pass looked instead at endpoints reached by a different identifier, and found a fresh batch:
-
get_attachment(High severity) β served an email attachment's file content by attachment id, with no check. An analyst could fetch another company's attachments by enumerating ids. Fixed by resolving attachment β email β ticket and then access-checking. -
debug_get_attachmentβ same leak, plus it dumped SQL, server paths and directory listings (a debug artifact that shouldn't be in production). -
get_ticket_sla/get_tickets_sla_batchβ returned SLA timing/priority for any ticket id (the batch one looped over a caller-supplied list). -
request_csatβ could trigger a satisfaction-survey email to another company's requester.
A final pass enumerated every endpoint in api/tickets and classified each: does it touch a specific ticket or a child row (emails, attachments, notes, time entries, audit, CMDB links, recordings, SLA, CSAT), and is it guarded? It also chased indirection (any child id must resolve to its parent ticket before the check) and the usual suspects (recordings, mailbox lookup, reply-body builder, by-ticket_number lookups). Verdict: fully isolated β no remaining ticket-data-by-id leak. That's when it merged.
| Kind of endpoint | Fix |
|---|---|
| List / aggregate reads (search, user tickets, scheduled, dashboard widgets, per-user count) | Append ticketTenantFilter() to the query; merge its parameters in the correct positional order |
| Detail & child-data reads by id (email detail, thread, notes, attachments list, audit, CMDB links, time entries) |
analystCanAccessTicket() guard right after the DB connection; child ids resolved to their ticket first |
| Mutations by id (assign, owner, send, AI cleanup, schedule, delete, note, time, audit, CMDB link/unlink) | Same analystCanAccessTicket() guard before acting; forbidden/unknown β "not found" |
| Attachment serving | Resolve attachment β email β ticket, then guard |
| Per-ticket SLA / CSAT | Guard the ticket id (the batch endpoint silently skips ids the analyst can't access) |
| Triage filing (write-auth) |
analystCanAccessTenant() β can't file a triaged ticket into a company you can't access |
A subtlety worth recording: the dashboard-widget endpoint builds one shared WHERE reused by ~15 aggregate queries, so the tenant condition is injected once where that WHERE is assembled β every widget inherits it, and the parameter order stays correct across the categorical, time-series and created-vs-closed query shapes.
Isolation was completed for the tickets module. Other modules that read the tickets table are Phase 3 work and are tracked, not forgotten β they only matter once a second company exists:
- Self-service portal (requester-facing β needs its own tenancy model)
- CMDB β tickets cross-reads, Tasks β ticket links
- Reporting / cron machinery: Watchtower queries, the SLA breach cron, the workflow engine, CSAT and templated-email senders
There's also a small non-tenancy cleanup outstanding: the debug_get_attachment diagnostic still discloses server paths and should simply be removed.
The mechanism is simple β one filter for lists, one access check for by-id β but completeness is everything: a single missed endpoint is a leak. The value of auditing in widening passes (rather than trusting the first sweep) is concrete here: passes 2 and 3 each caught real, exploitable gaps β including a cross-company delete and an attachment-content read β that a single look would have shipped.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)