Super-admin: delete any board, ban a member (needs a design first) #196
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/Ponente#196
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #190. This needs a design conversation before it needs code — it is filed so the open questions are written down, not so it gets picked up as is.
Decided already: the super-admin (
Allowlist:Admins[]) can not open every board, but can delete any board and ban a member.Open:
BoardService.DeleteAsyncrefuses (BoardNotEmptyException) and the FKs areRestrictall the way down, on the principle that deleting a board never deletes a card. A super-admin who cannot see the cards cannot empty the board first, so this has to cascade — and then so does a board admin deleting their own. Soft delete with a grace period is worth weighing: it is the only version with an undo.MemberService, and the revalidating auth state provider ends their circuits within five minutes). What happens to what they made — their cards and comments on other people's boards, the boards they alone administer — is undecided.🤖 On it — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
I've read the code behind all four questions. Here is my reply.
#196 — the design
No code and no branch: the issue asks for the argument, not the diff. Everything below is grounded in what is actually in
maintoday; the reasons land indocs/in the pull requests that implement them, not now, because adocs/page describing unbuilt behaviour is the "confidently wrong" stateCLAUDE.mdwarns about.Four questions, four answers, then the schema and the work split. The short version:
BoardNotEmptyExceptiongoes.Membersrow, not the absence of one. Their work stays, under their name.1. What the super-admin sees of a board they are not on
Start from the fact the rest of the design has to be honest about: the super-admin holds the encryption key. They run the container, they have the
psqlandENCRYPTION_KEYthatEncryption/reads. There is no version of this app in which a restricted board is secret from them. So the boundary between the super-admin and somebody else's board is not a security boundary and never was — it is an etiquette boundary, and what the app owes a restricted board is the absence of a screen that invites snooping, not secrecy it cannot deliver.That reframing settles the question the issue calls "the actual question" — whether showing the name counts as access. It does count. It is allowed anyway, narrowly, and here is where the line falls:
Restrictedexists to prevent, and #190 already decided it ("a restricted board appears in nobody's home page or switcher unless they are on it — the super-admin included"). Nothing here reopens that./admin/board/{id}, a single board's record. Not a list. You reach it by having been handed the id — which is what a report is: somebody pastes the link they are complaining about. That inverts the privacy question from "here is every private board" to "somebody showed me this one; tell me what it is".BoardService.GetTilesAsyncalready runs on, for the same reason.BoardAccess.VisibleTokeeps having no admin bypass./board/{id}still saysboard.deniedto a super-admin who was never granted it.Why the name is allowed and the contents are not
Three reasons, in the order they matter:
The record page is logged
Opening
/admin/board/{id}writes one structured line — who looked at which board, when — through the ordinaryILogger. No new table, no new screen. Say plainly in the docs that this is not tamper-proof and cannot be: the person it records owns the log. It is there so the ordinary case leaves a trail, not to defend against the operator.Deliberately not doing: telling the board's admins that a deployment admin looked. Ponente has no in-app notifications and
Membersholds no email address (onlyAccessRequestdoes), so "tell the admins" is a whole new surface for one event. What the admins do get told is the thing that actually changes their board — see the next section: a moderation delete is a visible state on the board itself, with the date and who did it.2. Deleting a board that holds cards
Today
BoardService.DeleteAsyncrefuses (BoardNotEmptyException) and the FKs areRestrictfromBoard→BoardColumn→Card→ (CardTag,CardAssignee,Comment), withTagrestricted too. The comment on the method says it: "it could not cascade even if it wanted to".Decision: soft delete with a 30-day grace period, and it is the same mechanism for a board admin deleting their own board and for a super-admin deleting somebody else's. The issue is right that a super-admin who cannot see the cards cannot empty the board first, and right that the board admin's delete has to follow — one delete verb, two people allowed to press it.
What a delete does
Sets three plaintext columns on
Boardand nothing else:Nothing is destroyed. Not one row is removed. Grants and
BoardOrderrows stay — they are what restore needs.The reason this design is cheap: the rule already lives in one expression
BoardAccess.VisibleTois anExpression<Func<Board, bool>>and every board read in the app goes through it or throughBoardService.GetVisibleAsync, which does. Addingb.DeletedAt == nullto that one expression removes a deleted board from:home tiles · the nav switcher ·
BoardPage·/calendar,/calendar/alland/calendar/board/{id}· both.icsendpoints ·SearchService· theForYouServicestrip ·CardService's move-destination check ·SetPinnedAsync…in one line, and
BoardAccessTestscompiles the same expression, so the case is pinned without a fixture. That property is the strongest argument for doing it this way rather than with a separateIsDeletedfilter per call site.Two reads need their own clause because they deliberately do not use
VisibleTo:BoardService.GetAdministeredAsync— read byNavHeader(does this board wear a "Board settings" link) and byAdminRedirect. A deleted board must not be the board a bare/admin/tagssubstitutes to.ResolveAsync's fall-through. It returnsDeniedfor any board that exists but did not passVisibleTo, which would tell a board's own admin that their just-deleted board is somebody else's. SoBoardOutcomegains a fourth value,Deleted— the enum already documents having dropped a third one, so this is the shape it is used to. A viewer who would have passedVisibleTobut forDeletedAtgetsDeleted; everybody else getsMissing, which is the truthful answer for them.The
board.deletedstate is the audit trail/board/{id}for somebody who could see it before renders a fifth empty state (data-outcome="deleted", besidemissinganddenied): "Deleted on 14 September. Recoverable until 14 October." A board admin also gets a Restore button.That state is how a board's admins find out a deployment admin deleted their board — for free, on the thing it is about, with no notification system. A board vanishing with no explanation is indistinguishable from a bug; this one explains itself.
Who may restore:
DeletedAsModeration)That is what the boolean is for. A moderation delete the board's admins can undo with one click is not moderation, and inferring it from whether
DeletedByDidis currently inAllowlist:Admins[]would give a different answer the day the environment changes — which is precisely the "it reads at runtime" property #192 built on purpose. Store the fact.The purge
One
BackgroundServicebesideMailSender, waking daily, destroying boards whoseDeletedAtis older than 30 days — and that is the only place in the app that may destroy a card.Restrict. The purge writes the delete order out by hand, in one transaction:CardTag/CardAssignee/Comment→Card→BoardColumn→Tag→BoardMember/BoardOrder→Board. Switching toCascadewould be less code and would delete the schema's own statement that nothing loses a card by accident. What protects work now is the grace period; the FK stays as the thing that makes the accident impossible, and exactly one method is allowed past it.Boards:GraceDaysknob would ask every deployment to decide how long work stays recoverable, and every deployment would leave it at the default.What changes for members
BoardNotEmptyExceptionis deleted. A board admin deleting a board full of cards is now allowed, where it used to refuse and tell them to empty it first. That is the member-visible reversal and it needs its own line inChangelog.Unreleased, in both locales, plus a rewrite ofsettings.delete.hintto say what goes and for how long it comes back.settings.delete.cards(theTCountplural) goes with the exception.This reverses the paragraph in
docs/boards-and-permissions.mdthat says "Deleting a board never deletes a card" and the<remarks>onDeleteAsyncthat argues it. Both get rewritten with the new reason in the same PR: deleting a board still never destroys a card at the moment you press it — the guarantee moved from "it refuses" to "you have thirty days", which is strictly more than the old one bought, because the old one had no undo for the delete it did allow.3. What a ban is
The mechanism that exists does not work
MemberService.RemoveAsyncdeletes theMembersrow. ButCard.CreatedByDid,Comment.AuthorDidandCardAssignee.MemberDidare all FKRestricttoMember, so removal fails for anybody who has ever made a card, written a comment, or been assigned to one. The page already admits it —Members.razorcatchesDbUpdateExceptionand showsadmin.members.remove.error: "Can't remove: member is still assigned to a card or has authored cards."So the mechanism the issue calls "off the invite list" works on exactly the people a ban is never for, and refuses the people it is. A ban cannot be a row deletion.
Decision: a ban is a flag on the row
BannedReasongets the encryption converter inOnModelCreatingand not anEncryptionMigrator.Targetsentry:Targetsis for pre-existing rows that could hold plaintext, and a new column has none.Three reasons for a flag rather than a delete, in order:
What a ban actually does — one line, and everything downstream is already right
DbAllowlistService.ReloadAsyncgains.Where(m => m.BannedAt == null)on its DID query. That snapshot isIsAllowed, andIsAllowedis:Allowlistedpolicy on every page, so every board-facing screen closes;PersistingRevalidatingAuthStateProvider, which ends their live circuits inside five minutes;CalendarFeedService.ResolveAsync, which already re-checksallowlist.IsAllowed(did)on every feed fetch — the comment there calls it "the check that makes removal stick" — so the banned member's.icssubscription stops on the next sync with no extra code.That the existing revalidation and the feed-token path both fall out of one predicate is the reason to put the ban on the member row rather than inventing a second gate beside it.
What happens to what they made: nothing, and that is the decision
Their cards and comments stay, on the boards they are on, under their name. A board is a shared record of work. Deleting one member's cards out of eight people's board deletes the board's history to punish one person, and the people left with the holes did not do anything.
Where the record itself is the problem, the tool already exists and is better aimed:
CardPermissionslets a board admin delete any card or any comment on their board. A ban removes the person; a board admin removes the thing. Two tools, two scopes, and the small one already works.Their board grants stay too
A ban changes nothing on
BoardMember. The allowlist is the gate that stops them; the grants are inert while the ban holds, and keeping them is what makes lifting a ban one click instead of a re-invitation across every board they were on.Readers keep working; pickers narrow
MemberService.GetAllAsynckeeps returning everybody. This is the same decisionMember.UsesLocalProfilealready made and it is written down indocs/orientation.mdas one of the dozen surprises — "every reader is unaffected, which is why it is written this way". Six call sites read that list, and some of them use it to draw the face of an assignee who is already on a card. Filter it in the service and those faces become bare DIDs.So the filter goes where a member is offered as a choice, not where they are read:
AssigneePicker's candidate list (BoardPage,Calendar, viaAvailableMembers);Components/Pages/Settings/BoardAccess.razor's grant picker;Home.razor's new-board flow.A banned member already on a card keeps their face and their name. They are just not offered to anybody again.
The front door has to close too, or the ban means nothing
MemberService.AddAsyncagainst an existing banned row refuses — re-adding is not how a ban is lifted.RequestsPanel.ApproveAsyncrefuses a banned DID before it callsAddAsync, and/requestfrom one is declined.IsAllowedshort-circuits on_superAdminsfrom the config, so the button would do nothing; refuse it by name rather than let it lie.The honest limit, and it belongs in the docs
A ban bans a DID, not a person. Somebody holding a PDS invite code can sign up at
/signupand arrive as a new DID. The thing that actually controls the front door is the invite list, which is already the super-admin's — so the ban screen says so, next to the button, in one sentence: a banned member with an invite code can make a new account. Disable the code too.PdsGatewayalready hasDisableInviteAsyncand the invites panel already exposes it; the copy just has to point at it.4. A board whose last admin is gone
The invariant is stated slightly wrong today
LastBoardAdminExceptionguards "a board always has at least one admin" inSetAccessAsyncand inRemoveAsync, andDatabaseInitializer.EnsureBoardAdminsAsyncrepairs it on boot. With a ban as a flag, the grant row survives the ban — so the invariant is never technically broken, and the board is unrunnable anyway. The invariant that matters is:That is a one-word change with three consequences:
EnsureBoardAdminsAsync's query becomes!b.Members.Any(m => m.Role == Admin && m.Member.BannedAt == null).SetAccessAsyncandMemberService.RemoveAsyncmust count only non-banned admins — otherwise a banned admin "counts", and you can take the last reachable one off a board without being refused. That is a real bug the ban introduces if this is missed.The rule: the longest-standing member inherits
On ban, in the same transaction, for every board where the banned member held the only non-banned admin grant: promote the longest-standing remaining grant holder —
BoardMember.GrantedAtascending, then DID ordinal so it is deterministic.GrantedAtmeans what this needs it to mean.SetAccessAsyncalready preserves it across re-saves on purpose — "re-saving the list is not re-inviting everybody, and the history a grant row carries is the only record of who was given a board when."MarkOpenedAsyncalready argues.The rule is pure — a list of grants in, one DID or nothing out — so it goes in a static
Services/BoardSuccession.csin the shape ofCardMoveandBoardFilter, andBoardSuccessionTestspins the cases (nobody left, one member, a tie onGrantedAt, every remaining member already banned) in xunit instead of at a board apiece in Playwright.A board with nobody to inherit is left alone
A private board of one, whose one member is banned, has no successor.
It stays. Admin-less, unreachable, untouched. Not soft-deleted by the ban, and — this is the part that changes today's code — not handed to the super-admin.
/admin/board/{id}and soft-deletes it there, with the 30-day undo. The policy call stays with a person and the default is "do nothing irreversible".EnsureBoardAdminsAsyncstops granting super-admins — this is a back door todayRight now, any board left without an admin grant gets the configured super-admin DIDs granted on the next boot, which hands them full read access to a restricted board they were never given. It exists as a repair for states that predate #192's refusal, and it is rare today. Under a ban regime it would become the routine outcome — quietly undoing the decision #190 made explicitly.
So the startup repair runs the succession rule instead: promote the longest-standing non-banned member, and where there is none, leave the board admin-less and log it. It does not grant anybody.
Compatibility: grants already minted by the old repair are real rows and stay — dropping them could orphan a board that now depends on one. What changes is that no new ones are minted, and a super-admin who only holds a board because of the old repair can take their own grant off from the board's access page, which they already have.
Remove and ban stay two different verbs
MemberService.RemoveAsynckeeps refusing withLastBoardAdminException. Removal is a tidy-up — somebody left the group — and it is right to make you hand the board over first. A ban is the thing that cannot wait for a negotiation, and it is the one that promotes. That asymmetry is the reason both verbs exist, and it goes indocs/auth.mdin those words.Schema, all of it
BoardsDeletedAttimestamp?BoardAccess.VisibleTohas to be able to ask it in SQL — the same rule asBoardMember.BoardsDeletedByDidtext?BoardsDeletedAsModerationboolBoardRole.MembersBannedAttimestamp?DbAllowlistService.ReloadAsyncin SQL on every reload.MembersBannedByDidtext?MembersBannedReasontext?OnModelCreating; noEncryptionMigrator.Targetsentry — no pre-existing rows can hold it.No index needed at this scale;
DeletedAtis one more predicate on a table with tens of rows.Two migrations, one per step below. Both satisfy #190's rule — nobody's access changes on deploy day and the previous release still runs against the database: every column is nullable or defaults to the current behaviour, and nothing is dropped.
Work split — a proposal, not filed
I have deliberately not opened implementation issues. The issue asks for a conversation and the split should survive it. If these read right, say so and I will file them.
Boards.DeletedAtand friends, the clause inVisibleTo,BoardOutcome.Deleted, theboard.deletedstate, the purge service,BoardNotEmptyExceptionremoved. Ships alone and is worth having alone: it gives every board admin an undo they do not have today. Playwright goes inboards.spec.js, which already creates and deletes its own boards.Members.BannedAt, the one line inReloadAsync, the front-door refusals, the picker filters,BoardSuccession+ its unit tests, the corrected last-admin counts,EnsureBoardAdminsAsyncstops granting super-admins, and the ban/lift controls on/admin/members. Needs nothing from step 1. Newtests/e2e/moderation.spec.js— the fixture is a super-admin, two ordinary members and a shared board, which is genuinely its own and does not belong bolted ontopermissions.spec.js./admin/board/{id}behind theAdminpolicy, the structure read, the log line, and the super-admin's moderation delete on top of step 1's mechanism. Needs both.Docs each step outdates, in its own PR:
boards-and-permissions.md(the delete paragraph, which this reverses, and Who may run a board),auth.md(the allowlist, the ban, remove-versus-ban),data.md(the six columns and why five are plaintext, and the purge as the one place a card dies),ui.md/i18n.mdfor the strings,testing.mdfor the new spec.One thing I did not decide
Whether a board admin's own delete should be restorable by them alone or by any admin of that board. I have written "any admin of that board" above, on the grounds that the role is one job and
BoardPermissionsgives one answer for all of it — but a board with three admins where one deletes it and another restores it is a disagreement the app would be silently arbitrating, and that is a group's problem rather than a schema's. It is one line either way and it does not block anything; I would rather it was said out loud than defaulted.Filed separately: two paragraphs in
docs/that went stale across #193/#194 and now state the reverse of what the code does — found while reading for this, neither in scope here.⏱ 6m 52s · 37 turns · tokens: 119.2k in / 30.6k out (+2377.8k cache-read)
📜 Full session transcript
→ Filed follow-up issue #210: Two docs paragraphs predate the home page and still describe the old model