Files
curriculum-project-hub/docs/adr/0038-member-group-management-and-resolution.md
T

8.4 KiB
Raw Blame History

ADR 0038: Member Group Management And Resolution

Status

Accepted.

Context

ADR-0020 fixed Organization as the tenant root and ADR-0019 pinned the principal-set permission model. The file library (《文件库-接口契约.md》) computes effective permission over two principal kinds — USER and GROUP — and consumes the group side through a single read-only port, GroupResolver (resolveMemberGroupIds(userId) → groupIds[], contract C2/G2).

The contract's v0.1 proposal framed the Group system as a separate HTTP service owned by another team, consumed read-only. In practice the schema now carries the group tables directly in the hub database (MemberGroup, MemberGroupMembership, MemberGroupClosure — a global, unlimited-depth, closure-backed hierarchy), and the product requirement is to build group management in the backend admin, not to integrate a foreign service. Until this ADR, nothing read or wrote those tables: the live GroupResolver was a transitional implementation reading flat hub Team membership, and the admin "Group 管理" panel actually managed Team.

This ADR settles the semantics needed to make the MemberGroup tables the real, in-hub group system.

Decision

Group system is in-hub, not a foreign service

MemberGroup is the platform's global member-group principal. It lives in the hub database and is managed through the /database backend. The contract's "separate service" framing was an unfrozen v0.1 proposal; the implementation aligns to the tables that were actually built. The GroupResolver port stays — an external HUB_GROUP_SERVICE_URL HTTP implementation remains a supported override — but the default implementation reads the in-hub MemberGroup closure.

Authority: website administrator only

Group create/delete and member add/remove are restricted to the website administrator, defined (consistently with the rest of the file library, D19/C4 adaptation) as an OWNER/ADMIN of the silo Organization (isWebsiteAdmin in filelib/guards.ts). ADR-0023's PlatformIdentity is the future "true" platform control plane; the file library uniformly uses org OWNER/ADMIN today and this feature stays consistent with that. Reading groups for the authorization selector (/groups/search) is not admin-gated — picking a group to grant is a Manage holder's ability, not an administrator's.

Resolution semantics (the crux)

resolveMemberGroupIds(user) returns the user's active direct groups the active ancestors of those groups, deduplicated (the closure's depth-0 self row makes each direct group its own ancestor). This is the single query the permission engine relies on; equivalently: a grant placed on group G applies to members of G and of every descendant of G (requirement 3.2 — permission flows down the tree, so resolution collects up the tree). It is computed live, never cached (contract D4/G4): a membership change is visible on the very next protected request.

MemberGroup is global (no organizationId), so resolution is not org-scoped.

Soft delete via archivedAt, cascading the subtree

Delete is soft: MemberGroup.archivedAt is a tag. Deleting a group cascade-soft-deletes its whole subtree (walk MemberGroupClosure where ancestorId = G, stamp archivedAt on each active descendant) — an application operation, not a DB constraint. Closure and membership rows are retained; resolution and listing filter by archivedAt, so an archived group and everything under it stop contributing to permission at once.

Closure maintenance

The closure is maintained on create: insert (G, G, 0), then for a parent P insert (a.ancestorId, G, a.depth + 1) for every a in closure where descendantId = P. v1 does not support reparenting a group (moving it under a new parent). The schema reserves reparent (closure rebuild plus the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.

Rename and description edits are in scope; reparent stays out

A group's name and description are mutable by the website administrator (PATCH /database/api/groups/:id, audited as group.update). This is deliberately separated from reparent: renaming touches no closure row and cannot create a cycle, so it carries none of the invariant risk that keeps reparent out of v1. The endpoint therefore rejects a parentId field outright rather than ignoring it, so a future reparent cannot arrive silently through this route. Passing an empty description clears it; omitting a field leaves it unchanged.

Restore is deliberately asymmetric with delete

Archived groups stay visible to the administrator (GET /database/api/groups?includeArchived=1 returns them carrying archivedAt; the console tags and greys them) and can be restored (POST /database/api/groups/:id/restore, audited as group.restore).

Restore is not the mirror image of delete. Delete cascades down the whole subtree; restore un-archives the group plus every archived ancestor of it, and nothing below it:

  • Restoring the ancestor chain is mandatory, not a convenience. An active group whose parent is archived has no path in the tree, and the depth derivation (closure row count) presumes "an active group's ancestors are active" — the invariant that cascade-delete establishes. Restoring a node alone would break it.
  • The subtree is deliberately left archived. A group's descendants may have been archived for reasons of their own, and one click should not silently re-grant permission across a whole historical branch. Descendants remain visible in their archived state and are each restored explicitly.

Restore takes effect immediately, like every other membership change (D4/G4): the group resumes contributing permission on the next resolution.

An archived group is readable but not writable. Its membership rows are never revoked by archiving, so listMembers succeeds on an archived group — the console must be able to show who was in it before deciding whether to restore it. Every mutation, by contrast, still requires an active group (requireActiveGroup → 404): rename, child creation, and member add/remove all reject. The group is inert for permission purposes and frozen for editing, but not hidden and not forgotten.

Member picker reads global users, admin-only

GET /database/api/users/search backs the "add member" picker: it matches User by display name or Feishu open id and is gated to the website administrator, the same authority that may add members. It widens no existing capability — adding a member already accepts any global user (resolveUser does not require an org membership), so the endpoint only replaces blind id entry with search. It is deliberately not opened to the non-admin authorization-selector audience that /groups/search serves: choosing a group to grant is a Manage-holder action, whereas enumerating people is not. excludeGroupId filters out the target group's active members so the picker cannot surface a candidate that must 409.

Audit is written in-hub

The contract (C3 §6.3) originally deferred group actions to the foreign Group service's own audit. With the group system in-hub, group mutations are audited through the existing file-library sink (filelib/audit.ts, same-transaction AuditEntry) under the silo Organization — MemberGroup has no organizationId, so the audit row is attributed to the silo org. New actions: group.create, group.update, group.delete, group.restore, group.member_add, group.member_remove; new audit object type group.

Consequences

  • The default GroupResolver becomes the in-hub MemberGroup closure reader. createTeamGroupResolver is retained but deprecated (no longer wired); existing flat-Team group grants no longer resolve for the file library.
  • Group grants take effect in real time through the existing effectiveRole reducer (P6) with no change to the permission algebra — only the set of group ids fed to it changes.
  • v1 omits reparent; the closure invariants above must hold whenever reparent is added later (rebuild descendants' ancestor rows, reject cycles).
  • Group management is an admin-only surface; the authorization selector is not.
  • Numeric limits (max depth, max members) and a hard-delete/restore path remain follow-on operational decisions; they must not weaken the archived-filter, admin-authority, or live-resolution invariants fixed here.