-
Notifications
You must be signed in to change notification settings - Fork 15
Multi Tenancy Test Harness
Part of Multi-tenancy. How to prove a module is actually isolated, without a browser and without touching your dev database.
Isolation bugs are invisible in normal use: at N=1 everything looks perfect, and at N>1 you only notice if you happen to be looking at the right screen as the right user. That's why this exists.
| Test | Why it matters | |
|---|---|---|
| 1οΈβ£ | N=1 is a no-op | Almost every install is single-company. A regression here breaks everyone. |
| 2οΈβ£ | Nβ₯2 isolates | Company A can't read, open-by-id, write or link to company B's rows. |
Both, every slice. Neither is optional.
This is the rule that matters most, because breaking it produces a green suite that proves nothing.
A "was it blocked?" assertion 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 died at 'name' is required before reaching a single tenancy check, and all six isolation assertions went green.
[PASS] co4 CANNOT rename co6 CI β refused? no. it never ran.
[PASS] co4 CANNOT delete co6 CI β same
[FAIL] co4 CAN rename its own CI β β οΈ THE TELL
Nine green checks proving nothing. The only reason it was caught is that the positive controls failed alongside the negatives.
π 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.
Re-implementing a query in your test proves your reimplementation is correct.
The bug class this catches is PDO parameter order. A scope fragment lands in three different places, and they bind in this order:
SELECT (subquery β¦ AND o.tenant_id = ?) β 1st
FROM x
LEFT JOIN y ON y.id = x.y_id AND y.tenant_id = ? β 2nd
WHERE x.id = ? AND x.tenant_id = ? β 3rd
Get that wrong and PDO doesn't error β it silently binds a tenant id as an object id and vice versa. Only the real query, with real parameters, catches it.
$params = array_merge($aChild, $aParent, $tArgs); // SELECT, then JOIN, then WHEREDB_NAME is define()d in db_config.php, and a second define() on an existing constant is ignored β so define it first and yours wins, while the real credentials still load normally.
define('DB_NAME', $argv[1]); // β MUST be before config.php loads db_config.php
require 'config.php';Endpoints call session_start() themselves, which overwrites a plain $_SESSION array. Open the session first, write to it, close it β the endpoint's own session_start() then reopens the same id.
session_id('cmdbtest' . $analyst);
@session_start();
$_SESSION['analyst_id'] = $analyst;
session_write_close();It's empty in the CLI SAPI. Override the wrapper so a piped body reaches the endpoint:
$body = stream_get_contents(STDIN);
class TestInputStream {
public static string $data = '';
private int $pos = 0;
public function stream_open($path, $m, $o, &$p): bool { return $path === 'php://input'; }
public function stream_read(int $n): string {
$c = substr(self::$data, $this->pos, $n); $this->pos += strlen($c); return $c;
}
public function stream_eof(): bool { return $this->pos >= strlen(self::$data); }
public function stream_stat() { return ['size' => strlen(self::$data)]; }
}
TestInputStream::$data = $body;
stream_wrapper_unregister('php');
stream_wrapper_register('php', 'TestInputStream');tenantCount() memoises in a static, so isMultiTenant() is evaluated once per process and everything below inherits that answer. A script that checks N=1, 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. Separate database, separate process, per fixture.
Import database/freeitsm.sql into a throwaway schema β never mutate the dev database. This doubles as a fresh-install test: if the baseline has drifted from db_verify, the import or the first scoped query fails right there (#879).
putenv('MYSQL_PWD=' . DB_PASSWORD); // keeps the password out of the command line and logs
shell_exec(sprintf('"%s" -u %s -h %s %s < "%s/database/freeitsm.sql"',
$mysql, escapeshellarg(DB_USERNAME), escapeshellarg(DB_SERVER),
escapeshellarg($TEST), $root));A fixture that exercises the interesting cases:
| Seed | Why |
|---|---|
| Analyst all-access, analyst co4 only, analyst co6 only | the three reaches that behave differently |
A row in co4, a row in co6, a row with NULL
|
NULL must resolve as Default's, not "everyone's" |
| A child in each company | parent/child hydration |
| A deliberately cross-company link | proves reads don't expose it and that the invariant blocks new ones |
| A deleted row (ticket in the recycle bin) | catches missing deleted_datetime filters |
| A team granting company access |
getAccessibleTenantIds() unions analyst + team grants β a guard reading only analyst_tenant_access passes a naive test and is still wrong |
Gotchas: freeitsm.sql already seeds the Default company as id 1 and default reference rows (relationship types, classes) β use INSERT IGNORE and look ids up rather than assuming.
LIST each analyst sees only their company's rows; all-access in Default
context sees Default's (activeTenantFilter = the ACTIVE company,
not "everything you could reach")
BY-ID A cannot open B's; A CAN open its own (positive control);
the refusal is framed as NOT-FOUND, never "forbidden"
NEIGHBOUR a linked row in another company is not NAMED in the payload,
while a same-company one still is (positive control)
WRITE create is stamped with the right company; A cannot create INTO B;
A cannot update/delete B's; A CAN update its own (positive control)
INVARIANT cross-company link refused β INCLUDING for an all-access actor
COUNTS sidebar/badge totals scope too (a COUNT of data is data)
REST same set again via the API key's company_scope
N=1 every one of the above is a no-op
Written for CMDB, but the runner and fixture are ~20 lines from being module-agnostic β copy them for Calendar, Forms or Network Mapper.
| File | Purpose |
|---|---|
cmdb_build_fixture.php |
builds a scratch DB at N=1 or N=3 and seeds it |
cmdb_endpoint_runner.php |
runs one real api/cmdb/ endpoint as a chosen analyst |
tickets_endpoint_runner.php / nm_endpoint_runner.php
|
same, other module folders (a one-line sed of the above) |
cmdb_isolation_suite.php |
reads: list, by-id, impact, search, linked tickets, neighbours, counts |
cmdb_write_suite.php |
writes: create/update/delete + the same-company invariant |
cmdb_ticketlink_suite.php |
the ticket β CI link, both directions |
cmdb_rest_suite.php + cmdb_rest_call.php
|
the REST resource with synthetic API keys |
cmdb_slice1_test.php |
schema: fresh import, FK rule, column parity, drift self-check |
Result: 83 checks, N=1 and N=3, in a few seconds.
- Developer Guide β the recipe
- CMDB case study β what these tests actually caught
- Database Verification β schema drift, and why a fresh-install test matters
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)