Version: v0.13
Secret Management
The IDK provides secure handling of secrets and sensitive configuration values through the SecretProvider interface and secret reference system.
Overview
Secrets require special handling:
- Never logged - Secret values are masked in logs
- External storage - Actual values stored in secure vaults
- Reference-based - Configuration stores references, not values
- Lazy resolution - Secrets resolved only when needed
SecretProvider Interface
interface SecretProvider {
/**
* Resolve a secret reference to its actual value.
*
* @param reference The secret reference (e.g., "vault://secrets/db/password")
* @return The resolved secret value or error
*/
suspend fun resolveSecret(reference: String): IdkResult<String, IdkError>
/**
* Check if a reference is a valid secret reference.
*/
fun isSecretReference(value: String): Boolean
/**
* Get the provider identifier.
*/
val providerId: String
}
Secret References
Secrets are stored as references rather than actual values:
// Configuration stores a reference
val passwordRef = "vault://secrets/database/password"
// At runtime, reference is resolved to actual value
val actualPassword = secretProvider.resolveSecret(passwordRef)
Reference Formats
| Format | Provider | Example |
|---|---|---|
vault:// | HashiCorp Vault | vault://secrets/db/password |
aws:// | AWS Secrets Manager | aws://prod/database/credentials |
azure:// | Azure Key Vault | azure://my-vault/db-password |
env:// | Environment Variable | env://DATABASE_PASSWORD |
file:// | File-based | file:///etc/secrets/db-password |
Built-in Secret Providers
Environment Variable Provider
Resolves secrets from environment variables:
- Android/kotlin
- iOS/Swift
class EnvironmentSecretProvider : SecretProvider {
override val providerId = "env"
override fun isSecretReference(value: String): Boolean =
value.startsWith("env://")
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
if (!isSecretReference(reference)) {
return Err(IdkError.ILLEGAL_ARGUMENT_ERROR(
message = "Invalid env reference: $reference"
))
}
val envVar = reference.removePrefix("env://")
val value = System.getenv(envVar)
return if (value != null) {
Ok(value)
} else {
Err(IdkError.NOT_FOUND_ERROR(
message = "Environment variable not found: $envVar"
))
}
}
}
class EnvironmentSecretProvider: SecretProvider {
let providerId = "env"
func isSecretReference(value: String) -> Bool {
return value.hasPrefix("env://")
}
func resolveSecret(reference: String) async -> IdkResult<String, IdkError> {
guard isSecretReference(value: reference) else {
return .err(IdkError.illegalArgumentError(
message: "Invalid env reference: \(reference)"
))
}
let envVar = reference.replacingOccurrences(of: "env://", with: "")
guard let value = ProcessInfo.processInfo.environment[envVar] else {
return .err(IdkError.notFoundError(
message: "Environment variable not found: \(envVar)"
))
}
return .ok(value)
}
}
File-Based Provider
Reads secrets from files (useful for Kubernetes secrets):
class FileSecretProvider : SecretProvider {
override val providerId = "file"
override fun isSecretReference(value: String): Boolean =
value.startsWith("file://")
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
val path = reference.removePrefix("file://")
return try {
val content = File(path).readText().trim()
Ok(content)
} catch (e: IOException) {
Err(IdkError.IO_ERROR(
message = "Failed to read secret file: $path",
cause = e
))
}
}
}
Using Secrets in Configuration
Marking Settings as Secret References
// In database persistence
val setting = SettingEntity(
propertyKey = "database.password",
propertyValue = "vault://secrets/database/password",
isSecretRef = true, // Marks as secret reference
// ...
)
Resolving Secrets
@Inject
class DatabaseConnectionFactory(
private val configProvider: ConfigProvider,
private val secretProvider: SecretProvider
) {
suspend fun createConnection(): Connection {
val host = configProvider.getProperty("database.host", String::class)
?: throw ConfigurationException("database.host not configured")
val username = configProvider.getProperty("database.username", String::class)
?: throw ConfigurationException("database.username not configured")
// Password might be a secret reference
val passwordValue = configProvider.getProperty("database.password", String::class)
?: throw ConfigurationException("database.password not configured")
val password = if (secretProvider.isSecretReference(passwordValue)) {
secretProvider.resolveSecret(passwordValue).getOrElse { error ->
throw ConfigurationException("Failed to resolve database password: ${error.message}")
}
} else {
passwordValue
}
return DriverManager.getConnection(
"jdbc:postgresql://$host/mydb",
username,
password
)
}
}
Automatic Secret Resolution
The PropertySource can automatically resolve secrets:
class SecretAwarePropertySource(
private val delegate: PropertySource<*>,
private val secretProvider: SecretProvider
) : PropertySource<Any> {
override fun <V : Any> getProperty(key: String, type: KClass<V>): V? {
val value = delegate.getProperty(key, String::class) ?: return null
val resolvedValue = if (secretProvider.isSecretReference(value)) {
runBlocking {
secretProvider.resolveSecret(value).getOrNull()
}
} else {
value
}
return convertValue(resolvedValue, type)
}
}
Implementing Vault Providers
HashiCorp Vault
class VaultSecretProvider(
private val vaultClient: VaultClient,
private val mountPath: String = "secret"
) : SecretProvider {
override val providerId = "vault"
override fun isSecretReference(value: String): Boolean =
value.startsWith("vault://")
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
// Parse: vault://secrets/database/password
val path = reference.removePrefix("vault://")
val parts = path.split("/")
if (parts.size < 2) {
return Err(IdkError.ILLEGAL_ARGUMENT_ERROR(
message = "Invalid vault reference format: $reference"
))
}
val secretPath = parts.dropLast(1).joinToString("/")
val key = parts.last()
return try {
val secret = vaultClient.readSecret("$mountPath/data/$secretPath")
val value = secret.data?.get(key) as? String
?: return Err(IdkError.NOT_FOUND_ERROR(
message = "Secret key not found: $key"
))
Ok(value)
} catch (e: VaultException) {
Err(IdkError.EXTERNAL_SERVICE_ERROR(
message = "Vault error: ${e.message}",
cause = e
))
}
}
}
AWS Secrets Manager
class AwsSecretsManagerProvider(
private val client: SecretsManagerClient
) : SecretProvider {
override val providerId = "aws"
override fun isSecretReference(value: String): Boolean =
value.startsWith("aws://")
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
// Parse: aws://prod/database/credentials#password
val uri = reference.removePrefix("aws://")
val (secretId, key) = if ("#" in uri) {
val parts = uri.split("#", limit = 2)
parts[0] to parts[1]
} else {
uri to null
}
return try {
val request = GetSecretValueRequest.builder()
.secretId(secretId)
.build()
val response = client.getSecretValue(request)
val secretString = response.secretString()
if (key != null) {
// JSON secret with specific key
val json = Json.parseToJsonElement(secretString).jsonObject
val value = json[key]?.jsonPrimitive?.content
?: return Err(IdkError.NOT_FOUND_ERROR(
message = "Key not found in secret: $key"
))
Ok(value)
} else {
Ok(secretString)
}
} catch (e: SecretsManagerException) {
Err(IdkError.EXTERNAL_SERVICE_ERROR(
message = "AWS Secrets Manager error: ${e.message}",
cause = e
))
}
}
}
Azure Key Vault
class AzureKeyVaultSecretProvider(
private val client: SecretClient
) : SecretProvider {
override val providerId = "azure"
override fun isSecretReference(value: String): Boolean =
value.startsWith("azure://")
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
// Parse: azure://my-vault/secret-name
val path = reference.removePrefix("azure://")
val secretName = path.substringAfterLast("/")
return try {
val secret = client.getSecret(secretName)
Ok(secret.value)
} catch (e: ResourceNotFoundException) {
Err(IdkError.NOT_FOUND_ERROR(
message = "Secret not found: $secretName"
))
} catch (e: Exception) {
Err(IdkError.EXTERNAL_SERVICE_ERROR(
message = "Azure Key Vault error: ${e.message}",
cause = e
))
}
}
}
Composite Secret Provider
Combine multiple providers:
class CompositeSecretProvider(
private val providers: List<SecretProvider>
) : SecretProvider {
override val providerId = "composite"
override fun isSecretReference(value: String): Boolean =
providers.any { it.isSecretReference(value) }
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
val provider = providers.find { it.isSecretReference(reference) }
?: return Err(IdkError.ILLEGAL_ARGUMENT_ERROR(
message = "No provider found for reference: $reference"
))
return provider.resolveSecret(reference)
}
}
// Usage
val secretProvider = CompositeSecretProvider(
listOf(
VaultSecretProvider(vaultClient),
AwsSecretsManagerProvider(awsClient),
EnvironmentSecretProvider(),
FileSecretProvider()
)
)
Secret Caching
Cache resolved secrets with TTL:
class CachingSecretProvider(
private val delegate: SecretProvider,
private val cacheTtl: Duration = 5.minutes
) : SecretProvider {
private val cache = Kache<String, CachedSecret>(
maxSize = 100,
expireAfterWrite = cacheTtl
)
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
cache.get(reference)?.let { cached ->
return Ok(cached.value)
}
return delegate.resolveSecret(reference).also { result ->
if (result.isOk) {
cache.put(reference, CachedSecret(result.value))
}
}
}
fun invalidate(reference: String) {
cache.remove(reference)
}
fun invalidateAll() {
cache.clear()
}
private data class CachedSecret(val value: String)
}
Security Best Practices
1. Never Log Secret Values
// WRONG - logs actual password
log.info("Connecting with password: $password")
// CORRECT - log reference only
log.info("Connecting with password from: $passwordRef")
// CORRECT - mask sensitive values
log.info("Connecting with password: ${password.take(2)}****")
2. Use Short-Lived Credentials
// Prefer short-lived tokens over long-lived secrets
val token = secretProvider.resolveSecret("vault://auth/token")
.getOrElse { throw AuthenticationException("Failed to get token") }
// Token has TTL, automatically rotated
3. Limit Secret Scope
// Store secrets at appropriate scope
configProvider.setProperty(
scope = ConfigScope.TENANT, // Not APP - tenant-isolated
key = "api.key",
value = "vault://secrets/tenant/${tenantId}/api-key"
)
4. Rotate Secrets Regularly
class SecretRotationService(
private val secretProvider: CachingSecretProvider
) {
fun onSecretRotated(secretPath: String) {
// Invalidate cached value to force re-fetch
secretProvider.invalidate(secretPath)
log.info("Secret cache invalidated: $secretPath")
}
}
5. Audit Secret Access
class AuditingSecretProvider(
private val delegate: SecretProvider,
private val auditLog: AuditLog
) : SecretProvider {
override suspend fun resolveSecret(reference: String): IdkResult<String, IdkError> {
val result = delegate.resolveSecret(reference)
auditLog.log(
event = "secret_access",
reference = reference,
success = result.isOk,
principal = currentPrincipal(),
timestamp = Clock.System.now()
)
return result
}
}
6. Handle Resolution Failures
// Don't expose failure details
val password = secretProvider.resolveSecret(passwordRef).getOrElse { error ->
log.error("Failed to resolve secret: ${error.code}") // Log code, not message
throw ConfigurationException("Database credentials unavailable")
}