token: expiring credentials cannot mint; record creator; default read !483
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 | ||
| 16 | Tokens are minted wherever the registry is reached: over SSH, on the | 16 | Tokens are minted wherever the registry is reached: over SSH, on the |
| 17 | API, anywhere. A full-scope token can mint another, which is what full | 17 | API, anywhere. =token create= makes a =read= token unless =--scope full= |
| 18 | scope means; a read-scoped one cannot, because minting is a write. The | 18 | is given; a read token runs only commands marked read-only. A full-scope |
| 19 | controls here are scope, TTL and revocation, not which door a request | 19 | token can mint another, but a token with a =--ttl= cannot run any |
| 20 | arrived through (#234). Give a token the narrowest scope and shortest | 20 | command 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 | ||
| 23 | it made would outlive it. Give a token the narrowest scope and shortest | ||
| 21 | TTL that does its job, and revoke it when the job is over. | 24 | TTL that does its job, and revoke it when the job is over. |
| 22 | 25 | ||
| 23 | #+begin_src sh | 26 | #+begin_src sh |
| 24 | gitbay auth token create --name ci [--scope full|read] [--ttl 30d] | 27 | gitbay auth token create --name ci [--scope read|full] [--ttl 30d] |
| 25 | gitbay auth token list | 28 | gitbay auth token list |
| 26 | gitbay auth token revoke ci | 29 | gitbay auth token revoke ci [--created] |
| 27 | #+end_src | 30 | #+end_src |
| 28 | 31 | ||
| 32 | Tokens and keys record the token they were created through. =token | ||
| 33 | revoke= prints what the token created, at any depth; with =--created= | ||
| 34 | it revokes those too, and their SSH connections close. Without it they | ||
| 35 | stay and the link is dropped. | ||
| 36 | |||
| 29 | The token (prefix =gb_=, shown exactly once) is presented as | 37 | The 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. |
| 31 | Scope =read= permits list/show/log/diff-style commands and refuses | 39 | Scope =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 | | |
| 31 | Decisions already taken on these: #257 refuses credential | ||
| 32 | creation from expiring tokens, records which token created each | ||
| 33 | credential, 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 | |||
| 4 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 5 | anything 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 | 9 | Credentials: 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 | ||
| 13 | must 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. |
| 266 | func mintToken(t *testing.T, inst *instance, key, name string) string { | 266 | func 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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 13 | func 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 | ||
| 93 | var registry []Command | 104 | var registry []Command |
| @@ -159,6 +170,11 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 159 | if c.ReadOnly && !cmd.ReadOnly { | 170 | if c.ReadOnly && !cmd.ReadOnly { |
| 160 | return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) | 171 | return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) |
| 161 | } | 172 | } |
| 173 | // What an expiring credential creates would outlive it (#257). | ||
| 174 | if cmd.MintsCredential && c.Expires != nil { | ||
| 175 | return c.fail(protocol.ExitDenied, | ||
| 176 | "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path)) | ||
| 177 | } | ||
| 162 | // The SSH listener refuses a disabled account before it gets here; the | 178 | // The SSH listener refuses a disabled account before it gets here; the |
| 163 | // API and the web reach Dispatch directly, so the check lives here too. | 179 | // API and the web reach Dispatch directly, so the check lives here too. |
| 164 | if c.User.Disabled { | 180 | if c.User.Disabled { |
internal/control/deploykey.go +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. |
| 20 | func init() { | 20 | func init() { |
| 21 | register(Command{Path: []string{"repo", "runner", "add"}, | 21 | register(Command{Path: []string{"repo", "runner", "add"}, |
| 22 | Summary: "attach a runner's public key to a repository", | 22 | Summary: "attach a runner's public key to a repository", |
| 23 | Usage: "repo runner add <owner/name> < key.pub", | 23 | Usage: "repo runner add <owner/name> < key.pub", |
| 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, | 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, |
| 25 | ReadsStdin: true, Run: runRepoRunnerAdd}) | 25 | ReadsStdin: true, |
| 26 | MintsCredential: true, Run: runRepoRunnerAdd}) | ||
| 26 | register(Command{Path: []string{"repo", "runner", "list"}, | 27 | register(Command{Path: []string{"repo", "runner", "list"}, |
| 27 | Summary: "list the runners attached to a repository", | 28 | Summary: "list the runners attached to a repository", |
| 28 | Usage: "repo runner list <owner/name>", | 29 | Usage: "repo runner list <owner/name>", |
| @@ -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 ( | |||
| 15 | func init() { | 15 | func 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 | ||
| 49 | func runTokenCreate(c *Ctx, args []string) int { | 53 | func 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 | ||
| 124 | func runTokenRevoke(c *Ctx, args []string) int { | 129 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "slices" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/protocol" | ||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // The minting commands, pinned: adding one to the list, or dropping | ||
| 15 | // one, is a decision this test makes someone take. | ||
| 16 | func TestMintingCommandsMarked(t *testing.T) { | ||
| 17 | want := []string{ | ||
| 18 | "admin email verify", "admin invite", "admin user create", "email verify", | ||
| 19 | "keys add", "repo deploy-key add", "repo runner add", "token create", "web login", | ||
| 20 | } | ||
| 21 | var got []string | ||
| 22 | for _, cmd := range Commands() { | ||
| 23 | if cmd.MintsCredential { | ||
| 24 | got = append(got, joinPath(cmd.Path)) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | slices.Sort(got) | ||
| 28 | if !slices.Equal(got, want) { | ||
| 29 | t.Fatalf("MintsCredential on %q, want %q", got, want) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | // Dispatch refuses before the command runs, so no arguments are needed. | ||
| 34 | func TestExpiringCredentialCannotMint(t *testing.T) { | ||
| 35 | exp := time.Now().Add(time.Hour) | ||
| 36 | for _, cmd := range Commands() { | ||
| 37 | if !cmd.MintsCredential { | ||
| 38 | continue | ||
| 39 | } | ||
| 40 | var out, errOut bytes.Buffer | ||
| 41 | c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut} | ||
| 42 | if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") { | ||
| 43 | t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) { | ||
| 49 | st, _, uid := newQueueTestRepo(t) | ||
| 50 | if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil { | ||
| 51 | t.Fatal(err) | ||
| 52 | } | ||
| 53 | _, parent, err := st.APITokenUser("h-parent") | ||
| 54 | if err != nil { | ||
| 55 | t.Fatal(err) | ||
| 56 | } | ||
| 57 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | ||
| 58 | c.Cfg.Limits.WriteRate = -1 | ||
| 59 | c.TokenID = parent.ID | ||
| 60 | if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK { | ||
| 61 | t.Fatalf("exit %d: %s", code, errOut) | ||
| 62 | } | ||
| 63 | toks, err := st.ListAPITokens(uid) | ||
| 64 | if err != nil { | ||
| 65 | t.Fatal(err) | ||
| 66 | } | ||
| 67 | 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 | ||
| 15 | func init() { | 15 | func init() { |
| 16 | register(Command{Path: []string{"web", "login"}, | 16 | register(Command{Path: []string{"web", "login"}, |
| 17 | Summary: "mint a one-time browser login URL", | 17 | Summary: "mint a one-time browser login URL", |
| 18 | Usage: "web login", | 18 | Usage: "web login", |
| 19 | Examples: []string{"web login"}, Run: runWebLogin}) | 19 | MintsCredential: true, |
| 20 | Examples: []string{"web login"}, Run: runWebLogin}) | ||
| 20 | register(Command{Path: []string{"web", "sessions", "list"}, | 21 | register(Command{Path: []string{"web", "sessions", "list"}, |
| 21 | Summary: "list your browser sessions", | 22 | Summary: "list your browser sessions", |
| 22 | Usage: "web sessions list", | 23 | Usage: "web sessions list", |
internal/httpd/api.go +10 −8
| @@ -28,7 +28,7 @@ const maxAPIBody = 1 << 20 | |||
| 28 | // semantics. Exit codes map onto HTTP statuses; the body is the command's | 28 | // semantics. Exit codes map onto HTTP statuses; the body is the command's |
| 29 | // JSON envelope with exit_code added. | 29 | // JSON envelope with exit_code added. |
| 30 | func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | 30 | func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { |
| 31 | user, scope, ok := s.apiAuth(w, r) | 31 | user, tok, ok := s.apiAuth(w, r) |
| 32 | if !ok { | 32 | if !ok { |
| 33 | return | 33 | return |
| 34 | } | 34 | } |
| @@ -72,7 +72,9 @@ func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | |||
| 72 | Stderr: &stderr, | 72 | Stderr: &stderr, |
| 73 | JSON: true, | 73 | JSON: true, |
| 74 | ViaAPI: true, | 74 | ViaAPI: true, |
| 75 | ReadOnly: scope == "read", | 75 | ReadOnly: tok.Scope == "read", |
| 76 | TokenID: tok.ID, | ||
| 77 | Expires: tok.ExpiresAt, | ||
| 76 | Done: s.until(r), | 78 | Done: s.until(r), |
| 77 | Stopping: s.stopping, | 79 | Stopping: s.stopping, |
| 78 | } | 80 | } |
| @@ -124,23 +126,23 @@ func (s *Server) limitKey(r *http.Request, user store.User) string { | |||
| 124 | } | 126 | } |
| 125 | 127 | ||
| 126 | // apiAuth resolves the bearer token; failures are uniform 401s. | 128 | // apiAuth resolves the bearer token; failures are uniform 401s. |
| 127 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) { | 129 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) { |
| 128 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") | 130 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") |
| 129 | if !ok || token == "" { | 131 | if !ok || token == "" { |
| 130 | w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) | 132 | w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) |
| 131 | apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") | 133 | apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") |
| 132 | return store.User{}, "", false | 134 | return store.User{}, store.APIToken{}, false |
| 133 | } | 135 | } |
| 134 | user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) | 136 | user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) |
| 135 | if err != nil { | 137 | if err != nil { |
| 136 | if errors.Is(err, store.ErrNotFound) { | 138 | if errors.Is(err, store.ErrNotFound) { |
| 137 | apiError(w, http.StatusUnauthorized, "invalid or expired token") | 139 | apiError(w, http.StatusUnauthorized, "invalid or expired token") |
| 138 | return store.User{}, "", false | 140 | return store.User{}, store.APIToken{}, false |
| 139 | } | 141 | } |
| 140 | apiError(w, http.StatusInternalServerError, "internal error") | 142 | apiError(w, http.StatusInternalServerError, "internal error") |
| 141 | return store.User{}, "", false | 143 | return store.User{}, store.APIToken{}, false |
| 142 | } | 144 | } |
| 143 | return user, scope, true | 145 | return user, tok, true |
| 144 | } | 146 | } |
| 145 | 147 | ||
| 146 | func apiError(w http.ResponseWriter, status int, msg string) { | 148 | func apiError(w http.ResponseWriter, status int, msg string) { |
internal/store/migrations/0060_credential_origin.down.sql added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | ALTER TABLE ssh_keys DROP COLUMN created_by_token; | ||
| 2 | ALTER 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. | ||
| 3 | ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL; | ||
| 4 | ALTER 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 | ||
| 10 | type APIToken struct { | 11 | type 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. |
| 19 | func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error { | 22 | func 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. | ||
| 31 | func (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. |
| 35 | func (s *Store) APITokenUser(tokenHash string) (User, string, error) { | 47 | func (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 | ||
| 53 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | 67 | func (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 | ||
| 74 | func (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. |
| 92 | type 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. | ||
| 99 | const 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. | ||
| 105 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func 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. | ||
| 20 | func 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 | |||
| 43 | func 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 | |||
| 63 | func 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 | |||
| 85 | func 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 | |||
| 105 | func 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. | ||
| 275 | type 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. |
| 274 | func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error { | 280 | func (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. | ||
| 285 | func (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 | ||
| 344 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | 355 | func (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 |
| 193 | easier to pipe than to paste, and a minted token is shown once.</p> | 193 | easier 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 |
| 195 | gitbay web sessions list # browser sessions | 195 | gitbay web sessions list # browser sessions |
| 196 | gitbay admin ... # instance administration</pre> | 196 | gitbay 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: |