Skip to content
Merged
91 changes: 55 additions & 36 deletions packages/amplify-graphql-api-construct/.jsii

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/amplify-graphql-api-construct/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ export interface SQLLambdaModelDataSourceStrategy {
readonly customSqlStatements?: Record<string, string>;
readonly dbConnectionConfig: SqlModelDataSourceDbConnectionConfig;
readonly dbType: ModelDataSourceStrategySqlDbType;
readonly minimizeRdsVpcEndpoints?: boolean;
readonly name: string;
readonly sqlLambdaProvisionedConcurrencyConfig?: ProvisionedConcurrencyConfig;
readonly vpcConfiguration?: VpcConfig;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import { CfnFunction, CfnAlias } from 'aws-cdk-lib/aws-lambda';
import { CfnVPCEndpoint } from 'aws-cdk-lib/aws-ec2';
import { mockSqlDataSourceStrategy } from '@aws-amplify/graphql-transformer-test-utils';
import { getResourceNamesForStrategy } from '@aws-amplify/graphql-transformer-core';
import { AmplifyGraphqlApi } from '../../amplify-graphql-api';
Expand Down Expand Up @@ -101,6 +102,46 @@ describe('sql-bound API generated resource access', () => {
expect(endpoints.length).toBe(5);
});

it('ssm as credential store with minimizeRdsVpcEndpoints enabled', () => {
const strategy = mockSqlDataSourceStrategy({
vpcConfiguration: {
vpcId: 'vpc-123abc',
securityGroupIds: ['sg-123abc'],
subnetAvailabilityZoneConfig: [{ subnetId: 'subnet-123abc', availabilityZone: 'us-east-1a' }],
},
minimizeRdsVpcEndpoints: true,
});

const stack = new cdk.Stack();
const userPool = cognito.UserPool.fromUserPoolId(stack, 'ImportedUserPool', 'ImportedUserPoolId');
const api = new AmplifyGraphqlApi(stack, 'TestSqlBoundApi', {
definition: AmplifyGraphqlDefinition.fromString(defaultSchema, strategy),
authorizationModes: {
userPoolConfig: { userPool },
},
});

const {
resources: {
cfnResources: { additionalCfnResources },
},
} = api;

expect(additionalCfnResources).toBeDefined();
const endpoints = Object.values(additionalCfnResources).filter(
(resource) => resource.cfnResourceType === 'AWS::EC2::VPCEndpoint',
);

// With minimizeRdsVpcEndpoints enabled, only the `ssm` endpoint is provisioned.
expect(endpoints.length).toBe(1);
const [ssmEndpoint] = endpoints as CfnVPCEndpoint[];
const resolvedServiceName = JSON.stringify(cdk.Stack.of(ssmEndpoint).resolve(ssmEndpoint.serviceName));
expect(resolvedServiceName).toContain('ssm');
expect(resolvedServiceName).not.toContain('ssmmessages');
expect(resolvedServiceName).not.toContain('ec2');
expect(resolvedServiceName).not.toContain('kms');
});

it('secrets manager as credentials store', () => {
const strategy = mockSqlDataSourceStrategy({
dbConnectionConfig: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ export interface SQLLambdaModelDataSourceStrategy {
*/
readonly vpcConfiguration?: VpcConfig;

/**
* Opt-in optimization that minimizes the interface VPC endpoints provisioned for this SQL data source's Lambda. When enabled, only the
* `ssm` interface VPC endpoint - the sole endpoint the SQL Lambda consumes at runtime to read the database connection secret - is
* provisioned. When disabled (the default), the full set of endpoints (`ssm`, `ssmmessages`, `ec2`, `ec2messages`, `kms`) is provisioned,
* preserving the existing behavior. This setting only takes effect when `vpcConfiguration` is set; it has no effect for SQL data sources
* that are not installed into a VPC.
* @default false
*/
readonly minimizeRdsVpcEndpoints?: boolean;

/**
* Custom SQL statements. The key is the value of the `references` attribute of the `@sql` directive in the `schema`; the value is the SQL
* to be executed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,58 @@ describe('ModelTransformer with SQL data sources:', () => {
expect(envVars.SSL_CERT_SSM_PATH).toBeDefined();
expect(envVars.SSL_CERT_SSM_PATH).toEqual('["/test/sslCert/1","/test/sslCert/2"]');
});

describe('VPC endpoint minimization (minimizeRdsVpcEndpoints)', () => {
const mysqlVpcStrategy: SQLLambdaModelDataSourceStrategy = {
...mysqlStrategy,
name: 'mysqlVpcStrategy',
vpcConfiguration: {
vpcId: 'vpc-123abc',
securityGroupIds: ['sg-123abc'],
subnetAvailabilityZoneConfig: [{ subnetId: 'subnet-123abc', availabilityZone: 'us-east-1a' }],
},
};

const getVpcEndpointResources = (out: ReturnType<typeof testTransform>): any[] => {
const resourceNames = getResourceNamesForStrategy(mysqlVpcStrategy);
const sqlApiStack = out.stacks[resourceNames.sqlStack];
expect(sqlApiStack).toBeDefined();
return Object.values(sqlApiStack.Resources!).filter((resource: any) => resource.Type === 'AWS::EC2::VPCEndpoint');
};

it('provisions all five VPC endpoints by default (minimizeRdsVpcEndpoints defaults to false)', () => {
const out = testTransform({
schema: validSchema,
transformers: [new ModelTransformer(), new PrimaryKeyTransformer()],
dataSourceStrategies: constructDataSourceStrategies(validSchema, mysqlVpcStrategy),
});
expect(out).toBeDefined();
const endpoints = getVpcEndpointResources(out);
expect(endpoints.length).toEqual(5);
const serviceNames = JSON.stringify(endpoints.map((resource: any) => resource.Properties.ServiceName));
['ssm', 'ssmmessages', 'ec2', 'ec2messages', 'kms'].forEach((service) => {
expect(serviceNames).toContain(service);
});
});

it('provisions only the ssm VPC endpoint when minimizeRdsVpcEndpoints is enabled', () => {
const minimizedVpcStrategy: SQLLambdaModelDataSourceStrategy = {
...mysqlVpcStrategy,
minimizeRdsVpcEndpoints: true,
};
const out = testTransform({
schema: validSchema,
transformers: [new ModelTransformer(), new PrimaryKeyTransformer()],
dataSourceStrategies: constructDataSourceStrategies(validSchema, minimizedVpcStrategy),
});
expect(out).toBeDefined();
const endpoints = getVpcEndpointResources(out);
expect(endpoints.length).toEqual(1);
const serviceName = JSON.stringify(endpoints[0].Properties.ServiceName);
expect(serviceName).toContain('ssm');
expect(serviceName).not.toContain('ssmmessages');
expect(serviceName).not.toContain('ec2');
expect(serviceName).not.toContain('kms');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,26 @@ export const setRDSSNSTopicMappings = (scope: Construct, mapping: RDSSNSTopicMap
* Returns an SSM Endpoint if needed by the current configuration, undefined otherwise. If the current configuration includes a VPC, the SSM
* endpoint will be a VPC service endpoint, otherwise it will be the standard regionalized endpoint for the service. SSM Endpoints are
* required if the DB connection information is stored in SSM, or if the configuration uses a custom SSL certificate.
* @param scope Construct
* @param resourceNames the SQL Lambda resource names
* @param sqlLambdaVpcConfig the VPC configuration for the SQL Lambda, if any
* @param minimizeRdsVpcEndpoints when true, only the `ssm` interface VPC endpoint is provisioned. Defaults to false, which provisions the
* full set (`ssm`, `ssmmessages`, `ec2`, `ec2messages`, `kms`) for backward compatibility.
*/
export const getSsmEndpoint = (scope: Construct, resourceNames: SQLLambdaResourceNames, sqlLambdaVpcConfig?: VpcConfig): string => {
export const getSsmEndpoint = (
scope: Construct,
resourceNames: SQLLambdaResourceNames,
sqlLambdaVpcConfig?: VpcConfig,
minimizeRdsVpcEndpoints = false,
): string => {
if (!sqlLambdaVpcConfig) {
// Default, non-VPC SSM endpoint
return Fn.join('', ['ssm.', Fn.ref('AWS::Region'), '.amazonaws.com']);
}

// Although the Lambda function will only invoke SSM directly, internally the SDK makes calls to other services as well
const services = ['ssm', 'ssmmessages', 'ec2', 'ec2messages', 'kms'];
// Only the `ssm` endpoint is consumed at runtime to read the DB connection secret from Parameter Store. The full set is provisioned by
// default to preserve existing stacks; opting in to `minimizeRdsVpcEndpoints` provisions only the `ssm` endpoint.
const services = minimizeRdsVpcEndpoints ? ['ssm'] : ['ssm', 'ssmmessages', 'ec2', 'ec2messages', 'kms'];
const endpoints = addVpcEndpoints(scope, sqlLambdaVpcConfig, resourceNames, services);
const endpointEntries = endpoints.find((endpoint) => endpoint.service === 'ssm')?.endpoint.attrDnsEntries;
if (!endpointEntries) {
Expand All @@ -115,6 +126,8 @@ export const getSsmEndpoint = (scope: Construct, resourceNames: SQLLambdaResourc
* @param scope Construct
* @param apiGraphql GraphQLAPIProvider
* @param lambdaRole IRole
* @param minimizeRdsVpcEndpoints when true, only the `ssm` interface VPC endpoint is provisioned for the SQL Lambda's VPC. Defaults to
* false, which provisions the full set for backward compatibility.
*/
export const createRdsLambda = (
scope: Construct,
Expand All @@ -126,6 +139,7 @@ export const createRdsLambda = (
environment?: { [key: string]: string },
sqlLambdaVpcConfig?: VpcConfig,
sqlLambdaProvisionedConcurrencyConfig?: ProvisionedConcurrencyConfig,
minimizeRdsVpcEndpoints = false,
): IFunction => {
const lambdaEnvironment = {
...environment,
Expand All @@ -134,7 +148,7 @@ export const createRdsLambda = (
if (credentialStorageMethod === CredentialStorageMethod.SSM) {
lambdaEnvironment.CREDENTIAL_STORAGE_METHOD = CredentialStorageMethod.SSM;
if (!lambdaEnvironment.SSM_ENDPOINT) {
lambdaEnvironment.SSM_ENDPOINT = getSsmEndpoint(scope, resourceNames, sqlLambdaVpcConfig);
lambdaEnvironment.SSM_ENDPOINT = getSsmEndpoint(scope, resourceNames, sqlLambdaVpcConfig, minimizeRdsVpcEndpoints);
}
} else if (credentialStorageMethod === CredentialStorageMethod.SECRETS_MANAGER) {
// Default Secrets Manager endpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export class RdsModelResourceGenerator extends ModelResourceGenerator {
// cert, even if the rest of the DB configuration is stored in Secrets Manager.
if (sslCertSsmPath) {
environment.SSL_CERT_SSM_PATH = JSON.stringify(sslCertSsmPath);
environment.SSM_ENDPOINT = getSsmEndpoint(lambdaScope, resourceNames, strategy.vpcConfiguration);
environment.SSM_ENDPOINT = getSsmEndpoint(lambdaScope, resourceNames, strategy.vpcConfiguration, strategy.minimizeRdsVpcEndpoints);
}

const lambda = createRdsLambda(
Expand All @@ -167,6 +167,7 @@ export class RdsModelResourceGenerator extends ModelResourceGenerator {
environment,
strategy.vpcConfiguration,
strategy.sqlLambdaProvisionedConcurrencyConfig,
strategy.minimizeRdsVpcEndpoints,
);

// Note that this tag will be added to either the bare function, or the alias created to handle provisioned concurrency
Expand Down
2 changes: 2 additions & 0 deletions packages/amplify-graphql-transformer-interfaces/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ export interface SQLLambdaModelDataSourceStrategy extends ModelDataSourceStrateg
// (undocumented)
readonly dbType: ModelDataSourceStrategySqlDbType;
// (undocumented)
readonly minimizeRdsVpcEndpoints?: boolean;
// (undocumented)
readonly name: string;
// (undocumented)
readonly sqlLambdaProvisionedConcurrencyConfig?: ProvisionedConcurrencyConfig;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ export interface SQLLambdaModelDataSourceStrategy extends ModelDataSourceStrateg
*/
readonly vpcConfiguration?: VpcConfig;

/**
* Opt-in optimization that minimizes the interface VPC endpoints provisioned for this SQL data source's Lambda. When enabled, only the
* `ssm` interface VPC endpoint - the sole endpoint the SQL Lambda consumes at runtime to read the database connection secret - is
* provisioned. When disabled (the default), the full set of endpoints (`ssm`, `ssmmessages`, `ec2`, `ec2messages`, `kms`) is provisioned,
* preserving the existing behavior. This setting only takes effect when `vpcConfiguration` is set; it has no effect for SQL data sources
* that are not installed into a VPC.
* @default false
*/
readonly minimizeRdsVpcEndpoints?: boolean;

/**
* The configuration for the provisioned concurrency of the Lambda.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/amplify-graphql-transformer-test-utils/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface MakeSqlDataSourceStrategyOptions {
// (undocumented)
dbType?: ModelDataSourceStrategySqlDbType;
// (undocumented)
minimizeRdsVpcEndpoints?: boolean;
// (undocumented)
name?: string;
// (undocumented)
sqlLambdaProvisionedConcurrencyConfig?: ProvisionedConcurrencyConfig;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface MakeSqlDataSourceStrategyOptions {
dbType?: ModelDataSourceStrategySqlDbType;
dbConnectionConfig?: SqlModelDataSourceDbConnectionConfig;
vpcConfiguration?: VpcConfig;
minimizeRdsVpcEndpoints?: boolean;
sqlLambdaProvisionedConcurrencyConfig?: ProvisionedConcurrencyConfig;
// Note: this is only supported in the CDK Construct flavor of the SQLLambdaModelDataSourceStrategy
customSqlStatements?: Record<string, string>;
Expand Down Expand Up @@ -39,6 +40,7 @@ export const MOCK_DB_CONNECTION_CONFIG: SqlModelDataSourceDbConnectionConfig = {
* - name: `${dbType}MockStrategy`
* - dbConnectionConfig: MOCK_DB_CONNECTION_CONFIG
* - vpcConfiguration: undefined
* - minimizeRdsVpcEndpoints: undefined
* - sqlLambdaProvisionedConcurrencyConfig: undefined
* - customSqlStatements: undefined
*/
Expand All @@ -51,13 +53,15 @@ export const mockSqlDataSourceStrategy = (
const name = options?.name ?? `${dbType}MockStrategy`;
const dbConnectionConfig = options?.dbConnectionConfig ?? MOCK_DB_CONNECTION_CONFIG;
const vpcConfiguration = options?.vpcConfiguration;
const minimizeRdsVpcEndpoints = options?.minimizeRdsVpcEndpoints;
const sqlLambdaProvisionedConcurrencyConfig = options?.sqlLambdaProvisionedConcurrencyConfig;
const customSqlStatements = options?.customSqlStatements;
return {
name,
dbType,
dbConnectionConfig,
vpcConfiguration,
minimizeRdsVpcEndpoints,
sqlLambdaProvisionedConcurrencyConfig,
customSqlStatements,
};
Expand Down
Loading