diff --git a/src/lib/export-import-toggles/export-import-service.ts b/src/lib/export-import-toggles/export-import-service.ts index dd9e0bc9d1..080a7bf90f 100644 --- a/src/lib/export-import-toggles/export-import-service.ts +++ b/src/lib/export-import-toggles/export-import-service.ts @@ -17,29 +17,20 @@ import { IContextFieldStore, IUnleashStores } from '../types/stores'; import { ISegmentStore } from '../types/stores/segment-store'; import { ExportQuerySchema } from '../openapi/spec/export-query-schema'; import { - CREATE_CONTEXT_FIELD, - CREATE_FEATURE, - CREATE_FEATURE_STRATEGY, - DELETE_FEATURE_STRATEGY, FEATURES_EXPORTED, FEATURES_IMPORTED, IFlagResolver, IUnleashServices, - UPDATE_FEATURE, - UPDATE_FEATURE_ENVIRONMENT_VARIANTS, - UPDATE_TAG_TYPE, WithRequired, } from '../types'; import { ExportResultSchema, FeatureStrategySchema, - ImportTogglesValidateItemSchema, ImportTogglesValidateSchema, } from '../openapi'; import { ImportTogglesSchema } from '../openapi/spec/import-toggles-schema'; import User from '../types/user'; -import { IContextFieldDto } from '../types/stores/context-field-store'; -import { BadDataError, InvalidOperationError } from '../error'; +import { BadDataError } from '../error'; import { extractUsernameFromUser } from '../util'; import { AccessService, @@ -51,6 +42,8 @@ import { } from '../services'; import { isValidField } from './import-context-validation'; import { IImportTogglesStore } from './import-toggles-store-type'; +import { ImportPermissionsService } from './import-permissions-service'; +import { ImportValidationMessages } from './import-validation-messages'; export default class ExportImportService { private logger: Logger; @@ -87,6 +80,8 @@ export default class ExportImportService { private featureTagService: FeatureTagService; + private importPermissionsService: ImportPermissionsService; + constructor( stores: Pick< IUnleashStores, @@ -137,6 +132,12 @@ export default class ExportImportService { this.accessService = accessService; this.tagTypeService = tagTypeService; this.featureTagService = featureTagService; + this.importPermissionsService = new ImportPermissionsService( + this.importTogglesStore, + this.accessService, + this.tagTypeService, + this.contextService, + ); this.logger = getLogger('services/state-service.js'); } @@ -157,20 +158,29 @@ export default class ExportImportService { this.getUnsupportedContextFields(dto), this.getArchivedFeatures(dto), this.getOtherProjectFeatures(dto), - this.getMissingPermissions(dto, user), + this.importPermissionsService.getMissingPermissions( + dto, + user, + 'regular', + ), ]); - const errors = this.compileErrors( + const errors = ImportValidationMessages.compileErrors( dto.project, unsupportedStrategies, - otherProjectFeatures, unsupportedContextFields, + [], + otherProjectFeatures, + false, ); - const warnings = this.compileWarnings( + const warnings = ImportValidationMessages.compileWarnings( usedCustomStrategies, archivedFeatures, ); - const permissions = this.compilePermissionErrors(missingPermissions); + const permissions = + ImportValidationMessages.compilePermissionErrors( + missingPermissions, + ); return { errors, @@ -185,7 +195,11 @@ export default class ExportImportService { await Promise.all([ this.verifyStrategies(cleanedDto), this.verifyContextFields(cleanedDto), - this.verifyPermissions(dto, user), + this.importPermissionsService.verifyPermissions( + dto, + user, + 'regular', + ), this.verifyFeatures(dto), ]); await this.createToggles(cleanedDto, user); @@ -359,15 +373,6 @@ export default class ExportImportService { } } - private async verifyPermissions(dto: ImportTogglesSchema, user: User) { - const missingPermissions = await this.getMissingPermissions(dto, user); - if (missingPermissions.length > 0) { - throw new InvalidOperationError( - 'You are missing permissions to import', - ); - } - } - private async verifyFeatures(dto: ImportTogglesSchema) { const otherProjectFeatures = await this.getOtherProjectFeatures(dto); if (otherProjectFeatures.length > 0) { @@ -444,75 +449,6 @@ export default class ExportImportService { } } - private compileErrors( - projectName: string, - strategies: FeatureStrategySchema[], - otherProjectFeatures: string[], - contextFields?: IContextFieldDto[], - ) { - const errors: ImportTogglesValidateItemSchema[] = []; - - if (strategies.length > 0) { - errors.push({ - message: - 'We detected the following custom strategy in the import file that needs to be created first:', - affectedItems: strategies.map((strategy) => strategy.name), - }); - } - if (Array.isArray(contextFields) && contextFields.length > 0) { - errors.push({ - message: - 'We detected the following context fields that do not have matching legal values with the imported ones:', - affectedItems: contextFields.map( - (contextField) => contextField.name, - ), - }); - } - if (otherProjectFeatures.length > 0) { - errors.push({ - message: `You cannot import a features that already exist in other projects. You already have the following features defined outside of project ${projectName}:`, - affectedItems: otherProjectFeatures, - }); - } - - return errors; - } - - private compileWarnings( - usedCustomStrategies: string[], - archivedFeatures: string[], - ) { - const warnings: ImportTogglesValidateItemSchema[] = []; - if (usedCustomStrategies.length > 0) { - warnings.push({ - message: - 'The following strategy types will be used in import. Please make sure the strategy type parameters are configured as in source environment:', - affectedItems: usedCustomStrategies, - }); - } - if (archivedFeatures.length > 0) { - warnings.push({ - message: - 'The following features will not be imported as they are currently archived. To import them, please unarchive them first:', - affectedItems: archivedFeatures, - }); - } - return warnings; - } - - private compilePermissionErrors(missingPermissions: string[]) { - const errors: ImportTogglesValidateItemSchema[] = []; - if (missingPermissions.length > 0) { - errors.push({ - message: - 'We detected you are missing the following permissions:', - affectedItems: missingPermissions, - }); - } - - return errors; - } - private async getUnsupportedStrategies( dto: ImportTogglesSchema, ): Promise { @@ -572,80 +508,6 @@ export default class ExportImportService { ); } - private async getMissingPermissions( - dto: ImportTogglesSchema, - user: User, - ): Promise { - const [ - newTagTypes, - newContextFields, - strategiesExistForFeatures, - featureEnvsWithVariants, - existingFeatures, - ] = await Promise.all([ - this.getNewTagTypes(dto), - this.getNewContextFields(dto), - this.importTogglesStore.strategiesExistForFeatures( - dto.data.features.map((feature) => feature.name), - dto.environment, - ), - dto.data.featureEnvironments?.filter( - (featureEnvironment) => - Array.isArray(featureEnvironment.variants) && - featureEnvironment.variants.length > 0, - ) || Promise.resolve([]), - this.importTogglesStore.getExistingFeatures( - dto.data.features.map((feature) => feature.name), - ), - ]); - - const permissions = [UPDATE_FEATURE]; - if (newTagTypes.length > 0) { - permissions.push(UPDATE_TAG_TYPE); - } - - if (Array.isArray(newContextFields) && newContextFields.length > 0) { - permissions.push(CREATE_CONTEXT_FIELD); - } - - if (strategiesExistForFeatures) { - permissions.push(DELETE_FEATURE_STRATEGY); - } - - if (dto.data.featureStrategies.length > 0) { - permissions.push(CREATE_FEATURE_STRATEGY); - } - - if (featureEnvsWithVariants.length > 0) { - permissions.push(UPDATE_FEATURE_ENVIRONMENT_VARIANTS); - } - - if (existingFeatures.length < dto.data.features.length) { - permissions.push(CREATE_FEATURE); - } - - const displayPermissions = - await this.importTogglesStore.getDisplayPermissions(permissions); - - const results = await Promise.all( - displayPermissions.map((permission) => - this.accessService - .hasPermission( - user, - permission.name, - dto.project, - dto.environment, - ) - .then( - (hasPermission) => [permission, hasPermission] as const, - ), - ), - ); - return results - .filter(([, hasAccess]) => !hasAccess) - .map(([permission]) => permission.displayName); - } - private async getNewTagTypes(dto: ImportTogglesSchema) { const existingTagTypes = (await this.tagTypeService.getAll()).map( (tagType) => tagType.name, diff --git a/src/lib/export-import-toggles/import-permissions-service.ts b/src/lib/export-import-toggles/import-permissions-service.ts new file mode 100644 index 0000000000..e1a7358f06 --- /dev/null +++ b/src/lib/export-import-toggles/import-permissions-service.ts @@ -0,0 +1,157 @@ +import { IImportTogglesStore } from './import-toggles-store-type'; +import { AccessService, ContextService, TagTypeService } from '../services'; +import { ContextFieldSchema, ImportTogglesSchema } from '../openapi'; +import { ITagType } from '../types/stores/tag-type-store'; +import User from '../types/user'; +import { + CREATE_CONTEXT_FIELD, + CREATE_FEATURE, + CREATE_FEATURE_STRATEGY, + DELETE_FEATURE_STRATEGY, + UPDATE_FEATURE, + UPDATE_FEATURE_ENVIRONMENT_VARIANTS, + UPDATE_TAG_TYPE, +} from '../types'; +import { InvalidOperationError } from '../error'; + +type Mode = 'regular' | 'change_request'; + +export class ImportPermissionsService { + private importTogglesStore: IImportTogglesStore; + + private accessService: AccessService; + + private tagTypeService: TagTypeService; + + private contextService: ContextService; + + private async getNewTagTypes( + dto: ImportTogglesSchema, + ): Promise { + const existingTagTypes = (await this.tagTypeService.getAll()).map( + (tagType) => tagType.name, + ); + const newTagTypes = dto.data.tagTypes?.filter( + (tagType) => !existingTagTypes.includes(tagType.name), + ); + return [ + ...new Map(newTagTypes.map((item) => [item.name, item])).values(), + ]; + } + + private async getNewContextFields( + dto: ImportTogglesSchema, + ): Promise { + const availableContextFields = await this.contextService.getAll(); + + return dto.data.contextFields?.filter( + (contextField) => + !availableContextFields.some( + (availableField) => + availableField.name === contextField.name, + ), + ); + } + + constructor( + importTogglesStore: IImportTogglesStore, + accessService: AccessService, + tagTypeService: TagTypeService, + contextService: ContextService, + ) { + this.importTogglesStore = importTogglesStore; + this.accessService = accessService; + this.tagTypeService = tagTypeService; + this.contextService = contextService; + } + + async getMissingPermissions( + dto: ImportTogglesSchema, + user: User, + mode: Mode, + ): Promise { + const [ + newTagTypes, + newContextFields, + strategiesExistForFeatures, + featureEnvsWithVariants, + existingFeatures, + ] = await Promise.all([ + this.getNewTagTypes(dto), + this.getNewContextFields(dto), + this.importTogglesStore.strategiesExistForFeatures( + dto.data.features.map((feature) => feature.name), + dto.environment, + ), + dto.data.featureEnvironments?.filter( + (featureEnvironment) => + Array.isArray(featureEnvironment.variants) && + featureEnvironment.variants.length > 0, + ) || Promise.resolve([]), + this.importTogglesStore.getExistingFeatures( + dto.data.features.map((feature) => feature.name), + ), + ]); + const permissions = [UPDATE_FEATURE]; + if (newTagTypes.length > 0) { + permissions.push(UPDATE_TAG_TYPE); + } + if (Array.isArray(newContextFields) && newContextFields.length > 0) { + permissions.push(CREATE_CONTEXT_FIELD); + } + + if (strategiesExistForFeatures && mode === 'regular') { + permissions.push(DELETE_FEATURE_STRATEGY); + } + + if (dto.data.featureStrategies.length > 0 && mode === 'regular') { + permissions.push(CREATE_FEATURE_STRATEGY); + } + + if (featureEnvsWithVariants.length > 0 && mode === 'regular') { + permissions.push(UPDATE_FEATURE_ENVIRONMENT_VARIANTS); + } + + if (existingFeatures.length < dto.data.features.length) { + permissions.push(CREATE_FEATURE); + } + + const displayPermissions = + await this.importTogglesStore.getDisplayPermissions(permissions); + + const results = await Promise.all( + displayPermissions.map((permission) => + this.accessService + .hasPermission( + user, + permission.name, + dto.project, + dto.environment, + ) + .then( + (hasPermission) => [permission, hasPermission] as const, + ), + ), + ); + return results + .filter(([, hasAccess]) => !hasAccess) + .map(([permission]) => permission.displayName); + } + + async verifyPermissions( + dto: ImportTogglesSchema, + user: User, + mode: Mode, + ): Promise { + const missingPermissions = await this.getMissingPermissions( + dto, + user, + mode, + ); + if (missingPermissions.length > 0) { + throw new InvalidOperationError( + 'You are missing permissions to import', + ); + } + } +} diff --git a/src/lib/export-import-toggles/import-validation-messages.ts b/src/lib/export-import-toggles/import-validation-messages.ts new file mode 100644 index 0000000000..7b6642dab7 --- /dev/null +++ b/src/lib/export-import-toggles/import-validation-messages.ts @@ -0,0 +1,94 @@ +import { + FeatureStrategySchema, + ImportTogglesValidateItemSchema, +} from '../openapi'; +import { IContextFieldDto } from '../types/stores/context-field-store'; + +export class ImportValidationMessages { + static compilePermissionErrors( + missingPermissions: string[], + ): ImportTogglesValidateItemSchema[] { + const errors: ImportTogglesValidateItemSchema[] = []; + if (missingPermissions.length > 0) { + errors.push({ + message: + 'We detected you are missing the following permissions:', + affectedItems: missingPermissions, + }); + } + + return errors; + } + + static compileErrors( + projectName: string, + strategies: FeatureStrategySchema[], + contextFields: IContextFieldDto[], + segments: string[], + otherProjectFeatures: string[], + changeRequestExists: boolean, + ): ImportTogglesValidateItemSchema[] { + const errors: ImportTogglesValidateItemSchema[] = []; + + if (strategies.length > 0) { + errors.push({ + message: + 'We detected the following custom strategy in the import file that needs to be created first:', + affectedItems: strategies.map((strategy) => strategy.name), + }); + } + if (contextFields.length > 0) { + errors.push({ + message: + 'We detected the following context fields that do not have matching legal values with the imported ones:', + affectedItems: contextFields.map( + (contextField) => contextField.name, + ), + }); + } + if (segments.length > 0) { + errors.push({ + message: + 'We detected the following segments in the import file that need to be created first:', + affectedItems: segments, + }); + } + if (changeRequestExists) { + errors.push({ + message: + 'Before importing any data, please resolve your pending change request in this project and environment as it is preventing you from importing at this time', + affectedItems: [], + }); + } + if (otherProjectFeatures.length > 0) { + errors.push({ + message: `You cannot import a features that already exist in other projects. You already have the following features defined outside of project ${projectName}:`, + affectedItems: otherProjectFeatures, + }); + } + + return errors; + } + + static compileWarnings( + usedCustomStrategies: string[], + archivedFeatures: string[], + ): ImportTogglesValidateItemSchema[] { + const warnings: ImportTogglesValidateItemSchema[] = []; + if (usedCustomStrategies.length > 0) { + warnings.push({ + message: + 'The following strategy types will be used in import. Please make sure the strategy type parameters are configured as in source environment:', + affectedItems: usedCustomStrategies, + }); + } + if (archivedFeatures.length > 0) { + warnings.push({ + message: + 'The following features will not be imported as they are currently archived. To import them, please unarchive them first:', + affectedItems: archivedFeatures, + }); + } + return warnings; + } +}