Skip to content

Commit 2d22399

Browse files
authored
fix: Correctly guard against last admin deleting their account (outline#2069)
* fix: Correctly guard against last admin deleting their account * test
1 parent 3fbb3a2 commit 2d22399

File tree

5 files changed

+163
-34
lines changed

5 files changed

+163
-34
lines changed

server/api/users.js

+11-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @flow
22
import Router from "koa-router";
3+
import userDestroyer from "../commands/userDestroyer";
34
import userInviter from "../commands/userInviter";
45
import userSuspender from "../commands/userSuspender";
56
import auth from "../middlewares/authentication";
@@ -232,17 +233,17 @@ router.post("users.delete", auth(), async (ctx) => {
232233
const { confirmation, id } = ctx.body;
233234
ctx.assertPresent(confirmation, "confirmation is required");
234235

235-
let user = ctx.state.user;
236-
if (id) user = await User.findByPk(id);
237-
authorize(ctx.state.user, "delete", user);
236+
const actor = ctx.state.user;
237+
let user = actor;
238+
if (id) {
239+
user = await User.findByPk(id);
240+
}
238241

239-
await user.destroy();
240-
await Event.create({
241-
name: "users.delete",
242-
actorId: user.id,
243-
userId: user.id,
244-
teamId: user.teamId,
245-
data: { name: user.name },
242+
authorize(actor, "delete", user);
243+
244+
await userDestroyer({
245+
user,
246+
actor,
246247
ip: ctx.request.ip,
247248
});
248249

server/api/users.test.js

+2-8
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,6 @@ describe("#users.delete", () => {
145145
expect(res.status).toEqual(400);
146146
});
147147

148-
it("should allow deleting last admin if only user", async () => {
149-
const user = await buildAdmin();
150-
const res = await server.post("/api/users.delete", {
151-
body: { token: user.getJwtToken(), confirmation: true },
152-
});
153-
expect(res.status).toEqual(200);
154-
});
155-
156148
it("should not allow deleting last admin if many users", async () => {
157149
const user = await buildAdmin();
158150
await buildUser({ teamId: user.teamId, isAdmin: false });
@@ -165,6 +157,8 @@ describe("#users.delete", () => {
165157

166158
it("should allow deleting user account with confirmation", async () => {
167159
const user = await buildUser();
160+
await buildUser({ teamId: user.teamId });
161+
168162
const res = await server.post("/api/users.delete", {
169163
body: { token: user.getJwtToken(), confirmation: true },
170164
});

server/commands/userDestroyer.js

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// @flow
2+
import { ValidationError } from "../errors";
3+
import { Event, User } from "../models";
4+
import { Op, sequelize } from "../sequelize";
5+
6+
export default async function userDestroyer({
7+
user,
8+
actor,
9+
ip,
10+
}: {
11+
user: User,
12+
actor: User,
13+
ip: string,
14+
}) {
15+
const { teamId } = user;
16+
17+
const usersCount = await User.count({
18+
where: {
19+
teamId,
20+
},
21+
});
22+
23+
if (usersCount === 1) {
24+
throw new ValidationError("Cannot delete last user on the team.");
25+
}
26+
27+
if (user.isAdmin) {
28+
const otherAdminsCount = await User.count({
29+
where: {
30+
isAdmin: true,
31+
teamId,
32+
id: { [Op.ne]: user.id },
33+
},
34+
});
35+
36+
if (otherAdminsCount === 0) {
37+
throw new ValidationError(
38+
"Cannot delete account as only admin. Please make another user admin and try again."
39+
);
40+
}
41+
}
42+
43+
let transaction = await sequelize.transaction();
44+
let response;
45+
46+
try {
47+
response = await user.destroy({ transaction });
48+
await Event.create(
49+
{
50+
name: "users.delete",
51+
actorId: actor.id,
52+
userId: user.id,
53+
teamId,
54+
data: { name: user.name },
55+
ip,
56+
},
57+
{ transaction }
58+
);
59+
60+
await transaction.commit();
61+
} catch (err) {
62+
await transaction.rollback();
63+
throw err;
64+
}
65+
66+
return response;
67+
}

server/commands/userDestroyer.test.js

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @flow
2+
import { buildUser, buildAdmin } from "../test/factories";
3+
import { flushdb } from "../test/support";
4+
import userDestroyer from "./userDestroyer";
5+
6+
beforeEach(() => flushdb());
7+
8+
describe("userDestroyer", () => {
9+
const ip = "127.0.0.1";
10+
11+
it("should prevent last user from deleting account", async () => {
12+
const user = await buildUser();
13+
14+
let error;
15+
16+
try {
17+
await userDestroyer({
18+
user,
19+
actor: user,
20+
ip,
21+
});
22+
} catch (err) {
23+
error = err;
24+
}
25+
expect(error && error.message).toContain("Cannot delete last user");
26+
});
27+
28+
it("should prevent last admin from deleting account", async () => {
29+
const user = await buildAdmin();
30+
await buildUser({ teamId: user.teamId });
31+
32+
let error;
33+
34+
try {
35+
await userDestroyer({
36+
user,
37+
actor: user,
38+
ip,
39+
});
40+
} catch (err) {
41+
error = err;
42+
}
43+
expect(error && error.message).toContain("Cannot delete account");
44+
});
45+
46+
it("should not prevent multiple admin from deleting account", async () => {
47+
const actor = await buildAdmin();
48+
const user = await buildAdmin({ teamId: actor.teamId });
49+
50+
let error;
51+
52+
try {
53+
await userDestroyer({
54+
user,
55+
actor,
56+
ip,
57+
});
58+
} catch (err) {
59+
error = err;
60+
}
61+
expect(error).toBeFalsy();
62+
expect(user.deletedAt).toBeTruthy();
63+
});
64+
65+
it("should not prevent last non-admin from deleting account", async () => {
66+
const user = await buildUser();
67+
await buildUser({ teamId: user.teamId });
68+
69+
let error;
70+
71+
try {
72+
await userDestroyer({
73+
user,
74+
actor: user,
75+
ip,
76+
});
77+
} catch (err) {
78+
error = err;
79+
}
80+
expect(error).toBeFalsy();
81+
expect(user.deletedAt).toBeTruthy();
82+
});
83+
});

server/models/User.js

-16
Original file line numberDiff line numberDiff line change
@@ -233,22 +233,6 @@ const removeIdentifyingInfo = async (model, options) => {
233233
await model.save({ hooks: false, transaction: options.transaction });
234234
};
235235

236-
const checkLastAdmin = async (model) => {
237-
const teamId = model.teamId;
238-
239-
if (model.isAdmin) {
240-
const userCount = await User.count({ where: { teamId } });
241-
const adminCount = await User.count({ where: { isAdmin: true, teamId } });
242-
243-
if (userCount > 1 && adminCount <= 1) {
244-
throw new ValidationError(
245-
"Cannot delete account as only admin. Please transfer admin permissions to another user and try again."
246-
);
247-
}
248-
}
249-
};
250-
251-
User.beforeDestroy(checkLastAdmin);
252236
User.beforeDestroy(removeIdentifyingInfo);
253237
User.beforeSave(uploadAvatar);
254238
User.beforeCreate(setRandomJwtSecret);

0 commit comments

Comments
 (0)