(core) add a tool for deleting a user

Summary:
This adds a `user:delete` target to the `cli.sh` tool. The desired user will be deleted from our database, from sendgrid, and from cognito.

There is code for scrubbing the user from team sites, but it isn't yet activated, I'm leaving finalizing and writing tests for it for follow-up.

Test Plan: tested manually

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3043
This commit is contained in:
Paul Fitzpatrick 2021-09-29 11:39:56 -04:00
parent 876a0298a2
commit 383b8ffbf0
9 changed files with 164 additions and 51 deletions

View File

@ -1,6 +1,13 @@
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';
/**
@ -11,6 +18,7 @@ import fetch from 'node-fetch';
*/
export class Doom {
constructor(private _dbManager: HomeDBManager, private _permitStore: IPermitStore,
private _notifier: INotifier, private _loginSystem: GristLoginSystem,
private _homeApiUrl: string) {
}
@ -78,6 +86,83 @@ export class Doom {
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
const fullUser = this._dbManager.makeFullUser(user);
await this._loginSystem.deleteUser(fullUser);
// 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(

View File

@ -872,7 +872,8 @@ export class HomeDBManager extends EventEmitter {
* The anonymous user is treated specially, to avoid advertising organizations
* with anonymous access.
*/
public async getOrgs(users: AvailableUsers, domain: string|null): Promise<QueryResult<Organization[]>> {
public async getOrgs(users: AvailableUsers, domain: string|null,
options?: {ignoreEveryoneShares?: boolean}): Promise<QueryResult<Organization[]>> {
let queryBuilder = this._orgs()
.leftJoinAndSelect('orgs.owner', 'users', 'orgs.owner_id = users.id');
if (isSingleUser(users)) {
@ -887,7 +888,7 @@ export class HomeDBManager extends EventEmitter {
.addOrderBy('orgs.name');
queryBuilder = this._withAccess(queryBuilder, users, 'orgs');
// Add a direct, efficient filter to remove irrelevant personal orgs from consideration.
queryBuilder = this._filterByOrgGroups(queryBuilder, users, domain);
queryBuilder = this._filterByOrgGroups(queryBuilder, users, domain, options);
if (this._isAnonymousUser(users)) {
// The anonymous user is a special case. It may have access to potentially
// many orgs, but listing them all would be kind of a misfeature. but reporting
@ -2459,7 +2460,7 @@ export class HomeDBManager extends EventEmitter {
// Add information about owners of personal orgs.
query = query.leftJoinAndSelect('org_users.logins', 'org_logins');
// Add a direct, efficient filter to remove irrelevant personal orgs from consideration.
query = this._filterByOrgGroups(query, userId);
query = this._filterByOrgGroups(query, userId, null);
// The anonymous user is a special case; include only examples from support user.
if (userId === this.getAnonymousUserId()) {
query = query.andWhere('orgs.owner_id = :supportId', { supportId });
@ -3090,7 +3091,8 @@ export class HomeDBManager extends EventEmitter {
* whether this wrinkle is needed anymore, or can be safely removed.
*/
private _filterByOrgGroups(qb: SelectQueryBuilder<Organization>, users: AvailableUsers,
orgKey: string|number|null = null) {
orgKey: string|number|null,
options?: {ignoreEveryoneShares?: boolean}) {
qb = qb
.leftJoin('orgs.aclRules', 'acl_rules')
.leftJoin('acl_rules.group', 'groups')
@ -3100,13 +3102,16 @@ export class HomeDBManager extends EventEmitter {
const previewerId = this._specialUserIds[PREVIEWER_EMAIL];
if (users === previewerId) { return qb; }
const everyoneId = this._specialUserIds[EVERYONE_EMAIL];
if (options?.ignoreEveryoneShares) {
return qb.where('members.id = :userId', {userId: users});
}
return qb.andWhere(new Brackets(cond => {
// Accept direct membership, or via a share with "everyone@".
return cond
.where('members.id = :userId', {userId: users})
.orWhere(new Brackets(everyoneCond => {
const everyoneQuery = everyoneCond.where('members.id = :everyoneId', {everyoneId});
return orgKey ? this._whereOrg(everyoneQuery, orgKey) : everyoneQuery;
return (orgKey !== null) ? this._whereOrg(everyoneQuery, orgKey) : everyoneQuery;
}));
}));
}

View File

@ -37,7 +37,7 @@ import {IBilling} from 'app/server/lib/IBilling';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {INotifier} from 'app/server/lib/INotifier';
import * as log from 'app/server/lib/log';
import {getLoginMiddleware} from 'app/server/lib/logins';
import {getLoginSystem} from 'app/server/lib/logins';
import {IPermitStore} from 'app/server/lib/Permit';
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
@ -750,7 +750,8 @@ export class FlexServer implements GristServer {
// TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
// could create a mock SAML identity provider for testing this using the SAML flow.
this._loginMiddleware = await getLoginMiddleware(this);
const loginSystem = await getLoginSystem();
this._loginMiddleware = await loginSystem.getMiddleware(this);
this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);
this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware);
this._getLogoutRedirectUrl = tbind(this._loginMiddleware.getLogoutRedirectUrl, this._loginMiddleware);

View File

@ -1,4 +1,5 @@
import { GristLoadConfig } from 'app/common/gristUrls';
import { FullUser } from 'app/common/UserAPI';
import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization';
import { Workspace } from 'app/gen-server/entity/Workspace';
@ -33,6 +34,11 @@ export interface GristServer {
getStorageManager(): IDocStorageManager;
}
export interface GristLoginSystem {
getMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware>;
deleteUser(user: FullUser): Promise<void>;
}
export interface GristLoginMiddleware {
getLoginRedirectUrl(req: express.Request, target: URL): Promise<string>;
getSignUpRedirectUrl(req: express.Request, target: URL): Promise<string>;

View File

@ -1,4 +1,5 @@
export interface INotifier {
deleteUser(userId: number): Promise<void>;
// for test purposes, check if any notifications are in progress
readonly testPending: boolean;
}

View File

@ -1,37 +1,44 @@
import { UserProfile } from 'app/common/UserAPI';
import { GristLoginMiddleware, GristServer } from 'app/server/lib/GristServer';
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
import { Request } from 'express';
/**
* Return a login system that supports a single hard-coded user.
*/
export async function getMinimalLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware> {
export async function getMinimalLoginSystem(): Promise<GristLoginSystem> {
// Login and logout, redirecting immediately back. Signup is treated as login,
// no nuance here.
return {
async getLoginRedirectUrl(req: Request, url: URL) {
await setSingleUser(req, gristServer);
return url.href;
async getMiddleware(gristServer: GristServer) {
return {
async getLoginRedirectUrl(req: Request, url: URL) {
await setSingleUser(req, gristServer);
return url.href;
},
async getLogoutRedirectUrl(req: Request, url: URL) {
return url.href;
},
async getSignUpRedirectUrl(req: Request, url: URL) {
await setSingleUser(req, gristServer);
return url.href;
},
async addEndpoints() {
// If working without a login system, make sure default user exists.
const dbManager = gristServer.getHomeDBManager();
const profile = getDefaultProfile();
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
if (user) {
// No need to survey this user!
user.isFirstTimeUser = false;
await user.save();
}
return "no-logins";
},
};
},
async getLogoutRedirectUrl(req: Request, url: URL) {
return url.href;
async deleteUser() {
// nothing to do
},
async getSignUpRedirectUrl(req: Request, url: URL) {
await setSingleUser(req, gristServer);
return url.href;
},
async addEndpoints() {
// If working without a login system, make sure default user exists.
const dbManager = gristServer.getHomeDBManager();
const profile = getDefaultProfile();
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
if (user) {
// No need to survey this user!
user.isFirstTimeUser = false;
await user.save();
}
return "no-logins";
}
};
}

View File

@ -58,7 +58,7 @@ import * as fse from 'fs-extra';
import * as saml2 from 'saml2-js';
import {expressWrap} from 'app/server/lib/expressWrap';
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
import * as log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {fromCallback} from 'app/server/lib/serverUtils';
@ -238,23 +238,30 @@ export class SamlConfig {
}
/**
* Return SAML middleware if environment looks configured for it, else return undefined.
* Return SAML login system if environment looks configured for it, else return undefined.
*/
export async function getSamlLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware|undefined> {
export async function getSamlLoginSystem(): Promise<GristLoginSystem|undefined> {
if (!process.env.GRIST_SAML_SP_HOST) {
return undefined;
}
const samlConfig = new SamlConfig(gristServer);
await samlConfig.initSaml();
return {
getLoginRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
// For saml, always use regular login page, users are enrolled externally.
// TODO: is there a better link to give here?
getSignUpRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
getLogoutRedirectUrl: samlConfig.getLogoutRedirectUrl.bind(samlConfig),
async addEndpoints(app: express.Express) {
samlConfig.addSamlEndpoints(app, gristServer.getSessions());
return 'saml';
}
async getMiddleware(gristServer: GristServer) {
const samlConfig = new SamlConfig(gristServer);
await samlConfig.initSaml();
return {
getLoginRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
// For saml, always use regular login page, users are enrolled externally.
// TODO: is there a better link to give here?
getSignUpRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
getLogoutRedirectUrl: samlConfig.getLogoutRedirectUrl.bind(samlConfig),
async addEndpoints(app: express.Express) {
samlConfig.addSamlEndpoints(app, gristServer.getSessions());
return 'saml';
},
};
},
deleteUser() {
throw new Error('users cannot be deleted with SAML yet');
},
};
}

View File

@ -15,7 +15,8 @@ export const create: ICreate = {
},
Notifier() {
return {
get testPending() { return false; }
get testPending() { return false; },
deleteUser() { throw new Error('deleteUser unavailable'); },
};
},
Shell() {

View File

@ -1,9 +1,9 @@
import { GristLoginMiddleware, GristServer } from 'app/server/lib/GristServer';
import { getMinimalLoginMiddleware } from 'app/server/lib/MinimalLogin';
import { getSamlLoginMiddleware } from 'app/server/lib/SamlConfig';
import { GristLoginSystem } from 'app/server/lib/GristServer';
import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin';
import { getSamlLoginSystem } from 'app/server/lib/SamlConfig';
export async function getLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware> {
const saml = await getSamlLoginMiddleware(gristServer);
export async function getLoginSystem(): Promise<GristLoginSystem> {
const saml = await getSamlLoginSystem();
if (saml) { return saml; }
return getMinimalLoginMiddleware(gristServer);
return getMinimalLoginSystem();
}