1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-01 01:18:10 +02: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:
David Leek 2024-07-09 13:49:26 +02:00 committed by GitHub
parent f6c05eb877
commit 2e5d81cb89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 148 additions and 5 deletions

View File

@ -82,6 +82,7 @@ exports[`should create default config 1`] = `
"automatedActions": false, "automatedActions": false,
"caseInsensitiveInOperators": false, "caseInsensitiveInOperators": false,
"celebrateUnleash": false, "celebrateUnleash": false,
"cleanApiTokenWhenOrphaned": false,
"collectTrafficDataUsage": false, "collectTrafficDataUsage": false,
"commandBarUI": false, "commandBarUI": false,
"demo": false, "demo": false,

View File

@ -45,6 +45,8 @@ import { ProjectOwnersReadModel } from './project-owners-read-model';
import { FakeProjectOwnersReadModel } from './fake-project-owners-read-model'; import { FakeProjectOwnersReadModel } from './fake-project-owners-read-model';
import { FakeProjectFlagCreatorsReadModel } from './fake-project-flag-creators-read-model'; import { FakeProjectFlagCreatorsReadModel } from './fake-project-flag-creators-read-model';
import { ProjectFlagCreatorsReadModel } from './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 = ( export const createProjectService = (
db: Db, db: Db,
@ -109,6 +111,13 @@ export const createProjectService = (
eventService, eventService,
); );
const apiTokenStore = new ApiTokenStore(
db,
eventBus,
getLogger,
flagResolver,
);
const privateProjectChecker = createPrivateProjectChecker(db, config); const privateProjectChecker = createPrivateProjectChecker(db, config);
return new ProjectService( return new ProjectService(
@ -123,6 +132,7 @@ export const createProjectService = (
projectStatsStore, projectStatsStore,
projectOwnersReadModel, projectOwnersReadModel,
projectFlagCreatorsReadModel, projectFlagCreatorsReadModel,
apiTokenStore,
}, },
config, config,
accessService, accessService,
@ -153,6 +163,7 @@ export const createFakeProjectService = (
const { featureToggleService } = createFakeFeatureToggleService(config); const { featureToggleService } = createFakeFeatureToggleService(config);
const favoriteFeaturesStore = new FakeFavoriteFeaturesStore(); const favoriteFeaturesStore = new FakeFavoriteFeaturesStore();
const favoriteProjectsStore = new FakeFavoriteProjectsStore(); const favoriteProjectsStore = new FakeFavoriteProjectsStore();
const apiTokenStore = new FakeApiTokenStore();
const eventService = new EventService( const eventService = new EventService(
{ {
eventStore, eventStore,
@ -188,6 +199,7 @@ export const createFakeProjectService = (
featureTypeStore, featureTypeStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
apiTokenStore,
}, },
config, config,
accessService, accessService,

View File

@ -9,7 +9,7 @@ import { RoleName } from '../../types/model';
import { randomId } from '../../util/random-id'; import { randomId } from '../../util/random-id';
import EnvironmentService from '../project-environments/environment-service'; import EnvironmentService from '../project-environments/environment-service';
import IncompatibleProjectError from '../../error/incompatible-project-error'; import IncompatibleProjectError from '../../error/incompatible-project-error';
import { EventService } from '../../services'; import { ApiTokenService, EventService } from '../../services';
import { FeatureEnvironmentEvent } from '../../types/events'; import { FeatureEnvironmentEvent } from '../../types/events';
import { addDays, subDays } from 'date-fns'; import { addDays, subDays } from 'date-fns';
import { import {
@ -28,7 +28,8 @@ import {
} from '../../types'; } from '../../types';
import type { User } from '../../server-impl'; import type { User } from '../../server-impl';
import { BadDataError, InvalidOperationError } from '../../error'; 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 stores: IUnleashStores;
let db: ITestDb; let db: ITestDb;
@ -40,6 +41,7 @@ let environmentService: EnvironmentService;
let featureToggleService: FeatureToggleService; let featureToggleService: FeatureToggleService;
let user: User; // many methods in this test use User instead of IUser let user: User; // many methods in this test use User instead of IUser
let auditUser: IAuditUser; let auditUser: IAuditUser;
let apiTokenService: ApiTokenService;
let opsUser: IUser; let opsUser: IUser;
let group: IGroup; let group: IGroup;
@ -89,6 +91,7 @@ beforeAll(async () => {
environmentService = new EnvironmentService(stores, config, eventService); environmentService = new EnvironmentService(stores, config, eventService);
projectService = createProjectService(db.rawDatabase, config); projectService = createProjectService(db.rawDatabase, config);
apiTokenService = new ApiTokenService(stores, config, eventService);
}); });
beforeEach(async () => { beforeEach(async () => {
await stores.accessStore.addUserToRole(opsUser.id, 1, ''); 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(); 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 () => { test('deleting a project with no archived flags should not result in an error', async () => {
const project = { const project = {
id: 'project-with-nothing', id: 'project-with-nothing',

View File

@ -53,6 +53,7 @@ import {
type ProjectCreated, type ProjectCreated,
type IProjectOwnersReadModel, type IProjectOwnersReadModel,
ADMIN, ADMIN,
type IApiTokenStore,
} from '../../types'; } from '../../types';
import type { import type {
IProjectAccessModel, IProjectAccessModel,
@ -144,6 +145,8 @@ export default class ProjectService {
private accountStore: IAccountStore; private accountStore: IAccountStore;
private apiTokenStore: IApiTokenStore;
private favoritesService: FavoritesService; private favoritesService: FavoritesService;
private eventService: EventService; private eventService: EventService;
@ -168,6 +171,7 @@ export default class ProjectService {
featureTypeStore, featureTypeStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
apiTokenStore,
}: Pick< }: Pick<
IUnleashStores, IUnleashStores,
| 'projectStore' | 'projectStore'
@ -180,6 +184,7 @@ export default class ProjectService {
| 'accountStore' | 'accountStore'
| 'projectStatsStore' | 'projectStatsStore'
| 'featureTypeStore' | 'featureTypeStore'
| 'apiTokenStore'
>, >,
config: IUnleashConfig, config: IUnleashConfig,
accessService: AccessService, accessService: AccessService,
@ -198,6 +203,7 @@ export default class ProjectService {
this.eventStore = eventStore; this.eventStore = eventStore;
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.featureTypeStore = featureTypeStore; this.featureTypeStore = featureTypeStore;
this.apiTokenStore = apiTokenStore;
this.featureToggleService = featureToggleService; this.featureToggleService = featureToggleService;
this.favoritesService = favoriteService; this.favoritesService = favoriteService;
this.privateProjectChecker = privateProjectChecker; this.privateProjectChecker = privateProjectChecker;
@ -558,7 +564,26 @@ export default class ProjectService {
auditUser, 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( await this.eventService.storeEvent(
new ProjectDeletedEvent({ new ProjectDeletedEvent({

View File

@ -63,8 +63,9 @@ export type IFlagKey =
| 'flagCreator' | 'flagCreator'
| 'anonymizeProjectOwners' | 'anonymizeProjectOwners'
| 'resourceLimits' | 'resourceLimits'
| 'allowOrphanedWildcardTokens' | 'extendedMetrics'
| 'extendedMetrics'; | 'cleanApiTokenWhenOrphaned'
| 'allowOrphanedWildcardTokens';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -309,6 +310,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_EXTENDED_METRICS, process.env.UNLEASH_EXPERIMENTAL_EXTENDED_METRICS,
false, false,
), ),
cleanApiTokenWhenOrphaned: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_CLEAN_API_TOKEN_WHEN_ORPHANED,
false,
),
}; };
export const defaultExperimentalOptions: IExperimentalOptions = { export const defaultExperimentalOptions: IExperimentalOptions = {