diff --git a/coderd/audit/table.go b/coderd/audit/table.go index 1b4cf2a92f2e1..fe6e4dda7e043 100644 --- a/coderd/audit/table.go +++ b/coderd/audit/table.go @@ -41,6 +41,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "hashed_password": ActionSecret, // A user can change their own password. "created_at": ActionIgnore, // Never changes. "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. + "status": ActionTrack, // A user can update another user status }, &database.Workspace{}: { "id": ActionIgnore, // Never changes. diff --git a/coderd/coderd.go b/coderd/coderd.go index 00898ab229a9c..6f255826088c4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -182,6 +182,7 @@ func New(options *Options) (http.Handler, func()) { r.Use(httpmw.ExtractUserParam(options.Database)) r.Get("/", api.userByName) r.Put("/profile", api.putUserProfile) + r.Put("/suspend", api.putUserSuspend) r.Get("/organizations", api.organizationsByUser) r.Post("/organizations", api.postOrganizationsByUser) r.Post("/keys", api.postAPIKey) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index d7c70be2fad50..b48ede661296c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1138,6 +1138,7 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam CreatedAt: arg.CreatedAt, UpdatedAt: arg.UpdatedAt, Username: arg.Username, + Status: database.UserStatusActive, } q.users = append(q.users, user) return user, nil @@ -1159,6 +1160,22 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs return database.User{}, sql.ErrNoRows } +func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUserStatusParams) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, user := range q.users { + if user.ID != arg.ID { + continue + } + user.Status = arg.Status + user.UpdatedAt = arg.UpdatedAt + q.users[index] = user + return user, nil + } + return database.User{}, sql.ErrNoRows +} + func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7ef419042098c..c536883997a51 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -56,6 +56,11 @@ CREATE TYPE provisioner_type AS ENUM ( 'terraform' ); +CREATE TYPE user_status AS ENUM ( + 'active', + 'suspended' +); + CREATE TYPE workspace_transition AS ENUM ( 'start', 'stop', @@ -221,7 +226,8 @@ CREATE TABLE users ( username text DEFAULT ''::text NOT NULL, hashed_password bytea NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + updated_at timestamp with time zone NOT NULL, + status user_status DEFAULT 'active'::public.user_status NOT NULL ); CREATE TABLE workspace_agents ( diff --git a/coderd/database/migrations/000007_user_status.down.sql b/coderd/database/migrations/000007_user_status.down.sql new file mode 100644 index 0000000000000..ed764b23d89c4 --- /dev/null +++ b/coderd/database/migrations/000007_user_status.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE ONLY users + DROP COLUMN IF EXISTS status; + +DROP TYPE user_status; diff --git a/coderd/database/migrations/000007_user_status.up.sql b/coderd/database/migrations/000007_user_status.up.sql new file mode 100644 index 0000000000000..f0a791523d662 --- /dev/null +++ b/coderd/database/migrations/000007_user_status.up.sql @@ -0,0 +1,4 @@ +CREATE TYPE user_status AS ENUM ('active', 'suspended'); + +ALTER TABLE ONLY users + ADD COLUMN IF NOT EXISTS status user_status NOT NULL DEFAULT 'active'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 350e1dc927efa..99f7622c5ae71 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -208,6 +208,25 @@ func (e *ProvisionerType) Scan(src interface{}) error { return nil } +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusSuspended UserStatus = "suspended" +) + +func (e *UserStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = UserStatus(s) + case string: + *e = UserStatus(s) + default: + return fmt.Errorf("unsupported scan type for UserStatus: %T", src) + } + return nil +} + type WorkspaceTransition string const ( @@ -372,12 +391,13 @@ type TemplateVersion struct { } type User struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` } type Workspace struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6e30c18e0912e..3819eb3608934 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -85,6 +85,7 @@ type querier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) + UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a79ec16623543..5f0574812b79c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1782,7 +1782,7 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, username, hashed_password, created_at, updated_at + id, email, username, hashed_password, created_at, updated_at, status FROM users WHERE @@ -1807,13 +1807,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, + &i.Status, ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT - id, email, username, hashed_password, created_at, updated_at + id, email, username, hashed_password, created_at, updated_at, status FROM users WHERE @@ -1832,6 +1833,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, + &i.Status, ) return i, err } @@ -1852,7 +1854,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT - id, email, username, hashed_password, created_at, updated_at + id, email, username, hashed_password, created_at, updated_at, status FROM users WHERE @@ -1922,6 +1924,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, + &i.Status, ); err != nil { return nil, err } @@ -1947,7 +1950,7 @@ INSERT INTO updated_at ) VALUES - ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at + ($1, $2, $3, $4, $5, $6) RETURNING id, email, username, hashed_password, created_at, updated_at, status ` type InsertUserParams struct { @@ -1976,6 +1979,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, + &i.Status, ) return i, err } @@ -1988,7 +1992,7 @@ SET username = $3, updated_at = $4 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status ` type UpdateUserProfileParams struct { @@ -2013,6 +2017,38 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.HashedPassword, &i.CreatedAt, &i.UpdatedAt, + &i.Status, + ) + return i, err +} + +const updateUserStatus = `-- name: UpdateUserStatus :one +UPDATE + users +SET + status = $2, + updated_at = $3 +WHERE + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status +` + +type UpdateUserStatusParams struct { + ID uuid.UUID `db:"id" json:"id"` + Status UserStatus `db:"status" json:"status"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserStatus, arg.ID, arg.Status, arg.UpdatedAt) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, ) return i, err } diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index c8cc39b42ff1f..bf99df6a3f785 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -90,3 +90,12 @@ ORDER BY LIMIT -- A null limit means "no limit", so -1 means return all NULLIF(@limit_opt :: int, -1); + +-- name: UpdateUserStatus :one +UPDATE + users +SET + status = $2, + updated_at = $3 +WHERE + id = $1 RETURNING *; diff --git a/coderd/users.go b/coderd/users.go index 148386e93ce28..a38af8ba12f63 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -281,6 +281,25 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, convertUser(updatedUserProfile)) } +func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + + suspendedUser, err := api.Database.UpdateUserStatus(r.Context(), database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusSuspended, + UpdatedAt: database.Now(), + }) + + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("put user suspended: %s", err.Error()), + }) + return + } + + httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser)) +} + // Returns organizations the parameterized user has access to. func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) @@ -613,6 +632,7 @@ func convertUser(user database.User) codersdk.User { Email: user.Email, CreatedAt: user.CreatedAt, Username: user.Username, + Status: codersdk.UserStatus(user.Status), } } diff --git a/coderd/users_test.go b/coderd/users_test.go index ed2e4e95f4577..8ee4db1c6f80e 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -286,6 +286,38 @@ func TestUpdateUserProfile(t *testing.T) { }) } +func TestPutUserSuspend(t *testing.T) { + t.Parallel() + + t.Run("SuspendAnotherUser", func(t *testing.T) { + t.Skip() + t.Parallel() + client := coderdtest.New(t, nil) + me := coderdtest.CreateFirstUser(t, client) + client.User(context.Background(), codersdk.Me) + user, _ := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + Email: "bruno@coder.com", + Username: "bruno", + Password: "password", + OrganizationID: me.OrganizationID, + }) + user, err := client.SuspendUser(context.Background(), user.ID) + require.NoError(t, err) + require.Equal(t, user.Status, codersdk.UserStatusSuspended) + }) + + t.Run("SuspendItSelf", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + client.User(context.Background(), codersdk.Me) + suspendedUser, err := client.SuspendUser(context.Background(), codersdk.Me) + + require.NoError(t, err) + require.Equal(t, suspendedUser.Status, codersdk.UserStatusSuspended) + }) +} + func TestUserByName(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/users.go b/codersdk/users.go index 84d73285a74a8..38506f03cd192 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -28,12 +28,20 @@ type UsersRequest struct { Offset int `json:"offset"` } +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusSuspended UserStatus = "suspended" +) + // User represents a user in Coder. type User struct { - ID uuid.UUID `json:"id" validate:"required"` - Email string `json:"email" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required"` - Username string `json:"username" validate:"required"` + ID uuid.UUID `json:"id" validate:"required"` + Email string `json:"email" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + Username string `json:"username" validate:"required"` + Status UserStatus `json:"status"` } type CreateFirstUserRequest struct { @@ -146,6 +154,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req Up return user, json.NewDecoder(res.Body).Decode(&user) } +// SuspendUser enables callers to suspend a user +func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error) { + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/suspend", uuidOrMe(userID)), nil) + if err != nil { + return User{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return User{}, readBodyAsError(res) + } + var user User + return user, json.NewDecoder(res.Body).Decode(&user) +} + // CreateAPIKey generates an API key for the user ID provided. func (c *Client) CreateAPIKey(ctx context.Context, userID uuid.UUID) (*GenerateAPIKeyResponse, error) { res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", uuidOrMe(userID)), nil) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 58507b2ffb5ba..268d1f837c347 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -83,13 +83,14 @@ export interface UsersRequest { readonly offset: number } -// From codersdk/users.go:32:6. +// From codersdk/users.go:39:6. export interface User { readonly email: string readonly username: string + readonly status: UserStatus } -// From codersdk/users.go:39:6. +// From codersdk/users.go:47:6. export interface CreateFirstUserRequest { readonly email: string readonly username: string @@ -97,41 +98,41 @@ export interface CreateFirstUserRequest { readonly organization: string } -// From codersdk/users.go:52:6. +// From codersdk/users.go:60:6. export interface CreateUserRequest { readonly email: string readonly username: string readonly password: string } -// From codersdk/users.go:59:6. +// From codersdk/users.go:67:6. export interface UpdateUserProfileRequest { readonly email: string readonly username: string } -// From codersdk/users.go:65:6. +// From codersdk/users.go:73:6. export interface LoginWithPasswordRequest { readonly email: string readonly password: string } -// From codersdk/users.go:71:6. +// From codersdk/users.go:79:6. export interface LoginWithPasswordResponse { readonly session_token: string } -// From codersdk/users.go:76:6. +// From codersdk/users.go:84:6. export interface GenerateAPIKeyResponse { readonly key: string } -// From codersdk/users.go:80:6. +// From codersdk/users.go:88:6. export interface CreateOrganizationRequest { readonly name: string } -// From codersdk/users.go:85:6. +// From codersdk/users.go:93:6. export interface AuthMethods { readonly password: boolean readonly github: boolean @@ -234,5 +235,8 @@ export type ParameterScope = "organization" | "template" | "user" | "workspace" // From codersdk/provisionerdaemons.go:26:6. export type ProvisionerJobStatus = "pending" | "running" | "succeeded" | "canceling" | "canceled" | "failed" +// From codersdk/users.go:31:6. +export type UserStatus = "active" | "suspended" + // From codersdk/workspaceresources.go:15:6. export type WorkspaceAgentStatus = "connecting" | "connected" | "disconnected"