Skip to content

Multi Tenancy Progress

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

Multi-tenancy: progress tracker

Part of the Multi-tenancy section. Planned / in active development. Tracks the build against the plan. Last updated: 2026-07-05.

Legend: βœ… done Β Β·Β  🚧 in progress / next Β Β·Β  ⬜ not started

Where things stand: the tickets module is now solid for real multi-company use. The foundation is complete; tickets scope by company; per-company email routing works end-to-end (pinned/shared mailboxes, sender-domain and individual-address routing, a triage safety net, and diagnostics); the first per-company settings are live (ticket types & origins); the whole ticket layer is fully isolated (every ticket read/write is access-checked β€” see Data isolation & the audit); and you can now control which companies each analyst may access. What's left is rolling per-company scoping through the other modules, plus per-company departments/SLAs and the self-service portal.


Phase 0 β€” Foundation βœ… complete

The invisible plumbing. None of this changes anything on a single-company install.

  • βœ… tenants table + a silent Default company that owns all existing data
  • βœ… tenant_domains table (for future email-domain routing)
  • βœ… Analyst β†’ company access model (which staff can see which companies, + an "all companies" flag) β€” now with an admin UI to grant/deny it per analyst (System β†’ Analysts; see Staff access)
  • βœ… Team β†’ company access β€” a team can grant its members access to specific companies (or all), additive to each analyst's own access, set on System β†’ Teams β†’ Manage companies. Enforced through the same getAccessibleTenantIds() choke-point; defaults to granting nothing so upgrades never widen access. System β†’ Analysts shows effective (direct + team-inherited) access. New table team_tenant_access + teams.can_access_all_tenants.
  • βœ… tenant_id on tickets (every existing ticket assigned to the Default company)
  • βœ… Shared tenancy helper β€” the single choke-point all tenant logic routes through
  • βœ… Active-company session context
  • βœ… Company switcher in the header (appears only once a 2nd company exists)
  • βœ… System β†’ Companies admin page (list / create / rename companies)

Phase 1 β€” Tickets βœ… core complete

Making the tickets module company-aware.

  • βœ… Ticket list scopes to the active company
  • βœ… New tickets are stamped with the active company
  • βœ… Folder count badges (departments / statuses / analysts / unassigned) scope to the active company
  • βœ… Full ticket-layer isolation (Phase 1.5) β€” every ticket endpoint (detail, thread, attachments, notes, time, audit, CMDB, SLA, CSAT, and every action) is now access-checked, verified by a three-sweep audit. The tickets module is safe for real multi-company use.

Phase 2 β€” Per-company mailboxes & email routing βœ… core complete

So an MSP can receive each client's email and have it land under the right company.

  • βœ… Link mailboxes to companies (pinned = one mailbox per company, shared intake = one inbox sorted by sender) β€” set in Tickets β†’ Settings β†’ Mailboxes
  • βœ… Register each company's email domains β€” managed in System β†’ Companies
  • βœ… Route incoming email to the right company by sender domain (covers both Microsoft & Gmail import)
  • βœ… Specific senders β€” map an individual address to a company (e.g. jane@gmail.com β†’ Acme), matched before the domain, so personal/webmail senders route correctly
  • βœ… Triage queue for unmatched / unknown senders β€” reached from the company switcher, with one-click filing and "route this domain" or "always file this sender" (each sweeps the other queued mail and auto-routes future mail)
  • βœ… Public-email (Gmail/Outlook/…) domains are never auto-mapped (different clients share them); the built-in list is customisable (add-only β€” extend it, can't un-protect a built-in)
  • βœ… "How email reaches this company" β€” a read-only routing summary on each company's editor
  • βœ… Email routing test β€” a dry-run diagnostic: type a sender + mailbox, see where it would land and why
  • ⬜ A per-company reply-identity override (replies already go out from the mailbox the ticket arrived on; this is the only outbound piece left) (next up)

Routing rule (first match wins): reply to an existing ticket β†’ inherits its company; otherwise a pinned mailbox β†’ its company (sender ignored); otherwise a shared mailbox β†’ matched by the exact sender address, then the sender domain β†’ that company, or triage if nothing matches. On a single-company install this is invisible β€” everything lands under the one Default company exactly as before.

Phase 3+ β€” Roll scoping through the other modules 🚧 started

  • 🚧 Per-module Settings: global default + per-company override (the add + hide model). Built: ticket types, ticket origins. Next: categories, departments (with extra wiring), SLAs. Statuses/priorities/teams/mailboxes stay global on purpose β€” see Settings rationale.
  • βœ… Change Management β€” company-scoped like tickets/problems (list + calendar scope to the active company; new changes stamped with it; every by-id read/write, UI and REST API, isolated by company; a change in one company is a 404 to another). changes.tenant_id, NULL = Default; invisible at N=1.
  • βœ… Asset Management β€” assets belong to a company (assets.tenant_id); the list and dashboard scope to it, agent ingest files machines by their API key's company (apikeys.tenant_id, so two clients can each have a LAPTOP-01), and every by-id read/write (incl. an asset's disks/software/history child reads), UI and REST API, is isolated. Asset types, statuses are per-company add/hide lists, and locations are a purely per-company tree (a client's sites are entirely its own). Suppliers stay a shared registry (used by Contracts/RFP too β€” changeable later). Deferred: routing vCenter/Intune-synced machines to their company (they land in Default for now). See the Developer Guide β€” assets is its worked example.
  • βœ… Knowledge β€” articles have a company and an audience (internal / customer / public), so a web chat widget can only ever be answered from its own company's articles plus shared ones, and only from articles deliberately marked public. Note this module intentionally breaks the usual NULL rule β€” blank means shared with everyone, not "the Default company's" β€” because an MSP's generic how-to legitimately serves every client.
  • βœ… Messaging & web chat β€” write isolation. Channels and widgets are connection rows rather than scoped data: NULL means shared intake (one WhatsApp number serving every company, sorted by sender), not "Default's". The list stays install-wide, matching how mailbox connections have always behaved, but writes are now gated β€” an analyst can only pin a channel to a company they can reach, and only edit or delete one that's already theirs. Shared-intake channels remain administerable by anyone managing messaging, since they belong to no single company. Invisible at N=1.
  • βœ… CMDB β€” a configuration item belongs to exactly one company (cmdb_objects.tenant_id, NULL = Default). The browse list, search and every picker scope to the active company; opening, editing or deleting a CI outside it is a not-found. There are no shared CIs, so a CI's parent, its relationships and its object_ref properties must all stay within one company β€” and that invariant binds all-access staff too, since scope checks alone would let someone who can reach both companies wire two clients' estates together. Ticket↔CI links check both ends and the CI's linked-tickets panel is ticket-scoped. Classes, class properties, relationship types and icons stay install-wide β€” they describe how the CMDB is modelled, not whose kit it is, so they're config rather than data. Network Mapper's CMDB reads and its node-write path are gated the same way. Invisible at N=1.
  • ⬜ Calendar, Forms, Network Mapper (diagrams themselves), Contracts, Tasks, Workflow, Forms

Deferred in CMDB β€” moving a CI between companies. There's no "move to company" action, unlike tickets and changes. It's deliberately out of v1: a CI isn't a standalone row, it sits in a tree with relationships and object_ref properties hanging off it, so moving one has to decide what happens to its parent, its children and every link β€” leave them and you've created exactly the cross-company links the invariant forbids; drag them along and one click silently re-homes a whole subtree. The sane 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 companies, and audit it. Until then, a wrongly-filed CI is deleted and recreated.

Network Mapper boundary. CMDB objects are company-scoped but network diagrams are not yet, so api/network-mapper/get_diagram.php deliberately does not filter its nodes β€” doing so would make CIs silently vanish from existing diagrams. Both routes onto a canvas (the CI search and "add related objects") are scoped, so no new diagram can pick up a foreign CI; the residual is historical data only. Giving diagrams their own company is the first job of Network Mapper's own slice.

Still open in messaging: tenant_channel_senders β€” the per-sender routing map that makes a shared-intake channel work β€” has no management UI, so rows must be inserted by hand and shared-intake routing isn't practically usable yet. Web chat also reads its channel's company directly instead of going through resolveTicketTenantForChannel(), so it skips the single-company default and the sender fallback; the two inbound paths should be reconciled.

Self-service portal 🚧 SSO built; ticket isolation still pending

  • ⬜ Global requester identity β€” one login, tickets across companies, each badged
  • ⬜ Portal scoping + the "only your own tickets" guardrails
  • βœ… Per-company SSO β€” requesters sign in via their own company's identity provider, routed by email domain (0 / 1 / 2+ IdPs β†’ local / straight-through / picker); single-company and MSP both supported, invisible at N=1. Uses each provider's own credentials today; the credential-free "MSP multi-tenant app + consent" model is designed but not built. See Single Sign-On.

Cross-cutting / housekeeping

  • βœ… README β€” an "in development" multi-tenancy section is now in the project README
  • βœ… Admin visibility tools β€” System β†’ Topology (a company-rooted tree of mailboxes, domains, providers, analysts, ticket/requester counts across every company) and System β†’ Orphaned tickets (find + rehome tickets stuck in a deleted department, which are hidden from every team queue). See System.
  • ⬜ Isolation beyond the tickets module β€” other modules that read tickets (self-service portal, CMDBβ†’tickets, Tasks↔tickets, Watchtower, the SLA cron, the workflow engine, templated/CSAT email) are not yet company-scoped; only matters at N>1, fix as each module gets its Phase-3 scoping. See Data isolation.
  • ⬜ Companies: delete / archive management (today you can only deactivate)
  • ⬜ Automated isolation tests (guarding against one company seeing another's data)
  • ⬜ Tidy-up: remove the api/tickets/debug_get_attachment.php diagnostic (discloses server paths)

This page is maintained by hand as work lands β€” see the GitHub roadmap issue (#24) for live discussion.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally