support delete account

This commit is contained in:
miloschwartz
2026-02-14 17:27:51 -08:00
parent 843b13ed57
commit 9eacefb155
13 changed files with 963 additions and 192 deletions

169
server/lib/deleteOrg.ts Normal file
View File

@@ -0,0 +1,169 @@
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
domains,
olms,
orgDomains,
orgs,
resources,
sites
} from "@server/db";
import { newts, newtSessions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
olmsToTerminate: string[];
};
/**
* Deletes one organization and its related data. Returns ids for termination
* messages; caller should call sendTerminationMessages with the result.
* Throws if org not found.
*/
export async function deleteOrgById(
orgId: string
): Promise<DeleteOrgByIdResult> {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
);
}
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, orgId))
.limit(1);
const orgClients = await db
.select()
.from(clients)
.where(eq(clients.orgId, orgId));
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
if (site.type == "wireguard") {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
deletedNewtIds.push(deletedNewt.newtId);
await trx
.delete(newtSessions)
.where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
}
}
}
logger.info(`Deleting site ${site.siteId}`);
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
const [olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (olm) {
olmsToTerminate.push(olm.olmId);
}
logger.info(`Deleting client ${client.clientId}`);
await trx
.delete(clients)
.where(eq(clients.clientId, client.clientId));
await trx
.delete(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
await trx
.delete(clientSitesAssociationsCache)
.where(
eq(clientSitesAssociationsCache.clientId, client.clientId)
);
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
return { deletedNewtIds, olmsToTerminate };
}
export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
for (const newtId of result.deletedNewtIds) {
sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch(
(error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
}
);
}
for (const olmId of result.olmsToTerminate) {
sendTerminateClient(
0,
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error(
"Failed to send termination message to olm:",
error
);
});
}
}

View File

@@ -0,0 +1,228 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, userOrgs, users } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { verifySession } from "@server/auth/sessions/verifySession";
import {
invalidateSession,
createBlankSessionTokenCookie
} from "@server/auth/sessions/app";
import { verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import {
deleteOrgById,
sendTerminationMessages
} from "@server/lib/deleteOrg";
import { UserType } from "@server/types/UserTypes";
const deleteMyAccountBody = z.strictObject({
password: z.string().optional(),
code: z.string().optional()
});
export type DeleteMyAccountPreviewResponse = {
preview: true;
orgs: { orgId: string; name: string }[];
twoFactorEnabled: boolean;
};
export type DeleteMyAccountCodeRequestedResponse = {
codeRequested: true;
};
export type DeleteMyAccountSuccessResponse = {
success: true;
};
/**
* Self-service account deletion (saas only). Returns preview when no password;
* requires password and optional 2FA code to perform deletion. Uses shared
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
*/
export async function deleteMyAccount(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { user, session } = await verifySession(req);
if (!user || !session) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
);
}
if (user.serverAdmin) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admins cannot delete their account this way"
)
);
}
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Account deletion with password is only supported for internal users"
)
);
}
const parsed = deleteMyAccountBody.safeParse(req.body ?? {});
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { password, code } = parsed.data;
const userId = user.userId;
const ownedOrgsRows = await db
.select({
orgId: userOrgs.orgId
})
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.isOwner, true)
)
);
const orgIds = ownedOrgsRows.map((r) => r.orgId);
if (!password) {
const orgsWithNames =
orgIds.length > 0
? await db
.select({
orgId: orgs.orgId,
name: orgs.name
})
.from(orgs)
.where(inArray(orgs.orgId, orgIds))
: [];
return response<DeleteMyAccountPreviewResponse>(res, {
data: {
preview: true,
orgs: orgsWithNames.map((o) => ({
orgId: o.orgId,
name: o.name ?? ""
})),
twoFactorEnabled: user.twoFactorEnabled ?? false
},
success: true,
error: false,
message: "Preview",
status: HttpCode.OK
});
}
const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid password")
);
}
if (user.twoFactorEnabled) {
if (!code) {
return response<DeleteMyAccountCodeRequestedResponse>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor code required",
status: HttpCode.ACCEPTED
});
}
const validOTP = await verifyTotpCode(
code,
user.twoFactorSecret!,
user.userId
);
if (!validOTP) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The two-factor code you entered is incorrect"
)
);
}
}
const allDeletedNewtIds: string[] = [];
const allOlmsToTerminate: string[] = [];
for (const row of ownedOrgsRows) {
try {
const result = await deleteOrgById(row.orgId);
allDeletedNewtIds.push(...result.deletedNewtIds);
allOlmsToTerminate.push(...result.olmsToTerminate);
} catch (err) {
logger.error(
`Failed to delete org ${row.orgId} during account deletion`,
err
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete organization"
)
);
}
}
sendTerminationMessages({
deletedNewtIds: allDeletedNewtIds,
olmsToTerminate: allOlmsToTerminate
});
await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId));
await calculateUserClientsForOrgs(userId, trx);
});
try {
await invalidateSession(session.sessionId);
} catch (error) {
logger.error(
"Failed to invalidate session after account deletion",
error
);
}
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<DeleteMyAccountSuccessResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Account deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
);
}
}

View File

@@ -17,4 +17,5 @@ export * from "./securityKey";
export * from "./startDeviceWebAuth";
export * from "./verifyDeviceWebAuth";
export * from "./pollDeviceWebAuth";
export * from "./lookupUser";
export * from "./lookupUser";
export * from "./deleteMyAccount";

View File

@@ -1164,6 +1164,7 @@ authRouter.post(
auth.login
);
authRouter.post("/logout", auth.logout);
authRouter.post("/delete-my-account", auth.deleteMyAccount);
authRouter.post(
"/lookup-user",
rateLimit({

View File

@@ -1,28 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
domains,
olms,
orgDomains,
resources
} from "@server/db";
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi";
import { OlmErrorCodes } from "../olm/error";
import { sendTerminateClient } from "../client/terminate";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
const deleteOrgSchema = z.strictObject({
orgId: z.string()
@@ -56,170 +40,9 @@ export async function deleteOrg(
)
);
}
const { orgId } = parsedParams.data;
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
// we need to handle deleting each site
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, orgId))
.limit(1);
const orgClients = await db
.select()
.from(clients)
.where(eq(clients.orgId, orgId));
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
if (site.type == "wireguard") {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
deletedNewtIds.push(deletedNewt.newtId);
// delete all of the sessions for the newt
await trx
.delete(newtSessions)
.where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
}
}
}
logger.info(`Deleting site ${site.siteId}`);
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
const [olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (olm) {
olmsToTerminate.push(olm.olmId);
}
logger.info(`Deleting client ${client.clientId}`);
await trx
.delete(clients)
.where(eq(clients.clientId, client.clientId));
// also delete the associations
await trx
.delete(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
await trx
.delete(clientSitesAssociationsCache)
.where(
eq(
clientSitesAssociationsCache.clientId,
client.clientId
)
);
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
// For each domain, check if it belongs to multiple organizations
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
// Count how many organizations this domain belongs to
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
// Delete domains that belong exclusively to this organization
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
// Delete resources
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
// Send termination messages outside of transaction to prevent blocking
for (const newtId of deletedNewtIds) {
const payload = {
type: `newt/wg/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
});
}
for (const olmId of olmsToTerminate) {
sendTerminateClient(
0, // clientId not needed since we're passing olmId
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error(
"Failed to send termination message to olm:",
error
);
});
}
const result = await deleteOrgById(orgId);
sendTerminationMessages(result);
return response(res, {
data: null,
success: true,
@@ -228,6 +51,9 @@ export async function deleteOrg(
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
logger.error(error);
return next(
createHttpError(