Add TOTP-gated provider API key reveal

Allow super administrators to inspect effective provider credentials only after audited, rate-limited step-up verification.
This commit is contained in:
Rocky
2026-08-22 17:56:35 +08:00
parent 636a8541bc
commit 544e0d7356
14 changed files with 524 additions and 6 deletions
@@ -120,6 +120,7 @@ enum class AdminAuditAction {
CONTENT_HINT_FEED_SETTINGS_UPDATED,
CONTENT_HINT_FEED_GENERATED,
PROVIDER_API_KEY_UPDATED,
PROVIDER_API_KEY_REVEALED,
}
enum class AdminAuditOutcome {
@@ -127,6 +128,12 @@ enum class AdminAuditOutcome {
DENIED,
}
enum class AdminStepUpResult {
VERIFIED,
INVALID_TOTP,
LOCKED,
}
data class NewAdminAuditEvent(
val id: UUID = UUID.randomUUID(),
val actorOperatorId: UUID?,
@@ -13,6 +13,7 @@ import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminStepUpResult
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.services.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService
@@ -63,6 +64,7 @@ import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.plugins.ratelimit.rateLimit
import io.ktor.server.request.header
import io.ktor.server.request.receive
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
@@ -191,6 +193,55 @@ fun Route.adminApiRoutes(
call.respond(service.listStatuses())
}
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
post("/providers/{providerId}/api-key/reveal") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
if (principal.role != AdminRole.SUPER_ADMIN) {
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
return@post
}
val provider = GatewayCredentialProvider.fromProviderId(
call.parameters["providerId"],
) ?: run {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("PROVIDER_NOT_FOUND"))
return@post
}
val request = call.receiveAdminRequest<ProviderApiKeyRevealRequest>() ?: return@post
when (
authService.verifyStepUpTotp(
principal = principal,
totpCode = request.totpCode,
action = AdminAuditAction.PROVIDER_API_KEY_REVEALED,
targetType = "PROVIDER",
targetId = provider.providerId,
requestId = call.request.header("X-Request-ID"),
)
) {
AdminStepUpResult.LOCKED -> {
call.respond(HttpStatusCode.TooManyRequests, AdminErrorResponse("RATE_LIMITED"))
return@post
}
AdminStepUpResult.INVALID_TOTP -> {
call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("INVALID_TOTP"))
return@post
}
AdminStepUpResult.VERIFIED -> Unit
}
val revealed = service.revealApiKey(provider) ?: run {
call.respond(
HttpStatusCode.NotFound,
AdminErrorResponse("PROVIDER_API_KEY_NOT_CONFIGURED"),
)
return@post
}
call.response.header(HttpHeaders.CacheControl, "no-store")
call.response.header(HttpHeaders.Pragma, "no-cache")
call.respond(ProviderApiKeyRevealResponse(revealed.value))
}
}
put("/providers/{providerId}/api-key") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put
if (principal.role != AdminRole.SUPER_ADMIN) {
@@ -1146,6 +1197,16 @@ private data class AdminGrantResponse(val transactionId: String, val balanceAfte
@Serializable
private data class ProviderApiKeyUpdateRequest(val apiKey: String)
@Serializable
private class ProviderApiKeyRevealRequest(val totpCode: String) {
override fun toString(): String = "ProviderApiKeyRevealRequest(totpCode=[REDACTED])"
}
@Serializable
private class ProviderApiKeyRevealResponse(val apiKey: String) {
override fun toString(): String = "ProviderApiKeyRevealResponse(apiKey=[REDACTED])"
}
@Serializable
private data class AdminOperatorCreateRequest(
val username: String,
@@ -10,6 +10,7 @@ import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminSessionCredentials
import com.osglab.account.features.admin.models.AdminStepUpResult
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository
@@ -155,6 +156,50 @@ class AdminAuthService(
)
}
suspend fun verifyStepUpTotp(
principal: AdminPrincipal,
totpCode: String,
action: AdminAuditAction,
targetType: String,
targetId: String,
requestId: String? = null,
): AdminStepUpResult {
val now = clock.instant()
val operator = repository.findOperatorForAuthentication(principal.normalizedUsername)
val result = when {
operator == null ||
operator.id != principal.operatorId ||
operator.disabledAt != null -> AdminStepUpResult.INVALID_TOTP
operator.lockState.isLockedAt(now) -> AdminStepUpResult.LOCKED
verifyTotp(
operator.id,
operator.encryptedTotpSecret,
totpCode,
now,
) != null -> AdminStepUpResult.VERIFIED
else -> AdminStepUpResult.INVALID_TOTP
}
repository.appendAudit(
NewAdminAuditEvent(
actorOperatorId = principal.operatorId,
action = action,
outcome = if (result == AdminStepUpResult.VERIFIED) {
AdminAuditOutcome.SUCCESS
} else {
AdminAuditOutcome.DENIED
},
targetType = targetType,
targetId = targetId,
requestId = validateRequestId(requestId),
occurredAt = now,
),
)
return result
}
private suspend fun failAuthentication(
operatorId: UUID,
now: Instant,
@@ -9,6 +9,14 @@ import java.util.UUID
class InvalidProviderApiKeyException : IllegalArgumentException("Invalid provider API key")
class RevealedProviderApiKey(val value: String) {
init {
require(value.isNotBlank())
}
override fun toString(): String = "RevealedProviderApiKey([REDACTED])"
}
class GatewayCredentialService(
private val repository: GatewayCredentialRepository,
private val resolver: DatabaseProviderApiKeyResolver,
@@ -18,6 +26,9 @@ class GatewayCredentialService(
suspend fun listStatuses(): List<GatewayCredentialStatus> =
GatewayCredentialProvider.entries.map { resolver.status(it) }
suspend fun revealApiKey(provider: GatewayCredentialProvider): RevealedProviderApiKey? =
resolver.resolve(provider)?.let(::RevealedProviderApiKey)
suspend fun updateApiKey(
provider: GatewayCredentialProvider,
apiKey: String,