feat: Allow deleting users (#4028)

* Add deleted column to the users table

* Fix user indexes

* Add frontend

* Add test
This commit is contained in:
Kyle Carberry
2022-09-12 18:24:20 -05:00
committed by GitHub
parent a2098254cd
commit 850a83097c
26 changed files with 498 additions and 70 deletions

View File

@ -346,6 +346,7 @@ func New(options *Options) *API {
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Delete("/", api.deleteUser)
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Route("/status", func(r chi.Router) {

View File

@ -284,7 +284,7 @@ func (q *fakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.G
defer q.mutex.RUnlock()
for _, user := range q.users {
if user.Email == arg.Email || user.Username == arg.Username {
if (user.Email == arg.Email || user.Username == arg.Username) && user.Deleted == arg.Deleted {
return user, nil
}
}
@ -307,7 +307,13 @@ func (q *fakeQuerier) GetUserCount(_ context.Context) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
return int64(len(q.users)), nil
existing := int64(0)
for _, u := range q.users {
if !u.Deleted {
existing++
}
}
return existing, nil
}
func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) {
@ -316,13 +322,27 @@ func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) {
active := int64(0)
for _, u := range q.users {
if u.Status == database.UserStatusActive {
if u.Status == database.UserStatusActive && !u.Deleted {
active++
}
}
return active, nil
}
func (q *fakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for i, u := range q.users {
if u.ID == params.ID {
u.Deleted = params.Deleted
q.users[i] = u
return nil
}
}
return sql.ErrNoRows
}
func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.User, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -341,6 +361,16 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
return a.CreatedAt.Before(b.CreatedAt)
})
if params.Deleted {
tmp := make([]database.User, 0, len(users))
for _, user := range users {
if user.Deleted {
tmp = append(tmp, user)
}
}
users = tmp
}
if params.AfterID != uuid.Nil {
found := false
for i, v := range users {
@ -409,16 +439,19 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
return users, nil
}
func (q *fakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]database.User, error) {
func (q *fakeQuerier) GetUsersByIDs(_ context.Context, params database.GetUsersByIDsParams) ([]database.User, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
users := make([]database.User, 0)
for _, user := range q.users {
for _, id := range ids {
for _, id := range params.IDs {
if user.ID.String() != id.String() {
continue
}
if user.Deleted != params.Deleted {
continue
}
users = append(users, user)
}
}
@ -879,8 +912,8 @@ func (q *fakeQuerier) ParameterValues(_ context.Context, arg database.ParameterV
}
}
if len(arg.Ids) > 0 {
if !slice.Contains(arg.Ids, parameterValue.ID) {
if len(arg.IDs) > 0 {
if !slice.Contains(arg.IDs, parameterValue.ID) {
continue
}
}
@ -961,9 +994,9 @@ func (q *fakeQuerier) GetTemplatesWithFilter(_ context.Context, arg database.Get
continue
}
if len(arg.Ids) > 0 {
if len(arg.IDs) > 0 {
match := false
for _, id := range arg.Ids {
for _, id := range arg.IDs {
if template.ID == id {
match = true
break

View File

@ -301,7 +301,8 @@ CREATE TABLE users (
status user_status DEFAULT 'active'::public.user_status NOT NULL,
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL,
login_type login_type DEFAULT 'password'::public.login_type NOT NULL,
avatar_url text
avatar_url text,
deleted boolean DEFAULT false NOT NULL
);
CREATE TABLE workspace_agents (
@ -504,13 +505,13 @@ CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name);
CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name));
CREATE UNIQUE INDEX idx_users_email ON users USING btree (email);
CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username);
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username));
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);

View File

@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN deleted;

View File

@ -0,0 +1,9 @@
ALTER TABLE users
ADD COLUMN deleted boolean DEFAULT false NOT NULL;
DROP INDEX idx_users_email;
DROP INDEX idx_users_username;
DROP INDEX users_username_lower_idx;
CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE deleted = false;
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE deleted = false;
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE deleted = false;

View File

@ -506,6 +506,7 @@ type User struct {
RBACRoles []string `db:"rbac_roles" json:"rbac_roles"`
LoginType LoginType `db:"login_type" json:"login_type"`
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
Deleted bool `db:"deleted" json:"deleted"`
}
type UserLink struct {

View File

@ -74,7 +74,7 @@ type querier interface {
GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error)
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error)
GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error)
GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) ([]User, error)
GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error)
GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error)
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
@ -137,6 +137,7 @@ type querier interface {
UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error)
UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error
UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error
UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error)
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)

View File

@ -1450,7 +1450,7 @@ WHERE
type ParameterValuesParams struct {
Scopes []ParameterScope `db:"scopes" json:"scopes"`
ScopeIds []uuid.UUID `db:"scope_ids" json:"scope_ids"`
Ids []uuid.UUID `db:"ids" json:"ids"`
IDs []uuid.UUID `db:"ids" json:"ids"`
Names []string `db:"names" json:"names"`
}
@ -1458,7 +1458,7 @@ func (q *sqlQuerier) ParameterValues(ctx context.Context, arg ParameterValuesPar
rows, err := q.db.QueryContext(ctx, parameterValues,
pq.Array(arg.Scopes),
pq.Array(arg.ScopeIds),
pq.Array(arg.Ids),
pq.Array(arg.IDs),
pq.Array(arg.Names),
)
if err != nil {
@ -2205,7 +2205,7 @@ type GetTemplatesWithFilterParams struct {
Deleted bool `db:"deleted" json:"deleted"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
ExactName string `db:"exact_name" json:"exact_name"`
Ids []uuid.UUID `db:"ids" json:"ids"`
IDs []uuid.UUID `db:"ids" json:"ids"`
}
func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) {
@ -2213,7 +2213,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
arg.Deleted,
arg.OrganizationID,
arg.ExactName,
pq.Array(arg.Ids),
pq.Array(arg.IDs),
)
if err != nil {
return nil, err
@ -2884,7 +2884,7 @@ SELECT
FROM
users
WHERE
status = 'active'::public.user_status
status = 'active'::public.user_status AND deleted = false
`
func (q *sqlQuerier) GetActiveUserCount(ctx context.Context) (int64, error) {
@ -2937,12 +2937,12 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
FROM
users
WHERE
LOWER(username) = LOWER($1)
OR email = $2
(LOWER(username) = LOWER($1) OR email = $2)
AND deleted = $3
LIMIT
1
`
@ -2950,10 +2950,11 @@ LIMIT
type GetUserByEmailOrUsernameParams struct {
Username string `db:"username" json:"username"`
Email string `db:"email" json:"email"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByEmailOrUsername, arg.Username, arg.Email)
row := q.db.QueryRowContext(ctx, getUserByEmailOrUsername, arg.Username, arg.Email, arg.Deleted)
var i User
err := row.Scan(
&i.ID,
@ -2966,13 +2967,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
FROM
users
WHERE
@ -2995,6 +2997,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
@ -3003,7 +3006,7 @@ const getUserCount = `-- name: GetUserCount :one
SELECT
COUNT(*)
FROM
users
users WHERE deleted = false
`
func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
@ -3015,15 +3018,16 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getUsers = `-- name: GetUsers :many
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
FROM
users
WHERE
CASE
users.deleted = $1
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN $1 :: uuid != '00000000-00000000-00000000-00000000' THEN (
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the created_at field, so select all
-- rows after the cursor.
@ -3033,7 +3037,7 @@ WHERE
FROM
users
WHERE
id = $1
id = $2
)
)
ELSE true
@ -3041,9 +3045,9 @@ WHERE
-- Start filters
-- Filter by name, email or username
AND CASE
WHEN $2 :: text != '' THEN (
email ILIKE concat('%', $2, '%')
OR username ILIKE concat('%', $2, '%')
WHEN $3 :: text != '' THEN (
email ILIKE concat('%', $3, '%')
OR username ILIKE concat('%', $3, '%')
)
ELSE true
END
@ -3051,29 +3055,30 @@ WHERE
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality($3 :: user_status[]) > 0 THEN
status = ANY($3 :: user_status[])
WHEN cardinality($4 :: user_status[]) > 0 THEN
status = ANY($4 :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality($4 :: text[]) > 0 AND 'member' != ANY($4 :: text[]) THEN
rbac_roles && $4 :: text[]
WHEN cardinality($5 :: text[]) > 0 AND 'member' != ANY($5 :: text[]) THEN
rbac_roles && $5 :: text[]
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users, even if they share
-- a timestamp. This is to ensure consistent pagination.
(created_at, id) ASC OFFSET $5
(created_at, id) ASC OFFSET $6
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($6 :: int, 0)
NULLIF($7 :: int, 0)
`
type GetUsersParams struct {
Deleted bool `db:"deleted" json:"deleted"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Status []UserStatus `db:"status" json:"status"`
@ -3084,6 +3089,7 @@ type GetUsersParams struct {
func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsers,
arg.Deleted,
arg.AfterID,
arg.Search,
pq.Array(arg.Status),
@ -3109,6 +3115,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
); err != nil {
return nil, err
}
@ -3124,11 +3131,16 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url FROM users WHERE id = ANY($1 :: uuid [ ])
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted FROM users WHERE id = ANY($1 :: uuid [ ]) AND deleted = $2
`
func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsersByIDs, pq.Array(ids))
type GetUsersByIDsParams struct {
IDs []uuid.UUID `db:"ids" json:"ids"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsersByIDs, pq.Array(arg.IDs), arg.Deleted)
if err != nil {
return nil, err
}
@ -3147,6 +3159,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
); err != nil {
return nil, err
}
@ -3174,7 +3187,7 @@ INSERT INTO
login_type
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type InsertUserParams struct {
@ -3211,10 +3224,30 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
const updateUserDeletedByID = `-- name: UpdateUserDeletedByID :exec
UPDATE
users
SET
deleted = $2
WHERE
id = $1
`
type UpdateUserDeletedByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error {
_, err := q.db.ExecContext(ctx, updateUserDeletedByID, arg.ID, arg.Deleted)
return err
}
const updateUserHashedPassword = `-- name: UpdateUserHashedPassword :exec
UPDATE
users
@ -3243,7 +3276,7 @@ SET
avatar_url = $4,
updated_at = $5
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type UpdateUserProfileParams struct {
@ -3274,6 +3307,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
@ -3286,7 +3320,7 @@ SET
rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[]))
WHERE
id = $2
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type UpdateUserRolesParams struct {
@ -3308,6 +3342,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
@ -3319,7 +3354,7 @@ SET
status = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type UpdateUserStatusParams struct {
@ -3342,6 +3377,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}

View File

@ -9,7 +9,7 @@ LIMIT
1;
-- name: GetUsersByIDs :many
SELECT * FROM users WHERE id = ANY(@ids :: uuid [ ]);
SELECT * FROM users WHERE id = ANY(@ids :: uuid [ ]) AND deleted = @deleted;
-- name: GetUserByEmailOrUsername :one
SELECT
@ -17,8 +17,8 @@ SELECT
FROM
users
WHERE
LOWER(username) = LOWER(@username)
OR email = @email
(LOWER(username) = LOWER(@username) OR email = @email)
AND deleted = @deleted
LIMIT
1;
@ -26,7 +26,7 @@ LIMIT
SELECT
COUNT(*)
FROM
users;
users WHERE deleted = false;
-- name: GetActiveUserCount :one
SELECT
@ -34,7 +34,7 @@ SELECT
FROM
users
WHERE
status = 'active'::public.user_status;
status = 'active'::public.user_status AND deleted = false;
-- name: InsertUser :one
INSERT INTO
@ -80,13 +80,22 @@ SET
WHERE
id = $1;
-- name: UpdateUserDeletedByID :exec
UPDATE
users
SET
deleted = $2
WHERE
id = $1;
-- name: GetUsers :many
SELECT
*
FROM
users
WHERE
CASE
users.deleted = @deleted
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.

View File

@ -30,4 +30,5 @@ rename:
rbac_roles: RBACRoles
ip_address: IPAddress
ip_addresses: IPAddresses
ids: IDs
jwt: JWT

View File

@ -17,9 +17,9 @@ const (
UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name);
UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name));
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email);
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username));
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
)

View File

@ -707,7 +707,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
}
inheritedParams, err := db.ParameterValues(r.Context(), database.ParameterValuesParams{
Ids: inherits,
IDs: inherits,
})
if err != nil {
return xerrors.Errorf("fetch inherited params: %w", err)

View File

@ -338,6 +338,57 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(rw, http.StatusCreated, convertUser(user, []uuid.UUID{req.OrganizationID}))
}
func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
Features: api.FeaturesService,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
aReq.Old = user
defer commitAudit()
if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceUser) {
httpapi.Forbidden(rw)
return
}
workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{
OwnerID: user.ID,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
Detail: err.Error(),
})
return
}
if len(workspaces) > 0 {
httpapi.Write(rw, http.StatusExpectationFailed, codersdk.Response{
Message: "You cannot delete a user that has workspaces. Delete their workspaces and try again!",
})
return
}
err = api.Database.UpdateUserDeletedByID(r.Context(), database.UpdateUserDeletedByIDParams{
ID: user.ID,
Deleted: true,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting user.",
Detail: err.Error(),
})
return
}
user.Deleted = true
aReq.New = user
httpapi.Write(rw, http.StatusOK, codersdk.Response{
Message: "User has been deleted!",
})
}
// Returns the parameterized user requested. All validation
// is completed in the middleware for this route.
func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {

View File

@ -257,6 +257,52 @@ func TestPostLogin(t *testing.T) {
})
}
func TestDeleteUser(t *testing.T) {
t.Parallel()
t.Run("Works", func(t *testing.T) {
t.Parallel()
api := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, api)
_, another := coderdtest.CreateAnotherUserWithUser(t, api, user.OrganizationID)
err := api.DeleteUser(context.Background(), another.ID)
require.NoError(t, err)
// Attempt to create a user with the same email and username, and delete them again.
another, err = api.CreateUser(context.Background(), codersdk.CreateUserRequest{
Email: another.Email,
Username: another.Username,
Password: "testing",
OrganizationID: user.OrganizationID,
})
require.NoError(t, err)
err = api.DeleteUser(context.Background(), another.ID)
require.NoError(t, err)
})
t.Run("NoPermission", func(t *testing.T) {
t.Parallel()
api := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, api)
client, _ := coderdtest.CreateAnotherUserWithUser(t, api, firstUser.OrganizationID)
err := client.DeleteUser(context.Background(), firstUser.UserID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("HasWorkspaces", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.NewWithProvisionerCloser(t, nil)
user := coderdtest.CreateFirstUser(t, client)
anotherClient, another := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.CreateWorkspace(t, anotherClient, user.OrganizationID, template.ID)
err := client.DeleteUser(context.Background(), another.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusExpectationFailed, apiErr.StatusCode())
})
}
func TestPostLogout(t *testing.T) {
t.Parallel()

View File

@ -38,7 +38,9 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
return
}
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID})
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@ -135,7 +137,9 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
for _, build := range builds {
userIDs = append(userIDs, build.InitiatorID)
}
users, err := api.Database.GetUsersByIDs(r.Context(), userIDs)
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: userIDs,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@ -221,7 +225,9 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
return
}
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID})
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@ -476,9 +482,11 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
return
}
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{
workspace.OwnerID,
workspaceBuild.InitiatorID,
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{
workspace.OwnerID,
workspaceBuild.InitiatorID,
},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{

View File

@ -98,7 +98,9 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
return err
})
group.Go(func() (err error) {
users, err = api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, build.InitiatorID})
users, err = api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, build.InitiatorID},
})
return err
})
err = group.Wait()
@ -470,7 +472,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
}
aReq.New = workspace
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{apiKey.UserID, workspaceBuild.InitiatorID})
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{apiKey.UserID, workspaceBuild.InitiatorID},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@ -856,7 +860,9 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
return err
})
group.Go(func() (err error) {
users, err = api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, build.InitiatorID})
users, err = api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, build.InitiatorID},
})
return err
})
err = group.Wait()
@ -897,7 +903,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
return nil, xerrors.Errorf("get workspace builds: %w", err)
}
templates, err := db.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
Ids: templateIDs,
IDs: templateIDs,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
@ -905,7 +911,9 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
if err != nil {
return nil, xerrors.Errorf("get templates: %w", err)
}
users, err := db.GetUsersByIDs(ctx, userIDs)
users, err := db.GetUsersByIDs(ctx, database.GetUsersByIDsParams{
IDs: userIDs,
})
if err != nil {
return nil, xerrors.Errorf("get users: %w", err)
}