mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: add PUT /api/v2/users/:user-id/suspend endpoint (#1154)
This commit is contained in:
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
8
coderd/database/dump.sql
generated
8
coderd/database/dump.sql
generated
@ -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 (
|
||||
|
4
coderd/database/migrations/000007_user_status.down.sql
Normal file
4
coderd/database/migrations/000007_user_status.down.sql
Normal file
@ -0,0 +1,4 @@
|
||||
ALTER TABLE ONLY users
|
||||
DROP COLUMN IF EXISTS status;
|
||||
|
||||
DROP TYPE user_status;
|
4
coderd/database/migrations/000007_user_status.up.sql
Normal file
4
coderd/database/migrations/000007_user_status.up.sql
Normal 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';
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 *;
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
Reference in New Issue
Block a user