packages/constructs/L3/utility/m2m-api-l3-construct/lib/m2m-api-l3-construct.ts (523 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { MdaaLogGroup, MdaaLogGroupProps } from '@aws-mdaa/cloudwatch-constructs';
import { MdaaParamAndOutput } from '@aws-mdaa/construct'; //NOSONAR
import { MdaaManagedPolicy, MdaaRole } from '@aws-mdaa/iam-constructs';
import { MdaaResolvableRole, MdaaRoleRef } from '@aws-mdaa/iam-role-helper';
import { MdaaKmsKey, DECRYPT_ACTIONS, ENCRYPT_ACTIONS } from '@aws-mdaa/kms-constructs';
import { MdaaL3Construct, MdaaL3ConstructProps } from '@aws-mdaa/l3-construct';
import { MdaaLambdaFunction, MdaaLambdaRole } from '@aws-mdaa/lambda-constructs';
import { Duration } from 'aws-cdk-lib';
import {
AccessLogFormat,
AuthorizationType,
CfnAccount,
CognitoUserPoolsAuthorizer,
LambdaIntegration,
LogGroupLogDestination,
MethodLoggingLevel,
RestApi,
} from 'aws-cdk-lib/aws-apigateway';
import {
AccountRecovery,
CfnUserPool,
IUserPool,
OAuthScope,
ResourceServerScope,
UserPool,
UserPoolOperation,
} from 'aws-cdk-lib/aws-cognito';
import { AnyPrincipal, Effect, PolicyDocument, PolicyStatement, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { IKey, Key } from 'aws-cdk-lib/aws-kms';
import { Code, Runtime } from 'aws-cdk-lib/aws-lambda';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import {
CfnIPSet,
CfnLoggingConfiguration,
CfnWebACL,
CfnWebACLAssociation,
CfnWebACLProps,
} from 'aws-cdk-lib/aws-wafv2';
import { MdaaNagSuppressions } from '@aws-mdaa/construct'; //NOSONAR
import { Construct } from 'constructs';
export interface M2MApiProps {
/**
* Roles which will be provided Admin access to the
* KMS key, and KeyPair secrets.
*/
readonly adminRoles: MdaaRoleRef[];
/**
* API stage name. Defaults to 'prod'
*/
readonly stageName?: string;
/**
* Required. Identifies the target bucket
*/
readonly targetBucketName: string;
/**
* Required. Identifies the target prefix within the bucket
*/
readonly targetPrefix: string;
/**
* Identifies the target prefix for metadata within the bucket.
* If not specified, will default to targetPrefix.
*/
readonly metadataTargetPrefix?: string;
/**
* List in CIDR ranges which will be permitted to connect to the API resource policy
*/
readonly allowedCidrs: string[];
/**
* Concurrency limits to be placed on API Lambda functions. This will essentially limit the number of concurrent
* API requests.
*/
readonly concurrencyLimit: number;
/**
* Arns of WAF to be applied to API.
*/
readonly wafArns?: { [wafname: string]: string };
/**
* Specific key to use to encrypt CloudWatch logs. If not specifed, one will be created.
*/
readonly kmsKeyArn?: string;
/**
* If true (default false), the API Gateway Cloudwatch role will be set at the account/region level.
* This should be done only once per account/region.
*/
readonly setAccountCloudWatchRole?: boolean;
/**
* If specified, the integration Lambda function will run as this role.
* If not specified, one will be generated
*/
readonly integrationLambdaRoleArn?: string;
/**
* List of Cognito app clients to be created.
*/
readonly appClients?: NamedAppClientProps;
/**
* Map of accepted request parameter names to boolean indicating if they are required.
* If specified, API gateway will validate that: 1) each provided parameter is accepted;
* and 2) all required parameters have been provided.
*/
readonly requestParameters?: { [paramName: string]: boolean };
/**
* Specified fields will be mapped from the request into the metadata
* persisted in S3 for each upload request. The key is the destination
* key in the metadata, and the value is the event source key in dot notation
* such as "requestContext.requestTime".
*/
readonly eventMetadataMappings?: { [dest: string]: string };
}
export interface NamedAppClientProps {
/** @jsii ignore */
readonly [name: string]: AppClientProps;
}
export interface AppClientProps {
/**
* The validity period of the ID Token in minutes (default 60 minutes).
* Valid values are between 5 minutes and 1 day
*/
readonly idTokenValidityMinutes?: number;
/**
* The validity period of the Refresh Token in hours (default 30 days).
* Valid values between 60 minutes and 10 years
*/
readonly refreshTokenValidityHours?: number;
/**
* The validity period of the access token (default 60 minutes).
* Valid values are between 5 minutes and 1 day
*/
readonly accessTokenValidityMinutes?: number;
}
export interface M2MApiL3ConstructProps extends MdaaL3ConstructProps {
/**
* The Ingestion App definition.
*/
readonly m2mApiProps: M2MApiProps;
}
export class M2MApiL3Construct extends MdaaL3Construct {
protected readonly props: M2MApiL3ConstructProps;
private readonly adminRoles: MdaaResolvableRole[];
private static readonly identifier: string = 'm2m-api';
constructor(scope: Construct, id: string, props: M2MApiL3ConstructProps) {
super(scope, id, props);
this.props = props;
this.adminRoles = props.roleHelper.resolveRoleRefsWithOrdinals(props.m2mApiProps.adminRoles, 'admin');
const kmsKey = props.m2mApiProps.kmsKeyArn
? Key.fromKeyArn(this, 'kms-key', props.m2mApiProps.kmsKeyArn)
: this.createKmsKey();
const apiScope = new ResourceServerScope({ scopeName: 'm2m-custom', scopeDescription: 'Generate URL Access' });
const cognitoPool = this.setupCognitoM2M(apiScope);
this.createAPI(cognitoPool, apiScope, kmsKey);
}
private createKmsKey(): IKey {
const kmsKey = new MdaaKmsKey(this, 'kms-key', {
naming: this.props.naming,
keyAdminRoleIds: this.adminRoles.map(x => x.id()),
keyUserRoleIds: this.adminRoles.map(x => x.id()),
});
const cloudwatchStatement = new PolicyStatement({
sid: 'CloudWatchLogsEncryption',
effect: Effect.ALLOW,
actions: [...DECRYPT_ACTIONS, ...ENCRYPT_ACTIONS],
principals: [new ServicePrincipal(`logs.${this.region}.amazonaws.com`)],
resources: ['*'],
//Limit access to use this key only for log groups within this account
conditions: {
ArnEquals: {
'kms:EncryptionContext:aws:logs:arn': `arn:${this.partition}:logs:${this.region}:${this.account}:log-group:*`,
},
},
});
kmsKey.addToResourcePolicy(cloudwatchStatement);
return kmsKey;
}
private setupCognitoM2M(apiScope: ResourceServerScope): UserPool {
const userPool = new UserPool(this, 'user-pool', {
enableSmsRole: false,
userPoolName: this.props.naming.resourceName(),
selfSignUpEnabled: false,
accountRecovery: AccountRecovery.NONE,
});
MdaaNagSuppressions.addCodeResourceSuppressions(
userPool,
[
{
id: 'AwsSolutions-COG1',
reason: 'User Pool used only for app integration, and will not contain users or passwords.',
},
{ id: 'AwsSolutions-COG2', reason: 'User Pool used only for app integration, and will not contain users.' },
],
true,
);
(userPool.node.defaultChild as CfnUserPool).userPoolAddOns = {
advancedSecurityMode: 'ENFORCED',
};
const domainName = userPool.addDomain('DomainName', {
cognitoDomain: {
domainPrefix: this.props.naming.resourceName(undefined, 64),
},
});
const resourceServer = userPool.addResourceServer('resource-server', {
userPoolResourceServerName: this.props.naming.resourceName(undefined, 64),
identifier: M2MApiL3Construct.identifier,
scopes: [apiScope],
});
const oauthScope = OAuthScope.resourceServer(resourceServer, apiScope);
Object.entries(this.props.m2mApiProps.appClients || {}).forEach(appClientEntry => {
const appClientName = appClientEntry[0];
const appClientProps = appClientEntry[1];
userPool.addClient(`oauth-client-${appClientName}`, {
userPoolClientName: this.props.naming.resourceName(appClientName, 64),
idTokenValidity: appClientProps.idTokenValidityMinutes
? Duration.minutes(appClientProps.idTokenValidityMinutes)
: undefined,
accessTokenValidity: appClientProps.accessTokenValidityMinutes
? Duration.minutes(appClientProps.accessTokenValidityMinutes)
: undefined,
refreshTokenValidity: appClientProps.refreshTokenValidityHours
? Duration.hours(appClientProps.refreshTokenValidityHours)
: undefined,
authFlows: {
userPassword: false,
userSrp: false,
custom: true,
},
oAuth: {
flows: {
authorizationCodeGrant: false,
implicitCodeGrant: false,
clientCredentials: true,
},
scopes: [oauthScope],
},
preventUserExistenceErrors: true,
generateSecret: true,
enableTokenRevocation: true,
});
});
const cognitoAuthLogFunctionRole = new MdaaLambdaRole(this, 'cognito-auth-lambda-role', {
description: 'Lambda Role for Cognito Auth Logger function',
roleName: 'cognito-auth',
naming: this.props.naming,
logGroupNames: [this.props.naming.resourceName('log-auth-event')],
createParams: false,
createOutputs: false,
});
const postAuthLogFn = new MdaaLambdaFunction(this, 'postAuthLogFn', {
runtime: Runtime.NODEJS_22_X,
handler: 'index.handler',
functionName: 'log-auth-event',
role: cognitoAuthLogFunctionRole,
naming: this.props.naming,
code: Code.fromInline(`
const handler = async function(event) {
console.log("Authentication successful");
console.log("Trigger function =", event.triggerSource);
console.log("User pool = ", event.userPoolId);
console.log("App client ID = ", event.callerContext.clientId);
console.log("User ID = ", event.userName);
return event;
};
exports.handler = handler;
`),
});
MdaaNagSuppressions.addCodeResourceSuppressions(
postAuthLogFn,
[
{ id: 'NIST.800.53.R5-LambdaDLQ', reason: 'Function only logs to stdout. DLQ is not required.' },
{
id: 'NIST.800.53.R5-LambdaInsideVPC',
reason:
'Function is logging Cognito events directly to CloudWatch via stdout and is not VPC bound by design.',
},
{
id: 'NIST.800.53.R5-LambdaConcurrency',
reason: 'Function is logging successful authentication requests. Concurrency is unbounded by design.',
},
{ id: 'HIPAA.Security-LambdaDLQ', reason: 'Function only logs to stdout. DLQ is not required.' },
{ id: 'PCI.DSS.321-LambdaDLQ', reason: 'Function only logs to stdout. DLQ is not required.' },
{
id: 'HIPAA.Security-LambdaInsideVPC',
reason:
'Function is logging Cognito events directly to CloudWatch via stdout and is not VPC bound by design.',
},
{
id: 'PCI.DSS.321-LambdaInsideVPC',
reason:
'Function is logging Cognito events directly to CloudWatch via stdout and is not VPC bound by design.',
},
{
id: 'HIPAA.Security-LambdaConcurrency',
reason: 'Function is logging successful authentication requests. Concurrency is unbounded by design.',
},
{
id: 'PCI.DSS.321-LambdaConcurrency',
reason: 'Function is logging successful authentication requests. Concurrency is unbounded by design.',
},
],
true,
);
userPool.addTrigger(UserPoolOperation.POST_AUTHENTICATION, postAuthLogFn);
new MdaaParamAndOutput(this, {
...{
resourceType: 'cognito-userpool-id',
resourceId: 'm2m-cognito-userpool-id',
name: 'm2m-userpool-id',
value: userPool.userPoolProviderName,
},
naming: this.props.naming,
});
new MdaaParamAndOutput(this, {
...{
resourceType: 'cognito-userpool-domain-name',
resourceId: 'cognito-userpool-domain-name-id',
name: 'm2m-userpool-domain-id',
value: `https://${domainName.domainName}.auth.${this.region}.amazoncognito.com`,
},
naming: this.props.naming,
});
return userPool;
}
private createAPI(m2mUserPool: IUserPool, apiScope: ResourceServerScope, kmsKey: IKey): void {
const stageName = this.props.m2mApiProps.stageName || 'prod';
const integrationLambdaRole = this.props.m2mApiProps.integrationLambdaRoleArn
? MdaaLambdaRole.fromRoleArn(this, 'imported-integration-role', this.props.m2mApiProps.integrationLambdaRoleArn)
: new MdaaLambdaRole(this, 'url-gen-lambda-role', {
description: 'Lambda Role for presigned S3 URL generation Logger function',
roleName: 'url-gen-lambda-role',
naming: this.props.naming,
logGroupNames: [this.props.naming.resourceName('signed-s3-url-gen')],
createParams: false,
createOutputs: false,
});
// creates lambda function to generate presigned URL
const s3UrlGenLambda = new MdaaLambdaFunction(this, 's3-url-gen-lambda', {
runtime: Runtime.PYTHON_3_13,
handler: 's3_url.handler',
functionName: 'signed-s3-url-gen',
role: integrationLambdaRole,
naming: this.props.naming,
code: Code.fromAsset(`${__dirname}/../src/lambda/s3_url`),
environment: {
EXPIRY_TIME_SECONDS: '600',
TARGET_BUCKET: this.props.m2mApiProps.targetBucketName,
TARGET_PREFIX: this.props.m2mApiProps.targetPrefix,
METADATA_TARGET_PREFIX: this.props.m2mApiProps.metadataTargetPrefix || this.props.m2mApiProps.targetPrefix,
EVENT_METADATA_MAPPINGS: JSON.stringify(this.props.m2mApiProps.eventMetadataMappings || {}),
LOG_LEVEL: 'INFO',
},
reservedConcurrentExecutions: this.props.m2mApiProps.concurrencyLimit,
});
MdaaNagSuppressions.addCodeResourceSuppressions(
s3UrlGenLambda,
[
{
id: 'NIST.800.53.R5-LambdaDLQ',
reason:
'Function is API implementation and will be invoked syncronously. Error handling is handled by API spec. DLQ not required.',
},
{ id: 'NIST.800.53.R5-LambdaInsideVPC', reason: 'Function is API implementation behind API gateway.' },
{
id: 'HIPAA.Security-LambdaDLQ',
reason:
'Function is API implementation and will be invoked syncronously. Error handling is handled by API spec. DLQ not required.',
},
{
id: 'PCI.DSS.321-LambdaDLQ',
reason:
'Function is API implementation and will be invoked syncronously. Error handling is handled by API spec. DLQ not required.',
},
{ id: 'HIPAA.Security-LambdaInsideVPC', reason: 'Function is API implementation behind API gateway.' },
{ id: 'PCI.DSS.321-LambdaInsideVPC', reason: 'Function is API implementation behind API gateway.' },
],
true,
);
//create API and components
const apiResourcePolicy = new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['execute-api:Invoke'],
principals: [new AnyPrincipal()],
resources: [`execute-api:/${stageName}/GET/upload*`],
}),
new PolicyStatement({
effect: Effect.DENY,
principals: [new AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: [`execute-api:/${stageName}/GET/upload*`],
conditions: {
NotIpAddress: {
'aws:SourceIp': this.props.m2mApiProps.allowedCidrs,
},
},
}),
],
});
const accessLogGroupProps: MdaaLogGroupProps = {
logGroupName: 'access-logs',
encryptionKey: kmsKey,
logGroupNamePathPrefix: '',
retention: RetentionDays.INFINITE,
naming: this.props.naming,
};
const accessLogGroup = new MdaaLogGroup(this, 'access-log-group', accessLogGroupProps);
const restApi = new RestApi(this, 'rest-api', {
restApiName: this.props.naming.resourceName(undefined, 128),
description: 'REST API to endpoint to proxy an S3 Signed URL generation Lambda',
policy: apiResourcePolicy,
cloudWatchRole: false, //Will be created below
deployOptions: {
stageName: stageName,
accessLogDestination: new LogGroupLogDestination(accessLogGroup),
accessLogFormat: AccessLogFormat.jsonWithStandardFields(),
tracingEnabled: true,
methodOptions: {
'/*/*': {
loggingLevel: MethodLoggingLevel.INFO,
cachingEnabled: false,
cacheDataEncrypted: false,
},
},
},
});
MdaaNagSuppressions.addCodeResourceSuppressions(
restApi,
[
{
id: 'NIST.800.53.R5-APIGWSSLEnabled',
reason: 'Integrations/backend are Lambda functions. Backend client certificate not required.',
},
{
id: 'HIPAA.Security-APIGWSSLEnabled',
reason: 'Integrations/backend are Lambda functions. Backend client certificate not required.',
},
{
id: 'PCI.DSS.321-APIGWSSLEnabled',
reason: 'Integrations/backend are Lambda functions. Backend client certificate not required.',
},
{ id: 'NIST.800.53.R5-APIGWCacheEnabledAndEncrypted', reason: 'Caching intentionally disabled.' },
{ id: 'HIPAA.Security-APIGWCacheEnabledAndEncrypted', reason: 'Caching intentionally disabled.' },
{ id: 'PCI.DSS.321-APIGWCacheEnabledAndEncrypted', reason: 'Caching intentionally disabled.' },
],
true,
);
if (this.props.m2mApiProps.setAccountCloudWatchRole ?? false) {
const cloudwatchRole = new MdaaRole(this, 'cloudwatch-role', {
roleName: 'cloudwatch',
naming: this.props.naming,
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
cloudwatchRole.addManagedPolicy(
MdaaManagedPolicy.fromAwsManagedPolicyNameWithPartition(
this,
'service-role/AmazonAPIGatewayPushToCloudWatchLogs',
),
);
MdaaNagSuppressions.addCodeResourceSuppressions(
cloudwatchRole,
[
{
id: 'AwsSolutions-IAM4',
reason:
'AmazonAPIGatewayPushToCloudWatchLogs provides the minimum required permissions for API Gateway logging to Cloudwatch: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html',
},
],
true,
);
const account = new CfnAccount(this, 'api-gw-account', {
cloudWatchRoleArn: cloudwatchRole.roleArn,
});
restApi.node.addDependency(account);
}
const ipAllowSet = new CfnIPSet(this, 'ip-allow-set', {
addresses: this.props.m2mApiProps.allowedCidrs,
ipAddressVersion: 'IPV4',
scope: 'REGIONAL',
name: this.props.naming.resourceName('ip-allow-set', 255),
});
const ipAllowRuleProps: CfnWebACL.RuleProperty = {
name: 'ipAllow',
priority: 0,
visibilityConfig: {
cloudWatchMetricsEnabled: false,
metricName: this.props.naming.resourceName('ip-allow', 255),
sampledRequestsEnabled: false,
},
statement: {
ipSetReferenceStatement: {
arn: ipAllowSet.attrArn,
},
},
action: {
allow: {},
},
};
const defaultWafProps: CfnWebACLProps = {
name: this.props.naming.resourceName('default-waf', 128),
defaultAction: {
block: {},
},
scope: 'REGIONAL',
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: this.props.naming.resourceName(undefined, 255),
sampledRequestsEnabled: false,
},
rules: [ipAllowRuleProps],
};
const defaultWaf = new CfnWebACL(this, 'default-waf', defaultWafProps);
const defaultWafLogGroupProps: MdaaLogGroupProps = {
logGroupName: 'default-waf',
encryptionKey: kmsKey,
// WAF log group destination names must start with aws-waf-logs-
// https://docs.aws.amazon.com/waf/latest/developerguide/logging-cw-logs.html
logGroupNamePathPrefix: 'aws-waf-logs-',
retention: RetentionDays.INFINITE,
naming: this.props.naming,
};
const defaultWafLogGroup = new MdaaLogGroup(this, 'default-waf-log-group', defaultWafLogGroupProps);
new CfnLoggingConfiguration(this, 'default-waf-logging-config', {
logDestinationConfigs: [defaultWafLogGroup.logGroupArn],
resourceArn: defaultWaf.attrArn,
});
new CfnWebACLAssociation(this, `default-waf-association`, {
resourceArn: restApi.deploymentStage.stageArn,
webAclArn: defaultWaf.attrArn,
});
Object.entries(this.props.m2mApiProps.wafArns || {}).forEach(wafEntry => {
new CfnWebACLAssociation(this, `waf-association-${wafEntry[0]}`, {
resourceArn: restApi.deploymentStage.stageArn,
webAclArn: wafEntry[1],
});
});
const cognitoAuthorizer = new CognitoUserPoolsAuthorizer(this, 'cognito-authorizer', {
authorizerName: this.props.naming.resourceName(),
resultsCacheTtl: Duration.seconds(0),
cognitoUserPools: [m2mUserPool],
});
const restApiRole = new MdaaRole(this, `integration-role`, {
roleName: `integration`,
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
naming: this.props.naming,
});
restApiRole.addToPolicy(
new PolicyStatement({
resources: [s3UrlGenLambda.functionArn],
actions: ['lambda:InvokeFunction'],
effect: Effect.ALLOW,
}),
);
MdaaNagSuppressions.addCodeResourceSuppressions(
restApiRole,
[
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy is specific to this role and function.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy is specific to this role and function.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy is specific to this role and function.' },
],
true,
);
const integrationRequestParamers = Object.fromEntries(
Object.keys(this.props.m2mApiProps.requestParameters || {}).map(param => {
return [`integration.request.querystring.${param}`, `method.request.querystring.${param}`];
}),
);
const integration = new LambdaIntegration(s3UrlGenLambda, {
credentialsRole: restApiRole,
requestParameters: integrationRequestParamers,
});
const uploadResource = restApi.root.addResource('upload');
const proxyResource = uploadResource.addResource('{proxy+}');
const methodRequestParamers = Object.fromEntries(
Object.entries(this.props.m2mApiProps.requestParameters || {}).map(entry => {
return [`method.request.querystring.${entry[0]}`, entry[1]];
}),
);
proxyResource.addMethod('GET', integration, {
authorizationType: AuthorizationType.COGNITO,
authorizer: cognitoAuthorizer,
authorizationScopes: [`${M2MApiL3Construct.identifier}/${apiScope.scopeName}`],
requestParameters: methodRequestParamers,
requestValidatorOptions: {
validateRequestParameters: true,
validateRequestBody: true,
},
});
const apistagePath = `/${stageName}`;
new MdaaParamAndOutput(this, {
...{
resourceType: 'rest-api-url',
resourceId: 'rest-api-upload-url',
name: 'rest-api-end-point-stage-url',
value: restApi.urlForPath(apistagePath),
},
naming: this.props.naming,
});
}
}