From cadf1352b459cfd03480beaf344580bd1e56cecf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 27 Oct 2025 17:07:25 +0100 Subject: [PATCH] feat: add scoped token support to CLI (#19985) Add support for scoped API tokens in CLI This PR adds CLI support for creating and viewing API tokens with scopes and allow lists. It includes: - New `--scope` and `--allow` flags for the `tokens create` command - A new `tokens view` command to display detailed information about a token - Updated table columns in `tokens list` to show scopes and allow list entries - Updated help text and examples These changes enable users to create tokens with limited permissions through the CLI, similar to the existing functionality in the web UI. --- cli/allowlistflag.go | 78 +++++++++++++ cli/testdata/coder_tokens_--help.golden | 5 + .../coder_tokens_create_--help.golden | 6 + cli/testdata/coder_tokens_list_--help.golden | 2 +- cli/testdata/coder_tokens_view_--help.golden | 16 +++ cli/tokens.go | 108 +++++++++++++++++- cli/tokens_test.go | 59 +++++++++- docs/manifest.json | 5 + docs/reference/cli/tokens.md | 15 ++- docs/reference/cli/tokens_create.md | 16 +++ docs/reference/cli/tokens_list.md | 8 +- docs/reference/cli/tokens_view.md | 30 +++++ 12 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 cli/allowlistflag.go create mode 100644 cli/testdata/coder_tokens_view_--help.golden create mode 100644 docs/reference/cli/tokens_view.md diff --git a/cli/allowlistflag.go b/cli/allowlistflag.go new file mode 100644 index 0000000000..208bf24b3e --- /dev/null +++ b/cli/allowlistflag.go @@ -0,0 +1,78 @@ +package cli + +import ( + "encoding/csv" + "strings" + + "github.com/spf13/pflag" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +var ( + _ pflag.SliceValue = &AllowListFlag{} + _ pflag.Value = &AllowListFlag{} +) + +// AllowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries. +type AllowListFlag []codersdk.APIAllowListTarget + +func AllowListFlagOf(al *[]codersdk.APIAllowListTarget) *AllowListFlag { + return (*AllowListFlag)(al) +} + +func (a AllowListFlag) String() string { + return strings.Join(a.GetSlice(), ",") +} + +func (a AllowListFlag) Value() []codersdk.APIAllowListTarget { + return []codersdk.APIAllowListTarget(a) +} + +func (AllowListFlag) Type() string { return "allow-list" } + +func (a *AllowListFlag) Set(set string) error { + values, err := csv.NewReader(strings.NewReader(set)).Read() + if err != nil { + return xerrors.Errorf("parse allow list entries as csv: %w", err) + } + for _, v := range values { + if err := a.Append(v); err != nil { + return err + } + } + return nil +} + +func (a *AllowListFlag) Append(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return xerrors.New("allow list entry cannot be empty") + } + var target codersdk.APIAllowListTarget + if err := target.UnmarshalText([]byte(value)); err != nil { + return err + } + + *a = append(*a, target) + return nil +} + +func (a *AllowListFlag) Replace(items []string) error { + *a = []codersdk.APIAllowListTarget{} + for _, item := range items { + if err := a.Append(item); err != nil { + return err + } + } + return nil +} + +func (a *AllowListFlag) GetSlice() []string { + out := make([]string, len(*a)) + for i, entry := range *a { + out[i] = entry.String() + } + return out +} diff --git a/cli/testdata/coder_tokens_--help.golden b/cli/testdata/coder_tokens_--help.golden index 7247c42a4b..fb58dab8b3 100644 --- a/cli/testdata/coder_tokens_--help.golden +++ b/cli/testdata/coder_tokens_--help.golden @@ -16,6 +16,10 @@ USAGE: $ coder tokens ls + - Create a scoped token: + + $ coder tokens create --scope workspace:read --allow workspace: + - Remove a token by ID: $ coder tokens rm WuoWs4ZsMX @@ -24,6 +28,7 @@ SUBCOMMANDS: create Create a token list List tokens remove Delete a token + view Display detailed information about a token ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_tokens_create_--help.golden b/cli/testdata/coder_tokens_create_--help.golden index 9399635563..6db7a07a27 100644 --- a/cli/testdata/coder_tokens_create_--help.golden +++ b/cli/testdata/coder_tokens_create_--help.golden @@ -6,12 +6,18 @@ USAGE: Create a token OPTIONS: + --allow allow-list + Repeatable allow-list entry (:, e.g. workspace:1234-...). + --lifetime string, $CODER_TOKEN_LIFETIME Specify a duration for the lifetime of the token. -n, --name string, $CODER_TOKEN_NAME Specify a human-readable name. + --scope string-array + Repeatable scope to attach to the token (e.g. workspace:read). + -u, --user string, $CODER_TOKEN_USER Specify the user to create the token for (Only works if logged in user is admin). diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 9ad17fbafb..a3c24bcd0f 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -12,7 +12,7 @@ OPTIONS: Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens). - -c, --column [id|name|last used|expires at|created at|owner] (default: id,name,last used,expires at,created at) + -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) Columns to display in table output. -o, --output table|json (default: table) diff --git a/cli/testdata/coder_tokens_view_--help.golden b/cli/testdata/coder_tokens_view_--help.golden new file mode 100644 index 0000000000..1bceac32ce --- /dev/null +++ b/cli/testdata/coder_tokens_view_--help.golden @@ -0,0 +1,16 @@ +coder v0.0.0-devel + +USAGE: + coder tokens view [flags] + + Display detailed information about a token + +OPTIONS: + -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) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/tokens.go b/cli/tokens.go index 5d63f2e1ae..1c1bcd78a2 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -4,12 +4,14 @@ import ( "fmt" "os" "slices" + "sort" "strings" "time" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -27,6 +29,10 @@ func (r *RootCmd) tokens() *serpent.Command { Description: "List your tokens", Command: "coder tokens ls", }, + Example{ + Description: "Create a scoped token", + Command: "coder tokens create --scope workspace:read --allow workspace:", + }, Example{ Description: "Remove a token by ID", Command: "coder tokens rm WuoWs4ZsMX", @@ -39,6 +45,7 @@ func (r *RootCmd) tokens() *serpent.Command { Children: []*serpent.Command{ r.createToken(), r.listTokens(), + r.viewToken(), r.removeToken(), }, } @@ -50,6 +57,8 @@ func (r *RootCmd) createToken() *serpent.Command { tokenLifetime string name string user string + scopes []string + allowList []codersdk.APIAllowListTarget ) cmd := &serpent.Command{ Use: "create", @@ -88,10 +97,18 @@ func (r *RootCmd) createToken() *serpent.Command { } } - res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{ + req := codersdk.CreateTokenRequest{ Lifetime: parsedLifetime, TokenName: name, - }) + } + if len(req.Scopes) == 0 { + req.Scopes = slice.StringEnums[codersdk.APIKeyScope](scopes) + } + if len(allowList) > 0 { + req.AllowList = append([]codersdk.APIAllowListTarget(nil), allowList...) + } + + res, err := client.CreateToken(inv.Context(), userID, req) if err != nil { return xerrors.Errorf("create tokens: %w", err) } @@ -123,6 +140,16 @@ func (r *RootCmd) createToken() *serpent.Command { Description: "Specify the user to create the token for (Only works if logged in user is admin).", Value: serpent.StringOf(&user), }, + { + Flag: "scope", + Description: "Repeatable scope to attach to the token (e.g. workspace:read).", + Value: serpent.StringArrayOf(&scopes), + }, + { + Flag: "allow", + Description: "Repeatable allow-list entry (:, e.g. workspace:1234-...).", + Value: AllowListFlagOf(&allowList), + }, } return cmd @@ -136,6 +163,8 @@ type tokenListRow struct { // For table format: ID string `json:"-" table:"id,default_sort"` TokenName string `json:"token_name" table:"name"` + Scopes string `json:"-" table:"scopes"` + Allow string `json:"-" table:"allow list"` LastUsed time.Time `json:"-" table:"last used"` ExpiresAt time.Time `json:"-" table:"expires at"` CreatedAt time.Time `json:"-" table:"created at"` @@ -143,20 +172,47 @@ type tokenListRow struct { } func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow { + return tokenListRowFromKey(token.APIKey, token.Username) +} + +func tokenListRowFromKey(token codersdk.APIKey, owner string) tokenListRow { return tokenListRow{ - APIKey: token.APIKey, + APIKey: token, ID: token.ID, TokenName: token.TokenName, + Scopes: joinScopes(token.Scopes), + Allow: joinAllowList(token.AllowList), LastUsed: token.LastUsed, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, - Owner: token.Username, + Owner: owner, } } +func joinScopes(scopes []codersdk.APIKeyScope) string { + if len(scopes) == 0 { + return "" + } + vals := slice.ToStrings(scopes) + sort.Strings(vals) + return strings.Join(vals, ", ") +} + +func joinAllowList(entries []codersdk.APIAllowListTarget) string { + if len(entries) == 0 { + return "" + } + vals := make([]string, len(entries)) + for i, entry := range entries { + vals[i] = entry.String() + } + sort.Strings(vals) + return strings.Join(vals, ", ") +} + func (r *RootCmd) listTokens() *serpent.Command { // we only display the 'owner' column if the --all argument is passed in - defaultCols := []string{"id", "name", "last used", "expires at", "created at"} + defaultCols := []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at"} if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") { defaultCols = append(defaultCols, "owner") } @@ -226,6 +282,48 @@ func (r *RootCmd) listTokens() *serpent.Command { return cmd } +func (r *RootCmd) viewToken() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]tokenListRow{}, []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at", "owner"}), + cliui.JSONFormat(), + ) + + cmd := &serpent.Command{ + Use: "view ", + Short: "Display detailed information about a token", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + tokenName := inv.Args[0] + token, err := client.APIKeyByName(inv.Context(), codersdk.Me, tokenName) + if err != nil { + maybeID := strings.Split(tokenName, "-")[0] + token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID) + if err != nil { + return xerrors.Errorf("fetch api key by name or id: %w", err) + } + } + + row := tokenListRowFromKey(*token, "") + out, err := formatter.Format(inv.Context(), []tokenListRow{row}) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + func (r *RootCmd) removeToken() *serpent.Command { cmd := &serpent.Command{ Use: "remove ", diff --git a/cli/tokens_test.go b/cli/tokens_test.go index 0c717bb890..990516aa9b 100644 --- a/cli/tokens_test.go +++ b/cli/tokens_test.go @@ -4,10 +4,13 @@ import ( "bytes" "context" "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/require" + "github.com/google/uuid" + "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" @@ -46,6 +49,18 @@ func TestTokens(t *testing.T) { require.NotEmpty(t, res) id := res[:10] + allowWorkspaceID := uuid.New() + allowSpec := fmt.Sprintf("workspace:%s", allowWorkspaceID.String()) + inv, root = clitest.New(t, "tokens", "create", "--name", "scoped-token", "--scope", string(codersdk.APIKeyScopeWorkspaceRead), "--allow", allowSpec) + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + scopedTokenID := res[:10] + // Test creating a token for second user from first user's (admin) session inv, root = clitest.New(t, "tokens", "create", "--name", "token-two", "--user", secondUser.ID.String()) clitest.SetupConfig(t, client, root) @@ -67,7 +82,7 @@ func TestTokens(t *testing.T) { require.NoError(t, err) res = buf.String() require.NotEmpty(t, res) - // Result should only contain the token created for the admin user + // Result should only contain the tokens created for the admin user require.Contains(t, res, "ID") require.Contains(t, res, "EXPIRES AT") require.Contains(t, res, "CREATED AT") @@ -76,6 +91,16 @@ func TestTokens(t *testing.T) { // Result should not contain the token created for the second user require.NotContains(t, res, secondTokenID) + inv, root = clitest.New(t, "tokens", "view", "scoped-token") + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + res = buf.String() + require.Contains(t, res, string(codersdk.APIKeyScopeWorkspaceRead)) + require.Contains(t, res, allowSpec) + // Test listing tokens from the second user's session inv, root = clitest.New(t, "tokens", "ls") clitest.SetupConfig(t, secondUserClient, root) @@ -101,6 +126,14 @@ func TestTokens(t *testing.T) { // User (non-admin) should not be able to create a token for another user require.Error(t, err) + inv, root = clitest.New(t, "tokens", "create", "--name", "invalid-allow", "--allow", "badvalue") + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid allow_list entry") + inv, root = clitest.New(t, "tokens", "ls", "--output=json") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) @@ -110,8 +143,17 @@ func TestTokens(t *testing.T) { var tokens []codersdk.APIKey require.NoError(t, json.Unmarshal(buf.Bytes(), &tokens)) - require.Len(t, tokens, 1) - require.Equal(t, id, tokens[0].ID) + require.Len(t, tokens, 2) + tokenByName := make(map[string]codersdk.APIKey, len(tokens)) + for _, tk := range tokens { + tokenByName[tk.TokenName] = tk + } + require.Contains(t, tokenByName, "token-one") + require.Contains(t, tokenByName, "scoped-token") + scopedToken := tokenByName["scoped-token"] + require.Contains(t, scopedToken.Scopes, codersdk.APIKeyScopeWorkspaceRead) + require.Len(t, scopedToken.AllowList, 1) + require.Equal(t, allowSpec, scopedToken.AllowList[0].String()) // Delete by name inv, root = clitest.New(t, "tokens", "rm", "token-one") @@ -135,6 +177,17 @@ func TestTokens(t *testing.T) { require.NotEmpty(t, res) require.Contains(t, res, "deleted") + // Delete scoped token by ID + inv, root = clitest.New(t, "tokens", "rm", scopedTokenID) + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + require.Contains(t, res, "deleted") + // Create third token inv, root = clitest.New(t, "tokens", "create", "--name", "token-three") clitest.SetupConfig(t, client, root) diff --git a/docs/manifest.json b/docs/manifest.json index 8f624cb7f2..78a0d38ec9 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1798,6 +1798,11 @@ "description": "Delete a token", "path": "reference/cli/tokens_remove.md" }, + { + "title": "tokens view", + "description": "Display detailed information about a token", + "path": "reference/cli/tokens_view.md" + }, { "title": "unfavorite", "description": "Remove a workspace from your favorites", diff --git a/docs/reference/cli/tokens.md b/docs/reference/cli/tokens.md index 36b6575ed3..fd4369d5e6 100644 --- a/docs/reference/cli/tokens.md +++ b/docs/reference/cli/tokens.md @@ -25,6 +25,10 @@ Tokens are used to authenticate automated clients to Coder. $ coder tokens ls + - Create a scoped token: + + $ coder tokens create --scope workspace:read --allow workspace: + - Remove a token by ID: $ coder tokens rm WuoWs4ZsMX @@ -32,8 +36,9 @@ Tokens are used to authenticate automated clients to Coder. ## Subcommands -| Name | Purpose | -|-------------------------------------------|----------------| -| [create](./tokens_create.md) | Create a token | -| [list](./tokens_list.md) | List tokens | -| [remove](./tokens_remove.md) | Delete a token | +| Name | Purpose | +|-------------------------------------------|--------------------------------------------| +| [create](./tokens_create.md) | Create a token | +| [list](./tokens_list.md) | List tokens | +| [view](./tokens_view.md) | Display detailed information about a token | +| [remove](./tokens_remove.md) | Delete a token | diff --git a/docs/reference/cli/tokens_create.md b/docs/reference/cli/tokens_create.md index 7ad9699c17..d5dd916a46 100644 --- a/docs/reference/cli/tokens_create.md +++ b/docs/reference/cli/tokens_create.md @@ -37,3 +37,19 @@ Specify a human-readable name. | Environment | $CODER_TOKEN_USER | Specify the user to create the token for (Only works if logged in user is admin). + +### --scope + +| | | +|------|---------------------------| +| Type | string-array | + +Repeatable scope to attach to the token (e.g. workspace:read). + +### --allow + +| | | +|------|-------------------------| +| Type | allow-list | + +Repeatable allow-list entry (:, e.g. workspace:1234-...). diff --git a/docs/reference/cli/tokens_list.md b/docs/reference/cli/tokens_list.md index 150b411855..53d5e9b7b5 100644 --- a/docs/reference/cli/tokens_list.md +++ b/docs/reference/cli/tokens_list.md @@ -25,10 +25,10 @@ Specifies whether all users' tokens will be listed or not (must have Owner role ### -c, --column -| | | -|---------|-------------------------------------------------------------------| -| Type | [id\|name\|last used\|expires at\|created at\|owner] | -| Default | id,name,last used,expires at,created at | +| | | +|---------|---------------------------------------------------------------------------------------| +| Type | [id\|name\|scopes\|allow list\|last used\|expires at\|created at\|owner] | +| Default | id,name,scopes,allow list,last used,expires at,created at | Columns to display in table output. diff --git a/docs/reference/cli/tokens_view.md b/docs/reference/cli/tokens_view.md new file mode 100644 index 0000000000..f5008f5e41 --- /dev/null +++ b/docs/reference/cli/tokens_view.md @@ -0,0 +1,30 @@ + +# tokens view + +Display detailed information about a token + +## Usage + +```console +coder tokens view [flags] +``` + +## Options + +### -c, --column + +| | | +|---------|---------------------------------------------------------------------------------------| +| Type | [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 | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format.