feat: Add team deletion flow for cloud-hosted (#5717)

This commit is contained in:
Tom Moor
2023-08-21 20:24:46 -04:00
committed by GitHub
parent 5c07694f6b
commit 418d3305b2
26 changed files with 461 additions and 71 deletions

View File

@@ -362,7 +362,7 @@ describe("accountProvisioner", () => {
}
expect(error.message).toEqual(
"The maximum number of teams has been reached"
"The maximum number of workspaces has been reached"
);
});

View File

@@ -0,0 +1,33 @@
import { Transaction } from "sequelize";
import { Event, User, Team } from "@server/models";
export default async function teamDestroyer({
user,
team,
ip,
transaction,
}: {
user: User;
team: Team;
ip: string;
transaction?: Transaction;
}) {
await Event.create(
{
name: "teams.delete",
actorId: user.id,
teamId: team.id,
data: {
name: team.name,
},
ip,
},
{
transaction,
}
);
return team.destroy({
transaction,
});
}

View File

@@ -4,6 +4,7 @@ import {
DomainNotAllowedError,
InvalidAuthenticationError,
MaximumTeamsError,
TeamPendingDeletionError,
} from "@server/errors";
import { traceFunction } from "@server/logging/tracing";
import { Team, AuthenticationProvider } from "@server/models";
@@ -58,6 +59,7 @@ async function teamProvisioner({
model: Team,
as: "team",
required: true,
paranoid: false,
},
],
});
@@ -65,6 +67,10 @@ async function teamProvisioner({
// This authentication provider already exists which means we have a team and
// there is nothing left to do but return the existing credentials
if (authP) {
if (authP.team.deletedAt) {
throw TeamPendingDeletionError();
}
return {
authenticationProvider: authP,
team: authP.team,

View File

@@ -1,16 +1,17 @@
import { Op } from "sequelize";
import { Op, Transaction } from "sequelize";
import { Event, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { ValidationError } from "../errors";
export default async function userDestroyer({
user,
actor,
ip,
transaction,
}: {
user: User;
actor: User;
ip: string;
transaction?: Transaction;
}) {
const { teamId } = user;
const usersCount = await User.count({
@@ -20,7 +21,9 @@ export default async function userDestroyer({
});
if (usersCount === 1) {
throw ValidationError("Cannot delete last user on the team.");
throw ValidationError(
"Cannot delete last user on the team, delete the workspace instead."
);
}
if (user.isAdmin) {
@@ -41,33 +44,23 @@ export default async function userDestroyer({
}
}
const transaction = await sequelize.transaction();
let response;
try {
response = await user.destroy({
transaction,
});
await Event.create(
{
name: "users.delete",
actorId: actor.id,
userId: user.id,
teamId,
data: {
name: user.name,
},
ip,
await Event.create(
{
name: "users.delete",
actorId: actor.id,
userId: user.id,
teamId,
data: {
name: user.name,
},
{
transaction,
}
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
ip,
},
{
transaction,
}
);
return response;
return user.destroy({
transaction,
});
}

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import env from "@server/env";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import CopyableCode from "./components/CopyableCode";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = EmailProps & {
deleteConfirmationCode: string;
};
/**
* Email sent to a user when they request to delete their workspace.
*/
export default class ConfirmTeamDeleteEmail extends BaseEmail<
Props,
Record<string, any>
> {
protected subject() {
return `Your workspace deletion request`;
}
protected preview() {
return `Your requested workspace deletion code`;
}
protected renderAsText({ deleteConfirmationCode }: Props): string {
return `
You requested to permanantly delete your ${env.APP_NAME} workspace. Please enter the code below to confirm the workspace deletion.
Code: ${deleteConfirmationCode}
`;
}
protected render({ deleteConfirmationCode }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your workspace deletion request</Heading>
<p>
You requested to permanantly delete your {env.APP_NAME} workspace.
Please enter the code below to confirm your workspace deletion.
</p>
<EmptySpace height={5} />
<p>
<CopyableCode>{deleteConfirmationCode}</CopyableCode>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@@ -134,10 +134,18 @@ export function OAuthStateMismatchError(
}
export function MaximumTeamsError(
message = "The maximum number of teams has been reached"
message = "The maximum number of workspaces has been reached"
) {
return httpErrors(400, message, {
id: "maximum_teams",
id: "maximum_reached",
});
}
export function TeamPendingDeletionError(
message = "The workspace is pending deletion"
) {
return httpErrors(403, message, {
id: "pending_deletion",
});
}
@@ -160,7 +168,7 @@ export function MicrosoftGraphError(
}
export function TeamDomainRequiredError(
message = "Unable to determine team from current domain or subdomain"
message = "Unable to determine workspace from current domain or subdomain"
) {
return httpErrors(400, message, {
id: "domain_required",

View File

@@ -1,3 +1,4 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { URL } from "url";
@@ -176,6 +177,22 @@ class Team extends ParanoidModel {
return url.href.replace(/\/$/, "");
}
/**
* Returns a code that can be used to delete the user's team. The code will
* be rotated when the user signs out.
*
* @returns The deletion code.
*/
public getDeleteConfirmationCode(user: User) {
return crypto
.createHash("md5")
.update(`${this.id}${user.jwtSecret}`)
.digest("hex")
.replace(/[l1IoO0]/gi, "")
.slice(0, 8)
.toUpperCase();
}
/**
* Preferences that decide behavior for the team.
*

View File

@@ -12,7 +12,6 @@ it("should allow reading only", async () => {
});
const abilities = serialize(user, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(false);
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
@@ -28,7 +27,6 @@ it("should allow admins to manage", async () => {
});
const abilities = serialize(admin, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(true);
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
@@ -46,7 +44,6 @@ it("should allow creation on hosted envs", async () => {
});
const abilities = serialize(admin, team);
expect(abilities.read).toEqual(true);
expect(abilities.manage).toEqual(true);
expect(abilities.createTeam).toEqual(true);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);

View File

@@ -14,11 +14,22 @@ allow(User, "share", Team, (user, team) => {
allow(User, "createTeam", Team, () => {
if (!env.isCloudHosted()) {
throw IncorrectEditionError("createTeam only available on cloud");
throw IncorrectEditionError("Functionality is only available on cloud");
}
return true;
});
allow(User, ["update", "manage"], Team, (user, team) => {
allow(User, "update", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) {
return false;
}
return user.isAdmin;
});
allow(User, ["delete", "audit"], Team, (user, team) => {
if (!env.isCloudHosted()) {
throw IncorrectEditionError("Functionality is only available on cloud");
}
if (!team || user.isViewer || user.teamId !== team.id) {
return false;
}

View File

@@ -1,9 +1,14 @@
import env from "@server/env";
import { buildEvent, buildUser } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#events.list", () => {
beforeEach(() => {
env.DEPLOYMENT = "hosted";
});
it("should only return activity events", async () => {
const { user, admin, document, collection } = await seed();
// audit event

View File

@@ -42,7 +42,7 @@ router.post(
}
if (auditLog) {
authorize(user, "manage", user.team);
authorize(user, "audit", user.team);
where.name = Event.AUDIT_EVENTS;
}

View File

@@ -48,7 +48,7 @@ router.post(
type,
};
const team = await Team.findByPk(user.teamId);
authorize(user, "manage", team);
authorize(user, "update", team);
const [exports, total] = await Promise.all([
FileOperation.findAll({

View File

@@ -53,3 +53,11 @@ export const TeamsUpdateSchema = BaseSchema.extend({
});
export type TeamsUpdateSchemaReq = z.infer<typeof TeamsUpdateSchema>;
export const TeamsDeleteSchema = BaseSchema.extend({
body: z.object({
code: z.string(),
}),
});
export type TeamsDeleteSchemaReq = z.infer<typeof TeamsDeleteSchema>;

View File

@@ -1,7 +1,11 @@
import invariant from "invariant";
import Router from "koa-router";
import teamCreator from "@server/commands/teamCreator";
import teamDestroyer from "@server/commands/teamDestroyer";
import teamUpdater from "@server/commands/teamUpdater";
import ConfirmTeamDeleteEmail from "@server/emails/templates/ConfirmTeamDeleteEmail";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
@@ -11,9 +15,11 @@ import { authorize } from "@server/policies";
import { presentTeam, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { safeEqual } from "@server/utils/crypto";
import * as T from "./schema";
const router = new Router();
const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development");
router.post(
"team.update",
@@ -44,6 +50,63 @@ router.post(
}
);
router.post(
"teams.requestDelete",
rateLimiter(RateLimiterStrategy.FivePerHour),
auth(),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const { team } = user;
authorize(user, "delete", team);
if (emailEnabled) {
await new ConfirmTeamDeleteEmail({
to: user.email,
deleteConfirmationCode: team.getDeleteConfirmationCode(user),
}).schedule();
}
ctx.body = {
success: true,
};
}
);
router.post(
"teams.delete",
rateLimiter(RateLimiterStrategy.TenPerHour),
auth(),
validate(T.TeamsDeleteSchema),
transaction(),
async (ctx: APIContext<T.TeamsDeleteSchemaReq>) => {
const { auth, transaction } = ctx.state;
const { code } = ctx.input.body;
const { user } = auth;
const { team } = user;
authorize(user, "delete", team);
if (emailEnabled) {
const deleteConfirmationCode = team.getDeleteConfirmationCode(user);
if (!safeEqual(code, deleteConfirmationCode)) {
throw ValidationError("The confirmation code was incorrect");
}
}
await teamDestroyer({
team,
user,
transaction,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
}
);
router.post(
"teams.create",
rateLimiter(RateLimiterStrategy.FivePerHour),

View File

@@ -33,3 +33,12 @@ export const UsersUpdateSchema = BaseSchema.extend({
});
export type UsersUpdateReq = z.infer<typeof UsersUpdateSchema>;
export const UsersDeleteSchema = BaseSchema.extend({
body: z.object({
code: z.string().optional(),
id: z.string().uuid().optional(),
}),
});
export type UsersDeleteSchemaReq = z.infer<typeof UsersDeleteSchema>;

View File

@@ -28,7 +28,6 @@ import {
assertSort,
assertPresent,
assertArray,
assertUuid,
} from "@server/validation";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
@@ -449,13 +448,15 @@ router.post(
"users.delete",
rateLimiter(RateLimiterStrategy.TenPerHour),
auth(),
async (ctx: APIContext) => {
const { id, code = "" } = ctx.request.body;
validate(T.UsersDeleteSchema),
transaction(),
async (ctx: APIContext<T.UsersDeleteSchemaReq>) => {
const { transaction } = ctx.state;
const { id, code } = ctx.request.body;
const actor = ctx.state.auth.user;
let user: User;
if (id) {
assertUuid(id, "id must be a UUID");
user = await User.findByPk(id, {
rejectOnEmpty: true,
});
@@ -478,6 +479,7 @@ router.post(
user,
actor,
ip: ctx.request.ip,
transaction,
});
ctx.body = {