diff --git a/README.md b/README.md index 335d220..0e762ef 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ if(ssm.secret) { - `kmsKey` - optional - must be a `kms.IKey` - the sops file contains a reference to the KMS key, so probably not actually needed -- `mappings` and `wholeFile` - must set `mappings` or set `wholeFile` to `true` +- `mappings`, `wholeFile` and `singleValueMapping` - must set `mappings` or `singleValueMapping` or set `wholeFile` to `true` - if `mappings`, must be a `SopsSecretsManagerMappings` - which determines how the values from the sops file are mapped to keys in the secret (see below) + - if `singleValueMapping`, must be a `SopsSecretsManagerMapping` + - which determines how a single value from the sops file is mapped to the text value of the secret - if `wholeFile` is true - then rather than treating the sops data as structured and mapping keys over, the whole file will be decrypted and stored as the body of the secret - `fileType` - optional diff --git a/cdkv1.ts b/cdkv1.ts index ae79b3d..eea76e0 100644 --- a/cdkv1.ts +++ b/cdkv1.ts @@ -71,10 +71,19 @@ export class SopsSecretsManager extends cdk.Construct { } this.asset = this.getAsset(props.asset, props.path); - if (props.wholeFile && props.mappings) { - throw new Error('Cannot set mappings and set wholeFile to true'); - } else if (!props.wholeFile && !props.mappings) { - throw new Error('Must set mappings or set wholeFile to true'); + const mutuallyExclusiveProps: Record = { + wholeFile: !!props.wholeFile, + mappings: !!props.mappings, + singleValueMapping: !!props.singleValueMapping, + } + + const mutuallyExclusivePropsEnabled = Object.keys(mutuallyExclusiveProps).filter((key) => mutuallyExclusiveProps[key]); + if (mutuallyExclusivePropsEnabled.length > 1) { + throw new Error(`Cannot set more than one of ${mutuallyExclusivePropsEnabled.join(', ')}`); + } + + if (mutuallyExclusivePropsEnabled.length === 0) { + throw new Error(`Must set one of ${Object.keys(mutuallyExclusiveProps).join(', ')}`); } new cfn.CustomResource(this, 'Resource', { @@ -87,6 +96,7 @@ export class SopsSecretsManager extends cdk.Construct { SourceHash: this.asset.sourceHash, KMSKeyArn: props.kmsKey?.keyArn, Mappings: JSON.stringify(props.mappings || {}), + SingleValueMapping: JSON.stringify(props.singleValueMapping || null), WholeFile: props.wholeFile || false, FileType: props.fileType, }, diff --git a/cdkv2.ts b/cdkv2.ts index 1a1b49d..380f2ec 100644 --- a/cdkv2.ts +++ b/cdkv2.ts @@ -72,10 +72,19 @@ export class SopsSecretsManager extends constructs.Construct { } this.asset = this.getAsset(props.asset, props.path); - if (props.wholeFile && props.mappings) { - throw new Error('Cannot set mappings and set wholeFile to true'); - } else if (!props.wholeFile && !props.mappings) { - throw new Error('Must set mappings or set wholeFile to true'); + const mutuallyExclusiveProps: Record = { + wholeFile: !!props.wholeFile, + mappings: !!props.mappings, + singleValueMapping: !!props.singleValueMapping, + } + + const mutuallyExclusivePropsEnabled = Object.keys(mutuallyExclusiveProps).filter((key) => mutuallyExclusiveProps[key]); + if (mutuallyExclusivePropsEnabled.length > 1) { + throw new Error(`Cannot set more than one of ${mutuallyExclusivePropsEnabled.join(', ')}`); + } + + if (mutuallyExclusivePropsEnabled.length === 0) { + throw new Error(`Must set one of ${Object.keys(mutuallyExclusiveProps).join(', ')}`); } const provider = SopsSecretsManagerProvider.getOrCreate(this); @@ -90,6 +99,7 @@ export class SopsSecretsManager extends constructs.Construct { SourceHash: this.asset.assetHash, KMSKeyArn: props.kmsKey?.keyArn, Mappings: JSON.stringify(props.mappings || {}), + SingleValueMapping: JSON.stringify(props.singleValueMapping || null), WholeFile: props.wholeFile || false, FileType: props.fileType, }, diff --git a/common.ts b/common.ts index bc45dd1..3fed43c 100644 --- a/common.ts +++ b/common.ts @@ -21,6 +21,7 @@ export interface SopsSecretsManagerBaseProps { readonly kmsKey?: unknown; readonly mappings?: SopsSecretsManagerMappings; readonly wholeFile?: boolean; + readonly singleValueMapping?: SopsSecretsManagerMapping; readonly fileType?: SopsSecretsManagerFileType; } diff --git a/provider/index.ts b/provider/index.ts index 684587f..71c6841 100644 --- a/provider/index.ts +++ b/provider/index.ts @@ -58,6 +58,7 @@ interface ResourceProperties { S3Bucket: string; S3Path: string; Mappings: string; // json encoded Mappings; + SingleValueMapping: string; // json encoded Mapping; WholeFile: boolean | string; SecretArn: string; SourceHash: string; @@ -170,6 +171,18 @@ const toMappingsOrError = (obj: unknown, errorMessage: string): Mappings => { throw new Error(errorMessage); }; +const toMappingOrNullOrError = (obj: unknown, errorMessage: string): Mapping | null => { + console.log('obj', obj); + + if (obj === null) { + return null; + } + if (isMapping(obj)) { + return obj; + } + throw new Error(errorMessage); +}; + const toMappingEncodingOrError = (mappingEncodingAsString: string | undefined): MappingEncoding | undefined => { if (typeof mappingEncodingAsString === 'undefined') { return undefined; @@ -311,6 +324,7 @@ const handleCreate = async (event: CreateOrUpdateEvent): Promise => { const s3BucketName = event.ResourceProperties.S3Bucket; const s3Path = event.ResourceProperties.S3Path; const mappings = toMappingsOrError(JSON.parse(event.ResourceProperties.Mappings), 'Unable to parse mappings to a valid shape'); + const singleValueMapping = toMappingOrNullOrError(JSON.parse(event.ResourceProperties.SingleValueMapping), 'Unable to parse singleValueMapping to a valid shape'); const wholeFile = normaliseBoolean(event.ResourceProperties.WholeFile); const secretArn = event.ResourceProperties.SecretArn; // const sourceHash = event.ResourceProperties.SourceHash; @@ -348,6 +362,10 @@ const handleCreate = async (event: CreateOrUpdateEvent): Promise => { log('Writing decoded data to secretsmanager as whole file', { secretArn }); const wholeFileData = (data as SopsWholeFileData).data || ''; await setSecretString(wholeFileData, secretArn); + } else if (singleValueMapping) { + log('Mapping values from decoded data', { singleValueMapping }); + const mappedValue = resolveMappings(data, { '': singleValueMapping })['']; + await setSecretString(mappedValue, secretArn); } else { log('Mapping values from decoded data', { mappings }); const mappedValues = resolveMappings(data, mappings); @@ -449,6 +467,7 @@ const decodeResourceProperties = (resourceProperties: unknown): ResourceProperti S3Bucket: getStringKeyOrError('S3Bucket', resourceProperties, 'Invalid resourceProperties'), S3Path: getStringKeyOrError('S3Path', resourceProperties, 'Invalid resourceProperties'), Mappings: getStringKeyOrError('Mappings', resourceProperties, 'Invalid resourceProperties'), + SingleValueMapping: getStringKeyOrError('SingleValueMapping', resourceProperties, 'Invalid resourceProperties'), WholeFile: getStringOrBooleanKeyOrError('WholeFile', resourceProperties, 'Invalid resourceProperties'), SecretArn: getStringKeyOrError('SecretArn', resourceProperties, 'Invalid resourceProperties'), SourceHash: getStringKeyOrError('SourceHash', resourceProperties, 'Invalid resourceProperties'), diff --git a/provider/tests/index.test.ts b/provider/tests/index.test.ts index f49d1d7..1472497 100644 --- a/provider/tests/index.test.ts +++ b/provider/tests/index.test.ts @@ -115,6 +115,7 @@ describe('onCreate', () => { path: ['a'], }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123', @@ -175,6 +176,7 @@ describe('onCreate', () => { path: ['a'], }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123', @@ -221,6 +223,7 @@ describe('onCreate', () => { path: ['a'], }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123', @@ -262,6 +265,7 @@ describe('onCreate', () => { encoding: 'json', }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123', @@ -303,6 +307,7 @@ describe('onCreate', () => { encoding: 'json', }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: 'false', // because a boolean set in the CDK becomes a string once it reaches the provider SecretArn: 'mysecretarn', SourceHash: '123', @@ -337,6 +342,7 @@ describe('onCreate', () => { S3Bucket: 'mys3bucket', S3Path: 'mys3path.txt', Mappings: JSON.stringify({}), + SingleValueMapping: JSON.stringify(null), WholeFile: true, SecretArn: 'mysecretarn', SourceHash: '123', @@ -359,6 +365,38 @@ describe('onCreate', () => { }); }); + test('singleValueMapping', async () => { + setMockSpawn({ + stdoutData: JSON.stringify({ + a: { + b: 'c', + }, + }), + }); + + await onEvent({ + RequestType: 'Create', + ResourceProperties: { + KMSKeyArn: undefined, + S3Bucket: 'mys3bucket', + S3Path: 'mys3path.txt', + Mappings: JSON.stringify({}), + SingleValueMapping: JSON.stringify({ + path: ['a', 'b'], + }), + WholeFile: false, + SecretArn: 'mysecretarn', + SourceHash: '123', + FileType: undefined, + }, + }); + + expect(mockSecretsManagerPutSecretValue).toBeCalledWith({ + SecretId: 'mysecretarn', + SecretString: 'c', + }); + }); + test('pass kms key arn', async () => { mockS3GetObject.mockImplementation( (): Promise => @@ -383,6 +421,7 @@ describe('onCreate', () => { path: ['a'], }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123', @@ -436,6 +475,7 @@ describe('onUpdate', () => { path: ['a'], }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123', @@ -554,6 +594,7 @@ describe('invalid event attribute value shapes', () => { path: ['a'], }, }), + SingleValueMapping: JSON.stringify(null), WholeFile: false, SecretArn: 'mysecretarn', SourceHash: '123',