-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #5046 from novuhq/nv-3172-add-field-level-encrypti…
…on-to-api-keys Add field-level encryption to API Keys
- Loading branch information
Showing
25 changed files
with
444 additions
and
80 deletions.
There are no files selected for viewing
119 changes: 119 additions & 0 deletions
119
apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import { expect } from 'chai'; | ||
import { faker } from '@faker-js/faker'; | ||
import { createHash } from 'crypto'; | ||
|
||
import { UserSession } from '@novu/testing'; | ||
import { ChannelTypeEnum } from '@novu/stateless'; | ||
import { EnvironmentRepository } from '@novu/dal'; | ||
import { decryptApiKey } from '@novu/application-generic'; | ||
|
||
import { encryptApiKeysMigration } from './encrypt-api-keys-migration'; | ||
|
||
async function pruneIntegration({ environmentRepository }: { environmentRepository: EnvironmentRepository }) { | ||
const old = await environmentRepository.find({}); | ||
|
||
for (const oldKey of old) { | ||
await environmentRepository.delete({ _id: oldKey._id }); | ||
} | ||
} | ||
|
||
describe('Encrypt Old api keys', function () { | ||
let session: UserSession; | ||
const environmentRepository = new EnvironmentRepository(); | ||
|
||
beforeEach(async () => { | ||
session = new UserSession(); | ||
await session.initialize(); | ||
}); | ||
|
||
it('should decrypt all old api keys', async function () { | ||
await pruneIntegration({ environmentRepository }); | ||
|
||
for (let i = 0; i < 2; i++) { | ||
await environmentRepository.create({ | ||
identifier: 'identifier' + i, | ||
name: faker.name.jobTitle(), | ||
_organizationId: session.organization._id, | ||
apiKeys: [ | ||
{ | ||
key: 'not-encrypted-secret-key', | ||
_userId: session.user._id, | ||
}, | ||
], | ||
}); | ||
} | ||
|
||
const newEnvironments = await environmentRepository.find({}); | ||
|
||
expect(newEnvironments.length).to.equal(2); | ||
|
||
for (const environment of newEnvironments) { | ||
expect(environment.identifier).to.contains('identifier'); | ||
expect(environment.name).to.exist; | ||
expect(environment._organizationId).to.equal(session.organization._id); | ||
expect(environment.apiKeys[0].key).to.equal('not-encrypted-secret-key'); | ||
expect(environment.apiKeys[0].hash).to.not.exist; | ||
expect(environment.apiKeys[0]._userId).to.equal(session.user._id); | ||
} | ||
|
||
await encryptApiKeysMigration(); | ||
|
||
const encryptEnvironments = await environmentRepository.find({}); | ||
|
||
for (const environment of encryptEnvironments) { | ||
const decryptedApiKey = decryptApiKey(environment.apiKeys[0].key); | ||
const hashedApiKey = createHash('sha256').update(decryptedApiKey).digest('hex'); | ||
|
||
expect(environment.identifier).to.contains('identifier'); | ||
expect(environment.name).to.exist; | ||
expect(environment._organizationId).to.equal(session.organization._id); | ||
expect(environment.apiKeys[0].key).to.contains('nvsk.'); | ||
expect(environment.apiKeys[0].hash).to.equal(hashedApiKey); | ||
expect(environment.apiKeys[0]._userId).to.equal(session.user._id); | ||
} | ||
}); | ||
|
||
it('should validate migration idempotence', async function () { | ||
await pruneIntegration({ environmentRepository }); | ||
|
||
const data = { | ||
providerId: 'sendgrid', | ||
channel: ChannelTypeEnum.EMAIL, | ||
active: false, | ||
}; | ||
|
||
for (let i = 0; i < 2; i++) { | ||
await environmentRepository.create({ | ||
identifier: 'identifier' + i, | ||
name: faker.name.jobTitle(), | ||
_organizationId: session.organization._id, | ||
apiKeys: [ | ||
{ | ||
key: 'not-encrypted-secret-key', | ||
_userId: session.user._id, | ||
}, | ||
], | ||
}); | ||
} | ||
|
||
await encryptApiKeysMigration(); | ||
const firstMigrationExecution = await environmentRepository.find({}); | ||
|
||
await encryptApiKeysMigration(); | ||
const secondMigrationExecution = await environmentRepository.find({}); | ||
|
||
expect(firstMigrationExecution[0].identifier).to.contains(secondMigrationExecution[0].identifier); | ||
expect(firstMigrationExecution[0].name).to.exist; | ||
expect(firstMigrationExecution[0]._organizationId).to.equal(secondMigrationExecution[0]._organizationId); | ||
expect(firstMigrationExecution[0].apiKeys[0].key).to.contains(secondMigrationExecution[0].apiKeys[0].key); | ||
expect(firstMigrationExecution[0].apiKeys[0].hash).to.contains(secondMigrationExecution[0].apiKeys[0].hash); | ||
expect(firstMigrationExecution[0].apiKeys[0]._userId).to.equal(secondMigrationExecution[0].apiKeys[0]._userId); | ||
|
||
expect(firstMigrationExecution[1].identifier).to.contains(secondMigrationExecution[1].identifier); | ||
expect(firstMigrationExecution[1].name).to.exist; | ||
expect(firstMigrationExecution[1]._organizationId).to.equal(secondMigrationExecution[1]._organizationId); | ||
expect(firstMigrationExecution[1].apiKeys[0].key).to.contains(secondMigrationExecution[1].apiKeys[0].key); | ||
expect(firstMigrationExecution[1].apiKeys[0].hash).to.contains(secondMigrationExecution[1].apiKeys[0].hash); | ||
expect(firstMigrationExecution[1].apiKeys[0]._userId).to.equal(secondMigrationExecution[1].apiKeys[0]._userId); | ||
}); | ||
}); |
70 changes: 70 additions & 0 deletions
70
apps/api/migrations/encrypt-api-keys/encrypt-api-keys-migration.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import { EnvironmentRepository, IApiKey } from '@novu/dal'; | ||
import { encryptSecret } from '@novu/application-generic'; | ||
import { EncryptedSecret } from '@novu/shared'; | ||
import { createHash } from 'crypto'; | ||
|
||
export async function encryptApiKeysMigration() { | ||
// eslint-disable-next-line no-console | ||
console.log('start migration - encrypt api keys'); | ||
|
||
const environmentRepository = new EnvironmentRepository(); | ||
const environments = await environmentRepository.find({}); | ||
|
||
for (const environment of environments) { | ||
// eslint-disable-next-line no-console | ||
console.log(`environment ${environment._id}`); | ||
|
||
if (!environment.apiKeys) { | ||
// eslint-disable-next-line no-console | ||
console.log(`environment ${environment._id} - is not contains api keys, skipping..`); | ||
continue; | ||
} | ||
|
||
if ( | ||
environment.apiKeys.every((key) => { | ||
isEncrypted(key.key); | ||
}) | ||
) { | ||
// eslint-disable-next-line no-console | ||
console.log(`environment ${environment._id} - api keys are already encrypted, skipping..`); | ||
continue; | ||
} | ||
|
||
const updatePayload: IEncryptedApiKey[] = encryptApiKeysWithGuard(environment.apiKeys); | ||
|
||
await environmentRepository.update( | ||
{ _id: environment._id }, | ||
{ | ||
$set: { apiKeys: updatePayload }, | ||
} | ||
); | ||
// eslint-disable-next-line no-console | ||
console.log(`environment ${environment._id} - api keys updated`); | ||
} | ||
// eslint-disable-next-line no-console | ||
console.log('end migration'); | ||
} | ||
|
||
export function encryptApiKeysWithGuard(apiKeys: IApiKey[]): IEncryptedApiKey[] { | ||
return apiKeys.map((apiKey: IApiKey) => { | ||
const hashedApiKey = createHash('sha256').update(apiKey.key).digest('hex'); | ||
|
||
const encryptedApiKey: IEncryptedApiKey = { | ||
hash: apiKey?.hash ? apiKey?.hash : hashedApiKey, | ||
key: isEncrypted(apiKey.key) ? apiKey.key : encryptSecret(apiKey.key), | ||
_userId: apiKey._userId, | ||
}; | ||
|
||
return encryptedApiKey; | ||
}); | ||
} | ||
|
||
function isEncrypted(apiKey: string): apiKey is EncryptedSecret { | ||
return apiKey.startsWith('nvsk.'); | ||
} | ||
|
||
export interface IEncryptedApiKey { | ||
key: EncryptedSecret; | ||
_userId: string; | ||
hash: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { UserSession } from '@novu/testing'; | ||
import { expect } from 'chai'; | ||
import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared'; | ||
|
||
describe('Get Environment API Keys - /environments/api-keys (GET)', async () => { | ||
let session: UserSession; | ||
before(async () => { | ||
session = new UserSession(); | ||
await session.initialize({}); | ||
}); | ||
|
||
it('should get environment api keys correctly', async () => { | ||
const demoEnvironment = { | ||
name: 'Hello App', | ||
}; | ||
await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201); | ||
|
||
const { body } = await session.testAgent.get('/v1/environments/api-keys').send(); | ||
|
||
expect(body.data[0].key).to.not.contains(NOVU_ENCRYPTION_SUB_MASK); | ||
expect(body.data[0]._userId).to.equal(session.user._id); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.