Skip to content

Commit 33ee2a4

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 f6e86c6 commit 33ee2a4

12 files changed

Lines changed: 333 additions & 18 deletions

cli/allowlistflag.go

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

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 allow-list
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 string-array
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: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import (
44
"fmt"
55
"os"
66
"slices"
7+
"sort"
78
"strings"
89
"time"
910

1011
"golang.org/x/xerrors"
1112

1213
"github.com/coder/coder/v2/cli/cliui"
14+
"github.com/coder/coder/v2/coderd/util/slice"
1315
"github.com/coder/coder/v2/codersdk"
1416
"github.com/coder/serpent"
1517
)
@@ -27,6 +29,10 @@ func (r *RootCmd) tokens() *serpent.Command {
2729
Description: "List your tokens",
2830
Command: "coder tokens ls",
2931
},
32+
Example{
33+
Description: "Create a scoped token",
34+
Command: "coder tokens create --scope workspace:read --allow workspace:<uuid>",
35+
},
3036
Example{
3137
Description: "Remove a token by ID",
3238
Command: "coder tokens rm WuoWs4ZsMX",
@@ -39,6 +45,7 @@ func (r *RootCmd) tokens() *serpent.Command {
3945
Children: []*serpent.Command{
4046
r.createToken(),
4147
r.listTokens(),
48+
r.viewToken(),
4249
r.removeToken(),
4350
},
4451
}
@@ -50,6 +57,8 @@ func (r *RootCmd) createToken() *serpent.Command {
5057
tokenLifetime string
5158
name string
5259
user string
60+
scopes []string
61+
allowList []codersdk.APIAllowListTarget
5362
)
5463
cmd := &serpent.Command{
5564
Use: "create",
@@ -88,10 +97,18 @@ func (r *RootCmd) createToken() *serpent.Command {
8897
}
8998
}
9099

91-
res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{
100+
req := codersdk.CreateTokenRequest{
92101
Lifetime: parsedLifetime,
93102
TokenName: name,
94-
})
103+
}
104+
if len(req.Scopes) == 0 {
105+
req.Scopes = slice.StringEnums[codersdk.APIKeyScope](scopes)
106+
}
107+
if len(allowList) > 0 {
108+
req.AllowList = append([]codersdk.APIAllowListTarget(nil), allowList...)
109+
}
110+
111+
res, err := client.CreateToken(inv.Context(), userID, req)
95112
if err != nil {
96113
return xerrors.Errorf("create tokens: %w", err)
97114
}
@@ -123,6 +140,16 @@ func (r *RootCmd) createToken() *serpent.Command {
123140
Description: "Specify the user to create the token for (Only works if logged in user is admin).",
124141
Value: serpent.StringOf(&user),
125142
},
143+
{
144+
Flag: "scope",
145+
Description: "Repeatable scope to attach to the token (e.g. workspace:read).",
146+
Value: serpent.StringArrayOf(&scopes),
147+
},
148+
{
149+
Flag: "allow",
150+
Description: "Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).",
151+
Value: AllowListFlagOf(&allowList),
152+
},
126153
}
127154

128155
return cmd
@@ -136,27 +163,59 @@ type tokenListRow struct {
136163
// For table format:
137164
ID string `json:"-" table:"id,default_sort"`
138165
TokenName string `json:"token_name" table:"name"`
166+
Scopes string `json:"-" table:"scopes"`
167+
Allow string `json:"-" table:"allow list"`
139168
LastUsed time.Time `json:"-" table:"last used"`
140169
ExpiresAt time.Time `json:"-" table:"expires at"`
141170
CreatedAt time.Time `json:"-" table:"created at"`
142171
Owner string `json:"-" table:"owner"`
143172
}
144173

145174
func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow {
175+
return tokenListRowFromKey(token.APIKey, token.Username)
176+
}
177+
178+
func tokenListRowFromKey(token codersdk.APIKey, owner string) tokenListRow {
146179
return tokenListRow{
147-
APIKey: token.APIKey,
180+
APIKey: token,
148181
ID: token.ID,
149182
TokenName: token.TokenName,
183+
Scopes: joinScopes(token.Scopes),
184+
Allow: joinAllowList(token.AllowList),
150185
LastUsed: token.LastUsed,
151186
ExpiresAt: token.ExpiresAt,
152187
CreatedAt: token.CreatedAt,
153-
Owner: token.Username,
188+
Owner: owner,
154189
}
155190
}
156191

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

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

0 commit comments

Comments
 (0)