Asymmetrik/node-rest-starter

View on GitHub
src/app/core/teams/teams.service.ts

Summary

Maintainability
F
5 days
Test Coverage
import _ from 'lodash';
import mongoose, { FilterQuery, PopulateOptions, Types } from 'mongoose';

import {
    TeamRoleImplicit,
    TeamRoleMinimumWithAccess,
    TeamRolePriorities,
    TeamRoles
} from './team-role.model';
import { ITeam, Team, TeamDocument, TeamModel } from './team.model';
import { config, emailService, utilService } from '../../../dependencies';
import { logger } from '../../../lib/logger';
import {
    BadRequestError,
    ForbiddenError,
    InternalServerError,
    NotFoundError
} from '../../common/errors';
import { PagingResults } from '../../common/mongoose/paginate.plugin';
import { IdOrObject, Override } from '../../common/typescript-util';
import userAuthService from '../user/auth/user-authorization.service';
import { IUser, User, UserDocument, UserModel } from '../user/user.model';

/**
 * Copies the mutable fields from src to dest
 */
const copyMutableFields = (dest, src) => {
    dest.name = src.name;
    dest.description = src.description;
    dest.implicitMembers = src.implicitMembers;
    dest.requiresExternalRoles = src.requiresExternalRoles;
    dest.requiresExternalTeams = src.requiresExternalTeams;
};

const isObjectIdEqual = (value1, value2) => {
    return value1?.equals(value2) ?? false;
};

class TeamsService {
    constructor(
        private model: TeamModel,
        private userModel: UserModel
    ) {}

    /**
     * Creates a new team with the requested metadata
     *
     * @returns Returns a promise that resolves if team is successfully created, and rejects otherwise
     */
    async create(
        teamInfo: Partial<
            Override<ITeam, 'parent', IdOrObject<string | Types.ObjectId>>
        >,
        creator: UserDocument,
        firstAdmin?: string | Types.ObjectId
    ): Promise<TeamDocument> {
        // Create the new team model
        const newTeam = new this.model();

        copyMutableFields(newTeam, teamInfo);

        // Write the auto-generated metadata
        newTeam.creator = creator._id;
        newTeam.creatorName = creator.name;

        // Nested teams
        if (teamInfo.parent) {
            const parentTeam = await this.read(utilService.getId(teamInfo.parent));
            newTeam.parent = parentTeam._id;
            newTeam.ancestors = [...parentTeam.ancestors, parentTeam._id];
        }

        const user = await this.userModel.findById(firstAdmin).exec();

        // Save the new team
        const savedTeam = await newTeam.save();

        // Add first admin as first team member with admin role, or the creator if null
        await this.addMemberToTeam(user || creator, newTeam, TeamRoles.Admin);

        return savedTeam;
    }

    read(
        id: string | Types.ObjectId,
        populate:
            | string
            | string[]
            | PopulateOptions
            | Array<string | PopulateOptions> = []
    ): Promise<TeamDocument | null> {
        if (!mongoose.Types.ObjectId.isValid(id)) {
            return Promise.reject(new NotFoundError('Invalid team ID'));
        }
        return this.model
            .findById(id)
            .populate(populate as string[])
            .exec();
    }

    /**
     * Updates an existing team with fresh metadata
     *
     * @param document The team object to update
     * @param obj The obj with updated fields
     * @returns Returns a promise that resolves if team is successfully updated, and rejects otherwise
     */
    update(document: TeamDocument, obj: unknown): Promise<TeamDocument> {
        // Copy in the fields that can be changed by the user
        copyMutableFields(document, obj);

        // Save the updated team
        return document.save();
    }

    /**
     * Deletes an existing team, after verifying that team contains no more resources.
     *
     * @param {TeamDocument} document The team object to delete
     * @returns {Promise<TeamDocument>} Returns a promise that resolves if team is successfully deleted, and rejects otherwise
     */
    async delete(document: TeamDocument): Promise<TeamDocument> {
        await this.verifyNoResourcesInTeam(document);

        // Delete the team and update all members in the team
        await Promise.all([
            document.deleteOne(),
            this.userModel
                .updateMany(
                    { 'teams._id': document._id },
                    { $pull: { teams: { _id: document._id } } }
                )
                .exec()
        ]);

        return document;
    }

    async search(
        queryParams = {},
        query: FilterQuery<TeamDocument> = {},
        search = '',
        user: UserDocument
    ): Promise<PagingResults<TeamDocument>> {
        const page = utilService.getPage(queryParams);
        const limit = utilService.getLimit(queryParams, 1000);
        const sort = utilService.getSortObj(queryParams, 'DESC', '_id');

        let teamIds = await this.getTeamIds(
            user,
            ...this.getRoles(TeamRoleMinimumWithAccess)
        );

        // convert team ids to strings
        const teamIdStrings = teamIds.map((id) => id.toString());

        // If user is not an admin, constrain the results to the user's teams
        if (!userAuthService.hasRoles(user, ['admin'])) {
            // If the query already has a filter by team, take the intersection
            if (null != query._id && null != query._id.$in) {
                teamIds = _.intersectionWith(teamIds, query._id.$in, isObjectIdEqual);
            }

            // If no remaining teams, return no results
            if (teamIds.length === 0) {
                return Promise.resolve(utilService.getPagingResults(limit));
            }

            query._id = {
                $in: teamIds
            };
        }

        // get results
        const results = await this.model
            .find(query)
            .containsSearch(search)
            .sort(sort)
            .paginate(limit, page);

        return {
            pageNumber: results.pageNumber,
            pageSize: results.pageSize,
            totalPages: results.totalPages,
            totalSize: results.totalSize,
            elements: results.elements.map((res) => {
                // append isMember field to elements if user is part of the team
                return {
                    ...res.toJSON(),
                    isMember: teamIdStrings.includes(res.id)
                } as unknown as TeamDocument;
            })
        };
    }

    /**
     * Gets the role of this user in this team.  If the team is nested, it will check the user's
     * role on all ancestor teams and grant the highest assigned role.
     *
     * @returns Returns the role of the user in the team or null if user doesn't belong to team.
     */
    getTeamRole(
        user: UserDocument,
        team: Pick<TeamDocument, '_id'> & Partial<Pick<TeamDocument, 'ancestors'>>
    ): string | null {
        if (team.ancestors && config.get<boolean>('teams.nestedTeams')) {
            const roles = [...team.ancestors, team._id]
                .map((_id) => this.getTeamRole(user, { _id }))
                .filter((role) => role);

            if (roles.length > 0) {
                return roles.reduce((prevHighestRole, role) => {
                    return TeamRolePriorities[role] > TeamRolePriorities[prevHighestRole]
                        ? role
                        : prevHighestRole;
                }, TeamRoles.Blocked);
            }
        } else {
            return user.teams.find((t) => t._id.equals(team._id))?.role;
        }
        return null;
    }

    /**
     * Checks if the user meets the required external teams for this team
     * If the user is bypassed, they automatically meet the required external teams
     */
    isImplicitMember(user: UserDocument, team: TeamDocument): boolean {
        if (config.get<boolean>('teams.implicitMembers.enabled')) {
            const strategy = config.get<string>('teams.implicitMembers.strategy');

            if (strategy === 'roles') {
                return this.meetsRequiredExternalRoles(user, team);
            }
            if (strategy === 'teams') {
                return this.meetsRequiredExternalTeams(user, team);
            }
        }
        return false;
    }

    /**
     * Checks if the user meets the required external teams for this team.
     * Requires matching only one external team.
     * If the user is bypassed, they automatically meet the required external teams
     */
    meetsRequiredExternalTeams(user: UserDocument, team: TeamDocument): boolean {
        if (true === user.bypassAccessCheck) {
            return true;
        }
        // Check the required external teams against the user's externalGroups
        return (
            _.intersection(team.requiresExternalTeams, user.externalGroups).length > 0
        );
    }

    /**
     * Checks if the user meets the required external roles for this team.
     * Requires matching all external roles.
     */
    meetsRequiredExternalRoles(user: UserDocument, team: TeamDocument): boolean {
        if ((team.requiresExternalRoles?.length ?? 0) === 0) {
            return false;
        }
        // Check the required external roles against the user's externalRoles
        return (
            _.intersection(team.requiresExternalRoles, user.externalRoles).length ===
            team.requiresExternalRoles.length
        );
    }

    meetsRoleRequirement(
        user: UserDocument,
        team: TeamDocument,
        role: TeamRoles
    ): Promise<void> {
        // Check role of the user in this team
        const userRole = this.getActiveTeamRole(user, team);

        if (null != userRole && this.meetsOrExceedsRole(userRole, role)) {
            return Promise.resolve();
        }

        return Promise.reject(
            new ForbiddenError(
                'The user does not have the required roles for the team'
            )
        );
    }

    /**
     * Checks if user role meets or exceeds the requestedRole according to
     * a pre-defined role hierarchy
     */
    meetsOrExceedsRole(userRole: string, requestedRole: TeamRoles): boolean {
        if (
            null != userRole &&
            _.has(TeamRolePriorities, userRole) &&
            null != requestedRole &&
            _.has(TeamRolePriorities, requestedRole)
        ) {
            return TeamRolePriorities[userRole] >= TeamRolePriorities[requestedRole];
        }
        return false;
    }

    /**
     * Gets the team role for the specified user
     * and also applies the business logic of if they are implicitly a member
     * of the team or if they are an inactive explicit member of a team
     *
     * @returns Returns a role, or null if the user is not a member of the team
     */
    getActiveTeamRole(user: UserDocument, team: TeamDocument): string | null {
        // No matter what, we need to get these
        const teamRole = this.getTeamRole(user, team);

        // User has an explicit team role
        if (teamRole) {
            return teamRole;
        }

        const implicitMembersEnabled = config.get<boolean>(
            'teams.implicitMembers.enabled'
        );

        if (
            implicitMembersEnabled &&
            team.implicitMembers &&
            this.isImplicitMember(user, team)
        ) {
            // implicit members get the default 'member' role.
            return 'member';
        }

        // Return null since the user is neither an explicit nor implicit member of the team.
        return null;
    }

    /**
     * Checks if there are resources that belong to the team
     *
     * @param team The team object of interest
     * @returns A promise that resolves if there are no more resources in the team, and rejects otherwise
     */
    async verifyNoResourcesInTeam(team: TeamDocument): Promise<void> {
        const resourceCount = await this.getResourceCount(team);

        if (resourceCount > 0) {
            return Promise.reject(
                new BadRequestError('There are still resources in this team.')
            );
        }

        return Promise.resolve();
    }

    /**
     * Stub implementation. Downstream projects can implement their own custom resource count logic to prevent team deletion.
     * @param team
     */
    getResourceCount(
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        team: TeamDocument
    ): Promise<number> {
        return Promise.resolve(0);
    }

    /**
     * Checks if the member is the last admin of the team
     *
     * @param user The user object of interest
     * @param team The team object of interest
     * @returns Returns a promise that resolves if the user is not the last admin, and rejects otherwise
     */
    async verifyNotLastAdmin(
        user: UserDocument,
        team: TeamDocument
    ): Promise<void> {
        // Search for all users who have the admin role set to true
        const results = await this.userModel
            .find({
                _id: { $ne: user._id },
                teams: { $elemMatch: { _id: team._id, role: 'admin' } }
            })
            .exec();

        // Just need to make sure we find one active admin who isn't this user
        const adminFound = results.some((u) => {
            const role = this.getActiveTeamRole(u, team);
            return role === 'admin';
        });

        if (adminFound) {
            return Promise.resolve();
        }
        return Promise.reject(
            new BadRequestError('Team must have at least one admin')
        );
    }

    private getImplicitMemberFilter(
        team: TeamDocument
    ): FilterQuery<TeamDocument> {
        const implicitTeamStrategy = config.get<string>(
            'teams.implicitMembers.strategy'
        );
        if (
            implicitTeamStrategy === 'roles' &&
            team.requiresExternalRoles?.length > 0
        ) {
            return {
                $and: [
                    { externalRoles: { $all: team.requiresExternalRoles } },
                    { 'teams._id': { $ne: team._id } }
                ]
            };
        }
        if (
            implicitTeamStrategy === 'teams' &&
            team.requiresExternalTeams?.length > 0
        ) {
            return {
                $and: [
                    { externalGroups: { $all: team.requiresExternalTeams } },
                    { 'teams._id': { $ne: team._id } }
                ]
            };
        }
    }

    updateMemberFilter(query: FilterQuery<UserDocument>, team: TeamDocument) {
        // Extract member types and roles for filtering
        const types = query.$and?.find((filter) => filter.type)?.type.$in ?? [];
        const roles: TeamRoles[] =
            query.$and?.find((filter) => filter.role)?.role.$in ?? [];

        // Remove member types and roles filters from query
        _.remove(query.$and, (filter) => filter.type || filter.role);
        if (query.$and?.length === 0) {
            delete query.$and;
        }

        query.$or = [];
        if (types.length === 0 && roles.length === 0) {
            const implicitFilter = this.getImplicitMemberFilter(team);
            if (implicitFilter) {
                query.$or.push(implicitFilter);
            }
            query.$or.push({ 'teams._id': team._id });
        } else if (types.length > 0 && roles.length > 0) {
            if (
                types.indexOf('implicit') !== -1 &&
                roles.indexOf(TeamRoleImplicit) !== -1
            ) {
                const implicitFilter = this.getImplicitMemberFilter(team);
                if (implicitFilter) {
                    query.$or.push(implicitFilter);
                }
            }
            if (types.indexOf('explicit') !== -1) {
                query.$or.push({
                    teams: { $elemMatch: { _id: team._id, role: { $in: roles } } }
                });
            }
        } else if (types.length > 0) {
            if (types.indexOf('implicit') !== -1) {
                const implicitFilter = this.getImplicitMemberFilter(team);
                if (implicitFilter) {
                    query.$or.push(implicitFilter);
                }
            }
            if (types.indexOf('explicit') !== -1) {
                query.$or.push({ 'teams._id': team._id });
            }
        } /* roles.length > 0 */ else {
            if (roles.indexOf(TeamRoleImplicit) !== -1) {
                const implicitFilter = this.getImplicitMemberFilter(team);
                if (implicitFilter) {
                    query.$or.push(implicitFilter);
                }
            }
            query.$or.push({
                teams: { $elemMatch: { _id: team._id, role: { $in: roles } } }
            });
        }

        // If $or is empty, that means we have conflicting filters (i.e. implicit members with admin role) and should
        // return zero results.  Need to create invalid query to ensure no results are found.
        if (query.$or.length === 0) {
            query.$or.push({ 'teams.role': 'invalid' });
        }

        return query;
    }

    /**
     * Adds a new member to the existing team.
     *
     * @param user The user to add to the team
     * @param team The team object
     * @param role The role of the user in this team
     * @returns Returns a promise that resolves if the user is successfully added to the team, and rejects otherwise
     */
    addMemberToTeam(
        user: UserDocument,
        team: TeamDocument,
        role: TeamRoles
    ): Promise<UserDocument> {
        return this.userModel
            .findOneAndUpdate(
                { _id: user._id },
                {
                    $addToSet: {
                        teams: {
                            _id: team._id,
                            role: role
                        }
                    }
                }
            )
            .exec();
    }

    async updateMemberRole(
        user: UserDocument,
        team: TeamDocument,
        role: TeamRoles
    ): Promise<UserDocument> {
        const currentRole = this.getTeamRole(user, team);

        if (currentRole === 'admin') {
            await this.verifyNotLastAdmin(user, team);
        }

        return this.userModel
            .findOneAndUpdate(
                {
                    _id: user._id,
                    'teams._id': team._id
                },
                { $set: { 'teams.$.role': role } }
            )
            .exec();
    }

    /**
     * Removes an existing member from an existing team, after verifying that member is not the last admin
     * on the team.
     *
     * @param user The user to remove
     * @param team The team object
     * @returns {Promise} Returns a promise that resolves if the user is successfully removed from the team, and rejects otherwise
     */
    async removeMemberFromTeam(user: UserDocument, team: TeamDocument) {
        // Verify the user is not the last admin in the team
        await this.verifyNotLastAdmin(user, team);

        // Apply the update
        return this.userModel
            .findOneAndUpdate(
                { _id: user._id },
                { $pull: { teams: { _id: team._id } } }
            )
            .exec();
    }

    async sendRequestEmail(
        toEmail: string[],
        requester: UserDocument,
        team: TeamDocument,
        req
    ): Promise<void> {
        try {
            const mailOptions = await emailService.generateMailOptions(
                requester,
                null,
                config.get('coreEmails.teamAccessRequestEmail'),
                {
                    team: team.toJSON()
                },
                {
                    team: team.toJSON()
                },
                {
                    bcc: toEmail
                }
            );
            await emailService.sendMail(mailOptions);
            logger.debug(`Sent approved user (${requester.username}) alert email`);
        } catch (error) {
            // Log the error but this shouldn't block
            logger.error('Failure sending email.', { err: error, req: req });
        }
    }

    async requestAccessToTeam(
        requester: UserDocument,
        team: TeamDocument,
        req
    ): Promise<void> {
        // Lookup the emails of all team admins
        const admins = await this.userModel
            .find({
                teams: {
                    $elemMatch: {
                        _id: new mongoose.Types.ObjectId(team._id),
                        role: TeamRoles.Admin
                    }
                }
            })
            .exec();

        const adminEmails = admins.map((admin) => admin.email);

        if (null == adminEmails || adminEmails.length === 0) {
            return Promise.reject(
                new InternalServerError('Error retrieving team admins')
            );
        }

        // Add requester role to user for this team
        await this.addMemberToTeam(requester, team, TeamRoles.Requester);

        // Email template rendering requires simple objects and not Mongo classes
        return this.sendRequestEmail(adminEmails, requester, team, req);
    }

    async requestNewTeam(
        org: string,
        aoi: string,
        description: string,
        requester: UserDocument,
        req
    ): Promise<void> {
        if (null == org) {
            return Promise.reject(
                new BadRequestError('Organization cannot be empty')
            );
        }
        if (null == aoi) {
            return Promise.reject(new BadRequestError('AOI cannot be empty'));
        }
        if (null == description) {
            return Promise.reject(new BadRequestError('Description cannot be empty'));
        }
        if (null == requester) {
            return Promise.reject(new BadRequestError('Invalid requester'));
        }

        try {
            const mailOptions = await emailService.generateMailOptions(
                requester,
                req,
                config.get('coreEmails.newTeamRequest'),
                {
                    org: org,
                    aoi: aoi,
                    description: description
                }
            );
            await emailService.sendMail(mailOptions);
            logger.debug('Sent team request email');
        } catch (error) {
            // Log the error but this shouldn't block
            logger.error('Failure sending email.', { err: error, req: req });
        }
    }

    getExplicitTeamIds(
        user: IUser,
        ...roles: TeamRoles[]
    ): Promise<Types.ObjectId[]> {
        // Validate the user input
        // This check shouldn't be need once strictNullChecks is enabled
        if (null == user) {
            return Promise.reject(new InternalServerError('User does not exist'));
        }

        let userTeams = _.isArray(user.teams) ? user.teams : [];
        if (roles && roles.length > 0) {
            userTeams = userTeams.filter(
                (t) => null != t.role && roles.includes(t.role)
            );
        }

        const userTeamIds = userTeams.map((t) => t._id);

        return Promise.resolve(userTeamIds);
    }

    async getImplicitTeamIds(
        user: IUser,
        ...roles: TeamRoles[]
    ): Promise<Types.ObjectId[]> {
        // Validate the user input
        // This check shouldn't be need once strictNullChecks is enabled
        if (null == user) {
            return Promise.reject(new InternalServerError('User does not exist'));
        }

        const implicitMembersEnabled = config.get<boolean>(
            'teams.implicitMembers.enabled'
        );
        const strategy = config.get<string>('teams.implicitMembers.strategy');

        if (
            !implicitMembersEnabled ||
            (roles.length > 0 && !roles.includes(TeamRoleImplicit))
        ) {
            return Promise.resolve([]);
        }

        const query: FilterQuery<TeamDocument> = {
            $and: [{ implicitMembers: true }]
        };
        if (strategy === 'roles' && (user.externalRoles?.length ?? 0) > 0) {
            query.$and.push(
                {
                    requiresExternalRoles: { $exists: true }
                },
                {
                    requiresExternalRoles: { $ne: null }
                },
                {
                    requiresExternalRoles: { $ne: [] }
                },
                {
                    requiresExternalRoles: {
                        $not: { $elemMatch: { $nin: user.externalRoles } }
                    }
                }
            );
        }
        if (strategy === 'teams' && (user.externalGroups?.length ?? 0) > 0) {
            query.$and.push(
                {
                    requiresExternalTeams: { $exists: true }
                },
                {
                    requiresExternalTeams: { $ne: null }
                },
                {
                    requiresExternalTeams: { $ne: [] }
                },
                {
                    requiresExternalTeams: {
                        $not: { $elemMatch: { $nin: user.externalGroups } }
                    }
                }
            );
        }

        if (query.$and.length === 1) {
            return Promise.resolve([]);
        }

        const rolesToExclude: TeamRoles[] = Object.entries(TeamRolePriorities)
            .filter(([, priority]) => priority < TeamRolePriorities[TeamRoleImplicit])
            .map(([role]) => role as TeamRoles);

        const excludedTeamIds = await this.getExplicitTeamIds(
            user,
            ...rolesToExclude
        );
        if (excludedTeamIds.length > 0) {
            query.$and.push({
                _id: { $nin: excludedTeamIds }
            });
        }

        return this.model.distinct<'_id', Types.ObjectId>('_id', query).exec();
    }

    getNestedTeamIds(teamIds: Types.ObjectId[] = []): Promise<Types.ObjectId[]> {
        const nestedTeamsEnabled = config.get<boolean>('teams.nestedTeams');
        if (!nestedTeamsEnabled || teamIds.length === 0) {
            return Promise.resolve([]);
        }

        return this.model
            .distinct<'_id', Types.ObjectId>('_id', {
                _id: { $nin: teamIds },
                ancestors: { $in: teamIds }
            })
            .exec();
    }

    getAncestorTeamIds(
        teamIds: Types.ObjectId[] = []
    ): Promise<Types.ObjectId[]> {
        return this.model.distinct('ancestors', { _id: { $in: teamIds } }).exec();
    }

    async getTeamIds(
        user: IUser,
        ...roles: TeamRoles[]
    ): Promise<Types.ObjectId[]> {
        const explicitTeamIds = await this.getExplicitTeamIds(user, ...roles);
        const implicitTeamIds = await this.getImplicitTeamIds(user, ...roles);
        const nestedTeamIds = await this.getNestedTeamIds([
            ...new Set([...explicitTeamIds, ...implicitTeamIds])
        ]);

        return [
            ...new Set([...explicitTeamIds, ...implicitTeamIds, ...nestedTeamIds])
        ];
    }

    /**
     * Constrain a set of teamIds provided by the user to those the user actually has access to.
     */
    async filterTeamIds(
        user: UserDocument,
        teamIds: Types.ObjectId[] = []
    ): Promise<Types.ObjectId[]> {
        const memberTeamIds = await this.getTeamIds(
            user,
            ...this.getRoles(TeamRoleMinimumWithAccess)
        );

        // If there were no teamIds to filter by, return all the team ids
        if (null == teamIds || (_.isArray(teamIds) && teamIds.length === 0)) {
            return memberTeamIds;
        }
        // Else, return the intersection of the two
        return _.intersectionWith(memberTeamIds, teamIds, isObjectIdEqual);
    }

    async updateTeams(user: IUser) {
        const strategy = config.get('teams.implicitMembers.strategy');
        const nestedTeamsEnabled = config.get<boolean>('teams.nestedTeams');

        if (strategy === 'disabled' && !nestedTeamsEnabled) {
            return;
        }

        const teamIdsByRole = await Promise.all(
            Object.values(TeamRoles).map(async (role) => {
                return {
                    role,
                    priority: TeamRolePriorities[role],
                    teamIds: await this.getTeamIds(user, role)
                };
            })
        );

        const getHigherPriorityIds = (targetPriority) =>
            teamIdsByRole
                .filter(({ priority }) => priority > targetPriority)
                .flatMap(({ teamIds }) => teamIds);

        user.teams = teamIdsByRole.flatMap(({ role, priority, teamIds }) => {
            const excludeIds = getHigherPriorityIds(priority);
            return _.differenceWith(teamIds, excludeIds, isObjectIdEqual).map(
                (id) => ({
                    role: role,
                    _id: id
                })
            );
        });
    }

    getRoles(minPriorityRole: TeamRoles) {
        return Object.values(TeamRoles).filter(
            (role) => TeamRolePriorities[role] >= TeamRolePriorities[minPriorityRole]
        );
    }
}

export = new TeamsService(Team, User);