tenant_id Enforcement Patterns
Implementation patterns for guaranteed tenant_id scoping across all data operations.
Prerequisites: PRIMITIVES.md, multi-tenant-isolation.md
Core Invariant
Section titled “Core Invariant”// GOLDEN RULE: tenant_id ALWAYS from auth context, NEVER from request∀ Query → WHERE tenant_id = auth.tenant_id∀ Fact → fact.tenant_id = auth.tenant_id (forced)∀ Entity → entity.tenant_id validated against auth.tenant_idThis is a ONE-WAY DOOR decision (Principle 2). If tenant_id enforcement is compromised, multi-tenant isolation fails catastrophically.
Anti-Patterns (Never Do This)
Section titled “Anti-Patterns (Never Do This)”❌ Anti-Pattern 1: tenant_id from Request
Section titled “❌ Anti-Pattern 1: tenant_id from Request”// WRONG: Attacker can set tenant_id to any valueasync function getFacts(request: Request): Promise<Fact[]> { const { tenant_id, type } = await request.json(); return db.query('SELECT * FROM facts WHERE tenant_id = ? AND type = ?', [tenant_id, type]);}Attack:
curl -X POST https://api.web1.co/facts \ -d '{"tenant_id": "tenant_victim", "type": "charge"}'Attacker reads Tenant B’s data despite being authenticated as Tenant A.
❌ Anti-Pattern 2: Optional tenant_id Validation
Section titled “❌ Anti-Pattern 2: Optional tenant_id Validation”// WRONG: Validation can be bypassedasync function updateEntity(authCtx: AuthContext, entityId: string, updates: Partial<Entity>): Promise<Entity> { const entity = await db.queryOne('SELECT * FROM entities WHERE id = ?', [entityId]);
// BUG: Only validates if entity exists. Attacker can create entity_id that doesn't exist. if (entity && entity.tenant_id !== authCtx.tenant_id) { throw new ForbiddenError('Entity does not belong to tenant'); }
return db.update('UPDATE entities SET ... WHERE id = ?', [entityId]);}Attack: Attacker creates entity with entity_id from another tenant. No validation occurs.
❌ Anti-Pattern 3: Global Queries Without tenant_id
Section titled “❌ Anti-Pattern 3: Global Queries Without tenant_id”// WRONG: Queries all tenants' dataasync function searchContacts(authCtx: AuthContext, query: string): Promise<Contact[]> { // BUG: Missing WHERE tenant_id = ? return db.query('SELECT * FROM entities WHERE type = ? AND name LIKE ?', ['contact', `%${query}%`]);}Attack: Attacker searches for contacts across all tenants.
❌ Anti-Pattern 4: Trusting Entity Metadata
Section titled “❌ Anti-Pattern 4: Trusting Entity Metadata”// WRONG: Entity metadata can be forgedasync function appendFact(authCtx: AuthContext, fact: Fact): Promise<Fact> { const entity = await getEntity(fact.entity_id);
// BUG: entity.tenant_id might be from another tenant if getEntity doesn't validate fact.tenant_id = entity.tenant_id;
return accountDO.appendFact(fact);}Attack: Attacker supplies entity_id from another tenant. If getEntity doesn’t validate tenant_id, Fact is written to wrong tenant.
Correct Patterns
Section titled “Correct Patterns”✅ Pattern 1: Force tenant_id from Auth
Section titled “✅ Pattern 1: Force tenant_id from Auth”// CORRECT: tenant_id forced from auth, request value ignoredasync function queryFacts(authCtx: AuthContext, filters: FactFilters): Promise<Fact[]> { // filters.tenant_id is ignored even if provided return db.query(` SELECT * FROM facts WHERE tenant_id = ? AND type = ? AND timestamp >= ? `, [ authCtx.tenant_id, // Always from auth filters.type, filters.since ]);}Key: Request can send tenant_id, but it’s never used. Auth context is authoritative.
✅ Pattern 2: Validate Entity Ownership
Section titled “✅ Pattern 2: Validate Entity Ownership”// CORRECT: Validate entity belongs to tenant before operatingasync function getEntity(authCtx: AuthContext, entityId: string): Promise<Entity> { const entity = await db.queryOne(` SELECT * FROM entities WHERE id = ? AND tenant_id = ? `, [entityId, authCtx.tenant_id]);
if (!entity) { // Same error whether entity doesn't exist or belongs to another tenant throw new NotFoundError('Entity not found'); }
return entity;}
async function updateEntity(authCtx: AuthContext, entityId: string, updates: Partial<Entity>): Promise<Entity> { // Validate ownership first const entity = await getEntity(authCtx, entityId);
// Update with tenant_id in WHERE clause (belt and suspenders) await db.exec(` UPDATE entities SET data = ?, updated_at = ? WHERE id = ? AND tenant_id = ? `, [ JSON.stringify({ ...entity, ...updates }), Date.now(), entityId, authCtx.tenant_id ]);
return { ...entity, ...updates };}Key: Both read and write operations validate tenant_id in WHERE clause.
✅ Pattern 3: Fact Append with tenant_id Injection
Section titled “✅ Pattern 3: Fact Append with tenant_id Injection”// CORRECT: Force tenant_id on append, validate all referencesasync function appendFact(authCtx: AuthContext, fact: Partial<Fact>): Promise<Fact> { // Force tenant_id from auth (ignore request value) const validatedFact: Fact = { ...fact, id: fact.id ?? generateFactId(), tenant_id: authCtx.tenant_id, // Always from auth timestamp: fact.timestamp ?? Date.now() };
// Validate entity belongs to tenant (if specified) if (validatedFact.entity_id) { await validateEntityOwnership(authCtx, validatedFact.entity_id); }
// Validate campaign belongs to tenant if (validatedFact.campaign_id) { await validateEntityOwnership(authCtx, validatedFact.campaign_id); }
// Validate asset belongs to tenant if (validatedFact.asset_id) { await validateEntityOwnership(authCtx, validatedFact.asset_id); }
// Validate contact belongs to tenant if (validatedFact.contact_id) { await validateEntityOwnership(authCtx, validatedFact.contact_id); }
// Append to DO const accountDO = getAccountDO(authCtx.tenant_id, validatedFact.entity_id); return accountDO.appendFact(validatedFact);}
async function validateEntityOwnership(authCtx: AuthContext, entityId: string): Promise<void> { const entity = await db.queryOne(` SELECT id FROM entities WHERE id = ? AND tenant_id = ? `, [entityId, authCtx.tenant_id]);
if (!entity) { throw new ForbiddenError(`Entity ${entityId} does not belong to tenant`); }}Key: All entity references in Fact are validated before append. Fact.tenant_id is always forced from auth.
✅ Pattern 4: Cross-Tenant References for Global Entities
Section titled “✅ Pattern 4: Cross-Tenant References for Global Entities”// CORRECT: Global entities (tools, vendors, users) have tenant_id = nullasync function getTool(toolId: string): Promise<Tool> { // Tools are global, no tenant_id scoping const tool = await db.queryOne(` SELECT * FROM entities WHERE id = ? AND type = 'tool' AND tenant_id IS NULL `, [toolId]);
if (!tool) { throw new NotFoundError('Tool not found'); }
return tool;}
// Global entities are READ-ONLY for tenantsasync function updateTool(authCtx: AuthContext, toolId: string, updates: Partial<Tool>): Promise<Tool> { // Only platform account can modify global entities if (authCtx.tenant_id !== 'platform') { throw new ForbiddenError('Cannot modify global entities'); }
await db.exec(` UPDATE entities SET data = ?, updated_at = ? WHERE id = ? AND type = 'tool' AND tenant_id IS NULL `, [ JSON.stringify(updates), Date.now(), toolId ]);
return getTool(toolId);}Key: Global entities have tenant_id = null explicitly. Only platform account can modify.
✅ Pattern 5: Durable Object ID Scoping
Section titled “✅ Pattern 5: Durable Object ID Scoping”// CORRECT: DO IDs include tenant_id, validated on accessfunction getAccountDO(authCtx: AuthContext, accountId: string): DurableObjectStub { // Validate accountId belongs to tenant // Account IDs must be prefixed with tenant_id if (!accountId.startsWith(`acct_${authCtx.tenant_id}_`)) { throw new ForbiddenError('Account does not belong to tenant'); }
// DO ID: {tenant_id}_{entity_id} const doId = env.ACCOUNT_LEDGER.idFromName(`${authCtx.tenant_id}_${accountId}`); return env.ACCOUNT_LEDGER.get(doId);}
// Within DO: Validate tenant_id on every operationclass AccountDO extends DurableObject { private tenantId: string;
constructor(state: DurableObjectState, env: Env) { super(state, env);
// Extract tenant_id from DO name const doName = state.id.name; this.tenantId = doName.split('_')[0]; // "tenant_acme_account_123" → "tenant_acme" }
async fetch(request: Request): Promise<Response> { // Extract auth context const authCtx = await authenticateRequest(request);
// Validate request is for this tenant if (authCtx.tenant_id !== this.tenantId) { throw new ForbiddenError('Tenant mismatch'); }
// Process request (tenant_id already validated) return this.handleRequest(request); }}Key: DO IDs encode tenant_id. DO validates auth matches its tenant on every request.
Durable Object Patterns
Section titled “Durable Object Patterns”DO Initialization with tenant_id
Section titled “DO Initialization with tenant_id”class AccountDO extends DurableObject { private tenantId: string; private entityId: string; private sql: SqlStorage;
constructor(state: DurableObjectState, env: Env) { super(state, env); this.sql = state.storage.sql;
// Parse DO name: "{tenant_id}_{entity_id}" const [tenantId, ...entityIdParts] = state.id.name.split('_'); this.tenantId = tenantId; this.entityId = entityIdParts.join('_'); }
async fetch(request: Request): Promise<Response> { const authCtx = await authenticateRequest(request);
// Validate tenant_id matches DO if (authCtx.tenant_id !== this.tenantId) { throw new ForbiddenError('Tenant mismatch'); }
// All queries automatically scoped to this.tenantId return this.handleRequest(request); }
async queryFacts(filters: FactFilters): Promise<Fact[]> { // tenant_id implicit from DO context return this.sql.exec(` SELECT * FROM facts WHERE tenant_id = ? AND type = ? AND timestamp >= ? `, [ this.tenantId, // From DO construction filters.type, filters.since ]).all(); }}Key: DO extracts tenant_id from its name on construction. All operations use this.tenantId.
Cross-DO Coordination with tenant_id Validation
Section titled “Cross-DO Coordination with tenant_id Validation”// AssetDO checking budget in AccountDOasync checkCampaignBudget(campaignId: string): Promise<boolean> { // 1. Resolve campaign entity (validates ownership) const campaign = await this.resolveCampaign(campaignId);
// 2. Validate campaign belongs to this tenant if (campaign.tenant_id !== this.tenantId) { throw new ForbiddenError('Campaign does not belong to this tenant'); }
// 3. Get AccountDO for campaign's parent account const accountId = campaign.parent_id; const accountDO = env.ACCOUNT_LEDGER.get( env.ACCOUNT_LEDGER.idFromName(`${this.tenantId}_${accountId}`) );
// 4. Stub call with tenant_id in request const response = await accountDO.fetch(new Request('https://fake/budget/check', { method: 'POST', headers: { 'X-Tenant-ID': this.tenantId, // For validation 'X-Campaign-ID': campaignId }, body: JSON.stringify({ campaign_id: campaignId }) }));
const { available } = await response.json(); return available;}
// In AccountDOasync handleBudgetCheck(request: Request): Promise<Response> { const requestTenantId = request.headers.get('X-Tenant-ID');
// Validate tenant_id matches DO if (requestTenantId !== this.tenantId) { throw new ForbiddenError('Tenant mismatch in cross-DO call'); }
const { campaign_id } = await request.json(); const available = await this.checkBudget(campaign_id);
return Response.json({ available });}Key: Cross-DO calls include tenant_id in request. Receiving DO validates it matches.
Workers for Platforms Patterns
Section titled “Workers for Platforms Patterns”Dispatch Worker: Extract tenant_id from Auth
Section titled “Dispatch Worker: Extract tenant_id from Auth”// Dispatch Workerexport default { async fetch(request: Request, env: Env): Promise<Response> { // 1. Authenticate request const authCtx = await authenticateRequest(request);
// 2. Get tenant worker from WfP namespace // Script name = tenant_id const tenantWorker = env.TENANT_WORKERS.get(authCtx.tenant_id); if (!tenantWorker) { return new Response('Tenant not found', { status: 404 }); }
// 3. Forward request with tenant_id header const tenantRequest = new Request(request.url, { method: request.method, headers: new Headers(request.headers), body: request.body });
// Add auth context headers (tenant worker trusts dispatch worker) tenantRequest.headers.set('x-z0-tenant-id', authCtx.tenant_id); tenantRequest.headers.set('x-z0-key-id', authCtx.key_id); tenantRequest.headers.set('x-z0-scopes', authCtx.scopes.join(','));
// 4. Dispatch to tenant worker return tenantWorker.fetch(tenantRequest); }};Key: Dispatch worker extracts tenant_id from auth, adds it to headers for tenant worker.
Tenant Worker: Trust Dispatch, Validate tenant_id
Section titled “Tenant Worker: Trust Dispatch, Validate tenant_id”// Tenant Worker (user-controlled code)export default { async fetch(request: Request, env: Env): Promise<Response> { // Extract tenant_id from trusted dispatch worker header const tenantId = request.headers.get('x-z0-tenant-id'); const keyId = request.headers.get('x-z0-key-id'); const scopes = request.headers.get('x-z0-scopes')?.split(',') || [];
if (!tenantId) { return new Response('Unauthorized', { status: 401 }); }
// Validate this worker is for the correct tenant // (env.TENANT_ID set at deployment time) if (tenantId !== env.TENANT_ID) { return new Response('Tenant mismatch', { status: 403 }); }
// Build auth context const authCtx: AuthContext = { tenant_id: tenantId, key_id: keyId, scopes };
// All operations automatically scoped to authCtx.tenant_id return handleRequest(request, authCtx); }};Key: Tenant worker validates tenant_id matches its deployment-time tenant binding.
Database Query Patterns
Section titled “Database Query Patterns”D1 Queries (Always Include tenant_id)
Section titled “D1 Queries (Always Include tenant_id)”// CORRECT: All queries include tenant_idasync function queryFacts(authCtx: AuthContext, filters: FactFilters): Promise<Fact[]> { const stmt = env.D1.prepare(` SELECT * FROM facts WHERE tenant_id = ? AND type = ? AND timestamp >= ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ? `);
const { results } = await stmt.bind( authCtx.tenant_id, filters.type, filters.start_date, filters.end_date, filters.limit || 100 ).all();
return results;}
// CORRECT: Aggregations scoped to tenantasync function getChargesSummary(authCtx: AuthContext, startDate: Date, endDate: Date): Promise<Summary> { const stmt = env.D1.prepare(` SELECT campaign_id, COUNT(*) as count, SUM(amount) as total FROM facts WHERE tenant_id = ? AND type = 'charge' AND timestamp >= ? AND timestamp < ? GROUP BY campaign_id `);
const { results } = await stmt.bind( authCtx.tenant_id, startDate.getTime(), endDate.getTime() ).all();
return results;}Key: Every query has WHERE tenant_id = ? with value from auth context.
KV Namespace Isolation
Section titled “KV Namespace Isolation”// CORRECT: KV keys prefixed with tenant_idasync function cacheValue(authCtx: AuthContext, key: string, value: any, ttl: number): Promise<void> { const scopedKey = `${authCtx.tenant_id}:${key}`; await env.KV.put(scopedKey, JSON.stringify(value), { expirationTtl: ttl });}
async function getCachedValue(authCtx: AuthContext, key: string): Promise<any> { const scopedKey = `${authCtx.tenant_id}:${key}`; const cached = await env.KV.get(scopedKey); return cached ? JSON.parse(cached) : null;}
// WRONG: KV keys without tenant_id prefixasync function cacheValueWrong(key: string, value: any): Promise<void> { await env.KV.put(key, JSON.stringify(value)); // Leaks across tenants}Key: KV keys always prefixed with ${tenant_id}: to prevent cross-tenant access.
Testing tenant_id Enforcement
Section titled “Testing tenant_id Enforcement”Unit Tests
Section titled “Unit Tests”describe('tenant_id enforcement', () => { it('should reject queries without tenant_id from auth', async () => { const authCtx = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
// Attempt to query tenant_b's data const request = { tenant_id: 'tenant_b', // Request specifies different tenant type: 'charge' };
await expect(queryFacts(authCtx, request)).rejects.toThrow(ForbiddenError); });
it('should force tenant_id on Fact append', async () => { const authCtx = { tenant_id: 'tenant_a', scopes: ['write:facts'] };
const fact = { type: 'charge', tenant_id: 'tenant_b', // Request tries to set wrong tenant amount: 100 };
const appended = await appendFact(authCtx, fact);
// tenant_id forced to tenant_a expect(appended.tenant_id).toBe('tenant_a'); });
it('should validate entity ownership before Fact append', async () => { const authCtx = { tenant_id: 'tenant_a', scopes: ['write:facts'] };
// Create entity for tenant_b await createEntity({ id: 'entity_b', tenant_id: 'tenant_b' });
// Try to append Fact referencing tenant_b's entity const fact = { type: 'outcome', entity_id: 'entity_b', // Belongs to tenant_b tenant_id: 'tenant_a' };
await expect(appendFact(authCtx, fact)).rejects.toThrow(ForbiddenError); });});Integration Tests
Section titled “Integration Tests”describe('cross-tenant isolation', () => { it('should prevent tenant_a from reading tenant_b data', async () => { // Setup: Create Facts for both tenants await appendFact({ tenant_id: 'tenant_a' }, { type: 'charge', amount: 100 }); await appendFact({ tenant_id: 'tenant_b' }, { type: 'charge', amount: 200 });
// Test: tenant_a queries Facts const authCtxA = { tenant_id: 'tenant_a', scopes: ['read:facts'] }; const factsA = await queryFacts(authCtxA, { type: 'charge' });
// Should only see tenant_a's Facts expect(factsA).toHaveLength(1); expect(factsA[0].amount).toBe(100); expect(factsA[0].tenant_id).toBe('tenant_a'); });
it('should prevent DO name manipulation', async () => { const authCtxA = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
// Try to access tenant_b's DO by constructing DO name const attemptedAccess = () => { const doId = env.ACCOUNT_LEDGER.idFromName('tenant_b_account_123'); return env.ACCOUNT_LEDGER.get(doId).fetch(new Request('https://fake/facts')); };
// Should fail because auth context doesn't match DO tenant await expect(attemptedAccess()).rejects.toThrow(ForbiddenError); });});Penetration Testing Scenarios
Section titled “Penetration Testing Scenarios”describe('tenant_id penetration tests', () => { it('should prevent SQL injection via tenant_id', async () => { const maliciousAuthCtx = { tenant_id: "tenant_a' OR '1'='1", // SQL injection attempt scopes: ['read:facts'] };
// Prepared statements prevent SQL injection const facts = await queryFacts(maliciousAuthCtx, { type: 'charge' });
// Should return empty (no tenant matches that ID) expect(facts).toHaveLength(0); });
it('should prevent tenant_id tampering in API requests', async () => { const authCtx = { tenant_id: 'tenant_a', scopes: ['read:facts'] };
// Malicious request with different tenant_id in body const request = { tenant_id: 'tenant_b', type: 'charge' };
const facts = await queryFacts(authCtx, request);
// Should only see tenant_a's data (request.tenant_id ignored) expect(facts.every(f => f.tenant_id === 'tenant_a')).toBe(true); });
it('should prevent cross-tenant entity references', async () => { const authCtx = { tenant_id: 'tenant_a', scopes: ['write:facts'] };
// Create entity for tenant_b await createEntity({ id: 'campaign_b', tenant_id: 'tenant_b', type: 'campaign' });
// Try to create Fact referencing tenant_b's campaign const fact = { type: 'invocation', campaign_id: 'campaign_b', // Belongs to tenant_b tenant_id: 'tenant_a' };
await expect(appendFact(authCtx, fact)).rejects.toThrow(ForbiddenError); });});Audit Checklist
Section titled “Audit Checklist”Before deploying any code that touches tenant data:
Query Layer
Section titled “Query Layer”- All SELECT queries include
WHERE tenant_id = ? - tenant_id value always from
authCtx.tenant_id, never from request - Prepared statements used (no string concatenation)
- Aggregations include tenant_id in WHERE clause
Write Layer
Section titled “Write Layer”- All INSERT operations set
tenant_id = authCtx.tenant_id - All UPDATE operations include
WHERE tenant_id = ? - All DELETE operations include
WHERE tenant_id = ? - Fact appends force tenant_id from auth context
Entity References
Section titled “Entity References”- All entity_id references validated against authCtx.tenant_id
- campaign_id validated (belongs to tenant)
- asset_id validated (belongs to tenant)
- contact_id validated (belongs to tenant)
- deal_id validated (belongs to tenant)
Durable Objects
Section titled “Durable Objects”- DO IDs include tenant_id prefix
- DO validates auth.tenant_id matches DO.tenantId on every request
- Cross-DO calls include tenant_id validation
- DO SQLite queries include WHERE tenant_id = ?
Workers for Platforms
Section titled “Workers for Platforms”- Dispatch worker extracts tenant_id from auth, not request
- Tenant worker validates tenant_id matches deployment binding
- Tenant-specific bindings (KV, D1, secrets) configured per tenant
- No global bindings shared across tenants
KV/Cache
Section titled “KV/Cache”- KV keys prefixed with
${tenant_id}: - Cache keys prefixed with
${tenant_id}: - No global cache shared across tenants
Error Messages
Section titled “Error Messages”- Errors do not leak tenant_id or entity existence
- Same error for “not found” vs “belongs to other tenant”
- No enumeration possible via error messages
Summary
Section titled “Summary”| Principle | Implementation |
|---|---|
| tenant_id source | Always from auth context, never from request |
| Query enforcement | Every query includes WHERE tenant_id = ? |
| Write enforcement | tenant_id forced on INSERT/UPDATE, validated on reference |
| DO isolation | DO ID encodes tenant_id, validated on every operation |
| WfP isolation | Per-tenant scripts with isolated bindings |
| Global entities | tenant_id = null, read-only for tenants |
| Validation | All entity references validated against auth.tenant_id |
| Testing | Unit, integration, and penetration tests enforce isolation |
Critical Path: API Key → Auth Context → tenant_id → Query WHERE clause
Any break in this chain compromises multi-tenant isolation. tenant_id enforcement is the foundation of z0’s security model.