🚧 backend: wip initial setup for admin route to delete user (#799)

This commit is contained in:
Eric Doughty-Papassideris
2025-02-24 01:06:02 +01:00
committed by Anton Shepilov
parent 185ec5ac5c
commit 16c68f25fd
19 changed files with 500 additions and 4 deletions
@@ -0,0 +1,13 @@
import config from "../../../config";
interface IAdminConfig {
// This secret must be provided to the administration endpoints
endpointSecret?: string;
}
export const getConfig = (): IAdminConfig => {
const configSection = config.get("admin") as IAdminConfig;
return {
...configSection,
};
};
@@ -0,0 +1,92 @@
import gr from "../../../../../services/global-resolver";
import { getLogger } from "../../../../../core/platform/framework";
import User, { TYPE as UserType } from "../../../../../services/user/entities/user";
import type { DatabaseServiceAPI } from "../../database/api";
import type { ExecutionContext } from "../../../../platform/framework/api/crud-service";
import type { SearchServiceAPI } from "../../search/api";
import {
DriveFile,
TYPE as DriveFileType,
} from "../../../../../services/documents/entities/drive-file";
import { File } from "../../../../../services/files/entities/file";
import {
FileVersion,
TYPE as FileVersionType,
} from "../../../../../services/documents/entities/file-version";
import ExternalUser, {
TYPE as ExternalUserType,
} from "../../../../../services/user/entities/external_user";
import CompanyUser, {
TYPE as CompanyUserType,
} from "../../../../../services/user/entities/company_user";
const FileType = "files";
const logger = getLogger("AdminDeleteUserController");
/**
* Create all repositories required for deleting a user
* @deprecated Do not use this outside of this file, it is exported exclusively for e2e tests
*/
export async function buildUserDeletionRepositories(
db: DatabaseServiceAPI,
search: SearchServiceAPI,
) {
return {
driveFile: await db.getRepository<DriveFile>(DriveFileType, DriveFile),
file: await db.getRepository<File>(FileType, File),
fileVersion: await db.getRepository<FileVersion>(FileVersionType, FileVersion),
user: await db.getRepository<User>(UserType, User),
companyUser: await db.getRepository<CompanyUser>(CompanyUserType, CompanyUser),
externalUser: await db.getRepository<ExternalUser>(ExternalUserType, ExternalUser),
// group_entity
// group_user
// missed_drive_files
// session
// user
// user_online
search: {
driveFile: await search.getRepository<DriveFile>(DriveFileType, DriveFile),
user: await search.getRepository<User>(UserType, User),
},
};
}
export class AdminDeleteUserController {
private constructor(
private readonly repos: Awaited<ReturnType<typeof buildUserDeletionRepositories>>,
) {}
public static async create() {
return new AdminDeleteUserController(
await buildUserDeletionRepositories(gr.database, gr.platformServices.search),
);
}
// fisherYattesShuffleInPlace
/** Begin or forward the deletion process of a user */
async deleteUser(userId: string): Promise<"failed" | "deleting" | "done"> {
try {
await gr.services.users.anonymizeAndDelete({ id: userId }, {
user: { server_request: true },
company: { id: "// TODO: REPLACE WITH COMPANY ID" },
} as unknown as ExecutionContext);
const existingUser = await this.repos.user.findOne({ id: userId });
if (existingUser?.deleted) {
if (existingUser.delete_process_started_epoch > 0) return "deleting";
}
} catch (err) {
logger.error({ err, userId }, "User deletion error");
return "failed";
}
return "done";
}
/** Get an array of user IDs that are incompletely deleted */
async listUsersPendingDeletion() {
return (await this.repos.user.find({}, { $gt: [["delete_process_started_epoch", 0]] }))
.getEntities()
.map(({ id }) => id);
}
}
@@ -0,0 +1,36 @@
import { TdriveService, Consumes, Prefix, ServiceName } from "../../framework";
import web from "./web";
import AdminServiceAPI from "./service-provider";
import AdminServiceImpl from "./service";
import WebServerAPI from "../webserver/provider";
/**
* The admin service exposes endpoint that are of use for operational reasons to administrators only, and should not be exposed.
*/
@Prefix("/admin")
@Consumes(["webserver"])
@ServiceName("admin")
export default class AdminService extends TdriveService<AdminServiceAPI> {
name = "admin";
service: AdminServiceAPI;
api(): AdminServiceAPI {
return this.service;
}
public async doInit(): Promise<this> {
this.service = new AdminServiceImpl();
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
public async doStop(): Promise<this> {
return this;
}
}
@@ -0,0 +1,5 @@
import type { TdriveServiceProvider } from "../../framework";
type AdminServiceAPI = TdriveServiceProvider;
export default AdminServiceAPI;
@@ -0,0 +1,5 @@
import AdminServiceAPI from "./service-provider";
export default class AdminServiceImpl implements AdminServiceAPI {
version: "1";
}
@@ -0,0 +1,46 @@
import type { FastifyInstance, FastifyPluginCallback, FastifyReply, FastifyRequest } from "fastify";
import { getConfig } from "../../../framework/api/admin";
import { AdminDeleteUserController } from "../controller/delete-user-controller";
const config = getConfig();
type TQueryBody = { secret: string };
function authenticateAdminQuery(request: FastifyRequest, reply: FastifyReply) {
const body = request.body as TQueryBody;
if (body?.secret !== config.endpointSecret) {
reply.status(403).send();
return false;
}
return true;
}
type TUserDeleteQueryBody = TQueryBody & { userId: string };
function getUserIfValidQuery(request: FastifyRequest, reply: FastifyReply) {
if (!authenticateAdminQuery(request, reply)) return false;
const body = request.body as TUserDeleteQueryBody;
if (!body.userId?.length) {
reply.status(400).send();
return false;
}
return body.userId;
}
const routes: FastifyPluginCallback = async (fastify: FastifyInstance, _opts, next) => {
const config = getConfig();
const controller = await AdminDeleteUserController.create();
if (config?.endpointSecret?.length) {
fastify.post("/user/delete", async (request, reply) => {
const userId = getUserIfValidQuery(request, reply);
if (!userId) return false;
return reply.send({ status: await controller.deleteUser(userId) });
});
fastify.post("/user/delete/pending", async (request, reply) => {
if (!authenticateAdminQuery(request, reply)) return false;
return reply.send(await controller.listUsersPendingDeletion());
});
}
next();
};
export default routes;
@@ -0,0 +1,9 @@
import type { FastifyInstance, FastifyRegisterOptions } from "fastify";
import deleteUserRoutes from "./delete-user-routes";
export default (
fastify: FastifyInstance,
opts: FastifyRegisterOptions<{ prefix: string }>,
): void => {
fastify.register(deleteUserRoutes, opts);
};
@@ -78,6 +78,12 @@ export default class User {
@Column("deleted", "tdrive_boolean")
deleted: boolean;
/**
* If set, a restartable suppression process is currently incomplete.
*/
@Column("delete_process_started_epoch", "number")
delete_process_started_epoch?: number;
@Column("mail_verified", "tdrive_boolean")
mail_verified: boolean;
@@ -310,7 +310,11 @@ export class CompanyServiceImpl {
for (const company of companies) {
logger.warn(`User ${userPk.id} is deleted so removed from company ${company.id}`);
await this.removeUserFromCompany(company, user);
await gr.services.workspaces.ensureUserNotInCompanyIsNotInWorkspace(userPk, company.id);
try {
await gr.services.workspaces.ensureUserNotInCompanyIsNotInWorkspace(userPk, company.id);
} catch (err) {
logger.error({ err }, "Error removing user from company from workspace");
}
}
}
}
@@ -170,6 +170,7 @@ export class UserServiceImpl {
user.thumbnail_id = null;
user.status_icon = null;
user.deleted = true;
user.delete_process_started_epoch = new Date().getTime();
await this.save(user);
@@ -15,6 +15,7 @@ export const userObjectSchema = {
last_name: { type: "string" },
created_at: { type: "number" },
deleted: { type: "boolean" },
delete_process_started_epoch: { type: "number" },
status: { type: "string" },
last_activity: { type: "number" },
@@ -732,9 +732,11 @@ export class WorkspaceServiceImpl implements TdriveServiceProvider, Initializabl
logger.warn(
`User ${userPk.id} is not in company ${workspace.company_id} so removing from workspace ${workspace.id}`,
);
this.removeUser({ workspaceId: workspace.id, userId: userPk.id }, companyId, context).then(
() => null,
);
await this.removeUser(
{ workspaceId: workspace.id, userId: userPk.id },
companyId,
context,
).then(() => null);
}
}
}
+1
View File
@@ -30,6 +30,7 @@ export async function formatUser(
full_name: [user.first_name, user.last_name].join(" "),
created_at: user.creation_date,
deleted: Boolean(user.deleted),
delete_process_started_epoch: user.delete_process_started_epoch,
status: user.status_icon,
last_activity: user.last_activity,
cache: { companies: user.cache?.companies || [] },