travoBooks বহু-পার্টনার (multi-tenant)—একই ইনস্টলেশনে একাধিক ট্রাভেল এজেন্সি সম্পূর্ণ ডেটা আইসোলেশনের সাথে চলে। প্রতিটি টেবিলে partner_id কলাম এবং তিনটি এনফোর্সমেন্ট লেয়ার: অ্যাপ্লিকেশন, ডেটাবেস, স্টোরেজ।
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
- Insert
partnersrow. - Bootstrap partner-scoped reference data: seed CoA template, seed default tax profile, seed default notification templates, enable functional currency in
partner_currencies. - Create initial
partner_adminuser and send setup email. - Create a partner-scoped object-storage prefix.
- Emit
partner.createdevent for downstream (subscriptions, billing).
6.3 Outputs
- A new
partner_idreturned to the caller. - The new admin user receives an account-activation email.
- A
partnersaudit 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:
- Platform admin actions (Anthropic-style super-admin support sessions): require explicit "support mode" elevation, are recorded with
cross_partner=truein audit logs, and are visible to the partner in their audit feed. - 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_idis 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
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).groupsandgroup_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_idand reviewers must approve. - ⚠️ Reusing a
customer_idfrom 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_idalongside 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.