Range-diff !563
back to !563 control: self-service account deletion
1: 8544b6a ! 1: 161f585 control: self-service account deletion
@@ Commit message
address. Opening it at /settings/delete disables the account and sets
users.delete_after seven days out. A web login or any command over SSH
with a full-scope key cancels; other key scopes and API tokens are
- refused and cannot. An admin disable or enable replaces the schedule.
+ refused and cannot. An admin disable or enable replaces the schedule, and a link opened on
+ a suspended account schedules nothing. The only instance admin is
+ refused.
- The gitbayd reaper purges due accounts hourly: owned repositories go
+ The gitbayd reaper purges due accounts hourly. It first claims the
+ account (delete_after = 'purging'), after which signing in no longer
+ cancels; a failed purge is retried and does not hold up others. Owned
+ repositories go
through the repo delete path, issues, MRs, comments, diff comments and
reviews on other owners' repositories move to a ghost account (created
- on first use, disabled, reserved name), then DeleteUser. The only admin
+ on first use, disabled, reserved name, never an org member), then
+ DeleteUser. Pending draft comments are deleted and reviews made stale. The only admin
of an org is refused at request time and skipped at purge.
Spec: docs/specs/2026-10-02-account-delete-design.md
@@ internal/control/accountdelete.go (new)
+ if f.Value("--confirm") != c.User.Username {
+ return c.fail(protocol.ExitUsage, "type your username to confirm: account delete --confirm %s", c.User.Username)
+ }
++ if c.User.IsAdmin {
++ if n, err := c.Store.OtherActiveAdmins(c.User.ID); err != nil {
++ return c.fail(protocol.ExitFailure, "%v", err)
++ } else if n == 0 {
++ return c.fail(protocol.ExitDenied, "you are the instance's only admin; promote another account first")
++ }
++ }
+ orgs, err := c.Store.SoleAdminOrgs(c.User.ID)
+ if err != nil {
+ return c.fail(protocol.ExitFailure, "%v", err)
@@ internal/control/accountdelete.go (new)
+// ScheduledRefusal is what a credential that cannot cancel a scheduled
+// deletion is told.
+func ScheduledRefusal(u store.User) string {
++ if u.DeleteAfter == store.Purging {
++ return "this account is being deleted"
++ }
+ return fmt.Sprintf("this account is scheduled for deletion at %s; sign in on the web or over SSH with a full-scope key to cancel", u.DeleteAfter)
+}
+
@@ internal/control/accountdelete.go (new)
+
+// PurgeDueAccounts deletes every account whose grace period has passed.
+// An account that became the only admin of an org since it was scheduled
-+// is skipped and audited; an instance admin resolves it.
++// is skipped and audited; an instance admin resolves it. One account's
++// failure does not hold up the others; it is retried on the next tick.
+func PurgeDueAccounts(cfg config.Config, st *store.Store, now time.Time) ([]string, error) {
+ due, err := st.DueDeletions(now)
+ if err != nil || len(due) == 0 {
+ return nil, err
+ }
+ var purged []string
++ var errs []error
+ for _, u := range due {
-+ if orgs, err := st.SoleAdminOrgs(u.ID); err != nil {
-+ return purged, err
-+ } else if len(orgs) > 0 {
-+ st.Audit(0, "account.delete.blocked", map[string]any{"user": u.Username, "orgs": orgs})
++ if u.DeleteAfter != store.Purging {
++ if orgs, err := st.SoleAdminOrgs(u.ID); err != nil {
++ errs = append(errs, err)
++ continue
++ } else if len(orgs) > 0 {
++ st.Audit(0, "account.delete.blocked", map[string]any{"user": u.Username, "orgs": orgs})
++ continue
++ }
++ }
++ // The claim is what a cancel races: once it holds, signing in
++ // no longer cancels, and before it the purge has touched nothing.
++ if ok, err := st.ClaimDeletion(u.ID, now); err != nil {
++ errs = append(errs, err)
++ continue
++ } else if !ok {
+ continue
+ }
+ if err := purgeAccount(cfg, st, u); err != nil {
-+ return purged, fmt.Errorf("purging %s: %w", u.Username, err)
++ errs = append(errs, fmt.Errorf("purging %s: %w", u.Username, err))
++ continue
+ }
+ st.Audit(0, "account.delete.purged", map[string]any{"user": u.Username})
+ purged = append(purged, u.Username)
+ }
-+ return purged, nil
++ return purged, errors.Join(errs...)
+}
+
+func purgeAccount(cfg config.Config, st *store.Store, u store.User) error {
@@ internal/control/accountdelete_test.go (new)
+ if code, _ := runAs(st, bob, root, "account", "delete", "--confirm", "bobb"); code != protocol.ExitUsage {
+ t.Fatalf("mistyped name: exit %d", code)
+ }
++ // The only instance admin is refused.
++ soleAdmin := bob
++ soleAdmin.IsAdmin = true
++ st.DB.Exec("UPDATE users SET is_admin = 1 WHERE id = ?", bob.ID)
++ if code, errOut := runAs(st, soleAdmin, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "only admin") {
++ t.Fatalf("sole instance admin: exit %d %q", code, errOut)
++ }
++ st.DB.Exec("UPDATE users SET is_admin = 0 WHERE id = ?", bob.ID)
+ if code, _ := runAs(st, bob, root, "org", "create", "acme"); code != 0 {
+ t.Fatal("org create")
+ }
@@ internal/control/accountdelete_test.go (new)
+ if !CancelScheduledDeletion(st, &s, "test") || s.Disabled {
+ t.Fatal("cancel did not restore the account")
+ }
++ // A link opened after an admin suspended the account schedules
++ // nothing.
++ _, hash, _ := store.NewToken()
++ st.RequestAccountDeletion(bob.ID, hash, time.Hour)
++ st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob.ID)
++ if _, err := st.ConfirmAccountDeletion(hash, time.Now()); err == nil {
++ t.Fatal("a suspended account was scheduled")
++ }
++ st.DB.Exec("UPDATE users SET disabled = 0 WHERE id = ?", bob.ID)
++ // Once the purge has claimed an account, signing in cannot cancel.
++ s = schedule(bob, time.Now().Add(-time.Minute))
++ if ok, err := st.ClaimDeletion(bob.ID, time.Now()); !ok || err != nil {
++ t.Fatalf("claim: %v %v", ok, err)
++ }
++ s, _ = st.UserByID(bob.ID)
++ if CancelScheduledDeletion(st, &s, "test") {
++ t.Fatal("cancelled a purge in progress")
++ }
++ st.DB.Exec("UPDATE users SET disabled = 0, delete_after = NULL WHERE id = ?", bob.ID)
+ // An admin disabling a scheduled account keeps it, disabled.
+ schedule(bob, time.Now().Add(-time.Minute))
+ if err := st.SetUserDisabled(bob.ID, true); err != nil {
@@ internal/control/loginlink.go: func RequestLoginLink(cfg config.Config, st *stor
}
+ ## internal/control/org.go ##
+@@ internal/control/org.go: func runOrgMembersAdd(c *Ctx, args []string) int {
+ if err != nil {
+ return c.fail(protocol.ExitFailure, "%v", err)
+ }
++ if target.Ghost {
++ return c.fail(protocol.ExitDenied, "%v", errGhost)
++ }
+ if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil {
+ return c.failErr(err)
+ }
+
## internal/control/reauth_test.go ##
@@ internal/control/reauth_test.go: func TestNeedsRecentSignInSet(t *testing.T) {
}
@@ internal/store/accountdelete.go (new)
+ if err != nil {
+ return User{}, err
+ }
++ // A suspension is an admin's decision; the link cannot turn it into
++ // a schedule its owner could then cancel by signing in.
++ if u.Disabled {
++ return User{}, ErrNotFound
++ }
+ tx, err := s.DB.Begin()
+ if err != nil {
+ return User{}, err
@@ internal/store/accountdelete.go (new)
+ if _, err := tx.Exec("DELETE FROM account_deletions WHERE user_id = ?", u.ID); err != nil {
+ return User{}, err
+ }
-+ if _, err := tx.Exec("UPDATE users SET disabled = 1, delete_after = ? WHERE id = ?", fmtTime(after), u.ID); err != nil {
++ if _, err := tx.Exec("UPDATE users SET disabled = 1, delete_after = ? WHERE id = ? AND disabled = 0", fmtTime(after), u.ID); err != nil {
+ return User{}, err
+ }
+ for _, table := range []string{"web_sessions", "login_tokens"} {
@@ internal/store/accountdelete.go (new)
+ return u, nil
+}
+
++// Purging marks users.delete_after while the purge runs. It sorts after
++// every timestamp, so nothing cancels it, and DueDeletions returns it
++// again until the purge completes.
++const Purging = "purging"
++
++// ClaimDeletion marks a due account as being purged. It reports false
++// when a cancel or an admin got there first.
++func (s *Store) ClaimDeletion(userID int64, now time.Time) (bool, error) {
++ res, err := s.DB.Exec(`UPDATE users SET delete_after = ? WHERE id = ? AND disabled = 1
++ AND delete_after IS NOT NULL AND (delete_after <= ? OR delete_after = ?)`, Purging, userID, fmtTime(now), Purging)
++ if err != nil {
++ return false, err
++ }
++ n, _ := res.RowsAffected()
++ return n == 1, nil
++}
++
+// CancelAccountDeletion drops a waiting request and a scheduled purge.
+// It reports whether either existed.
+func (s *Store) CancelAccountDeletion(userID int64) (bool, error) {
@@ internal/store/accountdelete.go (new)
+ return false, err
+ }
+ requested, _ := res.RowsAffected()
-+ res, err = s.DB.Exec("UPDATE users SET disabled = 0, delete_after = NULL WHERE id = ? AND delete_after IS NOT NULL", userID)
++ res, err = s.DB.Exec("UPDATE users SET disabled = 0, delete_after = NULL WHERE id = ? AND delete_after IS NOT NULL AND delete_after != ?", userID, Purging)
+ if err != nil {
+ return false, err
+ }
@@ internal/store/accountdelete.go (new)
+
+// DueDeletions lists the accounts whose scheduled purge time has passed.
+func (s *Store) DueDeletions(now time.Time) ([]User, error) {
-+ rows, err := s.DB.Query("SELECT id FROM users WHERE delete_after IS NOT NULL AND delete_after <= ?", fmtTime(now))
++ rows, err := s.DB.Query("SELECT id FROM users WHERE delete_after IS NOT NULL AND (delete_after <= ? OR delete_after = ?)", fmtTime(now), Purging)
+ if err != nil {
+ return nil, err
+ }
@@ internal/store/accountdelete.go (new)
+ rows, err := s.DB.Query(`SELECT o.name FROM orgs o JOIN org_members m ON m.org_id = o.id
+ WHERE m.user_id = ? AND m.role = 'admin'
+ AND NOT EXISTS (SELECT 1 FROM org_members x
-+ WHERE x.org_id = o.id AND x.role = 'admin' AND x.user_id != m.user_id)
++ WHERE x.org_id = o.id AND x.role = 'admin' AND x.user_id != m.user_id
++ AND x.user_id NOT IN (SELECT id FROM users WHERE ghost = 1))
+ ORDER BY o.name`, userID)
+ if err != nil {
+ return nil, err
@@ internal/store/accountdelete.go (new)
+ return names, rows.Err()
+}
+
++// OtherActiveAdmins counts instance admins other than the user who can
++// still act.
++func (s *Store) OtherActiveAdmins(userID int64) (int64, error) {
++ var n int64
++ err := s.DB.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1 AND disabled = 0
++ AND pending = 0 AND id != ?`, userID).Scan(&n)
++ return n, err
++}
++
+// EnsureGhost returns the ghost account's id, creating it on first use.
+// It is disabled and holds no credentials, so nothing can act as it.
+func (s *Store) EnsureGhost() (int64, error) {
@@ internal/store/accountdelete.go (new)
+
+// ReassignToGhost moves what the user wrote on other owners' repositories
+// to the ghost: issues, merge requests, comments, diff comments and
-+// reviews, the rows DeleteUser otherwise refuses over.
++// reviews, the rows DeleteUser otherwise refuses over. Pending draft
++// comments are deleted and reviews made stale.
+func (s *Store) ReassignToGhost(userID, ghostID int64) error {
+ tx, err := s.DB.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
++ // Unsubmitted drafts go; reviews stay as text but no longer count
++ // toward a merge gate.
++ if _, err := tx.Exec("DELETE FROM mr_diff_comments WHERE author_id = ? AND pending = 1", userID); err != nil {
++ return err
++ }
++ if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE reviewer_id = ?", userID); err != nil {
++ return err
++ }
+ for _, col := range []struct{ table, column string }{
+ {"issues", "author_id"},
+ {"merge_requests", "author_id"},
@@ internal/store/users.go: func (s *Store) OwnerExists(name string) bool {
return u, err
}
-@@ internal/store/users.go: func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
+@@ internal/store/users.go: func (s *Store) ListEmails(userID int64) ([]Email, error) {
+ // are closed.
+ func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
+ v := 0
++ if _, err := s.DB.Exec("DELETE FROM account_deletions WHERE user_id = ?", userID); err != nil {
++ return err
++ }
if disabled {
v = 1
}
- res, err := s.DB.Exec("UPDATE users SET disabled = ? WHERE id = ?", v, userID)
-+ // An admin's decision replaces a scheduled deletion either way: a
-+ // disabled account stays disabled and is not purged, an enabled one
-+ // is back in use.
-+ res, err := s.DB.Exec("UPDATE users SET disabled = ?, delete_after = NULL WHERE id = ?", v, userID)
++ // An admin's decision replaces a scheduled or requested deletion
++ // either way: a disabled account stays disabled and is not purged,
++ // an enabled one is back in use. A purge already under way finishes.
++ res, err := s.DB.Exec("UPDATE users SET disabled = ?, delete_after = CASE WHEN delete_after = ? THEN delete_after END WHERE id = ?", v, Purging, userID)
if err != nil {
return err
}