Skip to content

ENG-1834 Managing group admin rights of other members - #1469

Merged
maparent merged 1 commit into
mainfrom
eng-1834-managing-group-admin-rights-of-other-members
Sep 28, 2026
Merged

maparent merged 1 commit into
mainfrom
eng-1834-managing-group-admin-rights-of-other-members

Conversation

@maparent

@maparent maparent commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

https://entire.io/gh/DiscourseGraphs/discourse-graph/trails/4

Reviewer brief

The admin control shows as a column of checkboxes only for admin users. Non-admin view is as before.

Verification

Loom and unit tests

Loom video

https://www.loom.com/share/7b04f37e714b42729d0a11e7d82c8654

Scope check

  • Ran $scope-check against ENG-1834 and the final diff.
  • Scope beyond Done When: buildMemberRows tightens the removal rule so a sole admin can no longer remove their own membership (the previous inline condition allowed it, contradicting its own comment).
  • Required now: The same last-admin invariant that gates the new admin checkbox (canSetAdmin); leaving the removal path open would let the sole admin empty the group of admins by another route.
  • Anyone affected or consulted: Not documented
  • Decision: Not documented

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context. Use $dg-delegated-full-review when no other full-review workflow is available.

The reviewer noticed there is a way to remove all admins from a group (making it irrecoverable) with a race condition. I think it's too rare to bother, but there should be a database check at some point.

Other considerations:
The last-admin guard is client-side only. buildMemberRows withholds both the admin checkbox and the Remove button from the last admin, so the UI cannot leave a group with zero admins. Nothing enforces this below that: RLS accepts a self-demotion or self-removal by the last admin, and two admins demoting each other concurrently both succeed. A zero-admin group cannot be repaired without the service role, because is_group_admin is then false for everyone and group_membership_insert_policy only allows inserts into a group that does not yet exist. The fix would be a BEFORE UPDATE / BEFORE DELETE trigger on group_membership. Judged out of scope here: the risk is low at current group sizes, and it is a packages/database change rather than a UI one. Worth its own ticket if groups get larger or more admins per group become normal.

This also tightened an existing rule. The removal condition previously read (isAdmin && (numAdmins > 1 || !isMe)) || isMe, where the trailing clause meant the last admin could always remove themselves and the numAdmins > 1 guard never applied. Self-removal is now scoped to non-admins.

Admins are counted over group_membership, but person members are still not listed. The count drives the guard, so it has to cover everyone who can hold admin rights. Counting it over my_pseudo_accounts would have missed person members, since that view ends WHERE pa.agent_type = 'anonymous', and the viewer's own isAdmin already came from group_membership. Two halves of one permission decision over two populations is the kind of thing that fails silently. Displaying person members is a separate change: the view cannot return them, and it raises questions this ticket does not answer, such as what fills the Space column for a member with no space. The unit test "frees the guard for an admin who holds no listed space row" pins the behavior person accounts will need, so the guard is already correct when they land.

https://linear.app/discourse-graphs/issue/ENG-1834/managing-group-admin-rights-of-other-members


Devin Review

@vercel

vercel Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
discourse-graph Ready Ready Preview Sep 24, 2026 2:08pm UTC

Request Review

@supabase

supabase Bot commented Sep 20, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@linear-code

linear-code Bot commented Sep 20, 2026

Copy link
Copy Markdown

ENG-1834

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +174 to +179
const response = await client
.from("group_membership")
.update({ admin })
.eq("member_id", memberId)
.eq("group_id", groupId)
.select();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Demotions can orphan a group

A stale page or concurrent requests let setGroupAdmin demote the final administrator. The update policy checks authority but preserves no administrator. The group then loses invitations and admin management.

Learn more

The last-administrator check exists only in the rendered rows. It uses a count fetched before the user acts, while setGroupAdmin performs an unconditional authorized update. The update policy verifies that the caller is currently an administrator but permits self-demotion. Separate requests can therefore pass the UI guard and remove every administrator.

Example: Alice and Bob are administrators. Both load the page while numAdmins is 2. Bob is demoted first, then Alice uses her still-visible self-demotion control. Both updates succeed, leaving zero administrators.

Recommended fix: Enforce the invariant in the database through one atomic RPC or trigger used by every demotion and membership deletion path. Serialize mutations for each group_id, recount administrators inside that transaction, and reject any mutation that would reduce the count to zero.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real but self-removal is very rare, and concurrent self-removal is extremely unlikely.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(And the real solution is a check in the database, which I think is out of scope.)

Comment on lines +29 to +30
pseudoAccounts.map((pseudoAccount) => {
const memberId = pseudoAccount.dg_account;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Person members lack admin controls

When a person account joins, buildMemberRows omits it because my_pseudo_accounts exposes only anonymous accounts. Administrators get no toggle to grant or revoke that member's rights.

Learn more

Group invitations insert the authenticated user's ID directly into group_membership, so both anonymous space accounts and person accounts can become members. The page derives controls only from my_pseudo_accounts, whose SQL view requires pa.agent_type = 'anonymous'. A person membership can contribute to numAdmins, but it never produces a visible row or control.

Example: Priya signs in with a person account and accepts a member invitation. Her membership exists with admin = false, but the group page lists no row for Priya. An existing administrator cannot promote her.

Recommended fix: Build this screen from all group_membership rows, then attach optional space or profile display data. Add coverage for promoting and demoting a person member with no anonymous pseudo-account row.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not have person accounts yet.

@maparent
maparent marked this pull request as draft September 20, 2026 12:23
@maparent
maparent requested a review from mdroidian September 20, 2026 20:28
@maparent
maparent force-pushed the eng-1834-managing-group-admin-rights-of-other-members branch from 26041f5 to 3452ae6 Compare September 21, 2026 16:27
@maparent
maparent removed the request for review from mdroidian September 22, 2026 13:18
@maparent
maparent force-pushed the eng-1834-managing-group-admin-rights-of-other-members branch from 3452ae6 to 29144eb Compare September 22, 2026 13:19
@maparent
maparent requested a review from mdroidian September 22, 2026 13:22
@maparent
maparent marked this pull request as ready for review September 22, 2026 13:22
@maparent
maparent force-pushed the eng-1834-managing-group-admin-rights-of-other-members branch from 29144eb to 6594384 Compare September 24, 2026 14:06
@maparent
maparent merged commit 38d45ee into main Sep 28, 2026
9 checks passed
@maparent
maparent deleted the eng-1834-managing-group-admin-rights-of-other-members branch September 28, 2026 02:25

This branch was successfully deployed

1 active deployment
Preview — 6594384a Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants