From adef36538f1e15f334c93febba08e324ffe7581b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:07:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth,gateway,user):=20X-API-Key=20?= =?UTF-8?q?=E7=9C=9F=E6=8E=A5=E7=BA=BF=20+=20key=20=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E8=B6=85=E7=AE=A1=20+=20owner=20=E6=A1=A3=E6=A1=88=E7=9C=9F?= =?UTF-8?q?=E8=90=BD=E7=9B=98=EF=BC=88=E8=A3=81=E5=86=B3=2015.4.2/15.4.3/1?= =?UTF-8?q?0.4.5/08.4.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 显式报错,不再派生回落 --- frontend/web/src/admin/modules/Users.tsx | 374 ++++++++++++------ frontend/web/src/lib/api.ts | 2 + frontend/web/src/lib/api/admin.ts | 25 +- internal/auth/apikey.go | 45 ++- internal/auth/apikey_test.go | 41 +- internal/auth/rbac.go | 108 ++++- internal/auth/rbac_test.go | 132 +++++++ .../gateway/admin_write_personas_users.go | 185 +++++++-- .../admin_write_personas_users_test.go | 361 +++++++++++++++++ internal/services/gateway/routes.go | 49 ++- internal/user/load.go | 11 + internal/user/save.go | 23 ++ internal/user/save_test.go | 66 ++++ 13 files changed, 1223 insertions(+), 199 deletions(-) create mode 100644 internal/services/gateway/admin_write_personas_users_test.go diff --git a/frontend/web/src/admin/modules/Users.tsx b/frontend/web/src/admin/modules/Users.tsx index b31b960..6db35df 100644 --- a/frontend/web/src/admin/modules/Users.tsx +++ b/frontend/web/src/admin/modules/Users.tsx @@ -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 = { 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(initialProfile); - const [issuedKey, setIssuedKey] = useState(null); - const [role, setRole] = useState("owner"); const [busy, setBusy] = useState(false); const [fail, setFail] = useState(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 ( +
+ + 新增用户 + + } + /> + {fail ? {fail} : null} + + + +
+ ); +} + +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[] = [ { key: "username", title: "用户", render: (u) => {u.username} }, { key: "role", title: "角色", render: (u) => {u.role} }, @@ -99,7 +111,7 @@ export function UsersModule(): React.ReactNode { title: "操作", render: (u) => u.username === "apikey" && isOwner ? ( - ) : ( @@ -107,100 +119,214 @@ export function UsersModule(): React.ReactNode { ), }, ]; + return ( + +

用户列表 · 角色(RBAC)

+ u.id} + /> +
+ ); +} + +/** 主人档案编辑块(裁决 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(null); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + const [fail, setFail] = useState(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 ( -
- - 新增用户 - - } - /> - + +

主人档案({isOwner ? "可编辑" : "只读 · 需 owner"})

+

+ 对应 configs/users/owner.yaml:各渠道确认的主人账号白名单(策略引擎据此比对 actor 元数据,非提示词/LLM 判断)+ 关系备注。身份说明书(给 LLM 的人格知识,非权限依据)见 identityFile。 +

+ {error ? ( + + 主人档案读取失败:{error}(未拿到真实档案,表单已禁用,不拿本地假数据冒充) + + ) : null} {fail ? {fail} : null} + {notice ? {notice} : null} + {profile ? ( + + ) : ( +

{loading ? "读取中…" : "未加载到主人档案"}

+ )} +
+ ); +} - -

用户列表 · 角色(RBAC)

- {error && !data ? ( -
- 后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{error} - u.id} - /> -
- ) : ( - u.id} /> - )} -
+interface OwnerProfileFormProps { + profile: OwnerProfile; + disabled: boolean; + busy: boolean; + onChange: (next: OwnerProfile) => void; + onSave: () => void; +} - -

主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})

-

- 对应 configs/users/owner.yaml:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。 -

-
- setProfile({ ...profile, id: v })} disabled={!isOwner} /> - setProfile({ ...profile, displayName: v })} disabled={!isOwner} /> - setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} /> - setProfile({ ...profile, identityFile: v })} disabled={!isOwner} /> -
-
- 关系备注 -