feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 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:
1 parent
8c6466cb00
commit
adef36538f
13 files changed
+1223
-199
No files matched your search
@@ -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;
|
||||
|
||||
@@ -58,6 +58,7 @@ export {
|
||||
adminDeleteRag,
|
||||
adminExportSession,
|
||||
adminGetLlm,
|
||||
adminGetOwnerProfile,
|
||||
adminIndexRag,
|
||||
adminIoTControl,
|
||||
adminIoTRule,
|
||||
@@ -78,6 +79,7 @@ export {
|
||||
adminPluginUninstall,
|
||||
adminRevokeApiKey,
|
||||
adminRunAutomation,
|
||||
adminSaveOwnerProfile,
|
||||
adminSearchRag,
|
||||
adminSetPluginPolicy,
|
||||
adminTestLlm,
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
PersonaUpsertRequest,
|
||||
PluginInstallRequest,
|
||||
PluginInstallResult,
|
||||
OwnerProfile,
|
||||
RagIndexRequest,
|
||||
SessionMeta,
|
||||
SessionRecord,
|
||||
@@ -149,11 +150,25 @@ export function adminCreateUser(req: { username: string; role: string }): Promis
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/users/apikey — mints a key; the plaintext comes back once. */
|
||||
export function adminIssueApiKey(role: string): Promise<ApiKeyIssueResult> {
|
||||
return request<ApiKeyIssueResult>("/admin/users/apikey", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ role }),
|
||||
/** POST /api/v1/admin/users/apikey — mints a key; the plaintext comes back once.
|
||||
* 裁决 15.4.3:key 一律超级管理员,不按 role 分档 ⇒ 请求体不带 role,
|
||||
* 响应里的 `role` 如实回报实际授予的角色(恒为 owner)。 */
|
||||
export function adminIssueApiKey(): Promise<ApiKeyIssueResult> {
|
||||
return request<ApiKeyIssueResult>("/admin/users/apikey", { method: "POST" });
|
||||
}
|
||||
/** GET /api/v1/admin/owner-profile — 主人档案(owner-only):owner.yaml 的
|
||||
* 显示名/默认人格/身份说明书/关系备注 + 各渠道账号白名单(策略引擎据此比对
|
||||
* actor 元数据来认出主人)。全新安装返回空白档案(不是 404)。 */
|
||||
export function adminGetOwnerProfile(): Promise<OwnerProfile> {
|
||||
return request<OwnerProfile>("/admin/owner-profile");
|
||||
}
|
||||
/** PUT /api/v1/admin/owner-profile — 真实落盘(0600 原子写)+ 热替换活档案,
|
||||
* 因此新白名单立即参与 owner 判定。校验失败回 400、落盘失败回 500 ——
|
||||
* 调用方必须把失败呈现给用户,**不得假装保存成功**。 */
|
||||
export function adminSaveOwnerProfile(profile: OwnerProfile): Promise<OwnerProfile> {
|
||||
return request<OwnerProfile>("/admin/owner-profile", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(profile),
|
||||
});
|
||||
}
|
||||
/** DELETE /api/v1/admin/users/apikey/{id} — revokes a key by masked id. */
|
||||
|
||||
+25
-20
@@ -11,15 +11,16 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// APIKey manages a bounded, in-memory constant table of API keys, each bound to
|
||||
// a Role so keys can be minted for admin/owner to reach administrative
|
||||
// endpoints. Keys are generated with crypto/rand and stored by their string
|
||||
// form. This suits the current single-binary milestone; production should
|
||||
// persist keys (e.g. hashed) and support revocation.
|
||||
// APIKey manages a bounded, in-memory constant table of API keys. 按裁决
|
||||
// 15.4.3(主人原话:「key 始终给超级管理员权限」),表里**不再绑定角色**:
|
||||
// 任何已登记且未吊销的 key 鉴权后一律 owner 级 —— key 用于管理员调试,甚至
|
||||
// 生产环境救火,因此不做权限分级。Keys are generated with crypto/rand and
|
||||
// stored by their string form. This suits the current single-binary milestone;
|
||||
// production should persist keys (e.g. hashed) and support revocation.
|
||||
type APIKey struct {
|
||||
mu sync.RWMutex
|
||||
cfg config.AuthConfig
|
||||
keys map[string]Role
|
||||
keys map[string]struct{}
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
@@ -28,16 +29,18 @@ type APIKey struct {
|
||||
func NewAPIKey(cfg config.AuthConfig) *APIKey {
|
||||
return &APIKey{
|
||||
cfg: cfg,
|
||||
keys: make(map[string]Role),
|
||||
keys: make(map[string]struct{}),
|
||||
log: logger.WithSource(slog.Default(), source),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate mints a new random key bound to role and stores it. Returns the
|
||||
// plaintext key exactly once; it must not be logged.
|
||||
func (k *APIKey) Generate(role Role) (string, error) {
|
||||
if !role.Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role)
|
||||
// Generate 铸造一个新 key 并登记进表,返回**仅此一次**可见的明文(绝不记录到
|
||||
// 日志)。role 是可选形参:传入时只校验它是不是已知角色名(未知即报
|
||||
// ErrInvalidRole),**不参与存储、不影响授权** —— 按裁决 15.4.3,key 表已无
|
||||
// 角色维度,任何有效 key 都是超管级(见 Lookup)。
|
||||
func (k *APIKey) Generate(role ...Role) (string, error) {
|
||||
if len(role) > 0 && !role[0].Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role[0])
|
||||
}
|
||||
raw := make([]byte, keyLength)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
@@ -46,7 +49,7 @@ func (k *APIKey) Generate(role Role) (string, error) {
|
||||
key := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
k.mu.Lock()
|
||||
k.keys[key] = role
|
||||
k.keys[key] = struct{}{}
|
||||
k.mu.Unlock()
|
||||
return key, nil
|
||||
}
|
||||
@@ -57,12 +60,13 @@ func (k *APIKey) Verify(key string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Lookup returns the role bound to key.
|
||||
// Lookup 报告 key 是否已登记,并返回其授权角色。按裁决 15.4.3,**任何**已登记
|
||||
// 的 key 都是超管(owner)级。key 不在表中时 ok 为 false,调用方必须据此拒绝。
|
||||
func (k *APIKey) Lookup(key string) (Role, bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
role, ok := k.keys[key]
|
||||
return role, ok
|
||||
_, ok := k.keys[key]
|
||||
return RoleOwner, ok
|
||||
}
|
||||
|
||||
// Revoke removes key from the constant table.
|
||||
@@ -79,7 +83,7 @@ func (k *APIKey) RevokeAll() int {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
n := len(k.keys)
|
||||
k.keys = make(map[string]Role)
|
||||
k.keys = make(map[string]struct{})
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -106,13 +110,14 @@ func (k *APIKey) RevokeByMask(mask string) bool {
|
||||
// List returns a summary of every currently registered API key for the admin
|
||||
// panel. Key material is never returned; each identifier is masked so the panel
|
||||
// can tell keys apart without exposing the secret. The in-memory store keeps no
|
||||
// creation time, so CreatedAt is nil for every entry.
|
||||
// creation time, so CreatedAt is nil for every entry. 每条的角色恒为 owner
|
||||
// (裁决 15.4.3:key 不做角色分级)。
|
||||
func (k *APIKey) List() []UserSummary {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
out := make([]UserSummary, 0, len(k.keys))
|
||||
for key, role := range k.keys {
|
||||
out = append(out, UserSummary{ID: maskKey(key), Username: "apikey", Role: role})
|
||||
for key := range k.keys {
|
||||
out = append(out, UserSummary{ID: maskKey(key), Username: "apikey", Role: RoleOwner})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func newTestAPIKey() *APIKey {
|
||||
func TestAPIKey_GenerateVerify(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
key, err := k.Generate(RoleAdmin)
|
||||
key, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
@@ -24,20 +24,45 @@ func TestAPIKey_GenerateVerify(t *testing.T) {
|
||||
if !k.Verify(key) {
|
||||
t.Errorf("Verify(%q) = false, want true", key)
|
||||
}
|
||||
// 裁决 15.4.3:任何有效 key 都是超管级,不按 role 分档。
|
||||
role, ok := k.Lookup(key)
|
||||
if !ok || role != RoleAdmin {
|
||||
t.Errorf("Lookup(%q) = (%q,%v), want (admin,true)", key, role, ok)
|
||||
if !ok || role != RoleOwner {
|
||||
t.Errorf("Lookup(%q) = (%q,%v), want (owner,true)", key, role, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKey_GenerateIgnoresLegacyRole 钉住「key 不做角色分级」这条裁决的
|
||||
// 可观察后果:即使调用方沿旧签名传了 admin/viewer,签出来的 key 仍然是
|
||||
// 超管级,且表里不留任何角色维度。
|
||||
func TestAPIKey_GenerateIgnoresLegacyRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
for _, want := range []Role{RoleAdmin, RoleViewer} {
|
||||
key, err := k.Generate(want)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%q) error: %v", want, err)
|
||||
}
|
||||
role, ok := k.Lookup(key)
|
||||
if !ok || role != RoleOwner {
|
||||
t.Fatalf("Generate(%q) → Lookup = (%q,%v), want (owner,true)", want, role, ok)
|
||||
}
|
||||
}
|
||||
// 列表也一律如实报 owner,面板据此没有分级可显示。
|
||||
for _, s := range k.List() {
|
||||
if s.Role != RoleOwner {
|
||||
t.Fatalf("List() entry role = %q, want owner", s.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_GenerateUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
a, err := k.Generate(RoleViewer)
|
||||
a, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
b, err := k.Generate(RoleViewer)
|
||||
b, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
@@ -68,7 +93,7 @@ func TestAPIKey_VerifyUnknown(t *testing.T) {
|
||||
func TestAPIKey_Revoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
key, err := k.Generate(RoleViewer)
|
||||
key, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
@@ -85,8 +110,8 @@ func TestAPIKey_RevokeAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
keys := make([]string, 0, 3)
|
||||
for _, role := range []Role{RoleOwner, RoleAdmin, RoleViewer} {
|
||||
key, err := k.Generate(role)
|
||||
for range 3 {
|
||||
key, err := k.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
|
||||
+101
-7
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -16,12 +17,29 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// apiKeyHeader 是 API key 凭据的请求头(裁决 15.4.2)。
|
||||
const apiKeyHeader = "X-API-Key"
|
||||
|
||||
// apiKeySubPrefix 是 API key 分支写入 Claims.Sub 的前缀。Sub 取**脱敏** id
|
||||
// 而不是明文,使审计链能定位是哪把 key,却永不记录密钥材料。
|
||||
const apiKeySubPrefix = "apikey:"
|
||||
|
||||
// KeyTable 是 API key 表的最小读接口(*APIKey 满足),使下面的中间件不依赖
|
||||
// 具体存储实现。
|
||||
type KeyTable interface {
|
||||
// Lookup 报告 key 是否已登记,并返回其授权角色。
|
||||
Lookup(key string) (Role, bool)
|
||||
}
|
||||
|
||||
// Authorize returns an HTTP middleware that requires a valid Bearer JWT whose
|
||||
// role satisfies at least one of the required roles. With no required roles it
|
||||
// admits any authenticated call. A missing/invalid/expired token yields 401
|
||||
// (ErrUnauthorized); an authenticated token with insufficient privilege yields
|
||||
// 403 (ErrForbidden). The validated claims are stored in the request context
|
||||
// for downstream handlers.
|
||||
//
|
||||
// 本方法只认 Bearer;`X-API-Key` 凭据由 WithAPIKey 叠加(裁决 15.4.2),
|
||||
// 两条分支共用同一段角色判定与上下文注入逻辑(claimGate)。
|
||||
func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,17 +55,93 @@ func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler
|
||||
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !satisfies(claims.Role, required) {
|
||||
s.log.Debug("insufficient role", "role", claims.Role)
|
||||
writeAuthError(w, http.StatusForbidden, ErrForbidden)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
claimGate{log: s.log, required: required}.admit(w, r, claims, next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey 在既有凭据门 bearer 之上叠加 `X-API-Key` 分支(裁决 15.4.2),
|
||||
// 使签发出去的 key 真的能用:
|
||||
//
|
||||
// - 请求带 `X-API-Key` 头时**只**走 key 分支 —— 命中 keys 表即以 owner 级
|
||||
// 过门(裁决 15.4.3「key 一律超级管理员」,不做角色分档;owner 满足一切
|
||||
// 角色要求);为空值、未知或已吊销一律 401(fail-closed),**绝不回落到
|
||||
// Bearer**,因此一把坏 key 无法借道另一份凭据混进门;
|
||||
// - 不带该头时完全交给 bearer,Bearer JWT 路径的行为一字不变。
|
||||
//
|
||||
// keys 为 nil 表示 key 表未注入(等同空表):此时带头的请求一律 401。
|
||||
func WithAPIKey(
|
||||
keys KeyTable,
|
||||
bearer func(http.Handler) http.Handler,
|
||||
log *slog.Logger,
|
||||
required ...Role,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
// Bearer 门在注册期构建一次(与原 Authorize 的分配方式一致),
|
||||
// 不走 key 分支的请求照旧零额外分配。
|
||||
bearerDoor := bearer(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key, present := apiKeyFromRequest(r)
|
||||
if !present {
|
||||
bearerDoor.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
claims, ok := lookupAPIKeyClaims(keys, key)
|
||||
if !ok {
|
||||
log.Debug("api key rejected", "reason", "absent table, unknown or revoked key")
|
||||
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
claimGate{log: log, required: required}.admit(w, r, claims, next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// claimGate 是两种凭据(Bearer JWT 与 X-API-Key)共用的后半段:角色判定 →
|
||||
// 403/放行 → 已验证 claims 进请求上下文。这正是裁决 15.4.2 要求的「与 Bearer
|
||||
// 走同一 Authorize 路径」。
|
||||
type claimGate struct {
|
||||
log *slog.Logger
|
||||
required []Role
|
||||
}
|
||||
|
||||
// admit 在 claims 满足 required 时把 claims 放进请求上下文并交给 next,
|
||||
// 否则回 403。
|
||||
func (g claimGate) admit(w http.ResponseWriter, r *http.Request, claims *Claims, next http.Handler) {
|
||||
if !satisfies(claims.Role, g.required) {
|
||||
g.log.Debug("insufficient role", "role", claims.Role)
|
||||
writeAuthError(w, http.StatusForbidden, ErrForbidden)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// apiKeyFromRequest 取出 `X-API-Key` 头的值。present 报告该头是否**出现**
|
||||
// (即使值为空)——出现即表示调用方选择了 key 凭据,因此空值也按无效凭据
|
||||
// 拒绝,而不是悄悄回落到 Bearer。
|
||||
func apiKeyFromRequest(r *http.Request) (string, bool) {
|
||||
values := r.Header.Values(apiKeyHeader)
|
||||
if len(values) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(values[0]), true
|
||||
}
|
||||
|
||||
// lookupAPIKeyClaims 校验 key 并映射为 Claims。按裁决 15.4.3,任何已登记且
|
||||
// 未吊销的 key 都是超管级(由表给出角色,见 APIKey.Lookup)。空 key 与「表未
|
||||
// 注入」都直接失败:fail-closed。
|
||||
func lookupAPIKeyClaims(keys KeyTable, key string) (*Claims, bool) {
|
||||
if keys == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
role, ok := keys.Lookup(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &Claims{Sub: apiKeySubPrefix + MaskKey(key), Role: role}, true
|
||||
}
|
||||
|
||||
// satisfies reports whether role has enough privilege for at least one of the
|
||||
// required roles. An empty required list means any authenticated role passes.
|
||||
func satisfies(role Role, required []Role) bool {
|
||||
|
||||
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -172,3 +174,133 @@ func TestClaimsFromContext_Missing(t *testing.T) {
|
||||
t.Fatal("ClaimsFromContext on empty ctx should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- X-API-Key 分支(裁决 15.4.2 / 15.4.3)-----------------------------------
|
||||
|
||||
// discardLog 是中间件测试用的丢弃型 logger(不产出日志噪声)。
|
||||
func discardLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// keyDoor 构建「Bearer + X-API-Key」双凭据门。
|
||||
func keyDoor(keys KeyTable, required ...Role) func(http.Handler) http.Handler {
|
||||
return WithAPIKey(keys, New(testConfig("")).Authorize(required...), discardLog(), required...)
|
||||
}
|
||||
|
||||
// doAPIKey 发一个只带 `X-API-Key` 头的请求。
|
||||
func doAPIKey(h http.Handler, key string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(apiKeyHeader, key)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// TestWithAPIKey_ValidKeyPassesOwnerGate 是 15.4.2 的核心断言:签发出去的 key
|
||||
// 真的能用,并且拿到 owner 级(15.4.3),Sub 是脱敏 id 而不是密钥明文。
|
||||
func TestWithAPIKey_ValidKeyPassesOwnerGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), key)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
if got := rr.Header().Get("X-Claims-Role"); got != string(RoleOwner) {
|
||||
t.Errorf("X-Claims-Role = %q, want owner", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Claims-Sub"); got != apiKeySubPrefix+MaskKey(key) {
|
||||
t.Errorf("X-Claims-Sub = %q, want masked id %q", got, apiKeySubPrefix+MaskKey(key))
|
||||
}
|
||||
if strings.Contains(rr.Header().Get("X-Claims-Sub"), key) {
|
||||
t.Error("claims Sub leaked the key material")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithAPIKey_UnknownKeyRejected 断言未知 key 一律 401,且**即使同时带着一份
|
||||
// 合法 Bearer 也不回落** —— fail-closed,坏 key 不能借道另一份凭据混过门。
|
||||
func TestWithAPIKey_UnknownKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
door := keyDoor(keys, RoleOwner)
|
||||
rr := doAPIKey(door(claimsHandler(t)), "not-a-key")
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
assertErrorCode(t, rr, "unauthorized")
|
||||
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(apiKeyHeader, "not-a-key")
|
||||
req.Header.Set("Authorization", bearerHeader(tok))
|
||||
rec := httptest.NewRecorder()
|
||||
WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)(claimsHandler(t)).ServeHTTP(rec, req)
|
||||
responseCode(t, rec, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_RevokedKeyRejected 断言吊销后的 key 立即失效(能发也能废)。
|
||||
func TestWithAPIKey_RevokedKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
door := keyDoor(keys, RoleOwner)
|
||||
responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusOK)
|
||||
keys.Revoke(key)
|
||||
responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_EmptyHeaderValueRejected 断言该头出现但值为空时按无效凭据拒绝,
|
||||
// 而不是被当成「没带 key」悄悄回落到 Bearer。
|
||||
func TestWithAPIKey_EmptyHeaderValueRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := newTestAPIKey()
|
||||
if _, err := keys.Generate(); err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), "")
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_NilTableRejectsKeyHeader 断言 key 表未注入时带 key 的请求一律
|
||||
// 拒绝(表不在 = 表为空),不带 key 的请求则照旧走 Bearer。
|
||||
func TestWithAPIKey_NilTableRejectsKeyHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
door := WithAPIKey(nil, svc.Authorize(RoleOwner), discardLog(), RoleOwner)
|
||||
responseCode(t, doAPIKey(door(claimsHandler(t)), "any-key"), http.StatusUnauthorized)
|
||||
|
||||
tok, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(door(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
}
|
||||
|
||||
// TestWithAPIKey_BearerPathUnchanged 断言不带该头时行为与纯 Bearer 门逐字一致:
|
||||
// 角色不足仍 403、缺凭据仍 401。
|
||||
func TestWithAPIKey_BearerPathUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
keys := newTestAPIKey()
|
||||
door := WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)
|
||||
|
||||
admin, err := svc.Sign("u", RoleAdmin, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
responseCode(t, doAuth(door(claimsHandler(t)), admin), http.StatusForbidden)
|
||||
responseCode(t, doAuth(door(claimsHandler(t)), ""), http.StatusUnauthorized)
|
||||
|
||||
owner, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
responseCode(t, doAuth(door(claimsHandler(t)), owner), http.StatusOK)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格
|
||||
@@ -66,6 +68,14 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required"))
|
||||
return
|
||||
}
|
||||
// identity.systemPrompt 必填(裁决 08.4.2):这里不做回落、不派生默认值 ——
|
||||
// 否则一条没有身份的「人格」会被静默降级成拼凑出的说明串。载入期
|
||||
// (loader.Load)执行同一规则,因此写进来的档案必然也能被重新载入。
|
||||
if strings.TrimSpace(req.SystemPrompt) == "" {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)"))
|
||||
return
|
||||
}
|
||||
reg, ok := s.personas.(*personareg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
@@ -117,6 +127,13 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required"))
|
||||
return
|
||||
}
|
||||
// identity.systemPrompt 必填(裁决 08.4.2),与创建路径同一把门:缺失/空白
|
||||
// 显式报错,不回落、不静默保留旧值(「没改」要由客户端显式传回原值)。
|
||||
if strings.TrimSpace(req.SystemPrompt) == "" {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)"))
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
reg, ok := s.personas.(*personareg.Registry)
|
||||
if !ok {
|
||||
@@ -174,40 +191,32 @@ func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// apiKeyIssueRequest 是 POST /api/v1/admin/users/apikey 的请求体。
|
||||
type apiKeyIssueRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// adminIssueApiKey 铸造一个绑定到某角色的真实 API key,并返回一次
|
||||
// 明文(唯一可见的一次)。响应中的 id 是管理列表所展示的
|
||||
// 脱敏形式,因此之后可用它吊销该 key。
|
||||
// adminIssueApiKey 铸造一个真实 API key 并返回一次明文(唯一可见的一次)。
|
||||
// 响应中的 id 是管理列表所展示的脱敏形式,之后可用它吊销该 key。
|
||||
//
|
||||
// 按裁决 15.4.3,key **不做角色分级**:本端点不再读请求体里的 role(也不
|
||||
// 接受 role 语义),表里也没有角色维度 —— 任何有效 key 鉴权后都是超级管理员
|
||||
// (owner)。响应仍回传实际授予的角色(恒为 owner),让调用方一眼看到
|
||||
// 「签出去的就是超管 key」,而不是以为自己在签一把只读 key。
|
||||
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req apiKeyIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
role, err := auth.ParseRole(req.Role)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "apikey.issue", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
|
||||
return
|
||||
}
|
||||
keys, ok := s.users.(*auth.APIKey)
|
||||
if !ok {
|
||||
s.auditClaims(r, "apikey.issue", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
|
||||
return
|
||||
}
|
||||
key, err := keys.Generate(role)
|
||||
key, err := keys.Generate()
|
||||
if err != nil {
|
||||
s.auditClaims(r, "apikey.issue", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "apikey.issue", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)})
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": auth.MaskKey(key),
|
||||
"key": key,
|
||||
"role": string(auth.RoleOwner),
|
||||
})
|
||||
}
|
||||
|
||||
// adminRevokeApiKey 按脱敏 id(管理列表返回的标识)吊销一个
|
||||
@@ -229,13 +238,133 @@ func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
|
||||
}
|
||||
|
||||
// personaFromRequest 从 upsert 请求构建 loader.Persona,在省略时
|
||||
// 填入派生的 system prompt/characterRef,使最小创建也能通过校验。
|
||||
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
|
||||
systemPrompt := strings.TrimSpace(req.SystemPrompt)
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
|
||||
// --- 主人档案(裁决 10.4.5)--------------------------------------------------
|
||||
//
|
||||
// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据
|
||||
// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写)
|
||||
// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。
|
||||
|
||||
// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML
|
||||
// 一一对应,面板可原样往返回填。
|
||||
type ownerProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DefaultPersona string `json:"defaultPersona,omitempty"`
|
||||
IdentityFile string `json:"identityFile,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Accounts []ownerAccountRequest `json:"accounts"`
|
||||
}
|
||||
|
||||
// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。
|
||||
type ownerAccountRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
|
||||
// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。
|
||||
func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse {
|
||||
out := ownerProfileResponse{
|
||||
ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona,
|
||||
IdentityFile: p.IdentityFile, Notes: p.Notes,
|
||||
Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)),
|
||||
}
|
||||
for _, a := range p.Accounts {
|
||||
out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白
|
||||
// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。
|
||||
func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile {
|
||||
p := &user.OwnerProfile{
|
||||
ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName),
|
||||
DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile),
|
||||
Notes: strings.TrimSpace(r.Notes),
|
||||
Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)),
|
||||
}
|
||||
for _, a := range r.Accounts {
|
||||
p.Accounts = append(p.Accounts, user.OwnerAccount{
|
||||
Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID),
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过
|
||||
// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。
|
||||
func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := user.LoadOwnerProfile(ownerProfilePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{}))
|
||||
return
|
||||
}
|
||||
// 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案:
|
||||
// 那会让「白名单是空的」和「档案读不出来」看起来一模一样。
|
||||
s.log.Error("owner profile read", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
|
||||
}
|
||||
|
||||
// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使
|
||||
// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、
|
||||
// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。
|
||||
func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var req ownerProfileResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
p := req.toOwnerProfile()
|
||||
if err := p.Validate(); err != nil {
|
||||
s.auditClaims(r, "owner.profile.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err))
|
||||
return
|
||||
}
|
||||
if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil {
|
||||
s.auditClaims(r, "owner.profile.update", "error")
|
||||
s.log.Error("owner profile persist", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile"))
|
||||
return
|
||||
}
|
||||
s.hotSwapOwnerProfile(p)
|
||||
s.auditClaims(r, "owner.profile.update", "ok")
|
||||
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
|
||||
}
|
||||
|
||||
// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其
|
||||
// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来
|
||||
// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。
|
||||
type liveOwnerProfile interface {
|
||||
// LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。
|
||||
LiveOwnerProfile() *user.OwnerProfile
|
||||
}
|
||||
|
||||
// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条
|
||||
// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。
|
||||
func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) {
|
||||
holder, ok := s.ownerRegistrar.(liveOwnerProfile)
|
||||
if !ok {
|
||||
s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply")
|
||||
return
|
||||
}
|
||||
live := holder.LiveOwnerProfile()
|
||||
if live == nil {
|
||||
s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply")
|
||||
return
|
||||
}
|
||||
live.Replace(p)
|
||||
s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts))
|
||||
}
|
||||
|
||||
// personaFromRequest 从 upsert 请求构建 loader.Persona。characterRef 省略时
|
||||
// 回落到 id(它只是资源引用,有人格 id 就够);identity.systemPrompt 则
|
||||
// **不回落到任何派生串** —— 缺失由调用方显式报 400(裁决 08.4.2),这样写进
|
||||
// 磁盘的人格必然带着真实身份,也必然能被 loader.Load 重新载入。
|
||||
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
|
||||
characterRef := strings.TrimSpace(req.CharacterRef)
|
||||
if characterRef == "" {
|
||||
characterRef = req.ID
|
||||
@@ -244,7 +373,7 @@ func personaFromRequest(req personaUpsertRequest) *loader.Persona {
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
CharacterRef: characterRef,
|
||||
Identity: loader.Identity{Role: req.Role, SystemPrompt: systemPrompt},
|
||||
Identity: loader.Identity{Role: req.Role, SystemPrompt: strings.TrimSpace(req.SystemPrompt)},
|
||||
Personality: req.Personality,
|
||||
SpeechStyle: req.SpeechStyle,
|
||||
Catchphrase: req.Catchphrase,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 本文件钉住阶段 3-G3D 的四条裁决:主人档案真实读写(10.4.5)、API key 真能
|
||||
// 用来鉴权(15.4.2)、key 一律超管(15.4.3)、identity.systemPrompt 必填
|
||||
// (08.4.2)。涉及落盘的用例先把工作目录切到临时目录,因此它们**不碰仓库里的
|
||||
// configs/**;(t.Chdir 与 t.Parallel 互斥,这些用例刻意不加 t.Parallel)。
|
||||
|
||||
const ownerProfileURL = "/api/v1/admin/owner-profile"
|
||||
|
||||
// liveOwnerRegistrar 是组合根 ownerRegistrar 的测试替身:除满足 OwnerRegistrar
|
||||
// 外,还实现可选扩展 LiveOwnerProfile,使热替换路径可被端到端演练。
|
||||
type liveOwnerRegistrar struct{ live *user.OwnerProfile }
|
||||
|
||||
func (r *liveOwnerRegistrar) RegisterChannelOwner(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *liveOwnerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.live }
|
||||
|
||||
// decodeOwnerProfile 从 {data} 信封解出一份主人档案。
|
||||
func decodeOwnerProfile(t *testing.T, body []byte) ownerProfileResponse {
|
||||
t.Helper()
|
||||
var got ownerProfileResponse
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &got); err != nil {
|
||||
t.Fatalf("decode owner profile: %v (body=%s)", err, body)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// doAPIKeyReq 发一个只带 `X-API-Key` 头的请求,模拟用 key 救火的管理员客户端。
|
||||
func doAPIKeyReq(t *testing.T, method, url, key string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if key != "" {
|
||||
req.Header.Set("X-API-Key", key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
return resp, b
|
||||
}
|
||||
|
||||
// TestOwnerProfile_GetPutRoundTrip 钉住 10.4.5:面板的「保存」走真实 API —— PUT
|
||||
// 真的落盘(0600 原子写,内容可被重新载入),GET 读回同一份,不再有「只改本地
|
||||
// state」的假成功。
|
||||
func TestOwnerProfile_GetPutRoundTrip(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// 全新安装(还没人配过主人):200 + 空白档案,而不是 404,也不假装档已存在。
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if got := decodeOwnerProfile(t, body); got.ID != "" || got.DisplayName != "" || len(got.Accounts) != 0 {
|
||||
t.Fatalf("fresh profile = %+v, want an empty profile", got)
|
||||
}
|
||||
if _, err := os.Stat(ownerProfilePath); err == nil {
|
||||
t.Fatal("a fresh install must not have a profile on disk yet")
|
||||
}
|
||||
|
||||
want := ownerProfileResponse{
|
||||
ID: "owner", DisplayName: "叶酱", DefaultPersona: "cyrene",
|
||||
IdentityFile: "configs/users/owner_identity.md", Notes: "主人。",
|
||||
Accounts: []ownerAccountRequest{{Platform: " telegram ", AccountID: " 10086 "}},
|
||||
}
|
||||
resp, body = doReq(t, http.MethodPut, ts.URL+ownerProfileURL, want, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
saved := decodeOwnerProfile(t, body)
|
||||
if saved.ID != "owner" || saved.DisplayName != "叶酱" || len(saved.Accounts) != 1 {
|
||||
t.Fatalf("PUT data = %+v, want the saved profile", saved)
|
||||
}
|
||||
if saved.Accounts[0].Platform != "telegram" || saved.Accounts[0].AccountID != "10086" {
|
||||
t.Fatalf("accounts = %+v, want whitespace-trimmed platform/accountId", saved.Accounts[0])
|
||||
}
|
||||
|
||||
// 真保存的第一判据:磁盘上能重新载入出一模一样的档案(含白名单)。
|
||||
onDisk, err := user.LoadOwnerProfile(ownerProfilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("reload %s: %v", ownerProfilePath, err)
|
||||
}
|
||||
if onDisk.DisplayName != "叶酱" || !onDisk.IsOwner("telegram", "10086") {
|
||||
t.Fatalf("disk profile = %+v, want the saved whitelist", onDisk)
|
||||
}
|
||||
info, err := os.Stat(ownerProfilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat saved profile: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
// Windows 没有常规文件的 POSIX 权限位(Chmod 只切只读标志),因此
|
||||
// 0600 只在类 Unix 平台可断言;save.go 仍然请求 0600。
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("saved profile mode = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// 第二判据:GET 与 PUT 对同一份档案的读法一致(面板可信任任一方回填)。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET after PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
reloaded := decodeOwnerProfile(t, body)
|
||||
if reloaded.ID != saved.ID || reloaded.Notes != saved.Notes || len(reloaded.Accounts) != 1 {
|
||||
t.Fatalf("GET after PUT = %+v, want %+v", reloaded, saved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerProfile_PutRejectsInvalidProfile 断言坏档案被显式拒绝且**不落盘**:
|
||||
// 校验失败绝不能留下半份档案。
|
||||
func TestOwnerProfile_PutRejectsInvalidProfile(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body ownerProfileResponse
|
||||
}{
|
||||
{"missing id", ownerProfileResponse{DisplayName: "叶酱"}},
|
||||
{"missing displayName", ownerProfileResponse{ID: "owner"}},
|
||||
{"half-filled account", ownerProfileResponse{
|
||||
ID: "owner", DisplayName: "叶酱",
|
||||
Accounts: []ownerAccountRequest{{Platform: "telegram"}},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+ownerProfileURL, tc.body, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("%s: PUT status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
if _, err := os.Stat(ownerProfilePath); err == nil {
|
||||
t.Fatalf("%s: a rejected profile must not be persisted", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerProfile_OwnerOnly 断言这份档案只给 owner:它是身份红线(design/11
|
||||
// §0),admin 也不能读、不能写。
|
||||
func TestOwnerProfile_OwnerOnly(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
for _, role := range []auth.Role{auth.RoleAdmin, auth.RoleViewer} {
|
||||
tok := signAny(t, jwt, "acct", role)
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("%s GET status = %d, want 403 (body=%s)", role, resp.StatusCode, body)
|
||||
}
|
||||
resp, body = doReq(t, http.MethodPut, ts.URL+ownerProfileURL,
|
||||
ownerProfileResponse{ID: "owner", DisplayName: "x"}, tok)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("%s PUT status = %d, want 403 (body=%s)", role, resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerProfile_PutHotSwapsLiveProfile 断言保存后活档案被**原地**替换:渠道
|
||||
// bridge 持有的是同一个指针,因此新白名单立即生效、被移除的账号立即失效,无需
|
||||
// 重启(design/11 §9「可编辑、热重载」)。
|
||||
func TestOwnerProfile_PutHotSwapsLiveProfile(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
live := &user.OwnerProfile{
|
||||
ID: "owner", DisplayName: "旧名",
|
||||
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "1"}},
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{OwnerRegistrar: &liveOwnerRegistrar{live: live}})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+ownerProfileURL, ownerProfileResponse{
|
||||
ID: "owner", DisplayName: "叶酱",
|
||||
Accounts: []ownerAccountRequest{{Platform: "telegram", AccountID: "10086"}},
|
||||
}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if live.DisplayName != "叶酱" {
|
||||
t.Errorf("live DisplayName = %q, want 叶酱 (in-place swap)", live.DisplayName)
|
||||
}
|
||||
if !live.IsOwner("telegram", "10086") {
|
||||
t.Error("newly whitelisted account must be live immediately")
|
||||
}
|
||||
if live.IsOwner("kook", "1") {
|
||||
t.Error("removed account must stop being the owner")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminApiKey_IssuedKeyReallyAuthenticates 是 15.4.2 的端到端断言:签发的
|
||||
// key 真的能过 owner-only 门(不再是「可签发、无中间件接受」的死端),列表里恒为
|
||||
// owner(15.4.3),吊销后立刻失效。
|
||||
func TestAdminApiKey_IssuedKeyReallyAuthenticates(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := auth.NewAPIKey(config.AuthConfig{})
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// 签发不带任何 role:key 一律超管(15.4.3)。
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/users/apikey", nil, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("issue status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var issued struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &issued); err != nil {
|
||||
t.Fatalf("decode issued key: %v", err)
|
||||
}
|
||||
if issued.Key == "" || issued.ID == "" {
|
||||
t.Fatalf("issued = %+v, want id and one-time plaintext", issued)
|
||||
}
|
||||
if issued.Role != string(auth.RoleOwner) {
|
||||
t.Fatalf("issued role = %q, want owner", issued.Role)
|
||||
}
|
||||
if issued.ID == issued.Key {
|
||||
t.Error("the returned id must be the masked form, not the plaintext")
|
||||
}
|
||||
|
||||
// 只带 X-API-Key(无 Bearer)访问 owner-only 读端点 —— 这就是「key 能用」。
|
||||
resp, body = doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", issued.Key)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /admin/users with X-API-Key status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 管理列表里这条 key 恒为 owner(表已无角色维度)。
|
||||
var listEnv struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &listEnv); err != nil {
|
||||
t.Fatalf("decode users list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, u := range listEnv.Data {
|
||||
if u.ID == issued.ID {
|
||||
found = true
|
||||
if u.Username != "apikey" || u.Role != string(auth.RoleOwner) {
|
||||
t.Fatalf("key entry = %+v, want apikey/owner", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("issued key %s missing from the users list", issued.ID)
|
||||
}
|
||||
|
||||
// 吊销后同一把 key 立即 401:能发也能废。
|
||||
resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/users/apikey/"+issued.ID, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revoke status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", issued.Key)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked key status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// TestAdminApiKey_BadKeyIsFailClosed 断言未知 key 一律 401,且**即使同时带着一份
|
||||
// 合法 Bearer 也不回落** —— 坏 key 不能借道另一份凭据进门。
|
||||
func TestAdminApiKey_BadKeyIsFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := auth.NewAPIKey(config.AuthConfig{})
|
||||
if _, err := keys.Generate(); err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
|
||||
|
||||
resp, body := doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", "not-a-key")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unknown key status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/users", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("X-API-Key", "not-a-key")
|
||||
withBoth, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer withBoth.Body.Close()
|
||||
if withBoth.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("bad key + valid bearer status = %d, want 401 (fail-closed)", withBoth.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaWrite_RequiresSystemPrompt 钉住 08.4.2:identity.systemPrompt 必填,
|
||||
// 缺失/空白显式报 400(不回落、不派生),且被拒的请求不落盘;带上它则写出的
|
||||
// YAML 能被载入期用同一套规则重新载入。
|
||||
func TestPersonaWrite_RequiresSystemPrompt(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
reg := personareg.New()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
url := ts.URL + "/api/v1/admin/personas"
|
||||
|
||||
for _, req := range []map[string]any{
|
||||
{"id": "xilian", "name": "昔涟", "role": "r"},
|
||||
{"id": "xilian", "name": "昔涟", "role": "r", "systemPrompt": " "},
|
||||
} {
|
||||
resp, body := doReq(t, http.MethodPost, url, req, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("POST %v status = %d, want 400 (body=%s)", req, resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
if _, ok := reg.Persona("xilian"); ok {
|
||||
t.Fatal("a persona with no systemPrompt must not be registered")
|
||||
}
|
||||
}
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, url, map[string]any{
|
||||
"id": "xilian", "name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。",
|
||||
}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("POST with systemPrompt status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
reloaded, err := loader.Load(filepath.Join(personaDir, "xilian.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("reload the saved persona (loader enforces the same rule): %v", err)
|
||||
}
|
||||
if reloaded.Identity.SystemPrompt != "你是昔涟。" {
|
||||
t.Fatalf("systemPrompt = %q, want the written value", reloaded.Identity.SystemPrompt)
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ func (s *Server) Handler() http.Handler {
|
||||
// 已认证角色」:它指 viewer 角色存在的那个只读监控
|
||||
// 表面(模块清单、运行时指标/trace、日志尾部
|
||||
// 与通知 feed)—— 见 registerAdminRoutes。
|
||||
anyAuth := s.auth.Authorize()
|
||||
ownerAuth := s.auth.Authorize(auth.RoleOwner)
|
||||
writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
|
||||
viewerAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
|
||||
anyAuth := s.authorize()
|
||||
ownerAuth := s.authorize(auth.RoleOwner)
|
||||
writeAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin)
|
||||
viewerAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
|
||||
|
||||
s.registerPublicRoutes(mux)
|
||||
s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth)
|
||||
@@ -121,8 +121,7 @@ func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.H
|
||||
// - ownerAuth(owner):带审计的策略写、每个获取凭据的
|
||||
// 渠道端点(login-qr/login-status)以及每一个其他变更。
|
||||
//
|
||||
// 每个写 handler 都会持久化到真实后端存储,或用明确的
|
||||
// "M10 / not-wired" 错误诚实作答,而不是编造成功。
|
||||
// 管理写路由表见 registerAdminWriteRoutes。
|
||||
func (s *Server) registerAdminRoutes(
|
||||
mux *http.ServeMux,
|
||||
ownerAuth func(http.Handler) http.Handler,
|
||||
@@ -163,9 +162,19 @@ func (s *Server) registerAdminRoutes(
|
||||
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
|
||||
|
||||
// 管理写:仅 owner,带审计。
|
||||
s.registerAdminWriteRoutes(mux, ownerAuth)
|
||||
}
|
||||
|
||||
// registerAdminWriteRoutes 挂载管理写路由:仅 owner,带审计。每个 handler 都会
|
||||
// 持久化到真实后端存储,或用明确的 "M10 / not-wired" 错误诚实作答,而不是编造成功。
|
||||
func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler) {
|
||||
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
|
||||
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
|
||||
// 主人档案(裁决 10.4.5):白名单 + 显示名/关系备注的真实读写。
|
||||
// **仅 owner** —— 这份档案决定谁能进 owner 车道(design/11 §0 的身份红线),
|
||||
// 因此既不给 viewer 读、也不给 admin 写。
|
||||
mux.Handle("GET /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminGetOwnerProfile)))
|
||||
mux.Handle("PUT /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminPutOwnerProfile)))
|
||||
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
|
||||
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
|
||||
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
|
||||
@@ -212,6 +221,32 @@ func (s *Server) mountWebhooks(mux *http.ServeMux) {
|
||||
}
|
||||
}
|
||||
|
||||
// authorize 返回要求给定角色的凭据门。它由 Bearer JWT 门加上 `X-API-Key`
|
||||
// 分支组成(裁决 15.4.2):key 表已注入时两种凭据都收,否则只认 Bearer。
|
||||
// 按裁决 15.4.3,任何有效 key 一律 owner 级(owner 满足一切角色要求),
|
||||
// 未知/已吊销 key 一律 401 且不回落 Bearer(fail-closed,见 auth.WithAPIKey)。
|
||||
func (s *Server) authorize(required ...auth.Role) func(http.Handler) http.Handler {
|
||||
bearer := s.auth.Authorize(required...)
|
||||
keys := s.apiKeyTable()
|
||||
if keys == nil {
|
||||
return bearer
|
||||
}
|
||||
return auth.WithAPIKey(keys, bearer, s.log, required...)
|
||||
}
|
||||
|
||||
// apiKeyTable 返回注入的 API key 表;未注入(或注入的实现不是 key 表)时为
|
||||
// nil,此时鉴权只认 Bearer —— 「表不在」等同空表,绝不因此放行任何 key。
|
||||
func (s *Server) apiKeyTable() auth.KeyTable {
|
||||
if s.users == nil {
|
||||
return nil
|
||||
}
|
||||
table, ok := s.users.(auth.KeyTable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
|
||||
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
|
||||
// panic recover、CORS 与请求日志。
|
||||
|
||||
@@ -29,6 +29,17 @@ func LoadOwnerProfile(path string) (*OwnerProfile, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Validate 用与 LoadOwnerProfile 完全相同的一套规则校验一份**内存中**的档案,
|
||||
// 使管理面板的写路径能在落盘前就拒掉坏档案,而不是先写进磁盘、等下次启动才
|
||||
// 报错。它只校验必填字段与半空的 accounts 项,不校验平台名是否已知
|
||||
// (适配器可以后装)。
|
||||
func (p *OwnerProfile) Validate() error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("%w: empty profile", ErrRequiredField)
|
||||
}
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
// validate checks the owner profile for its required fields and for partial
|
||||
// whitelist entries. An empty whitelist is allowed: on a fresh install nobody
|
||||
// has been configured as owner, so the policy engine defaults to deny until the
|
||||
|
||||
@@ -68,6 +68,29 @@ func SaveOwnerProfile(path string, p *OwnerProfile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace 把 src 的**落盘字段**(owner.yaml 的那些)原地装进 p,**保留 p 的指针
|
||||
// 身份与互斥量**。
|
||||
//
|
||||
// 这是「后台面板改主人白名单立即生效」的实现方式:渠道 bridge 在启动时就捕获
|
||||
// 了同一个 *OwnerProfile 指针并用它解析 actor(见 IsOwner),换指针则那些组件
|
||||
// 仍看着旧档案。内存态来源(例如每渠道配置在热重载时带进来的层)不在这里的字段
|
||||
// 之列,因此不会被一次面板保存抹掉。accounts 取副本,调用方之后改动 src 不会
|
||||
// 影响活档案。它通过 mu 与 IsOwner/RegisterAccount 并发安全;src 为 nil 时是安全
|
||||
// 的空操作。
|
||||
func (p *OwnerProfile) Replace(src *OwnerProfile) {
|
||||
if p == nil || src == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.ID = src.ID
|
||||
p.DisplayName = src.DisplayName
|
||||
p.DefaultPersona = src.DefaultPersona
|
||||
p.IdentityFile = src.IdentityFile
|
||||
p.Notes = src.Notes
|
||||
p.Accounts = append([]OwnerAccount(nil), src.Accounts...)
|
||||
}
|
||||
|
||||
// RegisterOwnerAccount loads the owner whitelist at path, appends (platform,
|
||||
// accountID) when it is not already present (idempotent), and writes the updated
|
||||
// profile back. It returns an error when the profile cannot be loaded or saved.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -156,3 +157,68 @@ func TestRegisterAccount_HotEffect(t *testing.T) {
|
||||
t.Fatalf("accounts after duplicate = %d, want still 2", len(p.Accounts))
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerProfile_Replace_HotEffect 钉住后台面板保存后的热替换语义:Replace 把
|
||||
// 新档案装进**同一个指针**(渠道 bridge 解析 actor 用的就是它),因此新白名单
|
||||
// 立即生效、被移除的账号立即失效。换指针的实现会在这里失败。
|
||||
func TestOwnerProfile_Replace_HotEffect(t *testing.T) {
|
||||
t.Parallel()
|
||||
live := &OwnerProfile{ID: "owner", DisplayName: "旧名",
|
||||
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "1"}}}
|
||||
saved := &OwnerProfile{ID: "owner", DisplayName: "叶酱", DefaultPersona: "cyrene",
|
||||
IdentityFile: "configs/users/owner_identity.md", Notes: "主人。",
|
||||
Accounts: []OwnerAccount{{Platform: "telegram", AccountID: "10086"}}}
|
||||
|
||||
live.Replace(saved)
|
||||
|
||||
if live.DisplayName != "叶酱" || live.DefaultPersona != "cyrene" || live.Notes != "主人。" {
|
||||
t.Fatalf("live profile = %+v, want the saved values", live)
|
||||
}
|
||||
if !live.IsOwner("telegram", "10086") {
|
||||
t.Error("newly whitelisted account must be recognized immediately")
|
||||
}
|
||||
if live.IsOwner("kook", "1") {
|
||||
t.Error("removed account must stop being the owner — Replace replaces the whole list")
|
||||
}
|
||||
|
||||
// accounts 是副本:随后改动 saved 不会影响活档案。
|
||||
saved.Accounts[0].AccountID = "999"
|
||||
if live.IsOwner("telegram", "999") || !live.IsOwner("telegram", "10086") {
|
||||
t.Error("live profile must not alias the saved profile's account slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerProfile_Replace_NilSafe 断言 nil 入参是安全空操作(不 panic)。
|
||||
func TestOwnerProfile_Replace_NilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
live := &OwnerProfile{ID: "owner", DisplayName: "叶酱"}
|
||||
live.Replace(nil)
|
||||
if live.DisplayName != "叶酱" {
|
||||
t.Fatalf("DisplayName = %q, want unchanged", live.DisplayName)
|
||||
}
|
||||
var nilProfile *OwnerProfile
|
||||
nilProfile.Replace(live) // 必须不 panic
|
||||
}
|
||||
|
||||
// TestOwnerProfile_Validate 断言写路径能在落盘前用与载入期同一套规则拒掉坏档案:
|
||||
// 缺 id/displayName、accounts 项只填一半都报错,而空白名单是合法的(全新安装
|
||||
// 就是没人被配成主人,策略引擎默认拒绝)。
|
||||
func TestOwnerProfile_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := (&OwnerProfile{ID: "owner", DisplayName: "叶酱"}).Validate(); err != nil {
|
||||
t.Fatalf("valid profile error: %v", err)
|
||||
}
|
||||
if err := (&OwnerProfile{ID: "owner"}).Validate(); !errors.Is(err, ErrRequiredField) {
|
||||
t.Fatalf("missing displayName err = %v, want ErrRequiredField", err)
|
||||
}
|
||||
if err := (&OwnerProfile{DisplayName: "叶酱"}).Validate(); !errors.Is(err, ErrRequiredField) {
|
||||
t.Fatalf("missing id err = %v, want ErrRequiredField", err)
|
||||
}
|
||||
half := &OwnerProfile{ID: "owner", DisplayName: "叶酱", Accounts: []OwnerAccount{{Platform: "kook"}}}
|
||||
if err := half.Validate(); !errors.Is(err, ErrRequiredField) {
|
||||
t.Fatalf("half-filled account err = %v, want ErrRequiredField", err)
|
||||
}
|
||||
if err := (*OwnerProfile)(nil).Validate(); !errors.Is(err, ErrRequiredField) {
|
||||
t.Fatalf("nil profile err = %v, want ErrRequiredField", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user