Commit dd36248950

dd36248950b65539421e485322ff455927a96328

parent: 89dd0a3eba

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:08 UTC

token: default --scope read; record the creating token; revoke --created

Ref #257

Layout: unified · split

CHANGELOG.org +3
@@ -12,6 +12,9 @@ anything beyond "replace the binary and restart" is needed.
12- A request whose credential has an expiry cannot run a command that 12- A request whose credential has an expiry cannot run a command that
13 mints another one — tokens, keys, login links, invites, accounts, 13 mints another one — tokens, keys, login links, invites, accounts,
14 verified addresses (#257). 14 verified addresses (#257).
15- Tokens and SSH keys record the token that created them; `token create`
16 defaults to =--scope read=; `token revoke --created` also revokes what
17 a token made, at any depth (#257).
15 18
16* v1.36.0 — 2026-09-23 19* v1.36.0 — 2026-09-23
17 20
e2e/api_test.go +2 −2
@@ -47,7 +47,7 @@ func TestJSONAPI(t *testing.T) {
47 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") 47 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
48 48
49 // Tokens are minted over SSH, shown once. 49 // Tokens are minted over SSH, shown once.
50 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json") 50 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--scope", "full", "--json")
51 if code != 0 { 51 if code != 0 {
52 t.Fatalf("token create: %s", errOut) 52 t.Fatalf("token create: %s", errOut)
53 } 53 }
@@ -265,7 +265,7 @@ func TestAPIRateLimit(t *testing.T) {
265// mintToken creates an API token over SSH and returns its value. 265// mintToken creates an API token over SSH and returns its value.
266func mintToken(t *testing.T, inst *instance, key, name string) string { 266func mintToken(t *testing.T, inst *instance, key, name string) string {
267 t.Helper() 267 t.Helper()
268 out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--json") 268 out, errOut, code := inst.ssh(t, key, "", "token", "create", "--name", name, "--scope", "full", "--json")
269 if code != 0 { 269 if code != 0 {
270 t.Fatalf("token create: %s", errOut) 270 t.Fatalf("token create: %s", errOut)
271 } 271 }
internal/control/adminhost.go +1 −1
@@ -125,7 +125,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
125 if pub != nil { 125 if pub != nil {
126 fp = ssh.FingerprintSHA256(pub) 126 fp = ssh.FingerprintSHA256(pub)
127 label, _ := keyLabel(comment) 127 label, _ := keyLabel(comment)
128 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 {
129 return c.failErr(err) 129 return c.failErr(err)
130 } 130 }
131 } 131 }
internal/control/deploykey.go +1 −1
@@ -69,7 +69,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
69 } 69 }
70 fp := ssh.FingerprintSHA256(pub) 70 fp := ssh.FingerprintSHA256(pub)
71 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) 71 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode)
72 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 {
73 if errors.Is(err, store.ErrDuplicateKey) { 73 if errors.Is(err, store.ErrDuplicateKey) {
74 return c.failErr(err) 74 return c.failErr(err)
75 } 75 }
internal/control/identity.go +3 −2
@@ -87,10 +87,11 @@ func runKeysList(c *Ctx, args []string) int {
87 Algo string `json:"algo"` 87 Algo string `json:"algo"`
88 Scope string `json:"scope"` 88 Scope string `json:"scope"`
89 Label string `json:"label"` 89 Label string `json:"label"`
90 CreatedBy string `json:"created_by,omitempty"`
90 } 91 }
91 var ds []out 92 var ds []out
92 for _, k := range keys { 93 for _, k := range keys {
93 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})
94 } 95 }
95 return c.emit(ds, func(w io.Writer) { 96 return c.emit(ds, func(w io.Writer) {
96 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") 97 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
@@ -150,7 +151,7 @@ func runKeysAdd(c *Ctx, args []string) int {
150 return c.fail(protocol.ExitUsage, "%v", err) 151 return c.fail(protocol.ExitUsage, "%v", err)
151 } 152 }
152 fp := ssh.FingerprintSHA256(pub) 153 fp := ssh.FingerprintSHA256(pub)
153 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 {
154 if errors.Is(err, store.ErrDuplicateKey) { 155 if errors.Is(err, store.ErrDuplicateKey) {
155 return c.failErr(err) 156 return c.failErr(err)
156 } 157 }
internal/control/runnerrepo.go +1 −1
@@ -58,7 +58,7 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
58 switch { 58 switch {
59 case errors.Is(err, store.ErrNotFound): 59 case errors.Is(err, store.ErrNotFound):
60 label, _ := keyLabel(comment) 60 label, _ := keyLabel(comment)
61 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 {
62 return c.fail(protocol.ExitFailure, "adding key: %v", err) 62 return c.fail(protocol.ExitFailure, "adding key: %v", err)
63 } 63 }
64 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { 64 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
internal/control/token.go +46 −15
@@ -15,13 +15,13 @@ import (
15func init() { 15func init() {
16 register(Command{Path: []string{"token", "create"}, 16 register(Command{Path: []string{"token", "create"},
17 Summary: "mint an API token (shown once)", 17 Summary: "mint an API token (shown once)",
18 Usage: "token create --name <n> [--scope full|read] [--ttl 30d|720h]", 18 Usage: "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
19 Flags: []Flag{ 19 Flags: []Flag{
20 {"--name", "<n>", "the token's name", ""}, 20 {"--name", "<n>", "the token's name", ""},
21 {"--scope", "full|read", "what the token may do", "full"}, 21 {"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
22 {"--ttl", "30d|720h", "how long the token is valid", "never expires"}, 22 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
23 }, 23 },
24 Examples: []string{"token create --name laptop --scope read --ttl 30d"}, 24 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
25 MintsCredential: true, 25 MintsCredential: true,
26 Run: runTokenCreate}) 26 Run: runTokenCreate})
27 register(Command{Path: []string{"token", "list"}, 27 register(Command{Path: []string{"token", "list"},
@@ -29,9 +29,12 @@ func init() {
29 Usage: "token list", 29 Usage: "token list",
30 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList}) 30 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList})
31 register(Command{Path: []string{"token", "revoke"}, 31 register(Command{Path: []string{"token", "revoke"},
32 Summary: "revoke an API token by name", 32 Summary: "revoke an API token by name",
33 Usage: "token revoke <name>", 33 Usage: "token revoke <name> [--created]",
34 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"},
35 Run: runTokenRevoke}) 38 Run: runTokenRevoke})
36} 39}
37 40
@@ -48,11 +51,11 @@ func parseTTL(s string) (time.Duration, error) {
48} 51}
49 52
50func runTokenCreate(c *Ctx, args []string) int { 53func runTokenCreate(c *Ctx, args []string) int {
51 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})
52 if err != nil { 55 if err != nil {
53 return c.fail(protocol.ExitUsage, "%v", err) 56 return c.fail(protocol.ExitUsage, "%v", err)
54 } 57 }
55 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl") 58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
56 if f.Has("--scope") { 59 if f.Has("--scope") {
57 scope = f.Value("--scope") 60 scope = f.Value("--scope")
58 } 61 }
@@ -99,10 +102,11 @@ func runTokenList(c *Ctx, args []string) int {
99 CreatedAt string `json:"created_at"` 102 CreatedAt string `json:"created_at"`
100 ExpiresAt *time.Time `json:"expires_at,omitempty"` 103 ExpiresAt *time.Time `json:"expires_at,omitempty"`
101 LastUsedAt *time.Time `json:"last_used_at,omitempty"` 104 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
105 CreatedBy string `json:"created_by,omitempty"`
102 } 106 }
103 var ds []out 107 var ds []out
104 for _, t := range tokens { 108 for _, t := range tokens {
105 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})
106 } 110 }
107 return c.emit(ds, func(w io.Writer) { 111 return c.emit(ds, func(w io.Writer) {
108 tb := c.table(w, "NAME", "SCOPE", "EXPIRES") 112 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
@@ -123,16 +127,43 @@ func runTokenList(c *Ctx, args []string) int {
123} 127}
124 128
125func runTokenRevoke(c *Ctx, args []string) int { 129func runTokenRevoke(c *Ctx, args []string) int {
126 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 == "" {
127 return c.usage() 136 return c.usage()
128 } 137 }
129 if _, err := c.Store.RevokeAPIToken(c.User.ID, args[0], false); err != nil { 138 withCreated := f.Has("--created")
139 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
140 if err != nil {
130 if errors.Is(err, store.ErrNotFound) { 141 if errors.Is(err, store.ErrNotFound) {
131 return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) 142 return c.fail(protocol.ExitNotFound, "no token named %q", name)
132 } 143 }
133 return c.fail(protocol.ExitFailure, "%v", err) 144 return c.fail(protocol.ExitFailure, "%v", err)
134 } 145 }
135 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) { 146 type out struct {
136 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 }
137 }) 168 })
138} 169}
internal/control/token_test.go +9 −1
@@ -64,9 +64,17 @@ func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
64 if err != nil { 64 if err != nil {
65 t.Fatal(err) 65 t.Fatal(err)
66 } 66 }
67 var found bool
67 for _, tk := range toks { 68 for _, tk := range toks {
68 if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") { 69 if tk.Name != "child" {
70 continue
71 }
72 found = true
73 if tk.Scope != "read" || tk.CreatedBy != "parent" {
69 t.Fatalf("child: %+v", tk) 74 t.Fatalf("child: %+v", tk)
70 } 75 }
71 } 76 }
77 if !found {
78 t.Fatal(`no token named "child"`)
79 }
72} 80}