In this volume · VOLUME 01
Foundations
Multi-Tenancy

Chapter 1.1 — Multi-Tenant Architecture

chapter: 01-foundations/01-multi-tenancy
version: 1.0.0
status: stable
last_reviewed: 2026-05-26
owners: [platform-engineering, security]

1. Purpose

This chapter defines the partner tenancy model — how a single deployment of travoBooks serves many independent travel businesses while guaranteeing that one partner can never see, mutate, or affect another partner's data.

2. Why this matters in modern travel accounting

A travel agency's data is its livelihood: customer lists, supplier net rates, commission structures, BSP settlement amounts. Any leakage across tenants is not merely an embarrassment; in many jurisdictions it is a reportable data breach and in commercial terms it can hand a competitor a complete customer book.

At the same time, building a separate deployment per agency is economically impossible at the scale this platform targets. The model must therefore be logical isolation with cryptographic-grade enforcement — strict enough that the operations team itself cannot accidentally cross-pollinate data.

3. Industry relevance

Most travel back-office systems built before 2015 assumed one customer per database. When their vendors moved to SaaS, isolation became an afterthought bolted on with WHERE clauses sprinkled by hand. Predictable breakages followed. travoBooks is built tenant-aware from row zero.

4. Compliance considerations

  • GDPR / DPDPA / local data protection: each partner is a separate "data controller". Their data must be isolable on request (e.g. for an export, a deletion, or a regulator inquiry).
  • Financial audit: an auditor for Partner A must see only Partner A's books. Audit packs export per partner.
  • Group reporting: for franchise groups, an authorised user may legitimately span multiple partners. The model accommodates this without leaking by default.

5. Business logic

5.1 What a partner is

A partner is a tenant — the legal entity that holds the agency's IATA accreditation (or operates without one), holds its own books, files its own taxes, and pays travoBooks for the platform. Examples:

  • "Innovate Solution Travel Ltd" — single-entity agency.
  • "Acme Travel Group" — holding company that owns three trading entities; each trading entity is a separate partner with consolidation done at the group level.

5.2 What is partitioned by partner

Every domain row is partitioned by partner_id. This includes:

  • All operational data: customers, suppliers, bookings, tickets, refunds.
  • All financial data: chart of accounts, journals, ledger, invoices, payments.
  • All configuration: tax profiles, currencies enabled, notification templates, branding.
  • All user data: users, roles, permissions, sessions, API tokens.
  • All audit data: audit logs, activity records.
  • All file blobs in object storage, prefixed by partner_id.

5.3 What is shared across partners (platform-level)

A small set of reference data is intentionally shared:

  • Country / currency / timezone / locale codes (ISO standards).
  • Airport / airline / hotel chain reference data (industry standards).
  • FX rate snapshots (configurable per partner whether to use platform-shared or partner-provided).
  • The travoBooks application code itself.

Shared reference data is read-only to partners.

5.4 What a group is

A group is an optional construct that allows a single user to operate across multiple partners they belong to (e.g. a CFO at a holding company). Group access requires explicit per-partner role assignment; merely being in a group does not grant access.

6. Inputs → processing → outputs

6.1 Provisioning a new partner (input)

partner:
  legal_name: "Innovate Solution Travel Ltd"
  trade_name: "Innovate Travel"
  country: "BD"
  functional_currency: "BDT"
  default_timezone: "Asia/Dhaka"
  default_locale: "en_BD"
  iata_code: "12345678"
  tax_registration:
    type: "VAT"
    number: "1234567890"
  fiscal_year:
    starts_month: 7   # July (Bangladesh)
  contact:
    primary_email: "[email protected]"
admin_user:
  email: "[email protected]"
  full_name: "Nayeem Khan"
  role: "partner_admin"

6.2 Processing

  1. Insert partners row.
  2. Bootstrap partner-scoped reference data: seed CoA template, seed default tax profile, seed default notification templates, enable functional currency in partner_currencies.
  3. Create initial partner_admin user and send setup email.
  4. Create a partner-scoped object-storage prefix.
  5. Emit partner.created event for downstream (subscriptions, billing).

6.3 Outputs

  • A new partner_id returned to the caller.
  • The new admin user receives an account-activation email.
  • A partners audit log row.
  • A pre-built sandbox dataset, if requested.

7. Module dependencies

Reads from Writes to
Country/currency/locale reference partners, partner_currencies, partner_users, chart_of_accounts, tax_profiles, notification_templates, audit_logs

Every other module in the platform depends on this one. There is no operation that does not carry a partner_id.

8. Security & permissions

8.1 Enforcement layers

Isolation is enforced at three layers. All three must pass; a hole in any one is treated as a security incident.

Layer 1 — Application: - The request context carries actor_partner_ids: Set[partner_id] (usually a singleton). - The active partner_id is selected per request from a header (X-Partner-Id) or the path (/p/{partner_id}/...) and must be a member of the actor's set. - All ORM/repository methods inject partner_id = :active into every query.

Layer 2 — Database: - Every domain table has partner_id BIGINT NOT NULL with a FK to partners(id) and an index. - Every unique key includes partner_id (e.g. UNIQUE(partner_id, code)). - Every foreign key into a domain table is enforced to come from the same partner via the application layer; physical FK constraints check ID existence, but partner equality is enforced by query construction and audit checks. - Optionally, MySQL/MariaDB views per partner are exposed for direct-DB analytics, gated by @partner_id session variable.

Layer 3 — Object storage: - File keys are prefixed with partner_id. Signed URLs are scoped to that prefix.

8.2 Cross-partner operations

Two operations legitimately cross partner boundaries:

  1. Platform admin actions (Anthropic-style super-admin support sessions): require explicit "support mode" elevation, are recorded with cross_partner=true in audit logs, and are visible to the partner in their audit feed.
  2. Group consolidation reporting (Module 22): runs as a read-only aggregator over partners the actor has access to. Writes are forbidden.

No other operation may cross boundaries. This is enforced by a static analyzer in CI.

8.3 Threat model

Threat Mitigation
Forged X-Partner-Id header from a logged-in user of partner A targeting partner B's data Header must be in actor's allowed set; mismatch returns 403; audit-logged.
SQL with missing partner_id clause Repository pattern always injects; raw SQL is forbidden by linter outside of platform layer.
Cross-partner FK Application validates; database constraint on shared lookup tables only.
Object-storage URL guessing Keys are UUID-suffixed; URLs are signed; partner prefix enforced.
Backup restore exposing all partners Restore tooling supports per-partner extraction; default restore is to staging only.

9. Validation rules

Rule Behaviour on violation
Every domain insert must include partner_id Repository raises; HTTP 500 with code MULTI_TENANT_PARTNER_MISSING.
X-Partner-Id must match the user's allowed set HTTP 403, code PARTNER_FORBIDDEN.
Foreign references (e.g. invoice → customer) must be same-partner HTTP 422, code CROSS_PARTNER_REFERENCE.
Functional currency must be ISO 4217 HTTP 422, code CURRENCY_INVALID.
Fiscal year start must be 1..12 HTTP 422.
Partner legal name unique within a group HTTP 409, code PARTNER_DUPLICATE.

10. Error handling

  • Hard fail on missing partner context: never silently fall back. A query without partner_id is a bug.
  • Distinct error codes for "you are not authorised for this partner" vs. "the partner does not exist." The first is a security event; the second is a 404.
  • Audit every 403 caused by PARTNER_FORBIDDEN; clusters indicate either compromise or a buggy client.

11. Real-world example

Scenario. A CFO at Acme Travel Group oversees three trading entities (partners P1, P2, P3). She logs in and is shown the partner switcher in the navbar. She selects P2. She creates a new customer, "Beta Corp Ltd". A junior agent at P1, also logged in, opens the customer list — does not see Beta Corp because the list query is WHERE partner_id = P1.

Later, the CFO opens the group consolidation report. The report aggregates P1+P2+P3, displaying "Beta Corp Ltd" only under P2. She exports to PDF; the PDF includes a footer reading "Consolidated view: Acme Travel Group — partners P1, P2, P3".

The junior agent at P1 has no UI affordance to access the consolidation report; her role does not include the group:consolidation:read permission.

12. Step-by-step workflow — provisioning a partner

sequenceDiagram autonumber participant Plat as Platform Admin participant API as Provisioning API participant DB as DB participant Tpl as CoA Template Engine participant Mail as Email participant Sub as Subscriptions Plat->>API: POST /admin/partners {payload} API->>DB: BEGIN API->>DB: INSERT partners API->>DB: INSERT partner_currencies (functional + enabled) API->>Tpl: render CoA template for country=BD Tpl-->>API: list of accounts API->>DB: INSERT chart_of_accounts rows API->>DB: INSERT tax_profiles (default for BD) API->>DB: INSERT notification_templates (defaults) API->>DB: INSERT partner_users (admin) API->>DB: INSERT roles + permissions assignments API->>DB: COMMIT API->>Sub: notify partner.created API->>Mail: send activation email API-->>Plat: 201 {partner_id, admin_setup_url}

13. Database tables touched

Full table specifications in 10-database/03-table-reference.md. Tables involved:

  • partners — the tenant root.
  • partner_currencies — currencies enabled per partner.
  • partner_users — membership of users in partners (many-to-many with roles).
  • groups and group_partners — optional group consolidation.
  • audit_logs — every state change.

14. Future scalability

Pressure Response
Number of partners grows past ~10k on one DB Shard by partner_id. Shard key is already on every domain row.
Very large single partners (>50M bookings) Per-partner sub-sharding via hash of booking_id.
Cross-region partners with data-residency rules Region-pinned shards; partner record carries data_region and the application routes accordingly.
Group consolidation across regions Pre-aggregated nightly facts shipped to a central warehouse; live reads use the regional shard.

15. Common pitfalls (⚠️)

  • ⚠️ Adding a new domain table without partner_id. Schema migrations are linted in CI; this should be caught, but reviewers must verify.
  • ⚠️ Reaching for raw SQL inside a service to "just do this one report quickly". If unavoidable, the raw query must explicitly bind partner_id and reviewers must approve.
  • ⚠️ Reusing a customer_id from one partner in another partner's invoice. The application's same-partner FK check stops this, but only if the check is invoked — every cross-table service call must pass through the partner-scoped repository.
  • 🔒 Never log partner_id alongside an authentication failure with credentials — that can confirm a partner exists to an unauthorised probe.

Next: 02-data-model-foundations.md — the foundational data model decisions that all modules inherit.