-
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 two kinds of table, and they use opposite conventions for tenant_id. Getting this distinction right up front saves you every time.
| Scoped data | Config list | |
|---|---|---|
| Examples | tickets, assets, changes | ticket types, asset statuses, locations |
tenant_id meaning |
the one company that owns this row |
NULL = a global default shared by all; set = one company's own |
NULL means |
"the Default company" (unrouted / preβmigration) | "everyone sees it" |
| Model | one owner, hard isolation | global default + perβcompany add/hide |
| Read helper | activeTenantFilter() |
getTenantConfigRows() |
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.
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)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).
- 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's- Decided each table's shape (scoped data vs config list) β they use opposite
tenant_idconventions. -
tenant_idin freeitsm.sql and db_verify ($schema+ index/FK/unique sections). - 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)