-
Notifications
You must be signed in to change notification settings - Fork 15
Multi Tenancy CMDB Case Study
Part of Multi-tenancy. The recipe lives in the Developer Guide; this page is the worked example β what actually happened when CMDB became the sixth company-aware module, including the things the recipe didn't cover.
Read this if you're about to make another module multi-company, especially one where rows link to each other. CMDB is the hardest case shipped so far.
Every earlier module was a list of independent rows. A ticket doesn't reference another ticket in a way that matters for isolation; an asset doesn't structurally depend on another asset.
A CMDB is a graph. Its entire value is that things point at each other:
ββββββββββββββββ
β ACME-HYPERV1 β β parent
ββββββββ¬ββββββββ
β parent_id
ββββββββΌββββββββ depends on ββββββββββββββββ
β ACME-SQL01 β ββββββββββββββββββββββββΊ β ACME-SAN01 β
ββββββββ¬ββββββββ cmdb_object_relationships ββββββββββββββ
β
β object_ref property ("Hosted on")
βΌ
ββββββββββββββββ
β ACME-RACK-04 β
ββββββββββββββββ
Three different mechanisms link two CIs, and each one is a separate isolation hole if you miss it:
| # | Mechanism | Where it lives | Miss it and⦠|
|---|---|---|---|
| 1 | Parent / child | cmdb_objects.parent_id |
a CI can be filed under another client's tree |
| 2 | Typed relationship | cmdb_object_relationships |
two clients' estates get wired together |
| 3 | object_ref property |
cmdb_object_properties.value_object_id |
the quiet one β a property value that points at a foreign CI |
The recipe's usual question β "is NULL unassigned, shared, or Default?" β was the easy part. The hard part was that a scoped list is not enough when rows can point at each other.
Ed's call: a CI belongs to exactly ONE company. There are no shared CIs.
This is the opposite of Knowledge, where blank means shared with everyone because an MSP's generic how-to legitimately serves every client. A client's servers are not like that β they're entirely their own.
That single answer determined the rest:
-
NULLmeans the Default company's, the standard scoped-data convention βactiveTenantFilter(), not a config resolver; - the FK is
ON DELETE SET NULL(mirroring assets) β deleting a company must not destroy the estate record; - and because nothing is shared, every one of the three link mechanisms must be policed.
π Ask the sharing question first. It isn't a schema detail β it decides whether you're writing a list filter or an invariant.
This is the part the recipe didn't have, and the part worth carrying to every future module.
A permission check answers "can this actor reach this row?". That's enough to stop someone reading another company's CI. It is not enough to stop them linking two companies together β because an MSP administrator legitimately reaches both sides.
// NOT sufficient on its own:
assertScope($conn, $ctx, $from); // β
actor can reach A
assertScope($conn, $ctx, $to); // β
actor can reach B
// β¦an all-access actor passes both, and A and B are different companies.
// The invariant, which binds regardless of reach:
assertSameCompany($conn, $fromId, $toId, 'relationship');Two things follow that are easy to get wrong:
- A link leaks even when the far row is unreadable. You may not be able to open the other CI, but you can still confirm it exists and bind your own records to it. Existence is information.
- Scope the pickers, not just the writes. The write guard is what makes it safe; the scoped picker is what makes it discoverable. Without both, users hit a mysterious error instead of simply not seeing an invalid choice.
Concretely: search_objects.php feeds the parent picker, the relationship-target picker and the object_ref picker. Scoping that one endpoint closed all three UI routes at once.
Colour key: ποΈ schema Β· π read Β· βοΈ write Β· π cross-module Β· π REST Β· π₯οΈ UI/help Β· π docs
| File | π¨ | What changed |
|---|---|---|
database/freeitsm.sql |
ποΈ |
cmdb_objects.tenant_id, ix_cmdb_objects_tenant_id, fk_cmdb_objects_tenant (SET NULL) |
includes/db_verify_schema.php |
ποΈ | the expected column (this was inline in db_verify.php at the time; it now lives here so the drift guard can require it) |
api/system/db_verify.php |
ποΈ | the FK, in a separate FK group β $schema never creates foreign keys |
includes/db_verify_indexes.php |
ποΈ |
Generated β regenerate via scripts/gen_db_verify_indexes.php, don't hand-edit |
β οΈ These must agree, and drift guards now enforce it for both columns and indexes β a mismatch turns into a red card on the next Verification run. Foreign keys are still unguarded, so check those by eye. Getting a column wrong is what caused #879: a fresh install silently missing it while every upgraded install was fine. (At the time of this work the column guard did not exist; it was built immediately afterwards, prompted by exactly this bug.)
| File | π¨ | What changed |
|---|---|---|
includes/tenancy.php |
π |
analystCanAccessCmdbObject() β the by-id gate, twin of analystCanAccessAsset()
|
| File | π¨ | What changed |
|---|---|---|
get_objects.php |
π | list scoped; parent filtered in the ON clause so a CI never vanishes; child_count subquery scoped |
search_objects.php |
π | scoped β the single most valuable line, it feeds all three pickers |
get_object.php |
π | by-id gate + every hydrated neighbour scoped separately (parent, children, object_ref targets, both relationship directions) |
get_object_impact.php |
π | by-id gate β blast radius walks descendants, so ungated it enumerated the estate |
get_object_tickets.php |
π | by-id gate and ticketTenantFilter on the tickets; also started excluding deleted tickets |
generate_object_summary.php |
πβοΈ | by-id gate β reads the neighbourhood into an AI prompt and writes ai_summary back |
get_classes.php |
π | classes stay shared, but object_count is data and now scopes (#882 β see Β§6) |
get_class_properties.php |
π | module guard only (config, not scoped) |
get_relationship_types.php |
π | module guard only (config, not scoped) |
test_ai_key.php |
π | module + AI capability guards, matching its Forms/Workflow twins |
delete_class.php |
βοΈ | deliberately NOT scoped β commented; see Β§6 |
| File | π¨ | What changed |
|---|---|---|
includes/services/cmdb.php |
βοΈ |
assertScope(), assertSameCompany(), resolveTenantForCreate(), tenantOf(), assertObjectRefsInCompany(); wired into all five write methods |
The tell that this file was the bug: the IDE flagged
$ctxas declared but not used increateObject,updateObject,deleteObject,createRelationshipanddeleteRelationship. The service already received the actor's company scope and ignored all of it.
| File | π¨ | What changed |
|---|---|---|
api/tickets/save_ticket_cmdb_object.php |
π | ticket side was gated, CI side was not; now both, plus same-company |
api/tickets/get_ticket_cmdb_objects.php |
π | CI + parent names scoped (a pre-existing link can still straddle) |
api/network-mapper/get_related_objects.php |
π | a CMDB read living in another module β same gate, or it's a way around Β§3 |
includes/services/network_mapper.php |
βοΈ |
ActorContext threaded through replaceContents β validateNodeInput β validateObjectExists
|
api/network-mapper/get_diagram.php |
π | deliberately unfiltered β comment only; see Β§5 |
| File | π¨ | What changed |
|---|---|---|
api/v1/resources/cmdb.php |
π |
apiKeyTenantFilter on the list, apiKeyCanAccessTenantRow in the loader, ?company_id=, company in the serialiser, neighbours scoped, header comment corrected |
api/v1/lib/openapi_schemas.php |
π |
company on CmdbObject and CmdbObjectDetail
|
cmdb/help.php |
π₯οΈ | section 13 "Configuration items and companies", gated on isMultiTenant()
|
lang/en/cmdb.php + lang/pt-BR/cmdb.php
|
π₯οΈ | 7 keys in the same commit β see i18n |
README.md, CHANGELOG.local.md
|
π | module list + #881 / #882 |
No JavaScript changed. None. Not cmdb/browse.js, not cmdb/object.js (48 KB), not cmdb/settings/settings.js, not the CMDB block in assets/js/inbox.js.
That's the strongest argument for the architecture: the browse page renders whatever get_objects.php returns, and the pickers render whatever search_objects.php returns. Scope the endpoint and the whole UI follows. If you find yourself editing JS to hide rows, stop β you're filtering in the wrong layer, and the data is still on the wire.
CMDB isn't only consumed by CMDB. Network Mapper builds diagrams out of CIs, which created two distinct problems with two different answers.
Reads and writes β gated. get_related_objects.php took a CI id on session auth alone and returned its whole neighbourhood β a way around the CMDB gate. And NetworkMapperService::validateObjectExists() only checked a CI existed, so a raw id could still plant another company's CI on a canvas. Both fixed.
Diagram rendering β deliberately left alone. get_diagram.php does not filter its nodes, and that's a decision, not an oversight:
- diagrams have no company of their own yet;
- filtering would make CIs silently vanish from existing diagrams β a worse failure than the gap it closes;
- both routes onto a canvas are now scoped, so no new diagram can acquire a foreign CI;
- so the residual is historical data only.
π The general principle: when module A is scoped and module B isn't, the boundary between them needs a decision, and both answers can be wrong. Write down which you chose and why β in the code and on the wiki. Closing it properly means giving diagrams their own company, which is the first job of Network Mapper's own slice.
| Thing | Scoped? | Reasoning |
|---|---|---|
cmdb_objects (the CIs) |
β scoped | The data. Whose kit it is. |
cmdb_classes |
β shared | Describes how you model an estate, not whose it is |
cmdb_class_properties, ..._options
|
β shared | Hang off a class |
cmdb_relationship_types |
β shared | The verbs ("depends on") are vocabulary |
cmdb_icons |
β shared | Presentation |
cmdb_object_properties |
βͺοΈ inherits | Via object_id
|
cmdb_object_relationships |
βͺοΈ inherits | Via both ends |
ticket_cmdb_objects |
βͺοΈ inherits | Via both the ticket and the CI |
Only one table got a tenant_id. Everything else either inherits through a parent or is genuinely install-wide config β the same call made for ticket statuses and priorities.
Two counts of the same table go opposite ways, which makes the rule concrete:
-
get_classes.phpβ scoped. "How many CIs of this class do I have?" is a question about data. Unscoped it contradicted the list beside it and disclosed other companies' volumes. (This shipped as a bug β #882, spotted by Ed noticing the sidebar totals didn't change on switching company.) -
delete_class.phpβ unscoped, deliberately. "Is anyone still using this class?" must consider every company, or one company deletes a class another is actively using.
π Scope by what is being counted, not by which table the query starts from. A
COUNTof data hanging off a config row is still data. This is exactly where I slipped.
Tickets and changes have a move to company action. CMDB deliberately doesn't.
A ticket is a standalone row, so moving it is one UPDATE. A CI sits in a tree with relationships hanging off it, so a move must decide the fate of its parent, its children and every link β and both defaults are wrong:
- leave the links β you've created exactly the cross-company links Β§3 forbids;
- take them β one click silently re-homes an entire subtree.
The shape when it's built: move the CI and its descendants as a unit, refuse with a clear list if any relationship or object_ref would end up straddling, and audit it. Until then a wrongly-filed CI is deleted and recreated.
Six slices, each committed and tested separately, then 83 checks across five suites at N=1 and N=3 β all driving the real endpoint files, not reimplementations of their SQL. That harness is reusable and documented separately: Multi-tenancy: the test harness.
Two findings came only from those tests and would otherwise have shipped:
-
A cross-company relationship leaked the other CI's name through
get_object.phpβ the by-id gate passed (it's your CI), but the neighbour hydration didn't. This is what turned "gate the object" into "gate every neighbour independently". - A false green. The entire write suite passed while proving nothing β see the harness page. The tell was the positive controls failing alongside the negatives.
And one came only from Ed using it: the class sidebar counts (Β§6).
π Isolation bugs are invisible in normal use β at N=1 everything looks perfect, and at N>1 you only notice if you happen to look at the right screen as the right user. Tests aren't optional here in the way they are for a visible feature.
- Developer Guide β the recipe and the file map for any module
- Test harness β the reusable verification kit
- Progress tracker Β· Pitfalls Β· CMDB
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
- β³ π’ Ticket numbering
- β³ π 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)