chore: Permanent team deletion (#2493)
This commit is contained in:
@ -4,7 +4,7 @@ import { Document, Attachment } from "../models";
|
|||||||
import { sequelize } from "../sequelize";
|
import { sequelize } from "../sequelize";
|
||||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||||
|
|
||||||
export async function documentPermanentDeleter(documents: Document[]) {
|
export default async function documentPermanentDeleter(documents: Document[]) {
|
||||||
const activeDocument = documents.find((doc) => !doc.deletedAt);
|
const activeDocument = documents.find((doc) => !doc.deletedAt);
|
||||||
|
|
||||||
if (activeDocument) {
|
if (activeDocument) {
|
||||||
|
@ -3,7 +3,7 @@ import { subDays } from "date-fns";
|
|||||||
import { Attachment, Document } from "../models";
|
import { Attachment, Document } from "../models";
|
||||||
import { buildAttachment, buildDocument } from "../test/factories";
|
import { buildAttachment, buildDocument } from "../test/factories";
|
||||||
import { flushdb } from "../test/support";
|
import { flushdb } from "../test/support";
|
||||||
import { documentPermanentDeleter } from "./documentPermanentDeleter";
|
import documentPermanentDeleter from "./documentPermanentDeleter";
|
||||||
|
|
||||||
jest.mock("aws-sdk", () => {
|
jest.mock("aws-sdk", () => {
|
||||||
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||||
|
177
server/commands/teamPermanentDeleter.js
Normal file
177
server/commands/teamPermanentDeleter.js
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
// @flow
|
||||||
|
import Logger from "../logging/logger";
|
||||||
|
import {
|
||||||
|
ApiKey,
|
||||||
|
Attachment,
|
||||||
|
AuthenticationProvider,
|
||||||
|
Collection,
|
||||||
|
Document,
|
||||||
|
Event,
|
||||||
|
FileOperation,
|
||||||
|
Group,
|
||||||
|
Team,
|
||||||
|
NotificationSetting,
|
||||||
|
User,
|
||||||
|
UserAuthentication,
|
||||||
|
Integration,
|
||||||
|
SearchQuery,
|
||||||
|
Share,
|
||||||
|
} from "../models";
|
||||||
|
import { sequelize } from "../sequelize";
|
||||||
|
|
||||||
|
export default async function teamPermanentDeleter(team: Team) {
|
||||||
|
if (!team.deletedAt) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot permanently delete ${team.id} team. Please delete it and try again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
"commands",
|
||||||
|
`Permanently deleting team ${team.name} (${team.id})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamId = team.id;
|
||||||
|
let transaction;
|
||||||
|
|
||||||
|
try {
|
||||||
|
transaction = await sequelize.transaction();
|
||||||
|
|
||||||
|
await Attachment.findAllInBatches(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
async (attachments, options) => {
|
||||||
|
Logger.info(
|
||||||
|
"commands",
|
||||||
|
`Deleting attachments ${options.offset} – ${
|
||||||
|
options.offset + options.limit
|
||||||
|
}…`
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
attachments.map((attachment) => attachment.destroy({ transaction }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Destroy user-relation models
|
||||||
|
await User.findAllInBatches(
|
||||||
|
{
|
||||||
|
attributes: ["id"],
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
async (users) => {
|
||||||
|
const userIds = users.map((user) => user.id);
|
||||||
|
|
||||||
|
await UserAuthentication.destroy({
|
||||||
|
where: { userId: userIds },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ApiKey.destroy({
|
||||||
|
where: { userId: userIds },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Destory team-relation models
|
||||||
|
await AuthenticationProvider.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// events must be first due to db constraints
|
||||||
|
await Event.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Collection.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Document.unscoped().destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await FileOperation.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Group.unscoped().destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Integration.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await NotificationSetting.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await SearchQuery.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Share.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.destroy({
|
||||||
|
where: { teamId },
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await team.destroy({
|
||||||
|
force: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "teams.destroy",
|
||||||
|
modelId: teamId,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
if (transaction) {
|
||||||
|
await transaction.rollback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
100
server/commands/teamPermanentDeleter.test.js
Normal file
100
server/commands/teamPermanentDeleter.test.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// @flow
|
||||||
|
import { subDays } from "date-fns";
|
||||||
|
import { Attachment, User, Document, Collection, Team } from "../models";
|
||||||
|
import {
|
||||||
|
buildAttachment,
|
||||||
|
buildUser,
|
||||||
|
buildTeam,
|
||||||
|
buildDocument,
|
||||||
|
} from "../test/factories";
|
||||||
|
import { flushdb } from "../test/support";
|
||||||
|
import teamPermanentDeleter from "./teamPermanentDeleter";
|
||||||
|
|
||||||
|
jest.mock("aws-sdk", () => {
|
||||||
|
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||||
|
return {
|
||||||
|
S3: jest.fn(() => mS3),
|
||||||
|
Endpoint: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("teamPermanentDeleter", () => {
|
||||||
|
it("should destroy related data", async () => {
|
||||||
|
const team = await buildTeam({
|
||||||
|
deletedAt: subDays(new Date(), 90),
|
||||||
|
});
|
||||||
|
const user = await buildUser({
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
await buildDocument({
|
||||||
|
teamId: team.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await teamPermanentDeleter(team);
|
||||||
|
|
||||||
|
expect(await Team.count()).toEqual(0);
|
||||||
|
expect(await User.count()).toEqual(0);
|
||||||
|
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||||
|
expect(await Collection.unscoped().count({ paranoid: false })).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not destroy unrelated data", async () => {
|
||||||
|
const team = await buildTeam({
|
||||||
|
deletedAt: subDays(new Date(), 90),
|
||||||
|
});
|
||||||
|
|
||||||
|
await buildUser();
|
||||||
|
await buildTeam();
|
||||||
|
await buildDocument();
|
||||||
|
|
||||||
|
await teamPermanentDeleter(team);
|
||||||
|
|
||||||
|
expect(await Team.count()).toEqual(4); // each build command creates a team
|
||||||
|
expect(await User.count()).toEqual(2);
|
||||||
|
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
|
||||||
|
expect(await Collection.unscoped().count({ paranoid: false })).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should destroy attachments", async () => {
|
||||||
|
const team = await buildTeam({
|
||||||
|
deletedAt: subDays(new Date(), 90),
|
||||||
|
});
|
||||||
|
const user = await buildUser({
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
const document = await buildDocument({
|
||||||
|
teamId: team.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
await buildAttachment({
|
||||||
|
teamId: document.teamId,
|
||||||
|
documentId: document.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await teamPermanentDeleter(team);
|
||||||
|
|
||||||
|
expect(await Team.count()).toEqual(0);
|
||||||
|
expect(await User.count()).toEqual(0);
|
||||||
|
expect(await Attachment.count()).toEqual(0);
|
||||||
|
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||||
|
expect(await Collection.unscoped().count({ paranoid: false })).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should error when trying to destroy undeleted team", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await teamPermanentDeleter(team);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toEqual(
|
||||||
|
`Cannot permanently delete ${team.id} team. Please delete it and try again.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
78
server/migrations/20210921031555-missing-cascades.js
Normal file
78
server/migrations/20210921031555-missing-cascades.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
let tableName, constraintName;
|
||||||
|
|
||||||
|
tableName = 'collection_users';
|
||||||
|
constraintName = 'collection_users_collectionId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"
|
||||||
|
add constraint "${constraintName}" foreign key("collectionId") references "collections" ("id")
|
||||||
|
on delete cascade`
|
||||||
|
);
|
||||||
|
|
||||||
|
constraintName = 'collection_users_userId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"\
|
||||||
|
add constraint "${constraintName}" foreign key("userId") references "users" ("id")
|
||||||
|
on delete cascade`
|
||||||
|
);
|
||||||
|
|
||||||
|
tableName = 'group_users';
|
||||||
|
constraintName = 'group_users_groupId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"
|
||||||
|
add constraint "${constraintName}" foreign key("groupId") references "groups" ("id")
|
||||||
|
on delete cascade`
|
||||||
|
);
|
||||||
|
|
||||||
|
constraintName = 'group_users_userId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"
|
||||||
|
add constraint "${constraintName}" foreign key("userId") references "users" ("id")
|
||||||
|
on delete cascade`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
let tableName, constraintName;
|
||||||
|
|
||||||
|
tableName = 'collection_users';
|
||||||
|
constraintName = 'collection_users_collectionId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"\
|
||||||
|
add constraint "${constraintName}" foreign key("collectionId") references "collections" ("id")
|
||||||
|
on delete no action`
|
||||||
|
);
|
||||||
|
|
||||||
|
constraintName = 'collection_users_userId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"\
|
||||||
|
add constraint "${constraintName}" foreign key("userId") references "users" ("id")
|
||||||
|
on delete no action`
|
||||||
|
);
|
||||||
|
|
||||||
|
tableName = 'group_users';
|
||||||
|
constraintName = 'group_users_groupId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"
|
||||||
|
add constraint "${constraintName}" foreign key("groupId") references "groups" ("id")
|
||||||
|
on delete no action`
|
||||||
|
);
|
||||||
|
|
||||||
|
constraintName = 'group_users_userId_fkey';
|
||||||
|
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`alter table "${tableName}"
|
||||||
|
add constraint "${constraintName}" foreign key("userId") references "users" ("id")
|
||||||
|
on delete no action`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
@ -54,6 +54,22 @@ const Attachment = sequelize.define(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Attachment.findAllInBatches = async (
|
||||||
|
query,
|
||||||
|
callback: (attachments: Array<Attachment>, query: Object) => Promise<void>
|
||||||
|
) => {
|
||||||
|
if (!query.offset) query.offset = 0;
|
||||||
|
if (!query.limit) query.limit = 10;
|
||||||
|
let results;
|
||||||
|
|
||||||
|
do {
|
||||||
|
results = await Attachment.findAll(query);
|
||||||
|
|
||||||
|
await callback(results, query);
|
||||||
|
query.offset += query.limit;
|
||||||
|
} while (results.length >= query.limit);
|
||||||
|
};
|
||||||
|
|
||||||
Attachment.beforeDestroy(async (model) => {
|
Attachment.beforeDestroy(async (model) => {
|
||||||
await deleteFromS3(model.key);
|
await deleteFromS3(model.key);
|
||||||
});
|
});
|
||||||
|
@ -304,6 +304,22 @@ User.getCounts = async function (teamId: string) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
User.findAllInBatches = async (
|
||||||
|
query,
|
||||||
|
callback: (users: Array<User>, query: Object) => Promise<void>
|
||||||
|
) => {
|
||||||
|
if (!query.offset) query.offset = 0;
|
||||||
|
if (!query.limit) query.limit = 10;
|
||||||
|
let results;
|
||||||
|
|
||||||
|
do {
|
||||||
|
results = await User.findAll(query);
|
||||||
|
|
||||||
|
await callback(results, query);
|
||||||
|
query.offset += query.limit;
|
||||||
|
} while (results.length >= query.limit);
|
||||||
|
};
|
||||||
|
|
||||||
User.prototype.demote = async function (
|
User.prototype.demote = async function (
|
||||||
teamId: string,
|
teamId: string,
|
||||||
to: "member" | "viewer"
|
to: "member" | "viewer"
|
||||||
|
@ -4,9 +4,7 @@ import { USER_PRESENCE_INTERVAL } from "../../shared/constants";
|
|||||||
import { User } from "../models";
|
import { User } from "../models";
|
||||||
import { DataTypes, Op, sequelize } from "../sequelize";
|
import { DataTypes, Op, sequelize } from "../sequelize";
|
||||||
|
|
||||||
const View = sequelize.define(
|
const View = sequelize.define("view", {
|
||||||
"view",
|
|
||||||
{
|
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
@ -19,11 +17,7 @@ const View = sequelize.define(
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
defaultValue: 1,
|
defaultValue: 1,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
{
|
|
||||||
classMethods: {},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
View.associate = (models) => {
|
View.associate = (models) => {
|
||||||
View.belongsTo(models.Document);
|
View.belongsTo(models.Document);
|
||||||
|
@ -5,7 +5,7 @@ import { subtractDate } from "../../../shared/utils/date";
|
|||||||
import documentCreator from "../../commands/documentCreator";
|
import documentCreator from "../../commands/documentCreator";
|
||||||
import documentImporter from "../../commands/documentImporter";
|
import documentImporter from "../../commands/documentImporter";
|
||||||
import documentMover from "../../commands/documentMover";
|
import documentMover from "../../commands/documentMover";
|
||||||
import { documentPermanentDeleter } from "../../commands/documentPermanentDeleter";
|
import documentPermanentDeleter from "../../commands/documentPermanentDeleter";
|
||||||
import env from "../../env";
|
import env from "../../env";
|
||||||
import {
|
import {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { subDays } from "date-fns";
|
import { subDays } from "date-fns";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { documentPermanentDeleter } from "../../commands/documentPermanentDeleter";
|
import documentPermanentDeleter from "../../commands/documentPermanentDeleter";
|
||||||
|
import teamPermanentDeleter from "../../commands/teamPermanentDeleter";
|
||||||
import { AuthenticationError } from "../../errors";
|
import { AuthenticationError } from "../../errors";
|
||||||
import Logger from "../../logging/logger";
|
import Logger from "../../logging/logger";
|
||||||
import { Document, FileOperation } from "../../models";
|
import { Document, Team, FileOperation } from "../../models";
|
||||||
import { Op } from "../../sequelize";
|
import { Op } from "../../sequelize";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@ -59,6 +60,25 @@ router.post("utils.gc", async (ctx) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
"utils",
|
||||||
|
`Permanently destroying upto ${limit} teams older than 30 days…`
|
||||||
|
);
|
||||||
|
|
||||||
|
const teams = await Team.findAll({
|
||||||
|
where: {
|
||||||
|
deletedAt: {
|
||||||
|
[Op.lt]: subDays(new Date(), 30),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
await teamPermanentDeleter(team);
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user