feat: add PUT /api/v2/users/:user-id/suspend endpoint (#1154)

This commit is contained in:
Bruno Quaresma
2022-04-26 09:00:07 -05:00
committed by GitHub
parent f9ce54a51e
commit 441ffd6a0b
14 changed files with 202 additions and 25 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 (

View File

@@ -0,0 +1,4 @@
ALTER TABLE ONLY users
DROP COLUMN IF EXISTS status;
DROP TYPE user_status;

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 *;

View File

@@ -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),
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"