import {EntityManager} from "typeorm"; import * as roles from 'app/common/roles'; import {Document} from "app/gen-server/entity/Document"; import {Group} from "app/gen-server/entity/Group"; import {Organization} from "app/gen-server/entity/Organization"; import {Workspace} from "app/gen-server/entity/Workspace"; import pick = require('lodash/pick'); /** * * Remove the given user from the given org and every resource inside the org. * If the user being removed is an owner of any resources in the org, the caller replaces * them as the owner. This is to prevent complete loss of access to any resource. * * This method transforms ownership without regard to permissions. We all talked this * over and decided this is what we wanted, but there's no denying it is funky and could * be surprising. * TODO: revisit user scrubbing when we can. * */ export async function scrubUserFromOrg( orgId: number, removeUserId: number, callerUserId: number, manager: EntityManager ): Promise { await addMissingGuestMemberships(callerUserId, orgId, manager); // This will be a list of all mentions of removeUser and callerUser in any resource // within the org. const mentions: Mention[] = []; // Base query for all group_users related to these two users and this org. const q = manager.createQueryBuilder() .select('group_users.group_id, group_users.user_id') .from('group_users', 'group_users') .leftJoin(Group, 'groups', 'group_users.group_id = groups.id') .addSelect('groups.name as name') .leftJoin('groups.aclRule', 'acl_rules') .where('(group_users.user_id = :removeUserId or group_users.user_id = :callerUserId)', {removeUserId, callerUserId}) .andWhere('orgs.id = :orgId', {orgId}); // Pick out group_users related specifically to the org resource, in 'mentions' format // (including resource id, a tag for the kind of resource, the group name, the user // id, and the group id). const orgs = q.clone() .addSelect(`'org' as kind, orgs.id`) .innerJoin(Organization, 'orgs', 'orgs.id = acl_rules.org_id'); mentions.push(...await orgs.getRawMany()); // Pick out mentions related to any workspace within the org. const wss = q.clone() .innerJoin(Workspace, 'workspaces', 'workspaces.id = acl_rules.workspace_id') .addSelect(`'ws' as kind, workspaces.id`) .innerJoin('workspaces.org', 'orgs'); mentions.push(...await wss.getRawMany()); // Pick out mentions related to any doc within the org. const docs = q.clone() .innerJoin(Document, 'docs', 'docs.id = acl_rules.doc_id') .addSelect(`'doc' as kind, docs.id`) .innerJoin('docs.workspace', 'workspaces') .innerJoin('workspaces.org', 'orgs'); mentions.push(...await docs.getRawMany()); // Prepare to add and delete group_users. const toDelete: Mention[] = []; const toAdd: Mention[] = []; // Now index the mentions by whether they are for the removeUser or the callerUser, // and the resource they apply to. const removeUserMentions = new Map(); const callerUserMentions = new Map(); for (const mention of mentions) { const isGuest = mention.name === roles.GUEST; if (mention.user_id === removeUserId) { // We can safely remove any guest roles for the removeUser without any // further inspection. if (isGuest) { toDelete.push(mention); continue; } removeUserMentions.set(getMentionKey(mention), mention); } else { if (isGuest) { continue; } callerUserMentions.set(getMentionKey(mention), mention); } } // Now iterate across the mentions of removeUser, and see what we need to do // for each of them. for (const [key, removeUserMention] of removeUserMentions) { toDelete.push(removeUserMention); if (removeUserMention.name !== roles.OWNER) { // Nothing fancy needed for cases where the removeUser is not the owner. // Just discard those. continue; } // The removeUser was a direct owner on this resource, but the callerUser was // not. We set the callerUser as a direct owner on this resource, to preserve // access to it. // TODO: the callerUser might inherit sufficient access, in which case this // step is unnecessary and could be skipped. I believe it does no harm though. const callerUserMention = callerUserMentions.get(key); if (callerUserMention && callerUserMention.name === roles.OWNER) { continue; } if (callerUserMention) { toDelete.push(callerUserMention); } toAdd.push({...removeUserMention, user_id: callerUserId}); } if (toDelete.length > 0) { await manager.createQueryBuilder() .delete() .from('group_users') .whereInIds(toDelete.map(m => pick(m, ['user_id', 'group_id']))) .execute(); } if (toAdd.length > 0) { await manager.createQueryBuilder() .insert() .into('group_users') .values(toAdd.map(m => pick(m, ['user_id', 'group_id']))) .execute(); } // TODO: At this point, we've removed removeUserId from every mention in group_users. // The user may still be mentioned in billing_account_managers. If the billing_account // is linked to just this single organization, perhaps it would make sense to remove // the user there, if the callerUser is themselves a billing account manager? await addMissingGuestMemberships(callerUserId, orgId, manager); } /** * Adds specified user to any guest groups for the resources of an org where the * user needs to be and is not already. */ export async function addMissingGuestMemberships(userId: number, orgId: number, manager: EntityManager) { // For workspaces: // User should be in guest group if mentioned in a doc within that workspace. let groupUsers = await manager.createQueryBuilder() .select('workspace_groups.id as group_id, cast(:userId as int) as user_id') .setParameter('userId', userId) .from(Workspace, 'workspaces') .where('workspaces.org_id = :orgId', {orgId}) .innerJoin('workspaces.docs', 'docs') .innerJoin('docs.aclRules', 'doc_acl_rules') .innerJoin('doc_acl_rules.group', 'doc_groups') .innerJoin('doc_groups.memberUsers', 'doc_group_users') .andWhere('doc_group_users.id = :userId', {userId}) .leftJoin('workspaces.aclRules', 'workspace_acl_rules') .leftJoin('workspace_acl_rules.group', 'workspace_groups') .leftJoin('group_users', 'workspace_group_users', 'workspace_group_users.group_id = workspace_groups.id and ' + 'workspace_group_users.user_id = :userId') .andWhere('workspace_groups.name = :guestName', {guestName: roles.GUEST}) .groupBy('workspaces.id, workspace_groups.id, workspace_group_users.user_id') .having('workspace_group_users.user_id is null') .getRawMany(); if (groupUsers.length > 0) { await manager.createQueryBuilder() .insert() .into('group_users') .values(groupUsers) .execute(); } // For org: // User should be in guest group if mentioned in a workspace within that org. groupUsers = await manager.createQueryBuilder() .select('org_groups.id as group_id, cast(:userId as int) as user_id') .setParameter('userId', userId) .from(Organization, 'orgs') .where('orgs.id = :orgId', {orgId}) .innerJoin('orgs.workspaces', 'workspaces') .innerJoin('workspaces.aclRules', 'workspaces_acl_rules') .innerJoin('workspaces_acl_rules.group', 'workspace_groups') .innerJoin('workspace_groups.memberUsers', 'workspace_group_users') .andWhere('workspace_group_users.id = :userId', {userId}) .leftJoin('orgs.aclRules', 'org_acl_rules') .leftJoin('org_acl_rules.group', 'org_groups') .leftJoin('group_users', 'org_group_users', 'org_group_users.group_id = org_groups.id and ' + 'org_group_users.user_id = :userId') .andWhere('org_groups.name = :guestName', {guestName: roles.GUEST}) .groupBy('org_groups.id, org_group_users.user_id') .having('org_group_users.user_id is null') .getRawMany(); if (groupUsers.length > 0) { await manager.createQueryBuilder() .insert() .into('group_users') .values(groupUsers) .execute(); } // For doc: // Guest groups are not used. } interface Mention { id: string|number; // id of resource kind: 'org'|'ws'|'doc'; // type of resource user_id: number; // id of user in group group_id: number; // id of group name: string; // name of group } type MentionKey = string; function getMentionKey(mention: Mention): MentionKey { return `${mention.kind} ${mention.id}`; }