internal/control/accountdelete.go
198 lines · 7322 bytes
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")