Multi Tenancy Developer Guide - edmozley/freeitsm GitHub Wiki
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, external tracker connections |
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 outbound twin. The same rule applies in reverse wherever a workflow can send one company's data somewhere. Escalating a ticket to an external issue tracker is the first case: a connection is either shared or pinned to one client, and the compatibility check (integrationsCompaniesCompatible()) is hardcoded and runs before any network call — precisely because a workflow's conditions are editable by anyone who can author workflows. Direction reversed, boundary identical. See External issue trackers §6.
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.
Before any of the detail — this is the shape of the job. Making module X company-aware touches the same ten or so places every time. Print this, tick it off.
Colour key: 🗄️ schema · 📖 read · ✏️ write · 🔗 cross-module · 🔌 REST · 🖥️ UI/help · 📄 docs
| 🎨 | File | What you do there | Skippable? |
|---|---|---|---|
| 🗄️ | database/freeitsm.sql |
add tenant_id, its index and its FK to the table's CREATE TABLE
|
Never. Miss it and fresh installs break — see #879 |
| 🗄️ | includes/db_verify_schema.php |
the column, in the $schema array |
Never. A drift guard now fails loudly if this and freeitsm.sql disagree |
| 🗄️ | api/system/db_verify.php |
the FK, in a separate FK group |
Never. $schema does not create foreign keys — and FKs have no guard yet |
| 🗄️ | includes/db_verify_indexes.php |
regenerate — don't hand-edit | Only if you added an index (you did) |
| 📖 | includes/tenancy.php |
add analystCanAccess<X>(), the by-id gate |
Only if nothing is fetched by id (rare) |
| 📖 |
api/<module>/get_*.php, search_*.php
|
activeTenantFilter() on every list; the gate on every by-id read |
Never |
| 📖 | …the same files' counts and badges | a COUNT of data is data, even hanging off a config row |
Easy to miss — this shipped as a bug |
| ✏️ | includes/services/<module>.php |
stamp the company on create; assertScope() on every by-id write |
Only if the module has no service yet |
| 🔗 | other modules reading your table | grep for your table name outside your folder — that's the list | Never; this is where gaps hide |
| 🔌 | api/v1/resources/<module>.php |
apiKeyTenantFilter on the list, apiKeyCanAccessTenantRow in the loader, ?company_id=, company in the serialiser |
Never — standing rule |
| 🔌 | api/v1/lib/openapi_schemas.php |
document the new company field |
Never — an undocumented returned field is its own bug |
| 🖥️ | <module>/help.php |
a section gated on isMultiTenant()
|
Only if the module has no help page |
| 🖥️ |
lang/en/<module>.php + lang/pt-BR/<module>.php
|
new keys, in the same commit | Never — EN-only keys drift silently |
| 📄 |
CHANGELOG.local.md, README.md, this wiki |
log it | Never |
A correctly-scoped module needs no front-end changes at all. The CMDB slice changed zero lines of JS — not browse.js, not the 48 KB object.js, not the settings JS.
The list page renders whatever get_objects.php returns; the pickers render whatever search_objects.php returns. Scope the endpoint and the entire UI follows.
🔑 If you find yourself editing JavaScript to hide rows, stop — you're filtering in the wrong layer and the data is still going over the wire. Filter in SQL.
The 🔗 row above is where isolation gaps actually hide, and it's the one step with no checklist — you have to go looking:
# every file touching your table from OUTSIDE your own module folder
grep -rn "cmdb_objects" --include=*.php . | grep -v "^./api/cmdb/"For CMDB that surfaced Network Mapper (two files plus its service), the ticket↔CI link endpoints, and the workflow engine. Two of those were real holes.
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 includes/db_verify_schema.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);4.4 delete_* and set_*_hidden
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.) 2.6.0: a customer showed it can be real for locations (a data centre several clients use), so locations gained a per-rowis_sharedexception while staying data-scoped. See Shared asset locations - Developer Guide for how the parent rule and the unshare guard keep the tree whole. - 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."
Intune since 3.1.0 (PR #164): Settings → Intune can name the company new devices are matched and created in (one-company-per-connection, optional). An existing device-to-asset link is trusted wherever the asset has since been moved, so "land in Default, then move to the client" still works — the next sync neither clears the link nor makes a duplicate. Unset, Intune matches across all companies, as it always did.
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 andincludes/db_verify_schema.php, plus the index/FK sections. The column drift guard catches a mismatch on the first Verification run — but FKs are still unguarded, so check those by eye. - 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.
Copy from these — each is a worked, shipped example:
| Want to… | Read | Notes |
|---|---|---|
| scope a plain data list |
api/assets/get_assets.php, includes/services/assets.php
|
the simplest full example |
| build a config list (add/hide) | api/tickets/{get,save,delete,set_ticket_type_hidden}.php |
the canonical settings tab |
| scope a graph (rows linking rows) |
includes/services/cmdb.php, api/cmdb/get_object.php
|
see the CMDB case study |
| handle a shared-by-design row | Knowledge (knowledgeTenantFilter) |
NULL = shared, the opposite convention |
| handle a connection / intake row | api/messaging/save_channel.php |
NULL = shared intake; list stays global |
| verify any of it | the test harness | scratch DB, real endpoints, 83 checks |
The helpers themselves: includes/tenancy.php (analyst side) · api/v1/lib/auth.php (API-key side) · includes/service_context.php (ActorContext::companyScope).
- Related: Multi‑Tenancy · Settings: global vs per‑company · Isolation · Pitfalls · Progress · Service Layer.