Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5b6eafbd | |||
| f95d09f303 | |||
| b4064126fe | |||
| 544e0d7356 | |||
| babc80044d | |||
| 0dff35a0f7 | |||
| e6ce70117b |
@@ -15,17 +15,17 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: admin-web/package-lock.json
|
cache-dependency-path: admin-web/package-lock.json
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v6
|
||||||
- name: Verify Docker for MySQL integration tests
|
- name: Verify Docker for MySQL integration tests
|
||||||
run: docker info
|
run: docker info
|
||||||
- name: Validate production Compose
|
- name: Validate production Compose
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
|||||||
+4
@@ -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" {
|
||||||
|
|||||||
Reference in New Issue
Block a user