diff --git a/cli/testdata/coder_tokens_--help.golden b/cli/testdata/coder_tokens_--help.golden index 66b068ebc180b..06e8ed16c9ff1 100644 --- a/cli/testdata/coder_tokens_--help.golden +++ b/cli/testdata/coder_tokens_--help.golden @@ -22,7 +22,7 @@ Get Started: $ coder tokens rm WuoWs4ZsMX Commands: - create Create a tokens + create Create a token list List tokens remove Delete a token diff --git a/cli/testdata/coder_tokens_create_--help.golden b/cli/testdata/coder_tokens_create_--help.golden index 22cac73e41b95..dfeec67f0458a 100644 --- a/cli/testdata/coder_tokens_create_--help.golden +++ b/cli/testdata/coder_tokens_create_--help.golden @@ -1,4 +1,4 @@ -Create a tokens +Create a token Usage: coder tokens create [flags] @@ -7,6 +7,7 @@ Flags: -h, --help help for create --lifetime duration Specify a duration for the lifetime of the token. Consumes $CODER_TOKEN_LIFETIME (default 720h0m0s) + -n, --name string Specify a human-readable name. Global Flags: --global-config coder Path to the global coder config directory. diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 15711ddcccea0..6b7d93425206b 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -9,9 +9,9 @@ Aliases: Flags: -a, --all Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens). - -c, --column strings Columns to display in table output. Available columns: id, last used, - expires at, created at, owner (default [id,last used,expires - at,created at]) + -c, --column strings Columns to display in table output. Available columns: id, name, last + used, expires at, created at, owner (default [id,name,last + used,expires at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") diff --git a/cli/testdata/coder_tokens_remove_--help.golden b/cli/testdata/coder_tokens_remove_--help.golden index a0d46d2321b67..6edefb3fec3ee 100644 --- a/cli/testdata/coder_tokens_remove_--help.golden +++ b/cli/testdata/coder_tokens_remove_--help.golden @@ -1,7 +1,7 @@ Delete a token Usage: - coder tokens remove [id] [flags] + coder tokens remove [name] [flags] Aliases: remove, rm diff --git a/cli/tokens.go b/cli/tokens.go index 082d83303824a..3d51e0f1ff59d 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -49,10 +49,13 @@ func tokens() *cobra.Command { } func createToken() *cobra.Command { - var tokenLifetime time.Duration + var ( + tokenLifetime time.Duration + name string + ) cmd := &cobra.Command{ Use: "create", - Short: "Create a tokens", + Short: "Create a token", RunE: func(cmd *cobra.Command, args []string) error { client, err := CreateClient(cmd) if err != nil { @@ -60,7 +63,8 @@ func createToken() *cobra.Command { } res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{ - Lifetime: tokenLifetime, + Lifetime: tokenLifetime, + TokenName: name, }) if err != nil { return xerrors.Errorf("create tokens: %w", err) @@ -81,6 +85,7 @@ func createToken() *cobra.Command { } cliflag.DurationVarP(cmd.Flags(), &tokenLifetime, "lifetime", "", "CODER_TOKEN_LIFETIME", 30*24*time.Hour, "Specify a duration for the lifetime of the token.") + cmd.Flags().StringVarP(&name, "name", "n", "", "Specify a human-readable name.") return cmd } @@ -92,6 +97,7 @@ type tokenListRow struct { // For table format: ID string `json:"-" table:"id,default_sort"` + TokenName string `json:"token_name" table:"name"` LastUsed time.Time `json:"-" table:"last used"` ExpiresAt time.Time `json:"-" table:"expires at"` CreatedAt time.Time `json:"-" table:"created at"` @@ -102,6 +108,7 @@ func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow { return tokenListRow{ APIKey: token.APIKey, ID: token.ID, + TokenName: token.TokenName, LastUsed: token.LastUsed, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, @@ -111,7 +118,7 @@ func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow { func listTokens() *cobra.Command { // we only display the 'owner' column if the --all argument is passed in - defaultCols := []string{"id", "last used", "expires at", "created at"} + defaultCols := []string{"id", "name", "last used", "expires at", "created at"} if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") { defaultCols = append(defaultCols, "owner") } @@ -172,7 +179,7 @@ func listTokens() *cobra.Command { func removeToken() *cobra.Command { cmd := &cobra.Command{ - Use: "remove [id]", + Use: "remove [name]", Aliases: []string{"rm"}, Short: "Delete a token", Args: cobra.ExactArgs(1), @@ -182,7 +189,12 @@ func removeToken() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - err = client.DeleteAPIKey(cmd.Context(), codersdk.Me, args[0]) + token, err := client.APIKeyByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return xerrors.Errorf("fetch api key by name %s: %w", args[0], err) + } + + err = client.DeleteAPIKey(cmd.Context(), codersdk.Me, token.ID) if err != nil { return xerrors.Errorf("delete api key: %w", err) } diff --git a/cli/tokens_test.go b/cli/tokens_test.go index 6a30812d7bceb..aaeacc75edfbd 100644 --- a/cli/tokens_test.go +++ b/cli/tokens_test.go @@ -33,7 +33,7 @@ func TestTokens(t *testing.T) { res := buf.String() require.Contains(t, res, "tokens found") - cmd, root = clitest.New(t, "tokens", "create") + cmd, root = clitest.New(t, "tokens", "create", "--name", "token-one") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) cmd.SetOut(buf) @@ -73,7 +73,7 @@ func TestTokens(t *testing.T) { require.Len(t, tokens, 1) require.Equal(t, id, tokens[0].ID) - cmd, root = clitest.New(t, "tokens", "rm", id) + cmd, root = clitest.New(t, "tokens", "rm", "token-one") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) cmd.SetOut(buf) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d0ec2b075f9da..fc55d2f32982d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3337,6 +3337,48 @@ const docTemplate = `{ } } }, + "/users/{user}/keys/tokens/{keyname}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get API key by token name", + "operationId": "get-api-key-by-token-name", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key Name", + "name": "keyname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } + } + } + } + }, "/users/{user}/keys/{keyid}": { "get": { "security": [ @@ -3350,8 +3392,8 @@ const docTemplate = `{ "tags": [ "Users" ], - "summary": "Get API key", - "operationId": "get-api-key", + "summary": "Get API key by ID", + "operationId": "get-api-key-by-id", "parameters": [ { "type": "string", @@ -5342,6 +5384,7 @@ const docTemplate = `{ "lifetime_seconds", "login_type", "scope", + "token_name", "updated_at", "user_id" ], @@ -5388,6 +5431,9 @@ const docTemplate = `{ } ] }, + "token_name": { + "type": "string" + }, "updated_at": { "type": "string", "format": "date-time" @@ -6003,6 +6049,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.APIKeyScope" } ] + }, + "token_name": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0bc82ed8ee9ba..874c41aafd080 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2931,6 +2931,44 @@ } } }, + "/users/{user}/keys/tokens/{keyname}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get API key by token name", + "operationId": "get-api-key-by-token-name", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key Name", + "name": "keyname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } + } + } + } + }, "/users/{user}/keys/{keyid}": { "get": { "security": [ @@ -2940,8 +2978,8 @@ ], "produces": ["application/json"], "tags": ["Users"], - "summary": "Get API key", - "operationId": "get-api-key", + "summary": "Get API key by ID", + "operationId": "get-api-key-by-id", "parameters": [ { "type": "string", @@ -4735,6 +4773,7 @@ "lifetime_seconds", "login_type", "scope", + "token_name", "updated_at", "user_id" ], @@ -4773,6 +4812,9 @@ } ] }, + "token_name": { + "type": "string" + }, "updated_at": { "type": "string", "format": "date-time" @@ -5326,6 +5368,9 @@ "$ref": "#/definitions/codersdk.APIKeyScope" } ] + }, + "token_name": { + "type": "string" } } }, diff --git a/coderd/apikey.go b/coderd/apikey.go index 1bdafe9007d9f..3977fe8bc9b0e 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" "github.com/tabbed/pqtype" "golang.org/x/xerrors" @@ -62,6 +63,12 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { lifeTime = createToken.Lifetime } + tokenName := namesgenerator.GetRandomName(1) + + if len(createToken.TokenName) != 0 { + tokenName = createToken.TokenName + } + err := api.validateAPIKeyLifetime(lifeTime) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -77,8 +84,19 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { ExpiresAt: database.Now().Add(lifeTime), Scope: scope, LifetimeSeconds: int64(lifeTime.Seconds()), + TokenName: tokenName, }) if err != nil { + if database.IsUniqueViolation(err, database.UniqueIndexApiKeyName) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("A token with name %q already exists.", tokenName), + Validations: []codersdk.ValidationError{{ + Field: "name", + Detail: "This value is already in use and should be unique.", + }}, + }) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create API key.", Detail: err.Error(), @@ -133,8 +151,8 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value}) } -// @Summary Get API key -// @ID get-api-key +// @Summary Get API key by ID +// @ID get-api-key-by-id // @Security CoderSessionToken // @Produce json // @Tags Users @@ -142,7 +160,7 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { // @Param keyid path string true "Key ID" format(uuid) // @Success 200 {object} codersdk.APIKey // @Router /users/{user}/keys/{keyid} [get] -func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { +func (api *API) apiKeyByID(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() keyID := chi.URLParam(r, "keyid") @@ -167,6 +185,46 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key)) } +// @Summary Get API key by token name +// @ID get-api-key-by-token-name +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Param keyname path string true "Key Name" format(string) +// @Success 200 {object} codersdk.APIKey +// @Router /users/{user}/keys/tokens/{keyname} [get] +func (api *API) apiKeyByName(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + tokenName = chi.URLParam(r, "keyname") + ) + + token, err := api.Database.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{ + TokenName: tokenName, + UserID: user.ID, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API key.", + Detail: err.Error(), + }) + return + } + + if !api.Authorize(r, rbac.ActionRead, token) { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(token)) +} + // @Summary Get user tokens // @ID get-user-tokens // @Security CoderSessionToken @@ -305,6 +363,7 @@ type createAPIKeyParams struct { ExpiresAt time.Time LifetimeSeconds int64 Scope database.APIKeyScope + TokenName string } func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error { @@ -374,6 +433,7 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h HashedSecret: hashed[:], LoginType: params.LoginType, Scope: scope, + TokenName: params.TokenName, }) if err != nil { return nil, nil, xerrors.Errorf("insert API key: %w", err) diff --git a/coderd/coderd.go b/coderd/coderd.go index 276282d6c6800..1b91d56539502 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -559,9 +559,12 @@ func New(options *Options) *API { r.Route("/tokens", func(r chi.Router) { r.Post("/", api.postToken) r.Get("/", api.tokens) + r.Route("/{keyname}", func(r chi.Router) { + r.Get("/", api.apiKeyByName) + }) }) r.Route("/{keyid}", func(r chi.Router) { - r.Get("/", api.apiKey) + r.Get("/", api.apiKeyByID) r.Delete("/", api.deleteAPIKey) }) }) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index c4d4fd8a541f0..7b8e33140771f 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -95,6 +95,10 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { AssertObject: rbac.ResourceAPIKey, AssertAction: rbac.ActionRead, }, + "GET:/api/v2/users/{user}/keys/tokens/{keyname}": { + AssertObject: rbac.ResourceAPIKey, + AssertAction: rbac.ActionRead, + }, "GET:/api/v2/workspacebuilds/{workspacebuild}": { AssertAction: rbac.ActionRead, AssertObject: workspaceRBACObj, @@ -342,8 +346,9 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a t.Fail() } _, err := client.CreateToken(ctx, admin.UserID.String(), codersdk.CreateTokenRequest{ - Lifetime: time.Hour, - Scope: codersdk.APIKeyScopeAll, + Lifetime: time.Hour, + Scope: codersdk.APIKeyScopeAll, + TokenName: namesgenerator.GetRandomName(1), }) require.NoError(t, err, "create token") @@ -419,6 +424,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a "{templatename}": template.Name, "{workspace_and_agent}": workspace.Name + "." + workspace.LatestBuild.Resources[0].Agents[0].Name, "{keyid}": apiKey.ID, + "{keyname}": apiKey.TokenName, // Only checking template scoped params here "parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s", string(templateParam.Scope), templateParam.ScopeID.String()), diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 6aecd87baf35c..5032bbd0abcff 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -36,6 +36,10 @@ func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } +func (q *querier) GetAPIKeyByName(ctx context.Context, arg database.GetAPIKeyByNameParams) (database.APIKey, error) { + return fetch(q.log, q.auth, q.db.GetAPIKeyByName)(ctx, arg) +} + func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database.LoginType) ([]database.APIKey, error) { return fetchWithPostFilter(q.auth, q.db.GetAPIKeysByLoginType)(ctx, loginType) } diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index b1b21c78ae9de..a959329b714c3 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -23,6 +23,16 @@ func (s *MethodTestSuite) TestAPIKey() { key, _ := dbgen.APIKey(s.T(), db, database.APIKey{}) check.Args(key.ID).Asserts(key, rbac.ActionRead).Returns(key) })) + s.Run("GetAPIKeyByName", s.Subtest(func(db database.Store, check *expects) { + key, _ := dbgen.APIKey(s.T(), db, database.APIKey{ + TokenName: "marge-cat", + LoginType: database.LoginTypeToken, + }) + check.Args(database.GetAPIKeyByNameParams{ + TokenName: key.TokenName, + UserID: key.UserID, + }).Asserts(key, rbac.ActionRead).Returns(key) + })) s.Run("GetAPIKeysByLoginType", s.Subtest(func(db database.Store, check *expects) { a, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword}) b, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword}) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index b81ef3cb8972d..5590f498e3283 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -464,6 +464,21 @@ func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIK return database.APIKey{}, sql.ErrNoRows } +func (q *fakeQuerier) GetAPIKeyByName(_ context.Context, params database.GetAPIKeyByNameParams) (database.APIKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if params.TokenName == "" { + return database.APIKey{}, sql.ErrNoRows + } + for _, apiKey := range q.apiKeys { + if params.UserID == apiKey.UserID && params.TokenName == apiKey.TokenName { + return apiKey, nil + } + } + return database.APIKey{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time) ([]database.APIKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2515,6 +2530,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP LastUsed: arg.LastUsed, LoginType: arg.LoginType, Scope: arg.Scope, + TokenName: arg.TokenName, } q.apiKeys = append(q.apiKeys, key) return key, nil diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index cb8f52c06529a..f272f30ec7a6c 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -90,6 +90,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database UpdatedAt: takeFirst(seed.UpdatedAt, database.Now()), LoginType: takeFirst(seed.LoginType, database.LoginTypePassword), Scope: takeFirst(seed.Scope, database.APIKeyScopeAll), + TokenName: takeFirst(seed.TokenName), }) require.NoError(t, err, "insert api key") return key, fmt.Sprintf("%s-%s", key.ID, secret) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index bccf259df243a..94df373a0cca1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -134,7 +134,8 @@ CREATE TABLE api_keys ( login_type login_type NOT NULL, lifetime_seconds bigint DEFAULT 86400 NOT NULL, ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL, - scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL + scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL, + token_name text DEFAULT ''::text NOT NULL ); COMMENT ON COLUMN api_keys.hashed_secret IS 'hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code.'; @@ -748,6 +749,8 @@ CREATE INDEX idx_agent_stats_created_at ON workspace_agent_stats USING btree (cr CREATE INDEX idx_agent_stats_user_id ON workspace_agent_stats USING btree (user_id); +CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + CREATE INDEX idx_api_keys_user ON api_keys USING btree (user_id); CREATE INDEX idx_audit_log_organization_id ON audit_logs USING btree (organization_id); diff --git a/coderd/database/migrations/000103_add_apikey_name.down.sql b/coderd/database/migrations/000103_add_apikey_name.down.sql new file mode 100644 index 0000000000000..f7070bd3637e9 --- /dev/null +++ b/coderd/database/migrations/000103_add_apikey_name.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX idx_api_key_name; + +ALTER TABLE ONLY api_keys + DROP COLUMN IF EXISTS token_name; + +COMMIT; diff --git a/coderd/database/migrations/000103_add_apikey_name.up.sql b/coderd/database/migrations/000103_add_apikey_name.up.sql new file mode 100644 index 0000000000000..f1ba24ae0935b --- /dev/null +++ b/coderd/database/migrations/000103_add_apikey_name.up.sql @@ -0,0 +1,17 @@ +BEGIN; + +ALTER TABLE ONLY api_keys + ADD COLUMN IF NOT EXISTS token_name text NOT NULL DEFAULT ''; + +UPDATE + api_keys +SET + token_name = gen_random_uuid ()::text +WHERE + login_type = 'token'; + +CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) +WHERE + (login_type = 'token'); + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index 9dfc6671716ba..e67dc7127db65 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1216,6 +1216,7 @@ type APIKey struct { LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` Scope APIKeyScope `db:"scope" json:"scope"` + TokenName string `db:"token_name" json:"token_name"` } type AuditLog struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5082f1e9dfa23..667a3206f494b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -30,6 +30,8 @@ type sqlcQuerier interface { DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) + // there is no unique constraint on empty token names + GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 60541499003a0..ceb4308bc9b9d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -43,7 +43,7 @@ func (q *sqlQuerier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID const getAPIKeyByID = `-- name: GetAPIKeyByID :one SELECT - id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE @@ -67,12 +67,52 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, + ) + return i, err +} + +const getAPIKeyByName = `-- name: GetAPIKeyByName :one +SELECT + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name +FROM + api_keys +WHERE + user_id = $1 AND + token_name = $2 AND + token_name != '' +LIMIT + 1 +` + +type GetAPIKeyByNameParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + TokenName string `db:"token_name" json:"token_name"` +} + +// there is no unique constraint on empty token names +func (q *sqlQuerier) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) { + row := q.db.QueryRowContext(ctx, getAPIKeyByName, arg.UserID, arg.TokenName) + var i APIKey + err := row.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.LifetimeSeconds, + &i.IPAddress, + &i.Scope, + &i.TokenName, ) return i, err } const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 ` func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) { @@ -96,6 +136,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ); err != nil { return nil, err } @@ -111,7 +152,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT } const getAPIKeysByUserID = `-- name: GetAPIKeysByUserID :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 AND user_id = $2 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 AND user_id = $2 ` type GetAPIKeysByUserIDParams struct { @@ -140,6 +181,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ); err != nil { return nil, err } @@ -155,7 +197,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse } const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE last_used > $1 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE last_used > $1 ` func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { @@ -179,6 +221,7 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ); err != nil { return nil, err } @@ -206,7 +249,8 @@ INSERT INTO created_at, updated_at, login_type, - scope + scope, + token_name ) VALUES ($1, @@ -215,7 +259,7 @@ VALUES WHEN 0 THEN 86400 ELSE $2::bigint END - , $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope + , $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name ` type InsertAPIKeyParams struct { @@ -230,6 +274,7 @@ type InsertAPIKeyParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` LoginType LoginType `db:"login_type" json:"login_type"` Scope APIKeyScope `db:"scope" json:"scope"` + TokenName string `db:"token_name" json:"token_name"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -245,6 +290,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.UpdatedAt, arg.LoginType, arg.Scope, + arg.TokenName, ) var i APIKey err := row.Scan( @@ -259,6 +305,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 437f7f5a09bfd..53caa7633521f 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -8,6 +8,19 @@ WHERE LIMIT 1; +-- name: GetAPIKeyByName :one +SELECT + * +FROM + api_keys +WHERE + user_id = @user_id AND + token_name = @token_name AND +-- there is no unique constraint on empty token names + token_name != '' +LIMIT + 1; + -- name: GetAPIKeysLastUsedAfter :many SELECT * FROM api_keys WHERE last_used > $1; @@ -30,7 +43,8 @@ INSERT INTO created_at, updated_at, login_type, - scope + scope, + token_name ) VALUES (@id, @@ -39,7 +53,7 @@ VALUES WHEN 0 THEN 86400 ELSE @lifetime_seconds::bigint END - , @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope) RETURNING *; + , @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope, @token_name) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 6bf2abb04faee..f09f6ebdae374 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -23,6 +23,7 @@ const ( UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key); + UniqueIndexApiKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)); UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); diff --git a/coderd/users.go b/coderd/users.go index 8e080039db02b..31c548455b1be 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1176,5 +1176,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { LoginType: codersdk.LoginType(k.LoginType), Scope: codersdk.APIKeyScope(k.Scope), LifetimeSeconds: k.LifetimeSeconds, + TokenName: k.TokenName, } } diff --git a/coderd/users_test.go b/coderd/users_test.go index 801aa277f1ca3..a5916be338e7d 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -266,7 +266,7 @@ func TestPostLogin(t *testing.T) { defer cancel() split := strings.Split(client.SessionToken(), "-") - key, err := client.APIKey(ctx, admin.UserID.String(), split[0]) + key, err := client.APIKeyByID(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch login key") require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400") @@ -274,7 +274,7 @@ func TestPostLogin(t *testing.T) { token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) require.NoError(t, err, "make new token api key") split = strings.Split(token.Key, "-") - apiKey, err := client.APIKey(ctx, admin.UserID.String(), split[0]) + apiKey, err := client.APIKeyByID(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch api key") require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*29)), "default tokens lasts more than 29 days") @@ -356,7 +356,7 @@ func TestPostLogout(t *testing.T) { defer cancel() keyID := strings.Split(client.SessionToken(), "-")[0] - apiKey, err := client.APIKey(ctx, admin.UserID.String(), keyID) + apiKey, err := client.APIKeyByID(ctx, admin.UserID.String(), keyID) require.NoError(t, err) require.Equal(t, keyID, apiKey.ID, "API key should exist in the database") @@ -385,7 +385,7 @@ func TestPostLogout(t *testing.T) { } require.True(t, found, "auth cookie should be returned") - _, err = client.APIKey(ctx, admin.UserID.String(), keyID) + _, err = client.APIKeyByID(ctx, admin.UserID.String(), keyID) sdkErr := &codersdk.Error{} require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode(), "Expecting 401") @@ -723,7 +723,7 @@ func TestUpdateUserPassword(t *testing.T) { // Trying to get an API key should fail since our client's token // has been deleted. - _, err = client.APIKey(ctx, user.UserID.String(), apikey1.Key) + _, err = client.APIKeyByID(ctx, user.UserID.String(), apikey1.Key) require.Error(t, err) cerr := coderdtest.SDKError(t, err) require.Equal(t, http.StatusUnauthorized, cerr.StatusCode()) @@ -738,12 +738,12 @@ func TestUpdateUserPassword(t *testing.T) { // Trying to get an API key should fail since all keys are deleted // on password change. - _, err = client.APIKey(ctx, user.UserID.String(), apikey1.Key) + _, err = client.APIKeyByID(ctx, user.UserID.String(), apikey1.Key) require.Error(t, err) cerr = coderdtest.SDKError(t, err) require.Equal(t, http.StatusNotFound, cerr.StatusCode()) - _, err = client.APIKey(ctx, user.UserID.String(), apikey2.Key) + _, err = client.APIKeyByID(ctx, user.UserID.String(), apikey2.Key) require.Error(t, err) cerr = coderdtest.SDKError(t, err) require.Equal(t, http.StatusNotFound, cerr.StatusCode()) diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 40f08424b881d..6c1b24293dcf0 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -438,7 +438,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { // Get the current user and API key. user, err := client.User(ctx, codersdk.Me) require.NoError(t, err) - currentAPIKey, err := client.APIKey(ctx, firstUser.UserID.String(), strings.Split(client.SessionToken(), "-")[0]) + currentAPIKey, err := client.APIKeyByID(ctx, firstUser.UserID.String(), strings.Split(client.SessionToken(), "-")[0]) require.NoError(t, err) // Try to load the application without authentication. @@ -500,7 +500,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { apiKey := cookies[0].Value // Fetch the API key. - apiKeyInfo, err := client.APIKey(ctx, firstUser.UserID.String(), strings.Split(apiKey, "-")[0]) + apiKeyInfo, err := client.APIKeyByID(ctx, firstUser.UserID.String(), strings.Split(apiKey, "-")[0]) require.NoError(t, err) require.Equal(t, user.ID, apiKeyInfo.UserID) require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType) diff --git a/codersdk/apikey.go b/codersdk/apikey.go index ba24211169072..cba463084d645 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -20,6 +20,7 @@ type APIKey struct { UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` Scope APIKeyScope `json:"scope" validate:"required" enums:"all,application_connect"` + TokenName string `json:"token_name" validate:"required"` LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` } @@ -44,8 +45,9 @@ const ( ) type CreateTokenRequest struct { - Lifetime time.Duration `json:"lifetime"` - Scope APIKeyScope `json:"scope" enums:"all,application_connect"` + Lifetime time.Duration `json:"lifetime"` + Scope APIKeyScope `json:"scope" enums:"all,application_connect"` + TokenName string `json:"token_name"` } // GenerateAPIKeyResponse contains an API key for a user. @@ -119,8 +121,8 @@ func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } -// APIKey returns the api key by id. -func (c *Client) APIKey(ctx context.Context, userID string, id string) (*APIKey, error) { +// APIKeyByID returns the api key by id. +func (c *Client) APIKeyByID(ctx context.Context, userID string, id string) (*APIKey, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) if err != nil { return nil, err @@ -133,6 +135,20 @@ func (c *Client) APIKey(ctx context.Context, userID string, id string) (*APIKey, return apiKey, json.NewDecoder(res.Body).Decode(apiKey) } +// APIKeyByName returns the api key by name. +func (c *Client) APIKeyByName(ctx context.Context, userID string, name string) (*APIKey, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens/%s", userID, name), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusCreated { + return nil, ReadBodyAsError(res) + } + apiKey := &APIKey{} + return apiKey, json.NewDecoder(res.Body).Decode(apiKey) +} + // DeleteAPIKey deletes API key by id. func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) error { res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 5b9355b391e3d..0d376d755b377 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -11,7 +11,7 @@ We track the following resources: | Resource | | | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
updated_atfalse
user_idfalse
| +| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idfalse
| | Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 7385b1b61f7ac..afb7cd2fb764e 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -368,6 +368,7 @@ "lifetime_seconds": 0, "login_type": "password", "scope": "all", + "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } @@ -384,6 +385,7 @@ | `lifetime_seconds` | integer | true | | | | `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | | `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | true | | | +| `token_name` | string | true | | | | `updated_at` | string | true | | | | `user_id` | string | true | | | @@ -1141,16 +1143,18 @@ CreateParameterRequest is a structure used to create a new parameter value for a ```json { "lifetime": 0, - "scope": "all" + "scope": "all", + "token_name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | -------------------------------------------- | -------- | ------------ | ----------- | -| `lifetime` | integer | false | | | -| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------ | -------------------------------------------- | -------- | ------------ | ----------- | +| `lifetime` | integer | false | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `token_name` | string | false | | | #### Enumerated Values diff --git a/docs/api/users.md b/docs/api/users.md index 1fd8e73fc32e9..4c055609d093d 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -577,6 +577,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ "lifetime_seconds": 0, "login_type": "password", "scope": "all", + "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } @@ -603,6 +604,7 @@ Status Code **200** | `» lifetime_seconds` | integer | true | | | | `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | | `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | true | | | +| `» token_name` | string | true | | | | `» updated_at` | string(date-time) | true | | | | `» user_id` | string(uuid) | true | | | @@ -638,7 +640,8 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ ```json { "lifetime": 0, - "scope": "all" + "scope": "all", + "token_name": "string" } ``` @@ -667,7 +670,54 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get API key +## Get API key by token name + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/keys/tokens/{keyname}` + +### Parameters + +| Name | In | Type | Required | Description | +| --------- | ---- | -------------- | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | +| `keyname` | path | string(string) | true | Key Name | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "expires_at": "2019-08-24T14:15:22Z", + "id": "string", + "last_used": "2019-08-24T14:15:22Z", + "lifetime_seconds": 0, + "login_type": "password", + "scope": "all", + "token_name": "string", + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.APIKey](schemas.md#codersdkapikey) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get API key by ID ### Code samples @@ -700,6 +750,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ "lifetime_seconds": 0, "login_type": "password", "scope": "all", + "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } diff --git a/docs/cli/coder_tokens.md b/docs/cli/coder_tokens.md index 89877e555b72c..1130422bada75 100644 --- a/docs/cli/coder_tokens.md +++ b/docs/cli/coder_tokens.md @@ -28,8 +28,8 @@ coder tokens [flags] ## Subcommands -| Name | Purpose | -| -------------------------------------------- | --------------- | -| [create](./coder_tokens_create) | Create a tokens | -| [list](./coder_tokens_list) | List tokens | -| [remove](./coder_tokens_remove) | Delete a token | +| Name | Purpose | +| -------------------------------------------- | -------------- | +| [create](./coder_tokens_create) | Create a token | +| [list](./coder_tokens_list) | List tokens | +| [remove](./coder_tokens_remove) | Delete a token | diff --git a/docs/cli/coder_tokens_create.md b/docs/cli/coder_tokens_create.md index 5890243607b0b..6d5b92a5e5c5f 100644 --- a/docs/cli/coder_tokens_create.md +++ b/docs/cli/coder_tokens_create.md @@ -2,7 +2,7 @@ # coder tokens create -Create a tokens +Create a token ## Usage @@ -20,3 +20,10 @@ Specify a duration for the lifetime of the token. | --- | --- | | Consumes | $CODER_TOKEN_LIFETIME | | Default | 720h0m0s | + +### --name, -n + +Specify a human-readable name. +
+| | | +| --- | --- | diff --git a/docs/cli/coder_tokens_list.md b/docs/cli/coder_tokens_list.md index f629f4825436f..b36d4ea291bcb 100644 --- a/docs/cli/coder_tokens_list.md +++ b/docs/cli/coder_tokens_list.md @@ -22,11 +22,11 @@ Specifies whether all users' tokens will be listed or not (must have Owner role ### --column, -c -Columns to display in table output. Available columns: id, last used, expires at, created at, owner +Columns to display in table output. Available columns: id, name, last used, expires at, created at, owner
| | | | --- | --- | -| Default | [id,last used,expires at,created at] | +| Default | [id,name,last used,expires at,created at] | ### --output, -o diff --git a/docs/cli/coder_tokens_remove.md b/docs/cli/coder_tokens_remove.md index b6a445d2c68b7..f7041c37851e1 100644 --- a/docs/cli/coder_tokens_remove.md +++ b/docs/cli/coder_tokens_remove.md @@ -7,5 +7,5 @@ Delete a token ## Usage ```console -coder tokens remove [id] [flags] +coder tokens remove [name] [flags] ``` diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index ce47a56e40273..846692d583b82 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -146,6 +146,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "lifetime_seconds": ActionIgnore, "ip_address": ActionIgnore, "scope": ActionIgnore, + "token_name": ActionIgnore, }, // TODO: track an ID here when the below ticket is completed: // https://github.com/coder/coder/pull/6012 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cc2d5f95feab3..b2e4115b03bce 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -10,6 +10,7 @@ export interface APIKey { readonly updated_at: string readonly login_type: LoginType readonly scope: APIKeyScope + readonly token_name: string readonly lifetime_seconds: number } @@ -224,6 +225,7 @@ export interface CreateTokenRequest { // This is likely an enum in an external package ("time.Duration") readonly lifetime: number readonly scope: APIKeyScope + readonly token_name: string } // From codersdk/users.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3ae63bc9c3cff..373d2d192ca90 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -41,6 +41,7 @@ export const MockTokens: TypesGen.APIKey[] = [ login_type: "token", scope: "all", lifetime_seconds: 2592000, + token_name: "token-one", }, { id: "tBoVE3dqLl", @@ -52,6 +53,7 @@ export const MockTokens: TypesGen.APIKey[] = [ login_type: "token", scope: "all", lifetime_seconds: 2592000, + token_name: "token-two", }, ]