Skip to content

Multi Tenancy CMDB Case Study

Ed Mozley edited this page Jul 18, 2026 · 2 revisions

πŸ—„οΈ Case study: making CMDB multi-company

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.


1. Why CMDB was different

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.


2. The decision that shaped everything

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:

  • NULL means 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.


3. πŸ”΄ The core lesson: an invariant must bind an all-access actor

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.


4. πŸ“ The file map

Colour key: πŸ—„οΈ schema Β· πŸ“– read Β· ✏️ write Β· πŸ”— cross-module Β· πŸ”Œ REST Β· πŸ–₯️ UI/help Β· πŸ“„ docs

Schema β€” where a scoped column is declared three times

File 🎨 What changed
database/freeitsm.sql πŸ—„οΈ cmdb_objects.tenant_id, ix_cmdb_objects_tenant_id, fk_cmdb_objects_tenant (SET NULL)
api/system/db_verify.php πŸ—„οΈ $schema column and 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 three must agree or the drift guard fires β€” and for columns, which have no guard yet, a fresh install silently breaks. That's the #879 bug.

The shared helper

File 🎨 What changed
includes/tenancy.php πŸ“– analystCanAccessCmdbObject() β€” the by-id gate, twin of analystCanAccessAsset()

Reads β€” api/cmdb/

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

Writes β€” the service layer

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 $ctx as declared but not used in createObject, updateObject, deleteObject, createRelationship and deleteRelationship. The service already received the actor's company scope and ignored all of it.

Cross-module consumers

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

REST + docs

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

πŸŽ‰ What you don't touch

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.


5. πŸ—ΊοΈ Network Mapper: the second consumer

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.


6. What stayed shared, and why

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 COUNT of data hanging off a config row is still data. This is exactly where I slipped.


7. The gap left open: moving a CI between companies

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.


8. How it was verified

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:

  1. 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".
  2. 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.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally