feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
This commit is contained in:
AskaEth committed 2026-10-04 12:07:51 +08:00
1 parent 8c6466cb00
commit adef36538f
13 files changed
+1223 -199

No files matched your search

+250 -124
View File
@@ -1,13 +1,15 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminListUsers,
adminCreateUser,
adminGetOwnerProfile,
adminListUsers,
adminIssueApiKey,
adminRevokeApiKey,
adminSaveOwnerProfile,
getStoredRole,
} from "../../lib/api";
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
import type { AdminUserRecord, OwnerAccount, OwnerProfile } from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
@@ -18,38 +20,20 @@ const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
viewer: "neutral",
};
const initialProfile: OwnerProfile = {
id: "owner",
displayName: "叶酱",
defaultPersona: "cyrene",
identityFile: "configs/users/owner_identity.md",
notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。",
accounts: [],
};
export function UsersModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminListUsers, []);
const isOwner = getStoredRole() === "owner";
const users = useAsyncData(adminListUsers, []);
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
const [issuedKey, setIssuedKey] = useState<string | null>(null);
const [role, setRole] = useState<string>("owner");
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const saveProfile = () => {
log("owner.profile.edit", "ok");
setProfile({ ...profile });
};
const createUser = () => {
setBusy(true);
setFail(null);
adminCreateUser({ username: "new-user", role: "viewer" })
.then(() => {
log("user.create", "ok");
reload();
users.reload();
})
.catch((err: unknown) => {
log("user.create", "error");
@@ -58,29 +42,13 @@ export function UsersModule(): React.ReactNode {
.finally(() => setBusy(false));
};
const issueKey = () => {
setBusy(true);
setFail(null);
adminIssueApiKey(role)
.then((rg) => {
log("apikey.generate", "ok");
setIssuedKey(rg.key);
reload();
})
.catch((err: unknown) => {
log("apikey.generate", "error");
setFail(err instanceof Error ? err.message : "签发密钥失败");
})
.finally(() => setBusy(false));
};
const revokeKey = (id: string) => {
setBusy(true);
setFail(null);
adminRevokeApiKey(id)
.then(() => {
log("apikey.revoke", "ok");
reload();
users.reload();
})
.catch((err: unknown) => {
log("apikey.revoke", "error");
@@ -89,6 +57,50 @@ export function UsersModule(): React.ReactNode {
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="用户与权限"
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key(一律超管)· 主人档案(白名单 + 身份说明书)"
actions={
<Button variant="soft" disabled={!isOwner || busy} onClick={createUser}>
新增用户
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<UsersTableSection
rows={users.data}
loading={users.loading}
error={users.error}
isOwner={isOwner}
busy={busy}
onRevoke={revokeKey}
/>
<OwnerProfileSection isOwner={isOwner} />
<ApiKeySection isOwner={isOwner} onIssued={users.reload} />
</div>
);
}
interface UsersTableSectionProps {
rows: AdminUserRecord[] | null;
loading: boolean;
error: string | null;
isOwner: boolean;
busy: boolean;
onRevoke: (id: string) => void;
}
/** 账号/RBAC 列表 + API key 行的真实撤销。 */
function UsersTableSection({
rows,
loading,
error,
isOwner,
busy,
onRevoke,
}: UsersTableSectionProps): React.ReactNode {
const columns: AdminColumn<AdminUserRecord>[] = [
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
@@ -99,7 +111,7 @@ export function UsersModule(): React.ReactNode {
title: "操作",
render: (u) =>
u.username === "apikey" && isOwner ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => revokeKey(u.id)}>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onRevoke(u.id)}>
撤销
</Button>
) : (
@@ -107,100 +119,214 @@ export function UsersModule(): React.ReactNode {
),
},
];
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
<AdminTable
columns={columns}
rows={rows}
loading={loading}
error={error}
emptyTitle="无用户数据"
rowKey={(u) => u.id}
/>
</Surface>
);
}
/** 主人档案编辑块(裁决 10.4.5)。它自带读取/草稿/保存状态,因此面板的「保存」
* 是一次真实 PUT:成功回读服务端档案,失败如实报错 —— 绝不假装保存成功。 */
function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode {
const { data, loading, error } = useAsyncData(adminGetOwnerProfile, []);
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
// 后端是唯一事实源:每次成功读取都用服务端返回的档案覆盖草稿。
useEffect(() => {
if (data) setProfile(data);
}, [data]);
const save = () => {
if (!profile) return;
setBusy(true);
setFail(null);
setNotice(null);
adminSaveOwnerProfile(profile)
.then((saved) => {
log("owner.profile.edit", "ok");
setProfile(saved);
setNotice("已保存并热生效:configs/users/owner.yaml(0600 原子写),白名单立即参与 owner 判定。");
})
.catch((err: unknown) => {
log("owner.profile.edit", "error");
setFail(err instanceof Error ? err.message : "保存主人档案失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="用户与权限"
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key · 主人档案(白名单 + 身份说明书)"
actions={
<Button variant="soft" disabled={!isOwner || busy} onClick={createUser}>
新增用户
</Button>
}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">主人档案({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(策略引擎据此比对 actor 元数据,非提示词/LLM 判断)+ 关系备注。身份说明书(给 LLM 的人格知识,非权限依据)见 identityFile。
</p>
{error ? (
<Notice tone="err">
主人档案读取失败:{error}(未拿到真实档案,表单已禁用,不拿本地假数据冒充)
</Notice>
) : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{notice ? <Notice tone="ok">{notice}</Notice> : null}
{profile ? (
<OwnerProfileForm profile={profile} disabled={!isOwner} busy={busy} onChange={setProfile} onSave={save} />
) : (
<p className="text-xs text-[var(--mu-text-lo)]">{loading ? "读取中…" : "未加载到主人档案"}</p>
)}
</Surface>
);
}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
{error && !data ? (
<div className="space-y-2">
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{error}</Notice>
<AdminTable
columns={columns}
rows={[
{ id: "1", username: "owner", role: "owner", createdAt: "2026-01-01" },
{ id: "2", username: "admin", role: "admin", createdAt: "2026-01-01" },
{ id: "3", username: "viewer", role: "viewer", createdAt: "2026-01-01" },
]}
loading={false}
error={null}
rowKey={(u) => u.id}
/>
</div>
) : (
<AdminTable columns={columns} rows={data} loading={loading} error={error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
)}
</Surface>
interface OwnerProfileFormProps {
profile: OwnerProfile;
disabled: boolean;
busy: boolean;
onChange: (next: OwnerProfile) => void;
onSave: () => void;
}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="id" value={profile.id} onChange={(v) => setProfile({ ...profile, id: v })} disabled={!isOwner} />
<Field label="显示名" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} disabled={!isOwner} />
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => setProfile({ ...profile, identityFile: v })} disabled={!isOwner} />
</div>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
<textarea
value={profile.notes}
onChange={(e) => setProfile({ ...profile, notes: e.target.value })}
disabled={!isOwner}
rows={3}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
保存主人档案
/** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */
function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerProfileFormProps): React.ReactNode {
const patch = (p: Partial<OwnerProfile>): void => onChange({ ...profile, ...p });
const patchAccount = (i: number, p: Partial<OwnerAccount>): void =>
patch({ accounts: profile.accounts.map((a, idx) => (idx === i ? { ...a, ...p } : a)) });
const removeAccount = (i: number): void => patch({ accounts: profile.accounts.filter((_, idx) => idx !== i) });
const appendAccount = (): void => patch({ accounts: [...profile.accounts, { platform: "", accountId: "" }] });
return (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="id" value={profile.id} onChange={(v) => patch({ id: v })} disabled={disabled} />
<Field label="显示名" value={profile.displayName} onChange={(v) => patch({ displayName: v })} disabled={disabled} />
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => patch({ defaultPersona: v })} disabled={disabled} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => patch({ identityFile: v })} disabled={disabled} />
</div>
<AccountWhitelistEditor
accounts={profile.accounts}
disabled={disabled}
onPatch={patchAccount}
onRemove={removeAccount}
onAppend={appendAccount}
/>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
<textarea
value={profile.notes}
onChange={(e) => patch({ notes: e.target.value })}
disabled={disabled}
rows={3}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={disabled || busy} onClick={onSave}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
保存走审计并真实落盘 owner.yaml(0600 原子写);后端热替换后白名单立即生效,无需重启。
</span>
</div>
</>
);
}
interface AccountWhitelistEditorProps {
accounts: OwnerAccount[];
disabled: boolean;
onPatch: (index: number, patch: Partial<OwnerAccount>) => void;
onRemove: (index: number) => void;
onAppend: () => void;
}
/** 渠道账号白名单行编辑器:命中此列表的账号走 owner 车道(元数据判定,非提示词)。 */
function AccountWhitelistEditor({
accounts,
disabled,
onPatch,
onRemove,
onAppend,
}: AccountWhitelistEditorProps): React.ReactNode {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--mu-text-lo)]">渠道账号白名单(命中即走 owner 车道)</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAppend}>
添加账号
</Button>
</div>
{accounts.length === 0 ? (
<p className="text-xs text-[var(--mu-text-lo)]">尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。</p>
) : null}
{accounts.map((a, i) => (
<div key={`acct-${i}`} className="flex flex-wrap items-end gap-2">
<Field label="平台" value={a.platform} onChange={(v) => onPatch(i, { platform: v })} disabled={disabled} />
<Field label="账号 id" value={a.accountId} onChange={(v) => onPatch(i, { accountId: v })} disabled={disabled} />
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => onRemove(i)}>
删除
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;owner.yaml 持久化/热重载(编辑)由后端 M10 提供,当前为本地编辑。</span>
</div>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{issuedKey ? (
<div className="space-y-1">
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
>
<option value="owner">owner</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
<Button variant="soft" disabled={!isOwner || busy} onClick={issueKey}>
签发新密钥
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">行内「撤销」真实移除该密钥。</span>
</div>
</Surface>
))}
</div>
);
}
/** API key 签发块(裁决 15.4.3):不选角色 —— 所有 key 一律超管。 */
function ApiKeySection({ isOwner, onIssued }: { isOwner: boolean; onIssued: () => void }): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [issuedKey, setIssuedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const issue = () => {
setBusy(true);
setFail(null);
adminIssueApiKey()
.then((res) => {
log("apikey.generate", "ok");
setIssuedKey(res.key);
onIssued();
})
.catch((err: unknown) => {
log("apikey.generate", "error");
setFail(err instanceof Error ? err.message : "签发密钥失败");
})
.finally(() => setBusy(false));
};
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{issuedKey ? (
<div className="space-y-1">
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button variant="soft" disabled={!isOwner || busy} onClick={issue}>
签发新密钥
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
所有 key 一律超级管理员(owner),不做角色分级;X-API-Key 头直接过 owner 门。行内「撤销」真实移除该密钥。
</span>
</div>
</Surface>
);
}
interface FieldProps {
label: string;
value: string;