Design: hierarchy, scope model & inheritance-aware ownership
GitLab is a tree of nested groups with inherited membership, unlike the flat
orgs of GitHub and Forgejo. A naive set-diff would try to delete inherited members
and fail. This document defines the scope model and inheritance rules that the
config/types, diff(), and the membership cycle follow.
The central question it resolves:
Given a member that appears live at node X, is it a delete candidate?
A member is a delete candidate only if it is a direct membership at node X, it is
not in the desired config, and it is owned (opts.isOwned). An inherited or
shared-group membership is never a delete candidate at a child node.
1. Scope model
A node is a single group or project that the operator declares in config. warden
manages exactly the declared nodes; it does not auto-walk descendant_groups
to discover and manage the whole tree (that would silently claim ownership of nodes
the operator never named).
- Config is a map keyed by full path (URL-encoded when addressed):
acme/platform(a group),acme/platform/api(a project). - Each declared node becomes a reconcile scope (chant's per-scope loop). The
scope id is kind-prefixed (
group:acme/platform,project:acme/platform/api); the node "kind" (group vs project vs instance) selects which endpoints a cycle uses. - Selective-by-omission applies at two levels: which nodes are declared, and which fields/collections within a node are declared. Anything absent is never read for mutation, diffed, or changed.
Addressing & drift
- Config keys are human-readable full paths; warden resolves a path to the numeric id where the API needs it (members, hooks, and approval rules carry numeric ids).
- A renamed or transferred node (path no longer resolves) is treated as
not-found: reads tolerate the 404 and yield no live state, so a path that
stopped resolving never turns into a delete. (Slices declared on such a node
still plan as creates; their apply fails visibly in
failed[]rather than silently retargeting.) warden does not thrash on drift it didn't cause.
2. Membership: direct vs inherited
GitLab exposes two member rosters per group/project:
| Endpoint | Returns |
|---|---|
GET /groups\|projects/:id/members |
direct members only |
GET /groups\|projects/:id/members/all |
effective = direct + inherited (ancestors) + invited/shared-group |
Three rules follow:
- Diff against
/members(direct) only. Desired membership is reconciled against the direct roster at that node. The/members/allroster is not read at all — it is never the diff baseline. - An inherited member is out of scope at a child node. It does not appear in
/members, so it is never a create/update/delete candidate there. ADELETE .../members/:user_idon an inherited member 404s, because the grant lives at an ancestor; the fix (if any) belongs at that ancestor, which is a different node (and only actionable if the operator declared that ancestor). - Ownership means a direct membership at this node.
opts.isOwned("member", key)gates deletes exactly as in the sibling wardens; for members, "owned" means "present in this node's direct roster and not pinned out by config". The runner derives the predicate from the node'sowneddeclaration in the policy (owned: true, or a resource-type list includingmember; see the policy reference); a caller-supplieddiffOptions.isOwnedpredicate overrides the declaration. Either way it gates deletes of direct members only, since an inherited member is never in the diff baseline and so never reaches the gate.
Access levels
Numeric scheme (name to number), current as of GitLab 17 and 18:
| # | Role |
|---|---|
| 0 | No access |
| 5 | Minimal Access |
| 10 | Guest |
| 15 | Planner |
| 20 | Reporter |
| 30 | Developer |
| 40 | Maintainer |
| 50 | Owner |
60 (Admin) is only valid in a group update context; 25 (Security Manager)
appears in the access-token context. Config accepts either the name or the number;
warden maps to the number for the API. A custom member role (Ultimate) is
referenced by id alongside a base_access_level.
Access-level drift
A direct member whose live access_level differs from desired is an update
(PUT .../members/:user_id), not a delete+create. A child may raise an inherited
level via a direct membership but cannot set a lower level than inherited: when
config asks for a lower level than the inherited floor, GitLab rejects the write
and the attempt surfaces in the cycle's failed[] output (visible every run)
rather than silently converging.
Removal semantics
- Node-level removal means a
DELETEof a direct member at that node only. - Subtree-wide purge (top-group
DELETE /groups/:id/billable_members/:user_id) is out of scope, a documented non-goal. warden reconciles per declared node and leaves the rest of the billable surface alone.
3. Hierarchy mechanics
- Nesting: subgroups up to ~20 levels deep (platform limit; confirm per target
version). warden never needs the full tree, only the declared nodes, so depth is
not a traversal concern except for the
baselineprovisioning cycle, which may create intermediate subgroups. - Enumeration (only where a cycle needs siblings):
GET /groups/:id/subgroups(direct children) ordescendant_groups(whole subtree),GET /groups/:id/projectsfor projects. All paginated (keyset for large trees). - Transfer:
POST /groups/:id/transfercan reparent a node, changing its inherited membership out from under config. warden reports this drift instead of fighting it (see §1).
4. Component responsibilities
- types: config is a node-keyed map;
MemberConfigcarries{ user, accessLevel | memberRoleId }. Live mirrors carry numeric ids (never diffed). - diff: the direct roster is the only membership baseline; deletes are ownership-gated; an inherited entry never produces a change. Access-level drift becomes an update. A unit test asserts that an inherited-only member yields no change entry.
- members cycle:
fetchLivereads/members(direct).applydoesPOST/PUT/DELETE .../members/:user_id. It never deletes anything outside the direct roster. - runner: scopes = the declared nodes; a node's kind selects group vs project endpoints.
Non-goals (v1)
- Auto-discovering/managing the entire group tree from a root.
- Subtree-wide member purges via
billable_members. - Reconciling effective (inherited) permissions; warden manages direct grants at declared nodes only.