Skip to content

Commit

Permalink
chore: delete project api tokens when last mapped project is removed (#…
Browse files Browse the repository at this point in the history
…7503)

Deletes API tokens bound to specific projects when the last project they're mapped to is deleted.

---------

Co-authored-by: Tymoteusz Czech <[email protected]>
Co-authored-by: Thomas Heartman <[email protected]>
  • Loading branch information
3 people authored Jul 9, 2024
1 parent f6c05eb commit 2e5d81c
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 5 deletions.
1 change: 1 addition & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ exports[`should create default config 1`] = `
"automatedActions": false,
"caseInsensitiveInOperators": false,
"celebrateUnleash": false,
"cleanApiTokenWhenOrphaned": false,
"collectTrafficDataUsage": false,
"commandBarUI": false,
"demo": false,
Expand Down
12 changes: 12 additions & 0 deletions src/lib/features/project/createProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,6 +111,13 @@ export const createProjectService = (
eventService,
);

const apiTokenStore = new ApiTokenStore(
db,
eventBus,
getLogger,
flagResolver,
);

const privateProjectChecker = createPrivateProjectChecker(db, config);

return new ProjectService(
Expand All @@ -123,6 +132,7 @@ export const createProjectService = (
projectStatsStore,
projectOwnersReadModel,
projectFlagCreatorsReadModel,
apiTokenStore,
},
config,
accessService,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -188,6 +199,7 @@ export const createFakeProjectService = (
featureTypeStore,
accountStore,
projectStatsStore,
apiTokenStore,
},
config,
accessService,
Expand Down
104 changes: 102 additions & 2 deletions src/lib/features/project/project-service.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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, '');
Expand Down Expand Up @@ -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',
Expand Down
27 changes: 26 additions & 1 deletion src/lib/features/project/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
type ProjectCreated,
type IProjectOwnersReadModel,
ADMIN,
type IApiTokenStore,
} from '../../types';
import type {
IProjectAccessModel,
Expand Down Expand Up @@ -144,6 +145,8 @@ export default class ProjectService {

private accountStore: IAccountStore;

private apiTokenStore: IApiTokenStore;

private favoritesService: FavoritesService;

private eventService: EventService;
Expand All @@ -168,6 +171,7 @@ export default class ProjectService {
featureTypeStore,
accountStore,
projectStatsStore,
apiTokenStore,
}: Pick<
IUnleashStores,
| 'projectStore'
Expand All @@ -180,6 +184,7 @@ export default class ProjectService {
| 'accountStore'
| 'projectStatsStore'
| 'featureTypeStore'
| 'apiTokenStore'
>,
config: IUnleashConfig,
accessService: AccessService,
Expand All @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 7 additions & 2 deletions src/lib/types/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ export type IFlagKey =
| 'flagCreator'
| 'anonymizeProjectOwners'
| 'resourceLimits'
| 'allowOrphanedWildcardTokens'
| 'extendedMetrics';
| 'extendedMetrics'
| 'cleanApiTokenWhenOrphaned'
| 'allowOrphanedWildcardTokens';

export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;

Expand Down Expand Up @@ -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 = {
Expand Down

0 comments on commit 2e5d81c

Please sign in to comment.