Skip to content

Commit 441ffd6

Browse files
feat: add PUT /api/v2/users/:user-id/suspend endpoint (#1154)
1 parent f9ce54a commit 441ffd6

File tree

14 files changed

+202
-25
lines changed

14 files changed

+202
-25
lines changed

coderd/audit/table.go

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
4141
"hashed_password": ActionSecret, // A user can change their own password.
4242
"created_at": ActionIgnore, // Never changes.
4343
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
44+
"status": ActionTrack, // A user can update another user status
4445
},
4546
&database.Workspace{}: {
4647
"id": ActionIgnore, // Never changes.

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ func New(options *Options) (http.Handler, func()) {
182182
r.Use(httpmw.ExtractUserParam(options.Database))
183183
r.Get("/", api.userByName)
184184
r.Put("/profile", api.putUserProfile)
185+
r.Put("/suspend", api.putUserSuspend)
185186
r.Get("/organizations", api.organizationsByUser)
186187
r.Post("/organizations", api.postOrganizationsByUser)
187188
r.Post("/keys", api.postAPIKey)

coderd/database/databasefake/databasefake.go

+17
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,7 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
11381138
CreatedAt: arg.CreatedAt,
11391139
UpdatedAt: arg.UpdatedAt,
11401140
Username: arg.Username,
1141+
Status: database.UserStatusActive,
11411142
}
11421143
q.users = append(q.users, user)
11431144
return user, nil
@@ -1159,6 +1160,22 @@ func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs
11591160
return database.User{}, sql.ErrNoRows
11601161
}
11611162

1163+
func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
1164+
q.mutex.Lock()
1165+
defer q.mutex.Unlock()
1166+
1167+
for index, user := range q.users {
1168+
if user.ID != arg.ID {
1169+
continue
1170+
}
1171+
user.Status = arg.Status
1172+
user.UpdatedAt = arg.UpdatedAt
1173+
q.users[index] = user
1174+
return user, nil
1175+
}
1176+
return database.User{}, sql.ErrNoRows
1177+
}
1178+
11621179
func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) {
11631180
q.mutex.Lock()
11641181
defer q.mutex.Unlock()

coderd/database/dump.sql

+7-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE ONLY users
2+
DROP COLUMN IF EXISTS status;
3+
4+
DROP TYPE user_status;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CREATE TYPE user_status AS ENUM ('active', 'suspended');
2+
3+
ALTER TABLE ONLY users
4+
ADD COLUMN IF NOT EXISTS status user_status NOT NULL DEFAULT 'active';

coderd/database/models.go

+26-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/querier.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

+41-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/users.sql

+9
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,12 @@ ORDER BY
9090
LIMIT
9191
-- A null limit means "no limit", so -1 means return all
9292
NULLIF(@limit_opt :: int, -1);
93+
94+
-- name: UpdateUserStatus :one
95+
UPDATE
96+
users
97+
SET
98+
status = $2,
99+
updated_at = $3
100+
WHERE
101+
id = $1 RETURNING *;

coderd/users.go

+20
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,25 @@ func (api *api) putUserProfile(rw http.ResponseWriter, r *http.Request) {
281281
httpapi.Write(rw, http.StatusOK, convertUser(updatedUserProfile))
282282
}
283283

284+
func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
285+
user := httpmw.UserParam(r)
286+
287+
suspendedUser, err := api.Database.UpdateUserStatus(r.Context(), database.UpdateUserStatusParams{
288+
ID: user.ID,
289+
Status: database.UserStatusSuspended,
290+
UpdatedAt: database.Now(),
291+
})
292+
293+
if err != nil {
294+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
295+
Message: fmt.Sprintf("put user suspended: %s", err.Error()),
296+
})
297+
return
298+
}
299+
300+
httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser))
301+
}
302+
284303
// Returns organizations the parameterized user has access to.
285304
func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
286305
user := httpmw.UserParam(r)
@@ -613,6 +632,7 @@ func convertUser(user database.User) codersdk.User {
613632
Email: user.Email,
614633
CreatedAt: user.CreatedAt,
615634
Username: user.Username,
635+
Status: codersdk.UserStatus(user.Status),
616636
}
617637
}
618638

coderd/users_test.go

+32
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,38 @@ func TestUpdateUserProfile(t *testing.T) {
286286
})
287287
}
288288

289+
func TestPutUserSuspend(t *testing.T) {
290+
t.Parallel()
291+
292+
t.Run("SuspendAnotherUser", func(t *testing.T) {
293+
t.Skip()
294+
t.Parallel()
295+
client := coderdtest.New(t, nil)
296+
me := coderdtest.CreateFirstUser(t, client)
297+
client.User(context.Background(), codersdk.Me)
298+
user, _ := client.CreateUser(context.Background(), codersdk.CreateUserRequest{
299+
Email: "bruno@coder.com",
300+
Username: "bruno",
301+
Password: "password",
302+
OrganizationID: me.OrganizationID,
303+
})
304+
user, err := client.SuspendUser(context.Background(), user.ID)
305+
require.NoError(t, err)
306+
require.Equal(t, user.Status, codersdk.UserStatusSuspended)
307+
})
308+
309+
t.Run("SuspendItSelf", func(t *testing.T) {
310+
t.Parallel()
311+
client := coderdtest.New(t, nil)
312+
coderdtest.CreateFirstUser(t, client)
313+
client.User(context.Background(), codersdk.Me)
314+
suspendedUser, err := client.SuspendUser(context.Background(), codersdk.Me)
315+
316+
require.NoError(t, err)
317+
require.Equal(t, suspendedUser.Status, codersdk.UserStatusSuspended)
318+
})
319+
}
320+
289321
func TestUserByName(t *testing.T) {
290322
t.Parallel()
291323
client := coderdtest.New(t, nil)

codersdk/users.go

+26-4
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ type UsersRequest struct {
2828
Offset int `json:"offset"`
2929
}
3030

31+
type UserStatus string
32+
33+
const (
34+
UserStatusActive UserStatus = "active"
35+
UserStatusSuspended UserStatus = "suspended"
36+
)
37+
3138
// User represents a user in Coder.
3239
type User struct {
33-
ID uuid.UUID `json:"id" validate:"required"`
34-
Email string `json:"email" validate:"required"`
35-
CreatedAt time.Time `json:"created_at" validate:"required"`
36-
Username string `json:"username" validate:"required"`
40+
ID uuid.UUID `json:"id" validate:"required"`
41+
Email string `json:"email" validate:"required"`
42+
CreatedAt time.Time `json:"created_at" validate:"required"`
43+
Username string `json:"username" validate:"required"`
44+
Status UserStatus `json:"status"`
3745
}
3846

3947
type CreateFirstUserRequest struct {
@@ -146,6 +154,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req Up
146154
return user, json.NewDecoder(res.Body).Decode(&user)
147155
}
148156

157+
// SuspendUser enables callers to suspend a user
158+
func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error) {
159+
res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/suspend", uuidOrMe(userID)), nil)
160+
if err != nil {
161+
return User{}, err
162+
}
163+
defer res.Body.Close()
164+
if res.StatusCode != http.StatusOK {
165+
return User{}, readBodyAsError(res)
166+
}
167+
var user User
168+
return user, json.NewDecoder(res.Body).Decode(&user)
169+
}
170+
149171
// CreateAPIKey generates an API key for the user ID provided.
150172
func (c *Client) CreateAPIKey(ctx context.Context, userID uuid.UUID) (*GenerateAPIKeyResponse, error) {
151173
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", uuidOrMe(userID)), nil)

0 commit comments

Comments
 (0)