docs(adr): member-group ADR 改号 0028→0038,避免与 agent-config-folder-tree 撞号

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