Skip to main content
Version: v0.25.0 (Latest)

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:

ScopeConfigLevelLevelResolves from
AppAPP10Application-wide sources only
TenantTENANT20Tenant sources, then falls back to App
PrincipalPRINCIPAL30Principal 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:

// 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)

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:

config/tenant/acme/tenant-production.properties
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:

// 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

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:

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

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.