Skip to main content

Multi-Tenancy Architecture

Secure multi-tenant data isolation and configuration.

Overview

Olympus Cloud uses a shared-database, isolated-data multi-tenancy model:

AspectApproachBenefit
DatabaseShared Cloud SpannerCost efficiency, scalability
IsolationRow-level filteringStrong data separation
ConfigurationPer-tenant settingsFlexibility
EncryptionPer-tenant keysSecurity
RoutingSubdomain-basedSimple identification

Tenant Hierarchy

┌─────────────────────────────────────────────────────────────────┐
│ Tenant │
│ (Organization/Restaurant Group) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Subscription │ │
│ │ • Plan (Starter, Pro, Enterprise) │ │
│ │ • Billing cycle │ │
│ │ • Feature entitlements │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Location │ │ Location │ │ Location │ │
│ │ (Store 1) │ │ (Store 2) │ │ (Store 3) │ │
│ │ │ │ │ │ │ │
│ │ • Orders │ │ • Orders │ │ • Orders │ │
│ │ • Menu │ │ • Menu │ │ • Menu │ │
│ │ • Inventory │ │ • Inventory │ │ • Inventory │ │
│ │ • Staff │ │ • Staff │ │ • Staff │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Users │ │
│ │ • Owner (all locations) │ │
│ │ • Manager (assigned locations) │ │
│ │ • Staff (single location) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

Data Isolation

Database Schema

Cloud Spanner uses interleaved tables for tenant isolation:

-- Root tenant table
CREATE TABLE tenants (
tenant_id STRING(36) NOT NULL,
name STRING(255) NOT NULL,
subdomain STRING(63) NOT NULL,
settings JSON,
created_at TIMESTAMP NOT NULL,
) PRIMARY KEY (tenant_id);

-- Locations interleaved under tenant
CREATE TABLE locations (
tenant_id STRING(36) NOT NULL,
location_id STRING(36) NOT NULL,
name STRING(255) NOT NULL,
settings JSON,
) PRIMARY KEY (tenant_id, location_id),
INTERLEAVE IN PARENT tenants ON DELETE CASCADE;

-- Orders interleaved under location
CREATE TABLE orders (
tenant_id STRING(36) NOT NULL,
location_id STRING(36) NOT NULL,
order_id STRING(36) NOT NULL,
data JSON NOT NULL,
) PRIMARY KEY (tenant_id, location_id, order_id),
INTERLEAVE IN PARENT locations ON DELETE CASCADE;

Query Enforcement

warning

Every database query must include tenant_id in the WHERE clause. Queries that omit tenant filtering will access data across all tenants, violating isolation guarantees. The TenantContext struct enforces this at the repository layer.

All queries include tenant context:

pub struct TenantContext {
pub tenant_id: TenantId,
pub location_ids: Vec<LocationId>,
pub user_id: UserId,
pub permissions: Permissions,
}

impl OrderRepository {
pub async fn list(&self, ctx: &TenantContext, query: OrderQuery) -> Result<Vec<Order>> {
// Tenant ID is ALWAYS included in WHERE clause
let sql = r#"
SELECT * FROM orders
WHERE tenant_id = @tenant_id
AND location_id IN UNNEST(@location_ids)
AND status IN UNNEST(@statuses)
ORDER BY created_at DESC
LIMIT @limit
"#;

self.db.query(sql)
.bind("tenant_id", &ctx.tenant_id)
.bind("location_ids", &ctx.location_ids)
.bind("statuses", &query.statuses)
.bind("limit", query.limit)
.fetch_all()
.await
}
}

Tenant Identification

Request Flow

1. Request arrives at edge

2. Extract tenant from:
• Subdomain (acme.olympuscloud.ai → tenant "acme")
• JWT token (tenant_id claim)
• API key (lookup tenant)

3. Validate tenant exists and is active

4. Attach TenantContext to request

5. All data access uses TenantContext

Subdomain Resolution

// Edge Worker
function extractTenant(request: Request): string | null {
const url = new URL(request.url);
const host = url.hostname;

// Platform domain: acme.olympuscloud.ai
const platformMatch = host.match(/^([a-z0-9-]+)\.olympuscloud\.ai$/);
if (platformMatch) return platformMatch[1];

// Restaurant domain: acme.restaurantrevolution.ai
const restaurantMatch = host.match(/^([a-z0-9-]+)\.restaurantrevolution\.ai$/);
if (restaurantMatch) return restaurantMatch[1];

// Custom domain: lookup in KV
const customTenant = await env.CUSTOM_DOMAINS.get(host);
return customTenant;
}

Role-Based Access Control

Permission Model

Tenant
└─ Role (e.g., "Manager")
└─ Permissions (e.g., "orders.read", "orders.write")
└─ Scope (e.g., locations: ["loc-1", "loc-2"])

Permission Checking

pub fn check_permission(
ctx: &TenantContext,
required: Permission,
resource: &Resource,
) -> Result<()> {
// 1. Check if user has permission
if !ctx.permissions.has(&required) {
return Err(Error::Forbidden("Missing permission"));
}

// 2. Check location scope
if let Some(location_id) = resource.location_id() {
if !ctx.location_ids.contains(&location_id) {
return Err(Error::Forbidden("Location not in scope"));
}
}

// 3. Check resource ownership (if applicable)
if let Some(owner_id) = resource.owner_id() {
if !ctx.permissions.has(&Permission::ViewAll) && owner_id != ctx.user_id {
return Err(Error::Forbidden("Not resource owner"));
}
}

Ok(())
}

Built-in Roles

RolePermissionsScope
OwnerAllAll locations
AdminAll except billingAll locations
ManagerOperations, reportsAssigned locations
SupervisorOperationsAssigned locations
StaffBasic operationsSingle location
Read-OnlyView onlyAssigned locations

Tenant Configuration

Configuration Hierarchy

Default Settings (Platform)
└─ Tenant Settings (Override)
└─ Location Settings (Override)
└─ User Preferences (Override)

Settings Resolution

pub async fn get_setting<T: DeserializeOwned>(
&self,
ctx: &TenantContext,
key: &str,
) -> Result<T> {
// Try user preference
if let Some(value) = self.get_user_setting(ctx.user_id, key).await? {
return Ok(value);
}

// Try location setting
if let Some(loc_id) = ctx.primary_location() {
if let Some(value) = self.get_location_setting(loc_id, key).await? {
return Ok(value);
}
}

// Try tenant setting
if let Some(value) = self.get_tenant_setting(ctx.tenant_id, key).await? {
return Ok(value);
}

// Return platform default
self.get_default_setting(key).await
}

Common Settings

SettingScopeDescription
timezoneLocationTimezone for reports
currencyTenantCurrency code
tax_rateLocationDefault tax rate
receipt_logoLocationReceipt branding
kds_layoutLocationKDS display config
order_numberingLocationOrder number format

Subscription & Entitlements

Subscription Tiers

TierLocationsFeatures
Starter1Core POS, basic reports
Pro5+ Inventory, KDS, API
EnterpriseUnlimited+ AI, custom integrations

Feature Gating

pub async fn check_entitlement(
ctx: &TenantContext,
feature: Feature,
) -> Result<()> {
let subscription = self.get_subscription(ctx.tenant_id).await?;

if !subscription.plan.includes(&feature) {
return Err(Error::PaymentRequired(
format!("Feature '{}' requires {} plan or higher",
feature.name(),
feature.minimum_plan().name())
));
}

// Check usage limits
if let Some(limit) = subscription.plan.limit_for(&feature) {
let usage = self.get_usage(ctx.tenant_id, &feature).await?;
if usage >= limit {
return Err(Error::PaymentRequired(
format!("Usage limit reached for '{}'", feature.name())
));
}
}

Ok(())
}

Encryption

Per-Tenant Keys

Cloud KMS
└─ Master Key (Platform)
└─ Tenant KEK (Key Encryption Key)
└─ DEKs (Data Encryption Keys)
└─ Encrypted Data

Sensitive Data Encryption

pub struct EncryptionService {
kms: CloudKms,
key_cache: Cache<TenantId, DataKey>,
}

impl EncryptionService {
pub async fn encrypt(
&self,
tenant_id: &TenantId,
plaintext: &[u8],
) -> Result<EncryptedData> {
// Get or create DEK for tenant
let dek = self.get_or_create_dek(tenant_id).await?;

// Encrypt data with DEK
let ciphertext = dek.encrypt(plaintext)?;

Ok(EncryptedData {
key_id: dek.id,
ciphertext,
})
}
}

Tenant Lifecycle

Provisioning

1. Create tenant record
2. Generate encryption keys
3. Initialize default settings
4. Create owner user
5. Set up billing
6. Provision subdomain
7. Send welcome email

Offboarding

Data Deletion is Irreversible

Tenant offboarding permanently deletes all encryption keys and purges all tenant data. Always confirm data export has been completed and verified by the tenant before proceeding with step 3 (Delete encryption keys). Once keys are deleted, encrypted data cannot be recovered.

1. Cancel subscription
2. Export data (if requested)
3. Delete encryption keys
4. Purge all tenant data
5. Remove subdomain
6. Archive audit logs (retention)