Commit b56876ccbf

b56876ccbf79a7d35e0295b6d07a1e149f328d25

parent: 0dd284d9dd

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

cmc <hello@cleberg.net> · 2026-09-03 16:20 UTC

control: a disabled account is refused on every surface

The SSH listener refused a disabled account, but the API and the web
reach Dispatch directly and never checked the flag, and disabling
deleted browser sessions while leaving API tokens alive. Dispatch now
refuses a disabled account outright, and disabling revokes its API
tokens along with its sessions.

TestDisabledAccountAPI: a token answers before, 401 after, still 401
after re-enable, while the account itself is back over ssh.

Closes #95

Layout: unified · split

e2e/disabled_test.go added +51
@@ -0,0 +1,51 @@
1package e2e
2
3import (
4 "encoding/json"
5 "testing"
6)
7
8// Disabling an account ends every way in, not just SSH. The API used to
9// keep answering a disabled account's bearer token, because only the SSH
10// listener checked the flag and disabling deleted sessions but not tokens
11// (#95).
12func TestDisabledAccountAPI(t *testing.T) {
13 inst := startInstanceWith(t, "[api]\nenabled = true\n")
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17
18 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json")
19 if code != 0 {
20 t.Fatalf("token create: %s", errOut)
21 }
22 var env struct {
23 Data struct {
24 Token string `json:"token"`
25 } `json:"data"`
26 }
27 if err := json.Unmarshal([]byte(out), &env); err != nil {
28 t.Fatalf("token create output: %v %s", err, out)
29 }
30 token := env.Data.Token
31 if status, _ := inst.apiCall(t, token, []string{"whoami"}, ""); status != 200 {
32 t.Fatalf("token before disable: %d", status)
33 }
34
35 inst.admin(t, "admin", "user", "disable", "alice")
36 if status, _ := inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 {
37 t.Fatalf("disabled account's token still answers: %d, want 401", status)
38 }
39 if status, _ := inst.apiCall(t, token, []string{"repo", "create", "alice/late"}, ""); status != 401 {
40 t.Fatalf("disabled account's token still writes: %d, want 401", status)
41 }
42
43 // Re-enabling restores the account, not the token: it was revoked.
44 inst.admin(t, "admin", "user", "enable", "alice")
45 if status, _ := inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 {
46 t.Fatalf("revoked token answers after enable: %d, want 401", status)
47 }
48 if _, _, code := inst.ssh(t, aliceKey, "", "whoami"); code != 0 {
49 t.Fatalf("re-enabled account refused over ssh: exit %d", code)
50 }
51}
internal/control/control.go +5
@@ -94,6 +94,11 @@ func Dispatch(c *Ctx, argv []string) int {
94 if c.ReadOnly && !cmd.ReadOnly { 94 if c.ReadOnly && !cmd.ReadOnly {
95 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path)) 95 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path))
96 } 96 }
97 // The SSH listener refuses a disabled account before it gets here; the
98 // API and the web reach Dispatch directly, so the check lives here too.
99 if c.User.Disabled {
100 return c.fail(protocol.ExitDenied, "this account is disabled")
101 }
97 if c.User.Pending && !pendingAllowed(cmd.Path) { 102 if c.User.Pending && !pendingAllowed(cmd.Path) {
98 return c.fail(protocol.ExitDenied, 103 return c.fail(protocol.ExitDenied,
99 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") 104 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
internal/store/users.go +7 −1
@@ -183,7 +183,13 @@ func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
183 return ErrNotFound 183 return ErrNotFound
184 } 184 }
185 if disabled { 185 if disabled {
186 _, err = s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID) 186 // Every credential the account holds goes with it: browser
187 // sessions and API tokens. Re-enabling means minting again.
188 for _, table := range []string{"web_sessions", "api_tokens"} {
189 if _, err = s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
190 return err
191 }
192 }
187 } 193 }
188 return err 194 return err
189} 195}