diff --git a/DEPTH_ROADMAP.md b/DEPTH_ROADMAP.md index 5830b24..1533ee7 100644 --- a/DEPTH_ROADMAP.md +++ b/DEPTH_ROADMAP.md @@ -165,12 +165,12 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级* - [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go)| M - [ ] 多租户:Task/Eval/KB/Agent 加 tenant_id,owner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L -### [~] 🔴 T4.B 审计 / 可溯源 + 管理端聚合(推荐先做,内聚且喂管理端) +### [x] 🔴 T4.B 审计 / 可溯源 + 管理端聚合 ✅(大头全 live;剩 HITL 明细可选) - [x] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合 ✅ —— 全平台用户/KB/任务/评测/模型态/prompt 覆盖/健康;概览页已切过去(5调用→2),live 验证 users=7/kb 22/50 全平台口径。 - [x] `audit_log` 表 + 中间件 ✅ —— `store.AuditLog` + `middleware.Audit(db)` 挂管理组 + prompt 激活/撤销 + HITL 审批;只审计变更类(POST/PUT/DELETE/PATCH),best-effort 落库不拖垮主流程;`GET /admin/audit` 列表(倒序翻页)。live:PUT pricing / POST deactivate 留痕,GET 不记。 - [x] 护栏拦截事件落库 `guardrail_event` ✅ —— Guardrail 中间件命中(blocked/suspect)best-effort 落库(actor/kind/reason/signals/path/ip) + `GET /admin/guardrail-events`;live:注入 payload→422 硬拦+事件留痕。 -- [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S -- [ ] 前端:管理端加「审计流 / 安全事件」页或概览接 /admin/audit(把留痕可视化)| S +- [x] 前端「审计 & 安全」页 ✅ —— `/audit`(运维组)接 /admin/audit + /admin/guardrail-events:安全事件(护栏拦截/灰区)+ 操作审计(方法配色/状态码/actor/ip/时间)+ 顶栏统计 + 30s 自刷;live 验证。 +- [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S ← T4.B 唯一剩项(可选小项) ### [ ] T4.C 真实成本计费 - [ ] token×单价 落库 `token_usage`(现仅 Redis 按天计数 TTL48h,task_handler.go:532)| L diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index a43c9fe..8841a0b 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -242,6 +242,42 @@ export async function adminOverview(): Promise { return (await res.json()) as AdminOverview; } +// —— 审计 / 安全事件(敏感操作留痕 + 护栏命中)—— +export interface AuditEntry { + id: string; + actor: string; // 操作者 uid + action: string; // POST / PUT / DELETE / PATCH + route: string; + path: string; + status: number; + ip: string; + detail: string; + at: string; +} +export interface GuardrailEventItem { + id: string; + actor: string; + kind: string; // blocked / suspect + reason: string; + signals: string; // JSON 数组字符串 + method: string; + path: string; + ip: string; + at: string; +} + +export async function listAudit(limit = 50, offset = 0): Promise { + const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() })); + if (!res.ok) throw new Error(`audit failed: ${res.status}`); + return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? []; +} + +export async function listGuardrailEvents(limit = 50, offset = 0): Promise { + const res = guard(await fetch(`${ADMIN}/guardrail-events?limit=${limit}&offset=${offset}`, { headers: authHeaders() })); + if (!res.ok) throw new Error(`guardrail events failed: ${res.status}`); + return ((await res.json()) as { events?: GuardrailEventItem[] }).events ?? []; +} + // —— Prompt 控制面(建版本 → 激活 → 控制面热下发各服务,不重启即生效)—— // 注意:prompt 路由在 RequireAuth 组下(/api/v1/prompts),不在 /admin 前缀内。 const PROMPTS = `${GATEWAY}/api/v1/prompts`; diff --git a/sundynix-admin/src/pages/AuditPage.tsx b/sundynix-admin/src/pages/AuditPage.tsx new file mode 100644 index 0000000..0253aff --- /dev/null +++ b/sundynix-admin/src/pages/AuditPage.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api"; + +// 操作方法配色。 +const ACTION_STYLE: Record = { + POST: "bg-emerald-50 text-emerald-600", + PUT: "bg-amber-50 text-amber-600", + DELETE: "bg-rose-50 text-rose-600", + PATCH: "bg-violet-50 text-violet-600", +}; +const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500"); + +// uid 太长,展示时截断(保留首尾,hover 看全)。 +const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id || "系统"); +const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false }); + +export function AuditPage() { + const [audit, setAudit] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); + const [err, setErr] = useState(""); + + const load = async () => { + setRefreshing(true); + try { + const [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]); + setAudit(a); + setEvents(e); + setUpdatedAt(new Date()); + setErr(""); + } catch (er) { + setErr((er as Error).message); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), 30000); + return () => clearInterval(t); + }, []); + + if (loading) return
加载审计记录中…
; + if (err) return
加载失败:{err}
; + + const blocked = events.filter((e) => e.kind === "blocked").length; + const suspect = events.filter((e) => e.kind === "suspect").length; + + return ( +
+ {/* 顶栏 */} +
+
+

审计 & 安全事件

+

敏感操作留痕 + 输入护栏命中 · 只增不改,供运维溯源

+
+
+ + 0} /> + 0} /> +
+
+ {updatedAt && 更新于 {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}} + +
+
+ + {/* 安全事件(护栏命中) */} +
+
+

安全事件

+ 输入护栏拦截 / 灰区放行({events.length}) +
+ {events.length === 0 ? ( +
暂无护栏命中 🎉
+ ) : ( +
+ {events.map((e) => ( +
+ + {e.kind === "blocked" ? "拦截" : "灰区"} + + {e.reason || "(无原因)"} + {e.method} {e.path} + {shortId(e.actor)} + {e.ip} + {fmt(e.at)} +
+ ))} +
+ )} +
+ + {/* 操作审计 */} +
+
+

操作审计

+ 改配置 / 改密钥 / 激活提示词 / 审批 等变更操作({audit.length}) +
+ {audit.length === 0 ? ( +
暂无操作留痕
+ ) : ( +
+ {audit.map((a) => ( +
+ + {a.action} + + {a.path} + {a.status} + {shortId(a.actor)} + {a.ip} + {fmt(a.at)} +
+ ))} +
+ )} +
+
+ ); +} + +function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index 33db362..05cfd44 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -14,6 +14,7 @@ const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage }))); const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage }))); const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage }))); +const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage }))); export interface RouteDef { path: string; @@ -73,6 +74,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "/audit", + label: "审计 & 安全", + group: "运维", + ready: true, + element: , + }, { path: "/tenants", label: "租户 & 用户",