forked from EduCraft/curriculum-project-hub
154 lines
8.4 KiB
Markdown
154 lines
8.4 KiB
Markdown
# 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 `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.
|