8.4 KiB
ADR 0028: 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
depthderivation (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
GroupResolverbecomes the in-hubMemberGroupclosure reader.createTeamGroupResolveris 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
effectiveRolereducer (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.