diff --git a/CONTEXT.md b/CONTEXT.md index 0cba565..d31f8a7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,5 +101,5 @@ An audited Platform Administrator control that prevents new agent work for one O _Avoid_: Organization deletion, service restart **Member Group**: -A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization. +A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0038). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization. _Avoid_: Team (the org-scoped flat grouping), Feishu department diff --git a/docs/adr/0029-web-surface-frontend-backend-separation.md b/docs/adr/0029-web-surface-frontend-backend-separation.md index 5022ab1..8023903 100644 --- a/docs/adr/0029-web-surface-frontend-backend-separation.md +++ b/docs/adr/0029-web-surface-frontend-backend-separation.md @@ -104,7 +104,7 @@ route outranks the fallback. The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than restated. It came from `adminPanels.ts`; Group nodes deliberately use a two-person silhouette, not a folder glyph, because `MemberGroup` and the file - library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021). + library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0038, ADR-0021). - **A migrated surface is only done when its endpoint coverage matches.** Two panels were rebuilt from a superficially similar component that predated the diff --git a/docs/adr/0028-member-group-management-and-resolution.md b/docs/adr/0038-member-group-management-and-resolution.md similarity index 99% rename from docs/adr/0028-member-group-management-and-resolution.md rename to docs/adr/0038-member-group-management-and-resolution.md index 17dae92..ef44610 100644 --- a/docs/adr/0028-member-group-management-and-resolution.md +++ b/docs/adr/0038-member-group-management-and-resolution.md @@ -1,4 +1,4 @@ -# ADR 0028: Member Group Management And Resolution +# ADR 0038: Member Group Management And Resolution ## Status diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 03092fb..0da62a3 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -89,7 +89,7 @@ export interface GroupSearchResult { } -/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */ +/** 成员组(ADR-0038);后端返回扁平列表,前端按 parentId/depth 拼树。 */ export interface MemberGroupNode { readonly id: string; readonly parentId: string | null; @@ -97,7 +97,7 @@ export interface MemberGroupNode { readonly description: string | null; readonly depth: number; readonly memberCount: number; - /** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 + /** 软删标记(ADR-0038 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 * 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */ readonly archivedAt: string | null; } diff --git a/hub/src/database/README.md b/hub/src/database/README.md index 3543cec..6cb06aa 100644 --- a/hub/src/database/README.md +++ b/hub/src/database/README.md @@ -85,7 +85,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | `routes/databaseRoutes.ts` | `/database/config`、`/database/api/stats`、dev 旁路 + 各子路由装配点 | | `routes/filelibRoutes.ts` | 文件库 树/授权 API | | `routes/fileRoutes.ts` | 文件库 文件内容/导出 API | -| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) | +| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0038) | | `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 | | `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app`、`/database` 两个 SPA 回退 | | `filelib/` | 文件库领域层(见下) | @@ -99,7 +99,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11–D19 等) 出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`, 内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN) -另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是 +另见 ADR-0038。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是 两套体系,不复用。** | 文件 | 职责 | @@ -112,9 +112,9 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port | | `filelib/versionStore.ts` | 契约 C1 port + 内存实现(**仅测试用**,ADR-0030) | | `filelib/gitVersionStore.ts` | **生产** C1 实现:一项目一 git 仓库,VersionId = commit hash(ADR-0030) | -| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) | -| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) | -| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) | +| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0038) | +| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0038) | +| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0038) | | `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) | | `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 | | `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) | @@ -124,7 +124,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 - `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`) - `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub - MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team) + MemberGroup 闭包**(ADR-0038 起的默认;此前是扁平 hub Team) ## 存储布局(ADR-0030) diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts index 56f1267..6bdafe8 100644 --- a/hub/src/database/filelib/audit.ts +++ b/hub/src/database/filelib/audit.ts @@ -36,7 +36,7 @@ export const FILE_LIB_AUDIT_ACTIONS = { fileConflictDetected: "file.conflict_detected", exportRun: "export.run", adminForceAdjust: "admin.force_adjust", - // ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。 + // ADR-0038:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。 groupCreate: "group.create", groupUpdate: "group.update", groupDelete: "group.delete", diff --git a/hub/src/database/filelib/groupResolver.ts b/hub/src/database/filelib/groupResolver.ts index 2134476..94daaf4 100644 --- a/hub/src/database/filelib/groupResolver.ts +++ b/hub/src/database/filelib/groupResolver.ts @@ -2,7 +2,7 @@ * GroupResolver port(契约 C2)。 * * 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。 - * ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器 + * ADR-0038 起,默认实现是 in-hub 的 MemberGroup 闭包读取器 * (`createMemberGroupResolver`,见 memberGroupResolver.ts); * `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。 * 调用方只依赖此 port,不换调用点。 @@ -15,7 +15,7 @@ export interface GroupResolver { } /** - * @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为 + * @deprecated ADR-0038:成员组已内置为 in-hub MemberGroup,默认 resolver 改为 * `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照 * (以及潜在的迁移对照),新代码不要使用。 * diff --git a/hub/src/database/filelib/memberGroupResolver.ts b/hub/src/database/filelib/memberGroupResolver.ts index 60c2730..92b9e46 100644 --- a/hub/src/database/filelib/memberGroupResolver.ts +++ b/hub/src/database/filelib/memberGroupResolver.ts @@ -1,5 +1,5 @@ /** - * 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。 + * 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0038)。 * * resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重 * (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上, diff --git a/hub/src/database/filelib/memberGroupService.ts b/hub/src/database/filelib/memberGroupService.ts index 8035d8d..b0a5b30 100644 --- a/hub/src/database/filelib/memberGroupService.ts +++ b/hub/src/database/filelib/memberGroupService.ts @@ -1,5 +1,5 @@ /** - * 成员组(MemberGroup)管理服务(ADR-0028)。 + * 成员组(MemberGroup)管理服务(ADR-0038)。 * * 语义锚定: * - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org @@ -84,14 +84,14 @@ type Tx = Prisma.TransactionClient; /* ---------------------------------------------------------------- 内部工具 */ -/** 管理门禁:非网站管理员一律 403(决策2)。 */ +// 管理门禁:非网站管理员一律 403。 function requireAdmin(actor: FileLibActor): void { if (!actor.isWebsiteAdmin) { throw new FileLibError(403, "forbidden", "group management requires website administrator"); } } -/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */ +// 组名校验。 function normalizeGroupName(raw: string): string { const name = raw.trim(); if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty"); @@ -111,7 +111,7 @@ async function requireActiveGroup( return group; } -/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */ +// 全局用户解析:按 userId 或 feishuOpenId。不要求 org 成员。 async function resolveUser( tx: Tx, input: AddMemberInput, @@ -211,10 +211,7 @@ export async function createMemberGroup( }); } -/** - * 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1 - * 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。 - */ +/** 改名/改描述(决策6)。不动 parentId(决策5)。 */ export async function updateMemberGroup( deps: MemberGroupServiceDeps, actor: FileLibActor, @@ -306,15 +303,7 @@ export async function deleteMemberGroup( }); } -/** - * 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7): - * - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。 - * - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、 - * depth 推导(闭包行数)与"祖先必活跃"的前提脱节。 - * - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次 - * 恢复意外把整支历史组全部重新授权。 - * 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。 - */ +/** 恢复(取消归档)。与删除不对称(决策7):只恢复本组+已归档祖先,不动子树。 */ export async function restoreMemberGroup( deps: MemberGroupServiceDeps, actor: FileLibActor, @@ -402,10 +391,7 @@ export async function listMemberGroups( })); } -/** - * 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在, - * 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。 - */ +/** 组成员列表。已归档组也可读(决策7)。 */ export async function listMembers( deps: MemberGroupServiceDeps, actor: FileLibActor, @@ -506,12 +492,7 @@ export async function removeMember( }); } -/** - * 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2) - * —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大 - * 已有能力面,只是把"盲敲 id"变成"搜索选择"。 - * excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。 - */ +/** 成员选择器:按显示名/openId 搜全局用户(决策2)。 */ export async function searchUsers( deps: MemberGroupServiceDeps, actor: FileLibActor, diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 1ea7a70..ab344ea 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -138,7 +138,7 @@ export async function registerDatabaseRoutes( // 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。 // 依赖装配:VersionStore 是真 git —— 一项目一仓库 /, // VersionId = commit hash(ADR-0030); - // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028), + // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0038), // HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); // 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。 const siloOrg = await config.prisma.organization.findUnique({ diff --git a/hub/src/database/routes/filelibRoutes.ts b/hub/src/database/routes/filelibRoutes.ts index 9c6a2ac..9e165e7 100644 --- a/hub/src/database/routes/filelibRoutes.ts +++ b/hub/src/database/routes/filelibRoutes.ts @@ -254,7 +254,7 @@ export async function registerFileLibRoutes( }); // Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub - // MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。 + // MemberGroup 闭包(ADR-0038)。此处不再注册,避免重复。 } function parseGrants(raw: unknown): InitialGrant[] | undefined { diff --git a/hub/src/database/routes/memberGroupRoutes.ts b/hub/src/database/routes/memberGroupRoutes.ts index 0050aae..de813dd 100644 --- a/hub/src/database/routes/memberGroupRoutes.ts +++ b/hub/src/database/routes/memberGroupRoutes.ts @@ -1,5 +1,5 @@ /** - * /database/api/groups/* 成员组管理端点(ADR-0028)。 + * /database/api/groups/* 成员组管理端点(ADR-0038)。 * 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService; * 错误统一 sendRouteError。 * @@ -108,7 +108,7 @@ export async function registerMemberGroupRoutes( const { id } = request.params as { id: string }; const body = bodyObject(request.body); if (body["parentId"] !== undefined) { - throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)"); + throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0038)"); } // description 需区分"未传"(不动)与 ""(清空),故不用 optionalString // (它把 "" 也归为 undefined)。 diff --git a/hub/test/integration/member-groups.test.ts b/hub/test/integration/member-groups.test.ts index f00b15b..a4a294f 100644 --- a/hub/test/integration/member-groups.test.ts +++ b/hub/test/integration/member-groups.test.ts @@ -1,5 +1,5 @@ /** - * 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。 + * 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0038。 * 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递 * (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。 * 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。 @@ -128,7 +128,7 @@ describe("memberGroupService · 改名/改描述(决策6)", () => { expect(updated.parentId).toBe(a); expect(updated.depth).toBe(1); - // 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。 + // 闭包逐行未变 —— rename 不碰层级(ADR-0038 决策6 的核心不变量)。 const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] }); expect(after).toEqual(before); // C 仍在 B 之下,depth 不变。