diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index ba5c82f38f..af3e662024 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -55,6 +55,8 @@ import { createEventsService } from '../events/createEventsService'; import { EventEmitter } from 'stream'; import { FeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model'; import { FakeFeatureLifecycleReadModel } from '../feature-lifecycle/fake-feature-lifecycle-read-model'; +import { FakeFeatureCollaboratorsReadModel } from './fake-feature-collaborators-read-model'; +import { FeatureCollaboratorsReadModel } from './feature-collaborators-read-model'; export const createFeatureToggleService = ( db: Db, @@ -131,6 +133,8 @@ export const createFeatureToggleService = ( const dependentFeaturesService = createDependentFeaturesService(config)(db); + const featureCollaboratorsReadModel = new FeatureCollaboratorsReadModel(db); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -151,6 +155,7 @@ export const createFeatureToggleService = ( dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, + featureCollaboratorsReadModel, ); return featureToggleService; }; @@ -192,6 +197,8 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); const dependentFeaturesService = createFakeDependentFeaturesService(config); const featureLifecycleReadModel = new FakeFeatureLifecycleReadModel(); + const featureCollaboratorsReadModel = + new FakeFeatureCollaboratorsReadModel(); const featureToggleService = new FeatureToggleService( { @@ -218,6 +225,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, + featureCollaboratorsReadModel, ); return { featureToggleService, diff --git a/src/lib/features/feature-toggle/feature-toggle-controller.ts b/src/lib/features/feature-toggle/feature-toggle-controller.ts index eacd1dc825..58823ee511 100644 --- a/src/lib/features/feature-toggle/feature-toggle-controller.ts +++ b/src/lib/features/feature-toggle/feature-toggle-controller.ts @@ -705,6 +705,19 @@ export default class ProjectFeaturesController extends Controller { ) { return { ...feature, + ...(feature.collaborators + ? { + collaborators: { + ...feature.collaborators, + users: feature.collaborators.users.map( + (user) => ({ + ...user, + name: anonymise(user.name), + }), + ), + }, + } + : {}), createdBy: { ...feature.createdBy, name: anonymise(feature.createdBy?.name), diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index b5d32fc9eb..c772db4f5c 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -20,6 +20,7 @@ import { type IAuditUser, type IConstraint, type IDependency, + type IFeatureCollaboratorsReadModel, type IFeatureEnvironmentInfo, type IFeatureEnvironmentStore, type IFeatureLifecycleStage, @@ -110,6 +111,7 @@ import type EventEmitter from 'node:events'; import type { IFeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model-type'; import type { ResourceLimitsSchema } from '../../openapi'; import { throwExceedsLimitError } from '../../error/exceeds-limit-error'; +import type { Collaborator } from './types/feature-collaborators-read-model-type'; interface IFeatureContext { featureName: string; @@ -177,6 +179,8 @@ class FeatureToggleService { private featureLifecycleReadModel: IFeatureLifecycleReadModel; + private featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; + private dependentFeaturesService: DependentFeaturesService; private eventBus: EventEmitter; @@ -221,6 +225,7 @@ class FeatureToggleService { dependentFeaturesReadModel: IDependentFeaturesReadModel, dependentFeaturesService: DependentFeaturesService, featureLifecycleReadModel: IFeatureLifecycleReadModel, + featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel, ) { this.logger = getLogger('services/feature-toggle-service.ts'); this.featureStrategiesStore = featureStrategiesStore; @@ -240,6 +245,7 @@ class FeatureToggleService { this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.dependentFeaturesService = dependentFeaturesService; this.featureLifecycleReadModel = featureLifecycleReadModel; + this.featureCollaboratorsReadModel = featureCollaboratorsReadModel; this.eventBus = eventBus; this.resourceLimits = resourceLimits; } @@ -1088,10 +1094,19 @@ class FeatureToggleService { let dependencies: IDependency[] = []; let children: string[] = []; let lifecycle: IFeatureLifecycleStage | undefined = undefined; - [dependencies, children, lifecycle] = await Promise.all([ + let collaborators: Collaborator[] = []; + const featureCollaboratorsEnabled = this.flagResolver.isEnabled( + 'featureCollaborators', + ); + [dependencies, children, lifecycle, collaborators] = await Promise.all([ this.dependentFeaturesReadModel.getParents(featureName), this.dependentFeaturesReadModel.getChildren([featureName]), this.featureLifecycleReadModel.findCurrentStage(featureName), + featureCollaboratorsEnabled + ? this.featureCollaboratorsReadModel.getFeatureCollaborators( + featureName, + ) + : Promise.resolve([]), ]); if (environmentVariants) { @@ -1106,6 +1121,9 @@ class FeatureToggleService { dependencies, children, lifecycle, + ...(featureCollaboratorsEnabled + ? { collaborators: { users: collaborators } } + : {}), }; } else { const result = @@ -1119,6 +1137,9 @@ class FeatureToggleService { dependencies, children, lifecycle, + ...(featureCollaboratorsEnabled + ? { collaborators: { users: collaborators } } + : {}), }; } } diff --git a/src/lib/features/feature-toggle/tests/feature-toggles.auth.e2e.test.ts b/src/lib/features/feature-toggle/tests/feature-toggles.auth.e2e.test.ts index 6d32f06d9c..a6f90915fb 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggles.auth.e2e.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggles.auth.e2e.test.ts @@ -18,14 +18,19 @@ let db: ITestDb; beforeAll(async () => { db = await dbInit('feature_strategy_auth_api_serial', getLogger); - app = await setupAppWithAuth(db.stores, { - experimental: { - flags: { - strictSchemaValidation: true, - anonymiseEventLog: true, + app = await setupAppWithAuth( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + anonymiseEventLog: true, + featureCollaborators: true, + }, }, }, - }); + db.rawDatabase, + ); }); afterEach(async () => { @@ -140,7 +145,7 @@ test('Should not be possible auto-enable feature flag without CREATE_FEATURE_STR .expect(403); }); -test('Should read flag creator', async () => { +test('Should read flag creator and collaborators', async () => { const email = 'user@getunleash.io'; const url = '/api/admin/projects/default/features/'; const name = 'creator.flag'; @@ -153,10 +158,14 @@ test('Should read flag creator', async () => { TEST_AUDIT_USER, ); - await db.stores.featureToggleStore.create('default', { - name, - createdByUserId: user.id, - }); + await app.services.featureToggleService.createFeatureToggle( + 'default', + { + name, + createdByUserId: user.id, + }, + { id: user.id, username: 'irrelevant', ip: '::1' }, + ); await app.request.post('/auth/demo/login').send({ email, @@ -166,10 +175,16 @@ test('Should read flag creator', async () => { .get(`${url}/${name}`) .expect(200); - expect(feature.createdBy).toEqual({ + const expectedUser = { id: user.id, name: '3957b71c0@unleash.run', imageUrl: 'https://gravatar.com/avatar/3957b71c0a6d2528f03b423f432ed2efe855d263400f960248a1080493d9d68a?s=42&d=retro&r=g', + }; + + expect(feature.createdBy).toEqual(expectedUser); + + expect(feature.collaborators).toStrictEqual({ + users: [expectedUser], }); }); diff --git a/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts b/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts index 491d31eeea..5a36f84090 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts @@ -99,6 +99,7 @@ beforeAll(async () => { experimental: { flags: { strictSchemaValidation: true, + featureCollaborators: true, }, }, }, diff --git a/src/lib/services/feature-service-potentially-stale.test.ts b/src/lib/services/feature-service-potentially-stale.test.ts index 2108950ef1..db3585db18 100644 --- a/src/lib/services/feature-service-potentially-stale.test.ts +++ b/src/lib/services/feature-service-potentially-stale.test.ts @@ -1,6 +1,7 @@ import { FEATURE_POTENTIALLY_STALE_ON, type IBaseEvent, + type IFeatureCollaboratorsReadModel, type IUnleashConfig, type IUnleashStores, } from '../types'; @@ -68,6 +69,7 @@ test('Should only store events for potentially stale on', async () => { {} as IDependentFeaturesReadModel, {} as DependentFeaturesService, {} as IFeatureLifecycleReadModel, + {} as IFeatureCollaboratorsReadModel, ); await featureToggleService.updatePotentiallyStaleFeatures(); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 918891b94b..b86dd7ae94 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -135,6 +135,8 @@ import { createFakeApiTokenService, } from '../features/api-tokens/createApiTokenService'; import { IntegrationEventsService } from '../features/integration-events/integration-events-service'; +import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model'; +import { FakeFeatureCollaboratorsReadModel } from '../features/feature-toggle/fake-feature-collaborators-read-model'; export const createServices = ( stores: IUnleashStores, @@ -263,6 +265,10 @@ export const createServices = ( ? createFeatureSearchService(config)(db) : createFakeFeatureSearchService(config); + const featureCollaboratorsReadModel = db + ? new FeatureCollaboratorsReadModel(db) + : new FakeFeatureCollaboratorsReadModel(); + const featureToggleServiceV2 = new FeatureToggleService( stores, config, @@ -274,6 +280,7 @@ export const createServices = ( dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, + featureCollaboratorsReadModel, ); const transactionalEnvironmentService = db ? withTransactional(createEnvironmentService(config), db) diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index d2d9015597..1b21994a3a 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -9,6 +9,7 @@ import type { ProjectEnvironment } from '../features/project/project-store-type' import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema'; import type { IntegrationEventsService } from '../features/integration-events/integration-events-service'; import type { IFlagResolver } from './experimental'; +import type { Collaborator } from '../features/feature-toggle/types/feature-collaborators-read-model-type'; export type Operator = (typeof ALL_OPERATORS)[number]; @@ -114,6 +115,7 @@ export interface FeatureToggleView extends FeatureToggleWithEnvironment { dependencies: IDependency[]; children: string[]; lifecycle: IFeatureLifecycleStage | undefined; + collaborators?: { users: Collaborator[] }; } // @deprecated