Multi Tenant Architecture - gabrielmaialva33/innkeeper GitHub Wiki
Comprehensive guide to Innkeeper's multi-tenant architecture and implementation
Innkeeper is built from the ground up as a multi-tenant SaaS application, allowing multiple organizations (hotels, hotel chains, property management companies) to use the same application instance while maintaining complete data isolation and customization. This document provides a detailed overview of the multi-tenant architecture, implementation strategies, and best practices.
Key Multi-Tenancy Features:
- Complete Data Isolation - Each tenant's data is completely separated
- Customizable Settings - Per-tenant configuration and business rules
- Scalable Architecture - Single codebase serving multiple tenants
- Security by Design - Row-level security and access controls
- Performance Optimized - Efficient resource utilization across tenants
Innkeeper uses a single database, multi-tenant approach with row-level security (RLS) for optimal cost-effectiveness and maintainability:
graph TB
subgraph "Single Application Instance"
APP[Innkeeper Application]
MIDDLEWARE[Tenant Context Middleware]
end
subgraph "Tenant Identification"
ORG_A[Organization A<br/>Hotel Chain]
ORG_B[Organization B<br/>Boutique Hotel]
ORG_C[Organization C<br/>Resort Group]
end
subgraph "Shared Database with RLS"
DB[(PostgreSQL Database)]
RLS[Row Level Security]
ORGS[organizations table]
HOTELS[hotels table]
RESERVATIONS[reservations table]
GUESTS[guests table]
end
ORG_A --> APP
ORG_B --> APP
ORG_C --> APP
APP --> MIDDLEWARE
MIDDLEWARE --> DB
DB --> RLS
RLS --> ORGS
RLS --> HOTELS
RLS --> RESERVATIONS
RLS --> GUESTS
ORGS -.-> HOTELS
ORGS -.-> RESERVATIONS
ORGS -.-> GUESTS
- Cost Efficiency: Single infrastructure for all tenants
- Simplified Maintenance: One database to backup, update, and monitor
- Resource Optimization: Better utilization of database connections and memory
- Feature Consistency: All tenants get new features simultaneously
- Easier Scaling: Horizontal scaling without complex data distribution
- Careful Security Implementation: Requires robust row-level security
- Performance Monitoring: Need to monitor for tenant-specific performance issues
- Backup Complexity: Single backup contains all tenant data
- Compliance Requirements: May need special handling for data residency
The organizations
table serves as the primary tenant entity:
CREATE TABLE organizations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
tax_id VARCHAR(255),
email VARCHAR(255) NOT NULL,
phone VARCHAR(255),
-- Address information
address VARCHAR(255),
city VARCHAR(255),
state VARCHAR(255),
country VARCHAR(255),
postal_code VARCHAR(255),
-- Business settings
currency VARCHAR(3) DEFAULT 'USD',
timezone VARCHAR(255) DEFAULT 'UTC',
-- Status flags
is_active BOOLEAN DEFAULT true,
is_deleted BOOLEAN DEFAULT false,
-- Tenant-specific configuration
settings JSONB DEFAULT '{
"overbooking_percentage": 10,
"default_check_in_time": "14:00",
"default_check_out_time": "11:00",
"cancellation_policy": "flexible",
"payment_methods": ["cash", "card"],
"supported_languages": ["en", "pt"],
"night_audit_time": "02:00"
}',
-- Additional metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX idx_organizations_slug ON organizations(slug);
CREATE INDEX idx_organizations_active ON organizations(is_active);
CREATE INDEX idx_organizations_deleted ON organizations(is_deleted);
Every tenant-specific table includes an organization_id
foreign key:
-- Example: Hotels table with tenant isolation
CREATE TABLE hotels (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
-- ... other columns
UNIQUE(organization_id, slug), -- Unique within tenant
INDEX(organization_id) -- Performance index
);
-- Example: Reservations table with tenant isolation
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
hotel_id INTEGER NOT NULL REFERENCES hotels(id),
guest_id INTEGER NOT NULL REFERENCES guests(id),
-- ... other columns
INDEX(organization_id),
INDEX(organization_id, check_in_date, check_out_date)
);
erDiagram
organizations ||--o{ hotels : "owns"
organizations ||--o{ users : "employs"
organizations ||--o{ guests : "manages"
organizations ||--o{ staff : "employs"
hotels ||--o{ rooms : "contains"
hotels ||--o{ room_types : "defines"
hotels ||--o{ reservations : "receives"
hotels ||--o{ services : "offers"
guests ||--o{ reservations : "makes"
reservations ||--o{ payments : "generates"
reservations ||--o{ folios : "creates"
organizations {
int id PK
string name
string slug UK
string currency
string timezone
jsonb settings
boolean is_active
}
hotels {
int id PK
int organization_id FK
string name
string slug
string city
int star_rating
}
reservations {
int id PK
int organization_id FK
int hotel_id FK
int guest_id FK
date check_in_date
date check_out_date
string status
}
Innkeeper uses PostgreSQL's Row-Level Security (RLS) for database-level tenant isolation:
-- Enable RLS on tenant-specific tables
ALTER TABLE hotels ENABLE ROW LEVEL SECURITY;
ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;
ALTER TABLE guests ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
-- Create RLS policies for tenant isolation
CREATE POLICY tenant_isolation_policy ON hotels
FOR ALL
TO application_role
USING (organization_id = current_setting('app.current_organization')::INTEGER);
CREATE POLICY tenant_isolation_policy ON reservations
FOR ALL
TO application_role
USING (organization_id = current_setting('app.current_organization')::INTEGER);
-- Function to set current organization context
CREATE OR REPLACE FUNCTION set_current_organization(org_id INTEGER)
RETURNS VOID AS $$
BEGIN
PERFORM set_config('app.current_organization', org_id::TEXT, false);
END;
$$ LANGUAGE plpgsql;
The TenantContextMiddleware
manages tenant context for each request:
// app/middleware/tenant_context_middleware.ts
export default class TenantContextMiddleware {
async handle({ auth, response }: HttpContext, next: NextFn) {
try {
// Get organization ID from authenticated user
const organizationId = auth.user?.organization_id
if (!organizationId) {
// Handle users without organization (super admins)
return await next()
}
// Set PostgreSQL session variable for RLS
await db.rawQuery('SELECT set_current_organization(?)', [organizationId])
// Continue with request - all queries now filtered by tenant
return await next()
} catch (error) {
return response.status(500).json({
error: 'Failed to set tenant context'
})
}
}
}
All tenant-specific models include organization relationships:
// app/models/hotel.ts
export default class Hotel extends BaseModel {
@column()
declare organization_id: number
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
// Automatic tenant filtering in queries
static boot() {
super.boot()
this.addGlobalScope('tenant', (query) => {
const organizationId = getCurrentOrganizationId()
if (organizationId) {
query.where('organization_id', organizationId)
}
})
}
}
Business logic services maintain tenant context:
// app/services/reservation_service.ts
export class ReservationService {
constructor(private organizationId: number) {}
async createReservation(data: CreateReservationData) {
// Ensure all created records belong to current tenant
const reservation = await Reservation.create({
...data,
organization_id: this.organizationId
})
return reservation
}
async findAvailableRooms(hotelId: number, dates: DateRange) {
// Queries automatically filtered by RLS
return Room.query()
.where('hotel_id', hotelId)
.where('status', 'available')
// organization_id filter applied by RLS
}
}
Controllers validate tenant access:
// app/controllers/hotels_controller.ts
export default class HotelsController {
async show({ params, auth, response }: HttpContext) {
const hotel = await Hotel.find(params.id)
if (!hotel) {
return response.notFound({ message: 'Hotel not found' })
}
// Additional validation (RLS already filters, but explicit check for clarity)
if (hotel.organization_id !== auth.user!.organization_id) {
return response.forbidden({ message: 'Access denied' })
}
return hotel
}
}
graph TB
subgraph "Security Layers"
APP_AUTH[Application Authentication]
TENANT_CTX[Tenant Context Middleware]
RLS[PostgreSQL Row Level Security]
FK_CONSTRAINTS[Foreign Key Constraints]
end
subgraph "Request Flow"
USER[User Request]
AUTH_CHECK[Authentication Check]
TENANT_SET[Set Tenant Context]
QUERY[Database Query]
FILTERED_DATA[Tenant-Filtered Data]
end
USER --> AUTH_CHECK
AUTH_CHECK --> APP_AUTH
APP_AUTH --> TENANT_SET
TENANT_SET --> TENANT_CTX
TENANT_CTX --> QUERY
QUERY --> RLS
RLS --> FK_CONSTRAINTS
FK_CONSTRAINTS --> FILTERED_DATA
// Multiple layers of tenant validation
class SecureService {
async getData(id: number, user: User) {
// Layer 1: Application-level check
if (!user.organization_id) {
throw new UnauthorizedException('No organization access')
}
// Layer 2: Explicit tenant filtering
const data = await Model.query()
.where('id', id)
.where('organization_id', user.organization_id)
.first()
// Layer 3: RLS provides additional protection
return data
}
}
// Track all tenant-related operations
export class AuditService {
async logTenantAccess(user: User, resource: string, action: string) {
await AuditLog.create({
user_id: user.id,
organization_id: user.organization_id,
resource,
action,
ip_address: request.ip(),
user_agent: request.header('user-agent'),
timestamp: DateTime.now()
})
}
}
// Validate tenant-specific constraints
export class TenantValidator {
static validateHotelAccess = vine.compile(
vine.object({
hotel_id: vine.number().exists(async (db, value, field) => {
const hotel = await db
.from('hotels')
.where('id', value)
.where('organization_id', field.meta.organizationId)
.first()
return !!hotel
})
})
)
}
-- Composite indexes for tenant + common query patterns
CREATE INDEX idx_reservations_org_dates ON reservations(organization_id, check_in_date, check_out_date);
CREATE INDEX idx_guests_org_email ON guests(organization_id, email);
CREATE INDEX idx_rooms_org_status ON rooms(organization_id, status);
CREATE INDEX idx_payments_org_created ON payments(organization_id, created_at);
-- Partial indexes for active records
CREATE INDEX idx_hotels_org_active ON hotels(organization_id) WHERE is_active = true;
CREATE INDEX idx_users_org_active ON users(organization_id) WHERE is_deleted = false;
// Efficient tenant-aware queries
class OptimizedQueries {
// Good: Uses composite index
async getActiveReservations(organizationId: number) {
return db.query()
.from('reservations')
.where('organization_id', organizationId)
.where('status', 'active')
.orderBy('check_in_date')
}
// Better: Leverages RLS (organization_id filter automatic)
async getActiveReservationsWithRLS() {
return Reservation.query()
.where('status', 'active')
.orderBy('check_in_date')
}
}
// Cache keys include tenant context
export class TenantCache {
private static getCacheKey(organizationId: number, key: string): string {
return `org:${organizationId}:${key}`
}
static async get(organizationId: number, key: string) {
const cacheKey = this.getCacheKey(organizationId, key)
return await Redis.get(cacheKey)
}
static async set(organizationId: number, key: string, value: any, ttl = 3600) {
const cacheKey = this.getCacheKey(organizationId, key)
return await Redis.setex(cacheKey, ttl, JSON.stringify(value))
}
}
graph TB
subgraph "Load Balancer"
LB[Nginx Load Balancer]
end
subgraph "Application Tier"
APP1[App Instance 1<br/>Tenants: A, B, C]
APP2[App Instance 2<br/>Tenants: A, B, C]
APP3[App Instance 3<br/>Tenants: A, B, C]
end
subgraph "Database Tier"
MASTER[(Master DB<br/>All Tenants)]
REPLICA1[(Read Replica 1)]
REPLICA2[(Read Replica 2)]
end
subgraph "Cache Tier"
REDIS1[(Redis Cluster 1)]
REDIS2[(Redis Cluster 2)]
end
LB --> APP1
LB --> APP2
LB --> APP3
APP1 --> MASTER
APP2 --> REPLICA1
APP3 --> REPLICA2
APP1 --> REDIS1
APP2 --> REDIS2
APP3 --> REDIS1
- All tenants share the same infrastructure
- Simple and cost-effective
- Good for similar-sized tenants
// Route large tenants to dedicated resources
class TenantRouter {
static getConnectionConfig(organizationId: number) {
const tenant = TenantRegistry.get(organizationId)
if (tenant.tier === 'enterprise') {
return {
database: 'enterprise_db',
redis: 'enterprise_redis',
queue: 'enterprise_queue'
}
}
return {
database: 'shared_db',
redis: 'shared_redis',
queue: 'shared_queue'
}
}
}
graph LR
subgraph "Current: Modular Monolith"
MONOLITH[Innkeeper<br/>Multi-Tenant Monolith]
end
subgraph "Future: Tenant-Aware Microservices"
AUTH_MS[Auth Service<br/>Cross-Tenant]
HOTEL_MS[Hotel Service<br/>Tenant-Aware]
RESERVATION_MS[Reservation Service<br/>Tenant-Aware]
PAYMENT_MS[Payment Service<br/>Tenant-Aware]
end
subgraph "Service Mesh"
MESH[Istio/Envoy<br/>Tenant Context Propagation]
end
MONOLITH -.-> AUTH_MS
MONOLITH -.-> HOTEL_MS
MONOLITH -.-> RESERVATION_MS
MONOLITH -.-> PAYMENT_MS
AUTH_MS --> MESH
HOTEL_MS --> MESH
RESERVATION_MS --> MESH
PAYMENT_MS --> MESH
// Utility for managing tenant context
export class TenantContext {
private static context = new AsyncLocalStorage<{ organizationId: number }>()
static run<T>(organizationId: number, callback: () => T): T {
return this.context.run({ organizationId }, callback)
}
static getCurrentOrganizationId(): number | null {
return this.context.getStore()?.organizationId || null
}
static requireOrganizationId(): number {
const orgId = this.getCurrentOrganizationId()
if (!orgId) {
throw new Error('No tenant context available')
}
return orgId
}
}
// Usage in middleware
export default class TenantContextMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const organizationId = ctx.auth.user?.organization_id
if (organizationId) {
return TenantContext.run(organizationId, () => next())
}
return next()
}
}
// Base repository with tenant awareness
export abstract class TenantAwareRepository<T extends BaseModel> {
constructor(protected model: typeof BaseModel) {}
protected getQuery() {
const organizationId = TenantContext.getCurrentOrganizationId()
const query = this.model.query()
if (organizationId) {
query.where('organization_id', organizationId)
}
return query
}
async findById(id: number): Promise<T | null> {
return this.getQuery().where('id', id).first()
}
async create(data: Partial<T>): Promise<T> {
const organizationId = TenantContext.requireOrganizationId()
return this.model.create({
...data,
organization_id: organizationId
})
}
}
// Specific repository implementation
export class HotelRepository extends TenantAwareRepository<Hotel> {
constructor() {
super(Hotel)
}
async findBySlug(slug: string): Promise<Hotel | null> {
return this.getQuery().where('slug', slug).first()
}
async findActiveHotels(): Promise<Hotel[]> {
return this.getQuery()
.where('is_active', true)
.where('is_deleted', false)
.orderBy('name')
}
}
// Tenant-specific configuration
export class TenantConfig {
private static cache = new Map<number, any>()
static async get(organizationId: number, key: string, defaultValue?: any) {
let config = this.cache.get(organizationId)
if (!config) {
const org = await Organization.find(organizationId)
config = org?.settings || {}
this.cache.set(organizationId, config)
}
return config[key] ?? defaultValue
}
static async getCheckInTime(organizationId: number): Promise<string> {
return this.get(organizationId, 'default_check_in_time', '15:00')
}
static async getOverbookingPercentage(organizationId: number): Promise<number> {
return this.get(organizationId, 'overbooking_percentage', 0)
}
static async getSupportedLanguages(organizationId: number): Promise<string[]> {
return this.get(organizationId, 'supported_languages', ['en'])
}
}
// Test utilities for multi-tenant scenarios
export class TenantTestUtils {
static async createTestOrganization(overrides = {}) {
return Organization.create({
name: 'Test Organization',
slug: `test-org-${Date.now()}`,
email: '[email protected]',
currency: 'USD',
timezone: 'UTC',
settings: {
overbooking_percentage: 5,
default_check_in_time: '14:00',
default_check_out_time: '11:00'
},
...overrides
})
}
static async createTestUser(organizationId: number) {
return User.create({
full_name: 'Test User',
email: `test-${Date.now()}@example.com`,
password: 'password123',
organization_id: organizationId
})
}
static async withTenantContext<T>(
organizationId: number,
callback: () => Promise<T>
): Promise<T> {
await db.rawQuery('SELECT set_current_organization(?)', [organizationId])
try {
return await callback()
} finally {
await db.rawQuery('RESET app.current_organization')
}
}
}
// Test tenant data isolation
test('tenant data isolation', async ({ assert }) => {
// Create two organizations
const org1 = await TenantTestUtils.createTestOrganization({ name: 'Org 1' })
const org2 = await TenantTestUtils.createTestOrganization({ name: 'Org 2' })
// Create hotels for each organization
const hotel1 = await TenantTestUtils.withTenantContext(org1.id, async () => {
return Hotel.create({
organization_id: org1.id,
name: 'Hotel 1',
slug: 'hotel-1'
})
})
const hotel2 = await TenantTestUtils.withTenantContext(org2.id, async () => {
return Hotel.create({
organization_id: org2.id,
name: 'Hotel 2',
slug: 'hotel-2'
})
})
// Test that org1 can only see its own hotels
const org1Hotels = await TenantTestUtils.withTenantContext(org1.id, async () => {
return Hotel.all()
})
assert.lengthOf(org1Hotels, 1)
assert.equal(org1Hotels[0].id, hotel1.id)
// Test that org2 can only see its own hotels
const org2Hotels = await TenantTestUtils.withTenantContext(org2.id, async () => {
return Hotel.all()
})
assert.lengthOf(org2Hotels, 1)
assert.equal(org2Hotels[0].id, hotel2.id)
})
// Metrics collection with tenant context
export class TenantMetrics {
static async recordReservation(organizationId: number, hotelId: number) {
await Metrics.increment('reservations.created', {
organization_id: organizationId,
hotel_id: hotelId
})
}
static async recordRevenue(organizationId: number, amount: number, currency: string) {
await Metrics.gauge('revenue.total', amount, {
organization_id: organizationId,
currency
})
}
static async recordPerformance(organizationId: number, operation: string, duration: number) {
await Metrics.histogram('operation.duration', duration, {
organization_id: organizationId,
operation
})
}
}
// Health checks per tenant
export class TenantHealthCheck {
static async checkTenantHealth(organizationId: number) {
const checks = await Promise.allSettled([
this.checkDatabaseAccess(organizationId),
this.checkCacheAccess(organizationId),
this.checkBusinessRules(organizationId)
])
return {
organizationId,
status: checks.every(c => c.status === 'fulfilled') ? 'healthy' : 'unhealthy',
checks: checks.map((check, index) => ({
name: ['database', 'cache', 'business_rules'][index],
status: check.status,
error: check.status === 'rejected' ? check.reason : null
}))
}
}
private static async checkDatabaseAccess(organizationId: number) {
return TenantTestUtils.withTenantContext(organizationId, async () => {
await Hotel.query().limit(1)
})
}
}
// Debug missing tenant context
export class TenantDebugger {
static logMissingContext(operation: string) {
const organizationId = TenantContext.getCurrentOrganizationId()
if (!organizationId) {
logger.warn(`Operation ${operation} executed without tenant context`, {
stack: new Error().stack,
timestamp: new Date().toISOString()
})
}
}
}
-- Query to detect potential data leaks
SELECT
table_name,
COUNT(*) as total_rows,
COUNT(DISTINCT organization_id) as tenant_count,
COUNT(*) FILTER (WHERE organization_id IS NULL) as orphaned_rows
FROM (
SELECT 'hotels' as table_name, organization_id FROM hotels
UNION ALL
SELECT 'reservations' as table_name, organization_id FROM reservations
UNION ALL
SELECT 'guests' as table_name, organization_id FROM guests
) t
GROUP BY table_name;
// Monitor slow queries by tenant
export class TenantPerformanceMonitor {
static async logSlowQuery(
organizationId: number,
query: string,
duration: number
) {
if (duration > 1000) { // Log queries > 1 second
logger.warn('Slow query detected', {
organization_id: organizationId,
query,
duration,
timestamp: new Date().toISOString()
})
}
}
}
// ✅ Good: Explicit tenant context
async function createReservation(data: ReservationData, organizationId: number) {
return Reservation.create({
...data,
organization_id: organizationId
})
}
// ❌ Bad: Missing tenant context
async function createReservation(data: ReservationData) {
return Reservation.create(data) // Could create orphaned records
}
// ✅ Good: Validate tenant ownership
async function updateHotel(hotelId: number, data: HotelData, user: User) {
const hotel = await Hotel.query()
.where('id', hotelId)
.where('organization_id', user.organization_id)
.firstOrFail()
return hotel.merge(data).save()
}
// ❌ Bad: No tenant validation
async function updateHotel(hotelId: number, data: HotelData) {
const hotel = await Hotel.findOrFail(hotelId)
return hotel.merge(data).save()
}
// ✅ Good: Leverages RLS and explicit filtering
async function getHotelReservations(hotelId: number) {
return Reservation.query()
.where('hotel_id', hotelId)
// organization_id filter applied by RLS
.preload('guest')
.orderBy('check_in_date')
}
// Ensure migrations work with existing tenant data
export default class extends BaseSchema {
async up() {
this.schema.alterTable('hotels', (table) => {
// Add new column with default value
table.string('new_field').defaultTo('default_value')
})
// Update existing records per tenant
await this.defer(async (db) => {
const organizations = await db.from('organizations').select('id')
for (const org of organizations) {
await db.from('hotels')
.where('organization_id', org.id)
.update('new_field', `tenant_${org.id}_value`)
}
})
}
}
#!/bin/bash
# Tenant-aware backup script
# Full database backup (all tenants)
pg_dump innkeeper > backup_all_tenants_$(date +%Y%m%d).sql
# Per-tenant backup (if needed for compliance)
for org_id in $(psql -t -c "SELECT id FROM organizations WHERE is_active = true"); do
pg_dump innkeeper \
--where="organization_id = $org_id OR organization_id IS NULL" \
> backup_tenant_${org_id}_$(date +%Y%m%d).sql
done
- Custom fields per tenant
- Tenant-specific UI themes
- Configurable business workflows
- Geographic data residency
- Tenant-specific encryption keys
- Compliance-specific data handling
- Tenant-based database sharding
- Intelligent query routing
- Predictive resource scaling
- System Architecture - Overall system design
- Database Schema - Detailed database structure
- Security Model - Security implementation details
- API Documentation - API endpoints and usage
- Configuration Guide - Multi-tenant configuration
- 📖 Documentation: Wiki Home
- 🐛 Issues: GitHub Issues
- 💬 Community: GitHub Discussions
- 📧 Support: Contact the development team
← Previous: Database Schema | Wiki Home | Next: API Documentation →