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
      	}