Skip to main content
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

FormatProviderExample
vault://HashiCorp Vaultvault://secrets/db/password
aws://AWS Secrets Manageraws://prod/database/credentials
azure://Azure Key Vaultazure://my-vault/db-password
env://Environment Variableenv://DATABASE_PASSWORD
file://File-basedfile:///etc/secrets/db-password

Built-in Secret Providers

Environment Variable Provider

Resolves secrets from environment variables:

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"
))
}
}
}

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")
}