Multi-Tenancy
The IDK provides built-in support for multi-tenant applications through its scope hierarchy. This enables you to isolate data, configuration, and keys between different tenants (organizations) and principals (users) within your application.
Tenant and Principal Concepts
In the IDK's multi-tenancy model, a tenant represents an organization or isolated environment, while a principal represents an individual user or system account within that tenant.
The combination of tenant and principal creates a unique user context that isolates:
- Configuration values specific to that tenant or principal
- Cryptographic keys stored in the key management system
- Session state and cached data
- Logging context for traceability
Creating Tenant-Specific Contexts
When your application serves multiple organizations, create a user context for each tenant-principal combination:
- Android/kotlin
- iOS/Swift
// User from Organization A
val orgAUserContext = appComponent.userContextManager.createOrGetFromInputs(
tenantInput = DefaultTenantInputString(tenant = "org-a.example.com"),
principalInput = DefaultPrincipalInputString(principal = "alice@org-a.example.com"),
makeActive = false
)
// User from Organization B
val orgBUserContext = appComponent.userContextManager.createOrGetFromInputs(
tenantInput = DefaultTenantInputString(tenant = "org-b.example.com"),
principalInput = DefaultPrincipalInputString(principal = "bob@org-b.example.com"),
makeActive = false
)
// Each context has isolated configuration and data
val orgASession = orgAUserContext.sessionContextManager.createOrGetFromId("session-1", true)
val orgBSession = orgBUserContext.sessionContextManager.createOrGetFromId("session-1", true)
// Keys generated in one context are not visible in the other
val orgAKeyManager = orgASession.component.keyManagerService
val orgBKeyManager = orgBSession.component.keyManagerService
// User from Organization A
let orgAUserContext = appComponent.userContextManager.createOrGetFromInputs(
tenantInput: DefaultTenantInputString(tenant: "org-a.example.com"),
principalInput: DefaultPrincipalInputString(principal: "alice@org-a.example.com"),
makeActive: false
)
// User from Organization B
let orgBUserContext = appComponent.userContextManager.createOrGetFromInputs(
tenantInput: DefaultTenantInputString(tenant: "org-b.example.com"),
principalInput: DefaultPrincipalInputString(principal: "bob@org-b.example.com"),
makeActive: false
)
// Each context has isolated configuration and data
let orgASession = orgAUserContext.sessionContextManager.createOrGetFromId(sessionId: "session-1", makeActive: true)
let orgBSession = orgBUserContext.sessionContextManager.createOrGetFromId(sessionId: "session-1", makeActive: true)
// Keys generated in one context are not visible in the other
let orgAKeyManager = orgASession.component.keyManagerService
let orgBKeyManager = orgBSession.component.keyManagerService
Configuration Scoping
Configuration values can be set at different scope levels, allowing you to define defaults that can be overridden per tenant or principal.
Scope Precedence
When resolving a configuration value, the IDK checks scopes in this order:
- Session scope (most specific)
- Principal scope
- Tenant scope
- Application scope (least specific)
The first scope that has a value for the requested property wins.
Setting Tenant-Specific Configuration
- Android/kotlin
- iOS/Swift
// Application-wide default
appComponent.configProvider.setProperty(
scope = ConfigScope.APP,
key = "api.base.url",
value = "https://api.default.example.com"
)
// Tenant-specific override
val tenantConfigProvider = userContext.component.configProvider
tenantConfigProvider.setProperty(
scope = ConfigScope.TENANT,
key = "api.base.url",
value = "https://api.tenant-a.example.com"
)
// When resolved in this tenant's context, returns the tenant-specific value
val apiUrl = tenantConfigProvider.getProperty("api.base.url")
// Returns: "https://api.tenant-a.example.com"
// Application-wide default
appComponent.configProvider.setProperty(
scope: .app,
key: "api.base.url",
value: "https://api.default.example.com"
)
// Tenant-specific override
let tenantConfigProvider = userContext.component.configProvider
tenantConfigProvider.setProperty(
scope: .tenant,
key: "api.base.url",
value: "https://api.tenant-a.example.com"
)
// When resolved in this tenant's context, returns the tenant-specific value
let apiUrl = tenantConfigProvider.getProperty(key: "api.base.url")
// Returns: "https://api.tenant-a.example.com"
Key Isolation
Cryptographic keys are automatically isolated by the user context. Keys generated or stored in one tenant's context are not accessible from another tenant's context.
- Android/kotlin
- iOS/Swift
// Generate a key in Tenant A's context
val tenantASession = tenantAContext.sessionContextManager.createOrGetFromId("session", true)
val tenantAKeyManager = tenantASession.component.keyManagerService
val keyPairA = tenantAKeyManager.generateKeyAsync(
alg = SignatureAlgorithm.ES256,
keyId = "signing-key"
)
// This key is NOT accessible from Tenant B's context
val tenantBSession = tenantBContext.sessionContextManager.createOrGetFromId("session", true)
val tenantBKeyManager = tenantBSession.component.keyManagerService
// Attempting to retrieve the same key ID returns null or throws
val keyPairB = tenantBKeyManager.getKey("signing-key")
// keyPairB is null - the key doesn't exist in Tenant B's context
// Generate a key in Tenant A's context
let tenantASession = tenantAContext.sessionContextManager.createOrGetFromId(sessionId: "session", makeActive: true)
let tenantAKeyManager = tenantASession.component.keyManagerService
let keyPairA = try await tenantAKeyManager.generateKeyAsync(
alg: .es256,
keyId: "signing-key"
)
// This key is NOT accessible from Tenant B's context
let tenantBSession = tenantBContext.sessionContextManager.createOrGetFromId(sessionId: "session", makeActive: true)
let tenantBKeyManager = tenantBSession.component.keyManagerService
// Attempting to retrieve the same key ID returns null
let keyPairB = tenantBKeyManager.getKey(keyId: "signing-key")
// keyPairB is nil - the key doesn't exist in Tenant B's context
Data Storage Isolation
The key-value store and party data store also respect tenant boundaries:
// Store data in Tenant A's context
val tenantAStore = tenantASession.component.keyValueStore
tenantAStore.put("user-preferences", userPreferencesJson)
// This data is NOT accessible from Tenant B's context
val tenantBStore = tenantBSession.component.keyValueStore
val preferences = tenantBStore.get("user-preferences")
// preferences is null - data doesn't exist in Tenant B's context
Mobile Applications
For mobile applications that don't require true multi-tenancy (where a single user uses the app), you can still use the scope system with the anonymous context:
- Android/kotlin
- iOS/Swift
// Single-user mobile app
val userContext = appComponent.userContextManager.getAnonymous(makeActive = true)
// The anonymous context provides default tenant and principal values
// All data is still properly scoped, just not across multiple tenants
// Single-user mobile app
let userContext = appComponent.userContextManager.getAnonymous(makeActive: true)
// The anonymous context provides default tenant and principal values
// All data is still properly scoped, just not across multiple tenants
Server Applications
For server applications handling requests from multiple tenants, create a user context for each incoming request based on the authenticated user:
suspend fun handleRequest(request: HttpRequest): HttpResponse {
// Extract tenant and principal from the authenticated request
val tenantId = request.headers["X-Tenant-Id"] ?: "default"
val principalId = request.authentication.principal
// Create or retrieve the user context
val userContext = appComponent.userContextManager.createOrGetFromInputs(
tenantInput = DefaultTenantInputString(tenant = tenantId),
principalInput = DefaultPrincipalInputString(principal = principalId),
makeActive = false
)
// Create a request-scoped session
val requestId = request.headers["X-Request-Id"] ?: UUID.randomUUID().toString()
val session = userContext.sessionContextManager.createOrGetFromId(requestId, true)
try {
// Handle the request with tenant-isolated services
val keyManager = session.component.keyManagerService
// ... process request
} finally {
// Clean up the session after the request
userContext.sessionContextManager.destroy(requestId)
}
}
Best Practices
When implementing multi-tenancy:
Use meaningful tenant identifiers. Domain names or organization IDs work well and make logs easier to understand.
Don't hardcode tenant values. Accept them from authentication tokens or request headers.
Clean up sessions promptly in server applications. This prevents resource leaks when handling many concurrent requests.
Test with multiple tenants. Verify that data truly is isolated by creating test cases that attempt cross-tenant access.
Consider tenant provisioning. You may need additional logic to provision tenant-specific configuration when a new tenant onboards.