Created
March 22, 2022 17:23
-
-
Save seibar/fa6f9b07aa04f8a8a26bfcaa358c7791 to your computer and use it in GitHub Desktop.
AWS secret rotation lambda node js
This file contains hidden or 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
// Based on: https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/master/SecretsManagerRotationTemplate/lambda_function.py | |
import { SecretsManagerRotationEvent } from 'aws-lambda'; | |
import { DescribeSecretCommandOutput, SecretsManager, ResourceExistsException, GetSecretValueCommandOutput } from "@aws-sdk/client-secrets-manager"; | |
import { inspect } from 'util'; | |
export interface ILogger { | |
info: (...data: any[]) => void; | |
error: (...data: any[]) => void; | |
} | |
export interface SecretRotationHandlerResult { | |
/** The previous value of the secret. This will be undefined if the secret has not rotated before. */ | |
previousSecret?: string; | |
/** The current value of the secret. */ | |
currentSecret: string; | |
/** The pending value of the secret, i.e. what the secret value will be after the rotation is completed. */ | |
pendingSecret: string; | |
/** The version of the pending secret. */ | |
pendingSecretVersionId: string; | |
} | |
export interface SecretRotationHandlerOptions { | |
/** An optional logger. Defaults to `console.log()`/`console.error()`. */ | |
logger?: ILogger; | |
/** A function that generates a new secret string value for this secret. When the rotation happens, the secret will be updated with this value. */ | |
secretValueFactory: () => Promise<string>; | |
/** Any action that needs to be performed before the secret is finalized. For example if something needs to be done in a database with the new secret value, this is where that would happen. */ | |
sideEffect: (args: SecretRotationHandlerResult) => Promise<void>; | |
/** An optional function that validates that the the sideEffect did what it was supposed to do with the pending secret value. If this function does not return `true` then the secret's new value does not get finalized. */ | |
testSecret?: (pendingSecret: string) => Promise<boolean>; | |
} | |
const AWSPREVIOUS = 'AWSPREVIOUS'; | |
const AWSCURRENT = 'AWSCURRENT'; | |
const AWSPENDING = 'AWSPENDING'; | |
export class SecretRotationHandler { | |
private readonly logger: ILogger; | |
constructor ( | |
private readonly secretsManager: SecretsManager, | |
private readonly options: SecretRotationHandlerOptions | |
) { | |
if (!this.options?.secretValueFactory) { | |
throw new Error('secretValueFactory is required'); | |
} | |
if (!this.options?.sideEffect) { | |
throw new Error('sideEffect is required'); | |
} | |
if (!options?.logger) { | |
this.logger = { | |
info: console.log, | |
error: console.error | |
}; | |
} | |
} | |
async handle(event: SecretsManagerRotationEvent): Promise<void> { | |
if (!event) { | |
throw new Error('event is required'); | |
} | |
let secret: DescribeSecretCommandOutput; | |
try { | |
secret = await this.secretsManager.describeSecret({ | |
SecretId: event.SecretId, | |
}); | |
if (!secret) { | |
throw new Error('Secret is undefined'); | |
} | |
} catch (e) { | |
this.logger.error(`Error retrieving secret ${event.SecretId}`, inspect(e)); | |
throw new Error(`Error retrieving secret ${event.SecretId}`); | |
} | |
if (!secret.RotationEnabled) { | |
this.logger.error(`Secret ${event.SecretId} is not enabled for rotation`); | |
throw new Error(`Secret ${event.SecretId} is not enabled for rotation`); | |
} | |
if (Object.keys(secret.VersionIdsToStages).indexOf(event.ClientRequestToken) === -1) { | |
this.logger.error(`Secret version ${event.ClientRequestToken} has no stage for rotation of secret ${event.SecretId}`); | |
throw new Error(`Secret version ${event.ClientRequestToken} has no stage for rotation of secret ${event.SecretId}`); | |
} | |
if (secret.VersionIdsToStages[event.ClientRequestToken].indexOf(AWSCURRENT) !== -1) { | |
this.logger.info(`Secret version ${event.ClientRequestToken} already set as AWSCURRENT for secret ${event.SecretId}.`); | |
return; | |
} | |
if (secret.VersionIdsToStages[event.ClientRequestToken].indexOf(AWSPENDING) === -1) { | |
this.logger.error(`Secret version ${event.ClientRequestToken} not set as AWSPENDING for rotation of secret ${event.SecretId}.`); | |
throw new Error(`Secret version ${event.ClientRequestToken} not set as AWSPENDING for rotation of secret ${event.SecretId}.`); | |
} | |
if (event.Step === 'createSecret') { | |
await this.createSecret(event); | |
} else if (event.Step === 'setSecret') { | |
await this.setSecret(event); | |
} else if (event.Step === 'testSecret') { | |
await this.testSecret(event); | |
} else if (event.Step === 'finishSecret') { | |
await this.finishSecret(event); | |
} else { | |
throw new Error(`Invalid step parameter: ${event.Step} for secret ${event.SecretId}`); | |
} | |
} | |
private async createSecret(event: SecretsManagerRotationEvent): Promise<void> { | |
try { | |
await this.secretsManager.getSecretValue({ | |
SecretId: event.SecretId, | |
VersionId: event.ClientRequestToken, | |
VersionStage: AWSPENDING | |
}); | |
this.logger.info(`createSecret: Secret already exists. ${event.SecretId}`); | |
} catch (e) { | |
if (e instanceof ResourceExistsException) { | |
try { | |
const secretString = await this.options.secretValueFactory(); | |
if (!secretString) { | |
throw new Error('The result of secretValueFactory is invalid.') | |
} | |
await this.secretsManager.putSecretValue({ | |
SecretId: event.SecretId, | |
ClientRequestToken: event.ClientRequestToken, | |
SecretString: secretString, | |
VersionStages: [AWSPENDING] | |
}); | |
this.logger.info(`createSecret: Successfully put secret for ARN ${event.SecretId} and version ${event.ClientRequestToken}.`); | |
} catch (e2) { | |
this.logger.error(`createSecret: Error putting secret value for ${event.SecretId}`, inspect(e2)); | |
throw e2; | |
} | |
} else { | |
this.logger.info(`createSecret: error retrieving secret ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
} | |
} | |
private async setSecret(event: SecretsManagerRotationEvent): Promise<void> { | |
let previousSecret: GetSecretValueCommandOutput; | |
let currentSecret: GetSecretValueCommandOutput; | |
let pendingSecret: GetSecretValueCommandOutput; | |
try { | |
previousSecret = await this.secretsManager.getSecretValue({ | |
SecretId: event.SecretId, | |
VersionStage: AWSPREVIOUS | |
}); | |
} catch (e) { | |
// The previous stage will not always exist, for instance if this is the very first rotation. | |
if (!(e instanceof ResourceExistsException)) { | |
this.logger.error(`setSecret: Error retrieving AWSPREVIOUS secret stage for ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
} | |
try { | |
currentSecret = await this.secretsManager.getSecretValue({ | |
SecretId: event.SecretId, | |
VersionStage: AWSCURRENT, | |
VersionId: event.ClientRequestToken | |
}); | |
} catch (e) { | |
this.logger.error(`setSecret: Error retrieving AWSCURRENT secret stage for ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
try { | |
pendingSecret = await this.secretsManager.getSecretValue({ | |
SecretId: event.SecretId, | |
VersionStage: AWSPENDING, | |
VersionId: event.ClientRequestToken | |
}); | |
} catch (e) { | |
this.logger.error(`setSecret: Error retrieving AWSPENDING secret stage for ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
try { | |
await this.options.sideEffect({ | |
previousSecret: previousSecret?.SecretString, | |
currentSecret: currentSecret.SecretString, | |
pendingSecret: pendingSecret.SecretString, | |
pendingSecretVersionId: event.ClientRequestToken | |
}); | |
} catch (e) { | |
this.logger.error(`setSecret: error invoking sideEffect function for ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
} | |
private async testSecret(event: SecretsManagerRotationEvent): Promise<void> { | |
if (this.options.testSecret) { | |
let pendingSecret: GetSecretValueCommandOutput; | |
try { | |
pendingSecret = await this.secretsManager.getSecretValue({ | |
SecretId: event.SecretId, | |
VersionStage: AWSPENDING, | |
VersionId: event.ClientRequestToken | |
}); | |
} catch (e) { | |
this.logger.error(`testSecret: Error retrieving AWSPENDING secret stage for ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
let testResult = false; | |
try { | |
testResult = await this.options.testSecret(pendingSecret.SecretString); | |
} catch (e) { | |
this.logger.error(`testSecret function throw an error for ${event.SecretId}`, inspect(e)); | |
throw e; | |
} | |
if (testResult !== true) { | |
this.logger.error(`testSecret returned ${testResult}. The secret ${event.SecretId} will not be finalized.`); | |
throw new Error(`testSecret returned ${testResult}. The secret ${event.SecretId} will not be finalized.`); | |
} | |
} | |
} | |
private async finishSecret(event: SecretsManagerRotationEvent): Promise<void> { | |
const secret = await this.secretsManager.describeSecret({ | |
SecretId: event.SecretId | |
}); | |
// First find the existing current version so that it can be marked as previous. | |
let currentVersion: string; | |
for (const entry of Object.entries(secret.VersionIdsToStages)) { | |
const version = entry[0]; | |
const stages = entry[1] as string[]; | |
if (stages.indexOf(AWSCURRENT) !== -1) { | |
if (version === event.ClientRequestToken) { | |
// The correct version is already marked as current, return | |
this.logger.info(`finishSecret: Version ${version} is already marked as AWSCURRENT from ${event.SecretId}`); | |
return; | |
} | |
currentVersion = version; | |
break; | |
} | |
} | |
// Finalize the secret | |
await this.secretsManager.updateSecretVersionStage({ | |
SecretId: event.SecretId, | |
VersionStage: AWSCURRENT, | |
MoveToVersionId: event.ClientRequestToken, | |
RemoveFromVersionId: currentVersion | |
}); | |
this.logger.info(`finishSecret: Successfully set AWSCURRENT stage to version ${event.ClientRequestToken} for secret ${event.SecretId}`); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment