diff --git a/apps/api/src/app/environments-v1/environments-v1.controller.ts b/apps/api/src/app/environments-v1/environments-v1.controller.ts index 2b12acaf6b0..8d4a44e1741 100644 --- a/apps/api/src/app/environments-v1/environments-v1.controller.ts +++ b/apps/api/src/app/environments-v1/environments-v1.controller.ts @@ -137,12 +137,21 @@ export class EnvironmentsControllerV1 { const isApiKeyAuth = user.scheme === ApiAuthSchemeEnum.API_KEY; const canAccessApiKeys = isApiKeyAuth ? true : await this.canUserAccessApiKeys(user); + const isListEnvironmentsApiKeysEnabled = await this.featureFlagService.getFlag({ + organization: { _id: user.organizationId }, + user: { _id: user._id }, + key: FeatureFlagsKeysEnum.IS_LIST_ENVIRONMENTS_API_KEYS_ENABLED, + defaultValue: false, + }); + + const shouldScopeApiKeysToCallerEnvironment = isApiKeyAuth && !isListEnvironmentsApiKeysEnabled; + return await this.getMyEnvironmentsUsecase.execute( GetMyEnvironmentsCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, returnApiKeys: canAccessApiKeys, - apiKeysEnvironmentId: isApiKeyAuth ? user.environmentId : undefined, + apiKeysEnvironmentId: shouldScopeApiKeysToCallerEnvironment ? user.environmentId : undefined, userId: user._id, }) ); diff --git a/apps/api/src/app/environments-v1/usecases/get-my-environments/get-my-environments.usecase.spec.ts b/apps/api/src/app/environments-v1/usecases/get-my-environments/get-my-environments.usecase.spec.ts new file mode 100644 index 00000000000..e10cf9f3d0c --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/get-my-environments/get-my-environments.usecase.spec.ts @@ -0,0 +1,89 @@ +import { EnvironmentRepository } from '@novu/dal'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { PinoLogger } from '@novu/application-generic'; + +import { GetMyEnvironmentsCommand } from './get-my-environments.command'; +import { GetMyEnvironments } from './get-my-environments.usecase'; + +describe('GetMyEnvironments', () => { + const environmentRepository = new EnvironmentRepository(); + const logger = { setContext: sinon.stub(), trace: sinon.stub() } as unknown as PinoLogger; + const getMyEnvironments = new GetMyEnvironments(environmentRepository, logger); + + let findOrganizationEnvironmentsStub: sinon.SinonStub; + + beforeEach(() => { + findOrganizationEnvironmentsStub = sinon.stub(environmentRepository, 'findOrganizationEnvironments'); + findOrganizationEnvironmentsStub.resolves([ + { + _id: 'env-dev', + name: 'Development', + _organizationId: 'org-1', + apiKeys: [{ key: 'encrypted-dev', _userId: 'user-1', hash: 'hash-dev' }], + }, + { + _id: 'env-prod', + name: 'Production', + _organizationId: 'org-1', + apiKeys: [{ key: 'encrypted-prod', _userId: 'user-1', hash: 'hash-prod' }], + }, + ]); + + sinon.stub(getMyEnvironments as unknown as { decryptApiKeys: () => unknown }, 'decryptApiKeys').callsFake((apiKeys) => + apiKeys.map((apiKey: { key: string }) => ({ + ...apiKey, + key: `decrypted-${apiKey.key}`, + })) + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return apiKeys only for the scoped environment when apiKeysEnvironmentId is set', async () => { + const result = await getMyEnvironments.execute( + GetMyEnvironmentsCommand.create({ + organizationId: 'org-1', + returnApiKeys: true, + apiKeysEnvironmentId: 'env-dev', + }) + ); + + const devEnvironment = result.find((environment) => environment._id === 'env-dev'); + const prodEnvironment = result.find((environment) => environment._id === 'env-prod'); + + expect(devEnvironment?.apiKeys).to.have.lengthOf(1); + expect(devEnvironment?.apiKeys[0].key).to.equal('decrypted-encrypted-dev'); + expect(prodEnvironment?.apiKeys).to.have.lengthOf(0); + }); + + it('should return apiKeys for every environment when apiKeysEnvironmentId is not set', async () => { + const result = await getMyEnvironments.execute( + GetMyEnvironmentsCommand.create({ + organizationId: 'org-1', + returnApiKeys: true, + }) + ); + + for (const environment of result) { + expect(environment.apiKeys).to.have.lengthOf(1); + expect(environment.apiKeys[0].key).to.match(/^decrypted-/); + } + }); + + it('should not return apiKeys for any environment when returnApiKeys is false', async () => { + const result = await getMyEnvironments.execute( + GetMyEnvironmentsCommand.create({ + organizationId: 'org-1', + returnApiKeys: false, + apiKeysEnvironmentId: 'env-dev', + }) + ); + + for (const environment of result) { + expect(environment.apiKeys).to.have.lengthOf(0); + } + }); +}); diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 241b2dab102..4c0948affa5 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -43,6 +43,8 @@ export enum FeatureFlagsKeysEnum { IS_WORKFLOW_NODE_PREVIEW_ENABLED = 'IS_WORKFLOW_NODE_PREVIEW_ENABLED', IS_WEBHOOKS_MANAGEMENT_ENABLED = 'IS_WEBHOOKS_MANAGEMENT_ENABLED', IS_KEYLESS_ENVIRONMENT_CREATION_ENABLED = 'IS_KEYLESS_ENVIRONMENT_CREATION_ENABLED', + /** When enabled, API-key auth on GET /v1/environments returns decrypted apiKeys for every environment in the org (pre-NV-7641 opt-in behavior). */ + IS_LIST_ENVIRONMENTS_API_KEYS_ENABLED = 'IS_LIST_ENVIRONMENTS_API_KEYS_ENABLED', IS_TEST_PROVIDER_LIMITS_ENABLED = 'IS_TEST_PROVIDER_LIMITS_ENABLED', IS_2025_Q1_LEGACY_TIERING_MIGRATION = 'IS_2025_Q1_LEGACY_TIERING_MIGRATION', IS_SUBSCRIBER_ID_VALIDATION_DRY_RUN_ENABLED = 'IS_SUBSCRIBER_ID_VALIDATION_DRY_RUN_ENABLED',