Commit 161f585ca2

161f585ca2828c01d08728e2dabf3d2803addfe2

parent: ec2ae93416

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-02 15:29 UTC

control: self-service account deletion

account delete --confirm <username> (gitbay auth delete, and a section
at the bottom of /settings) mails a link to the primary verified
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, and a link opened on
a suspended account schedules nothing. The only instance admin is
refused.

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, 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

Closes #322

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -407,6 +407,7 @@ client has no use for one (krz/gitbay#57).
407| API token list, revoke | yes | yes | no | 407| API token list, revoke | yes | yes | no |
408| API token revoke with what it created | yes | no | no | 408| API token revoke with what it created | yes | no | no |
409| account export bundle | yes | yes | n/a | 409| account export bundle | yes | yes | n/a |
410| delete your own account | yes | yes | no |
410| profile set | yes | yes | yes | 411| profile set | yes | yes | yes |
411| write the profile about | yes | yes | yes | 412| write the profile about | yes | yes | yes |
412| request a login link | n/a | yes | n/a | 413| request a login link | n/a | yes | n/a |
.gitbay/wiki/Terms.org +4 −3
@@ -39,9 +39,10 @@ Reports go to the address on the [[Abuse][abuse page]].
39 39
40=account export= writes your profile, repositories, issues and merge 40=account export= writes your profile, repositories, issues and merge
41requests as a bundle another gitbay instance can replay 41requests as a bundle another gitbay instance can replay
42(=gitbay migrate=), and every repository can be cloned. To have 42(=gitbay migrate=), and every repository can be cloned.
43the account deleted, mail the abuse address from the account's primary 43=gitbay auth delete= (or the bottom of =/settings=) deletes the account
44email. 44seven days after its mailed link is opened; see [[Users][the user
45guide]] for what goes and what stays.
45 46
46If gitbay.org ever shuts down, every account with a verified address 47If gitbay.org ever shuts down, every account with a verified address
47gets notice and time to export first. 48gets notice and time to export first.
.gitbay/wiki/Users.org +20
@@ -366,6 +366,26 @@ in =$$= follows before a blank line. MathML typed as raw HTML is
366stripped like any other markup the sanitizer does not allow. The CLI 366stripped like any other markup the sanitizer does not allow. The CLI
367shows the source. 367shows the source.
368 368
369* Deleting your account
370
371#+begin_src sh
372gitbay auth delete --confirm <username> # mails a link to your primary address
373gitbay auth delete --cancel # withdraw it before the link is opened
374#+end_src
375
376The same is at the bottom of =/settings=. Nothing changes until the
377mailed link is opened (it works for 24 hours, and needs a verified
378address). Opening it disables the account at once and deletes it seven
379days later: its repositories, snippets, keys, addresses and tokens go.
380Issues, merge requests, comments and reviews it wrote on other people's
381repositories stay, under the name =ghost=. Signing in during the seven
382days — on the web, or any command over SSH with a full-scope key —
383cancels; git-, read-, runner-scoped keys and API tokens are refused and
384cannot cancel. The only admin of an organization is refused until
385another admin exists or the organization is deleted. Export first
386(=gitbay auth export=) to keep a copy. The username is free again after
387the purge.
388
369* Organizations 389* Organizations
370 390
371Orgs share the owner namespace with users and own repositories at 391Orgs share the owner namespace with users and own repositories at
cmd/gitbay/main.go +1
@@ -530,6 +530,7 @@ func authCmd() *cobra.Command {
530 ) 530 )
531 return group("auth", "whoami, SSH and PGP keys, email, API tokens", 531 return group("auth", "whoami, SSH and PGP keys, email, API tokens",
532 pass("export", passOpts{server: []string{"account", "export"}}), 532 pass("export", passOpts{server: []string{"account", "export"}}),
533 pass("delete", passOpts{server: []string{"account", "delete"}}),
533 tokens, 534 tokens,
534 pass("whoami", passOpts{server: []string{"whoami"}}), 535 pass("whoami", passOpts{server: []string{"whoami"}}),
535 group("keys", "manage SSH keys", 536 group("keys", "manage SSH keys",
cmd/gitbay/serverpath_test.go +1
@@ -17,6 +17,7 @@ import (
17// help print the CLI's path (#267). A change to this list is deliberate. 17// help print the CLI's path (#267). A change to this list is deliberate.
18func TestServerPathMismatches(t *testing.T) { 18func TestServerPathMismatches(t *testing.T) {
19 want := []string{ 19 want := []string{
20 "auth delete",
20 "auth email add", "auth email list", "auth email primary", 21 "auth email add", "auth email list", "auth email primary",
21 "auth email remove", "auth email verify", 22 "auth email remove", "auth email verify",
22 "auth export", 23 "auth export",
cmd/gitbay/summaries_gen.go +1
@@ -3,6 +3,7 @@
3package main 3package main
4 4
5var summaries = map[string]string{ 5var summaries = map[string]string{
6 "account delete": "delete your account: mails a link, then purges seven days after it is opened",
6 "account export": "write your account bundle (profile, repos, issues, MRs) as JSON", 7 "account export": "write your account bundle (profile, repos, issues, MRs) as JSON",
7 "account import-bundle": "replay an account bundle (see gitbay migrate)", 8 "account import-bundle": "replay an account bundle (see gitbay migrate)",
8 "admin email verify": "mark an address verified by admin assertion", 9 "admin email verify": "mark an address verified by admin assertion",
cmd/gitbayd/main.go +26
@@ -242,6 +242,7 @@ func serveCmd() *cobra.Command {
242 if d := cfg.Registration.PendingExpiryDuration(); d > 0 { 242 if d := cfg.Registration.PendingExpiryDuration(); d > 0 {
243 go reapPending(whCtx, st, d) 243 go reapPending(whCtx, st, d)
244 } 244 }
245 go purgeDeletions(whCtx, st, cfg)
245 go sweep(whCtx, st, cfg) 246 go sweep(whCtx, st, cfg)
246 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL, 247 go (&ci.Scheduler{St: st, SiteURL: cfg.Server.SiteURL,
247 RepoDir: func(owner, name string) string { 248 RepoDir: func(owner, name string) string {
@@ -654,6 +655,31 @@ func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) {
654 } 655 }
655} 656}
656 657
658// purgeDeletions deletes accounts whose deletion grace has passed,
659// hourly and once at start, on the same tick override as reapPending.
660func purgeDeletions(ctx context.Context, st *store.Store, cfg config.Config) {
661 tick := time.Hour
662 if v := os.Getenv("GITBAY_REAP_TICK"); v != "" {
663 if d, err := time.ParseDuration(v); err == nil {
664 tick = d
665 }
666 }
667 t := time.NewTicker(tick)
668 defer t.Stop()
669 for {
670 if purged, err := control.PurgeDueAccounts(cfg, st, time.Now()); err != nil {
671 slog.Error("purging deleted accounts", "err", err)
672 } else if len(purged) > 0 {
673 slog.Info("purged deleted accounts", "users", purged)
674 }
675 select {
676 case <-ctx.Done():
677 return
678 case <-t.C:
679 }
680 }
681}
682
657// pushPerQueue is how many pushes one principal may have waiting: half 683// pushPerQueue is how many pushes one principal may have waiting: half
658// the queue, at least one. 684// the queue, at least one.
659func pushPerQueue(queue int) int { 685func pushPerQueue(queue int) int {
docs/specs/2026-10-02-account-delete-design.md added +111
@@ -0,0 +1,111 @@
1# Account deletion
2
3Closes #322. An account deletes itself. Today only `admin user delete`
4exists, and it refuses an account that anchors issues, merge requests,
5comments, reviews or an org with no other admin.
6
7## Decision
8
9`account delete` is a write on every surface. It takes the typed
10username, mails a confirmation link to the primary verified address,
11and on confirmation marks the account for deletion seven days out.
12During those seven days the account is refused everywhere except the
13two ways a person signs in, either of which cancels. After seven days
14a reaper purges it: owned content goes, authored content on other
15owners' repositories moves to a `ghost` account.
16
17## States
18
19| state | set by | SSH (full scope) | SSH (other scopes), API tokens | web session | web login link |
20|---|---|---|---|---|---|
21| requested | `account delete --confirm <name>` | normal | normal | normal | normal |
22| scheduled | the mailed link | cancels, then normal | refused | ended | cancels, then normal |
23| purged | the reaper | no account | no account | no account | no account |
24
25- *requested* is a row in `account_deletions` (user_id, token_hash,
26 requested_at, expires_at; the link lives 24 hours). Nothing about the
27 account changes. A second request replaces the first.
28- *scheduled* sets `users.delete_after` (now + 7 days) and ends every
29 web session. A full-scope SSH session or a completed login link clears
30 `delete_after`, is audited `account.delete.cancelled`, and prints or
31 shows "deletion of your account was cancelled". Runner-, read- and
32 deploy-scoped keys and API tokens are refused with "this account is
33 scheduled for deletion on <date>; sign in to cancel" so automation
34 cannot cancel by accident.
35- *purged*: the reaper, on the tick that already runs
36 `ReapPendingUsers`, takes every account with `delete_after` in the
37 past.
38
39## Purge
40
41One function in `internal/control` (it needs the repository root), run
42from the reaper, in this order:
43
441. Re-check the org rule. If the account is now the only admin of an
45 org, skip, leave `delete_after` set, audit
46 `account.delete.blocked`, and show it in `admin user show`. An
47 instance admin resolves it.
482. Delete every repository the account owns through `deleteRepo`, the
49 same path as `repo delete` (open MRs sourced from them are marked
50 source-gone; the directories go).
513. In one transaction: reassign `issues.author_id`,
52 `merge_requests.author_id`, `issue_comments.author_id`,
53 `mr_comments.author_id`, `mr_diff_comments.author_id` and
54 `mr_reviews.reviewer_id` to the ghost; then `DeleteUser`, whose
55 anchor check now passes. Everything else is already `CASCADE` (keys,
56 emails, tokens, sessions, snippets, memberships, watches, reactions,
57 assignments, inbox, review requests) or `SET NULL` (audit actor,
58 event actor, signatures, statuses, merged/closed by, releases).
59 Grants and the profile backfill row go in the same transaction, as
60 `DeleteUser` already does.
614. Audit `account.delete.purged` with the username.
62
63The username is free again after the purge. User ids are never reused
64(#306).
65
66## The ghost
67
68A real `users` row named `ghost`, created by the first purge that needs
69it and flagged `users.ghost = 1`: it has no keys, emails or sessions,
70cannot be signed in to, own anything, be granted access, or be deleted.
71`ghost` is added to `internal/policy/names.go` so nobody can register
72it. If an instance already has a real account named `ghost`, the purge
73refuses with a message telling the operator to rename it; gitbay.org
74has none. Its profile page reads "This account stands in for deleted
75users." Content shows `ghost` as its author, the way GitHub shows it.
76
77## Surfaces
78
79- `account delete --confirm <username>`: refuses a mismatched name
80 (exit 2), an account with no verified address (exit 4, "add and
81 verify an address first"), and the only admin of an org (exit 4,
82 naming the orgs). Prints where the mail went and suggests
83 `account export`.
84- `account delete --cancel`: clears a request or a schedule (the
85 scheduled case is reachable only from a full-scope key, which already
86 cancels on connect; this exists for the requested state).
87- Web: a "Delete account" section at the bottom of `/settings` with
88 the `confirmfield` partial, posting the same command. The mailed link
89 opens `/settings/delete?token=<token>`, a page that names the purge date
90 and has one button; the GET changes nothing.
91- The mail names the purge date, says how to cancel, and suggests
92 `account export`.
93- `admin user show` reports `delete_after` and a blocked purge.
94
95## Migration
96
97`account_deletions`; `users.delete_after TEXT`; `users.ghost INTEGER
98NOT NULL DEFAULT 0`.
99
100## Docs
101
102Parity row; Users wiki page (a "Deleting your account" section);
103`/privacy` text; Terms wiki page's "Leaving" section, which today says
104to mail the operator.
105
106## Not doing
107
108- Deleting authored content on other owners' repositories. Decided on
109 #322: it moves to the ghost.
110- An admin-initiated version with the grace period. `admin user delete`
111 stays as it is.
e2e/accountdelete_test.go added +68
@@ -0,0 +1,68 @@
1package e2e
2
3import (
4 "fmt"
5 "net/url"
6 "os"
7 "regexp"
8 "strings"
9 "testing"
10)
11
12// Self-service deletion (#322): a request over SSH mails a link, the link
13// schedules the purge and disables the account, a narrower key is refused
14// and cannot cancel, and a full-scope key cancels by signing in.
15func TestAccountDeleteFlow(t *testing.T) {
16 t.Parallel()
17 smtp := startFakeSMTP(t)
18 inst := startInstanceWith(t, fmt.Sprintf(
19 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
20 smtp.addr))
21 key := inst.newKey(t, "erin")
22 inst.admin(t, "admin", "user", "create", "erin", "--key", key+".pub",
23 "--email", "erin@example.test", "--verified")
24 gitKey := inst.newKey(t, "erin-git")
25 pub, err := os.ReadFile(gitKey + ".pub")
26 if err != nil {
27 t.Fatal(err)
28 }
29 if _, errOut, code := inst.ssh(t, key, string(pub), "keys", "add", "--scope", "git"); code != 0 {
30 t.Fatalf("keys add: %s", errOut)
31 }
32
33 if _, errOut, code := inst.ssh(t, key, "", "account", "delete", "--confirm", "erin"); code != 0 {
34 t.Fatalf("account delete: exit %d %s", code, errOut)
35 }
36 mail := smtp.waitFor(t, "erin@example.test", "/settings/delete?token=")
37 link := regexp.MustCompile(`/settings/delete\?token=[A-Za-z0-9_-]+`).FindString(mail)
38 if link == "" {
39 t.Fatalf("no link in mail:\n%s", mail)
40 }
41
42 browser := newBrowser(t)
43 if status, body := browserGet(t, browser, inst.base()+link); status != 200 || !strings.Contains(body, "Delete erin") {
44 t.Fatalf("GET link: %d", status)
45 }
46 if _, _, code := inst.ssh(t, key, "", "whoami"); code != 0 {
47 t.Fatal("opening the page changed the account")
48 }
49 if status, body := browserPost(t, browser, inst.base()+link, url.Values{}); status != 200 || !strings.Contains(body, "Deletion scheduled") {
50 t.Fatalf("POST link: %d\n%s", status, body)
51 }
52
53 if _, errOut, code := inst.ssh(t, gitKey, "", "whoami"); code != 4 || !strings.Contains(errOut, "scheduled for deletion") {
54 t.Fatalf("git key while scheduled: exit %d %s", code, errOut)
55 }
56 if out := inst.admin(t, "admin", "user", "show", "erin", "--json"); !strings.Contains(out, `"delete_after"`) {
57 t.Fatalf("show lacks the schedule:\n%s", out)
58 }
59 if _, errOut, code := inst.ssh(t, key, "", "whoami"); code != 0 || !strings.Contains(errOut, "deletion of your account was cancelled") {
60 t.Fatalf("full key while scheduled: exit %d %s", code, errOut)
61 }
62 if out := inst.admin(t, "admin", "user", "show", "erin", "--json"); strings.Contains(out, `"delete_after"`) || !strings.Contains(out, `"state":"active"`) {
63 t.Fatalf("not restored:\n%s", out)
64 }
65 if _, errOut, _ := inst.ssh(t, gitKey, "", "whoami"); strings.Contains(errOut, "scheduled for deletion") {
66 t.Fatal("git key still told the account is scheduled after the cancel")
67 }
68}
internal/control/accountdelete.go added +198
@@ -0,0 +1,198 @@
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")
internal/control/accountdelete_test.go added +203
@@ -0,0 +1,203 @@
1package control
2
3import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// runAs dispatches argv as u and returns the exit code and stderr.
17func runAs(st *store.Store, u store.User, root string, argv ...string) (int, string) {
18 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
19 c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader("")}
20 c.Cfg.Server.Root = root
21 c.Cfg.Server.SiteURL = "https://forge.test/"
22 c.Cfg.Limits.WriteRate = -1
23 return Dispatch(c, argv), errOut.String()
24}
25
26func deleteFixture(t *testing.T) (*store.Store, string, store.User, store.User) {
27 t.Helper()
28 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { st.Close() })
33 if err := st.MigrateUp(); err != nil {
34 t.Fatal(err)
35 }
36 root := t.TempDir()
37 user := func(name string) store.User {
38 id, err := st.CreateUser(name, false)
39 if err != nil {
40 t.Fatal(err)
41 }
42 u, _ := st.UserByID(id)
43 return u
44 }
45 return st, root, user("alice"), user("bob")
46}
47
48// account delete refuses a mistyped name, an account with no verified
49// address, and the only admin of an org; otherwise it mails a link and
50// changes nothing else.
51func TestAccountDeleteRequest(t *testing.T) {
52 st, root, _, bob := deleteFixture(t)
53 if code, errOut := runAs(st, bob, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "verify an address") {
54 t.Fatalf("no address: exit %d %q", code, errOut)
55 }
56 if err := st.AddEmail(bob.ID, "bob@example.test", "smtp", true); err != nil {
57 t.Fatal(err)
58 }
59 if code, _ := runAs(st, bob, root, "account", "delete", "--confirm", "bobb"); code != protocol.ExitUsage {
60 t.Fatalf("mistyped name: exit %d", code)
61 }
62 // The only instance admin is refused.
63 soleAdmin := bob
64 soleAdmin.IsAdmin = true
65 st.DB.Exec("UPDATE users SET is_admin = 1 WHERE id = ?", bob.ID)
66 if code, errOut := runAs(st, soleAdmin, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "only admin") {
67 t.Fatalf("sole instance admin: exit %d %q", code, errOut)
68 }
69 st.DB.Exec("UPDATE users SET is_admin = 0 WHERE id = ?", bob.ID)
70 if code, _ := runAs(st, bob, root, "org", "create", "acme"); code != 0 {
71 t.Fatal("org create")
72 }
73 if code, errOut := runAs(st, bob, root, "account", "delete", "--confirm", "bob"); code != protocol.ExitDenied || !strings.Contains(errOut, "only admin of acme") {
74 t.Fatalf("sole org admin: exit %d %q", code, errOut)
75 }
76 if _, err := st.DB.Exec("DELETE FROM orgs WHERE name = 'acme'"); err != nil {
77 t.Fatal(err)
78 }
79 if code, errOut := runAs(st, bob, root, "account", "delete", "--confirm", "bob"); code != 0 {
80 t.Fatalf("request: exit %d %q", code, errOut)
81 }
82 var body string
83 if err := st.DB.QueryRow("SELECT body FROM notifications WHERE recipient = 'bob@example.test'").Scan(&body); err != nil ||
84 !strings.Contains(body, "https://forge.test/settings/delete?token=") {
85 t.Fatalf("mail: %v %q", err, body)
86 }
87 if u, _ := st.UserByID(bob.ID); u.Disabled || u.DeleteAfter != "" {
88 t.Fatal("a request alone changed the account")
89 }
90 if code, _ := runAs(st, bob, root, "account", "delete", "--cancel"); code != 0 {
91 t.Fatal("cancel")
92 }
93 if code, _ := runAs(st, bob, root, "account", "delete", "--cancel"); code != protocol.ExitNotFound {
94 t.Fatalf("second cancel: exit %d", code)
95 }
96}
97
98// A confirmed deletion disables the account; the purge removes its
99// repositories and the account, and moves what it wrote elsewhere to the
100// ghost. Cancelling restores the account, and an admin decision clears
101// the schedule.
102func TestPurgeDueAccounts(t *testing.T) {
103 st, root, alice, bob := deleteFixture(t)
104 runAs(st, alice, root, "repo", "create", "alice/app")
105 if code, errOut := runAs(st, bob, root, "repo", "create", "bob/own"); code != 0 {
106 t.Fatalf("bob repo: %s", errOut)
107 }
108 app, _ := st.RepoByPath("alice/app")
109 issue, err := st.CreateIssue(app.ID, bob.ID, "from bob", "", "md")
110 if err != nil {
111 t.Fatal(err)
112 }
113
114 schedule := func(u store.User, after time.Time) store.User {
115 t.Helper()
116 _, hash, _ := store.NewToken()
117 if err := st.RequestAccountDeletion(u.ID, hash, time.Hour); err != nil {
118 t.Fatal(err)
119 }
120 s, err := st.ConfirmAccountDeletion(hash, after)
121 if err != nil {
122 t.Fatal(err)
123 }
124 return s
125 }
126
127 // Cancelling (what signing in does) restores the account.
128 s := schedule(bob, time.Now().Add(time.Hour))
129 if code, errOut := runAs(st, s, root, "whoami"); code != protocol.ExitDenied || !strings.Contains(errOut, "scheduled for deletion") {
130 t.Fatalf("scheduled account: exit %d %q", code, errOut)
131 }
132 if !CancelScheduledDeletion(st, &s, "test") || s.Disabled {
133 t.Fatal("cancel did not restore the account")
134 }
135 // A link opened after an admin suspended the account schedules
136 // nothing.
137 _, hash, _ := store.NewToken()
138 st.RequestAccountDeletion(bob.ID, hash, time.Hour)
139 st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob.ID)
140 if _, err := st.ConfirmAccountDeletion(hash, time.Now()); err == nil {
141 t.Fatal("a suspended account was scheduled")
142 }
143 st.DB.Exec("UPDATE users SET disabled = 0 WHERE id = ?", bob.ID)
144 // Once the purge has claimed an account, signing in cannot cancel.
145 s = schedule(bob, time.Now().Add(-time.Minute))
146 if ok, err := st.ClaimDeletion(bob.ID, time.Now()); !ok || err != nil {
147 t.Fatalf("claim: %v %v", ok, err)
148 }
149 s, _ = st.UserByID(bob.ID)
150 if CancelScheduledDeletion(st, &s, "test") {
151 t.Fatal("cancelled a purge in progress")
152 }
153 st.DB.Exec("UPDATE users SET disabled = 0, delete_after = NULL WHERE id = ?", bob.ID)
154 // An admin disabling a scheduled account keeps it, disabled.
155 schedule(bob, time.Now().Add(-time.Minute))
156 if err := st.SetUserDisabled(bob.ID, true); err != nil {
157 t.Fatal(err)
158 }
159 if due, _ := st.DueDeletions(time.Now()); len(due) != 0 {
160 t.Fatal("an admin's disable left the purge scheduled")
161 }
162 st.SetUserDisabled(bob.ID, false)
163
164 schedule(bob, time.Now().Add(-time.Minute))
165 cfg := config.Default()
166 cfg.Server.Root = root
167 purged, err := PurgeDueAccounts(cfg, st, time.Now())
168 if err != nil || len(purged) != 1 || purged[0] != "bob" {
169 t.Fatalf("purge: %v %v", purged, err)
170 }
171 if _, err := st.UserByID(bob.ID); err == nil {
172 t.Fatal("bob still exists")
173 }
174 if _, err := os.Stat(RepoDir(root, "bob", "own")); !os.IsNotExist(err) {
175 t.Fatalf("bob/own directory: %v", err)
176 }
177 ghost, err := st.UserByUsername("ghost")
178 if err != nil || !ghost.Ghost || !ghost.Disabled {
179 t.Fatalf("ghost: %+v %v", ghost, err)
180 }
181 var author int64
182 st.DB.QueryRow("SELECT author_id FROM issues WHERE id = ?", issue).Scan(&author)
183 if author != ghost.ID {
184 t.Fatalf("issue author %d, want ghost %d", author, ghost.ID)
185 }
186 admin := alice
187 admin.IsAdmin = true
188 if code, _ := runAs(st, admin, root, "admin", "user", "enable", "ghost"); code != protocol.ExitDenied {
189 t.Fatalf("admin enable ghost: exit %d", code)
190 }
191
192 // The only admin of an org is skipped and stays scheduled.
193 carolID, _ := st.CreateUser("carol", false)
194 carol, _ := st.UserByID(carolID)
195 runAs(st, carol, root, "org", "create", "solo")
196 schedule(carol, time.Now().Add(-time.Minute))
197 if purged, err := PurgeDueAccounts(cfg, st, time.Now()); err != nil || len(purged) != 0 {
198 t.Fatalf("sole admin purged: %v %v", purged, err)
199 }
200 if u, err := st.UserByID(carolID); err != nil || u.DeleteAfter == "" {
201 t.Fatalf("carol: %+v %v", u, err)
202 }
203}
internal/control/admin.go +3
@@ -251,6 +251,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
251 ByteLimit int64 `json:"byte_limit"` // 0 unlimited 251 ByteLimit int64 `json:"byte_limit"` // 0 unlimited
252 APITokens []tokenOut `json:"api_tokens"` 252 APITokens []tokenOut `json:"api_tokens"`
253 WebSessions int64 `json:"web_sessions"` 253 WebSessions int64 `json:"web_sessions"`
254 DeleteAfter string `json:"delete_after,omitempty"` // a scheduled self-deletion
254 } 255 }
255 d := out{adminUserOut: adminUserRow(row), 256 d := out{adminUserOut: adminUserRow(row),
256 Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}} 257 Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
@@ -298,6 +299,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
298 if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil { 299 if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
299 return c.fail(protocol.ExitFailure, "%v", err) 300 return c.fail(protocol.ExitFailure, "%v", err)
300 } 301 }
302 d.DeleteAfter = u.DeleteAfter
301 303
302 return c.emitView(d, func(w io.Writer) { 304 return c.emitView(d, func(w io.Writer) {
303 admin := "" 305 admin := ""
@@ -312,6 +314,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
312 "last seen", c.when(d.LastSeen), 314 "last seen", c.when(d.LastSeen),
313 "repos", fmt.Sprintf("%d", d.Repos), 315 "repos", fmt.Sprintf("%d", d.Repos),
314 "web sessions", fmt.Sprintf("%d", d.WebSessions), 316 "web sessions", fmt.Sprintf("%d", d.WebSessions),
317 "deletes at", c.when(d.DeleteAfter),
315 ) 318 )
316 if len(d.Keys) > 0 { 319 if len(d.Keys) > 0 {
317 v.section("keys") 320 v.section("keys")
internal/control/adminhost.go +3
@@ -160,6 +160,9 @@ func adminUserArg(c *Ctx, args []string, usage string) (store.User, int) {
160 } else if err != nil { 160 } else if err != nil {
161 return u, c.fail(protocol.ExitFailure, "%v", err) 161 return u, c.fail(protocol.ExitFailure, "%v", err)
162 } 162 }
163 if u.Ghost {
164 return u, c.fail(protocol.ExitDenied, "%v", errGhost)
165 }
163 return u, -1 166 return u, -1
164} 167}
165 168
internal/control/control.go +3
@@ -247,6 +247,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int {
247 // The SSH listener refuses a disabled account before it gets here; the 247 // The SSH listener refuses a disabled account before it gets here; the
248 // API and the web reach Dispatch directly, so the check lives here too. 248 // API and the web reach Dispatch directly, so the check lives here too.
249 if c.User.Disabled { 249 if c.User.Disabled {
250 if c.User.DeleteAfter != "" {
251 return c.fail(protocol.ExitDenied, "%s", ScheduledRefusal(c.User))
252 }
250 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") 253 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
251 } 254 }
252 if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) { 255 if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) {
internal/control/loginlink.go +3 −1
@@ -69,7 +69,9 @@ func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) err
69 // read paths — which is the whole of what suspension prevents, and more 69 // read paths — which is the whole of what suspension prevents, and more
70 // than pendingAllowed grants an unverified account. Returning nil rather 70 // than pendingAllowed grants an unverified account. Returning nil rather
71 // than an error keeps the response identical to a miss. 71 // than an error keeps the response identical to a miss.
72 if user.Disabled || user.Pending { 72 // An account scheduled for deletion may still have a link: signing in
73 // is how its owner cancels.
74 if (user.Disabled && user.DeleteAfter == "") || user.Pending {
73 return nil 75 return nil
74 } 76 }
75 77
internal/control/org.go +3
@@ -274,6 +274,9 @@ func runOrgMembersAdd(c *Ctx, args []string) int {
274 if err != nil { 274 if err != nil {
275 return c.fail(protocol.ExitFailure, "%v", err) 275 return c.fail(protocol.ExitFailure, "%v", err)
276 } 276 }
277 if target.Ghost {
278 return c.fail(protocol.ExitDenied, "%v", errGhost)
279 }
277 if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil { 280 if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil {
278 return c.failErr(err) 281 return c.failErr(err)
279 } 282 }
internal/control/reauth_test.go +1 −1
@@ -107,7 +107,7 @@ func TestNeedsRecentSignInSet(t *testing.T) {
107 } 107 }
108 slices.Sort(got) 108 slices.Sort(got)
109 want := []string{ 109 want := []string{
110 "admin email verify", 110 "account delete", "admin email verify",
111 "admin invite", 111 "admin invite",
112 "admin repo visibility", 112 "admin repo visibility",
113 "admin user create", 113 "admin user create",
internal/control/repo.go +21 −9
@@ -703,22 +703,31 @@ func deleteRepo(c *Ctx, repo store.Repo) int {
703 return lockCode 703 return lockCode
704 } 704 }
705 defer release() 705 defer release()
706 // Open MRs sourced from this repo keep working (targets own the 706 if err := removeRepo(c.Store, c.Cfg.Server.Root, repo); err != nil {
707 // objects) but must show that the source is gone.
708 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
709 return c.fail(protocol.ExitFailure, "%v", err)
710 }
711 if err := c.Store.DeleteRepo(repo.ID); err != nil {
712 return c.fail(protocol.ExitFailure, "%v", err) 707 return c.fail(protocol.ExitFailure, "%v", err)
713 } 708 }
714 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
715 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
716 }
717 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { 709 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
718 fmt.Fprintf(w, "deleted %s\n", repo.Path()) 710 fmt.Fprintf(w, "deleted %s\n", repo.Path())
719 }) 711 })
720} 712}
721 713
714// removeRepo is deleteRepo's work without a request: the caller holds
715// off the backup.
716func removeRepo(st *store.Store, root string, repo store.Repo) error {
717 // Open MRs sourced from this repo keep working (targets own the
718 // objects) but must show that the source is gone.
719 if err := st.MarkSourceGoneForRepo(repo.ID); err != nil {
720 return err
721 }
722 if err := st.DeleteRepo(repo.ID); err != nil {
723 return err
724 }
725 if err := os.RemoveAll(RepoDir(root, repo.OwnerName, repo.Name)); err != nil {
726 return fmt.Errorf("database row removed but disk cleanup failed: %w", err)
727 }
728 return nil
729}
730
722// holdOffBackup keeps a full backup from starting while a repository 731// holdOffBackup keeps a full backup from starting while a repository
723// directory moves or goes, and refuses while one runs: the backup's 732// directory moves or goes, and refuses while one runs: the backup's
724// database snapshot names every repository its walk then archives 733// database snapshot names every repository its walk then archives
@@ -743,6 +752,9 @@ func runAccessGrant(c *Ctx, args []string) int {
743 if err != nil { 752 if err != nil {
744 return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) 753 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
745 } 754 }
755 if target.Ghost {
756 return c.fail(protocol.ExitDenied, "%v", errGhost)
757 }
746 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil { 758 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
747 return c.fail(protocol.ExitFailure, "%v", err) 759 return c.fail(protocol.ExitFailure, "%v", err)
748 } 760 }
internal/httpd/account.go +10
@@ -243,6 +243,16 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
243 return 243 return
244 } 244 }
245 back("", "key registered") 245 back("", "key registered")
246 case "account-delete":
247 if ok, msg := confirmed(r, u.Username); !ok {
248 back(msg, "")
249 return
250 }
251 if _, msg, ok := s.runControl(u, []string{"account", "delete", "--confirm", u.Username}); !ok {
252 back(msg, "")
253 return
254 }
255 back("", "a deletion link was mailed to your primary address; nothing changes until it is opened")
246 case "key-remove": 256 case "key-remove":
247 want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:")) 257 want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
248 if ok, msg := confirmed(r, want); !ok { 258 if ok, msg := confirmed(r, want); !ok {
internal/httpd/accountdelete.go added +40
@@ -0,0 +1,40 @@
1package httpd
2
3import (
4 "net/http"
5 "time"
6
7 "gitbay.org/gitbay/internal/control"
8 "gitbay.org/gitbay/internal/store"
9)
10
11// accountDeletePage is where the mailed deletion link lands. The token
12// is the authority, as a login link's is, so no session is needed; like
13// the login token it rides the query string. The GET changes nothing; the
14// button posts back to the same URL.
15type accountDeletePage struct {
16 basePage
17 User string
18 Scheduled string
19}
20
21func (s *Server) accountDeleteForm(w http.ResponseWriter, r *http.Request) {
22 w.Header().Set("Cache-Control", "no-store")
23 page := accountDeletePage{basePage: s.base(r)}
24 if u, err := s.st.AccountDeletionUser(store.HashToken(r.URL.Query().Get("token"))); err == nil {
25 page.User = u.Username
26 }
27 s.render(w, "accountdelete.html", page)
28}
29
30func (s *Server) accountDeleteConfirm(w http.ResponseWriter, r *http.Request) {
31 w.Header().Set("Cache-Control", "no-store")
32 u, err := s.st.ConfirmAccountDeletion(store.HashToken(r.URL.Query().Get("token")), time.Now().Add(control.DeletionGrace))
33 if err != nil {
34 s.render(w, "accountdelete.html", accountDeletePage{basePage: s.base(r)})
35 return
36 }
37 s.st.Audit(u.ID, "account.delete.scheduled", map[string]any{"user": u.Username, "after": u.DeleteAfter})
38 http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
39 s.render(w, "accountdelete.html", accountDeletePage{basePage: s.base(r), User: u.Username, Scheduled: u.DeleteAfter})
40}
internal/httpd/accounts.go +7 −1
@@ -140,7 +140,13 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
140 // and the session it would create renders every page the account can 140 // and the session it would create renders every page the account can
141 // read. Checking here covers every mint path. The message is the one a 141 // read. Checking here covers every mint path. The message is the one a
142 // bad token gets: a distinct one would confirm the account exists. 142 // bad token gets: a distinct one would confirm the account exists.
143 if u, err := s.st.UserByID(userID); err != nil || u.Disabled { 143 // A login is how the owner of an account scheduled for deletion
144 // cancels it.
145 u, err := s.st.UserByID(userID)
146 if err == nil {
147 control.CancelScheduledDeletion(s.st, &u, "web")
148 }
149 if err != nil || u.Disabled {
144 s.renderLogin(w, badLoginToken, false, "") 150 s.renderLogin(w, badLoginToken, false, "")
145 return 151 return
146 } 152 }
internal/httpd/routes.go +3
@@ -128,6 +128,9 @@ func (s *Server) Routes() []Route {
128 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, 128 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
129 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, 129 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
130 Route{Method: "GET", Pattern: "/settings/export", Handler: s.requireUser(s.accountExport)}, 130 Route{Method: "GET", Pattern: "/settings/export", Handler: s.requireUser(s.accountExport)},
131 Route{Method: "GET", Pattern: "/settings/delete", Handler: s.accountDeleteForm},
132 Route{Method: "POST", Pattern: "/settings/delete", Mutating: true,
133 Handler: s.checkOrigin(s.accountDeleteConfirm)},
131 Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)}, 134 Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)},
132 Route{Method: "POST", Pattern: "/notifications", Mutating: true, 135 Route{Method: "POST", Pattern: "/notifications", Mutating: true,
133 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))}, 136 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))},
internal/policy/names.go +1
@@ -20,6 +20,7 @@ var reservedNames = map[string]bool{
20 "favicon.svg": true, 20 "favicon.svg": true,
21 "gitbay": true, // vanity go-import path on gitbay.org 21 "gitbay": true, // vanity go-import path on gitbay.org
22 "gitbay-bot": true, // authors dependency-update issues 22 "gitbay-bot": true, // authors dependency-update issues
23 "ghost": true, // authors what deleted accounts wrote (#322)
23 "healthz": true, 24 "healthz": true,
24 "login": true, 25 "login": true,
25 "logout": true, 26 "logout": true,
internal/sshd/sshd.go +9
@@ -467,7 +467,16 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
467// receive-pack. A nil limiter is no limit. 467// receive-pack. A nil limiter is no limit.
468func Exec(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string, 468func Exec(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string,
469 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { 469 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
470 // A person signing in with a full-scope key cancels a scheduled
471 // deletion; automation on narrower keys is refused and cannot.
472 if user.Disabled && user.DeleteAfter != "" && key.Scope == "full" && control.CancelScheduledDeletion(st, &user, "ssh") {
473 fmt.Fprintln(stderr, "deletion of your account was cancelled")
474 }
470 if user.Disabled { 475 if user.Disabled {
476 if user.DeleteAfter != "" {
477 fmt.Fprintln(stderr, control.ScheduledRefusal(user))
478 return protocol.ExitDenied
479 }
471 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") 480 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
472 return protocol.ExitDenied 481 return protocol.ExitDenied
473 } 482 }
internal/store/accountdelete.go added +215
@@ -0,0 +1,215 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "time"
8)
9
10// ErrGhostNameTaken is returned when a real account holds the name the
11// ghost needs.
12var ErrGhostNameTaken = errors.New(`an account named "ghost" exists and is not the ghost; rename it before an account can be deleted`)
13
14// RequestAccountDeletion records a deletion request waiting on its mailed
15// link. A second request replaces the first.
16func (s *Store) RequestAccountDeletion(userID int64, tokenHash string, ttl time.Duration) error {
17 _, err := s.DB.Exec(`INSERT INTO account_deletions (user_id, token_hash, expires_at) VALUES (?, ?, ?)
18 ON CONFLICT (user_id) DO UPDATE SET token_hash = excluded.token_hash,
19 created_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), expires_at = excluded.expires_at`,
20 userID, tokenHash, fmtTime(time.Now().Add(ttl)))
21 return err
22}
23
24// AccountDeletionUser is the account an unexpired deletion link names.
25func (s *Store) AccountDeletionUser(tokenHash string) (User, error) {
26 var userID int64
27 err := s.DB.QueryRow("SELECT user_id FROM account_deletions WHERE token_hash = ? AND expires_at > ?",
28 tokenHash, fmtTime(time.Now())).Scan(&userID)
29 if errors.Is(err, sql.ErrNoRows) {
30 return User{}, ErrNotFound
31 }
32 if err != nil {
33 return User{}, err
34 }
35 return s.UserByID(userID)
36}
37
38// ConfirmAccountDeletion consumes the link and schedules the purge at
39// after: the account is disabled, its web sessions and login links end,
40// and its open connections close. API tokens stay, refused while the
41// account is disabled, so a cancelled deletion leaves them working.
42func (s *Store) ConfirmAccountDeletion(tokenHash string, after time.Time) (User, error) {
43 u, err := s.AccountDeletionUser(tokenHash)
44 if err != nil {
45 return User{}, err
46 }
47 // A suspension is an admin's decision; the link cannot turn it into
48 // a schedule its owner could then cancel by signing in.
49 if u.Disabled {
50 return User{}, ErrNotFound
51 }
52 tx, err := s.DB.Begin()
53 if err != nil {
54 return User{}, err
55 }
56 defer tx.Rollback()
57 if _, err := tx.Exec("DELETE FROM account_deletions WHERE user_id = ?", u.ID); err != nil {
58 return User{}, err
59 }
60 if _, err := tx.Exec("UPDATE users SET disabled = 1, delete_after = ? WHERE id = ? AND disabled = 0", fmtTime(after), u.ID); err != nil {
61 return User{}, err
62 }
63 for _, table := range []string{"web_sessions", "login_tokens"} {
64 if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ?", u.ID); err != nil {
65 return User{}, err
66 }
67 }
68 if err := tx.Commit(); err != nil {
69 return User{}, err
70 }
71 s.announce(Revoked{UserID: u.ID})
72 u.Disabled, u.DeleteAfter = true, fmtTime(after)
73 return u, nil
74}
75
76// Purging marks users.delete_after while the purge runs. It sorts after
77// every timestamp, so nothing cancels it, and DueDeletions returns it
78// again until the purge completes.
79const Purging = "purging"
80
81// ClaimDeletion marks a due account as being purged. It reports false
82// when a cancel or an admin got there first.
83func (s *Store) ClaimDeletion(userID int64, now time.Time) (bool, error) {
84 res, err := s.DB.Exec(`UPDATE users SET delete_after = ? WHERE id = ? AND disabled = 1
85 AND delete_after IS NOT NULL AND (delete_after <= ? OR delete_after = ?)`, Purging, userID, fmtTime(now), Purging)
86 if err != nil {
87 return false, err
88 }
89 n, _ := res.RowsAffected()
90 return n == 1, nil
91}
92
93// CancelAccountDeletion drops a waiting request and a scheduled purge.
94// It reports whether either existed.
95func (s *Store) CancelAccountDeletion(userID int64) (bool, error) {
96 res, err := s.DB.Exec("DELETE FROM account_deletions WHERE user_id = ?", userID)
97 if err != nil {
98 return false, err
99 }
100 requested, _ := res.RowsAffected()
101 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)
102 if err != nil {
103 return false, err
104 }
105 scheduled, _ := res.RowsAffected()
106 return requested+scheduled > 0, nil
107}
108
109// DueDeletions lists the accounts whose scheduled purge time has passed.
110func (s *Store) DueDeletions(now time.Time) ([]User, error) {
111 rows, err := s.DB.Query("SELECT id FROM users WHERE delete_after IS NOT NULL AND (delete_after <= ? OR delete_after = ?)", fmtTime(now), Purging)
112 if err != nil {
113 return nil, err
114 }
115 ids, err := scanIDs(rows)
116 if err != nil {
117 return nil, err
118 }
119 var out []User
120 for _, id := range ids {
121 u, err := s.UserByID(id)
122 if err != nil {
123 return nil, err
124 }
125 out = append(out, u)
126 }
127 return out, nil
128}
129
130// SoleAdminOrgs names the organizations where the user is the only admin.
131func (s *Store) SoleAdminOrgs(userID int64) ([]string, error) {
132 rows, err := s.DB.Query(`SELECT o.name FROM orgs o JOIN org_members m ON m.org_id = o.id
133 WHERE m.user_id = ? AND m.role = 'admin'
134 AND NOT EXISTS (SELECT 1 FROM org_members x
135 WHERE x.org_id = o.id AND x.role = 'admin' AND x.user_id != m.user_id
136 AND x.user_id NOT IN (SELECT id FROM users WHERE ghost = 1))
137 ORDER BY o.name`, userID)
138 if err != nil {
139 return nil, err
140 }
141 defer rows.Close()
142 var names []string
143 for rows.Next() {
144 var n string
145 if err := rows.Scan(&n); err != nil {
146 return nil, err
147 }
148 names = append(names, n)
149 }
150 return names, rows.Err()
151}
152
153// OtherActiveAdmins counts instance admins other than the user who can
154// still act.
155func (s *Store) OtherActiveAdmins(userID int64) (int64, error) {
156 var n int64
157 err := s.DB.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1 AND disabled = 0
158 AND pending = 0 AND id != ?`, userID).Scan(&n)
159 return n, err
160}
161
162// EnsureGhost returns the ghost account's id, creating it on first use.
163// It is disabled and holds no credentials, so nothing can act as it.
164func (s *Store) EnsureGhost() (int64, error) {
165 var id int64
166 err := s.DB.QueryRow("SELECT id FROM users WHERE ghost = 1").Scan(&id)
167 if err == nil {
168 return id, nil
169 }
170 if !errors.Is(err, sql.ErrNoRows) {
171 return 0, err
172 }
173 if _, err := s.UserByUsername("ghost"); err == nil {
174 return 0, ErrGhostNameTaken
175 }
176 res, err := s.DB.Exec(`INSERT INTO users (username, is_admin, disabled, ghost, description)
177 VALUES ('ghost', 0, 1, 1, 'This account stands in for deleted users.')`)
178 if err != nil {
179 return 0, err
180 }
181 return res.LastInsertId()
182}
183
184// ReassignToGhost moves what the user wrote on other owners' repositories
185// to the ghost: issues, merge requests, comments, diff comments and
186// reviews, the rows DeleteUser otherwise refuses over. Pending draft
187// comments are deleted and reviews made stale.
188func (s *Store) ReassignToGhost(userID, ghostID int64) error {
189 tx, err := s.DB.Begin()
190 if err != nil {
191 return err
192 }
193 defer tx.Rollback()
194 // Unsubmitted drafts go; reviews stay as text but no longer count
195 // toward a merge gate.
196 if _, err := tx.Exec("DELETE FROM mr_diff_comments WHERE author_id = ? AND pending = 1", userID); err != nil {
197 return err
198 }
199 if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE reviewer_id = ?", userID); err != nil {
200 return err
201 }
202 for _, col := range []struct{ table, column string }{
203 {"issues", "author_id"},
204 {"merge_requests", "author_id"},
205 {"issue_comments", "author_id"},
206 {"mr_comments", "author_id"},
207 {"mr_diff_comments", "author_id"},
208 {"mr_reviews", "reviewer_id"},
209 } {
210 if _, err := tx.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s = ?", col.table, col.column, col.column), ghostID, userID); err != nil {
211 return fmt.Errorf("reassigning %s: %w", col.table, err)
212 }
213 }
214 return tx.Commit()
215}
internal/store/idreuse_test.go +3 −2
@@ -149,8 +149,9 @@ func TestIDMigrationKeepsRowsAndForeignKeys(t *testing.T) {
149 } 149 }
150 150
151 // The triggers still fire. 151 // The triggers still fire.
152 alice, err := s.UserByUsername("alice") 152 // Read by column: the schema here is 0074's, older than User's loader.
153 if err != nil { 153 var alice User
154 if err := s.DB.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&alice.ID); err != nil {
154 t.Fatal(err) 155 t.Fatal(err)
155 } 156 }
156 if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", alice.ID); err == nil || !strings.Contains(err.Error(), "still owns repositories") { 157 if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", alice.ID); err == nil || !strings.Contains(err.Error(), "still owns repositories") {
internal/store/migrations/0077_account_delete.down.sql added +3
@@ -0,0 +1,3 @@
1ALTER TABLE users DROP COLUMN ghost;
2ALTER TABLE users DROP COLUMN delete_after;
3DROP TABLE account_deletions;
internal/store/migrations/0077_account_delete.up.sql added +12
@@ -0,0 +1,12 @@
1-- Self-service account deletion (#322). A request waits for its mailed
2-- link in account_deletions; a confirmed one sets users.delete_after and
3-- disables the account until the reaper purges it. users.ghost marks the
4-- one account that takes over what deleted accounts wrote.
5CREATE TABLE account_deletions (
6 user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
7 token_hash TEXT NOT NULL UNIQUE,
8 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 expires_at TEXT NOT NULL
10);
11ALTER TABLE users ADD COLUMN delete_after TEXT;
12ALTER TABLE users ADD COLUMN ghost INTEGER NOT NULL DEFAULT 0;
internal/store/users.go +24 −8
@@ -13,7 +13,11 @@ type User struct {
13 Username string 13 Username string
14 IsAdmin bool 14 IsAdmin bool
15 Pending bool // self-registered, email not yet verified 15 Pending bool // self-registered, email not yet verified
16 Disabled bool // administratively suspended 16 Disabled bool // administratively suspended, or scheduled for deletion
17 // DeleteAfter is when a scheduled deletion purges the account, ""
18 // when none is scheduled. A scheduled account is also Disabled.
19 DeleteAfter string
20 Ghost bool // stands in as the author of deleted accounts' content
17 // SignedInAt is when the browser session this user came from was 21 // SignedInAt is when the browser session this user came from was
18 // created by a login. Set by WebSessionUser only; zero elsewhere. 22 // created by a login. Set by WebSessionUser only; zero elsewhere.
19 SignedInAt time.Time 23 SignedInAt time.Time
@@ -137,15 +141,18 @@ func (s *Store) OwnerExists(name string) bool {
137 141
138func (s *Store) UserByUsername(name string) (User, error) { 142func (s *Store) UserByUsername(name string) (User, error) {
139 var u User 143 var u User
140 var admin, pending, disabled int 144 var admin, pending, disabled, ghost int
141 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE username = ?", name). 145 var deleteAfter sql.NullString
142 Scan(&u.ID, &u.Username, &admin, &pending, &disabled) 146 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled, delete_after, ghost FROM users WHERE username = ?", name).
147 Scan(&u.ID, &u.Username, &admin, &pending, &disabled, &deleteAfter, &ghost)
143 if errors.Is(err, sql.ErrNoRows) { 148 if errors.Is(err, sql.ErrNoRows) {
144 return u, ErrNotFound 149 return u, ErrNotFound
145 } 150 }
146 u.IsAdmin = admin != 0 151 u.IsAdmin = admin != 0
147 u.Pending = pending != 0 152 u.Pending = pending != 0
148 u.Disabled = disabled != 0 153 u.Disabled = disabled != 0
154 u.DeleteAfter = deleteAfter.String
155 u.Ghost = ghost != 0
149 return u, err 156 return u, err
150} 157}
151 158
@@ -203,10 +210,16 @@ func (s *Store) ListEmails(userID int64) ([]Email, error) {
203// are closed. 210// are closed.
204func (s *Store) SetUserDisabled(userID int64, disabled bool) error { 211func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
205 v := 0 212 v := 0
213 if _, err := s.DB.Exec("DELETE FROM account_deletions WHERE user_id = ?", userID); err != nil {
214 return err
215 }
206 if disabled { 216 if disabled {
207 v = 1 217 v = 1
208 } 218 }
209 res, err := s.DB.Exec("UPDATE users SET disabled = ? WHERE id = ?", v, userID) 219 // An admin's decision replaces a scheduled or requested deletion
220 // either way: a disabled account stays disabled and is not purged,
221 // an enabled one is back in use. A purge already under way finishes.
222 res, err := s.DB.Exec("UPDATE users SET disabled = ?, delete_after = CASE WHEN delete_after = ? THEN delete_after END WHERE id = ?", v, Purging, userID)
210 if err != nil { 223 if err != nil {
211 return err 224 return err
212 } 225 }
@@ -321,15 +334,18 @@ func (s *Store) SetDiffLayout(userID int64, layout string) error {
321 334
322func (s *Store) UserByID(id int64) (User, error) { 335func (s *Store) UserByID(id int64) (User, error) {
323 var u User 336 var u User
324 var admin, pending, disabled int 337 var admin, pending, disabled, ghost int
325 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE id = ?", id). 338 var deleteAfter sql.NullString
326 Scan(&u.ID, &u.Username, &admin, &pending, &disabled) 339 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled, delete_after, ghost FROM users WHERE id = ?", id).
340 Scan(&u.ID, &u.Username, &admin, &pending, &disabled, &deleteAfter, &ghost)
327 if errors.Is(err, sql.ErrNoRows) { 341 if errors.Is(err, sql.ErrNoRows) {
328 return u, ErrNotFound 342 return u, ErrNotFound
329 } 343 }
330 u.IsAdmin = admin != 0 344 u.IsAdmin = admin != 0
331 u.Pending = pending != 0 345 u.Pending = pending != 0
332 u.Disabled = disabled != 0 346 u.Disabled = disabled != 0
347 u.DeleteAfter = deleteAfter.String
348 u.Ghost = ghost != 0
333 return u, err 349 return u, err
334} 350}
335 351
internal/web/templates/account.html +11
@@ -20,6 +20,7 @@
20 <li><a href="#export">Export</a></li> 20 <li><a href="#export">Export</a></li>
21 <li><a href="#tokens">API tokens</a></li> 21 <li><a href="#tokens">API tokens</a></li>
22 <li><a href="#cli">On the command line</a></li> 22 <li><a href="#cli">On the command line</a></li>
23 <li><a href="#delete">Delete account</a></li>
23 </ul></div> 24 </ul></div>
24</details> 25</details>
25</nav> 26</nav>
@@ -251,6 +252,16 @@ gitbay admin ... # instance administration</pre>
251grouping words dropped: <code>ssh git@{{.Host}} whoami</code>, 252grouping words dropped: <code>ssh git@{{.Host}} whoami</code>,
252<code>ssh git@{{.Host}} web sessions list</code>.</p> 253<code>ssh git@{{.Host}} web sessions list</code>.</p>
253</section> 254</section>
255
256<section id="delete"><h2>Delete account</h2>
257<p class="meta">Deletes your repositories, snippets, keys and addresses.
258Issues, merge requests and comments you wrote on other people's
259repositories stay, under the name <code>ghost</code>. A link is mailed to
260your primary address; once it is opened the account is disabled, and it
261is deleted seven days later. Signing in during those days cancels.
262<a href="#export">Export</a> first to keep a copy.</p>
263<form method="post" action="/settings" class="setform"><input type="hidden" name="field" value="account-delete">{{template "confirmfield" .Viewer}} <button type="submit" class="danger">Mail the deletion link</button></form>
264</section>
254</div> 265</div>
255</div> 266</div>
256{{end}} 267{{end}}
internal/web/templates/accountdelete.html added +23
@@ -0,0 +1,23 @@
1{{define "width"}}bounded{{end}}
2{{define "title"}}delete account · {{.Site}}{{end}}
3{{define "content"}}
4{{if .Scheduled}}
5<h1>Deletion scheduled</h1>
6<p>{{.User}} is disabled and will be deleted at {{when .Scheduled}}.</p>
7<p class="meta">To cancel, <a href="/login">sign in</a> before then, or run any
8command over SSH with a full-scope key.</p>
9{{else if .User}}
10<h1>Delete {{.User}}</h1>
11<p>Confirming disables the account now and deletes it seven days later:
12its repositories, snippets, keys and addresses go, and what it wrote on
13other people's repositories stays under the name <code>ghost</code>.
14Signing in during those seven days cancels.</p>
15<form method="post">
16<p class="btngroup"><button type="submit" class="danger">Delete {{.User}}</button> <a href="/">Cancel</a></p>
17</form>
18{{else}}
19<h1>Link not valid</h1>
20<p class="meta">This deletion link has expired or was already used. Nothing
21has changed. Request a new one from <a href="/settings#delete">settings</a>.</p>
22{{end}}
23{{end}}
internal/web/templates/privacy.html +5 −1
@@ -19,7 +19,11 @@ repositories are visible only to accounts you grant; to everyone else
19they are indistinguishable from nonexistent.</p> 19they are indistinguishable from nonexistent.</p>
20<p>Your data is portable by design: <code>gitbay auth export</code> 20<p>Your data is portable by design: <code>gitbay auth export</code>
21downloads your account bundle, git data is yours by clone, and 21downloads your account bundle, git data is yours by clone, and
22<code>gitbay migrate</code> moves everything to another instance.</p> 22<code>gitbay migrate</code> moves everything to another instance.
23<code>gitbay auth delete</code>, or the bottom of the settings page,
24deletes the account seven days after the mailed link is opened; what it
25wrote on other people's repositories stays, attributed to
26<code>ghost</code>.</p>
23 27
24<h2>The mobile client</h2> 28<h2>The mobile client</h2>
25<p>The iOS app is a client for an instance you name at sign-in. It has 29<p>The iOS app is a client for an instance you name at sign-in. It has
internal/web/web_test.go +1 −1
@@ -90,7 +90,7 @@ func TestWhenNamesTheZone(t *testing.T) {
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "symbols.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "symbols.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true, "accountdelete.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {
96 src, err := TemplateSource(name) 96 src, err := TemplateSource(name)