diff --git a/app/gen-server/lib/Doom.ts b/app/gen-server/lib/Doom.ts index 56a7c3b0..283a42c5 100644 --- a/app/gen-server/lib/Doom.ts +++ b/app/gen-server/lib/Doom.ts @@ -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( diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 776ccebd..90c10e61 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -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> { + public async getOrgs(users: AvailableUsers, domain: string|null, + options?: {ignoreEveryoneShares?: boolean}): Promise> { 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, 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; })); })); } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index aa69902e..75171581 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -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); diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index d51fffaa..76acc972 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -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; + deleteUser(user: FullUser): Promise; +} + export interface GristLoginMiddleware { getLoginRedirectUrl(req: express.Request, target: URL): Promise; getSignUpRedirectUrl(req: express.Request, target: URL): Promise; diff --git a/app/server/lib/INotifier.ts b/app/server/lib/INotifier.ts index 44f84b57..b9d5e07b 100644 --- a/app/server/lib/INotifier.ts +++ b/app/server/lib/INotifier.ts @@ -1,4 +1,5 @@ export interface INotifier { + deleteUser(userId: number): Promise; // for test purposes, check if any notifications are in progress readonly testPending: boolean; } diff --git a/app/server/lib/MinimalLogin.ts b/app/server/lib/MinimalLogin.ts index 4e24e593..5ef0c1d1 100644 --- a/app/server/lib/MinimalLogin.ts +++ b/app/server/lib/MinimalLogin.ts @@ -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 { +export async function getMinimalLoginSystem(): Promise { // 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"; - } }; } diff --git a/app/server/lib/SamlConfig.ts b/app/server/lib/SamlConfig.ts index 3b9417c8..f7abff76 100644 --- a/app/server/lib/SamlConfig.ts +++ b/app/server/lib/SamlConfig.ts @@ -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 { +export async function getSamlLoginSystem(): Promise { 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'); + }, }; } diff --git a/stubs/app/server/lib/create.ts b/stubs/app/server/lib/create.ts index ba52e539..fbe67f53 100644 --- a/stubs/app/server/lib/create.ts +++ b/stubs/app/server/lib/create.ts @@ -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() { diff --git a/stubs/app/server/lib/logins.ts b/stubs/app/server/lib/logins.ts index 790b0bab..bceeac2b 100644 --- a/stubs/app/server/lib/logins.ts +++ b/stubs/app/server/lib/logins.ts @@ -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 { - const saml = await getSamlLoginMiddleware(gristServer); +export async function getLoginSystem(): Promise { + const saml = await getSamlLoginSystem(); if (saml) { return saml; } - return getMinimalLoginMiddleware(gristServer); + return getMinimalLoginSystem(); }