From 95cff8c5fbd8913b932b9cd2be4c3f4d6d5d3143 Mon Sep 17 00:00:00 2001 From: Zach <3724288+zedkipp@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:12:55 -0600 Subject: [PATCH] feat: add REST API handlers and client methods for user secrets (#24107) Add the five REST endpoints for managing user secrets, SDK client methods, and handler tests. Endpoints: - `POST /api/v2/users/{user}/secrets` - `GET /api/v2/users/{user}/secrets` - `GET /api/v2/users/{user}/secrets/{name}` - `PATCH /api/v2/users/{user}/secrets/{name}` - `DELETE /api/v2/users/{user}/secrets/{name}` Routes are registered under the existing `/{user}` group with `ExtractUserParam`. The delete query was changed from `:exec` to `:execrows` so the handler can distinguish "not found" from success (DELETE with `:exec` silently returns nil for zero affected rows). --- coderd/apidoc/docs.go | 272 ++++++++++++++ coderd/apidoc/swagger.json | 250 +++++++++++++ coderd/coderd.go | 9 + coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmetrics/querymetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 7 +- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 2 +- coderd/database/queries.sql.go | 11 +- coderd/database/queries/user_secrets.sql | 2 +- coderd/usersecrets.go | 280 +++++++++++++++ coderd/usersecrets_test.go | 413 ++++++++++++++++++++++ codersdk/usersecrets.go | 68 ++++ docs/manifest.json | 4 + docs/reference/api/schemas.md | 68 ++++ docs/reference/api/secrets.md | 246 +++++++++++++ 17 files changed, 1631 insertions(+), 17 deletions(-) create mode 100644 coderd/usersecrets.go create mode 100644 coderd/usersecrets_test.go create mode 100644 docs/reference/api/secrets.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 985914756e..ca60c0b1f6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9514,6 +9514,212 @@ const docTemplate = `{ ] } }, + "/users/{user}/secrets": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Secrets" + ], + "summary": "List user secrets", + "operationId": "list-user-secrets", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Secrets" + ], + "summary": "Create a new user secret", + "operationId": "create-a-new-user-secret", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Create secret request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateUserSecretRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/users/{user}/secrets/{name}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Secrets" + ], + "summary": "Get a user secret by name", + "operationId": "get-a-user-secret-by-name", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { + "tags": [ + "Secrets" + ], + "summary": "Delete a user secret", + "operationId": "delete-a-user-secret", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Secrets" + ], + "summary": "Update a user secret", + "operationId": "update-a-user-secret", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Update secret request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserSecretRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/users/{user}/status/activate": { "put": { "produces": [ @@ -15148,6 +15354,26 @@ const docTemplate = `{ } } }, + "codersdk.CreateUserSecretRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "env_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "codersdk.CreateWorkspaceBuildReason": { "type": "string", "enum": [ @@ -21277,6 +21503,23 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserSecretRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "env_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "codersdk.UpdateWorkspaceACL": { "type": "object", "properties": { @@ -21732,6 +21975,35 @@ const docTemplate = `{ } } }, + "codersdk.UserSecret": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "env_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.UserStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7a374a654a..22565df4bf 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8431,6 +8431,190 @@ ] } }, + "/users/{user}/secrets": { + "get": { + "produces": ["application/json"], + "tags": ["Secrets"], + "summary": "List user secrets", + "operationId": "list-user-secrets", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Secrets"], + "summary": "Create a new user secret", + "operationId": "create-a-new-user-secret", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Create secret request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateUserSecretRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/users/{user}/secrets/{name}": { + "get": { + "produces": ["application/json"], + "tags": ["Secrets"], + "summary": "Get a user secret by name", + "operationId": "get-a-user-secret-by-name", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { + "tags": ["Secrets"], + "summary": "Delete a user secret", + "operationId": "delete-a-user-secret", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "patch": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Secrets"], + "summary": "Update a user secret", + "operationId": "update-a-user-secret", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Update secret request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserSecretRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/users/{user}/status/activate": { "put": { "produces": ["application/json"], @@ -13649,6 +13833,26 @@ } } }, + "codersdk.CreateUserSecretRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "env_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "codersdk.CreateWorkspaceBuildReason": { "type": "string", "enum": [ @@ -19551,6 +19755,23 @@ } } }, + "codersdk.UpdateUserSecretRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "env_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "codersdk.UpdateWorkspaceACL": { "type": "object", "properties": { @@ -19981,6 +20202,35 @@ } } }, + "codersdk.UserSecret": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "env_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.UserStatus": { "type": "string", "enum": ["active", "dormant", "suspended"], diff --git a/coderd/coderd.go b/coderd/coderd.go index fa145d3fd2..1c47410aee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1608,6 +1608,15 @@ func New(options *Options) *API { r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) + r.Route("/secrets", func(r chi.Router) { + r.Post("/", api.postUserSecret) + r.Get("/", api.getUserSecrets) + r.Route("/{name}", func(r chi.Router) { + r.Get("/", api.getUserSecret) + r.Patch("/", api.patchUserSecret) + r.Delete("/", api.deleteUserSecret) + }) + }) r.Route("/notifications", func(r chi.Router) { r.Route("/preferences", func(r chi.Router) { r.Get("/", api.userNotificationPreferences) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9e952dc002..82df9d3553 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2180,10 +2180,10 @@ func (q *querier) DeleteUserChatProviderKey(ctx context.Context, arg database.De return q.db.DeleteUserChatProviderKey(ctx, arg) } -func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) error { +func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) (int64, error) { obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String()) if err := q.authorizeContext(ctx, policy.ActionDelete, obj); err != nil { - return err + return 0, err } return q.db.DeleteUserSecretByUserIDAndName(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c85769b562..4c9810b0f1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5431,10 +5431,10 @@ func (s *MethodTestSuite) TestUserSecrets() { s.Run("DeleteUserSecretByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { user := testutil.Fake(s.T(), faker, database.User{}) arg := database.DeleteUserSecretByUserIDAndNameParams{UserID: user.ID, Name: "test"} - dbm.EXPECT().DeleteUserSecretByUserIDAndName(gomock.Any(), arg).Return(nil).AnyTimes() + dbm.EXPECT().DeleteUserSecretByUserIDAndName(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() check.Args(arg). Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionDelete). - Returns() + Returns(int64(1)) })) } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 4b2d27ef0e..a867b464ba 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -736,12 +736,12 @@ func (m queryMetricsStore) DeleteUserChatProviderKey(ctx context.Context, arg da return r0 } -func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) error { +func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) (int64, error) { start := time.Now() - r0 := m.s.DeleteUserSecretByUserIDAndName(ctx, arg) + r0, r1 := m.s.DeleteUserSecretByUserIDAndName(ctx, arg) m.queryLatencies.WithLabelValues("DeleteUserSecretByUserIDAndName").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserSecretByUserIDAndName").Inc() - return r0 + return r0, r1 } func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8498ec0aa9..9399712000 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1244,11 +1244,12 @@ func (mr *MockStoreMockRecorder) DeleteUserChatProviderKey(ctx, arg any) *gomock } // DeleteUserSecretByUserIDAndName mocks base method. -func (m *MockStore) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) error { +func (m *MockStore) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteUserSecretByUserIDAndName", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 } // DeleteUserSecretByUserIDAndName indicates an expected call of DeleteUserSecretByUserIDAndName. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 0bf767c17b..86e598ba04 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -169,7 +169,7 @@ type sqlcQuerier interface { DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error) DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error DeleteUserChatProviderKey(ctx context.Context, arg DeleteUserChatProviderKeyParams) error - DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) error + DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (int64, error) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 0edd966d9e..b1f52054cc 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -7376,7 +7376,7 @@ func TestUserSecretsCRUDOperations(t *testing.T) { assert.Equal(t, "WORKFLOW_ENV", updatedSecret.EnvName) // EnvName unchanged // 6. DELETE - err = db.DeleteUserSecretByUserIDAndName(ctx, database.DeleteUserSecretByUserIDAndNameParams{ + _, err = db.DeleteUserSecretByUserIDAndName(ctx, database.DeleteUserSecretByUserIDAndNameParams{ UserID: testUser.ID, Name: "workflow-secret", }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6a207470fd..61d1160f7f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -23127,7 +23127,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP return i, err } -const deleteUserSecretByUserIDAndName = `-- name: DeleteUserSecretByUserIDAndName :exec +const deleteUserSecretByUserIDAndName = `-- name: DeleteUserSecretByUserIDAndName :execrows DELETE FROM user_secrets WHERE user_id = $1 AND name = $2 ` @@ -23137,9 +23137,12 @@ type DeleteUserSecretByUserIDAndNameParams struct { Name string `db:"name" json:"name"` } -func (q *sqlQuerier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) error { - _, err := q.db.ExecContext(ctx, deleteUserSecretByUserIDAndName, arg.UserID, arg.Name) - return err +func (q *sqlQuerier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteUserSecretByUserIDAndName, arg.UserID, arg.Name) + if err != nil { + return 0, err + } + return result.RowsAffected() } const getUserSecretByUserIDAndName = `-- name: GetUserSecretByUserIDAndName :one diff --git a/coderd/database/queries/user_secrets.sql b/coderd/database/queries/user_secrets.sql index 9a68843b36..cf55c72e62 100644 --- a/coderd/database/queries/user_secrets.sql +++ b/coderd/database/queries/user_secrets.sql @@ -56,6 +56,6 @@ SET WHERE user_id = @user_id AND name = @name RETURNING *; --- name: DeleteUserSecretByUserIDAndName :exec +-- name: DeleteUserSecretByUserIDAndName :execrows DELETE FROM user_secrets WHERE user_id = @user_id AND name = @name; diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go new file mode 100644 index 0000000000..ee404603af --- /dev/null +++ b/coderd/usersecrets.go @@ -0,0 +1,280 @@ +package coderd + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Create a new user secret +// @ID create-a-new-user-secret +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Secrets +// @Param user path string true "User ID, username, or me" +// @Param request body codersdk.CreateUserSecretRequest true "Create secret request" +// @Success 201 {object} codersdk.UserSecret +// @Router /users/{user}/secrets [post] +func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + var req codersdk.CreateUserSecretRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.Name == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Name is required.", + }) + return + } + if req.Value == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Value is required.", + }) + return + } + envOpts := codersdk.UserSecretEnvValidationOptions{ + AIGatewayEnabled: api.DeploymentValues.AI.BridgeConfig.Enabled.Value(), + } + if err := codersdk.UserSecretEnvNameValid(req.EnvName, envOpts); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid environment variable name.", + Detail: err.Error(), + }) + return + } + if err := codersdk.UserSecretFilePathValid(req.FilePath); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid file path.", + Detail: err.Error(), + }) + return + } + + secret, err := api.Database.CreateUserSecret(ctx, database.CreateUserSecretParams{ + ID: uuid.New(), + UserID: user.ID, + Name: req.Name, + Description: req.Description, + Value: req.Value, + ValueKeyID: sql.NullString{}, + EnvName: req.EnvName, + FilePath: req.FilePath, + }) + if err != nil { + if database.IsUniqueViolation(err) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "A secret with that name, environment variable, or file path already exists.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating secret.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret)) +} + +// @Summary List user secrets +// @ID list-user-secrets +// @Security CoderSessionToken +// @Produce json +// @Tags Secrets +// @Param user path string true "User ID, username, or me" +// @Success 200 {array} codersdk.UserSecret +// @Router /users/{user}/secrets [get] +func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route. + ctx := r.Context() + user := httpmw.UserParam(r) + + secrets, err := api.Database.ListUserSecrets(ctx, user.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error listing secrets.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecrets(secrets)) +} + +// @Summary Get a user secret by name +// @ID get-a-user-secret-by-name +// @Security CoderSessionToken +// @Produce json +// @Tags Secrets +// @Param user path string true "User ID, username, or me" +// @Param name path string true "Secret name" +// @Success 200 {object} codersdk.UserSecret +// @Router /users/{user}/secrets/{name} [get] +func (api *API) getUserSecret(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route. + ctx := r.Context() + user := httpmw.UserParam(r) + name := chi.URLParam(r, "name") + + secret, err := api.Database.GetUserSecretByUserIDAndName(ctx, database.GetUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: name, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching secret.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret)) +} + +// @Summary Update a user secret +// @ID update-a-user-secret +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Secrets +// @Param user path string true "User ID, username, or me" +// @Param name path string true "Secret name" +// @Param request body codersdk.UpdateUserSecretRequest true "Update secret request" +// @Success 200 {object} codersdk.UserSecret +// @Router /users/{user}/secrets/{name} [patch] +func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + name := chi.URLParam(r, "name") + + var req codersdk.UpdateUserSecretRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "At least one field must be provided.", + }) + return + } + if req.EnvName != nil { + envOpts := codersdk.UserSecretEnvValidationOptions{ + AIGatewayEnabled: api.DeploymentValues.AI.BridgeConfig.Enabled.Value(), + } + if err := codersdk.UserSecretEnvNameValid(*req.EnvName, envOpts); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid environment variable name.", + Detail: err.Error(), + }) + return + } + } + if req.FilePath != nil { + if err := codersdk.UserSecretFilePathValid(*req.FilePath); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid file path.", + Detail: err.Error(), + }) + return + } + } + + params := database.UpdateUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: name, + UpdateValue: req.Value != nil, + Value: "", + ValueKeyID: sql.NullString{}, + UpdateDescription: req.Description != nil, + Description: "", + UpdateEnvName: req.EnvName != nil, + EnvName: "", + UpdateFilePath: req.FilePath != nil, + FilePath: "", + } + if req.Value != nil { + params.Value = *req.Value + } + if req.Description != nil { + params.Description = *req.Description + } + if req.EnvName != nil { + params.EnvName = *req.EnvName + } + if req.FilePath != nil { + params.FilePath = *req.FilePath + } + + secret, err := api.Database.UpdateUserSecretByUserIDAndName(ctx, params) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if database.IsUniqueViolation(err) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Update would conflict with an existing secret.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating secret.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret)) +} + +// @Summary Delete a user secret +// @ID delete-a-user-secret +// @Security CoderSessionToken +// @Tags Secrets +// @Param user path string true "User ID, username, or me" +// @Param name path string true "Secret name" +// @Success 204 +// @Router /users/{user}/secrets/{name} [delete] +func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + name := chi.URLParam(r, "name") + + rowsAffected, err := api.Database.DeleteUserSecretByUserIDAndName(ctx, database.DeleteUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: name, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting secret.", + Detail: err.Error(), + }) + return + } + if rowsAffected == 0 { + httpapi.ResourceNotFound(rw) + return + } + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/coderd/usersecrets_test.go b/coderd/usersecrets_test.go new file mode 100644 index 0000000000..d7d36cfaa3 --- /dev/null +++ b/coderd/usersecrets_test.go @@ -0,0 +1,413 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestPostUserSecret(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_xxxxxxxxxxxx", + Description: "Personal GitHub PAT", + EnvName: "GITHUB_TOKEN", + FilePath: "~/.github-token", + }) + require.NoError(t, err) + assert.Equal(t, "github-token", secret.Name) + assert.Equal(t, "Personal GitHub PAT", secret.Description) + assert.Equal(t, "GITHUB_TOKEN", secret.EnvName) + assert.Equal(t, "~/.github-token", secret.FilePath) + assert.NotZero(t, secret.ID) + assert.NotZero(t, secret.CreatedAt) + }) + + t.Run("MissingName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Value: "some-value", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + assert.Contains(t, sdkErr.Message, "Name is required") + }) + + t.Run("MissingValue", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "missing-value-secret", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + assert.Contains(t, sdkErr.Message, "Value is required") + }) + + t.Run("DuplicateName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "dup-secret", + Value: "value1", + }) + require.NoError(t, err) + + _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "dup-secret", + Value: "value2", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + }) + + t.Run("DuplicateEnvName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "env-dup-1", + Value: "value1", + EnvName: "DUPLICATE_ENV", + }) + require.NoError(t, err) + + _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "env-dup-2", + Value: "value2", + EnvName: "DUPLICATE_ENV", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + }) + + t.Run("DuplicateFilePath", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "fp-dup-1", + Value: "value1", + FilePath: "/tmp/dup-file", + }) + require.NoError(t, err) + + _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "fp-dup-2", + Value: "value2", + FilePath: "/tmp/dup-file", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + }) + + t.Run("InvalidEnvName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "invalid-env-secret", + Value: "value", + EnvName: "1INVALID", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("ReservedEnvName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "reserved-env-secret", + Value: "value", + EnvName: "PATH", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("CoderPrefixEnvName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "coder-prefix-secret", + Value: "value", + EnvName: "CODER_AGENT_TOKEN", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("InvalidFilePath", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "bad-path-secret", + Value: "value", + FilePath: "relative/path", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) +} + +func TestGetUserSecrets(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // Verify no secrets exist on a fresh user. + ctx := testutil.Context(t, testutil.WaitMedium) + secrets, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, secrets) + + t.Run("WithSecrets", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "list-secret-a", + Value: "value-a", + }) + require.NoError(t, err) + + _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "list-secret-b", + Value: "value-b", + }) + require.NoError(t, err) + + secrets, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + require.Len(t, secrets, 2) + // Sorted by name. + assert.Equal(t, "list-secret-a", secrets[0].Name) + assert.Equal(t, "list-secret-b", secrets[1].Name) + }) +} + +func TestGetUserSecret(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + created, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "get-found-secret", + Value: "my-value", + EnvName: "GET_FOUND_SECRET", + }) + require.NoError(t, err) + + got, err := client.UserSecretByName(ctx, codersdk.Me, "get-found-secret") + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "get-found-secret", got.Name) + assert.Equal(t, "GET_FOUND_SECRET", got.EnvName) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.UserSecretByName(ctx, codersdk.Me, "nonexistent") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} + +func TestPatchUserSecret(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + t.Run("UpdateDescription", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "patch-desc-secret", + Value: "my-value", + Description: "original", + EnvName: "PATCH_DESC_ENV", + }) + require.NoError(t, err) + + newDesc := "updated" + updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "patch-desc-secret", codersdk.UpdateUserSecretRequest{ + Description: &newDesc, + }) + require.NoError(t, err) + assert.Equal(t, "updated", updated.Description) + // Other fields unchanged. + assert.Equal(t, "PATCH_DESC_ENV", updated.EnvName) + }) + + t.Run("NoFields", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "patch-nofields-secret", + Value: "my-value", + }) + require.NoError(t, err) + + _, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-nofields-secret", codersdk.UpdateUserSecretRequest{}) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + newVal := "new-value" + _, err := client.UpdateUserSecret(ctx, codersdk.Me, "nonexistent", codersdk.UpdateUserSecretRequest{ + Value: &newVal, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("ConflictEnvName", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "conflict-env-1", + Value: "value1", + EnvName: "CONFLICT_TAKEN_ENV", + }) + require.NoError(t, err) + + _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "conflict-env-2", + Value: "value2", + }) + require.NoError(t, err) + + taken := "CONFLICT_TAKEN_ENV" + _, err = client.UpdateUserSecret(ctx, codersdk.Me, "conflict-env-2", codersdk.UpdateUserSecretRequest{ + EnvName: &taken, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + }) + + t.Run("ConflictFilePath", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "conflict-fp-1", + Value: "value1", + FilePath: "/tmp/conflict-taken", + }) + require.NoError(t, err) + + _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "conflict-fp-2", + Value: "value2", + }) + require.NoError(t, err) + + taken := "/tmp/conflict-taken" + _, err = client.UpdateUserSecret(ctx, codersdk.Me, "conflict-fp-2", codersdk.UpdateUserSecretRequest{ + FilePath: &taken, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + }) +} + +func TestDeleteUserSecret(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "delete-me-secret", + Value: "my-value", + }) + require.NoError(t, err) + + err = client.DeleteUserSecret(ctx, codersdk.Me, "delete-me-secret") + require.NoError(t, err) + + // Verify it's gone. + _, err = client.UserSecretByName(ctx, codersdk.Me, "delete-me-secret") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + err := client.DeleteUserSecret(ctx, codersdk.Me, "nonexistent") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index ae53912bb0..43cfd00a4f 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -1,6 +1,10 @@ package codersdk import ( + "context" + "encoding/json" + "fmt" + "net/http" "time" "github.com/google/uuid" @@ -39,3 +43,67 @@ type UpdateUserSecretRequest struct { EnvName *string `json:"env_name,omitempty"` FilePath *string `json:"file_path,omitempty"` } + +func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUserSecretRequest) (UserSecret, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/secrets", user), req) + if err != nil { + return UserSecret{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return UserSecret{}, ReadBodyAsError(res) + } + var secret UserSecret + return secret, json.NewDecoder(res.Body).Decode(&secret) +} + +func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/secrets", user), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var secrets []UserSecret + return secrets, json.NewDecoder(res.Body).Decode(&secrets) +} + +func (c *Client) UserSecretByName(ctx context.Context, user string, name string) (UserSecret, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/secrets/%s", user, name), nil) + if err != nil { + return UserSecret{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserSecret{}, ReadBodyAsError(res) + } + var secret UserSecret + return secret, json.NewDecoder(res.Body).Decode(&secret) +} + +func (c *Client) UpdateUserSecret(ctx context.Context, user string, name string, req UpdateUserSecretRequest) (UserSecret, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/users/%s/secrets/%s", user, name), req) + if err != nil { + return UserSecret{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return UserSecret{}, ReadBodyAsError(res) + } + var secret UserSecret + return secret, json.NewDecoder(res.Body).Decode(&secret) +} + +func (c *Client) DeleteUserSecret(ctx context.Context, user string, name string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/secrets/%s", user, name), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/manifest.json b/docs/manifest.json index e70886cacb..30b4795a21 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1526,6 +1526,10 @@ "title": "Schemas", "path": "./reference/api/schemas.md" }, + { + "title": "Secrets", + "path": "./reference/api/secrets.md" + }, { "title": "Tasks", "path": "./reference/api/tasks.md" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fad89fef05..259ed26c7d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2706,6 +2706,28 @@ This is required on creation to enable a user-flow of validating a template work | `user_status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | User status defaults to UserStatusDormant. | | `username` | string | true | | | +## codersdk.CreateUserSecretRequest + +```json +{ + "description": "string", + "env_name": "string", + "file_path": "string", + "name": "string", + "value": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `description` | string | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `name` | string | false | | | +| `value` | string | false | | | + ## codersdk.CreateWorkspaceBuildReason ```json @@ -10588,6 +10610,26 @@ Restarts will only happen on weekdays in this list on weeks which line up with W The schedule must be daily with a single time, and should have a timezone specified via a CRON_TZ prefix (otherwise UTC will be used). If the schedule is empty, the user will be updated to use the default schedule.| +## codersdk.UpdateUserSecretRequest + +```json +{ + "description": "string", + "env_name": "string", + "file_path": "string", + "value": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `description` | string | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `value` | string | false | | | + ## codersdk.UpdateWorkspaceACL ```json @@ -11146,6 +11188,32 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `user_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. | | `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | +## codersdk.UserSecret + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "env_name": "string", + "file_path": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `description` | string | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `updated_at` | string | false | | | + ## codersdk.UserStatus ```json diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md new file mode 100644 index 0000000000..1015a60625 --- /dev/null +++ b/docs/reference/api/secrets.md @@ -0,0 +1,246 @@ +# Secrets + +## List user secrets + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/secrets` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | + +### Example responses + +> 200 Response + +```json +[ + { + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "env_name": "string", + "file_path": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|---------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) | + +