gristlabs_grist-core/app/gen-server/lib/Doom.ts
George Gevoian 74485f412d (core) Fix delete user button for Google-only accounts
Summary:
An unhandled error was being thrown by CognitoClient when a user was unable
to be found during account deletion. Google-only accounts are no longer
associated with a user in Cognito, so the error was actually benign. A warning is
now logged instead.

Test Plan: Manual.

Reviewers: paulfitz, jarek

Reviewed By: paulfitz, jarek

Differential Revision: https://phab.getgrist.com/D4073
2023-10-18 10:52:58 -04:00

214 lines
8.3 KiB
TypeScript

import { ApiError } from 'app/common/ApiError';
import { FullUser } from 'app/common/UserAPI';
import { Organization } from 'app/gen-server/entity/Organization';
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
import { INotifier } from 'app/server/lib/INotifier';
import { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg';
import { GristLoginSystem } from 'app/server/lib/GristServer';
import { IPermitStore } from 'app/server/lib/Permit';
import remove = require('lodash/remove');
import sortBy = require('lodash/sortBy');
import fetch from 'node-fetch';
/**
*
* This is a tool that specializes in deletion of resources. Deletion needs some
* coordination between multiple services.
*
*/
export class Doom {
constructor(private _dbManager: HomeDBManager, private _permitStore: IPermitStore,
private _notifier: INotifier, private _loginSystem: GristLoginSystem,
private _homeApiUrl: string) {
}
/**
* Deletes a team site.
* - Remove billing (fails if there is an outstanding balance).
* - Delete workspaces.
* - Delete org.
*/
public async deleteOrg(orgKey: number) {
await this._removeBillingFromOrg(orgKey);
const workspaces = await this._getWorkspaces(orgKey);
for (const workspace of workspaces) {
await this.deleteWorkspace(workspace.id);
}
const finalWorkspaces = await this._getWorkspaces(orgKey);
if (finalWorkspaces.length > 0) {
throw new ApiError(`Failed to remove all workspaces from org ${orgKey}`, 500);
}
// There is a window here in which user could put back docs, would be nice to close it.
const scope: Scope = {
userId: this._dbManager.getPreviewerUserId(),
specialPermit: {
org: orgKey
}
};
await this._dbManager.deleteOrg(scope, orgKey);
}
/**
* Deletes a workspace after bloody-mindedly deleting its documents one by one.
* Fails if any document is not successfully deleted.
*/
public async deleteWorkspace(workspaceId: number) {
const workspace = await this._getWorkspace(workspaceId);
for (const doc of workspace.docs) {
const permitKey = await this._permitStore.setPermit({docId: doc.id});
try {
const docApiUrl = this._homeApiUrl + `/api/docs/${doc.id}`;
const result = await fetch(docApiUrl, {
method: 'DELETE',
headers: {
Permit: permitKey
}
});
if (result.status !== 200) {
const info = await result.json().catch(e => null);
throw new ApiError(`failed to delete document ${doc.id}: ${result.status} ${JSON.stringify(info)}`, 500);
}
} finally {
await this._permitStore.removePermit(permitKey);
}
}
const finalWorkspace = await this._getWorkspace(workspaceId);
if (finalWorkspace.docs.length > 0) {
throw new ApiError(`Failed to remove all documents from workspace ${workspaceId}`, 500);
}
// There is a window here in which user could put back docs.
const scope: Scope = {
userId: this._dbManager.getPreviewerUserId(),
specialPermit: {
workspaceId: workspace.id
}
};
await this._dbManager.deleteWorkspace(scope, workspaceId);
}
/**
* Delete a user.
*/
public async deleteUser(userId: number) {
const user = await this._dbManager.getUser(userId);
if (!user) { throw new Error(`user not found: ${userId}`); }
// Don't try scrubbing users from orgs just yet, leave this to be done manually.
// Automatic scrubbing could do with a solid test set before being used.
/**
// Need to scrub the user from any org they are in, except their own personal org.
let orgs = await this._getOrgs(userId);
for (const org of orgs) {
if (org.ownerId !== userId) {
await this.deleteUserFromOrg(userId, org);
}
}
*/
let orgs = await this._getOrgs(userId);
if (orgs.length === 1 && orgs[0].ownerId === userId) {
await this.deleteOrg(orgs[0].id);
orgs = await this._getOrgs(userId);
}
if (orgs.length > 0) {
throw new ApiError('Cannot remove user from a site', 500);
}
// Remove user from sendgrid
await this._notifier.deleteUser(userId);
// Remove user from cognito
await this._loginSystem.deleteUser(user);
// Remove user from our db
await this._dbManager.deleteUser({userId}, userId);
}
/**
* Disentangle a user from a specific site. Everything a user has access to will be
* passed to another owner user. If there is no owner available, the call will fail -
* you'll need to explicitly delete the site. Owners who are billing managers are
* preferred. If there are multiple owners who are billing managers, the choice is
* made arbitrarily (alphabetically by email).
*/
public async deleteUserFromOrg(userId: number, org: Organization) {
const orgId = org.id;
const scope = {userId: this._dbManager.getPreviewerUserId()};
const members = this._dbManager.unwrapQueryResult(await this._dbManager.getOrgAccess(scope, orgId));
const owners: FullUser[] = members.users
.filter(u => u.access === 'owners' && u.id !== userId);
if (owners.length === 0) {
throw new ApiError(`No owner available for ${org.id}/${org.domain}/${org.name}`, 401);
}
if (owners.length > 1) {
const billing = await this._dbManager.getBillingAccount(scope, orgId, true);
const billingManagers = billing.managers.map(manager => manager.user)
.filter(u => u.id !== userId)
.map(u => this._dbManager.makeFullUser(u));
const billingManagerSet = new Set(billingManagers.map(bm => bm.id));
const nonBillingManagers = remove(owners, owner => !billingManagerSet.has(owner.id));
if (owners.length === 0) {
// Darn, no owners were billing-managers - so put them all back into consideration.
owners.push(...nonBillingManagers);
}
}
const candidate = sortBy(owners, ['email'])[0];
await scrubUserFromOrg(orgId, userId, candidate.id, this._dbManager.connection.manager);
}
// List the sites a user has access to.
private async _getOrgs(userId: number) {
const orgs = this._dbManager.unwrapQueryResult(await this._dbManager.getOrgs(userId, null,
{ignoreEveryoneShares: true}));
return orgs;
}
// Get information about a workspace, including the docs in it.
private async _getWorkspace(workspaceId: number) {
const workspace = this._dbManager.unwrapQueryResult(
await this._dbManager.getWorkspace({userId: this._dbManager.getPreviewerUserId(),
showAll: true}, workspaceId));
return workspace;
}
// List the workspaces in a site.
private async _getWorkspaces(orgKey: number) {
const org = this._dbManager.unwrapQueryResult(
await this._dbManager.getOrgWorkspaces({userId: this._dbManager.getPreviewerUserId(),
includeSupport: false, showAll: true}, orgKey));
return org;
}
// Do whatever it takes to clean up billing information linked with site.
private async _removeBillingFromOrg(orgKey: number): Promise<void> {
const account = await this._dbManager.getBillingAccount(
{userId: this._dbManager.getPreviewerUserId()}, orgKey, false);
if (account.stripeCustomerId === null) {
// Nothing to do.
return;
}
const url = this._homeApiUrl + `/api/billing/detach?orgId=${orgKey}`;
const permitKey = await this._permitStore.setPermit({org: orgKey});
try {
const result = await fetch(url, {
method: 'POST',
headers: {
Permit: permitKey
}
});
if (result.status !== 200) {
// There should be a better way to just pass on the error?
const info = await result.json().catch(e => null);
throw new ApiError(`failed to delete customer: ${result.status} ${JSON.stringify(info)}`, result.status);
}
} finally {
await this._permitStore.removePermit(permitKey);
}
await this._dbManager.updateBillingAccount(
this._dbManager.getPreviewerUserId(), orgKey, async (billingAccount, transaction) => {
billingAccount.stripeCustomerId = null;
billingAccount.stripePlanId = null;
billingAccount.stripeSubscriptionId = null;
});
}
}