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:
@@ -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,
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+4
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user