Multi-Tenancy Architecture
Secure multi-tenant data isolation and configuration.
Overview
Olympus Cloud uses a shared-database, isolated-data multi-tenancy model:
| Aspect | Approach | Benefit |
|---|---|---|
| Database | Shared Cloud Spanner | Cost efficiency, scalability |
| Isolation | Row-level filtering | Strong data separation |
| Configuration | Per-tenant settings | Flexibility |
| Encryption | Per-tenant keys | Security |
| Routing | Subdomain-based | Simple 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
| Role | Permissions | Scope |
|---|---|---|
| Owner | All | All locations |
| Admin | All except billing | All locations |
| Manager | Operations, reports | Assigned locations |
| Supervisor | Operations | Assigned locations |
| Staff | Basic operations | Single location |
| Read-Only | View only | Assigned 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
| Setting | Scope | Description |
|---|---|---|
timezone | Location | Timezone for reports |
currency | Tenant | Currency code |
tax_rate | Location | Default tax rate |
receipt_logo | Location | Receipt branding |
kds_layout | Location | KDS display config |
order_numbering | Location | Order number format |
Subscription & Entitlements
Subscription Tiers
| Tier | Locations | Features |
|---|---|---|
| Starter | 1 | Core POS, basic reports |
| Pro | 5 | + Inventory, KDS, API |
| Enterprise | Unlimited | + 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)
Related Documentation
- Architecture Overview - Platform architecture
- Tenants API - Tenant management API
- Roles API - RBAC API