Skip to content

Commit 0f1027b

Browse files
committed
feat: add scope and allowlist support to tokens CLI
This commit adds comprehensive support for token scoping and allow-listing in the CLI token management commands: - Add --scope flag to create scoped tokens with specific permissions - Add --allow flag to create tokens restricted to specific resources - Display scopes and allow-list in token list/view commands - Add tokens view subcommand for detailed token inspection - Update help text and documentation with scoping examples - Add comprehensive test coverage for new functionality
1 parent 42451c1 commit 0f1027b

13 files changed

+420
-18
lines changed

cli/testdata/coder_tokens_--help.golden

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ USAGE:
1616

1717
$ coder tokens ls
1818

19+
- Create a scoped token:
20+
21+
$ coder tokens create --scope workspace:read --allow workspace:<uuid>
22+
1923
- Remove a token by ID:
2024

2125
$ coder tokens rm WuoWs4ZsMX
@@ -24,6 +28,7 @@ SUBCOMMANDS:
2428
create Create a token
2529
list List tokens
2630
remove Delete a token
31+
view Display detailed information about a token
2732

2833
———
2934
Run `coder --help` for a list of global options.

cli/testdata/coder_tokens_create_--help.golden

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ USAGE:
66
Create a token
77

88
OPTIONS:
9+
--allow allowList
10+
Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).
11+
912
--lifetime string, $CODER_TOKEN_LIFETIME
1013
Specify a duration for the lifetime of the token.
1114

1215
-n, --name string, $CODER_TOKEN_NAME
1316
Specify a human-readable name.
1417

18+
--scope scope
19+
Repeatable scope to attach to the token (e.g. workspace:read).
20+
1521
-u, --user string, $CODER_TOKEN_USER
1622
Specify the user to create the token for (Only works if logged in user
1723
is admin).

cli/testdata/coder_tokens_list_--help.golden

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ OPTIONS:
1212
Specifies whether all users' tokens will be listed or not (must have
1313
Owner role to see all tokens).
1414

15-
-c, --column [id|name|last used|expires at|created at|owner] (default: id,name,last used,expires at,created at)
15+
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at)
1616
Columns to display in table output.
1717

1818
-o, --output table|json (default: table)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
coder v0.0.0-devel
2+
3+
USAGE:
4+
coder tokens view [flags] <name|id>
5+
6+
Display detailed information about a token
7+
8+
OPTIONS:
9+
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at,owner)
10+
Columns to display in table output.
11+
12+
-o, --output table|json (default: table)
13+
Output format.
14+
15+
———
16+
Run `coder --help` for a list of global options.

cli/tokens.go

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"slices"
7+
"sort"
78
"strings"
89
"time"
910

@@ -27,6 +28,10 @@ func (r *RootCmd) tokens() *serpent.Command {
2728
Description: "List your tokens",
2829
Command: "coder tokens ls",
2930
},
31+
Example{
32+
Description: "Create a scoped token",
33+
Command: "coder tokens create --scope workspace:read --allow workspace:<uuid>",
34+
},
3035
Example{
3136
Description: "Remove a token by ID",
3237
Command: "coder tokens rm WuoWs4ZsMX",
@@ -39,6 +44,7 @@ func (r *RootCmd) tokens() *serpent.Command {
3944
Children: []*serpent.Command{
4045
r.createToken(),
4146
r.listTokens(),
47+
r.viewToken(),
4248
r.removeToken(),
4349
},
4450
}
@@ -50,6 +56,8 @@ func (r *RootCmd) createToken() *serpent.Command {
5056
tokenLifetime string
5157
name string
5258
user string
59+
scopes []codersdk.APIKeyScope
60+
allowList []codersdk.APIAllowListTarget
5361
)
5462
cmd := &serpent.Command{
5563
Use: "create",
@@ -88,10 +96,18 @@ func (r *RootCmd) createToken() *serpent.Command {
8896
}
8997
}
9098

91-
res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{
99+
req := codersdk.CreateTokenRequest{
92100
Lifetime: parsedLifetime,
93101
TokenName: name,
94-
})
102+
}
103+
if len(scopes) > 0 {
104+
req.Scopes = append([]codersdk.APIKeyScope(nil), scopes...)
105+
}
106+
if len(allowList) > 0 {
107+
req.AllowList = append([]codersdk.APIAllowListTarget(nil), allowList...)
108+
}
109+
110+
res, err := client.CreateToken(inv.Context(), userID, req)
95111
if err != nil {
96112
return xerrors.Errorf("create tokens: %w", err)
97113
}
@@ -123,6 +139,16 @@ func (r *RootCmd) createToken() *serpent.Command {
123139
Description: "Specify the user to create the token for (Only works if logged in user is admin).",
124140
Value: serpent.StringOf(&user),
125141
},
142+
{
143+
Flag: "scope",
144+
Description: "Repeatable scope to attach to the token (e.g. workspace:read).",
145+
Value: newScopeFlag(&scopes),
146+
},
147+
{
148+
Flag: "allow",
149+
Description: "Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).",
150+
Value: newAllowListFlag(&allowList),
151+
},
126152
}
127153

128154
return cmd
@@ -136,27 +162,59 @@ type tokenListRow struct {
136162
// For table format:
137163
ID string `json:"-" table:"id,default_sort"`
138164
TokenName string `json:"token_name" table:"name"`
165+
Scopes string `json:"-" table:"scopes"`
166+
Allow string `json:"-" table:"allow list"`
139167
LastUsed time.Time `json:"-" table:"last used"`
140168
ExpiresAt time.Time `json:"-" table:"expires at"`
141169
CreatedAt time.Time `json:"-" table:"created at"`
142170
Owner string `json:"-" table:"owner"`
143171
}
144172

145173
func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow {
174+
return tokenListRowFromKey(token.APIKey, token.Username)
175+
}
176+
177+
func tokenListRowFromKey(token codersdk.APIKey, owner string) tokenListRow {
146178
return tokenListRow{
147-
APIKey: token.APIKey,
179+
APIKey: token,
148180
ID: token.ID,
149181
TokenName: token.TokenName,
182+
Scopes: joinScopes(token.Scopes),
183+
Allow: joinAllowList(token.AllowList),
150184
LastUsed: token.LastUsed,
151185
ExpiresAt: token.ExpiresAt,
152186
CreatedAt: token.CreatedAt,
153-
Owner: token.Username,
187+
Owner: owner,
154188
}
155189
}
156190

191+
func joinScopes(scopes []codersdk.APIKeyScope) string {
192+
if len(scopes) == 0 {
193+
return ""
194+
}
195+
vals := make([]string, len(scopes))
196+
for i, scope := range scopes {
197+
vals[i] = string(scope)
198+
}
199+
sort.Strings(vals)
200+
return strings.Join(vals, ", ")
201+
}
202+
203+
func joinAllowList(entries []codersdk.APIAllowListTarget) string {
204+
if len(entries) == 0 {
205+
return ""
206+
}
207+
vals := make([]string, len(entries))
208+
for i, entry := range entries {
209+
vals[i] = entry.String()
210+
}
211+
sort.Strings(vals)
212+
return strings.Join(vals, ", ")
213+
}
214+
157215
func (r *RootCmd) listTokens() *serpent.Command {
158216
// we only display the 'owner' column if the --all argument is passed in
159-
defaultCols := []string{"id", "name", "last used", "expires at", "created at"}
217+
defaultCols := []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at"}
160218
if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") {
161219
defaultCols = append(defaultCols, "owner")
162220
}
@@ -226,6 +284,48 @@ func (r *RootCmd) listTokens() *serpent.Command {
226284
return cmd
227285
}
228286

287+
func (r *RootCmd) viewToken() *serpent.Command {
288+
formatter := cliui.NewOutputFormatter(
289+
cliui.TableFormat([]tokenListRow{}, []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at", "owner"}),
290+
cliui.JSONFormat(),
291+
)
292+
293+
cmd := &serpent.Command{
294+
Use: "view <name|id>",
295+
Short: "Display detailed information about a token",
296+
Middleware: serpent.Chain(
297+
serpent.RequireNArgs(1),
298+
),
299+
Handler: func(inv *serpent.Invocation) error {
300+
client, err := r.InitClient(inv)
301+
if err != nil {
302+
return err
303+
}
304+
305+
tokenName := inv.Args[0]
306+
token, err := client.APIKeyByName(inv.Context(), codersdk.Me, tokenName)
307+
if err != nil {
308+
maybeID := strings.Split(tokenName, "-")[0]
309+
token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID)
310+
if err != nil {
311+
return xerrors.Errorf("fetch api key by name or id: %w", err)
312+
}
313+
}
314+
315+
row := tokenListRowFromKey(*token, "")
316+
out, err := formatter.Format(inv.Context(), []tokenListRow{row})
317+
if err != nil {
318+
return err
319+
}
320+
_, err = fmt.Fprintln(inv.Stdout, out)
321+
return err
322+
},
323+
}
324+
325+
formatter.AttachOptions(&cmd.Options)
326+
return cmd
327+
}
328+
229329
func (r *RootCmd) removeToken() *serpent.Command {
230330
cmd := &serpent.Command{
231331
Use: "remove <name|id|token>",

cli/tokens_allowlist_flag.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
6+
"golang.org/x/xerrors"
7+
8+
"github.com/coder/coder/v2/codersdk"
9+
)
10+
11+
// allowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries.
12+
type allowListFlag struct {
13+
targets *[]codersdk.APIAllowListTarget
14+
}
15+
16+
func newAllowListFlag(dst *[]codersdk.APIAllowListTarget) *allowListFlag {
17+
return &allowListFlag{targets: dst}
18+
}
19+
20+
func (a *allowListFlag) ensureSlice() error {
21+
if a.targets == nil {
22+
return xerrors.New("allow list destination is nil")
23+
}
24+
if *a.targets == nil {
25+
*a.targets = make([]codersdk.APIAllowListTarget, 0)
26+
}
27+
return nil
28+
}
29+
30+
func (a *allowListFlag) String() string {
31+
if a.targets == nil || len(*a.targets) == 0 {
32+
return ""
33+
}
34+
parts := make([]string, len(*a.targets))
35+
for i, t := range *a.targets {
36+
parts[i] = t.String()
37+
}
38+
return strings.Join(parts, ",")
39+
}
40+
41+
func (a *allowListFlag) Set(raw string) error {
42+
if err := a.ensureSlice(); err != nil {
43+
return err
44+
}
45+
raw = strings.TrimSpace(raw)
46+
if raw == "" {
47+
return xerrors.New("allow list entry cannot be empty")
48+
}
49+
var target codersdk.APIAllowListTarget
50+
if err := target.UnmarshalText([]byte(raw)); err != nil {
51+
return err
52+
}
53+
*a.targets = append(*a.targets, target)
54+
return nil
55+
}
56+
57+
func (*allowListFlag) Type() string { return "allowList" }
58+
59+
func (a *allowListFlag) Append(value string) error {
60+
return a.Set(value)
61+
}
62+
63+
func (a *allowListFlag) Replace(items []string) error {
64+
if err := a.ensureSlice(); err != nil {
65+
return err
66+
}
67+
(*a.targets) = (*a.targets)[:0]
68+
for _, item := range items {
69+
if err := a.Set(item); err != nil {
70+
return err
71+
}
72+
}
73+
return nil
74+
}
75+
76+
func (a *allowListFlag) GetSlice() []string {
77+
if a.targets == nil {
78+
return nil
79+
}
80+
out := make([]string, len(*a.targets))
81+
for i, t := range *a.targets {
82+
out[i] = t.String()
83+
}
84+
return out
85+
}

0 commit comments

Comments
 (0)