UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

746 lines • 33.4 kB
import { subDays } from 'date-fns'; import joi from 'joi'; const { ValidationError } = joi; import createSlug from 'slug'; import NameExistsError from '../../error/name-exists-error.js'; import InvalidOperationError from '../../error/invalid-operation-error.js'; import { nameType } from '../../routes/util.js'; import { projectSchema } from '../../services/project-schema.js'; import NotFoundError from '../../error/notfound-error.js'; import { ADMIN, ADMIN_TOKEN_USER, DEFAULT_PROJECT, MOVE_FEATURE_TOGGLE, ProjectAccessAddedEvent, ProjectAccessGroupRolesUpdated, ProjectAccessUserRolesDeleted, ProjectAccessUserRolesUpdated, ProjectArchivedEvent, ProjectCreatedEvent, ProjectDeletedEvent, ProjectGroupAddedEvent, ProjectRevivedEvent, ProjectUpdatedEvent, ProjectUserRemovedEvent, ProjectUserUpdateRoleEvent, RoleName, SYSTEM_USER_ID, } from '../../types/index.js'; import IncompatibleProjectError from '../../error/incompatible-project-error.js'; import { arraysHaveSameItems } from '../../util/index.js'; import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production/time-to-production.js'; import { uniqueByKey } from '../../util/unique.js'; import { BadDataError, PermissionError } from '../../error/index.js'; import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming-validation.js'; import { throwExceedsLimitError } from '../../error/exceeds-limit-error.js'; import { canGrantProjectRole } from './can-grant-project-role.js'; import { batchExecute } from '../../util/index.js'; import metricsHelper from '../../util/metrics-helper.js'; import { FUNCTION_TIME } from '../../metric-events.js'; export default class ProjectService { constructor({ projectStore, projectOwnersReadModel, projectFlagCreatorsReadModel, eventStore, featureToggleStore, environmentStore, featureEnvironmentStore, accountStore, projectStatsStore, projectReadModel, onboardingReadModel, }, config, accessService, featureToggleService, groupService, favoriteService, eventService, privateProjectChecker, apiTokenService, resourceLimitsService) { this.validateAndProcessFeatureNamingPattern = (featureNaming) => { const validationResult = checkFeatureNamingData(featureNaming); if (validationResult.state === 'invalid') { const [firstReason, ...remainingReasons] = validationResult.reasons.map((message) => ({ message, })); throw new BadDataError('The feature naming pattern data you provided was invalid.', [firstReason, ...remainingReasons]); } if (featureNaming.pattern && !featureNaming.example) { featureNaming.example = null; } if (featureNaming.pattern && !featureNaming.description) { featureNaming.description = null; } return featureNaming; }; this.projectStore = projectStore; this.projectOwnersReadModel = projectOwnersReadModel; this.projectFlagCreatorsReadModel = projectFlagCreatorsReadModel; this.environmentStore = environmentStore; this.featureEnvironmentStore = featureEnvironmentStore; this.accessService = accessService; this.eventStore = eventStore; this.featureToggleStore = featureToggleStore; this.apiTokenService = apiTokenService; this.featureToggleService = featureToggleService; this.favoritesService = favoriteService; this.privateProjectChecker = privateProjectChecker; this.accountStore = accountStore; this.groupService = groupService; this.eventService = eventService; this.projectStatsStore = projectStatsStore; this.logger = config.getLogger('services/project-service.js'); this.flagResolver = config.flagResolver; this.isEnterprise = config.isEnterprise; this.resourceLimitsService = resourceLimitsService; this.eventBus = config.eventBus; this.projectReadModel = projectReadModel; this.onboardingReadModel = onboardingReadModel; this.timer = (functionName) => metricsHelper.wrapTimer(config.eventBus, FUNCTION_TIME, { className: 'ProjectService', functionName, }); } async getProjects(query, userId) { const projects = await this.projectReadModel.getProjectsForAdminUi(query, userId); if (userId) { const projectAccess = await this.privateProjectChecker.getUserAccessibleProjects(userId); if (projectAccess.mode === 'all') { return projects; } else { return projects.filter((project) => projectAccess.projects.includes(project.id)); } } return projects; } async addOwnersToProjects(projects) { return this.projectOwnersReadModel.addOwners(projects); } async getProject(id) { const project = await this.projectStore.get(id); if (project === undefined) { throw new NotFoundError(`Could not find project with id ${id}`); } return Promise.resolve(project); } async validateEnvironmentsExist(environments) { const projectsAndExistence = await Promise.all(environments.map(async (env) => [ env, await this.environmentStore.exists(env), ])); const invalidEnvs = projectsAndExistence .filter(([_, exists]) => !exists) .map(([env]) => env); if (invalidEnvs.length > 0) { throw new BadDataError(`These environments do not exist: ${invalidEnvs .map((env) => `'${env}'`) .join(', ')}.`); } } async validateProjectEnvironments(environments) { if (environments) { await this.validateEnvironmentsExist(environments); } } async validateProjectLimit() { const { projects } = await this.resourceLimitsService.getResourceLimits(); const limit = Math.max(projects, 1); const projectCount = await this.projectStore.count(); if (projectCount >= limit) { throwExceedsLimitError(this.eventBus, { resource: 'project', limit, }); } } async generateProjectId(name) { const slug = createSlug(name).slice(0, 90); const generateUniqueId = async (suffix) => { const id = suffix ? `${slug}-${suffix}` : slug; if (await this.projectStore.hasProject(id)) { return await generateUniqueId((suffix ?? 0) + 1); } else { return id; } }; return generateUniqueId(); } async getAllChangeRequestEnvironments(newProject) { const predefinedChangeRequestEnvironments = await this.environmentStore.getChangeRequestEnvironments(newProject.environments || []); const userSelectedChangeRequestEnvironments = newProject.changeRequestEnvironments || []; const allChangeRequestEnvironments = [ ...userSelectedChangeRequestEnvironments.filter((userEnv) => !predefinedChangeRequestEnvironments.find((predefinedEnv) => predefinedEnv.name === userEnv.name)), ...predefinedChangeRequestEnvironments, ]; return allChangeRequestEnvironments; } async createProject(newProject, user, auditUser, enableChangeRequestsForSpecifiedEnvironments = async () => { return []; }) { await this.validateProjectLimit(); const validateData = async () => { await this.validateProjectEnvironments(newProject.environments); if (!newProject.id?.trim()) { newProject.id = await this.generateProjectId(newProject.name); return await projectSchema.validateAsync(newProject); } else { const validatedData = await projectSchema.validateAsync(newProject); await this.validateUniqueId(validatedData.id); return validatedData; } }; const validatedData = await validateData(); const data = this.removePropertiesForNonEnterprise(validatedData); await this.projectStore.create(data); const envsToEnable = newProject.environments ? newProject.environments : (await this.environmentStore.getAll({ enabled: true, })).map((env) => env.name); await Promise.all(envsToEnable.map(async (env) => { await this.featureEnvironmentStore.connectProject(env, data.id); })); if (this.isEnterprise) { if (newProject.changeRequestEnvironments) { await this.validateEnvironmentsExist(newProject.changeRequestEnvironments.map((env) => env.name)); const allChangeRequestEnvironments = await this.getAllChangeRequestEnvironments(newProject); const changeRequestEnvironments = await enableChangeRequestsForSpecifiedEnvironments(allChangeRequestEnvironments); data.changeRequestEnvironments = changeRequestEnvironments; } else { data.changeRequestEnvironments = []; } } await this.accessService.createDefaultProjectRoles(user, data.id); await this.eventService.storeEvent(new ProjectCreatedEvent({ data, project: data.id, auditUser, })); return { ...data, environments: envsToEnable }; } async updateProject(updatedProject, auditUser) { const preData = await this.projectStore.get(updatedProject.id); await this.projectStore.update(updatedProject); // updated project contains instructions to update the project but it may not represent a whole project const afterData = await this.projectStore.get(updatedProject.id); await this.eventService.storeEvent(new ProjectUpdatedEvent({ project: updatedProject.id, data: afterData, preData, auditUser, })); } async updateProjectEnterpriseSettings(updatedProject, auditUser) { const preData = await this.projectStore.get(updatedProject.id); if (updatedProject.featureNaming) { this.validateAndProcessFeatureNamingPattern(updatedProject.featureNaming); } await this.projectStore.updateProjectEnterpriseSettings(updatedProject); await this.eventService.storeEvent(new ProjectUpdatedEvent({ project: updatedProject.id, data: { ...preData, ...updatedProject }, preData, auditUser, })); } async checkProjectsCompatibility(feature, newProjectId) { const featureEnvs = await this.featureEnvironmentStore.getAll({ feature_name: feature.name, }); const newEnvs = await this.projectStore.getEnvironmentsForProject(newProjectId); return arraysHaveSameItems(featureEnvs.map((env) => env.environment), newEnvs.map((projectEnv) => projectEnv.environment)); } async addEnvironmentToProject(project, environment) { await this.projectStore.addEnvironmentToProject(project, environment); } async validateActiveProject(projectId) { const hasActiveProject = await this.projectStore.hasActiveProject(projectId); if (!hasActiveProject) { throw new NotFoundError(`Active project with id ${projectId} does not exist`); } } async changeProject(newProjectId, featureName, user, currentProjectId, auditUser) { const feature = await this.featureToggleStore.get(featureName); if (feature === undefined) { throw new NotFoundError(`Could not find feature ${featureName}`); } if (feature.project !== currentProjectId) { throw new PermissionError(MOVE_FEATURE_TOGGLE); } await this.validateActiveProject(newProjectId); const authorized = await this.accessService.hasPermission(user, MOVE_FEATURE_TOGGLE, newProjectId); if (!authorized) { throw new PermissionError(MOVE_FEATURE_TOGGLE); } const isCompatibleWithTargetProject = await this.checkProjectsCompatibility(feature, newProjectId); if (!isCompatibleWithTargetProject) { throw new IncompatibleProjectError(newProjectId); } const updatedFeature = await this.featureToggleService.changeProject(featureName, newProjectId, auditUser); await this.featureToggleService.updateFeatureStrategyProject(featureName, newProjectId); return updatedFeature; } async deleteProject(id, user, auditUser) { if (id === DEFAULT_PROJECT) { throw new InvalidOperationError('You can not delete the default project!'); } const flags = await this.featureToggleStore.getAll({ project: id, archived: false, }); if (flags.length > 0) { throw new InvalidOperationError('You can not delete a project with active feature flags'); } const archivedFlags = await this.featureToggleStore.getAll({ project: id, archived: true, }); await this.featureToggleService.deleteFeatures(archivedFlags.map((flag) => flag.name), id, auditUser); const allTokens = await this.apiTokenService.getAllTokens(); const projectTokens = allTokens.filter((token) => (token.projects && token.projects.length === 1 && token.projects[0] === id) || token.project === id); await this.projectStore.delete(id); await Promise.all(projectTokens.map((token) => this.apiTokenService.delete(token.secret, auditUser))); await this.eventService.storeEvent(new ProjectDeletedEvent({ project: id, auditUser, })); await this.accessService.removeDefaultProjectRoles(user, id); } async archiveProject(id, auditUser) { const flags = await this.featureToggleStore.getAll({ project: id, archived: false, }); // TODO: allow archiving project with unused flags if (flags.length > 0) { throw new InvalidOperationError('You can not archive a project with active feature flags'); } await this.projectStore.archive(id); await this.eventService.storeEvent(new ProjectArchivedEvent({ project: id, auditUser, })); } async reviveProject(id, auditUser) { await this.validateProjectLimit(); await this.projectStore.revive(id); await this.eventService.storeEvent(new ProjectRevivedEvent({ project: id, auditUser, })); } async validateId(id) { await nameType.validateAsync(id); await this.validateUniqueId(id); return true; } async validateUniqueId(id) { const exists = await this.projectStore.hasProject(id); if (exists) { throw new NameExistsError('A project with this id already exists.'); } } // RBAC methods async getAccessToProject(projectId) { return this.accessService.getProjectRoleAccess(projectId); } /** * @deprecated use removeUserAccess */ async removeUser(projectId, roleId, userId, auditUser) { const role = await this.findProjectRole(projectId, roleId); await this.accessService.removeUserFromRole(userId, role.id, projectId); const user = await this.accountStore.get(userId); await this.eventService.storeEvent(new ProjectUserRemovedEvent({ project: projectId, auditUser, preData: { roleId, userId, roleName: role.name, email: user?.email, }, })); } async removeUserAccess(projectId, userId, auditUser) { const existingRoles = await this.accessService.getProjectRolesForUser(projectId, userId); await this.accessService.removeUserAccess(projectId, userId); await this.eventService.storeEvent(new ProjectAccessUserRolesDeleted({ project: projectId, auditUser, preData: { roles: existingRoles, userId, }, })); } async removeGroupAccess(projectId, groupId, auditUser) { const existingRoles = await this.accessService.getProjectRolesForGroup(projectId, groupId); await this.accessService.removeGroupAccess(projectId, groupId); await this.eventService.storeEvent(new ProjectAccessUserRolesDeleted({ project: projectId, auditUser, preData: { roles: existingRoles, groupId, }, })); } async addGroup(projectId, roleId, groupId, auditUser) { const role = await this.accessService.getRole(roleId); const group = await this.groupService.getGroup(groupId); const project = await this.getProject(projectId); if (group.id == null) throw new ValidationError('Unexpected empty group id', [], undefined); await this.accessService.addGroupToRole(group.id, role.id, auditUser.username, project.id); await this.eventService.storeEvent(new ProjectGroupAddedEvent({ project: project.id, auditUser, data: { groupId: group.id, projectId: project.id, roleName: role.name, }, })); } isAdmin(userId, roles) { return (userId === SYSTEM_USER_ID || userId === ADMIN_TOKEN_USER.id || roles.some((r) => r.name === RoleName.ADMIN)); } isProjectOwner(roles, project) { return roles.some((r) => r.project === project && r.name === RoleName.OWNER); } async isAllowedToAddAccess(userAddingAccess, projectId, rolesBeingAdded) { const userPermissions = await this.accessService.getPermissionsForUser(userAddingAccess); if (userPermissions.some(({ permission }) => permission === ADMIN)) { return true; } const userRoles = await this.accessService.getAllProjectRolesForUser(userAddingAccess.id, projectId); if (this.isAdmin(userAddingAccess.id, userRoles) || this.isProjectOwner(userRoles, projectId)) { return true; } // Users may have access to multiple projects, so we need to filter out the permissions based on this project. // Since the project roles are just collections of permissions that are not tied to a project in the database // not filtering here might lead to false positives as they may have the permission in another project. if (this.flagResolver.isEnabled('projectRoleAssignment')) { const filteredUserPermissions = userPermissions.filter((permission) => permission.project === projectId); const rolesToBeAssignedData = await Promise.all(rolesBeingAdded.map((role) => this.accessService.getRole(role))); const rolesToBeAssignedPermissions = rolesToBeAssignedData.flatMap((role) => role.permissions); return canGrantProjectRole(filteredUserPermissions, rolesToBeAssignedPermissions); } else { return rolesBeingAdded.every((roleId) => userRoles.some((userRole) => userRole.id === roleId)); } } async addAccess(projectId, roles, groups, users, auditUser) { if (await this.isAllowedToAddAccess(auditUser, projectId, roles)) { await this.accessService.addAccessToProject(roles, groups, users, projectId, auditUser.username); await this.eventService.storeEvent(new ProjectAccessAddedEvent({ project: projectId, auditUser, data: { roles: roles.map((roleId) => { return { roleId, groupIds: groups, userIds: users, }; }), }, })); } else { throw new InvalidOperationError('User tried to grant role they did not have access to'); } } async setRolesForUser(projectId, userId, newRoles, auditUser) { const currentRoles = await this.accessService.getProjectRolesForUser(projectId, userId); const isAllowedToAssignRoles = await this.isAllowedToAddAccess(auditUser, projectId, newRoles); if (isAllowedToAssignRoles) { await this.accessService.setProjectRolesForUser(projectId, userId, newRoles); await this.eventService.storeEvent(new ProjectAccessUserRolesUpdated({ project: projectId, auditUser, data: { roles: newRoles, userId, }, preData: { roles: currentRoles, userId, }, })); } else { throw new InvalidOperationError('User tried to assign a role they did not have access to'); } } async setRolesForGroup(projectId, groupId, newRoles, auditUser) { const currentRoles = await this.accessService.getProjectRolesForGroup(projectId, groupId); const isAllowedToAssignRoles = await this.isAllowedToAddAccess(auditUser, projectId, newRoles); if (isAllowedToAssignRoles) { await this.accessService.setProjectRolesForGroup(projectId, groupId, newRoles, auditUser.username); await this.eventService.storeEvent(new ProjectAccessGroupRolesUpdated({ project: projectId, auditUser, data: { roles: newRoles, groupId, }, preData: { roles: currentRoles, groupId, }, })); } else { throw new InvalidOperationError('User tried to assign a role they did not have access to'); } } async findProjectRole(projectId, roleId) { const roles = await this.accessService.getRolesForProject(projectId); const role = roles.find((r) => r.id === roleId); if (!role) { throw new NotFoundError(`Couldn't find roleId=${roleId} on project=${projectId}`); } return role; } /** @deprecated use projectInsightsService instead */ async getDoraMetrics(projectId) { const activeFeatureFlags = (await this.featureToggleStore.getAll({ project: projectId })).map((feature) => feature.name); const archivedFeatureFlags = (await this.featureToggleStore.getAll({ project: projectId, archived: true, })).map((feature) => feature.name); const featureFlagNames = [ ...activeFeatureFlags, ...archivedFeatureFlags, ]; const projectAverage = calculateAverageTimeToProd(await this.projectStatsStore.getTimeToProdDates(projectId)); const flagAverage = await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(projectId, featureFlagNames); return { features: flagAverage, projectAverage: projectAverage, }; } async getApplications(searchParams) { const applications = await this.projectStore.getApplicationsByProject({ ...searchParams, sortBy: searchParams.sortBy || 'appName', }); return applications; } async getProjectFlagCreators(projectId) { return this.projectFlagCreatorsReadModel.getFlagCreators(projectId); } async changeRole(projectId, roleId, userId, auditUser) { const usersWithRoles = await this.getAccessToProject(projectId); const user = usersWithRoles.users.find((u) => u.id === userId); if (!user) throw new ValidationError('Unexpected empty user', [], undefined); const currentRole = usersWithRoles.roles.find((r) => r.id === user.roleId); if (!currentRole) throw new ValidationError('Unexpected empty current role', [], undefined); if (currentRole.id === roleId) { // Nothing to do.... return; } await this.accessService.updateUserProjectRole(userId, roleId, projectId); const role = await this.findProjectRole(projectId, roleId); await this.eventService.storeEvent(new ProjectUserUpdateRoleEvent({ project: projectId, auditUser, preData: { userId, roleId: currentRole.id, roleName: currentRole.name, email: user.email, }, data: { userId, roleId, roleName: role.name, email: user.email, }, })); } async getMembers(projectId) { return this.projectStore.getMembersCountByProject(projectId); } async getProjectUsers(projectId) { const { groups, users } = await this.accessService.getProjectRoleAccess(projectId); const actualUsers = users.map((user) => ({ id: user.id, email: user.email, username: user.username, })); const actualGroupUsers = groups .flatMap((group) => group.users) .map((user) => user.user) .map((user) => ({ id: user.id, email: user.email, username: user.username, })); return uniqueByKey([...actualUsers, ...actualGroupUsers], 'id'); } async isProjectUser(userId, projectId) { const users = await this.getProjectUsers(projectId); return Boolean(users.find((user) => user.id === userId)); } async getProjectsByUser(userId) { return this.projectReadModel.getProjectsByUser(userId); } async getProjectRoleUsage(roleId) { return this.accessService.getProjectRoleUsage(roleId); } async statusJob() { const projects = await this.projectStore.getAll(); // run one project status update at a time every void batchExecute(projects, 1, 30_000, async (project) => { const statusUpdate = await this.getStatusUpdates(project.id); await this.projectStatsStore.updateProjectStats(statusUpdate.projectId, statusUpdate.updates); }); } async getStatusUpdates(projectId) { const stopTimer = this.timer('getStatusUpdates'); const dateMinusThirtyDays = subDays(new Date(), 30).toISOString(); const dateMinusSixtyDays = subDays(new Date(), 60).toISOString(); const [createdCurrentWindow, createdPastWindow, archivedCurrentWindow, archivedPastWindow,] = await Promise.all([ await this.featureToggleStore.countByDate({ project: projectId, dateAccessor: 'created_at', date: dateMinusThirtyDays, }), await this.featureToggleStore.countByDate({ project: projectId, dateAccessor: 'created_at', range: [dateMinusSixtyDays, dateMinusThirtyDays], }), await this.featureToggleStore.countByDate({ project: projectId, archived: true, dateAccessor: 'archived_at', date: dateMinusThirtyDays, }), await this.featureToggleStore.countByDate({ project: projectId, archived: true, dateAccessor: 'archived_at', range: [dateMinusSixtyDays, dateMinusThirtyDays], }), ]); const [projectActivityCurrentWindow, projectActivityPastWindow] = await Promise.all([ this.eventStore.queryCount([ { op: 'where', parameters: { project: projectId }, }, { op: 'beforeDate', parameters: { dateAccessor: 'created_at', date: dateMinusThirtyDays, }, }, ]), this.eventStore.queryCount([ { op: 'where', parameters: { project: projectId }, }, { op: 'betweenDate', parameters: { dateAccessor: 'created_at', range: [dateMinusSixtyDays, dateMinusThirtyDays], }, }, ]), ]); const avgTimeToProdCurrentWindow = calculateAverageTimeToProd(await this.projectStatsStore.getTimeToProdDates(projectId)); const projectMembersAddedCurrentWindow = await this.projectStore.getMembersCountByProjectAfterDate(projectId, dateMinusThirtyDays); stopTimer(); return { projectId, updates: { avgTimeToProdCurrentWindow, createdCurrentWindow, createdPastWindow, archivedCurrentWindow, archivedPastWindow, projectActivityCurrentWindow, projectActivityPastWindow, projectMembersAddedCurrentWindow, }, }; } async getProjectHealth(projectId, archived = false, userId) { const [project, environments, features, members, favorite, projectStats,] = await Promise.all([ this.projectStore.get(projectId), this.projectStore.getEnvironmentsForProject(projectId), this.featureToggleService.getFeatureOverview({ projectId, archived, userId, }), this.projectStore.getMembersCountByProject(projectId), userId ? this.favoritesService.isFavoriteProject({ project: projectId, userId, }) : Promise.resolve(false), this.projectStatsStore.getProjectStats(projectId), ]); if (project === undefined) { throw new NotFoundError(`Could not find project with id ${projectId}`); } return { stats: projectStats, name: project.name, description: project.description, mode: project.mode, featureLimit: project.featureLimit, featureNaming: project.featureNaming, defaultStickiness: project.defaultStickiness, health: project.health || 0, technicalDebt: 100 - (project.health || 0), favorite: favorite, updatedAt: project.updatedAt, createdAt: project.createdAt, environments, features: features, members, version: 1, }; } async getProjectOverview(projectId, archived = false, userId) { const [project, environments, featureTypeCounts, members, favorite, projectStats, onboardingStatus,] = await Promise.all([ this.projectStore.get(projectId), this.projectStore.getEnvironmentsForProject(projectId), this.featureToggleService.getFeatureTypeCounts({ projectId, archived, userId, }), this.projectStore.getMembersCountByProject(projectId), userId ? this.favoritesService.isFavoriteProject({ project: projectId, userId, }) : Promise.resolve(false), this.projectStatsStore.getProjectStats(projectId), this.onboardingReadModel.getOnboardingStatusForProject(projectId), ]); if (project === undefined) { throw new NotFoundError(`Could not find project with id: ${projectId}`); } return { stats: projectStats, name: project.name, description: project.description, mode: project.mode, featureLimit: project.featureLimit, featureNaming: project.featureNaming, linkTemplates: project.linkTemplates, defaultStickiness: project.defaultStickiness, health: project.health || 0, technicalDebt: 100 - (project.health || 0), favorite: favorite, updatedAt: project.updatedAt, archivedAt: project.archivedAt, createdAt: project.createdAt, onboardingStatus: onboardingStatus ?? { status: 'onboarding-started', }, environments, featureTypeCounts, members, version: 1, }; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types removePropertiesForNonEnterprise(data) { if (this.isEnterprise) { return data; } const { mode, changeRequestEnvironments, ...proData } = data; return proData; } } //# sourceMappingURL=project-service.js.map