Multi-Tenancy
Build secure, scalable multi-tenant agent systems with automatic tenant isolation using Spice Framework's context propagation.
Overviewβ
Multi-tenancy allows a single application instance to serve multiple tenants (customers, organizations) with complete data isolation. Spice Framework provides first-class support for multi-tenant architectures through automatic context propagation.
Key Features:
- β Automatic Isolation - Tenant ID propagates through all operations
- β Zero Overhead - No manual tenant parameter passing
- β Type-Safe - Compile-time tenant access
- β Secure by Default - Impossible to forget tenant scoping
Architecture Patternsβ
Pattern 1: Shared Database, Tenant-Scoped Queriesβ
All tenants share the same database, but queries are scoped by tenant_id:
class OrderRepository : BaseContextAwareService() {
suspend fun findOrders() = withTenant { tenantId ->
database.query<Order>(
"""
SELECT * FROM orders
WHERE tenant_id = ?
ORDER BY created_at DESC
""",
tenantId
)
}
suspend fun createOrder(order: Order) = withTenantAndUser { tenantId, userId ->
order.copy(
tenantId = tenantId,
createdBy = userId,
createdAt = Instant.now()
).also { newOrder ->
database.insert(newOrder)
}
}
}
Pros:
- Simple to implement
- Cost-effective
- Easy to manage
Cons:
- Risk of data leakage if queries forget tenant filter
- Shared database performance limits
- Limited customization per tenant
Pattern 2: Database-Per-Tenantβ
Each tenant has their own database:
class TenantDatabaseRouter : BaseContextAwareService() {
private val dataSources = mutableMapOf<String, DataSource>()
suspend fun getConnection() = withTenant { tenantId ->
val dataSource = dataSources[tenantId]
?: throw IllegalStateException("No database for tenant $tenantId")
dataSource.connection
}
}
class OrderRepository(
private val router: TenantDatabaseRouter
) : BaseContextAwareService() {
suspend fun findOrders() = withTenant { tenantId ->
val conn = router.getConnection()
conn.query<Order>("SELECT * FROM orders") // No tenant_id filter needed!
}
}
Pros:
- Complete data isolation
- Per-tenant performance tuning
- Easy to migrate/backup individual tenants
Cons:
- More complex infrastructure
- Higher costs
- Schema migration challenges
Pattern 3: Schema-Per-Tenantβ
One database, separate schema per tenant:
class SchemaRouter : BaseContextAwareService() {
suspend fun getSchemaName() = withTenant { tenantId ->
"tenant_$tenantId"
}
}
class OrderRepository(
private val schemaRouter: SchemaRouter
) : BaseContextAwareService() {
suspend fun findOrders() = withTenant { tenantId ->
val schema = schemaRouter.getSchemaName()
database.query<Order>(
"SELECT * FROM ${schema}.orders"
)
}
}
Pros:
- Good balance of isolation and cost
- Easier management than database-per-tenant
- Performance isolation
Cons:
- Database platform specific
- More complex than shared schema
Implementing Multi-Tenancyβ
Step 1: Extract Tenant from Requestβ
@RestController
class OrderController(
private val orderAgent: Agent
) {
@PostMapping("/api/orders")
suspend fun createOrder(
@RequestHeader("X-Tenant-ID") tenantId: String,
@RequestHeader("Authorization") auth: String,
@RequestBody request: CreateOrderRequest
): ResponseEntity<OrderResponse> =
withAgentContext(
"tenantId" to tenantId,
"userId" to extractUserId(auth),
"correlationId" to UUID.randomUUID().toString()
) {
val comm = Comm(
id = UUID.randomUUID().toString(),
content = request.toJson(),
direction = CommDirection.IN
)
val result = orderAgent.processComm(comm)
result.fold(
onSuccess = { ResponseEntity.ok(it.toOrderResponse()) },
onFailure = { ResponseEntity.status(500).build() }
)
}
}
Step 2: Create Tenant-Scoped Servicesβ
class CustomerService : BaseContextAwareService() {
suspend fun findCustomer(customerId: String) = withTenant { tenantId ->
database.queryOne<Customer>(
"""
SELECT * FROM customers
WHERE tenant_id = ? AND id = ?
""",
tenantId, customerId
)
}
suspend fun createCustomer(customer: Customer) =
withTenantAndUser { tenantId, userId ->
customer.copy(
tenantId = tenantId,
createdBy = userId
).also { newCustomer ->
database.insert(newCustomer)
}
}
suspend fun updateCustomer(customerId: String, updates: CustomerUpdates) =
withTenant { tenantId ->
val existing = findCustomer(customerId)
?: throw NotFoundException("Customer not found")
// Verify tenant ownership
require(existing.tenantId == tenantId) {
"Customer belongs to different tenant"
}
database.update(existing.copy(
name = updates.name ?: existing.name,
email = updates.email ?: existing.email
))
}
}
Step 3: Create Tenant-Aware Toolsβ
val customerLookupTool = contextAwareTool("lookup_customer") {
description = "Look up customer by ID"
param("customerId", "string", "Customer ID", required = true)
execute { params, context ->
val tenantId = context.tenantId
?: throw IllegalStateException("Tenant required")
val customerId = params["customerId"] as String
customerService.findCustomer(customerId)
}
}
val createCustomerTool = contextAwareTool("create_customer") {
description = "Create new customer"
parameters {
string("name", "Customer name", required = true)
string("email", "Customer email", required = true)
string("phone", "Phone number", required = false)
}
execute { params, context ->
val customer = Customer(
id = UUID.randomUUID().toString(),
name = params["name"] as String,
email = params["email"] as String,
phone = params["phone"] as? String
)
customerService.createCustomer(customer)
}
}
Security Considerationsβ
1. Always Validate Tenant Ownershipβ
suspend fun updateOrder(orderId: String, updates: OrderUpdates) =
withTenant { tenantId ->
val order = findOrder(orderId)
?: throw NotFoundException("Order not found")
// CRITICAL: Verify tenant owns this resource
require(order.tenantId == tenantId) {
"Access denied: Order belongs to different tenant"
}
database.update(order.copy(status = updates.status))
}
2. Prevent Tenant ID Tamperingβ
// β BAD: Trust user input
@PostMapping("/api/orders")
suspend fun createOrder(@RequestBody request: CreateOrderRequest) =
withAgentContext("tenantId" to request.tenantId) { // User controls this!
// ...
}
// β
GOOD: Extract from authentication
@PostMapping("/api/orders")
suspend fun createOrder(
@AuthenticationPrincipal user: AuthenticatedUser,
@RequestBody request: CreateOrderRequest
) = withAgentContext("tenantId" to user.tenantId) { // Verified by auth system
// ...
}
3. Use Row-Level Security (If Available)β
PostgreSQL example:
-- Enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Create policy
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::TEXT);
-- Set tenant in application
SET app.current_tenant = 'ACME';
Then in Kotlin:
suspend fun setTenant() = withTenant { tenantId ->
database.execute("SET app.current_tenant = ?", tenantId)
}
4. Audit Tenant Accessβ
class AuditService : BaseContextAwareService() {
suspend fun logAccess(resource: String, action: String) =
withTenantAndUser { tenantId, userId ->
database.insert(AuditLog(
timestamp = Instant.now(),
tenantId = tenantId,
userId = userId,
resource = resource,
action = action
))
}
}
val secureTool = contextAwareTool("secure_operation") {
execute { params, context ->
auditService.logAccess("customer", "read")
// Perform operation
}
}
Testing Multi-Tenant Codeβ
Test Tenant Isolationβ
@Test
fun `tenant A cannot access tenant B data`() = runTest {
// Tenant A creates order
val orderA = withAgentContext("tenantId" to "TENANT-A") {
orderService.createOrder(Order(...))
}
// Tenant B tries to access tenant A's order
val result = withAgentContext("tenantId" to "TENANT-B") {
runCatching {
orderService.findOrder(orderA.id)
}
}
assertTrue(result.isFailure)
}
Test Cross-Tenant Updatesβ
@Test
fun `cannot update resource from different tenant`() = runTest {
val order = withAgentContext("tenantId" to "TENANT-A") {
orderService.createOrder(Order(...))
}
// Try to update from different tenant
val result = withAgentContext("tenantId" to "TENANT-B") {
runCatching {
orderService.updateOrder(order.id, OrderUpdates(...))
}
}
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()!!.message!!.contains("different tenant"))
}
Performance Optimizationβ
Cache Per-Tenantβ
val tenantConfigTool = contextAwareTool("get_tenant_config") {
cache {
ttl = 3600 // 1 hour
maxSize = 1000
// Tenant-specific cache keys
keyBuilder = { params, context ->
"config:${context.tenantId}"
}
}
execute { params, context ->
configService.getConfig(context.tenantId!!)
}
}
Connection Pooling Per-Tenantβ
class TenantAwareDataSource {
private val pools = mutableMapOf<String, HikariDataSource>()
fun getDataSource(tenantId: String): DataSource {
return pools.getOrPut(tenantId) {
HikariDataSource(HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://localhost/db_$tenantId"
maximumPoolSize = 10
minimumIdle = 2
})
}
}
}
Migration Strategiesβ
Adding Multi-Tenancy to Existing Appβ
- Add tenant_id column to all tables
- Update queries to filter by tenant
- Migrate to context-aware services
- Update tools to use context
// Before
class OrderService {
fun findOrders(): List<Order> {
return database.query("SELECT * FROM orders")
}
}
// After
class OrderService : BaseContextAwareService() {
suspend fun findOrders() = withTenant { tenantId ->
database.query(
"SELECT * FROM orders WHERE tenant_id = ?",
tenantId
)
}
}
Best Practicesβ
- Always use context-aware services for data access
- Validate tenant ownership before updates/deletes
- Never trust client-provided tenant IDs
- Audit cross-tenant access attempts
- Test tenant isolation thoroughly
- Use database-level isolation where possible
- Cache per-tenant for better performance
- Monitor tenant-specific metrics
See Alsoβ
- Context-Aware Tools - Building tenant-aware tools
- Context Testing - Testing multi-tenant code
- Context Propagation - How context works
- Production Examples - Real-world examples