Groups and membership
Two facts about groups account for most of the mistakes made against this SDK, and the second one produces authorization checks that look correct and are not.
A group is a principal
GroupInfoInstance extends the same principal base every other principal type extends. A
group therefore is not a separate kind of thing that principals belong to — it is itself a
first-class subject of ACLs and of membership. Measured against a running key server, a
group accepts a role assignment and accepts being placed inside another group.
Practical consequences:
GroupInfois a valid class name to pass togetPrincipalListWithClassNames, and groups come back in that list.- Groups are created and removed through the principal calls, not through a separate group lifecycle.
- Nothing in the role- or group-assignment paths excludes the group type, and that is deliberate.
Because a group is a principal, "how many principals are there" has more than one correct answer depending on whether groups are included. That is a question about the census, not about the type. When you report a number — in a console, in a report, in a log line — say which denominator it used.
A principal can hold more than one group
The group side is a set. This is worth stating plainly because the opposite was recorded in several places for a long time — in this package's own comments, in the Java admin client and in the engine headers — and all of them were wrong about the running key server.
Measured 2026-08-14 against a scratch key server, three independent ways — two sequential group assignments, one assignment carrying both ids, and the group-side "add principals to group" call — all three ended with the principal holding two groups, with both groups listing it as a member.
So setGroup() genuinely replaces: it clears the whole group list and then adds the
one id you gave it. That warning is real. Its historical justification — "a principal
belongs to at most one group" — was not. Use addGroup() when you mean to append.
Membership is transitive; getGroupIDs() is not
This is the single easiest place to write a correct-looking authorization check that is wrong.
getGroupIDs()returns the DIRECT list — the groups whose ids are stored on this principal. Surfaces that project a principal as JSON typically expose this asgroupIds.- The key server's notion of membership is the CLOSURE. Its own group expansion recurses: a principal in group A, where A is a member of B, is a member of B, and that closure is what every ACL group grant is tested against.
An authorization check built on groupIds therefore under-approximates membership. It
will deny access that the key server would grant, and — used as a precondition check — it
will reject a legitimately transitive group as if it were not a member at all.
Computing the closure
The walk starts from getGroupIDs() and iterates. It does not invert a
group-members read: you read each group as a principal and take its own getGroupIDs(),
repeating until nothing new appears.
async function transitiveGroups(admin, principal): Promise<Set<string>> {
const seen = new Set<string>();
let frontier = idList(principal.getGroupIDs()).map((g) => g.toLowerCase());
while (frontier.length > 0) {
const next: string[] = [];
for (const gid of frontier) {
if (seen.has(gid)) continue;
seen.add(gid);
const group = await findById(admin, gid);
if (group === null) continue; // see "unreadable groups" below
for (const parent of idList(group.getGroupIDs())) {
const lower = parent.toLowerCase();
if (!seen.has(lower)) next.push(lower);
}
}
frontier = next;
}
return seen;
}
Three things about that shape are load-bearing:
- The visited set is what makes the walk terminate, and it is what the engine's own expansion does too — it skips an id already in its accumulating list. Do not rely on the key server refusing to create cycles (it does; see below) as a reason to omit the guard. A cycle already in the store would spin this walk forever, and "the server should not have allowed it" is not a reason to hang.
- A group that cannot be read back is skipped, not thrown on. It contributes its own id — the principal genuinely holds it — but not its parents, which are unknowable. Failing the whole check because one group in a chain is unreadable would refuse something the key server would accept.
- Ids are compared case-insensitively. Hex from different sources differs in case.
Remember that every findById in that loop is a round trip, so the whole walk must run
inside one withConnection — see
the connection model.
The closure is same-system, and cycles cannot be created
The key server's group expansion is transitive and same-system: it resolves each group id against the caller's own system id and does not cross system boundaries.
Cyclic membership is refused at creation, checked recursively against the incoming principal before anything is written:
Cyclic Membership is not permitted
Making group A a member of B and then B a member of A is therefore refused at the second call, by the key server, in every deployment. There is no need to walk the membership graph client-side before a write — a client-side check would be a second copy of a rule already enforced where it matters.
Cyclic Membership is not permitted arrives as an ordinary operation failure. Nothing
numeric distinguishes it, and it matches none of the agent's built-in refusal patterns, so
a caller that does not look for it will report an internal error for a request the user
could have fixed. Match the sentence. See
Errors.
Reading a group's members
Listing the principals in a group is a separate, read-only operation from the principal-management family, and it is the honest way to verify a membership change: checking a mutation with the mutation's own return value proves only that the call returned.
A group with no members is an ordinary thing for a key server to hold — and it is also what you get back if you pass an id that is not a group at all. The call returns an empty list for both. It cannot tell those two cases apart, and neither can the Java client; a caller that needs to distinguish them must resolve the id separately.
Note also that this read gives you the group's direct members. It is not the inverse of the closure above: a principal that is a member of a nested subgroup will not appear in the parent group's member list.
Split duties
On a key server provisioned with separated administrative duties, creating a group and filling it require two different identities: the create is a principal-add operation (held by the Administrator role) and the membership writes are group operations (held by the Security Officer role), and neither role holds the other's. On a fixture instance where one built-in account holds everything, a single-identity integration test passes on one provisioning flavor and fails on the other. Test against the flavor you deploy.