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:
parent
f6c05eb877
commit
2e5d81cb89
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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({
|
||||||
|
@ -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 = {
|
||||||
|
Loading…
Reference in New Issue
Block a user