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.
1212- A request whose credential has an expiry cannot run a command that
1313 mints another one — tokens, keys, login links, invites, accounts,
1414 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).
1518
1619* v1.36.0 — 2026-09-23
1720
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 }
internal/control/adminhost.go +1 −1
@@ -125,7 +125,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
125125 if pub != nil {
126126 fp = ssh.FingerprintSHA256(pub)
127127 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 {
129129 return c.failErr(err)
130130 }
131131 }
internal/control/deploykey.go +1 −1
@@ -69,7 +69,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
6969 }
7070 fp := ssh.FingerprintSHA256(pub)
7171 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 {
7373 if errors.Is(err, store.ErrDuplicateKey) {
7474 return c.failErr(err)
7575 }
internal/control/identity.go +3 −2
@@ -87,10 +87,11 @@ func runKeysList(c *Ctx, args []string) int {
8787 Algo string `json:"algo"`
8888 Scope string `json:"scope"`
8989 Label string `json:"label"`
90 CreatedBy string `json:"created_by,omitempty"`
9091 }
9192 var ds []out
9293 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})
9495 }
9596 return c.emit(ds, func(w io.Writer) {
9697 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
@@ -150,7 +151,7 @@ func runKeysAdd(c *Ctx, args []string) int {
150151 return c.fail(protocol.ExitUsage, "%v", err)
151152 }
152153 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 {
154155 if errors.Is(err, store.ErrDuplicateKey) {
155156 return c.failErr(err)
156157 }
internal/control/runnerrepo.go +1 −1
@@ -58,7 +58,7 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
5858 switch {
5959 case errors.Is(err, store.ErrNotFound):
6060 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 {
6262 return c.fail(protocol.ExitFailure, "adding key: %v", err)
6363 }
6464 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
internal/control/token.go +46 −15
@@ -15,13 +15,13 @@ 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"},
24 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
2525 MintsCredential: true,
2626 Run: runTokenCreate})
2727 register(Command{Path: []string{"token", "list"},
@@ -29,9 +29,12 @@ func init() {
2929 Usage: "token list",
3030 Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList})
3131 register(Command{Path: []string{"token", "revoke"},
32 Summary: "revoke an API token by name",
33 Usage: "token revoke <name>",
34 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"},
3538 Run: runTokenRevoke})
3639}
3740
@@ -48,11 +51,11 @@ func parseTTL(s string) (time.Duration, error) {
4851}
4952
5053func 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})
5255 if err != nil {
5356 return c.fail(protocol.ExitUsage, "%v", err)
5457 }
55 name, scope, ttl := f.Value("--name"), "full", f.Value("--ttl")
58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
5659 if f.Has("--scope") {
5760 scope = f.Value("--scope")
5861 }
@@ -99,10 +102,11 @@ func runTokenList(c *Ctx, args []string) int {
99102 CreatedAt string `json:"created_at"`
100103 ExpiresAt *time.Time `json:"expires_at,omitempty"`
101104 LastUsedAt *time.Time `json:"last_used_at,omitempty"`
105 CreatedBy string `json:"created_by,omitempty"`
102106 }
103107 var ds []out
104108 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})
106110 }
107111 return c.emit(ds, func(w io.Writer) {
108112 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
@@ -123,16 +127,43 @@ func runTokenList(c *Ctx, args []string) int {
123127}
124128
125129func 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 == "" {
127136 return c.usage()
128137 }
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 {
130141 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)
132143 }
133144 return c.fail(protocol.ExitFailure, "%v", err)
134145 }
135 return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
136 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 }
137168 })
138169}
internal/control/token_test.go +9 −1
@@ -64,9 +64,17 @@ func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
6464 if err != nil {
6565 t.Fatal(err)
6666 }
67 var found bool
6768 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" {
6974 t.Fatalf("child: %+v", tk)
7075 }
7176 }
77 if !found {
78 t.Fatal(`no token named "child"`)
79 }
7280}