token: expiring credentials cannot mint; record creator; default read !483

merged merged by cmc on 2026-09-28 21:48 UTC · krz/gitbay:token-delegation into main

25 files changed, +564 −104

Layout: unified · split

.gitbay/wiki/API.org +14 −6
@@ -14,18 +14,26 @@ enabled = true
14** Tokens 14** Tokens
15 15
16Tokens are minted wherever the registry is reached: over SSH, on the 16Tokens are minted wherever the registry is reached: over SSH, on the
17API, anywhere. A full-scope token can mint another, which is what full 17API, anywhere. =token create= makes a =read= token unless =--scope full=
18scope means; a read-scoped one cannot, because minting is a write. The 18is given; a read token runs only commands marked read-only. A full-scope
19controls here are scope, TTL and revocation, not which door a request 19token can mint another, but a token with a =--ttl= cannot run any
20arrived through (#234). Give a token the narrowest scope and shortest 20command that creates a credential — =token create=, =keys add=,
21=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
22=admin user create=, =email verify=, =admin email verify= — since what
23it made would outlive it. Give a token the narrowest scope and shortest
21TTL that does its job, and revoke it when the job is over. 24TTL that does its job, and revoke it when the job is over.
22 25
23#+begin_src sh 26#+begin_src sh
24gitbay auth token create --name ci [--scope full|read] [--ttl 30d] 27gitbay auth token create --name ci [--scope read|full] [--ttl 30d]
25gitbay auth token list 28gitbay auth token list
26gitbay auth token revoke ci 29gitbay auth token revoke ci [--created]
27#+end_src 30#+end_src
28 31
32Tokens and keys record the token they were created through. =token
33revoke= prints what the token created, at any depth; with =--created=
34it revokes those too, and their SSH connections close. Without it they
35stay and the link is dropped.
36
29The token (prefix =gb_=, shown exactly once) is presented as 37The token (prefix =gb_=, shown exactly once) is presented as
30=Authorization: Bearer gb_...=. Only a hash is stored server-side. 38=Authorization: Bearer gb_...=. Only a hash is stored server-side.
31Scope =read= permits list/show/log/diff-style commands and refuses 39Scope =read= permits list/show/log/diff-style commands and refuses
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1 −1
@@ -17,7 +17,7 @@
17|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------| 17|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------|
18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys); closes its connections | 18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys); closes its connections |
19| Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | none | =repo deploy-key remove= (repo admin); closes its connections | 19| Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | none | =repo deploy-key remove= (repo admin); closes its connections |
20| API token | =gb_= + 32 random bytes hex | SHA-256 hash | =full= or =read= | optional =--ttl= | =token revoke= | 20| API token | =gb_= + 32 random bytes hex | SHA-256 hash | =read= (default) or =full=; with an expiry, no credential-minting command | optional =--ttl= | =token revoke [--created]= |
21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= | 21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= |
22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | 22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use |
23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | 23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -26,7 +26,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
26| Session lifetime | partial | 7 days absolute, no idle timeout (#276) | 26| Session lifetime | partial | 7 days absolute, no idle timeout (#276) |
27| Credential expiry | partial | API tokens optional; SSH and deploy keys none (#277) | 27| Credential expiry | partial | API tokens optional; SSH and deploy keys none (#277) |
28| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) | 28| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) |
29| Delegation bounded by the delegating credential | gap | expiring tokens can mint lasting credentials (#257) | 29| Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) |
30 30
31** Access control (V4) 31** Access control (V4)
32 32
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −5
@@ -11,7 +11,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
11| Issue | Area | Gap | Severity | 11| Issue | Area | Gap | Severity |
12|-------+------------------+-----------------------------------------------------------------------+----------| 12|-------+------------------+-----------------------------------------------------------------------+----------|
13| #255 | CI isolation | Untrusted and trusted builds of a repository share a writable build home | high | 13| #255 | CI isolation | Untrusted and trusted builds of a repository share a writable build home | high |
14| #257 | Credentials | An expiring token can create credentials that outlive it; tokens default to full scope | high |
15| #258 | CI integrity | Any writer can post a =ci/*= status; tree reuse ignores trust and image | high | 14| #258 | CI integrity | Any writer can post a =ci/*= status; tree reuse ignores trust and image | high |
16| #259 | Recovery | No restore has been exercised; verification does not check git connectivity | high | 15| #259 | Recovery | No restore has been exercised; verification does not check git connectivity | high |
17| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 16| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
@@ -27,10 +26,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
27| #280 | Mail | STARTTLS only when the relay offers it | medium | 26| #280 | Mail | STARTTLS only when the relay offers it | medium |
28| #281 | TLS | No explicit minimum TLS version | low | 27| #281 | TLS | No explicit minimum TLS version | low |
29| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium | 28| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
30 29| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
31Decisions already taken on these: #257 refuses credential
32creation from expiring tokens, records which token created each
33credential, and makes =read= the default scope.
34 30
35* Questions an auditor will ask that have no answer yet 31* Questions an auditor will ask that have no answer yet
36 32
.gitbay/wiki/Parity.org +1
@@ -356,6 +356,7 @@ client has no use for one (krz/gitbay#57).
356| activity push on, off | yes | yes | yes | 356| activity push on, off | yes | yes | yes |
357| web colour scheme | yes | yes | n/a | 357| web colour scheme | yes | yes | n/a |
358| API token mint | yes | no | no | 358| API token mint | yes | no | no |
359| API token revoke with what it created | yes | no | no |
359| account export bundle | yes | yes | n/a | 360| account export bundle | yes | yes | n/a |
360| profile set | yes | yes | yes | 361| profile set | yes | yes | yes |
361| write the profile about | yes | yes | yes | 362| write the profile about | yes | yes | yes |
.gitbay/wiki/Threat-Model.org +4 −2
@@ -52,8 +52,10 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite
52 OpenSSH and fronted unchanged by the JSON API and the web. No command 52 OpenSSH and fronted unchanged by the JSON API and the web. No command
53 belongs to one surface (#234): what a caller may do is the account's 53 belongs to one surface (#234): what a caller may do is the account's
54 rights narrowed by its credential's scope, decided in one place, so a 54 rights narrowed by its credential's scope, decided in one place, so a
55 bearer token is worth exactly its scope and no more. Git transport 55 bearer token is worth exactly its scope and no more, and a token or
56 never runs over the API. 56 SSH key with an expiry cannot create a credential that outlives it.
57 Browser sessions are not covered yet (#297). Git transport never runs
58 over the API.
57- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where 59- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
58 enabled, the read-only web UI — carry no credentials and expose only 60 enabled, the read-only web UI — carry no credentials and expose only
59 public data. HTTP push is refused via a pkt-line =ERR=, never a 401. 61 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
CHANGELOG.org +16 −5
@@ -4,11 +4,22 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* unreleased 7* Unreleased
8 8
9- Removing an SSH key, removing a deploy key, or disabling or deleting an 9Credentials: revocation and delegation (#256, #257).
10 account closes every open connection using an affected key, git 10
11 transports included; every command re-reads its key (#256). 11*Upgrade note.* =token create= makes a =read= token unless given
12=--scope full=. A script that mints a token and then writes with it
13must add =--scope full=. Existing tokens keep their scope.
14
15- A token with a =--ttl= is refused on every command that creates a
16 credential: tokens, keys, deploy keys, runner keys, login links,
17 invites, accounts and verified addresses (#257).
18- Tokens and SSH keys record the token they were created through.
19 =token revoke <name>= lists what it created; =--created= revokes
20 those too (#257).
21- Removing an SSH key, a deploy key, or disabling an account closes the
22 connections the key opened, a push in flight included (#256).
12 23
13* v1.36.0 — 2026-09-23 24* v1.36.0 — 2026-09-23
14 25
e2e/api_test.go +2 −2
@@ -47,7 +47,7 @@ func TestJSONAPI(t *testing.T) {
47 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") 47 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
48 48
49 // Tokens are minted over SSH, shown once. 49 // Tokens are minted over SSH, shown once.
50 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json") 50 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--scope", "full", "--json")
51 if code != 0 { 51 if code != 0 {
52 t.Fatalf("token create: %s", errOut) 52 t.Fatalf("token create: %s", errOut)
53 } 53 }
@@ -265,7 +265,7 @@ func TestAPIRateLimit(t *testing.T) {
265// mintToken creates an API token over SSH and returns its value. 265// mintToken creates an API token over SSH and returns its value.
266func mintToken(t *testing.T, inst *instance, key, name string) string { 266func mintToken(t *testing.T, inst *instance, key, name string) string {
267 t.Helper() 267 t.Helper()
268 out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--json") 268 out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--scope", "full", "--json")
269 if code != 0 { 269 if code != 0 {
270 t.Fatalf("token create: %s", errOut) 270 t.Fatalf("token create: %s", errOut)
271 } 271 }
e2e/tokenorigin_test.go added +74
@@ -0,0 +1,74 @@
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "strings"
8 "testing"
9)
10
11// An expiring token cannot mint a credential that outlives it, and
12// revoking a token can take what it created with it (#257).
13func TestTokenDelegation(t *testing.T) {
14 t.Parallel()
15 inst := startInstanceWith(t, "[api]\nenabled = true\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
18
19 mint := func(args ...string) (token, scope string) {
20 t.Helper()
21 out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"token", "create", "--json"}, args...)...)
22 if code != 0 {
23 t.Fatalf("token create %v: %s", args, errOut)
24 }
25 var env struct {
26 Data struct {
27 Token string `json:"token"`
28 Scope string `json:"scope"`
29 } `json:"data"`
30 }
31 if err := json.Unmarshal([]byte(out), &env); err != nil {
32 t.Fatalf("token create output: %v %s", err, out)
33 }
34 return env.Data.Token, env.Data.Scope
35 }
36 if _, scope := mint("--name", "plain"); scope != "read" {
37 t.Fatalf("default scope %q, want read", scope)
38 }
39 brief, _ := mint("--name", "brief", "--scope", "full", "--ttl", "1h")
40 lasting, _ := mint("--name", "lasting", "--scope", "full")
41
42 spare := inst.newKey(t, "spare")
43 pub, err := os.ReadFile(spare + ".pub")
44 if err != nil {
45 t.Fatal(err)
46 }
47 status, body := inst.apiCall(t, brief, []string{"keys", "add"}, string(pub))
48 if status != 403 || !strings.Contains(fmt.Sprint(body["error"]), "expires") {
49 t.Fatalf("expiring token added a key: %d %v", status, body)
50 }
51 if status, _ := inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
52 t.Fatalf("expiring token refused a read: %d", status)
53 }
54 if status, body := inst.apiCall(t, lasting, []string{"keys", "add"}, string(pub)); status != 200 {
55 t.Fatalf("keys add: %d %v", status, body)
56 }
57 if status, body := inst.apiCall(t, lasting, []string{"token", "create", "--name", "child"}, ""); status != 200 {
58 t.Fatalf("token create: %d %v", status, body)
59 }
60 if _, errOut, code := inst.ssh(t, spare, "", "whoami"); code != 0 {
61 t.Fatalf("the added key does not work: %s", errOut)
62 }
63
64 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "revoke", "lasting", "--created")
65 if code != 0 || !strings.Contains(out, "token child") || !strings.Contains(out, fingerprint(t, spare+".pub")) {
66 t.Fatalf("revoke --created: exit %d\n%s%s", code, out, errOut)
67 }
68 if _, _, code := inst.ssh(t, spare, "", "whoami"); code == 0 {
69 t.Fatal("a key the revoked token created still works")
70 }
71 if out, _, _ := inst.ssh(t, aliceKey, "", "token", "list"); strings.Contains(out, "child") {
72 t.Fatalf("the child token survived:\n%s", out)
73 }
74}
internal/control/adminhost.go +12 −9
@@ -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",
@@ -122,7 +125,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
122 if pub != nil { 125 if pub != nil {
123 fp = ssh.FingerprintSHA256(pub) 126 fp = ssh.FingerprintSHA256(pub)
124 label, _ := keyLabel(comment) 127 label, _ := keyLabel(comment)
125 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", label); err != nil { 128 if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
126 return c.failErr(err) 129 return c.failErr(err)
127 } 130 }
128 } 131 }
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
93var registry []Command 104var 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 +4 −3
@@ -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>",
@@ -68,7 +69,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
68 } 69 }
69 fp := ssh.FingerprintSHA256(pub) 70 fp := ssh.FingerprintSHA256(pub)
70 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) 71 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode)
71 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label); err != nil { 72 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
72 if errors.Is(err, store.ErrDuplicateKey) { 73 if errors.Is(err, store.ErrDuplicateKey) {
73 return c.failErr(err) 74 return c.failErr(err)
74 } 75 }
internal/control/identity.go +7 −5
@@ -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"},
@@ -86,10 +87,11 @@ func runKeysList(c *Ctx, args []string) int {
86 Algo string `json:"algo"` 87 Algo string `json:"algo"`
87 Scope string `json:"scope"` 88 Scope string `json:"scope"`
88 Label string `json:"label"` 89 Label string `json:"label"`
90 CreatedBy string `json:"created_by,omitempty"`
89 } 91 }
90 var ds []out 92 var ds []out
91 for _, k := range keys { 93 for _, k := range keys {
92 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label}) 94 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy})
93 } 95 }
94 return c.emit(ds, func(w io.Writer) { 96 return c.emit(ds, func(w io.Writer) {
95 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") 97 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
@@ -149,7 +151,7 @@ func runKeysAdd(c *Ctx, args []string) int {
149 return c.fail(protocol.ExitUsage, "%v", err) 151 return c.fail(protocol.ExitUsage, "%v", err)
150 } 152 }
151 fp := ssh.FingerprintSHA256(pub) 153 fp := ssh.FingerprintSHA256(pub)
152 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label); err != nil { 154 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
153 if errors.Is(err, store.ErrDuplicateKey) { 155 if errors.Is(err, store.ErrDuplicateKey) {
154 return c.failErr(err) 156 return c.failErr(err)
155 } 157 }
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 +6 −5
@@ -19,10 +19,11 @@ import (
19// read-only git. 19// read-only git.
20func init() { 20func 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>",
@@ -57,7 +58,7 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
57 switch { 58 switch {
58 case errors.Is(err, store.ErrNotFound): 59 case errors.Is(err, store.ErrNotFound):
59 label, _ := keyLabel(comment) 60 label, _ := keyLabel(comment)
60 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label); err != nil { 61 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
61 return c.fail(protocol.ExitFailure, "adding key: %v", err) 62 return c.fail(protocol.ExitFailure, "adding key: %v", err)
62 } 63 }
63 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { 64 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
internal/control/token.go +49 −17
@@ -15,22 +15,26 @@ import (
15func init() { 15func init() {
16 register(Command{Path: []string{"token", "create"}, 16 register(Command{Path: []string{"token", "create"},
17 Summary: "mint an API token (shown once)", 17 Summary: "mint an API token (shown once)",
18 Usage: "token create --name <n> [--scope full|read] [--ttl 30d|720h]", 18 Usage: "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
19 Flags: []Flag{ 19 Flags: []Flag{
20 {"--name", "<n>", "the token's name", ""}, 20 {"--name", "<n>", "the token's name", ""},
21 {"--scope", "full|read", "what the token may do", "full"}, 21 {"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
22 {"--ttl", "30d|720h", "how long the token is valid", "never expires"}, 22 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
23 }, 23 },
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"}, 24 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
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",
29 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList}) 30 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList})
30 register(Command{Path: []string{"token", "revoke"}, 31 register(Command{Path: []string{"token", "revoke"},
31 Summary: "revoke an API token by name", 32 Summary: "revoke an API token by name",
32 Usage: "token revoke <name>", 33 Usage: "token revoke <name> [--created]",
33 Examples: []string{"token revoke laptop"}, 34 Flags: []Flag{
35 {"--created", "", "also revoke the tokens and keys it created, at any depth", ""},
36 },
37 Examples: []string{"token revoke laptop", "token revoke laptop --created"},
34 Run: runTokenRevoke}) 38 Run: runTokenRevoke})
35} 39}
36 40
@@ -47,11 +51,11 @@ func parseTTL(s string) (time.Duration, error) {
47} 51}
48 52
49func runTokenCreate(c *Ctx, args []string) int { 53func runTokenCreate(c *Ctx, args []string) int {
50 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: "token create --name <n> [--scope full|read] [--ttl 30d]"}) 54 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
51 if err != nil { 55 if err != nil {
52 return c.fail(protocol.ExitUsage, "%v", err) 56 return c.fail(protocol.ExitUsage, "%v", err)
53 } 57 }
54 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl") 58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
55 if f.Has("--scope") { 59 if f.Has("--scope") {
56 scope = f.Value("--scope") 60 scope = f.Value("--scope")
57 } 61 }
@@ -73,7 +77,7 @@ func runTokenCreate(c *Ctx, args []string) int {
73 } 77 }
74 // The gb_ prefix makes leaked tokens findable by secret scanners. 78 // The gb_ prefix makes leaked tokens findable by secret scanners.
75 token := "gb_" + raw 79 token := "gb_" + raw
76 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil { 80 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
77 return c.failErr(err) 81 return c.failErr(err)
78 } 82 }
79 type out struct { 83 type out struct {
@@ -98,10 +102,11 @@ func runTokenList(c *Ctx, args []string) int {
98 CreatedAt string `json:"created_at"` 102 CreatedAt string `json:"created_at"`
99 ExpiresAt *time.Time `json:"expires_at,omitempty"` 103 ExpiresAt *time.Time `json:"expires_at,omitempty"`
100 LastUsedAt *time.Time `json:"last_used_at,omitempty"` 104 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
105 CreatedBy string `json:"created_by,omitempty"`
101 } 106 }
102 var ds []out 107 var ds []out
103 for _, t := range tokens { 108 for _, t := range tokens {
104 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) 109 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
105 } 110 }
106 return c.emit(ds, func(w io.Writer) { 111 return c.emit(ds, func(w io.Writer) {
107 tb := c.table(w, "NAME", "SCOPE", "EXPIRES") 112 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
@@ -122,16 +127,43 @@ func runTokenList(c *Ctx, args []string) int {
122} 127}
123 128
124func runTokenRevoke(c *Ctx, args []string) int { 129func runTokenRevoke(c *Ctx, args []string) int {
125 if len(args) != 1 { 130 f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
131 if err != nil {
132 return c.fail(protocol.ExitUsage, "%v", err)
133 }
134 name := f.pos(0)
135 if name == "" {
126 return c.usage() 136 return c.usage()
127 } 137 }
128 if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil { 138 withCreated := f.Has("--created")
139 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
140 if err != nil {
129 if errors.Is(err, store.ErrNotFound) { 141 if errors.Is(err, store.ErrNotFound) {
130 return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) 142 return c.fail(protocol.ExitNotFound, "no token named %q", name)
131 } 143 }
132 return c.fail(protocol.ExitFailure, "%v", err) 144 return c.fail(protocol.ExitFailure, "%v", err)
133 } 145 }
134 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) { 146 type out struct {
135 fmt.Fprintf(w, "revoked %s\n", args[0]) 147 Revoked string `json:"revoked"`
148 Created store.Created `json:"created"`
149 CreatedRevoked bool `json:"created_revoked"`
150 }
151 d := out{name, created, withCreated}
152 return c.emit(d, func(w io.Writer) {
153 fmt.Fprintf(w, "revoked %s\n", name)
154 if len(created.Tokens)+len(created.Keys) == 0 {
155 return
156 }
157 if withCreated {
158 fmt.Fprintln(w, "and what it created:")
159 } else {
160 fmt.Fprintln(w, "it created these, still in place:")
161 }
162 for _, n := range created.Tokens {
163 fmt.Fprintf(w, " token %s\n", n)
164 }
165 for _, fp := range created.Keys {
166 fmt.Fprintf(w, " key %s\n", fp)
167 }
136 }) 168 })
137} 169}
internal/control/token_test.go added +80
@@ -0,0 +1,80 @@
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 var found bool
68 for _, tk := range toks {
69 if tk.Name != "child" {
70 continue
71 }
72 found = true
73 if tk.Scope != "read" || tk.CreatedBy != "parent" {
74 t.Fatalf("child: %+v", tk)
75 }
76 }
77 if !found {
78 t.Fatal(`no token named "child"`)
79 }
80}
internal/control/web.go +4 −3
@@ -14,9 +14,10 @@ func newStoredToken() (token, hash string, err error) { return store.NewToken()
14 14
15func init() { 15func 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.
30func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { 30func (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.
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) {
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
146func apiError(w http.ResponseWriter, status int, msg string) { 148func apiError(w http.ResponseWriter, status int, msg string) {
internal/store/migrations/0060_credential_origin.down.sql added +2
@@ -0,0 +1,2 @@
1ALTER TABLE ssh_keys DROP COLUMN created_by_token;
2ALTER TABLE api_tokens DROP COLUMN created_by_token;
internal/store/migrations/0060_credential_origin.up.sql added +4
@@ -0,0 +1,4 @@
1-- The API token a credential was created through. NULL when it was not,
2-- and once that token is revoked.
3ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
4ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
internal/store/tokens.go +117 −22
@@ -4,55 +4,71 @@ import (
4 "database/sql" 4 "database/sql"
5 "errors" 5 "errors"
6 "fmt" 6 "fmt"
7 "strings"
7 "time" 8 "time"
8) 9)
9 10
10type APIToken struct { 11type APIToken struct {
12 ID int64
11 Name string 13 Name string
12 Scope string 14 Scope string
13 CreatedAt string 15 CreatedAt string
14 ExpiresAt *time.Time 16 ExpiresAt *time.Time
15 LastUsedAt *time.Time 17 LastUsedAt *time.Time
18 CreatedBy string // name of the token that created this one; "" for none
16} 19}
17 20
18// CreateAPIToken stores a token hash; expires nil means no expiry. 21// nullID stores 0 as NULL.
19func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error { 22func nullID(id int64) any {
23 if id == 0 {
24 return nil
25 }
26 return id
27}
28
29// CreateAPIToken stores a token hash; expires nil means no expiry,
30// createdByToken 0 means it was not created through a token.
31func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error {
20 var exp any 32 var exp any
21 if expires != nil { 33 if expires != nil {
22 exp = fmtTime(*expires) 34 exp = fmtTime(*expires)
23 } 35 }
24 _, err := s.DB.Exec( 36 _, err := s.DB.Exec(
25 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)", 37 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
26 userID, name, tokenHash, scope, exp) 38 userID, name, tokenHash, scope, exp, nullID(createdByToken))
27 if isUniqueErr(err) { 39 if isUniqueErr(err) {
28 return fmt.Errorf("you already have a token named %q", name) 40 return fmt.Errorf("you already have a token named %q", name)
29 } 41 }
30 return err 42 return err
31} 43}
32 44
33// APITokenUser resolves a presented token to its user and scope; expired and 45// APITokenUser resolves a presented token to its user and the token;
34// unknown tokens fail identically. 46// expired and unknown tokens fail identically.
35func (s *Store) APITokenUser(tokenHash string) (User, string, error) { 47func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
36 var userID int64 48 var userID int64
37 var scope string 49 var t APIToken
50 var exp sql.NullString
38 err := s.DB.QueryRow(` 51 err := s.DB.QueryRow(`
39 SELECT user_id, scope FROM api_tokens 52 SELECT user_id, id, name, scope, expires_at FROM api_tokens
40 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, 53 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
41 tokenHash, fmtTime(time.Now())).Scan(&userID, &scope) 54 tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp)
42 if errors.Is(err, sql.ErrNoRows) { 55 if errors.Is(err, sql.ErrNoRows) {
43 return User{}, "", ErrNotFound 56 return User{}, APIToken{}, ErrNotFound
44 } 57 }
45 if err != nil { 58 if err != nil {
46 return User{}, "", err 59 return User{}, APIToken{}, err
47 } 60 }
61 t.ExpiresAt = parseTime(exp)
48 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) 62 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
49 u, err := s.UserByID(userID) 63 u, err := s.UserByID(userID)
50 return u, scope, err 64 return u, t, err
51} 65}
52 66
53func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { 67func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
54 rows, err := s.DB.Query( 68 rows, err := s.DB.Query(`
55 "SELECT name, scope, created_at, expires_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY name", userID) 69 SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
70 FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token
71 WHERE t.user_id = ? ORDER BY t.name`, userID)
56 if err != nil { 72 if err != nil {
57 return nil, err 73 return nil, err
58 } 74 }
@@ -61,7 +77,7 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
61 for rows.Next() { 77 for rows.Next() {
62 var t APIToken 78 var t APIToken
63 var exp, used sql.NullString 79 var exp, used sql.NullString
64 if err := rows.Scan(&t.Name, &t.Scope, &t.CreatedAt, &exp, &used); err != nil { 80 if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil {
65 return nil, err 81 return nil, err
66 } 82 }
67 t.ExpiresAt = parseTime(exp) 83 t.ExpiresAt = parseTime(exp)
@@ -71,13 +87,92 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
71 return out, rows.Err() 87 return out, rows.Err()
72} 88}
73 89
74func (s *Store) RevokeAPIToken(userID int64, name string) error { 90// Created is what a token made, directly or through tokens it made:
75 res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name) 91// token names and SSH key fingerprints.
92type Created struct {
93 Tokens []string `json:"tokens"`
94 Keys []string `json:"keys"`
95}
96
97// chainCTE selects the token named by the first argument and every
98// token created from it, at any depth.
99const chainCTE = `WITH RECURSIVE chain(id) AS (
100 SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)`
101
102// RevokeAPIToken deletes the user's token by name and returns what it
103// created. withCreated deletes those too; otherwise they stay and lose
104// the link to the revoked token.
105func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) {
106 tx, err := s.DB.Begin()
76 if err != nil { 107 if err != nil {
77 return err 108 return Created{}, err
109 }
110 defer tx.Rollback()
111 var id int64
112 err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id)
113 if errors.Is(err, sql.ErrNoRows) {
114 return Created{}, ErrNotFound
115 }
116 if err != nil {
117 return Created{}, err
118 }
119 var c Created
120 rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id)
121 if err != nil {
122 return Created{}, err
123 }
124 for rows.Next() {
125 var n string
126 if err := rows.Scan(&n); err != nil {
127 rows.Close()
128 return Created{}, err
129 }
130 c.Tokens = append(c.Tokens, n)
131 }
132 rows.Close()
133 var keyIDs []int64
134 rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id)
135 if err != nil {
136 return Created{}, err
137 }
138 for rows.Next() {
139 var kid int64
140 var fp string
141 if err := rows.Scan(&kid, &fp); err != nil {
142 rows.Close()
143 return Created{}, err
144 }
145 keyIDs = append(keyIDs, kid)
146 c.Keys = append(c.Keys, fp)
147 }
148 rows.Close()
149
150 if !withCreated {
151 if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil {
152 return Created{}, err
153 }
154 return c, tx.Commit()
155 }
156 if len(keyIDs) > 0 {
157 args := make([]any, len(keyIDs))
158 for i, k := range keyIDs {
159 args[i] = k
160 }
161 if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil {
162 return Created{}, err
163 }
164 if err := bumpKeyEpoch(tx); err != nil {
165 return Created{}, err
166 }
167 }
168 if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil {
169 return Created{}, err
170 }
171 if err := tx.Commit(); err != nil {
172 return Created{}, err
78 } 173 }
79 if n, _ := res.RowsAffected(); n == 0 { 174 if len(keyIDs) > 0 {
80 return ErrNotFound 175 s.announce(Revoked{KeyIDs: keyIDs})
81 } 176 }
82 return nil 177 return c, nil
83} 178}
internal/store/tokens_test.go added +115
@@ -0,0 +1,115 @@
1package store
2
3import (
4 "slices"
5 "testing"
6 "time"
7)
8
9func tokenID(t *testing.T, s *Store, hash string) int64 {
10 t.Helper()
11 _, tok, err := s.APITokenUser(hash)
12 if err != nil {
13 t.Fatal(err)
14 }
15 return tok.ID
16}
17
18// parent made child, child made grandchild and a key; the key belongs
19// to another account, as admin user create --key makes one.
20func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) {
21 t.Helper()
22 s, uid, got := revokeFixture(t)
23 bob, err := s.CreateUser("bob", false)
24 if err != nil {
25 t.Fatal(err)
26 }
27 if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
28 t.Fatal(err)
29 }
30 if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil {
31 t.Fatal(err)
32 }
33 child := tokenID(t, s, "h-child")
34 if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil {
35 t.Fatal(err)
36 }
37 if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil {
38 t.Fatal(err)
39 }
40 return s, uid, got
41}
42
43func TestTokenRecordsItsCreator(t *testing.T) {
44 s, uid, _ := tokenChain(t)
45 toks, err := s.ListAPITokens(uid)
46 if err != nil {
47 t.Fatal(err)
48 }
49 by := map[string]string{}
50 for _, tk := range toks {
51 by[tk.Name] = tk.CreatedBy
52 }
53 if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" {
54 t.Fatalf("created by: %v", by)
55 }
56 bob, _ := s.UserByUsername("bob")
57 keys, err := s.ListSSHKeys(bob.ID)
58 if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" {
59 t.Fatalf("key created by: %+v %v", keys, err)
60 }
61}
62
63func TestRevokeAPITokenListsWhatItCreated(t *testing.T) {
64 s, uid, got := tokenChain(t)
65 c, err := s.RevokeAPIToken(uid, "parent", false)
66 if err != nil {
67 t.Fatal(err)
68 }
69 if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) {
70 t.Fatalf("created = %+v", c)
71 }
72 // Listed, not removed; the link to the revoked parent is gone.
73 toks, _ := s.ListAPITokens(uid)
74 if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" {
75 t.Fatalf("tokens after revoke: %+v", toks)
76 }
77 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil {
78 t.Fatalf("the key went: %v", err)
79 }
80 if len(*got) != 0 {
81 t.Fatalf("announced %+v with nothing revoked but the token", *got)
82 }
83}
84
85func TestRevokeAPITokenWithCreated(t *testing.T) {
86 s, uid, got := tokenChain(t)
87 k, _ := s.SSHKeyByFingerprint("SHA256:k")
88 if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil {
89 t.Fatal(err)
90 }
91 if toks, _ := s.ListAPITokens(uid); len(toks) != 0 {
92 t.Fatalf("tokens left: %+v", toks)
93 }
94 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound {
95 t.Fatalf("key left: %v", err)
96 }
97 if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) {
98 t.Fatalf("announced %+v", *got)
99 }
100 if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound {
101 t.Fatalf("second revoke: %v", err)
102 }
103}
104
105func TestAPITokenUserCarriesExpiry(t *testing.T) {
106 s, uid, _ := revokeFixture(t)
107 exp := time.Now().Add(time.Hour)
108 if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil {
109 t.Fatal(err)
110 }
111 _, tok, err := s.APITokenUser("h-brief")
112 if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 {
113 t.Fatalf("token %+v %v", tok, err)
114 }
115}
internal/store/users.go +18 −5
@@ -25,6 +25,7 @@ type SSHKey struct {
25 Label string // "" when the key was added with no name 25 Label string // "" when the key was added with no name
26 CreatedAt string 26 CreatedAt string
27 LastUsedAt string // "" when the key has never authenticated 27 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
28} 29}
29 30
30// ErrDuplicateKey carries the exact user-facing message from the spec. It 31// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -270,16 +271,26 @@ func (s *Store) UserByID(id int64) (User, error) {
270 return u, err 271 return u, err
271} 272}
272 273
274// KeyOrigin is how a key came to be.
275type KeyOrigin struct {
276 CreatedByToken int64 // the API token that added it; 0 for none
277}
278
273// AddSSHKey registers a key and bumps the key epoch in one transaction. 279// AddSSHKey registers a key and bumps the key epoch in one transaction.
274func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error { 280func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
281 return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
282}
283
284// AddSSHKeyFrom is AddSSHKey recording where the key came from.
285func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
275 tx, err := s.DB.Begin() 286 tx, err := s.DB.Begin()
276 if err != nil { 287 if err != nil {
277 return err 288 return err
278 } 289 }
279 defer tx.Rollback() 290 defer tx.Rollback()
280 if _, err := tx.Exec( 291 if _, err := tx.Exec(
281 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)", 292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
282 userID, fingerprint, algo, blob, scope, label); err != nil { 293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
283 if isUniqueErr(err) { 294 if isUniqueErr(err) {
284 return ErrDuplicateKey 295 return ErrDuplicateKey
285 } 296 }
@@ -343,8 +354,10 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
343 354
344func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { 355func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
345 rows, err := s.DB.Query( 356 rows, err := s.DB.Query(
346 `SELECT id, user_id, fingerprint, algo, blob, scope, label, created_at, COALESCE(last_used_at, '') 357 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
347 FROM ssh_keys WHERE user_id = ? ORDER BY id`, 358 COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
359 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
360 WHERE k.user_id = ? ORDER BY k.id`,
348 userID) 361 userID)
349 if err != nil { 362 if err != nil {
350 return nil, err 363 return nil, err
@@ -353,7 +366,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
353 var keys []SSHKey 366 var keys []SSHKey
354 for rows.Next() { 367 for rows.Next() {
355 var k SSHKey 368 var k SSHKey
356 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt); err != nil { 369 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
357 return nil, err 370 return nil, err
358 } 371 }
359 keys = append(keys, k) 372 keys = append(keys, k)
internal/web/templates/account.html +1 −1
@@ -191,7 +191,7 @@ never included; a replayed bundle's emails arrive unverified.</p>
191<section id="cli"><h2>On the command line</h2> 191<section id="cli"><h2>On the command line</h2>
192<p class="meta">No page here yet, and nothing refusing one: a credential is 192<p class="meta">No page here yet, and nothing refusing one: a credential is
193easier to pipe than to paste, and a minted token is shown once.</p> 193easier to pipe than to paste, and a minted token is shown once.</p>
194<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens 194<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens, read-only unless --scope full
195gitbay web sessions list # browser sessions 195gitbay web sessions list # browser sessions
196gitbay admin ... # instance administration</pre> 196gitbay admin ... # instance administration</pre>
197<p class="meta">All of it works from stock OpenSSH too: 197<p class="meta">All of it works from stock OpenSSH too: