Multi-Tenancy
The IDK provides built-in support for multi-tenant applications through its scope hierarchy. Configuration, cryptographic keys, and data are isolated between tenants (organizations) and principals (users) automatically.
Tenant and Principal Concepts
A tenant represents an organization or isolated environment. A principal represents an individual user or system account within that tenant. Together they form a unique 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
The Scope Hierarchy
Configuration is organized into three levels, represented by the ConfigLevel enum:
| Scope | ConfigLevel | Level | Resolves from |
|---|---|---|---|
| App | APP | 10 | Application-wide sources only |
| Tenant | TENANT | 20 | Tenant sources, then falls back to App |
| Principal | PRINCIPAL | 30 | Principal sources, then Tenant, then App |
When resolving a property, the system checks the most specific scope first. If no value is found, it walks up the hierarchy until it reaches APP.
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 = appGraph.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 = appGraph.userContextManager.createOrGetFromInputs(
tenantInput = DefaultTenantInputString(tenant = "org-b.example.com"),
principalInput = DefaultPrincipalInputString(principal = "bob@org-b.example.com"),
makeActive = false
)
// Each context has its own isolated ConfigService
val orgASession = orgAUserContext.sessionContextManager.createOrGetFromId("session-1", true)
val orgBSession = orgBUserContext.sessionContextManager.createOrGetFromId("session-1", true)
// User from Organization A
let orgAUserContext = appGraph.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 = appGraph.userContextManager.createOrGetFromInputs(
tenantInput: DefaultTenantInputString(tenant: "org-b.example.com"),
principalInput: DefaultPrincipalInputString(principal: "bob@org-b.example.com"),
makeActive: false
)
let orgASession = orgAUserContext.sessionContextManager.createOrGetFromId(sessionId: "session-1", makeActive: true)
let orgBSession = orgBUserContext.sessionContextManager.createOrGetFromId(sessionId: "session-1", makeActive: true)
Scoped ConfigService Instances
Each scope level has its own ConfigService implementation that resolves properties within its scope and inherits from its parent:
// App-level - shared defaults, no tenant context
val appConfigService: AppConfigService = appGraph.appConfigService
// Tenant-level - inherits from app, adds tenant-specific sources
val tenantConfigService: TenantConfigService = userContext.graph.tenantConfigService
// Principal-level - inherits from tenant, adds user-specific sources
val principalConfigService: PrincipalConfigService = userContext.graph.principalConfigService
Each of these implements the same ConfigService interface, so your business code can accept any of them without knowing the scope.
Setting Tenant-Specific Configuration
Tenant-specific values are provided through scoped property sources. The approach depends on where you store configuration.
Using Property Files
Place tenant-specific files in the config directory under a tenant/{tenantId}/ path:
api.subscription.key=acme-key-123
feature.premium.enabled=true
The PropertiesFileTenantPropertySource loads these files automatically.
Using Programmatic Sources
Add values to the default mutable property sources at each scope:
// App-level default (visible to all tenants)
val appSource = DefaultAppMapPropertySource
appSource.addProperty("api.base.url", "https://api.default.example.com")
// Tenant-level override
val tenantSource = DefaultTenantMapPropertySource
tenantSource.addProperty("api.base.url", "https://api.acme.example.com")
Using Database Sources (VDX)
With VDX database persistence, settings are stored with scope metadata. When the TenantConfigService resolves a property, the database source queries for that tenant's values first, then falls back to APP-level rows.
Resolution Example
// Given:
// APP source: api.base.url = "https://api.default.example.com"
// TENANT source: api.base.url = "https://api.acme.example.com" (for tenant "acme")
// Reading from the tenant config service in tenant "acme" context:
val url = tenantConfigService.getProperty("api.base.url", String::class)
// Returns: "https://api.acme.example.com" (tenant override wins)
// Reading from the app config service:
val appUrl = appConfigService.getProperty("api.base.url", String::class)
// Returns: "https://api.default.example.com" (no tenant context)
Property Protection Across Scopes
Protection flags prevent lower scopes from overriding or reading sensitive values.
FINAL Properties
A property marked FINAL at APP scope cannot be overridden by any TENANT or PRINCIPAL source. This is useful for infrastructure settings that must remain consistent:
# This value cannot be changed per-tenant
export FINAL_DATABASE_HOST=prod-db.example.com
If a tenant source provides a value for database.host, it is ignored because the APP-level source has the FINAL flag.
PROTECTED Properties
A property marked PROTECTED at APP scope cannot be interpolated from a TENANT or PRINCIPAL scope. This prevents a tenant's configuration from using ${app:admin.api.key} to read an app-level secret:
export PROTECTED_ADMIN_API_KEY=super-secret
A tenant-scoped property file referencing ${app:admin.api.key} will fail interpolation rather than leaking the value.
Key Isolation
Cryptographic keys are automatically isolated by user context. Keys generated or stored in one tenant's context are not accessible from another:
- Android/Kotlin
- iOS/Swift
// Generate a key in Tenant A's context
val tenantASession = tenantAContext.sessionContextManager.createOrGetFromId("session", true)
val tenantAKeyManager = tenantASession.graph.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.graph.keyManagerService
val keyPairB = tenantBKeyManager.getKey("signing-key")
// keyPairB is null - the key doesn't exist in Tenant B's context
let tenantASession = tenantAContext.sessionContextManager.createOrGetFromId(sessionId: "session", makeActive: true)
let tenantAKeyManager = tenantASession.graph.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.graph.keyManagerService
let keyPairB = tenantBKeyManager.getKey(keyId: "signing-key")
// keyPairB is nil
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.graph.keyValueStore
tenantAStore.put("user-preferences", userPreferencesJson)
// This data is NOT accessible from Tenant B's context
val tenantBStore = tenantBSession.graph.keyValueStore
val preferences = tenantBStore.get("user-preferences")
// preferences is null
Mobile Applications
For mobile applications that don't require multi-tenancy, use the anonymous context:
- Android/Kotlin
- iOS/Swift
val userContext = appGraph.userContextManager.getAnonymous(makeActive = true)
// The anonymous context provides default tenant and principal values
// All data is still properly scoped, just not across multiple tenants
let userContext = appGraph.userContextManager.getAnonymous(makeActive: true)
Server Applications
For server applications handling requests from multiple tenants, create a user context per request based on the authenticated user:
suspend fun handleRequest(request: HttpRequest): HttpResponse {
val tenantId = request.headers["X-Tenant-Id"] ?: "default"
val principalId = request.authentication.principal
val userContext = appGraph.userContextManager.createOrGetFromInputs(
tenantInput = DefaultTenantInputString(tenant = tenantId),
principalInput = DefaultPrincipalInputString(principal = principalId),
makeActive = false
)
val requestId = request.headers["X-Request-Id"] ?: UUID.randomUUID().toString()
val session = userContext.sessionContextManager.createOrGetFromId(requestId, true)
try {
// All services accessed via session.graph are tenant-isolated
val configService = session.graph.configService
val keyManager = session.graph.keyManagerService
// ... process request
} finally {
userContext.sessionContextManager.destroy(requestId)
}
}
Best Practices
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.
Use FINAL for shared infrastructure. Mark database hosts, encryption keys, and other values that must not vary per tenant as FINAL at APP scope.