Commit 89dd0a3eba

89dd0a3ebae349ef99ea66c5eedb8bf8792a8eda

parent: 4b94fa96a1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:55 UTC

control: expiring credentials cannot run credential-minting commands

Ref #257

Layout: unified · split

CHANGELOG.org +3
@@ -9,6 +9,9 @@ anything beyond "replace the binary and restart" is needed.
99- Removing an SSH key, removing a deploy key, or disabling or deleting an
1010 account closes every open connection using an affected key, git
1111 transports included; every command re-reads its key (#256).
12- A request whose credential has an expiry cannot run a command that
13 mints another one — tokens, keys, login links, invites, accounts,
14 verified addresses (#257).
1215
1316* v1.36.0 — 2026-09-23
1417
internal/control/adminhost.go +11 −8
@@ -30,8 +30,9 @@ func init() {
3030 {"--verified", "", "mark that address verified", ""},
3131 {"--key", "-", "read a public key from stdin", ""},
3232 },
33 Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"},
34 ReadsStdin: true, Run: runAdminUserCreate})
33 Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"},
34 ReadsStdin: true,
35 MintsCredential: true, Run: runAdminUserCreate})
3536 register(Command{Path: []string{"admin", "user", "disable"},
3637 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
3738 Usage: "admin user disable <username>",
@@ -51,18 +52,20 @@ func init() {
5152 Examples: []string{"admin user delete alice --yes"},
5253 Run: runAdminUserDelete})
5354 register(Command{Path: []string{"admin", "email", "verify"},
54 Summary: "mark an address verified by admin assertion",
55 Usage: "admin email verify <username> <address>",
56 Examples: []string{"admin email verify alice alice@example.org"},
57 Run: runAdminEmailVerify})
55 Summary: "mark an address verified by admin assertion",
56 Usage: "admin email verify <username> <address>",
57 Examples: []string{"admin email verify alice alice@example.org"},
58 MintsCredential: true,
59 Run: runAdminEmailVerify})
5860 register(Command{Path: []string{"admin", "invite"},
5961 Summary: "issue a registration invite and mail its code",
6062 Usage: "admin invite --email <address>",
6163 Flags: []Flag{
6264 {"--email", "<address>", "who the invite is for", ""},
6365 },
64 Examples: []string{"admin invite --email alice@example.org"},
65 Run: runAdminInvite})
66 Examples: []string{"admin invite --email alice@example.org"},
67 MintsCredential: true,
68 Run: runAdminInvite})
6669 register(Command{Path: []string{"admin", "stats"},
6770 Summary: "instance statistics: counts and per-repository disk usage",
6871 Usage: "admin stats",
internal/control/control.go +17 −1
@@ -40,6 +40,13 @@ type Ctx struct {
4040 // Source identifies the credential behind this session for the audit
4141 // log: an SSH key fingerprint, or "api" for token requests.
4242 Source string
43 // TokenID is the API token behind this request, 0 for none. A
44 // credential the request creates records it.
45 TokenID int64
46 // Expires is when the credential behind this request lapses; nil
47 // when it does not. Dispatch refuses MintsCredential commands when
48 // it is set.
49 Expires *time.Time
4350 // Cmd is the command being run, set by Dispatch, so a usage error can
4451 // print the registered usage rather than a copy of it.
4552 Cmd Command
@@ -87,7 +94,11 @@ type Command struct {
8794 Examples []string // full argv after the program, repository named
8895 ReadsStdin bool
8996 ReadOnly bool // safe for read-scoped API tokens
90 Run func(c *Ctx, args []string) int
97 // MintsCredential marks a command that creates a credential or a way
98 // to obtain one: tokens, keys, login links, invites, accounts,
99 // verified addresses. An expiring credential may not run it.
100 MintsCredential bool
101 Run func(c *Ctx, args []string) int
91102}
92103
93104var registry []Command
@@ -159,6 +170,11 @@ func Dispatch(c *Ctx, argv []string) int {
159170 if c.ReadOnly && !cmd.ReadOnly {
160171 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
161172 }
173 // What an expiring credential creates would outlive it (#257).
174 if cmd.MintsCredential && c.Expires != nil {
175 return c.fail(protocol.ExitDenied,
176 "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
177 }
162178 // The SSH listener refuses a disabled account before it gets here; the
163179 // API and the web reach Dispatch directly, so the check lives here too.
164180 if c.User.Disabled {
internal/control/deploykey.go +3 −2
@@ -19,8 +19,9 @@ func init() {
1919 Flags: []Flag{
2020 {"--rw", "", "the key may push, not just fetch", ""},
2121 },
22 Examples: []string{"repo deploy-key add krz/gitbay < key.pub"},
23 ReadsStdin: true, Run: runDeployKeyAdd})
22 Examples: []string{"repo deploy-key add krz/gitbay < key.pub"},
23 ReadsStdin: true,
24 MintsCredential: true, Run: runDeployKeyAdd})
2425 register(Command{Path: []string{"repo", "deploy-key", "list"},
2526 Summary: "list deploy keys",
2627 Usage: "repo deploy-key list <owner/name>",
internal/control/identity.go +4 −3
@@ -38,9 +38,10 @@ func init() {
3838 {"--scope", "full|git|runner", "what the key may do", "full"},
3939 {"--label", "<text>", "a name for the key", ""},
4040 },
41 Examples: []string{"keys add --label laptop < key.pub"},
42 ReadsStdin: true,
43 Run: runKeysAdd,
41 Examples: []string{"keys add --label laptop < key.pub"},
42 ReadsStdin: true,
43 MintsCredential: true,
44 Run: runKeysAdd,
4445 })
4546 register(Command{
4647 Path: []string{"keys", "label"},
internal/control/register.go +4 −3
@@ -36,9 +36,10 @@ func init() {
3636 Usage: "email add <address>",
3737 Examples: []string{"email add cmc@example.org"}, Run: runEmailAdd})
3838 register(Command{Path: []string{"email", "verify"},
39 Summary: "confirm a verification code",
40 Usage: "email verify <code>",
41 Examples: []string{"email verify abc123"}, Run: runEmailVerify})
39 Summary: "confirm a verification code",
40 Usage: "email verify <code>",
41 MintsCredential: true,
42 Examples: []string{"email verify abc123"}, Run: runEmailVerify})
4243 register(Command{Path: []string{"email", "list"},
4344 Summary: "list the addresses on your account",
4445 Usage: "email list",
internal/control/runnerrepo.go +5 −4
@@ -19,10 +19,11 @@ import (
1919// read-only git.
2020func init() {
2121 register(Command{Path: []string{"repo", "runner", "add"},
22 Summary: "attach a runner's public key to a repository",
23 Usage: "repo runner add <owner/name> < key.pub",
24 Examples: []string{"repo runner add krz/gitbay < key.pub"},
25 ReadsStdin: true, Run: runRepoRunnerAdd})
22 Summary: "attach a runner's public key to a repository",
23 Usage: "repo runner add <owner/name> < key.pub",
24 Examples: []string{"repo runner add krz/gitbay < key.pub"},
25 ReadsStdin: true,
26 MintsCredential: true, Run: runRepoRunnerAdd})
2627 register(Command{Path: []string{"repo", "runner", "list"},
2728 Summary: "list the runners attached to a repository",
2829 Usage: "repo runner list <owner/name>",
internal/control/token.go +5 −4
@@ -21,8 +21,9 @@ func init() {
2121 {"--scope", "full|read", "what the token may do", "full"},
2222 {"--ttl", "30d|720h", "how long the token is valid", "never expires"},
2323 },
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"},
25 Run: runTokenCreate})
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"},
25 MintsCredential: true,
26 Run: runTokenCreate})
2627 register(Command{Path: []string{"token", "list"},
2728 Summary: "list API tokens",
2829 Usage: "token list",
@@ -73,7 +74,7 @@ func runTokenCreate(c *Ctx, args []string) int {
7374 }
7475 // The gb_ prefix makes leaked tokens findable by secret scanners.
7576 token := "gb_" + raw
76 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
77 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
7778 return c.failErr(err)
7879 }
7980 type out struct {
@@ -125,7 +126,7 @@ func runTokenRevoke(c *Ctx, args []string) int {
125126 if len(args) != 1 {
126127 return c.usage()
127128 }
128 if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil {
129 if _, err := c.Store.RevokeAPIToken(c.User.ID, args[0], false); err != nil {
129130 if errors.Is(err, store.ErrNotFound) {
130131 return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
131132 }
internal/control/token_test.go added +72
@@ -0,0 +1,72 @@
1package control
2
3import (
4 "bytes"
5 "slices"
6 "strings"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// The minting commands, pinned: adding one to the list, or dropping
15// one, is a decision this test makes someone take.
16func TestMintingCommandsMarked(t *testing.T) {
17 want := []string{
18 "admin email verify", "admin invite", "admin user create", "email verify",
19 "keys add", "repo deploy-key add", "repo runner add", "token create", "web login",
20 }
21 var got []string
22 for _, cmd := range Commands() {
23 if cmd.MintsCredential {
24 got = append(got, joinPath(cmd.Path))
25 }
26 }
27 slices.Sort(got)
28 if !slices.Equal(got, want) {
29 t.Fatalf("MintsCredential on %q, want %q", got, want)
30 }
31}
32
33// Dispatch refuses before the command runs, so no arguments are needed.
34func TestExpiringCredentialCannotMint(t *testing.T) {
35 exp := time.Now().Add(time.Hour)
36 for _, cmd := range Commands() {
37 if !cmd.MintsCredential {
38 continue
39 }
40 var out, errOut bytes.Buffer
41 c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut}
42 if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") {
43 t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied)
44 }
45 }
46}
47
48func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
49 st, _, uid := newQueueTestRepo(t)
50 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
51 t.Fatal(err)
52 }
53 _, parent, err := st.APITokenUser("h-parent")
54 if err != nil {
55 t.Fatal(err)
56 }
57 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
58 c.Cfg.Limits.WriteRate = -1
59 c.TokenID = parent.ID
60 if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK {
61 t.Fatalf("exit %d: %s", code, errOut)
62 }
63 toks, err := st.ListAPITokens(uid)
64 if err != nil {
65 t.Fatal(err)
66 }
67 for _, tk := range toks {
68 if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") {
69 t.Fatalf("child: %+v", tk)
70 }
71 }
72}
internal/control/web.go +4 −3
@@ -14,9 +14,10 @@ func newStoredToken() (token, hash string, err error) { return store.NewToken()
1414
1515func init() {
1616 register(Command{Path: []string{"web", "login"},
17 Summary: "mint a one-time browser login URL",
18 Usage: "web login",
19 Examples: []string{"web login"}, Run: runWebLogin})
17 Summary: "mint a one-time browser login URL",
18 Usage: "web login",
19 MintsCredential: true,
20 Examples: []string{"web login"}, Run: runWebLogin})
2021 register(Command{Path: []string{"web", "sessions", "list"},
2122 Summary: "list your browser sessions",
2223 Usage: "web sessions list",
internal/httpd/api.go +10 −8
@@ -28,7 +28,7 @@ const maxAPIBody = 1 << 20
2828// semantics. Exit codes map onto HTTP statuses; the body is the command's
2929// JSON envelope with exit_code added.
3030func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
31 user, scope, ok := s.apiAuth(w, r)
31 user, tok, ok := s.apiAuth(w, r)
3232 if !ok {
3333 return
3434 }
@@ -72,7 +72,9 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) {
7272 Stderr: &stderr,
7373 JSON: true,
7474 ViaAPI: true,
75 ReadOnly: scope == "read",
75 ReadOnly: tok.Scope == "read",
76 TokenID: tok.ID,
77 Expires: tok.ExpiresAt,
7678 Done: s.until(r),
7779 Stopping: s.stopping,
7880 }
@@ -124,23 +126,23 @@ func (s *Server) limitKey(r *http.Request, user store.User) string {
124126}
125127
126128// apiAuth resolves the bearer token; failures are uniform 401s.
127func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) {
129func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
128130 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
129131 if !ok || token == "" {
130132 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
131133 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
132 return store.User{}, "", false
134 return store.User{}, store.APIToken{}, false
133135 }
134 user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
136 user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
135137 if err != nil {
136138 if errors.Is(err, store.ErrNotFound) {
137139 apiError(w, http.StatusUnauthorized, "invalid or expired token")
138 return store.User{}, "", false
140 return store.User{}, store.APIToken{}, false
139141 }
140142 apiError(w, http.StatusInternalServerError, "internal error")
141 return store.User{}, "", false
143 return store.User{}, store.APIToken{}, false
142144 }
143 return user, scope, true
145 return user, tok, true
144146}
145147
146148func apiError(w http.ResponseWriter, status int, msg string) {