1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-23 00:16:25 +01:00

chore: delete project api tokens when last mapped project is removed ()

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,
"caseInsensitiveInOperators": false,
"celebrateUnleash": false,
"cleanApiTokenWhenOrphaned": false,
"collectTrafficDataUsage": false,
"commandBarUI": 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 { 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,

View File

@ -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',

View File

@ -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({

View File

@ -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 = {