diff --git a/cli/testdata/coder_tokens_--help.golden b/cli/testdata/coder_tokens_--help.golden index fb58dab8b3..ac56408f6f 100644 --- a/cli/testdata/coder_tokens_--help.golden +++ b/cli/testdata/coder_tokens_--help.golden @@ -27,7 +27,7 @@ USAGE: SUBCOMMANDS: create Create a token list List tokens - remove Delete a token + remove Expire or delete a token view Display detailed information about a token ——— diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index a3c24bcd0f..3a0f4ed722 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -15,6 +15,10 @@ 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) Columns to display in table output. + --include-expired bool + Include expired tokens in the output. By default, expired tokens are + hidden. + -o, --output table|json (default: table) Output format. diff --git a/cli/testdata/coder_tokens_remove_--help.golden b/cli/testdata/coder_tokens_remove_--help.golden index 63caab0c7e..b6d500f395 100644 --- a/cli/testdata/coder_tokens_remove_--help.golden +++ b/cli/testdata/coder_tokens_remove_--help.golden @@ -1,11 +1,19 @@ coder v0.0.0-devel USAGE: - coder tokens remove + coder tokens remove [flags] - Delete a token + Expire or delete a token Aliases: delete, rm + Remove a token by expiring it. Use --delete to permanently hard-delete the + token instead. + +OPTIONS: + --delete bool + Permanently delete the token instead of expiring it. This removes the + audit trail. + ——— Run `coder --help` for a list of global options. diff --git a/cli/tokens.go b/cli/tokens.go index 624b91dae2..5920336e89 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -218,9 +218,10 @@ func (r *RootCmd) listTokens() *serpent.Command { } var ( - all bool - displayTokens []tokenListRow - formatter = cliui.NewOutputFormatter( + all bool + includeExpired bool + displayTokens []tokenListRow + formatter = cliui.NewOutputFormatter( cliui.TableFormat([]tokenListRow{}, defaultCols), cliui.JSONFormat(), ) @@ -246,6 +247,20 @@ func (r *RootCmd) listTokens() *serpent.Command { return xerrors.Errorf("list tokens: %w", err) } + // Filter out expired tokens unless --include-expired is set + // TODO(Cian): This _could_ get too big for client-side filtering. + // If it causes issues, we can filter server-side. + if !includeExpired { + now := time.Now() + filtered := make([]codersdk.APIKeyWithOwner, 0, len(tokens)) + for _, token := range tokens { + if token.ExpiresAt.After(now) { + filtered = append(filtered, token) + } + } + tokens = filtered + } + displayTokens = make([]tokenListRow, len(tokens)) for i, token := range tokens { @@ -274,6 +289,12 @@ func (r *RootCmd) listTokens() *serpent.Command { Description: "Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens).", Value: serpent.BoolOf(&all), }, + { + Name: "include-expired", + Flag: "include-expired", + Description: "Include expired tokens in the output. By default, expired tokens are hidden.", + Value: serpent.BoolOf(&includeExpired), + }, } formatter.AttachOptions(&cmd.Options) @@ -323,10 +344,13 @@ func (r *RootCmd) viewToken() *serpent.Command { } func (r *RootCmd) removeToken() *serpent.Command { + var deleteToken bool cmd := &serpent.Command{ Use: "remove ", Aliases: []string{"delete"}, - Short: "Delete a token", + Short: "Expire or delete a token", + Long: "Remove a token by expiring it. Use --delete to permanently hard-" + + "delete the token instead.", Middleware: serpent.Chain( serpent.RequireNArgs(1), ), @@ -338,7 +362,7 @@ func (r *RootCmd) removeToken() *serpent.Command { token, err := client.APIKeyByName(inv.Context(), codersdk.Me, inv.Args[0]) if err != nil { - // If it's a token, we need to extract the ID + // If it's a token, we need to extract the ID. maybeID := strings.Split(inv.Args[0], "-")[0] token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID) if err != nil { @@ -346,19 +370,31 @@ func (r *RootCmd) removeToken() *serpent.Command { } } - err = client.DeleteAPIKey(inv.Context(), codersdk.Me, token.ID) - if err != nil { - return xerrors.Errorf("delete api key: %w", err) + if deleteToken { + err = client.DeleteAPIKey(inv.Context(), codersdk.Me, token.ID) + if err != nil { + return xerrors.Errorf("delete api key: %w", err) + } + cliui.Infof(inv.Stdout, "Token has been deleted.") + return nil } - cliui.Infof( - inv.Stdout, - "Token has been deleted.", - ) - + err = client.ExpireAPIKey(inv.Context(), codersdk.Me, token.ID) + if err != nil { + return xerrors.Errorf("expire api key: %w", err) + } + cliui.Infof(inv.Stdout, "Token has been expired.") return nil }, } + cmd.Options = serpent.OptionSet{ + { + Flag: "delete", + Description: "Permanently delete the token instead of expiring it. This removes the audit trail.", + Value: serpent.BoolOf(&deleteToken), + }, + } + return cmd } diff --git a/cli/tokens_test.go b/cli/tokens_test.go index 565084fad8..f13ffba334 100644 --- a/cli/tokens_test.go +++ b/cli/tokens_test.go @@ -6,12 +6,16 @@ import ( "encoding/json" "fmt" "testing" + "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -22,7 +26,7 @@ func TestTokens(t *testing.T) { adminUser := coderdtest.CreateFirstUser(t, client) secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) - _, thirdUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) + thirdUserClient, thirdUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancelFunc() @@ -155,7 +159,7 @@ func TestTokens(t *testing.T) { require.Len(t, scopedToken.AllowList, 1) require.Equal(t, allowSpec, scopedToken.AllowList[0].String()) - // Delete by name + // Delete by name (default behavior is now expire) inv, root = clitest.New(t, "tokens", "rm", "token-one") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) @@ -164,10 +168,31 @@ func TestTokens(t *testing.T) { require.NoError(t, err) res = buf.String() require.NotEmpty(t, res) - require.Contains(t, res, "deleted") + require.Contains(t, res, "expired") - // Delete by ID + // Regular users cannot expire other users' tokens (expire is default now). inv, root = clitest.New(t, "tokens", "rm", secondTokenID) + clitest.SetupConfig(t, thirdUserClient, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Only admin users can expire other users' tokens (expire is default now). + inv, root = clitest.New(t, "tokens", "rm", secondTokenID) + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + // Validate that token was expired + if token, err := client.APIKeyByName(ctx, secondUser.ID.String(), "token-two"); assert.NoError(t, err) { + require.True(t, token.ExpiresAt.Before(time.Now())) + } + + // Delete by ID (explicit delete flag) + inv, root = clitest.New(t, "tokens", "rm", "--delete", secondTokenID) clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) inv.Stdout = buf @@ -177,8 +202,8 @@ 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) + // Delete scoped token by ID (explicit delete flag) + inv, root = clitest.New(t, "tokens", "rm", "--delete", scopedTokenID) clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) inv.Stdout = buf @@ -199,8 +224,8 @@ func TestTokens(t *testing.T) { require.NotEmpty(t, res) fourthToken := res - // Delete by token - inv, root = clitest.New(t, "tokens", "rm", fourthToken) + // Delete by token (explicit delete flag) + inv, root = clitest.New(t, "tokens", "rm", "--delete", fourthToken) clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) inv.Stdout = buf @@ -210,3 +235,114 @@ func TestTokens(t *testing.T) { require.NotEmpty(t, res) require.Contains(t, res, "deleted") } + +func TestTokensListExpiredFiltering(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + // Create a valid (non-expired) token + validToken, _ := dbgen.APIKey(t, api.Database, database.APIKey{ + UserID: owner.UserID, + ExpiresAt: time.Now().Add(24 * time.Hour), + LoginType: database.LoginTypeToken, + TokenName: "valid-token", + }) + + // Create an expired token + expiredToken, _ := dbgen.APIKey(t, api.Database, database.APIKey{ + UserID: owner.UserID, + ExpiresAt: time.Now().Add(-24 * time.Hour), + LoginType: database.LoginTypeToken, + TokenName: "expired-token", + }) + + t.Run("HidesExpiredByDefault", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + inv, root := clitest.New(t, "tokens", "ls") + 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, validToken.ID) + require.Contains(t, res, "valid-token") + require.NotContains(t, res, expiredToken.ID) + require.NotContains(t, res, "expired-token") + }) + + t.Run("ShowsExpiredWithFlag", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + inv, root := clitest.New(t, "tokens", "ls", "--include-expired") + 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, validToken.ID) + require.Contains(t, res, "valid-token") + require.Contains(t, res, expiredToken.ID) + require.Contains(t, res, "expired-token") + }) + + t.Run("JSONOutputRespectsFilter", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Default (no expired) + inv, root := clitest.New(t, "tokens", "ls", "--output=json") + 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, "valid-token") + require.NotContains(t, res, "expired-token") + + // With --include-expired + inv, root = clitest.New(t, "tokens", "ls", "--output=json", "--include-expired") + 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, "valid-token") + require.Contains(t, res, "expired-token") + }) + + t.Run("AllUsersWithIncludeExpired", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + inv, root := clitest.New(t, "tokens", "ls", "--all", "--include-expired") + clitest.SetupConfig(t, client, root) + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + res := buf.String() + // Should show both valid and expired tokens + require.Contains(t, res, validToken.ID) + require.Contains(t, res, "valid-token") + require.Contains(t, res, expiredToken.ID) + require.Contains(t, res, "expired-token") + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 32eab3d45c..37d5ee5d07 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8386,6 +8386,54 @@ const docTemplate = `{ } } }, + "/users/{user}/keys/{keyid}/expire": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Users" + ], + "summary": "Expire API key", + "operationId": "expire-api-key", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key ID", + "name": "keyid", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/users/{user}/login-type": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 156c5a8a42..20b19ba437 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7417,6 +7417,52 @@ } } }, + "/users/{user}/keys/{keyid}/expire": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Users"], + "summary": "Expire API key", + "operationId": "expire-api-key", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key ID", + "name": "keyid", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/users/{user}/login-type": { "get": { "security": [ diff --git a/coderd/apikey.go b/coderd/apikey.go index 303de98b3e..d88e9a063b 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -421,6 +421,69 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } +// @Summary Expire API key +// @ID expire-api-key +// @Security CoderSessionToken +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Param keyid path string true "Key ID" format(string) +// @Success 204 +// @Failure 404 {object} codersdk.Response +// @Failure 500 {object} codersdk.Response +// @Router /users/{user}/keys/{keyid}/expire [put] +func (api *API) expireAPIKey(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + keyID = chi.URLParam(r, "keyid") + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + + if err := api.Database.InTx(func(db database.Store) error { + key, err := db.GetAPIKeyByID(ctx, keyID) + if err != nil { + return xerrors.Errorf("fetch API key: %w", err) + } + if !key.ExpiresAt.After(api.Clock.Now()) { + return nil // Already expired + } + aReq.Old = key + if err := db.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ + ID: key.ID, + LastUsed: key.LastUsed, + ExpiresAt: dbtime.Now(), + IPAddress: key.IPAddress, + }); err != nil { + return xerrors.Errorf("expire API key: %w", err) + } + // Fetch the updated key for audit log. + newKey, err := db.GetAPIKeyByID(ctx, keyID) + if err != nil { + api.Logger.Warn(ctx, "failed to fetch updated API key for audit log", slog.Error(err)) + } else { + aReq.New = newKey + } + return nil + }, nil); httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } else if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error expiring API key.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + // @Summary Get token config // @ID get-token-config // @Security CoderSessionToken diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 76d4f9bf11..4da1006524 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -439,7 +439,7 @@ func TestAPIKey_PrebuildsNotAllowed(t *testing.T) { DeploymentValues: dc, }) - ctx := testutil.Context(t, testutil.WaitLong) + setupCtx := testutil.Context(t, testutil.WaitLong) // Given: an existing api token for the prebuilds user _, prebuildsToken := dbgen.APIKey(t, db, database.APIKey{ @@ -448,12 +448,167 @@ func TestAPIKey_PrebuildsNotAllowed(t *testing.T) { client.SetSessionToken(prebuildsToken) // When: the prebuilds user tries to create an API key - _, err := client.CreateAPIKey(ctx, database.PrebuildsSystemUserID.String()) + _, err := client.CreateAPIKey(setupCtx, database.PrebuildsSystemUserID.String()) // Then: denied. require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message) // When: the prebuilds user tries to create a token - _, err = client.CreateToken(ctx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{}) + _, err = client.CreateToken(setupCtx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{}) // Then: also denied. require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message) } + +//nolint:tparallel,paralleltest // Subtests share the same coderdtest instance and auditor. +func TestExpireAPIKey(t *testing.T) { + t.Parallel() + + auditor := audit.NewMock() + adminClient := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + admin := coderdtest.CreateFirstUser(t, adminClient) + memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID) + + t.Run("OwnerCanExpireOwnToken", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a token. + res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 7, + }) + require.NoError(t, err) + keyID := strings.Split(res.Key, "-")[0] + + // Verify the token is not expired. + key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.NoError(t, err) + require.True(t, key.ExpiresAt.After(time.Now())) + + auditor.ResetLogs() + + // Expire the token. + err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID) + require.NoError(t, err) + + // Verify the token is expired. + key, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.NoError(t, err) + require.True(t, key.ExpiresAt.Before(time.Now())) + + // Verify audit log. + als := auditor.AuditLogs() + require.Len(t, als, 1) + require.Equal(t, database.AuditActionWrite, als[0].Action) + require.Equal(t, database.ResourceTypeApiKey, als[0].ResourceType) + require.Equal(t, admin.UserID.String(), als[0].UserID.String()) + }) + + t.Run("AdminCanExpireOtherUsersToken", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a token for the member. + res, err := memberClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 7, + }) + require.NoError(t, err) + keyID := strings.Split(res.Key, "-")[0] + + // Admin expires the member's token. + err = adminClient.ExpireAPIKey(ctx, member.ID.String(), keyID) + require.NoError(t, err) + + // Verify the token is expired. + key, err := memberClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.NoError(t, err) + require.True(t, key.ExpiresAt.Before(time.Now())) + }) + + t.Run("MemberCannotExpireOtherUsersToken", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a token for the admin. + res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 7, + }) + require.NoError(t, err) + keyID := strings.Split(res.Key, "-")[0] + + // Member attempts to expire admin's token. + err = memberClient.ExpireAPIKey(ctx, admin.UserID.String(), keyID) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + // Members cannot read other users, so they get a 404 Not Found + // from the authorization layer. + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("NotFound", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Try to expire a non-existent token. + err := adminClient.ExpireAPIKey(ctx, codersdk.Me, "nonexistent") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("ExpiringAlreadyExpiredTokenSucceeds", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Create and expire a token. + res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 7, + }) + require.NoError(t, err) + keyID := strings.Split(res.Key, "-")[0] + + // Expire it once. + err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID) + require.NoError(t, err) + + // Invariant: make sure it's actually expired + key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.NoError(t, err) + require.LessOrEqual(t, key.ExpiresAt, time.Now(), "key should be expired") + + // Expire it again - should succeed (idempotent). + err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID) + require.NoError(t, err) + + // Token should still be just as expired as before. No more, no less. + keyAgain, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.NoError(t, err) + require.Equal(t, key.ExpiresAt, keyAgain.ExpiresAt, "expiration should be idempotent") + }) + + t.Run("DeletingExpiredTokenSucceeds", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a token. + res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 7, + }) + require.NoError(t, err) + keyID := strings.Split(res.Key, "-")[0] + + // Expire it first. + err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID) + require.NoError(t, err) + + // Verify it's expired. + key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.NoError(t, err) + require.True(t, key.ExpiresAt.Before(time.Now())) + + // Delete the expired token - should succeed. + err = adminClient.DeleteAPIKey(ctx, codersdk.Me, keyID) + require.NoError(t, err) + + // Verify it's gone. + _, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index fffea6a19e..24a9e7c531 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1399,6 +1399,7 @@ func New(options *Options) *API { r.Route("/{keyid}", func(r chi.Router) { r.Get("/", api.apiKeyByID) r.Delete("/", api.deleteAPIKey) + r.Put("/expire", api.expireAPIKey) }) }) diff --git a/codersdk/apikey.go b/codersdk/apikey.go index a5b622c73a..401ade022a 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -171,6 +171,20 @@ func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) err return nil } +// ExpireAPIKey expires an API key by id, setting its expiry to now. +// This preserves the API key record for audit purposes rather than deleting it. +func (c *Client) ExpireAPIKey(ctx context.Context, userID string, id string) error { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/keys/%s/expire", userID, id), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode > http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // GetTokenConfig returns deployment options related to token management func (c *Client) GetTokenConfig(ctx context.Context, userID string) (TokenConfig, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens/tokenconfig", userID), nil) diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 9c4fb5dfc6..7e6ec3cae1 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -13,32 +13,32 @@ We track the following resources: -| Resource | | | -|----------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| APIKey
login, logout, register, create, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete | |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| -| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| -| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| -| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| -| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| -| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
workspace_sharing_disabledtrue
| -| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| -| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| -| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| -| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| -| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| -| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| -| User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| -| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| +| Resource | | | +|-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete | |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| +| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| +| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| +| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| +| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| +| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| +| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| +| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
workspace_sharing_disabledtrue
| +| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| +| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| +| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| +| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| +| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| +| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| +| User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| +| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/admin/users/sessions-tokens.md b/docs/admin/users/sessions-tokens.md index 82da4b845c..cd97b4b4ba 100644 --- a/docs/admin/users/sessions-tokens.md +++ b/docs/admin/users/sessions-tokens.md @@ -96,6 +96,24 @@ You can use the server flag to set the maximum duration for long-lived tokens in your deployment. +### Remove or expire a token + +You can remove a token using the CLI or the API. By default, `coder tokens remove` +expires the token, (soft-delete): + +```console +coder tokens remove +``` + +Expired tokens can no longer be used for authentication but remain visible in +token listings. + +To hard-delete a token, use the `--delete` flag: + +```console +coder tokens remove --delete +``` + ## API Key Scopes API key scopes allow you to limit the permissions of a token to specific operations. By default, tokens are created with the `all` scope, granting full access to all actions the user can perform. For improved security, you can create tokens with limited scopes that restrict access to only the operations needed. diff --git a/docs/manifest.json b/docs/manifest.json index 7805e79b5b..a732fd6f43 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2126,7 +2126,7 @@ }, { "title": "tokens remove", - "description": "Delete a token", + "description": "Expire or delete a token", "path": "reference/cli/tokens_remove.md" }, { diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index ffc7dd51a9..5b0478dcbd 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -1015,6 +1015,40 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Expire API key + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/keys/{keyid}/expire \ + -H 'Accept: */*' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /users/{user}/keys/{keyid}/expire` + +### Parameters + +| Name | In | Type | Required | Description | +|---------|------|----------------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | +| `keyid` | path | string(string) | true | Key ID | + +### Example responses + +> 404 Response + +### Responses + +| Status | Meaning | Description | Schema | +|--------|----------------------------------------------------------------------------|-----------------------|--------------------------------------------------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +| 404 | [Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4) | Not Found | [codersdk.Response](schemas.md#codersdkresponse) | +| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | Internal Server Error | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get user login type ### Code samples diff --git a/docs/reference/cli/tokens.md b/docs/reference/cli/tokens.md index fd4369d5e6..687b90b3e3 100644 --- a/docs/reference/cli/tokens.md +++ b/docs/reference/cli/tokens.md @@ -41,4 +41,4 @@ Tokens are used to authenticate automated clients to Coder. | [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 | +| [remove](./tokens_remove.md) | Expire or delete a token | diff --git a/docs/reference/cli/tokens_list.md b/docs/reference/cli/tokens_list.md index 53d5e9b7b5..273901870b 100644 --- a/docs/reference/cli/tokens_list.md +++ b/docs/reference/cli/tokens_list.md @@ -23,6 +23,14 @@ coder tokens list [flags] Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens). +### --include-expired + +| | | +|------|-------------------| +| Type | bool | + +Include expired tokens in the output. By default, expired tokens are hidden. + ### -c, --column | | | diff --git a/docs/reference/cli/tokens_remove.md b/docs/reference/cli/tokens_remove.md index ae443f6ad0..8083cfa1f1 100644 --- a/docs/reference/cli/tokens_remove.md +++ b/docs/reference/cli/tokens_remove.md @@ -1,7 +1,7 @@ # tokens remove -Delete a token +Expire or delete a token Aliases: @@ -11,5 +11,21 @@ Aliases: ## Usage ```console -coder tokens remove +coder tokens remove [flags] ``` + +## Description + +```console +Remove a token by expiring it. Use --delete to permanently hard-delete the token instead. +``` + +## Options + +### --delete + +| | | +|------|-------------------| +| Type | bool | + +Permanently delete the token instead of expiring it. This removes the audit trail. diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 3c7adb7e64..cad1dcfb59 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -25,7 +25,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "Workspace": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "WorkspaceBuild": {codersdk.AuditActionStart, codersdk.AuditActionStop}, "Group": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, - "APIKey": {codersdk.AuditActionLogin, codersdk.AuditActionLogout, codersdk.AuditActionRegister, codersdk.AuditActionCreate, codersdk.AuditActionDelete}, + "APIKey": {codersdk.AuditActionLogin, codersdk.AuditActionLogout, codersdk.AuditActionRegister, codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, "Task": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, }