internal/control/accountdelete.go

main
gitbay/internal/control/accountdelete.go history · blame · raw

198 lines · 7322 bytes

9 symbols in this file
  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strings"
  8	"time"
  9
 10	"gitbay.org/gitbay/internal/backuplock"
 11	"gitbay.org/gitbay/internal/config"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16// DeletionGrace is how long a confirmed deletion waits before the purge.
 17// Signing in during it cancels.
 18const DeletionGrace = 7 * 24 * time.Hour
 19
 20// deletionLinkTTL is how long the mailed confirmation link works.
 21const deletionLinkTTL = 24 * time.Hour
 22
 23func init() {
 24	register(Command{Path: []string{"account", "delete"},
 25		NeedsRecentSignIn: true,
 26		Summary:           "delete your account: mails a link, then purges seven days after it is opened",
 27		Usage:             "account delete --confirm <username> | --cancel",
 28		Flags: []Flag{
 29			{"--confirm", "<username>", "your username, typed out", ""},
 30			{"--cancel", "", "withdraw a request that has not been confirmed", ""},
 31		},
 32		Examples: []string{"account delete --confirm alice"},
 33		Run:      runAccountDelete})
 34}
 35
 36func runAccountDelete(c *Ctx, args []string) int {
 37	f, err := c.parseArgs(args, flagSpec{Values: []string{"--confirm"}, Bools: []string{"--cancel"},
 38		Usage: "account delete --confirm <username> | --cancel"})
 39	if err != nil {
 40		return c.fail(protocol.ExitUsage, "%v", err)
 41	}
 42	if f.Has("--cancel") {
 43		had, err := c.Store.CancelAccountDeletion(c.User.ID)
 44		if err != nil {
 45			return c.fail(protocol.ExitFailure, "%v", err)
 46		}
 47		if !had {
 48			return c.fail(protocol.ExitNotFound, "no deletion is pending for %s", c.User.Username)
 49		}
 50		c.Store.Audit(c.User.ID, "account.delete.cancelled", map[string]any{"user": c.User.Username})
 51		return c.emit(map[string]any{"user": c.User.Username, "cancelled": true}, func(w io.Writer) {
 52			fmt.Fprintf(w, "deletion of %s cancelled\n", c.User.Username)
 53		})
 54	}
 55	if f.Value("--confirm") != c.User.Username {
 56		return c.fail(protocol.ExitUsage, "type your username to confirm: account delete --confirm %s", c.User.Username)
 57	}
 58	if c.User.IsAdmin {
 59		if n, err := c.Store.OtherActiveAdmins(c.User.ID); err != nil {
 60			return c.fail(protocol.ExitFailure, "%v", err)
 61		} else if n == 0 {
 62			return c.fail(protocol.ExitDenied, "you are the instance's only admin; promote another account first")
 63		}
 64	}
 65	orgs, err := c.Store.SoleAdminOrgs(c.User.ID)
 66	if err != nil {
 67		return c.fail(protocol.ExitFailure, "%v", err)
 68	}
 69	if len(orgs) > 0 {
 70		return c.fail(protocol.ExitDenied, "you are the only admin of %s; add another admin or delete the organization first",
 71			strings.Join(orgs, ", "))
 72	}
 73	address, err := c.Store.PreferredVerifiedEmail(c.User.ID)
 74	if err != nil || address == "" {
 75		return c.fail(protocol.ExitDenied, "deletion is confirmed by mail; add and verify an address first (email add)")
 76	}
 77	token, hash, err := store.NewToken()
 78	if err != nil {
 79		return c.fail(protocol.ExitFailure, "%v", err)
 80	}
 81	if err := c.Store.RequestAccountDeletion(c.User.ID, hash, deletionLinkTTL); err != nil {
 82		return c.fail(protocol.ExitFailure, "%v", err)
 83	}
 84	host := siteHost(c.Cfg)
 85	body := fmt.Sprintf(
 86		"Someone (hopefully you) asked to delete the account %s on %s.\n\n"+
 87			"To go ahead, open this link within 24 hours:\n\n    %s/settings/delete?token=%s\n\n"+
 88			"Confirming disables the account at once. Seven days later it is deleted:\n"+
 89			"its repositories, snippets, keys and addresses go, and what it wrote on\n"+
 90			"other people's repositories stays under the name \"ghost\".\n\n"+
 91			"Signing in during those seven days, on the web or over SSH with a\n"+
 92			"full-scope key, cancels the deletion.\n\n"+
 93			"To keep a copy first: ssh git@%s account export > bundle.json\n\n"+
 94			"If this wasn't you, ignore this mail. Nothing has changed on the account.\n",
 95		c.User.Username, host, strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), token, host)
 96	if err := c.Store.EnqueueMail(address, "delete your account on "+host, body); err != nil {
 97		return c.fail(protocol.ExitFailure, "%v", err)
 98	}
 99	c.Store.Audit(c.User.ID, "account.delete.requested", map[string]any{"user": c.User.Username})
100	return c.emit(map[string]any{"user": c.User.Username, "mailed": address}, func(w io.Writer) {
101		fmt.Fprintf(w, "mailed a confirmation link to %s; it works for 24 hours\n", address)
102		fmt.Fprintf(w, "nothing changes until it is opened. keep a copy first: account export > bundle.json\n")
103	})
104}
105
106// ScheduledRefusal is what a credential that cannot cancel a scheduled
107// deletion is told.
108func ScheduledRefusal(u store.User) string {
109	if u.DeleteAfter == store.Purging {
110		return "this account is being deleted"
111	}
112	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)
113}
114
115// CancelScheduledDeletion is what signing in does to an account scheduled
116// for deletion: the schedule goes and the account is enabled. It reports
117// whether there was one.
118func CancelScheduledDeletion(st *store.Store, u *store.User, how string) bool {
119	if u.DeleteAfter == "" {
120		return false
121	}
122	if had, err := st.CancelAccountDeletion(u.ID); err != nil || !had {
123		return false
124	}
125	st.Audit(u.ID, "account.delete.cancelled", map[string]any{"user": u.Username, "by": how})
126	u.Disabled, u.DeleteAfter = false, ""
127	return true
128}
129
130// PurgeDueAccounts deletes every account whose grace period has passed.
131// An account that became the only admin of an org since it was scheduled
132// is skipped and audited; an instance admin resolves it. One account's
133// failure does not hold up the others; it is retried on the next tick.
134func PurgeDueAccounts(cfg config.Config, st *store.Store, now time.Time) ([]string, error) {
135	due, err := st.DueDeletions(now)
136	if err != nil || len(due) == 0 {
137		return nil, err
138	}
139	var purged []string
140	var errs []error
141	for _, u := range due {
142		if u.DeleteAfter != store.Purging {
143			if orgs, err := st.SoleAdminOrgs(u.ID); err != nil {
144				errs = append(errs, err)
145				continue
146			} else if len(orgs) > 0 {
147				st.Audit(0, "account.delete.blocked", map[string]any{"user": u.Username, "orgs": orgs})
148				continue
149			}
150		}
151		// The claim is what a cancel races: once it holds, signing in
152		// no longer cancels, and before it the purge has touched nothing.
153		if ok, err := st.ClaimDeletion(u.ID, now); err != nil {
154			errs = append(errs, err)
155			continue
156		} else if !ok {
157			continue
158		}
159		if err := purgeAccount(cfg, st, u); err != nil {
160			errs = append(errs, fmt.Errorf("purging %s: %w", u.Username, err))
161			continue
162		}
163		st.Audit(0, "account.delete.purged", map[string]any{"user": u.Username})
164		purged = append(purged, u.Username)
165	}
166	return purged, errors.Join(errs...)
167}
168
169func purgeAccount(cfg config.Config, st *store.Store, u store.User) error {
170	ghost, err := st.EnsureGhost()
171	if err != nil {
172		return err
173	}
174	repos, err := st.ListReposForOwner("user", u.ID)
175	if err != nil {
176		return err
177	}
178	if len(repos) > 0 {
179		release, err := backuplock.TryShared(cfg.Server.Root)
180		if err != nil {
181			return err
182		}
183		for _, r := range repos {
184			if err := removeRepo(st, cfg.Server.Root, r); err != nil {
185				release()
186				return err
187			}
188		}
189		release()
190	}
191	if err := st.ReassignToGhost(u.ID, ghost); err != nil {
192		return err
193	}
194	return st.DeleteUser(u.ID)
195}
196
197// errGhost refuses an admin action on the ghost account.
198var errGhost = errors.New("ghost stands in for deleted accounts and cannot be changed")