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,
@@ -420,6 +420,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/auth/logout",
"/v1/admin/providers",
"/v1/admin/providers/{providerId}/api-key",
"/v1/admin/providers/{providerId}/api-key/reveal",
"/v1/admin/overview",
"/v1/admin/referrals",
"/v1/admin/analytics",
@@ -3,8 +3,10 @@ package com.osglab.account.features.admin.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminStepUpResult
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorService
@@ -25,10 +27,12 @@ import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
import com.osglab.account.features.gateway.credentials.GatewayCredentialSource
import com.osglab.account.features.gateway.credentials.GatewayCredentialStatus
import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException
import com.osglab.account.features.gateway.credentials.RevealedProviderApiKey
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
@@ -520,6 +524,77 @@ class AdminRoutesTest {
body shouldNotContain "secret-runtime-key"
}
@Test
fun `super admin can reveal provider API key after TOTP step-up`() = testApplication {
val authService = mockk<AdminAuthService>()
val credentialService = mockk<GatewayCredentialService>()
coEvery {
authService.verifyStepUpTotp(any(), any(), any(), any(), any(), any())
} returns AdminStepUpResult.VERIFIED
coEvery {
credentialService.revealApiKey(GatewayCredentialProvider.DEEPSEEK)
} returns RevealedProviderApiKey("secret-runtime-key")
application {
installAdminTestRoutes(
authService = authService,
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
credentialService = credentialService,
)
}
val response = client.post("/v1/admin/providers/deepseek/api-key/reveal") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody("""{"totpCode":"123456"}""")
}
response.status shouldBe HttpStatusCode.OK
response.headers[HttpHeaders.CacheControl] shouldBe "no-store"
response.bodyAsText() shouldBe """{"apiKey":"secret-runtime-key"}"""
coVerify {
authService.verifyStepUpTotp(
any(),
"123456",
AdminAuditAction.PROVIDER_API_KEY_REVEALED,
"PROVIDER",
"deepseek",
any(),
)
}
}
@Test
fun `invalid step-up TOTP never reveals provider API key`() = testApplication {
val authService = mockk<AdminAuthService>()
val credentialService = mockk<GatewayCredentialService>(relaxed = true)
coEvery {
authService.verifyStepUpTotp(any(), any(), any(), any(), any(), any())
} returns AdminStepUpResult.INVALID_TOTP
application {
installAdminTestRoutes(
authService = authService,
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
credentialService = credentialService,
)
}
val response = client.post("/v1/admin/providers/deepseek/api-key/reveal") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody("""{"totpCode":"000000"}""")
}
response.status shouldBe HttpStatusCode.Unauthorized
response.bodyAsText() shouldBe """{"code":"INVALID_TOTP"}"""
coVerify(exactly = 0) { credentialService.revealApiKey(any()) }
}
@Test
fun `non super admin cannot update provider API keys`() = testApplication {
val credentialService = mockk<GatewayCredentialService>(relaxed = true)
@@ -8,6 +8,9 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
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.AdminRole
import com.osglab.account.features.admin.models.AdminStepUpResult
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import io.kotest.core.spec.style.FunSpec
@@ -140,6 +143,49 @@ class AdminAuthServiceTest : FunSpec({
(result is AdminLoginResult.Authenticated) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
test("step-up accepts the current TOTP after login and audits the reveal") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val login = fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
)
(login is AdminLoginResult.Authenticated) shouldBe true
val result = fixture.service.verifyStepUpTotp(
principal = fixture.principal(),
totpCode = code,
action = AdminAuditAction.PROVIDER_API_KEY_REVEALED,
targetType = "PROVIDER",
targetId = "deepseek",
requestId = "reveal-1",
)
result shouldBe AdminStepUpResult.VERIFIED
fixture.repository.audits.last().let {
it.action shouldBe AdminAuditAction.PROVIDER_API_KEY_REVEALED
it.outcome shouldBe AdminAuditOutcome.SUCCESS
it.targetId shouldBe "deepseek"
it.requestId shouldBe "reveal-1"
}
}
test("invalid step-up TOTP is denied and audited") {
val fixture = authFixture()
val result = fixture.service.verifyStepUpTotp(
principal = fixture.principal(),
totpCode = "000000",
action = AdminAuditAction.PROVIDER_API_KEY_REVEALED,
targetType = "PROVIDER",
targetId = "volcengine",
)
result shouldBe AdminStepUpResult.INVALID_TOTP
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED
}
})
private data class AuthFixture(
@@ -151,6 +197,13 @@ private data class AuthFixture(
val lockPolicy: AdminLoginLockPolicy,
val tokenGenerator: SecureTokenGenerator,
) {
fun principal() = AdminPrincipal(
operatorId = repository.operatorId,
sessionId = UUID.randomUUID(),
normalizedUsername = repository.username,
role = AdminRole.SUPER_ADMIN,
)
fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService(
repository = repository,
passwordHasher = hasher,
@@ -57,6 +57,10 @@ class GatewayCredentialServiceTest : StringSpec({
resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe "runtime-volcengine"
resolver.status(GatewayCredentialProvider.VOLCENGINE).source shouldBe
GatewayCredentialSource.RUNTIME_OVERRIDE
val revealed = GatewayCredentialService(repository, resolver, encryptor)
.revealApiKey(GatewayCredentialProvider.VOLCENGINE)
revealed?.value shouldBe "runtime-volcengine"
revealed.toString() shouldNotContain "runtime-volcengine"
}
"two updates make new resolutions use the latest encrypted key" {