diff --git a/packages/amplify-e2e-core/src/utils/rds.ts b/packages/amplify-e2e-core/src/utils/rds.ts index a628cdcaf2..dd266482af 100644 --- a/packages/amplify-e2e-core/src/utils/rds.ts +++ b/packages/amplify-e2e-core/src/utils/rds.ts @@ -442,20 +442,33 @@ export const storeDbConnectionConfig = async (options: { export const storeDbConnectionStringConfig = async (options: { region: string; pathPrefix: string; - connectionUri: string; + connectionUri: string | string[]; }): Promise<{ - connectionUriSsmPath: string; + connectionUriSsmPath: string | string[]; }> => { - await storeSSMParameters({ - region: options.region, - pathPrefix: options.pathPrefix, - parameters: { - connectionUri: options.connectionUri, - }, - }); - return { - connectionUriSsmPath: `${options.pathPrefix}/connectionUri`, - }; + if (typeof options.connectionUri === 'string') { + await storeSSMParameters({ + region: options.region, + pathPrefix: options.pathPrefix, + parameters: { + connectionUri: options.connectionUri, + }, + }); + return { + connectionUriSsmPath: `${options.pathPrefix}/connectionUri`, + }; + } else { + await storeSSMParameters({ + region: options.region, + pathPrefix: options.pathPrefix, + parameters: { + connectionUri: options.connectionUri[1], + }, + }); + return { + connectionUriSsmPath: [`${options.pathPrefix}/connectionUri/doesnotexist`, `${options.pathPrefix}/connectionUri`], + }; + } }; export const storeSSMParameters = async (options: { region: string; pathPrefix: string; parameters: Record }) => { diff --git a/packages/amplify-graphql-api-construct-tests/src/__tests__/sql-models-2.test.ts b/packages/amplify-graphql-api-construct-tests/src/__tests__/sql-models-2.test.ts index 9919bc8c65..85a03d59fe 100644 --- a/packages/amplify-graphql-api-construct-tests/src/__tests__/sql-models-2.test.ts +++ b/packages/amplify-graphql-api-construct-tests/src/__tests__/sql-models-2.test.ts @@ -59,10 +59,14 @@ describe('CDK GraphQL Transformer deployments with SQL datasources', () => { await testGraphQLAPI(constructTestOptions('ssm')); }); - test('creates a GraphQL API from SQL-based models using Connection String SSM parameter', async () => { + test('creates a GraphQL API from SQL-based models using Connection Uri SSM path', async () => { await testGraphQLAPI(constructTestOptions('connectionUri')); }); + test('creates a GraphQL API from SQL-based models using multiple Connection Uri SSM paths', async () => { + await testGraphQLAPI(constructTestOptions('connectionUriMultiple')); + }); + const constructTestOptions = (connectionConfigName: string) => ({ projRoot, region, diff --git a/packages/amplify-graphql-api-construct-tests/src/sql-datatabase-controller.ts b/packages/amplify-graphql-api-construct-tests/src/sql-datatabase-controller.ts index 72deb291d3..94a14c4945 100644 --- a/packages/amplify-graphql-api-construct-tests/src/sql-datatabase-controller.ts +++ b/packages/amplify-graphql-api-construct-tests/src/sql-datatabase-controller.ts @@ -115,9 +115,18 @@ export class SqlDatatabaseController { this.options.dbname, ), }); + const dbConnectionStringConfigMultiple = await storeDbConnectionStringConfig({ + region: this.options.region, + pathPrefix, + connectionUri: [ + 'mysql://username:password@host:3306/dbname', + this.getConnectionUri(engine, this.options.username, dbConfig.password, dbConfig.endpoint, dbConfig.port, this.options.dbname), + ], + }); const parameters = { ...dbConnectionConfigSSM, ...dbConnectionStringConfigSSM, + ...dbConnectionStringConfigMultiple, }; if (!dbConnectionConfigSSM) { throw new Error('Failed to store db connection config for SSM'); @@ -144,6 +153,7 @@ export class SqlDatatabaseController { secretArn: dbConfig.managedSecretArn, }, connectionUri: dbConnectionStringConfigSSM, + connectionUriMultiple: dbConnectionStringConfigMultiple, }, }; @@ -179,9 +189,11 @@ export class SqlDatatabaseController { ], }); } else if (isSqlModelDataSourceSsmDbConnectionStringConfig(dbConnectionConfig)) { + const { connectionUriSsmPath } = dbConnectionConfig; + const paths = Array.isArray(connectionUriSsmPath) ? connectionUriSsmPath : [connectionUriSsmPath]; return deleteSSMParameters({ region: this.options.region, - parameterNames: [dbConnectionConfig.connectionUriSsmPath], + parameterNames: paths, }); } }), diff --git a/packages/amplify-graphql-api-construct/.jsii b/packages/amplify-graphql-api-construct/.jsii index 8845ec45cd..42a1bf1552 100644 --- a/packages/amplify-graphql-api-construct/.jsii +++ b/packages/amplify-graphql-api-construct/.jsii @@ -7610,7 +7610,21 @@ }, "name": "connectionUriSsmPath", "type": { - "primitive": "string" + "union": { + "types": [ + { + "primitive": "string" + }, + { + "collection": { + "elementtype": { + "primitive": "string" + }, + "kind": "array" + } + } + ] + } } } ], @@ -8171,5 +8185,5 @@ } }, "version": "1.8.1", - "fingerprint": "RYLDuPcXrKkJVdyr+N3cvr80/DnbRL4kKh9QZAl4lUY=" + "fingerprint": "hF3IhO8jnMvruVK74WA/JcSZ7FQvl6/6Sl4Y84Vq1qw=" } \ No newline at end of file diff --git a/packages/amplify-graphql-api-construct/API.md b/packages/amplify-graphql-api-construct/API.md index 684da7b881..b8d82b808e 100644 --- a/packages/amplify-graphql-api-construct/API.md +++ b/packages/amplify-graphql-api-construct/API.md @@ -370,7 +370,7 @@ export interface SqlModelDataSourceSsmDbConnectionConfig { // @public export interface SqlModelDataSourceSsmDbConnectionStringConfig { - readonly connectionUriSsmPath: string; + readonly connectionUriSsmPath: string | string[]; } // @public diff --git a/packages/amplify-graphql-api-construct/src/__tests__/__functional__/sql-model-strategy.test.ts b/packages/amplify-graphql-api-construct/src/__tests__/__functional__/sql-model-strategy.test.ts index 62ed6889f2..b975fde942 100644 --- a/packages/amplify-graphql-api-construct/src/__tests__/__functional__/sql-model-strategy.test.ts +++ b/packages/amplify-graphql-api-construct/src/__tests__/__functional__/sql-model-strategy.test.ts @@ -62,6 +62,13 @@ describe('SQL bound API definitions', () => { expect(isSqlModelDataSourceDbConnectionConfig(dbConfig)).toBe(true); }); + it('accepts multiple connection uri SSM paths in DB configuration', () => { + const dbConfig = { + connectionUriSsmPath: ['/ssm/path/connectionUri/1', '/ssm/path/connectionUri/2'], + }; + expect(isSqlModelDataSourceDbConnectionConfig(dbConfig)).toBe(true); + }); + it('does not accept a connection uri object in DB configuration', () => { const dbConfig = { connectionUriSsmPath: {}, diff --git a/packages/amplify-graphql-api-construct/src/__tests__/internal/data-source-config.test.ts b/packages/amplify-graphql-api-construct/src/__tests__/internal/data-source-config.test.ts index e39cd38e19..3aadb2270e 100644 --- a/packages/amplify-graphql-api-construct/src/__tests__/internal/data-source-config.test.ts +++ b/packages/amplify-graphql-api-construct/src/__tests__/internal/data-source-config.test.ts @@ -281,5 +281,43 @@ describe('datasource config', () => { 'Invalid data source strategy "mysqlStrategy". Following SSM paths must start with \'/\' in dbConnectionConfig: connectionUriPath.', ); }); + + it('validateDataSourceStrategy passes when multiple valid connection string SSM Paths are passed', () => { + expect(() => + validateDataSourceStrategy({ + name: 'mysqlStrategy', + dbType: 'MYSQL', + dbConnectionConfig: { + connectionUriSsmPath: ['/test/connectionUri/1', '/test/connectionUri/2'], + }, + }), + ).not.toThrow(); + }); + + it('validateDataSourceStrategy fails when any of the connection string SSM Paths is not valid', () => { + expect(() => + validateDataSourceStrategy({ + name: 'mysqlStrategy', + dbType: 'MYSQL', + dbConnectionConfig: { + connectionUriSsmPath: ['/test/connectionUri/1', 'connectionUriPath'], + }, + }), + ).toThrow( + 'Invalid data source strategy "mysqlStrategy". Following SSM paths must start with \'/\' in dbConnectionConfig: connectionUriPath.', + ); + }); + + it('validateDataSourceStrategy fails the connection string SSM Path is an empty array', () => { + expect(() => + validateDataSourceStrategy({ + name: 'mysqlStrategy', + dbType: 'MYSQL', + dbConnectionConfig: { + connectionUriSsmPath: [], + }, + }), + ).toThrow('Invalid data source strategy "mysqlStrategy". connectionUriSsmPath must be a string or non-empty array.'); + }); }); }); diff --git a/packages/amplify-graphql-api-construct/src/internal/data-source-config.ts b/packages/amplify-graphql-api-construct/src/internal/data-source-config.ts index 95c2d1a8c1..5003db732c 100644 --- a/packages/amplify-graphql-api-construct/src/internal/data-source-config.ts +++ b/packages/amplify-graphql-api-construct/src/internal/data-source-config.ts @@ -200,7 +200,18 @@ export const validateDataSourceStrategy = (strategy: ConstructModelDataSourceStr isSqlModelDataSourceSsmDbConnectionConfig(dbConnectionConfig) || isSqlModelDataSourceSsmDbConnectionStringConfig(dbConnectionConfig) ) { - const invalidSSMPaths = Object.values(dbConnectionConfig).filter((value) => typeof value === 'string' && !isValidSSMPath(value)); + const ssmPaths = Object.values(dbConnectionConfig).filter((value) => typeof value === 'string'); + if (isSqlModelDataSourceSsmDbConnectionStringConfig(dbConnectionConfig)) { + const hasMultipleSSMPaths = Array.isArray(dbConnectionConfig?.connectionUriSsmPath); + if (hasMultipleSSMPaths) { + if (dbConnectionConfig?.connectionUriSsmPath?.length < 1) { + throw new Error(`Invalid data source strategy "${strategy.name}". connectionUriSsmPath must be a string or non-empty array.`); + } + ssmPaths.push(...dbConnectionConfig.connectionUriSsmPath); + } + } + + const invalidSSMPaths = ssmPaths.filter((value) => !isValidSSMPath(value)); if (invalidSSMPaths.length > 0) { throw new Error( `Invalid data source strategy "${ diff --git a/packages/amplify-graphql-api-construct/src/model-datasource-strategy-types.ts b/packages/amplify-graphql-api-construct/src/model-datasource-strategy-types.ts index 00a84c3a84..00b7bf3d22 100644 --- a/packages/amplify-graphql-api-construct/src/model-datasource-strategy-types.ts +++ b/packages/amplify-graphql-api-construct/src/model-datasource-strategy-types.ts @@ -131,8 +131,13 @@ export type SqlModelDataSourceDbConnectionConfig = * @experimental */ export interface SqlModelDataSourceSsmDbConnectionStringConfig { - /** The SSM Path to the secure connection string used for connecting to the database. **/ - readonly connectionUriSsmPath: string; + /** + * The SSM Path to the secure connection string used for connecting to the database. If more than one path is provided, + * the SQL Lambda will attempt to retrieve connection information from each path in order until it finds a valid + * path entry, then stop. If the connection information contained in that path is invalid, the SQL Lambda will not + * attempt to retrieve connection information from subsequent paths in the array. + **/ + readonly connectionUriSsmPath: string | string[]; } /** diff --git a/packages/amplify-graphql-api-construct/src/sql-model-datasource-strategy.ts b/packages/amplify-graphql-api-construct/src/sql-model-datasource-strategy.ts index 92ef2da109..679b4ba8bd 100644 --- a/packages/amplify-graphql-api-construct/src/sql-model-datasource-strategy.ts +++ b/packages/amplify-graphql-api-construct/src/sql-model-datasource-strategy.ts @@ -76,7 +76,10 @@ export const isSqlModelDataSourceSecretsManagerDbConnectionConfig = ( * @returns true if the object is shaped like a SqlModelDataSourceSsmDbConnectionStringConfig */ export const isSqlModelDataSourceSsmDbConnectionStringConfig = (obj: any): obj is SqlModelDataSourceSsmDbConnectionStringConfig => { - return (typeof obj === 'object' || typeof obj === 'function') && typeof obj.connectionUriSsmPath === 'string'; + return ( + (typeof obj === 'object' || typeof obj === 'function') && + (typeof obj.connectionUriSsmPath === 'string' || Array.isArray(obj.connectionUriSsmPath)) + ); }; /** diff --git a/packages/amplify-graphql-model-transformer/rds-lambda/handler.ts b/packages/amplify-graphql-model-transformer/rds-lambda/handler.ts index c6aa9785ab..b479cbafb2 100644 --- a/packages/amplify-graphql-model-transformer/rds-lambda/handler.ts +++ b/packages/amplify-graphql-model-transformer/rds-lambda/handler.ts @@ -144,6 +144,34 @@ const getSecretManagerValue = async (secretArn: string | undefined): Promise<{ u } } +const resolveConnectionStringValue = async (jsonConnectionString: string): Promise => { + const parsedJsonConnectionString = JSON.parse(jsonConnectionString); + const ssmRequestError = 'Unable to connect to the database. Check the logs for more details.'; + const ssmLoggedError = 'Unable to fetch the connection Uri from SSM for the provided paths.'; + if (Array.isArray(parsedJsonConnectionString)) { + for (const connectionUriSsmPath of parsedJsonConnectionString) { + try { + return await getSSMValue(connectionUriSsmPath); + } + catch (e) { + // try the next secret path; + continue; + } + } + console.log(ssmLoggedError); + throw new Error(ssmRequestError); + } + else { + try { + return await getSSMValue(parsedJsonConnectionString); + } + catch (e) { + console.log(ssmLoggedError); + throw new Error(ssmRequestError); + } + } +}; + const getDBConfig = async (): DBConfig => { const config: DBConfig = {}; @@ -153,9 +181,9 @@ const getDBConfig = async (): DBConfig => { createSSMClient(); } - const connectionString = process.env.connectionString; - if (connectionString) { - config.connectionString = await getSSMValue(connectionString); + const jsonConnectionString = process.env.connectionString; + if (jsonConnectionString) { + config.connectionString = await resolveConnectionStringValue(jsonConnectionString); return config; } diff --git a/packages/amplify-graphql-model-transformer/src/__tests__/__snapshots__/amplify-sql-resource-generator.test.ts.snap b/packages/amplify-graphql-model-transformer/src/__tests__/__snapshots__/amplify-sql-resource-generator.test.ts.snap index 893f205594..037e2d6b62 100644 --- a/packages/amplify-graphql-model-transformer/src/__tests__/__snapshots__/amplify-sql-resource-generator.test.ts.snap +++ b/packages/amplify-graphql-model-transformer/src/__tests__/__snapshots__/amplify-sql-resource-generator.test.ts.snap @@ -1,5 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ModelTransformer with SQL data sources: should accept multiple connection uris as input 1`] = ` +Object { + "Statement": Array [ + Object { + "Action": Array [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:*:*:*", + }, + Object { + "Action": Array [ + "ssm:GetParameter", + "ssm:GetParameters", + ], + "Effect": "Allow", + "Resource": Array [ + "arn:aws:ssm:*:*:parameter/test/connectionUri/1", + "arn:aws:ssm:*:*:parameter/test/connectionUri/2", + ], + }, + ], + "Version": "2012-10-17", +} +`; + exports[`ModelTransformer with SQL data sources: should assign SSM permissions 1`] = ` Object { "Statement": Array [ diff --git a/packages/amplify-graphql-model-transformer/src/__tests__/amplify-sql-resource-generator.test.ts b/packages/amplify-graphql-model-transformer/src/__tests__/amplify-sql-resource-generator.test.ts index 924bd1280e..8bc90ab99c 100644 --- a/packages/amplify-graphql-model-transformer/src/__tests__/amplify-sql-resource-generator.test.ts +++ b/packages/amplify-graphql-model-transformer/src/__tests__/amplify-sql-resource-generator.test.ts @@ -275,4 +275,74 @@ describe('ModelTransformer with SQL data sources:', () => { expect(envVars.CREDENTIAL_STORAGE_METHOD).toEqual('SSM'); expect(envVars.SSM_ENDPOINT).toBeDefined(); }); + + it('should accept multiple connection uris as input', () => { + const connectionStringMySql = { + ...mysqlStrategy, + dbConnectionConfig: { + connectionUriSsmPath: ['/test/connectionUri/1', '/test/connectionUri/2'], + }, + }; + const out = testTransform({ + schema: validSchema, + transformers: [new ModelTransformer(), new PrimaryKeyTransformer()], + dataSourceStrategies: constructDataSourceStrategies(validSchema, connectionStringMySql), + }); + expect(out).toBeDefined(); + const resourceNames = getResourceNamesForStrategy(connectionStringMySql); + const sqlApiStack = out.stacks[resourceNames.sqlStack]; + expect(sqlApiStack).toBeDefined(); + + // Check that SSM permissions are assigned + const [, policy] = + Object.entries(sqlApiStack.Resources!).find(([resourceName]) => { + return resourceName.startsWith(resourceNames.sqlLambdaExecutionRolePolicy); + }) || []; + + expect(policy).toBeDefined(); + const { + Properties: { PolicyDocument }, + } = policy; + expect(PolicyDocument.Statement.length).toEqual(2); + + const ssmStatements = PolicyDocument.Statement.filter( + (statement: any) => JSON.stringify(statement.Action) === JSON.stringify(['ssm:GetParameter', 'ssm:GetParameters']), + ); + const ssmPaths = connectionStringMySql.dbConnectionConfig.connectionUriSsmPath; + expect(ssmStatements.length).toEqual(1); + expect(ssmStatements[0].Resource).toEqual(ssmPaths.map((ssmPath) => `arn:aws:ssm:*:*:parameter${ssmPath}`)); + + // Check that secrets manager permissions are not assigned + const secretsManagerStatements = PolicyDocument.Statement.filter( + (statement: any) => statement.Action === 'secretsmanager:GetSecretValue', + ); + expect(secretsManagerStatements.length).toEqual(0); + + const kmsStatements = PolicyDocument.Statement.filter((statement: any) => statement.Action === 'kms:Decrypt'); + expect(kmsStatements.length).toEqual(0); + + // Check that cloudwatch permissions are assigned + const cloudWatchStatements = PolicyDocument.Statement.filter( + (statement: any) => + JSON.stringify(statement.Action) === JSON.stringify(['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents']), + ); + expect(cloudWatchStatements.length).toEqual(1); + expect(cloudWatchStatements[0].Resource).toEqual('arn:aws:logs:*:*:*'); + + expect(PolicyDocument).toMatchSnapshot(); + + // Check that the connection uri is passed to the lambda as an environment variable + const [, sqlLambda] = + Object.entries(sqlApiStack.Resources!).find(([resourceName]) => { + return resourceName.startsWith(resourceNames.sqlLambdaFunction); + }) || []; + expect(sqlLambda).toBeDefined(); + const envVars = sqlLambda.Properties.Environment.Variables; + expect(envVars).toBeDefined(); + // The connection string information that is set in the lambda environment should be a valid JSON. + expect(envVars.connectionString).toBeDefined(); + expect(JSON.parse(envVars.connectionString)).toEqual(ssmPaths); + expect(envVars.CREDENTIAL_STORAGE_METHOD).toEqual('SSM'); + expect(envVars.SSM_ENDPOINT).toBeDefined(); + }); }); diff --git a/packages/amplify-graphql-model-transformer/src/resolvers/rds/resolver.ts b/packages/amplify-graphql-model-transformer/src/resolvers/rds/resolver.ts index 94e0cbb6b9..7d1208f623 100644 --- a/packages/amplify-graphql-model-transformer/src/resolvers/rds/resolver.ts +++ b/packages/amplify-graphql-model-transformer/src/resolvers/rds/resolver.ts @@ -373,11 +373,14 @@ export const createRdsLambdaRole = ( ); } } else if (isSqlModelDataSourceSsmDbConnectionStringConfig(secretEntry)) { + const connectionUriSsmPaths = Array.isArray(secretEntry.connectionUriSsmPath) + ? secretEntry.connectionUriSsmPath + : [secretEntry.connectionUriSsmPath]; policyStatements.push( new PolicyStatement({ actions: ['ssm:GetParameter', 'ssm:GetParameters'], effect: Effect.ALLOW, - resources: [`arn:aws:ssm:*:*:parameter${secretEntry.connectionUriSsmPath}`], + resources: connectionUriSsmPaths.map((ssmPath) => `arn:aws:ssm:*:*:parameter${ssmPath}`), }), ); } else { diff --git a/packages/amplify-graphql-model-transformer/src/resources/rds-model-resource-generator.ts b/packages/amplify-graphql-model-transformer/src/resources/rds-model-resource-generator.ts index ff536e9322..6b667c4ed0 100644 --- a/packages/amplify-graphql-model-transformer/src/resources/rds-model-resource-generator.ts +++ b/packages/amplify-graphql-model-transformer/src/resources/rds-model-resource-generator.ts @@ -136,7 +136,7 @@ export class RdsModelResourceGenerator extends ModelResourceGenerator { credentialStorageMethod = CredentialStorageMethod.SECRETS_MANAGER; } else if (isSqlModelDataSourceSsmDbConnectionStringConfig(secretEntry)) { environment.CREDENTIAL_STORAGE_METHOD = 'SSM'; - environment.connectionString = secretEntry.connectionUriSsmPath; + environment.connectionString = JSON.stringify(secretEntry.connectionUriSsmPath); credentialStorageMethod = CredentialStorageMethod.SSM; } diff --git a/packages/amplify-graphql-transformer-interfaces/API.md b/packages/amplify-graphql-transformer-interfaces/API.md index f1d8cdc6c8..03d59892b3 100644 --- a/packages/amplify-graphql-transformer-interfaces/API.md +++ b/packages/amplify-graphql-transformer-interfaces/API.md @@ -434,7 +434,7 @@ export interface SqlModelDataSourceSsmDbConnectionConfig { // @public (undocumented) export interface SqlModelDataSourceSsmDbConnectionStringConfig { // (undocumented) - readonly connectionUriSsmPath: string; + readonly connectionUriSsmPath: string | string[]; } // @public (undocumented) diff --git a/packages/amplify-graphql-transformer-interfaces/src/model-datasource/types.ts b/packages/amplify-graphql-transformer-interfaces/src/model-datasource/types.ts index 847c19631f..970a28c5dd 100644 --- a/packages/amplify-graphql-transformer-interfaces/src/model-datasource/types.ts +++ b/packages/amplify-graphql-transformer-interfaces/src/model-datasource/types.ts @@ -130,7 +130,7 @@ export type SqlModelDataSourceDbConnectionConfig = */ export interface SqlModelDataSourceSsmDbConnectionStringConfig { /** The SSM Path to the secure connection string used for connecting to the database. **/ - readonly connectionUriSsmPath: string; + readonly connectionUriSsmPath: string | string[]; } /* @@ -304,5 +304,8 @@ export const isSqlModelDataSourceSecretsManagerDbConnectionConfig = ( * @returns true if the object is shaped like a SqlModelDataSourceSsmDbConnectionStringConfig */ export const isSqlModelDataSourceSsmDbConnectionStringConfig = (obj: any): obj is SqlModelDataSourceSsmDbConnectionStringConfig => { - return (typeof obj === 'object' || typeof obj === 'function') && typeof obj.connectionUriSsmPath === 'string'; + return ( + (typeof obj === 'object' || typeof obj === 'function') && + (typeof obj.connectionUriSsmPath === 'string' || Array.isArray(obj.connectionUriSsmPath)) + ); };