You Can't Remove the Last Owner
An offboarding run came back "9 of 10 steps completed." Sign-in blocked, sessions revoked, password reset, mailbox converted, license reclaimed, groups stripped. The tenth step:
{
"group": "Site Leads",
"type": "group owner",
"error": "The group must have at least one owner, hence this owner cannot be removed.
(app-only also failed: Insufficient privileges to complete the operation.)"
}
Two different bugs wearing one error message.
Ownership is not membership
The removal walked /users/{id}/memberOf and, for every Microsoft 365 group it found, also deleted the owner reference. Reasonable — until you notice that ownership is its own directory edge. You can own a group you are not a member of. Graph will happily tell you about it, but only if you ask a different endpoint:
GET /users/{id}/ownedObjects/microsoft.graph.group
Worse, "owner but not member" is exactly the state a partially failed run leaves behind: the membership delete succeeded, the owner delete didn't, and now the leftover is invisible to the very code you'd re-run to clean it up. The failure mode hides its own evidence. The fix is to union the two reads before the loop:
by_id = {g["id"]: g for g in member_of}
for g in owned_objects:
if g["id"] in by_id:
by_id[g["id"]]["_owned"] = True
else:
g["_owned"] = g["_owner_only"] = True
groups.append(g)
The last owner is a hard refusal
The second half is not a permissions problem, which is what the "insufficient privileges" clause makes you think. Microsoft 365 groups are not allowed to be ownerless, so DELETE /groups/{id}/owners/{uid}/$ref on the only owner fails by design. There is no flag, no scope, and no admin role that makes that call succeed. Every sole-owner departure had been failing since the day the feature shipped, and it would have kept failing forever.
You cannot remove the last owner. You can only stop them being the last one:
err = graph(f"/groups/{gid}/owners/{uid}/$ref", method="DELETE").get("error")
if err and "at least one owner" in err.lower():
if not successor_id:
return "sole owner, and nobody was named to take it over"
graph(f"/groups/{gid}/owners/$ref", method="POST",
body={"@odata.id": f"{GRAPH}/directoryObjects/{successor_id}"})
err = graph(f"/groups/{gid}/owners/{uid}/$ref", method="DELETE").get("error")
The interesting design question isn't the code, it's who the successor is. Automation should not pick a random member and hand them a group. But the offboarding form already asks who takes the departing person's mailbox — that's a human decision, already made, sitting right there in the payload. Plumb it through; when it's empty, fail with the sentence a human needs ("nobody was named to inherit this, assign an owner in the admin center") instead of echoing the raw API error.
The part I deliberately didn't fix
Distribution lists have owners too, except in Exchange they're ManagedBy, which is not a Graph owner edge. I could have started failing those runs as well. I didn't — nothing had ever attempted it, so turning it into a hard failure would have flipped a long-green automation red across every tenant on the same day, for a condition that had been quietly true for years. They get reported in their own bucket with "clear this in the EAC" and counted as work outstanding, not work failed.
There's a general rule in there. When you fix a step that's been silently incomplete, separate "this is broken now" from "this was always missing." They need different noise levels, because only one of them is somebody's pager.
What I'd do differently
The clue was in the error string all along: The group must have at least one owner. That's an invariant, not a transient. Any time an API tells you about an invariant, the correct response is to satisfy it, not to retry with more privileges — and the "app-only also failed: insufficient privileges" suffix appended by our own fallback logic is what sent me looking at permissions first. Fallback error messages that concatenate two unrelated failures make the loudest one look like the cause.