Commit dd36248950
Verified · cmc
Layout: unified · split
CHANGELOG.org +3
| @@ -12,6 +12,9 @@ anything beyond "replace the binary and restart" is needed. | ||
| 12 | 12 | - A request whose credential has an expiry cannot run a command that |
| 13 | 13 | mints another one — tokens, keys, login links, invites, accounts, |
| 14 | 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 | 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 | 47 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") |
| 48 | 48 | |
| 49 | 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 | 51 | if code != 0 { |
| 52 | 52 | t.Fatalf("token create: %s", errOut) |
| 53 | 53 | } |
| @@ -265,7 +265,7 @@ func TestAPIRateLimit(t *testing.T) { | ||
| 265 | 265 | // mintToken creates an API token over SSH and returns its value. |
| 266 | 266 | func mintToken(t *testing.T, inst *instance, key, name string) string { |
| 267 | 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 | 269 | if code != 0 { |
| 270 | 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 | 125 | if pub != nil { |
| 126 | 126 | fp = ssh.FingerprintSHA256(pub) |
| 127 | 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 | 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 | 70 | fp := ssh.FingerprintSHA256(pub) |
| 71 | 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 | 73 | if errors.Is(err, store.ErrDuplicateKey) { |
| 74 | 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 | 87 | Algo string `json:"algo"` |
| 88 | 88 | Scope string `json:"scope"` |
| 89 | 89 | Label string `json:"label"` |
| 90 | CreatedBy string `json:"created_by,omitempty"` | |
| 90 | 91 | } |
| 91 | 92 | var ds []out |
| 92 | 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 | 96 | return c.emit(ds, func(w io.Writer) { |
| 96 | 97 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") |
| @@ -150,7 +151,7 @@ func runKeysAdd(c *Ctx, args []string) int { | ||
| 150 | 151 | return c.fail(protocol.ExitUsage, "%v", err) |
| 151 | 152 | } |
| 152 | 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 | 155 | if errors.Is(err, store.ErrDuplicateKey) { |
| 155 | 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 | 58 | switch { |
| 59 | 59 | case errors.Is(err, store.ErrNotFound): |
| 60 | 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 | 62 | return c.fail(protocol.ExitFailure, "adding key: %v", err) |
| 63 | 63 | } |
| 64 | 64 | if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { |
internal/control/token.go +46 −15
| @@ -15,13 +15,13 @@ import ( | ||
| 15 | 15 | func init() { |
| 16 | 16 | register(Command{Path: []string{"token", "create"}, |
| 17 | 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 | 19 | Flags: []Flag{ |
| 20 | 20 | {"--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"}, | |
| 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 | 25 | MintsCredential: true, |
| 26 | 26 | Run: runTokenCreate}) |
| 27 | 27 | register(Command{Path: []string{"token", "list"}, |
| @@ -29,9 +29,12 @@ func init() { | ||
| 29 | 29 | Usage: "token list", |
| 30 | 30 | Examples: []string{"token list"}, ReadOnly: true, Run: runTokenList}) |
| 31 | 31 | 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"}, | |
| 35 | 38 | Run: runTokenRevoke}) |
| 36 | 39 | } |
| 37 | 40 | |
| @@ -48,11 +51,11 @@ func parseTTL(s string) (time.Duration, error) { | ||
| 48 | 51 | } |
| 49 | 52 | |
| 50 | 53 | func 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 | 55 | if err != nil { |
| 53 | 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 | 59 | if f.Has("--scope") { |
| 57 | 60 | scope = f.Value("--scope") |
| 58 | 61 | } |
| @@ -99,10 +102,11 @@ func runTokenList(c *Ctx, args []string) int { | ||
| 99 | 102 | CreatedAt string `json:"created_at"` |
| 100 | 103 | ExpiresAt *time.Time `json:"expires_at,omitempty"` |
| 101 | 104 | LastUsedAt *time.Time `json:"last_used_at,omitempty"` |
| 105 | CreatedBy string `json:"created_by,omitempty"` | |
| 102 | 106 | } |
| 103 | 107 | var ds []out |
| 104 | 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 | 111 | return c.emit(ds, func(w io.Writer) { |
| 108 | 112 | tb := c.table(w, "NAME", "SCOPE", "EXPIRES") |
| @@ -123,16 +127,43 @@ func runTokenList(c *Ctx, args []string) int { | ||
| 123 | 127 | } |
| 124 | 128 | |
| 125 | 129 | func 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 | 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 | 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 | 144 | return c.fail(protocol.ExitFailure, "%v", err) |
| 134 | 145 | } |
| 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 | } | |
| 137 | 168 | }) |
| 138 | 169 | } |
internal/control/token_test.go +9 −1
| @@ -64,9 +64,17 @@ func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) { | ||
| 64 | 64 | if err != nil { |
| 65 | 65 | t.Fatal(err) |
| 66 | 66 | } |
| 67 | var found bool | |
| 67 | 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 | 74 | t.Fatalf("child: %+v", tk) |
| 70 | 75 | } |
| 71 | 76 | } |
| 77 | if !found { | |
| 78 | t.Fatal(`no token named "child"`) | |
| 79 | } | |
| 72 | 80 | } |