-
Notifications
You must be signed in to change notification settings - Fork 15
Multi Tenancy Developer Guide
How to make a module β or a single settings tab β companyβaware. This is the engineering recipe behind MultiβTenancy: the exact helpers, the two data shapes, and a copyβpasteβandβadapt path proven across Tickets, Changes, Problems and Asset Management. If you're adding a new module or retrofitting an existing one, start here.
The prime directive: invisible at N=1. Every change below is gated so that on a singleβcompany install the behaviour is byteβidentical to before. You never ship a "turn on multiβtenancy" switch β the machinery simply lies dormant until a second company exists. Get this right and the feature is safe to roll out moduleβbyβmodule.
Multiβtenancy touches three kinds of table, and they use different β sometimes opposite β conventions for tenant_id. Getting this distinction right up front saves you every time. Always ask first: does NULL here mean the Default company, everyone, or nobody in particular?
| Scoped data | Config list | Connection / intake | |
|---|---|---|---|
| Examples | tickets, assets, changes | ticket types, asset statuses, locations | mailboxes, messaging channels, web chat widgets |
tenant_id meaning |
the one company that owns this row |
NULL = a global default shared by all; set = one company's own |
NULL = shared intake; set = pinned to one company |
NULL means |
"the Default company" (unrouted / preβmigration) | "everyone sees it" | "serves every company β route each message by its sender" |
| Model | one owner, hard isolation | global default + perβcompany add/hide | pinned or shared (a property of the row, never a mode toggle) |
| Read helper | activeTenantFilter() |
getTenantConfigRows() |
none β the list is deliberately installβwide |
| Write gate | analystCanAccess<X>() |
ownership check in save_/delete_
|
analystCanAccessChannel() + analystCanAssignTenant()
|
A row of scoped data belongs to exactly one company and must never leak to another. A config list is a menu of options a scoped row points at; a company inherits the shared defaults and may add its own or hide ones it doesn't want.
A connection is different again: it is the plumbing by which work arrives, and it's administered centrally by the MSP. Its list is intentionally not filtered β an admin configuring inbound routing needs to see every mailbox and channel at once, exactly as api/tickets/get_mailboxes.php has always done. What is gated is writing: you may only pin a connection to a company you can reach, and you may only edit or delete one that is already yours.
β οΈ The trap.NULLon a connection does not mean "the Default company owns it". Reaching foractiveTenantFilter()here β which treatsNULLas Defaultβowned β would hide every sharedβintake channel from every client company and silently break perβsender routing. Shared intake is nobody's property, so anyone holding the relevant capability may administer it. That's three distinct meanings ofNULLin one codebase: assume nothing, and read the table's own comment before you scope it.
A read that returns credentials is the exception. Capabilities generally guard writes, not reads β but a read handing back secrets needs the same care as a write. Before leaving any connection list unfiltered, check what it actually serialises: api/messaging/get_channels.php returns a has_credentials boolean and never the secret itself, which is what makes an installβwide list defensible there.
Where the isolation boundary sits. Company routing for an inbound message is a hardcoded synchronous membrane (resolveTicketTenantForEmail() / resolveTicketTenantForChannel()). It is the isolation boundary and must never become a workflow rule, or you open a crossβclient leak window. Everything downstream of it (department, priority, assignment) is safe to be ruleβdriven.
The metaβprinciple (the most important design rule): never add an installβwide "mode" toggle. Flexibility lives in the row β a config list is "global default + perβcompany override," so an MSP that never overrides feels fullyβglobal and one that overrides per client feels fullyβperβcompany, with no switch to flip.
Everything routes through a handful of shared helpers. Learn these six and you can scope anything.
isMultiTenant($conn) // false at N=1 β every helper below no-ops
getActiveTenantId($conn, $analystId) // the analyst's current company context
getDefaultTenantId($conn) // the silent Default company's id
// SCOPED DATA β a WHERE fragment for a list query. ['',[]] at N=1.
[$sql, $args] = activeTenantFilter($conn, $analystId, 'a'); // 'a' = table alias
// β " AND (a.tenant_id = ? OR a.tenant_id IS NULL)" for the Default company
// β " AND a.tenant_id = ?" for a client company
// SCOPED DATA β the per-row gate for a by-id read/write. true at N=1.
analystCanAccessTicket($conn, $analystId, $id) // + Problem / Change / Asset twins
// CONFIG LIST β resolves "global-not-hidden + this company's own".
getTenantConfigRows($conn, 'asset_types', 'asset_type', $tenantId, $cols, $where, $orderBy)
// CONNECTION β the write gates. NULL tenant_id = shared intake, so both
// deliberately ALLOW it: a shared channel belongs to no one company.
analystCanAccessChannel($conn, $analystId, $channelId) // may I administer this one?
analystCanAssignTenant($conn, $analystId, $tenantId) // may I pin something to this company?
The REST layer (api/v1/lib/auth.php) has the exact parallels, keyed on the API key's company scope instead of the analyst's:
[$sql, $args] = apiKeyTenantFilter($conn, $apiKey, 'a'); // list scope
apiKeyCanAccessTenantRow($conn, $apiKey, 'assets', $id) // by-id gate
apiKeyDefaultTenantId($conn, $apiKey) // company for a new rowEach helper is defensive by construction: singleβcompany β noβop; unknown id β refuse; a partβmigrated table (column not added yet) β don't block. That's what makes the rollout safe midβflight.
Worked example: giving assets a company. Six steps.
Add tenant_id INT NULL in both database/freeitsm.sql and the $schema array in api/system/db_verify.php (columns are autoβadded on Verify; UNIQUE keys + FKs are separate β add them in db_verify's index/FK sections too). For a data table the FK is ON DELETE SET NULL (never cascadeβdelete records when a company is removed); index the column for the scope filter.
`tenant_id` INT NULL, -- NULL = the Default company
KEY `idx_assets_tenant` (`tenant_id`),
CONSTRAINT `fk_assets_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULLAppend the filter to each list query β the UI endpoints and any dashboard/aggregate queries (they leak totals otherwise):
[$tSql, $tArgs] = activeTenantFilter($conn, (int)$_SESSION['analyst_id'], 'a');
$sql = "SELECT ... FROM assets a WHERE 1=1" . $tSql;
$params = array_merge($params, $tArgs);Add the module's analystCanAccess<X>() twin to tenancy.php (they're all the same 15 lines β singleβcompanyβtrue, unknown idβfalse, NULLβDefault), then guard each byβid endpoint. Don't forget the child reads β an asset's disks/software/history were fetchable by asset_id across companies until each got the gate:
if (!analystCanAccessAsset($conn, (int)$_SESSION['analyst_id'], (int)$assetId)) {
echo json_encode(['success' => false, 'error' => 'Asset not found']); // framed as not-found
exit;
}Whoever creates the row decides its company. For assets, ingest agents authenticate by API key, so the key carries the company (apikeys.tenant_id) and the row is stamped from it β see the identity wrinkle below.
If writes go through a shared service (Service Layer), gate there too so the API and UI are both covered. ActorContext carries a companyScope; refuse outβofβscope rows:
private static function assertScope(PDO $conn, ActorContext $ctx, array $row): void {
if ($ctx->companyScope === null) return; // all-access β no gate
$tid = $row['tenant_id'] ?? null;
$tid = ($tid === null) ? getDefaultTenantId($conn) : (int)$tid;
if (!in_array($tid, $ctx->companyScope, true)) {
throw new ServiceError('not_found', 'not_found', 'Not found.');
}
}Standing rule: a module that gets tenant_id also gets its api/v1/resources/<module>.php scoped β apiKeyTenantFilter on the list, apiKeyCanAccessTenantRow in the byβid loader, company_id+default on create, a company field in the serialiser, and the same for its reference lists. Update the OpenAPI SPEC and the REST-API-<Module> wiki page.
Worked example: asset types (mirror ticket types exactly β the canonical implementation). Four endpoints + a UI tweak.
tenant_id INT NULL (here NULL = a global default). Widen the nameβunique from (name) to (tenant_id, name) so a company may hold an option whose name matches a shared default. Globalβname dedup can't be enforced by that key (NULL rows aren't deβduped), so it's enforced in the API instead β that's deliberate. FK is ON DELETE CASCADE (a company's own options go with the company).
The consumer list uses the resolver; ?manage=1 in a client context additionally returns the two groups the settings screen needs:
$rows = getTenantConfigRows($conn, 'asset_types', 'asset_type', $activeId, 'id, name, ...');
// $resp['scoped'] = ['globals' => [...with a `hidden` flag], 'own' => [...], 'company' => ...]The context decides scope; nameβuniqueness is checked against what this company actually sees (its own + nonβhidden globals); you may only edit your own rows:
$scopeTenant = $isDefaultCtx ? null : $activeId; // NULL = global default
// ...clash check across (own βͺ visible globals)...
INSERT INTO asset_types (name, ..., tenant_id) VALUES (?, ..., $scopeTenant);Delete guards ownership + an inβuse guard (can't remove an option live rows still point at). set_*_hidden is the "hide" half: it writes tenant_config_hidden (tenant_id, entity_type, entity_id) β the shared default is never touched, so historic rows still resolve it and it's fully reversible.
The settings JS just needs to (a) request ?manage=1, (b) when scoped is present, render two groups β the company's own (editable) and the shared defaults (each with an eye toggle) β and (c) POST to set_*_hidden. If your settings page uses a generic configβdriven renderer (assets does), add setHidden/hiddenParam to the endpoint config and a toggleHidden(), and every tab gets it for free.
-
A tree (e.g. locations). The flat add/hide model doesn't fit, and you still have to enforce that a node's parent is in the same scope as the node. First choose the scope model by asking "does a shared default make sense here?" β for physical locations the answer is no (a client's offices are entirely its own; there's no shared office across clients), so scope the tree like data, not config:
activeTenantFilterin theget_endpoint (the Default company owns the pre-existing NULL rows; each client sees only its own), and the same filter to validate the parent. Because the settings tree and the pickers all build from that oneget_endpoint, scoping it scopes the whole UI. (If a tree genuinely does have shared defaults, use "shared + own" instead βgetTenantConfigRowswith no hidden rows β but reach for that only when it's real.) - A crossβmodule shared registry (e.g. suppliers, shared by Assets + Contracts + RFP). Making it privateβperβcompany would leak a company's rows into the other modules' lists until they're scoped too. Either scope all consumers together, or keep it shared (the pragmatic default β and per the metaβprinciple you can add perβcompany later without a painful migration).
-
A graph of rows that link to each other (CMDB). When the answer to "can this be shared?" is no, every way two rows can link becomes something to police. CMDB has three β parent/child, typed relationships, and
object_refproperties β and all three needed the same check. π The invariant must bind an ALL-ACCESS actor too. A scope check can't help there: they can legitimately reach both companies, so nothing stops them wiring two clients' estates together. And a link leaks even when the far row is unreadable β you can still confirm it exists and bind your own records to it. Scope the pickers as well as the writes: the picker is what makes the rule discoverable rather than a mysterious error. - Statuses / structural lists. Consider keeping them global: crossβcompany reporting needs a status to mean the same everywhere, and invariants ("there is always a closed status") are hard to protect perβcompany. (Ticket statuses stayed global for this reason; asset statuses were made perβcompany by product choice β both are valid, it's a judgement call, not a default.)
If a row has a natural key that was globally unique β a hostname, an asset tag, an email β it must become unique per company (two clients can each have a LAPTOP-01). That means:
- the uniqueness check moves from
WHERE hostname = ?toWHERE hostname = ? AND tenant_id <=> ?(<=>is NULLβsafe, so a Defaultβcompany key matches NULL rows); - whoever creates the row must know the company. For machine ingest, pin it to the credential:
apikeys.tenant_idgives each agent's key a company, and everyWHERE hostnamein the ingest path scopes to it. This is the "pinned mailbox" idea applied to asset ingest.
A single shared connection that pulls rows for several companies (a shared vCenter/Intune) can't derive one company from its credential β that needs a routing rule (oneβcompanyβperβconnection, routeβbyβdomain, or a triage queue), which is its own piece of work. Until then, such rows land in the Default company as "needs assigning."
Every slice must pass both:
- N=1 is a noβop. On a singleβcompany install the output is unchanged. This is mostly proven by construction (every helper noβops), but sanityβcheck a couple of endpoints.
- N=2 isolates. On a twoβcompany DB, company A cannot see, readβbyβid, or write company B's rows; a config default hidden for A is unaffected for B.
You don't need the full UI to prove this β drive the helpers/service directly from the CLI against a multiβcompany dev DB:
$scopedToB = new ActorContext(1, [$companyB]);
AssetsService::updateFields($conn, $scopedToB, $companyA_assetId, ['model' => 'X']);
// β ServiceError 'not_found' β isolatedConfig resolution is just as quick to check:
getTenantConfigRows($conn, 'asset_types', 'asset_type', $companyA); // globals + A's own, not B'stenantCount() memoises in a static, so isMultiTenant() is evaluated once per process and every helper below it inherits that answer. A script that checks N=1 behaviour, inserts a second company, then checks isolation will report the second half as completely unscoped β the count is still cached at 1. That looks exactly like "my guards don't work" and will send you debugging correct code. Build the two fixtures as separate database + separate process runs.
A suite of "was it blocked?" assertions passes just as happily when the request was broken as when it was refused. Building the CMDB write suite, php://input turned out to be empty under the CLI SAPI, so every write failed validation with 'name' is required before reaching a single tenancy check β and all six isolation assertions passed. Nine green checks that proved nothing at all.
The tell was that the positive controls failed alongside the negatives: "co4 analyst CAN rename its own CI" was red. Without those in the suite, the false green ships. So for every "X cannot do Y", assert the matching "X can do Y in its own company" β if both move together, your harness is broken, not your code.
(If you hit the same php://input problem, override the php:// stream wrapper in the runner so a piped body reaches the endpoint.)
Re-implementing an endpoint's query in the test proves your reimplementation is correct. Include the actual file instead: define DB_NAME before config.php loads db_config.php (a second define() on the same constant is ignored, so yours wins), set a session id and write analyst_id into it before the endpoint's own session_start() runs, then require the endpoint and capture its output. That way the SQL, the PDO parameter order and the guards all get exercised. Ordering bugs are the ones this catches: a filter fragment in a SELECT subquery or a JOIN β¦ ON clause binds before the WHERE params, and getting that wrong silently swaps values rather than erroring.
You don't need a multi-company dev database to prove isolation, and you shouldn't mutate one to get it. Import database/freeitsm.sql into a throwaway schema, seed exactly the companies and analysts the test needs, call the helpers, then drop it:
// password via env so it never lands in a command line, a log, or terminal output
putenv('MYSQL_PWD=' . DB_PASSWORD);
shell_exec(sprintf('"%s" -u %s -h %s %s < "%s/database/freeitsm.sql"',
$mysql, escapeshellarg(DB_USERNAME), escapeshellarg(DB_SERVER),
escapeshellarg($TEST), $root));This doubles as a fresh-install test β if freeitsm.sql has drifted from db_verify, the import or the first scoped query fails right there (see Database Verification). Two notes: freeitsm.sql already seeds the Default company as id 1, so don't insert it again; and team-granted access must be exercised too, since getAccessibleTenantIds() unions analyst + team grants and a guard that only reads analyst_tenant_access will pass a naive test and still be wrong.
- Decided each table's shape (scoped data vs config list vs connection) β they use different
tenant_idconventions. Say out loud whatNULLmeans here. -
tenant_idin freeitsm.sql and db_verify ($schema+ index/FK/unique sections) β and the two agree. Dev only ever exercises the db_verify upgrade path, so baseline drift is invisible until a stranger does a fresh install. - A clientβsupplied
company_idis validated against the actor's access, not just "does this company exist" β and refused, never silently downgraded toNULL. - Every list read filtered (
activeTenantFilter) β including dashboards/aggregates. - Every byβid read/write gated (
analystCanAccess<X>) β including child collections. - Create path stamps the company; natural keys made perβcompany unique.
- Config tabs use
getTenantConfigRows+save/delete/set_hidden(mirror ticket types) + the twoβgroup UI. - Service enforces scope (
ctx->companyScope); REST resource scoped (standing rule). - N=1 noβop and N=2 isolation both verified on a dev DB.
- CHANGELOG, inβapp help, and the MultiβTenancyβProgress + Settings wiki pages updated.
- Helpers:
includes/tenancy.php(analyst side),api/v1/lib/auth.php(APIβkey side). - Canonical configβlist implementation:
api/tickets/{get,save,delete,set_ticket_type_hidden}.php. - Canonical dataβscoping + service: Asset Management (
api/assets/*,includes/services/assets.php,api/v1/resources/assets.php). - Related: MultiβTenancy Β· Settings: global vs perβcompany Β· Isolation Β· Pitfalls Β· Progress Β· Service Layer.
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)