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
+15
View File
@@ -24,6 +24,8 @@ import type {
CreateOfficialSkillRequest, CreateOfficialSkillRequest,
OfficialSkill, OfficialSkill,
OfficialSkillCatalog, OfficialSkillCatalog,
RevealProviderApiKeyRequest,
RevealProviderApiKeyResponse,
ReferralsQuery, ReferralsQuery,
ReferralOverview, ReferralOverview,
SessionResponse, SessionResponse,
@@ -94,6 +96,7 @@ function safeMessage(status: number, code?: string): string {
HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求", HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求",
HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用", HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用",
PROVIDER_API_KEY_INVALID: "API Key 不符合要求", PROVIDER_API_KEY_INVALID: "API Key 不符合要求",
PROVIDER_API_KEY_NOT_CONFIGURED: "该 Provider 尚未配置可读取的 API Key",
PROVIDER_NOT_FOUND: "不支持该 Provider", PROVIDER_NOT_FOUND: "不支持该 Provider",
RATE_LIMITED: "操作过于频繁,请稍后再试", RATE_LIMITED: "操作过于频繁,请稍后再试",
}; };
@@ -293,6 +296,18 @@ export const adminApi = {
providers: () => request<ManagedProviderOverview>("/providers"), providers: () => request<ManagedProviderOverview>("/providers"),
revealProviderApiKey: (
providerId: ManagedProviderId,
payload: RevealProviderApiKeyRequest,
) =>
request<RevealProviderApiKeyResponse>(
`/providers/${encodeURIComponent(providerId)}/api-key/reveal`,
{
method: "POST",
body: JSON.stringify(payload),
},
),
updateProviderApiKey: ( updateProviderApiKey: (
providerId: ManagedProviderId, providerId: ManagedProviderId,
payload: UpdateProviderApiKeyRequest, payload: UpdateProviderApiKeyRequest,
+10 -1
View File
@@ -20,7 +20,8 @@ export type AdminAuditAction =
| "CONTENT_HINT_PACK_SAVED" | "CONTENT_HINT_PACK_SAVED"
| "CONTENT_HINT_FEED_SETTINGS_UPDATED" | "CONTENT_HINT_FEED_SETTINGS_UPDATED"
| "CONTENT_HINT_FEED_GENERATED" | "CONTENT_HINT_FEED_GENERATED"
| "PROVIDER_API_KEY_UPDATED"; | "PROVIDER_API_KEY_UPDATED"
| "PROVIDER_API_KEY_REVEALED";
export interface SkillLocalization { export interface SkillLocalization {
name: string; name: string;
@@ -141,6 +142,14 @@ export interface UpdateProviderApiKeyRequest {
apiKey: string; apiKey: string;
} }
export interface RevealProviderApiKeyRequest {
totpCode: string;
}
export interface RevealProviderApiKeyResponse {
apiKey: string;
}
export interface CursorPageQuery { export interface CursorPageQuery {
cursor?: string; cursor?: string;
limit?: number; limit?: number;
@@ -1,4 +1,4 @@
import { KeyRound, RefreshCw, ServerCog } from "lucide-react"; import { Eye, EyeOff, KeyRound, RefreshCw, ServerCog } from "lucide-react";
import { useCallback, useEffect, useState, type FormEvent } from "react"; import { useCallback, useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client"; import { adminApi, ApiError } from "../../api/client";
@@ -11,6 +11,7 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
Dialog,
ErrorState, ErrorState,
Input, Input,
LoadingState, LoadingState,
@@ -74,7 +75,7 @@ export function ProvidersPage() {
<PageHeader <PageHeader
eyebrow="Managed Providers" eyebrow="Managed Providers"
title="Provider 配置" title="Provider 配置"
description="替换托管网关使用的上游 API Key。新配置只影响之后开始的请求,现有密钥不会返回浏览器。" description="查看或替换托管网关使用的上游 API Key。新配置只影响之后开始的请求。"
actions={ actions={
<Button variant="secondary" onClick={() => void load()}> <Button variant="secondary" onClick={() => void load()}>
<RefreshCw className="size-4" aria-hidden /> <RefreshCw className="size-4" aria-hidden />
@@ -89,7 +90,7 @@ export function ProvidersPage() {
<KeyRound className="size-4" aria-hidden /> <KeyRound className="size-4" aria-hidden />
</span> </span>
<p> <p>
Key Key Key
</p> </p>
</div> </div>
</Card> </Card>
@@ -124,8 +125,51 @@ function ProviderCard({
onUpdated: (status: ManagedProviderStatus) => void; onUpdated: (status: ManagedProviderStatus) => void;
}) { }) {
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const [currentApiKey, setCurrentApiKey] = useState<string>();
const [revealOpen, setRevealOpen] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [revealError, setRevealError] = useState("");
const [revealing, setRevealing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
function toggleCurrentApiKey() {
if (currentApiKey) {
setCurrentApiKey(undefined);
return;
}
setTotpCode("");
setRevealError("");
setRevealOpen(true);
}
function handleRevealOpenChange(open: boolean) {
setRevealOpen(open);
if (!open) {
setTotpCode("");
setRevealError("");
}
}
async function reveal(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!/^\d{6}$/.test(totpCode)) {
setRevealError("请输入 6 位动态验证码");
return;
}
setRevealing(true);
setRevealError("");
try {
const response = await adminApi.revealProviderApiKey(providerId, { totpCode });
setCurrentApiKey(response.apiKey);
handleRevealOpenChange(false);
} catch (error) {
setRevealError(error instanceof ApiError ? error.message : "当前 API Key 读取失败");
} finally {
setRevealing(false);
}
}
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const normalized = apiKey.trim(); const normalized = apiKey.trim();
@@ -143,6 +187,7 @@ function ProviderCard({
apiKey: normalized, apiKey: normalized,
}); });
setApiKey(""); setApiKey("");
setCurrentApiKey((current) => (current ? normalized : undefined));
onUpdated(updated); onUpdated(updated);
toast.success(`${name} API Key 已替换`); toast.success(`${name} API Key 已替换`);
} catch (error) { } catch (error) {
@@ -179,6 +224,48 @@ function ProviderCard({
</dl> </dl>
</div> </div>
<div className="border-b border-border p-5 sm:p-6">
<label
className="block text-sm font-semibold"
htmlFor={`${providerId}-current-api-key`}
>
<span className="mb-2 block">{name} API Key</span>
</label>
<div className="relative">
<Input
id={`${providerId}-current-api-key`}
className="pr-12 font-mono"
type="text"
autoComplete="off"
readOnly
spellCheck={false}
value={
status?.configured
? currentApiKey ?? "••••••••••••••••"
: "未配置"
}
/>
<Button
className="absolute right-0.5 top-0.5"
type="button"
variant="ghost"
size="icon"
disabled={!status?.configured || revealing}
aria-label={currentApiKey ? `隐藏 ${name} 当前 API Key` : `显示 ${name} 当前 API Key`}
onClick={toggleCurrentApiKey}
>
{currentApiKey ? (
<EyeOff className="size-4" aria-hidden />
) : (
<Eye className="size-4" aria-hidden />
)}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-muted">
</p>
</div>
<form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}> <form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}>
<label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}> <label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}>
<span className="mb-2 block">{name} API Key</span> <span className="mb-2 block">{name} API Key</span>
@@ -194,13 +281,54 @@ function ProviderCard({
/> />
</label> </label>
<p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted"> <p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted">
Key Key Key
</p> </p>
<Button type="submit" loading={saving} disabled={!apiKey.trim()}> <Button type="submit" loading={saving} disabled={!apiKey.trim()}>
<KeyRound className="size-4" aria-hidden /> <KeyRound className="size-4" aria-hidden />
{name} API Key {name} API Key
</Button> </Button>
</form> </form>
<Dialog
open={revealOpen}
onOpenChange={handleRevealOpenChange}
title={`验证后显示 ${name} API Key`}
description="请输入当前管理员的 6 位动态验证码。本次查看会写入审计日志。"
>
<form className="space-y-5" onSubmit={(event) => void reveal(event)} noValidate>
<label className="block text-sm font-semibold" htmlFor={`${providerId}-reveal-totp`}>
<span className="mb-2 block"></span>
<Input
id={`${providerId}-reveal-totp`}
className="text-center text-lg font-semibold tracking-[0.35em]"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, ""))}
autoFocus
/>
</label>
{revealError ? (
<p className="text-sm text-danger" role="alert">
{revealError}
</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => handleRevealOpenChange(false)}
>
</Button>
<Button type="submit" loading={revealing} disabled={totpCode.length !== 6}>
</Button>
</div>
</form>
</Dialog>
</Card> </Card>
); );
} }
+24
View File
@@ -302,4 +302,28 @@ describe("adminApi", () => {
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider"); expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider");
expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" })); expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" }));
}); });
it("Provider API Key 查看请求携带 CSRF 且只提交动态验证码", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ apiKey: "current-provider-key" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-reveal");
const result = await adminApi.revealProviderApiKey("volcengine", {
totpCode: "123456",
});
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/providers/volcengine/api-key/reveal",
);
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(request.method).toBe("POST");
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-reveal");
expect(request.body).toBe(JSON.stringify({ totpCode: "123456" }));
expect(result.apiKey).toBe("current-provider-key");
});
}); });
+29 -1
View File
@@ -12,7 +12,7 @@ afterEach(() => {
}); });
describe("Provider 配置", () => { describe("Provider 配置", () => {
it("仅展示配置状态,不会回显现有 API Key", async () => { it("默认仅展示配置状态,不会回显现有 API Key", async () => {
mockSession(); mockSession();
mockProviders(); mockProviders();
window.location.hash = "#/providers"; window.location.hash = "#/providers";
@@ -27,6 +27,34 @@ describe("Provider 配置", () => {
expect(document.body.textContent).not.toContain("existing-secret"); expect(document.body.textContent).not.toContain("existing-secret");
}); });
it("通过动态验证码显示当前 Key,并可再次点击眼睛隐藏", async () => {
mockSession();
mockProviders();
const reveal = vi.spyOn(adminApi, "revealProviderApiKey").mockResolvedValue({
apiKey: "existing-secret",
});
window.location.hash = "#/providers";
render(<App />);
const currentKey = await screen.findByLabelText("DeepSeek 当前 API Key");
expect(currentKey).toHaveProperty("value", "••••••••••••••••");
await userEvent.click(
screen.getByRole("button", { name: "显示 DeepSeek 当前 API Key" }),
);
await userEvent.type(screen.getByLabelText("动态验证码"), "123456");
await userEvent.click(screen.getByRole("button", { name: "验证并显示" }));
await waitFor(() =>
expect(reveal).toHaveBeenCalledWith("deepseek", { totpCode: "123456" }),
);
await waitFor(() => expect(currentKey).toHaveProperty("value", "existing-secret"));
await userEvent.click(
screen.getByRole("button", { name: "隐藏 DeepSeek 当前 API Key" }),
);
expect(currentKey).toHaveProperty("value", "••••••••••••••••");
});
it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => { it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => {
mockSession(); mockSession();
mockProviders(); mockProviders();
+57
View File
@@ -978,6 +978,62 @@ paths:
"400": { description: API key is blank, multiline, oversized, or malformed } "400": { description: API key is blank, multiline, oversized, or malformed }
"403": { description: SUPER_ADMIN role and valid CSRF are required } "403": { description: SUPER_ADMIN role and valid CSRF are required }
"404": { description: Provider is not supported } "404": { description: Provider is not supported }
/v1/admin/providers/{providerId}/api-key/reveal:
post:
security:
- adminMtls: []
adminSession: []
summary: Reveal the effective provider API key after a TOTP step-up
description: |
Returns the runtime override or environment-backed API key for the current
SUPER_ADMIN request only. Every attempt is rate-limited and audited without
recording the TOTP code or API key. Successful responses are non-cacheable.
parameters:
- name: providerId
in: path
required: true
schema: { type: string, enum: [deepseek, volcengine] }
- $ref: "#/components/parameters/AdminCsrf"
- name: X-Request-ID
in: header
required: false
schema: { type: string, pattern: "^[A-Za-z0-9_-]{8,64}$" }
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [totpCode]
properties:
totpCode:
type: string
pattern: "^[0-9]{6}$"
writeOnly: true
responses:
"200":
description: Effective API key revealed for this response only
headers:
Cache-Control:
schema: { type: string, const: no-store }
content:
application/json:
schema:
type: object
additionalProperties: false
required: [apiKey]
properties:
apiKey:
type: string
minLength: 1
maxLength: 4096
format: password
readOnly: true
"401": { description: TOTP verification failed or session is invalid }
"403": { description: SUPER_ADMIN role and valid CSRF are required }
"404": { description: Provider or a revealable API key was not found }
"429": { description: Step-up attempts are rate-limited }
/v1/admin/overview: /v1/admin/overview:
get: get:
security: security:
@@ -1358,6 +1414,7 @@ paths:
- CONTENT_HINT_FEED_SETTINGS_UPDATED - CONTENT_HINT_FEED_SETTINGS_UPDATED
- CONTENT_HINT_FEED_GENERATED - CONTENT_HINT_FEED_GENERATED
- PROVIDER_API_KEY_UPDATED - PROVIDER_API_KEY_UPDATED
- PROVIDER_API_KEY_REVEALED
- name: result - name: result
in: query in: query
schema: { type: string, enum: [success, rejected] } schema: { type: string, enum: [success, rejected] }
@@ -120,6 +120,7 @@ enum class AdminAuditAction {
CONTENT_HINT_FEED_SETTINGS_UPDATED, CONTENT_HINT_FEED_SETTINGS_UPDATED,
CONTENT_HINT_FEED_GENERATED, CONTENT_HINT_FEED_GENERATED,
PROVIDER_API_KEY_UPDATED, PROVIDER_API_KEY_UPDATED,
PROVIDER_API_KEY_REVEALED,
} }
enum class AdminAuditOutcome { enum class AdminAuditOutcome {
@@ -127,6 +128,12 @@ enum class AdminAuditOutcome {
DENIED, DENIED,
} }
enum class AdminStepUpResult {
VERIFIED,
INVALID_TOTP,
LOCKED,
}
data class NewAdminAuditEvent( data class NewAdminAuditEvent(
val id: UUID = UUID.randomUUID(), val id: UUID = UUID.randomUUID(),
val actorOperatorId: UUID?, 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.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder 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.models.AdminTimeFilter
import com.osglab.account.features.admin.services.AdminAuditCursorException import com.osglab.account.features.admin.services.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService 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.plugins.ratelimit.rateLimit
import io.ktor.server.request.header import io.ktor.server.request.header
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.header
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
@@ -191,6 +193,55 @@ fun Route.adminApiRoutes(
call.respond(service.listStatuses()) 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") { put("/providers/{providerId}/api-key") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put
if (principal.role != AdminRole.SUPER_ADMIN) { if (principal.role != AdminRole.SUPER_ADMIN) {
@@ -1146,6 +1197,16 @@ private data class AdminGrantResponse(val transactionId: String, val balanceAfte
@Serializable @Serializable
private data class ProviderApiKeyUpdateRequest(val apiKey: String) 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 @Serializable
private data class AdminOperatorCreateRequest( private data class AdminOperatorCreateRequest(
val username: String, 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.AdminLoginResult
import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminSessionCredentials 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.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminSession import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository 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( private suspend fun failAuthentication(
operatorId: UUID, operatorId: UUID,
now: Instant, now: Instant,
@@ -9,6 +9,14 @@ import java.util.UUID
class InvalidProviderApiKeyException : IllegalArgumentException("Invalid provider API key") class InvalidProviderApiKeyException : IllegalArgumentException("Invalid provider API key")
class RevealedProviderApiKey(val value: String) {
init {
require(value.isNotBlank())
}
override fun toString(): String = "RevealedProviderApiKey([REDACTED])"
}
class GatewayCredentialService( class GatewayCredentialService(
private val repository: GatewayCredentialRepository, private val repository: GatewayCredentialRepository,
private val resolver: DatabaseProviderApiKeyResolver, private val resolver: DatabaseProviderApiKeyResolver,
@@ -18,6 +26,9 @@ class GatewayCredentialService(
suspend fun listStatuses(): List<GatewayCredentialStatus> = suspend fun listStatuses(): List<GatewayCredentialStatus> =
GatewayCredentialProvider.entries.map { resolver.status(it) } GatewayCredentialProvider.entries.map { resolver.status(it) }
suspend fun revealApiKey(provider: GatewayCredentialProvider): RevealedProviderApiKey? =
resolver.resolve(provider)?.let(::RevealedProviderApiKey)
suspend fun updateApiKey( suspend fun updateApiKey(
provider: GatewayCredentialProvider, provider: GatewayCredentialProvider,
apiKey: String, apiKey: String,
@@ -420,6 +420,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/auth/logout", "/v1/admin/auth/logout",
"/v1/admin/providers", "/v1/admin/providers",
"/v1/admin/providers/{providerId}/api-key", "/v1/admin/providers/{providerId}/api-key",
"/v1/admin/providers/{providerId}/api-key/reveal",
"/v1/admin/overview", "/v1/admin/overview",
"/v1/admin/referrals", "/v1/admin/referrals",
"/v1/admin/analytics", "/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.AdminConfig
import com.osglab.account.config.AppConfig import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.services.AdminGrantService 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.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole 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.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorService 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.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType 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.GatewayCredentialService
import com.osglab.account.features.gateway.credentials.GatewayCredentialSource import com.osglab.account.features.gateway.credentials.GatewayCredentialSource
import com.osglab.account.features.gateway.credentials.GatewayCredentialStatus import com.osglab.account.features.gateway.credentials.GatewayCredentialStatus
import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException 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.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain import io.kotest.matchers.string.shouldNotContain
@@ -520,6 +524,77 @@ class AdminRoutesTest {
body shouldNotContain "secret-runtime-key" 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 @Test
fun `non super admin cannot update provider API keys`() = testApplication { fun `non super admin cannot update provider API keys`() = testApplication {
val credentialService = mockk<GatewayCredentialService>(relaxed = true) 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.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult 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.AdminPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier import com.osglab.account.features.admin.security.HmacTotpVerifier
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
@@ -140,6 +143,49 @@ class AdminAuthServiceTest : FunSpec({
(result is AdminLoginResult.Authenticated) shouldBe true (result is AdminLoginResult.Authenticated) shouldBe true
fixture.repository.audits.single().requestId shouldBe null 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( private data class AuthFixture(
@@ -151,6 +197,13 @@ private data class AuthFixture(
val lockPolicy: AdminLoginLockPolicy, val lockPolicy: AdminLoginLockPolicy,
val tokenGenerator: SecureTokenGenerator, val tokenGenerator: SecureTokenGenerator,
) { ) {
fun principal() = AdminPrincipal(
operatorId = repository.operatorId,
sessionId = UUID.randomUUID(),
normalizedUsername = repository.username,
role = AdminRole.SUPER_ADMIN,
)
fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService( fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService(
repository = repository, repository = repository,
passwordHasher = hasher, passwordHasher = hasher,
@@ -57,6 +57,10 @@ class GatewayCredentialServiceTest : StringSpec({
resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe "runtime-volcengine" resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe "runtime-volcengine"
resolver.status(GatewayCredentialProvider.VOLCENGINE).source shouldBe resolver.status(GatewayCredentialProvider.VOLCENGINE).source shouldBe
GatewayCredentialSource.RUNTIME_OVERRIDE 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" { "two updates make new resolutions use the latest encrypted key" {