Commit 89dd0a3eba
Verified · cmc
Layout: unified · split
CHANGELOG.org +3
| @@ -9,6 +9,9 @@ anything beyond "replace the binary and restart" is needed. | |||
| 9 | - Removing an SSH key, removing a deploy key, or disabling or deleting an | 9 | - Removing an SSH key, removing a deploy key, or disabling or deleting an |
| 10 | account closes every open connection using an affected key, git | 10 | account closes every open connection using an affected key, git |
| 11 | transports included; every command re-reads its key (#256). | 11 | 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). | ||
| 12 | 15 | ||
| 13 | * v1.36.0 — 2026-09-23 | 16 | * v1.36.0 — 2026-09-23 |
| 14 | 17 | ||
internal/control/adminhost.go +11 −8
| @@ -30,8 +30,9 @@ func init() { | |||
| 30 | {"--verified", "", "mark that address verified", ""}, | 30 | {"--verified", "", "mark that address verified", ""}, |
| 31 | {"--key", "-", "read a public key from stdin", ""}, | 31 | {"--key", "-", "read a public key from stdin", ""}, |
| 32 | }, | 32 | }, |
| 33 | Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, | 33 | Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, |
| 34 | ReadsStdin: true, Run: runAdminUserCreate}) | 34 | ReadsStdin: true, |
| 35 | MintsCredential: true, Run: runAdminUserCreate}) | ||
| 35 | register(Command{Path: []string{"admin", "user", "disable"}, | 36 | register(Command{Path: []string{"admin", "user", "disable"}, |
| 36 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", | 37 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", |
| 37 | Usage: "admin user disable <username>", | 38 | Usage: "admin user disable <username>", |
| @@ -51,18 +52,20 @@ func init() { | |||
| 51 | Examples: []string{"admin user delete alice --yes"}, | 52 | Examples: []string{"admin user delete alice --yes"}, |
| 52 | Run: runAdminUserDelete}) | 53 | Run: runAdminUserDelete}) |
| 53 | register(Command{Path: []string{"admin", "email", "verify"}, | 54 | register(Command{Path: []string{"admin", "email", "verify"}, |
| 54 | Summary: "mark an address verified by admin assertion", | 55 | Summary: "mark an address verified by admin assertion", |
| 55 | Usage: "admin email verify <username> <address>", | 56 | Usage: "admin email verify <username> <address>", |
| 56 | Examples: []string{"admin email verify alice alice@example.org"}, | 57 | Examples: []string{"admin email verify alice alice@example.org"}, |
| 57 | Run: runAdminEmailVerify}) | 58 | MintsCredential: true, |
| 59 | Run: runAdminEmailVerify}) | ||
| 58 | register(Command{Path: []string{"admin", "invite"}, | 60 | register(Command{Path: []string{"admin", "invite"}, |
| 59 | Summary: "issue a registration invite and mail its code", | 61 | Summary: "issue a registration invite and mail its code", |
| 60 | Usage: "admin invite --email <address>", | 62 | Usage: "admin invite --email <address>", |
| 61 | Flags: []Flag{ | 63 | Flags: []Flag{ |
| 62 | {"--email", "<address>", "who the invite is for", ""}, | 64 | {"--email", "<address>", "who the invite is for", ""}, |
| 63 | }, | 65 | }, |
| 64 | Examples: []string{"admin invite --email alice@example.org"}, | 66 | Examples: []string{"admin invite --email alice@example.org"}, |
| 65 | Run: runAdminInvite}) | 67 | MintsCredential: true, |
| 68 | Run: runAdminInvite}) | ||
| 66 | register(Command{Path: []string{"admin", "stats"}, | 69 | register(Command{Path: []string{"admin", "stats"}, |
| 67 | Summary: "instance statistics: counts and per-repository disk usage", | 70 | Summary: "instance statistics: counts and per-repository disk usage", |
| 68 | Usage: "admin stats", | 71 | Usage: "admin stats", |
internal/control/control.go +17 −1
| @@ -40,6 +40,13 @@ type Ctx struct { | |||
| 40 | // Source identifies the credential behind this session for the audit | 40 | // Source identifies the credential behind this session for the audit |
| 41 | // log: an SSH key fingerprint, or "api" for token requests. | 41 | // log: an SSH key fingerprint, or "api" for token requests. |
| 42 | Source string | 42 | 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 | ||
| 43 | // Cmd is the command being run, set by Dispatch, so a usage error can | 50 | // Cmd is the command being run, set by Dispatch, so a usage error can |
| 44 | // print the registered usage rather than a copy of it. | 51 | // print the registered usage rather than a copy of it. |
| 45 | Cmd Command | 52 | Cmd Command |
| @@ -87,7 +94,11 @@ type Command struct { | |||
| 87 | Examples []string // full argv after the program, repository named | 94 | Examples []string // full argv after the program, repository named |
| 88 | ReadsStdin bool | 95 | ReadsStdin bool |
| 89 | ReadOnly bool // safe for read-scoped API tokens | 96 | 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 | ||
| 91 | } | 102 | } |
| 92 | 103 | ||
| 93 | var registry []Command | 104 | var registry []Command |
| @@ -159,6 +170,11 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 159 | if c.ReadOnly && !cmd.ReadOnly { | 170 | if c.ReadOnly && !cmd.ReadOnly { |
| 160 | return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) | 171 | return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) |
| 161 | } | 172 | } |
| 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 | } | ||
| 162 | // The SSH listener refuses a disabled account before it gets here; the | 178 | // The SSH listener refuses a disabled account before it gets here; the |
| 163 | // API and the web reach Dispatch directly, so the check lives here too. | 179 | // API and the web reach Dispatch directly, so the check lives here too. |
| 164 | if c.User.Disabled { | 180 | if c.User.Disabled { |
internal/control/deploykey.go +3 −2
| @@ -19,8 +19,9 @@ func init() { | |||
| 19 | Flags: []Flag{ | 19 | Flags: []Flag{ |
| 20 | {"--rw", "", "the key may push, not just fetch", ""}, | 20 | {"--rw", "", "the key may push, not just fetch", ""}, |
| 21 | }, | 21 | }, |
| 22 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub"}, | 22 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub"}, |
| 23 | ReadsStdin: true, Run: runDeployKeyAdd}) | 23 | ReadsStdin: true, |
| 24 | MintsCredential: true, Run: runDeployKeyAdd}) | ||
| 24 | register(Command{Path: []string{"repo", "deploy-key", "list"}, | 25 | register(Command{Path: []string{"repo", "deploy-key", "list"}, |
| 25 | Summary: "list deploy keys", | 26 | Summary: "list deploy keys", |
| 26 | Usage: "repo deploy-key list <owner/name>", | 27 | Usage: "repo deploy-key list <owner/name>", |
internal/control/identity.go +4 −3
| @@ -38,9 +38,10 @@ func init() { | |||
| 38 | {"--scope", "full|git|runner", "what the key may do", "full"}, | 38 | {"--scope", "full|git|runner", "what the key may do", "full"}, |
| 39 | {"--label", "<text>", "a name for the key", ""}, | 39 | {"--label", "<text>", "a name for the key", ""}, |
| 40 | }, | 40 | }, |
| 41 | Examples: []string{"keys add --label laptop < key.pub"}, | 41 | Examples: []string{"keys add --label laptop < key.pub"}, |
| 42 | ReadsStdin: true, | 42 | ReadsStdin: true, |
| 43 | Run: runKeysAdd, | 43 | MintsCredential: true, |
| 44 | Run: runKeysAdd, | ||
| 44 | }) | 45 | }) |
| 45 | register(Command{ | 46 | register(Command{ |
| 46 | Path: []string{"keys", "label"}, | 47 | Path: []string{"keys", "label"}, |
internal/control/register.go +4 −3
| @@ -36,9 +36,10 @@ func init() { | |||
| 36 | Usage: "email add <address>", | 36 | Usage: "email add <address>", |
| 37 | Examples: []string{"email add cmc@example.org"}, Run: runEmailAdd}) | 37 | Examples: []string{"email add cmc@example.org"}, Run: runEmailAdd}) |
| 38 | register(Command{Path: []string{"email", "verify"}, | 38 | register(Command{Path: []string{"email", "verify"}, |
| 39 | Summary: "confirm a verification code", | 39 | Summary: "confirm a verification code", |
| 40 | Usage: "email verify <code>", | 40 | Usage: "email verify <code>", |
| 41 | Examples: []string{"email verify abc123"}, Run: runEmailVerify}) | 41 | MintsCredential: true, |
| 42 | Examples: []string{"email verify abc123"}, Run: runEmailVerify}) | ||
| 42 | register(Command{Path: []string{"email", "list"}, | 43 | register(Command{Path: []string{"email", "list"}, |
| 43 | Summary: "list the addresses on your account", | 44 | Summary: "list the addresses on your account", |
| 44 | Usage: "email list", | 45 | Usage: "email list", |
internal/control/runnerrepo.go +5 −4
| @@ -19,10 +19,11 @@ import ( | |||
| 19 | // read-only git. | 19 | // read-only git. |
| 20 | func init() { | 20 | func init() { |
| 21 | register(Command{Path: []string{"repo", "runner", "add"}, | 21 | register(Command{Path: []string{"repo", "runner", "add"}, |
| 22 | Summary: "attach a runner's public key to a repository", | 22 | Summary: "attach a runner's public key to a repository", |
| 23 | Usage: "repo runner add <owner/name> < key.pub", | 23 | Usage: "repo runner add <owner/name> < key.pub", |
| 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, | 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, |
| 25 | ReadsStdin: true, Run: runRepoRunnerAdd}) | 25 | ReadsStdin: true, |
| 26 | MintsCredential: true, Run: runRepoRunnerAdd}) | ||
| 26 | register(Command{Path: []string{"repo", "runner", "list"}, | 27 | register(Command{Path: []string{"repo", "runner", "list"}, |
| 27 | Summary: "list the runners attached to a repository", | 28 | Summary: "list the runners attached to a repository", |
| 28 | Usage: "repo runner list <owner/name>", | 29 | Usage: "repo runner list <owner/name>", |
internal/control/token.go +5 −4
| @@ -21,8 +21,9 @@ func init() { | |||
| 21 | {"--scope", "full|read", "what the token may do", "full"}, | 21 | {"--scope", "full|read", "what the token may do", "full"}, |
| 22 | {"--ttl", "30d|720h", "how long the token is valid", "never expires"}, | 22 | {"--ttl", "30d|720h", "how long the token is valid", "never expires"}, |
| 23 | }, | 23 | }, |
| 24 | Examples: []string{"token create --name laptop --scope read --ttl 30d"}, | 24 | Examples: []string{"token create --name laptop --scope read --ttl 30d"}, |
| 25 | Run: runTokenCreate}) | 25 | MintsCredential: true, |
| 26 | Run: runTokenCreate}) | ||
| 26 | register(Command{Path: []string{"token", "list"}, | 27 | register(Command{Path: []string{"token", "list"}, |
| 27 | Summary: "list API tokens", | 28 | Summary: "list API tokens", |
| 28 | Usage: "token list", | 29 | Usage: "token list", |
| @@ -73,7 +74,7 @@ func runTokenCreate(c *Ctx, args []string) int { | |||
| 73 | } | 74 | } |
| 74 | // The gb_ prefix makes leaked tokens findable by secret scanners. | 75 | // The gb_ prefix makes leaked tokens findable by secret scanners. |
| 75 | token := "gb_" + raw | 76 | 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 { |
| 77 | return c.failErr(err) | 78 | return c.failErr(err) |
| 78 | } | 79 | } |
| 79 | type out struct { | 80 | type out struct { |
| @@ -125,7 +126,7 @@ func runTokenRevoke(c *Ctx, args []string) int { | |||
| 125 | if len(args) != 1 { | 126 | if len(args) != 1 { |
| 126 | return c.usage() | 127 | return c.usage() |
| 127 | } | 128 | } |
| 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 { |
| 129 | if errors.Is(err, store.ErrNotFound) { | 130 | if errors.Is(err, store.ErrNotFound) { |
| 130 | return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) | 131 | return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) |
| 131 | } | 132 | } |
internal/control/token_test.go added +72
| @@ -0,0 +1,72 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 16 | func 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. | ||
| 34 | func 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 | |||
| 48 | func 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() | |||
| 14 | 14 | ||
| 15 | func init() { | 15 | func init() { |
| 16 | register(Command{Path: []string{"web", "login"}, | 16 | register(Command{Path: []string{"web", "login"}, |
| 17 | Summary: "mint a one-time browser login URL", | 17 | Summary: "mint a one-time browser login URL", |
| 18 | Usage: "web login", | 18 | Usage: "web login", |
| 19 | Examples: []string{"web login"}, Run: runWebLogin}) | 19 | MintsCredential: true, |
| 20 | Examples: []string{"web login"}, Run: runWebLogin}) | ||
| 20 | register(Command{Path: []string{"web", "sessions", "list"}, | 21 | register(Command{Path: []string{"web", "sessions", "list"}, |
| 21 | Summary: "list your browser sessions", | 22 | Summary: "list your browser sessions", |
| 22 | Usage: "web sessions list", | 23 | Usage: "web sessions list", |
internal/httpd/api.go +10 −8
| @@ -28,7 +28,7 @@ const maxAPIBody = 1 << 20 | |||
| 28 | // semantics. Exit codes map onto HTTP statuses; the body is the command's | 28 | // semantics. Exit codes map onto HTTP statuses; the body is the command's |
| 29 | // JSON envelope with exit_code added. | 29 | // JSON envelope with exit_code added. |
| 30 | func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | 30 | func (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) |
| 32 | if !ok { | 32 | if !ok { |
| 33 | return | 33 | return |
| 34 | } | 34 | } |
| @@ -72,7 +72,9 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | |||
| 72 | Stderr: &stderr, | 72 | Stderr: &stderr, |
| 73 | JSON: true, | 73 | JSON: true, |
| 74 | ViaAPI: true, | 74 | ViaAPI: true, |
| 75 | ReadOnly: scope == "read", | 75 | ReadOnly: tok.Scope == "read", |
| 76 | TokenID: tok.ID, | ||
| 77 | Expires: tok.ExpiresAt, | ||
| 76 | Done: s.until(r), | 78 | Done: s.until(r), |
| 77 | Stopping: s.stopping, | 79 | Stopping: s.stopping, |
| 78 | } | 80 | } |
| @@ -124,23 +126,23 @@ func (s *Server) limitKey(r *http.Request, user store.User) string { | |||
| 124 | } | 126 | } |
| 125 | 127 | ||
| 126 | // apiAuth resolves the bearer token; failures are uniform 401s. | 128 | // apiAuth resolves the bearer token; failures are uniform 401s. |
| 127 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) { | 129 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) { |
| 128 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") | 130 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") |
| 129 | if !ok || token == "" { | 131 | if !ok || token == "" { |
| 130 | w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) | 132 | w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) |
| 131 | apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") | 133 | 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 |
| 133 | } | 135 | } |
| 134 | user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) | 136 | user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) |
| 135 | if err != nil { | 137 | if err != nil { |
| 136 | if errors.Is(err, store.ErrNotFound) { | 138 | if errors.Is(err, store.ErrNotFound) { |
| 137 | apiError(w, http.StatusUnauthorized, "invalid or expired token") | 139 | apiError(w, http.StatusUnauthorized, "invalid or expired token") |
| 138 | return store.User{}, "", false | 140 | return store.User{}, store.APIToken{}, false |
| 139 | } | 141 | } |
| 140 | apiError(w, http.StatusInternalServerError, "internal error") | 142 | apiError(w, http.StatusInternalServerError, "internal error") |
| 141 | return store.User{}, "", false | 143 | return store.User{}, store.APIToken{}, false |
| 142 | } | 144 | } |
| 143 | return user, scope, true | 145 | return user, tok, true |
| 144 | } | 146 | } |
| 145 | 147 | ||
| 146 | func apiError(w http.ResponseWriter, status int, msg string) { | 148 | func apiError(w http.ResponseWriter, status int, msg string) { |