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
1414** Tokens
1515
1616Tokens are minted wherever the registry is reached: over SSH, on the
17API, anywhere. A full-scope token can mint another, which is what full
18scope means; a read-scoped one cannot, because minting is a write. The
19controls here are scope, TTL and revocation, not which door a request
20arrived through (#234). Give a token the narrowest scope and shortest
17API, anywhere. =token create= makes a =read= token unless =--scope full=
18is given; a read token runs only commands marked read-only. A full-scope
19token can mint another, but a token with a =--ttl= cannot run any
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
2124TTL that does its job, and revoke it when the job is over.
2225
2326#+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]
2528gitbay auth token list
26gitbay auth token revoke ci
29gitbay auth token revoke ci [--created]
2730#+end_src
2831
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
2937The token (prefix =gb_=, shown exactly once) is presented as
3038=Authorization: Bearer gb_...=. Only a hash is stored server-side.
3139Scope =read= permits list/show/log/diff-style commands and refuses
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1 −1
@@ -17,7 +17,7 @@
1717|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------|
1818| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys); closes its connections |
1919| 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]= |
2121| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= |
2222| 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 |
2323| 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.
2626| Session lifetime | partial | 7 days absolute, no idle timeout (#276) |
2727| Credential expiry | partial | API tokens optional; SSH and deploy keys none (#277) |
2828| 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) |
3030
3131** Access control (V4)
3232
.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.
1111| Issue | Area | Gap | Severity |
1212|-------+------------------+-----------------------------------------------------------------------+----------|
1313| #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 |
1514| #258 | CI integrity | Any writer can post a =ci/*= status; tree reuse ignores trust and image | high |
1615| #259 | Recovery | No restore has been exercised; verification does not check git connectivity | high |
1716| #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.
2726| #280 | Mail | STARTTLS only when the relay offers it | medium |
2827| #281 | TLS | No explicit minimum TLS version | low |
2928| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
30
31Decisions already taken on these: #257 refuses credential
32creation from expiring tokens, records which token created each
33credential, and makes =read= the default scope.
29| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
3430
3531* Questions an auditor will ask that have no answer yet
3632
.gitbay/wiki/Parity.org +1
@@ -356,6 +356,7 @@ client has no use for one (krz/gitbay#57).
356356| activity push on, off | yes | yes | yes |
357357| web colour scheme | yes | yes | n/a |
358358| API token mint | yes | no | no |
359| API token revoke with what it created | yes | no | no |
359360| account export bundle | yes | yes | n/a |
360361| profile set | yes | yes | yes |
361362| 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
5252 OpenSSH and fronted unchanged by the JSON API and the web. No command
5353 belongs to one surface (#234): what a caller may do is the account's
5454 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
56 never runs over the API.
55 bearer token is worth exactly its scope and no more, and a token or
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.
5759- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
5860 enabled, the read-only web UI — carry no credentials and expose only
5961 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
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* unreleased
8
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
11 transports included; every command re-reads its key (#256).
7* Unreleased
8
9Credentials: revocation and delegation (#256, #257).
10
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).
1223
1324* v1.36.0 — 2026-09-23
1425
e2e/api_test.go +2 −2
@@ -47,7 +47,7 @@ func TestJSONAPI(t *testing.T) {
4747 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
4848
4949 // 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")
5151 if code != 0 {
5252 t.Fatalf("token create: %s", errOut)
5353 }
@@ -265,7 +265,7 @@ func TestAPIRateLimit(t *testing.T) {
265265// mintToken creates an API token over SSH and returns its value.
266266func mintToken(t *testing.T, inst *instance, key, name string) string {
267267 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")
269269 if code != 0 {
270270 t.Fatalf("token create: %s", errOut)
271271 }
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() {
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",
@@ -122,7 +125,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
122125 if pub != nil {
123126 fp = ssh.FingerprintSHA256(pub)
124127 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 {
126129 return c.failErr(err)
127130 }
128131 }
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 +4 −3
@@ -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>",
@@ -68,7 +69,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
6869 }
6970 fp := ssh.FingerprintSHA256(pub)
7071 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 {
7273 if errors.Is(err, store.ErrDuplicateKey) {
7374 return c.failErr(err)
7475 }
internal/control/identity.go +7 −5
@@ -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"},
@@ -86,10 +87,11 @@ func runKeysList(c *Ctx, args []string) int {
8687 Algo string `json:"algo"`
8788 Scope string `json:"scope"`
8889 Label string `json:"label"`
90 CreatedBy string `json:"created_by,omitempty"`
8991 }
9092 var ds []out
9193 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})
9395 }
9496 return c.emit(ds, func(w io.Writer) {
9597 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
@@ -149,7 +151,7 @@ func runKeysAdd(c *Ctx, args []string) int {
149151 return c.fail(protocol.ExitUsage, "%v", err)
150152 }
151153 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 {
153155 if errors.Is(err, store.ErrDuplicateKey) {
154156 return c.failErr(err)
155157 }
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 +6 −5
@@ -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>",
@@ -57,7 +58,7 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
5758 switch {
5859 case errors.Is(err, store.ErrNotFound):
5960 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 {
6162 return c.fail(protocol.ExitFailure, "adding key: %v", err)
6263 }
6364 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
internal/control/token.go +49 −17
@@ -15,22 +15,26 @@ import (
1515func init() {
1616 register(Command{Path: []string{"token", "create"},
1717 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]",
1919 Flags: []Flag{
2020 {"--name", "<n>", "the token's name", ""},
21 {"--scope", "full|read", "what the token may do", "full"},
22 {"--ttl", "30d|720h", "how long the token is valid", "never expires"},
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; an expiring token cannot mint credentials", "never expires"},
2323 },
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"},
25 Run: runTokenCreate})
24 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
25 MintsCredential: true,
26 Run: runTokenCreate})
2627 register(Command{Path: []string{"token", "list"},
2728 Summary: "list API tokens",
2829 Usage: "token list",
2930 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList})
3031 register(Command{Path: []string{"token", "revoke"},
31 Summary: "revoke an API token by name",
32 Usage: "token revoke <name>",
33 Examples: []string{"token revoke laptop"},
32 Summary: "revoke an API token by name",
33 Usage: "token revoke <name> [--created]",
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"},
3438 Run: runTokenRevoke})
3539}
3640
@@ -47,11 +51,11 @@ func parseTTL(s string) (time.Duration, error) {
4751}
4852
4953func 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})
5155 if err != nil {
5256 return c.fail(protocol.ExitUsage, "%v", err)
5357 }
54 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl")
58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
5559 if f.Has("--scope") {
5660 scope = f.Value("--scope")
5761 }
@@ -73,7 +77,7 @@ func runTokenCreate(c *Ctx, args []string) int {
7377 }
7478 // The gb_ prefix makes leaked tokens findable by secret scanners.
7579 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 {
7781 return c.failErr(err)
7882 }
7983 type out struct {
@@ -98,10 +102,11 @@ func runTokenList(c *Ctx, args []string) int {
98102 CreatedAt string `json:"created_at"`
99103 ExpiresAt *time.Time `json:"expires_at,omitempty"`
100104 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
105 CreatedBy string `json:"created_by,omitempty"`
101106 }
102107 var ds []out
103108 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})
105110 }
106111 return c.emit(ds, func(w io.Writer) {
107112 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
@@ -122,16 +127,43 @@ func runTokenList(c *Ctx, args []string) int {
122127}
123128
124129func 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 == "" {
126136 return c.usage()
127137 }
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 {
129141 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)
131143 }
132144 return c.fail(protocol.ExitFailure, "%v", err)
133145 }
134 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
135 fmt.Fprintf(w, "revoked %s\n", args[0])
146 type out struct {
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 }
136168 })
137169}
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()
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) {
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 (
44 "database/sql"
55 "errors"
66 "fmt"
7 "strings"
78 "time"
89)
910
1011type APIToken struct {
12 ID int64
1113 Name string
1214 Scope string
1315 CreatedAt string
1416 ExpiresAt *time.Time
1517 LastUsedAt *time.Time
18 CreatedBy string // name of the token that created this one; "" for none
1619}
1720
18// CreateAPIToken stores a token hash; expires nil means no expiry.
19func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error {
21// nullID stores 0 as NULL.
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 {
2032 var exp any
2133 if expires != nil {
2234 exp = fmtTime(*expires)
2335 }
2436 _, err := s.DB.Exec(
25 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
26 userID, name, tokenHash, scope, exp)
37 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
38 userID, name, tokenHash, scope, exp, nullID(createdByToken))
2739 if isUniqueErr(err) {
2840 return fmt.Errorf("you already have a token named %q", name)
2941 }
3042 return err
3143}
3244
33// APITokenUser resolves a presented token to its user and scope; expired and
34// unknown tokens fail identically.
35func (s *Store) APITokenUser(tokenHash string) (User, string, error) {
45// APITokenUser resolves a presented token to its user and the token;
46// expired and unknown tokens fail identically.
47func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
3648 var userID int64
37 var scope string
49 var t APIToken
50 var exp sql.NullString
3851 err := s.DB.QueryRow(`
39 SELECT user_id, scope FROM api_tokens
52 SELECT user_id, id, name, scope, expires_at FROM api_tokens
4053 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)
4255 if errors.Is(err, sql.ErrNoRows) {
43 return User{}, "", ErrNotFound
56 return User{}, APIToken{}, ErrNotFound
4457 }
4558 if err != nil {
46 return User{}, "", err
59 return User{}, APIToken{}, err
4760 }
61 t.ExpiresAt = parseTime(exp)
4862 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
4963 u, err := s.UserByID(userID)
50 return u, scope, err
64 return u, t, err
5165}
5266
5367func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
54 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)
68 rows, err := s.DB.Query(`
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)
5672 if err != nil {
5773 return nil, err
5874 }
@@ -61,7 +77,7 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
6177 for rows.Next() {
6278 var t APIToken
6379 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 {
6581 return nil, err
6682 }
6783 t.ExpiresAt = parseTime(exp)
@@ -71,13 +87,92 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
7187 return out, rows.Err()
7288}
7389
74func (s *Store) RevokeAPIToken(userID int64, name string) error {
75 res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name)
90// Created is what a token made, directly or through tokens it made:
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()
76107 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
78173 }
79 if n, _ := res.RowsAffected(); n == 0 {
80 return ErrNotFound
174 if len(keyIDs) > 0 {
175 s.announce(Revoked{KeyIDs: keyIDs})
81176 }
82 return nil
177 return c, nil
83178}
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 {
2525 Label string // "" when the key was added with no name
2626 CreatedAt string
2727 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
2829}
2930
3031// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -270,16 +271,26 @@ func (s *Store) UserByID(id int64) (User, error) {
270271 return u, err
271272}
272273
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
273279// AddSSHKey registers a key and bumps the key epoch in one transaction.
274280func (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 {
275286 tx, err := s.DB.Begin()
276287 if err != nil {
277288 return err
278289 }
279290 defer tx.Rollback()
280291 if _, err := tx.Exec(
281 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)",
282 userID, fingerprint, algo, blob, scope, label); err != nil {
292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
283294 if isUniqueErr(err) {
284295 return ErrDuplicateKey
285296 }
@@ -343,8 +354,10 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
343354
344355func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
345356 rows, err := s.DB.Query(
346 `SELECT id, user_id, fingerprint, algo, blob, scope, label, created_at, COALESCE(last_used_at, '')
347 FROM ssh_keys WHERE user_id = ? ORDER BY id`,
357 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
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`,
348361 userID)
349362 if err != nil {
350363 return nil, err
@@ -353,7 +366,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
353366 var keys []SSHKey
354367 for rows.Next() {
355368 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 {
357370 return nil, err
358371 }
359372 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>
191191<section id="cli"><h2>On the command line</h2>
192192<p class="meta">No page here yet, and nothing refusing one: a credential is
193193easier 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
195195gitbay web sessions list # browser sessions
196196gitbay admin ... # instance administration</pre>
197197<p class="meta">All of it works from stock OpenSSH too: