mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	chore: delete project api tokens when last mapped project is removed (#7503)
Deletes API tokens bound to specific projects when the last project they're mapped to is deleted. --------- Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
		
							parent
							
								
									f6c05eb877
								
							
						
					
					
						commit
						2e5d81cb89
					
				| @ -82,6 +82,7 @@ exports[`should create default config 1`] = ` | ||||
|       "automatedActions": false, | ||||
|       "caseInsensitiveInOperators": false, | ||||
|       "celebrateUnleash": false, | ||||
|       "cleanApiTokenWhenOrphaned": false, | ||||
|       "collectTrafficDataUsage": false, | ||||
|       "commandBarUI": false, | ||||
|       "demo": false, | ||||
|  | ||||
| @ -45,6 +45,8 @@ import { ProjectOwnersReadModel } from './project-owners-read-model'; | ||||
| import { FakeProjectOwnersReadModel } from './fake-project-owners-read-model'; | ||||
| import { FakeProjectFlagCreatorsReadModel } from './fake-project-flag-creators-read-model'; | ||||
| import { ProjectFlagCreatorsReadModel } from './project-flag-creators-read-model'; | ||||
| import FakeApiTokenStore from '../../../test/fixtures/fake-api-token-store'; | ||||
| import { ApiTokenStore } from '../../db/api-token-store'; | ||||
| 
 | ||||
| export const createProjectService = ( | ||||
|     db: Db, | ||||
| @ -109,6 +111,13 @@ export const createProjectService = ( | ||||
|         eventService, | ||||
|     ); | ||||
| 
 | ||||
|     const apiTokenStore = new ApiTokenStore( | ||||
|         db, | ||||
|         eventBus, | ||||
|         getLogger, | ||||
|         flagResolver, | ||||
|     ); | ||||
| 
 | ||||
|     const privateProjectChecker = createPrivateProjectChecker(db, config); | ||||
| 
 | ||||
|     return new ProjectService( | ||||
| @ -123,6 +132,7 @@ export const createProjectService = ( | ||||
|             projectStatsStore, | ||||
|             projectOwnersReadModel, | ||||
|             projectFlagCreatorsReadModel, | ||||
|             apiTokenStore, | ||||
|         }, | ||||
|         config, | ||||
|         accessService, | ||||
| @ -153,6 +163,7 @@ export const createFakeProjectService = ( | ||||
|     const { featureToggleService } = createFakeFeatureToggleService(config); | ||||
|     const favoriteFeaturesStore = new FakeFavoriteFeaturesStore(); | ||||
|     const favoriteProjectsStore = new FakeFavoriteProjectsStore(); | ||||
|     const apiTokenStore = new FakeApiTokenStore(); | ||||
|     const eventService = new EventService( | ||||
|         { | ||||
|             eventStore, | ||||
| @ -188,6 +199,7 @@ export const createFakeProjectService = ( | ||||
|             featureTypeStore, | ||||
|             accountStore, | ||||
|             projectStatsStore, | ||||
|             apiTokenStore, | ||||
|         }, | ||||
|         config, | ||||
|         accessService, | ||||
|  | ||||
| @ -9,7 +9,7 @@ import { RoleName } from '../../types/model'; | ||||
| import { randomId } from '../../util/random-id'; | ||||
| import EnvironmentService from '../project-environments/environment-service'; | ||||
| import IncompatibleProjectError from '../../error/incompatible-project-error'; | ||||
| import { EventService } from '../../services'; | ||||
| import { ApiTokenService, EventService } from '../../services'; | ||||
| import { FeatureEnvironmentEvent } from '../../types/events'; | ||||
| import { addDays, subDays } from 'date-fns'; | ||||
| import { | ||||
| @ -28,7 +28,8 @@ import { | ||||
| } from '../../types'; | ||||
| import type { User } from '../../server-impl'; | ||||
| import { BadDataError, InvalidOperationError } from '../../error'; | ||||
| import { extractAuditInfoFromUser } from '../../util'; | ||||
| import { DEFAULT_ENV, extractAuditInfoFromUser } from '../../util'; | ||||
| import { ApiTokenType } from '../../types/models/api-token'; | ||||
| 
 | ||||
| let stores: IUnleashStores; | ||||
| let db: ITestDb; | ||||
| @ -40,6 +41,7 @@ let environmentService: EnvironmentService; | ||||
| let featureToggleService: FeatureToggleService; | ||||
| let user: User; // many methods in this test use User instead of IUser
 | ||||
| let auditUser: IAuditUser; | ||||
| let apiTokenService: ApiTokenService; | ||||
| 
 | ||||
| let opsUser: IUser; | ||||
| let group: IGroup; | ||||
| @ -89,6 +91,7 @@ beforeAll(async () => { | ||||
| 
 | ||||
|     environmentService = new EnvironmentService(stores, config, eventService); | ||||
|     projectService = createProjectService(db.rawDatabase, config); | ||||
|     apiTokenService = new ApiTokenService(stores, config, eventService); | ||||
| }); | ||||
| beforeEach(async () => { | ||||
|     await stores.accessStore.addUserToRole(opsUser.id, 1, ''); | ||||
| @ -2488,6 +2491,103 @@ test('deleting a project with archived flags should result in any remaining arch | ||||
|     expect(flags.find((t) => t.name === flagName)).toBeUndefined(); | ||||
| }); | ||||
| 
 | ||||
| test('should also delete api tokens that were only bound to deleted project', async () => { | ||||
|     const project = 'some'; | ||||
|     const tokenName = 'test'; | ||||
| 
 | ||||
|     await projectService.createProject( | ||||
|         { | ||||
|             id: project, | ||||
|             name: 'Test Project 1', | ||||
|         }, | ||||
|         user, | ||||
|         auditUser, | ||||
|     ); | ||||
| 
 | ||||
|     const token = await apiTokenService.createApiToken({ | ||||
|         type: ApiTokenType.CLIENT, | ||||
|         tokenName, | ||||
|         environment: DEFAULT_ENV, | ||||
|         project: project, | ||||
|     }); | ||||
| 
 | ||||
|     await projectService.deleteProject(project, user, auditUser); | ||||
|     const deletedToken = await apiTokenService.getToken(token.secret); | ||||
|     expect(deletedToken).toBeUndefined(); | ||||
| }); | ||||
| 
 | ||||
| test('should not delete project-bound api tokens still bound to project', async () => { | ||||
|     const project1 = 'token-deleted-project'; | ||||
|     const project2 = 'token-not-deleted-project'; | ||||
|     const tokenName = 'test'; | ||||
| 
 | ||||
|     await projectService.createProject( | ||||
|         { | ||||
|             id: project1, | ||||
|             name: 'Test Project 1', | ||||
|         }, | ||||
|         user, | ||||
|         auditUser, | ||||
|     ); | ||||
| 
 | ||||
|     await projectService.createProject( | ||||
|         { | ||||
|             id: project2, | ||||
|             name: 'Test Project 2', | ||||
|         }, | ||||
|         user, | ||||
|         auditUser, | ||||
|     ); | ||||
| 
 | ||||
|     const token = await apiTokenService.createApiToken({ | ||||
|         type: ApiTokenType.CLIENT, | ||||
|         tokenName, | ||||
|         environment: DEFAULT_ENV, | ||||
|         projects: [project1, project2], | ||||
|     }); | ||||
| 
 | ||||
|     await projectService.deleteProject(project1, user, auditUser); | ||||
|     const fetchedToken = await apiTokenService.getToken(token.secret); | ||||
|     expect(fetchedToken).not.toBeUndefined(); | ||||
|     expect(fetchedToken.project).toBe(project2); | ||||
| }); | ||||
| 
 | ||||
| test('should delete project-bound api tokens when all projects they belong to are deleted', async () => { | ||||
|     const project1 = 'token-deleted-project-1'; | ||||
|     const project2 = 'token-deleted-project-2'; | ||||
|     const tokenName = 'test'; | ||||
| 
 | ||||
|     await projectService.createProject( | ||||
|         { | ||||
|             id: project1, | ||||
|             name: 'Test Project 1', | ||||
|         }, | ||||
|         user, | ||||
|         auditUser, | ||||
|     ); | ||||
| 
 | ||||
|     await projectService.createProject( | ||||
|         { | ||||
|             id: project2, | ||||
|             name: 'Test Project 2', | ||||
|         }, | ||||
|         user, | ||||
|         auditUser, | ||||
|     ); | ||||
| 
 | ||||
|     const token = await apiTokenService.createApiToken({ | ||||
|         type: ApiTokenType.CLIENT, | ||||
|         tokenName, | ||||
|         environment: DEFAULT_ENV, | ||||
|         projects: [project1, project2], | ||||
|     }); | ||||
| 
 | ||||
|     await projectService.deleteProject(project1, user, auditUser); | ||||
|     await projectService.deleteProject(project2, user, auditUser); | ||||
|     const fetchedToken = await apiTokenService.getToken(token.secret); | ||||
|     expect(fetchedToken).toBeUndefined(); | ||||
| }); | ||||
| 
 | ||||
| test('deleting a project with no archived flags should not result in an error', async () => { | ||||
|     const project = { | ||||
|         id: 'project-with-nothing', | ||||
|  | ||||
| @ -53,6 +53,7 @@ import { | ||||
|     type ProjectCreated, | ||||
|     type IProjectOwnersReadModel, | ||||
|     ADMIN, | ||||
|     type IApiTokenStore, | ||||
| } from '../../types'; | ||||
| import type { | ||||
|     IProjectAccessModel, | ||||
| @ -144,6 +145,8 @@ export default class ProjectService { | ||||
| 
 | ||||
|     private accountStore: IAccountStore; | ||||
| 
 | ||||
|     private apiTokenStore: IApiTokenStore; | ||||
| 
 | ||||
|     private favoritesService: FavoritesService; | ||||
| 
 | ||||
|     private eventService: EventService; | ||||
| @ -168,6 +171,7 @@ export default class ProjectService { | ||||
|             featureTypeStore, | ||||
|             accountStore, | ||||
|             projectStatsStore, | ||||
|             apiTokenStore, | ||||
|         }: Pick< | ||||
|             IUnleashStores, | ||||
|             | 'projectStore' | ||||
| @ -180,6 +184,7 @@ export default class ProjectService { | ||||
|             | 'accountStore' | ||||
|             | 'projectStatsStore' | ||||
|             | 'featureTypeStore' | ||||
|             | 'apiTokenStore' | ||||
|         >, | ||||
|         config: IUnleashConfig, | ||||
|         accessService: AccessService, | ||||
| @ -198,6 +203,7 @@ export default class ProjectService { | ||||
|         this.eventStore = eventStore; | ||||
|         this.featureToggleStore = featureToggleStore; | ||||
|         this.featureTypeStore = featureTypeStore; | ||||
|         this.apiTokenStore = apiTokenStore; | ||||
|         this.featureToggleService = featureToggleService; | ||||
|         this.favoritesService = favoriteService; | ||||
|         this.privateProjectChecker = privateProjectChecker; | ||||
| @ -558,7 +564,26 @@ export default class ProjectService { | ||||
|             auditUser, | ||||
|         ); | ||||
| 
 | ||||
|         await this.projectStore.delete(id); | ||||
|         if (this.flagResolver.isEnabled('cleanApiTokenWhenOrphaned')) { | ||||
|             const allTokens = await this.apiTokenStore.getAll(); | ||||
|             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.apiTokenStore.delete(token.secret), | ||||
|                 ), | ||||
|             ); | ||||
|         } else { | ||||
|             await this.projectStore.delete(id); | ||||
|         } | ||||
| 
 | ||||
|         await this.eventService.storeEvent( | ||||
|             new ProjectDeletedEvent({ | ||||
|  | ||||
| @ -63,8 +63,9 @@ export type IFlagKey = | ||||
|     | 'flagCreator' | ||||
|     | 'anonymizeProjectOwners' | ||||
|     | 'resourceLimits' | ||||
|     | 'allowOrphanedWildcardTokens' | ||||
|     | 'extendedMetrics'; | ||||
|     | 'extendedMetrics' | ||||
|     | 'cleanApiTokenWhenOrphaned' | ||||
|     | 'allowOrphanedWildcardTokens'; | ||||
| 
 | ||||
| export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; | ||||
| 
 | ||||
| @ -309,6 +310,10 @@ const flags: IFlags = { | ||||
|         process.env.UNLEASH_EXPERIMENTAL_EXTENDED_METRICS, | ||||
|         false, | ||||
|     ), | ||||
|     cleanApiTokenWhenOrphaned: parseEnvVarBoolean( | ||||
|         process.env.UNLEASH_EXPERIMENTAL_CLEAN_API_TOKEN_WHEN_ORPHANED, | ||||
|         false, | ||||
|     ), | ||||
| }; | ||||
| 
 | ||||
| export const defaultExperimentalOptions: IExperimentalOptions = { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user