diff --git a/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs b/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs index 7e61a219e55..959fb43c00d 100644 --- a/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs +++ b/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs @@ -3,12 +3,6 @@ "Description": "API Gateway resource stack creation using Amplify CLI", <% if (props.dependsOn) { %> "Parameters": { - "authRoleName": { - "Type": "String" - }, - "unauthRoleName": { - "Type": "String" - }, "env": { "Type": "String" }<%if (props.dependsOn && props.dependsOn.length > 0) { %>,<% } %> @@ -18,7 +12,7 @@ "Type": "String", "Default": "<%= props.dependsOn[i].category %><%= props.dependsOn[i].resourceName %><%= props.dependsOn[i].attributes[j] %>" }<%if (i !== props.dependsOn.length - 1 || j !== props.dependsOn[i].attributes.length - 1) { %>,<% } %> - + <% } %> <% } %> <% } %> @@ -34,107 +28,6 @@ } }, "Resources": { - <%if (props.privacy.auth) { %> - "PolicyAPIGW<%= props.apiName %>auth": { - "DependsOn": [ - "<%= props.apiName %>" - ], - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyName": "PolicyAPIGW<%= props.apiName %>auth", - "Roles": [ - {"Ref": "authRoleName"} - ], - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "execute-api:Invoke" - ], - "Resource": [ - <% for(var i=0; i < props.paths.length; i++) { %> - <% if (props.paths[i].privacy && props.paths[i].privacy.auth) { %> - <% for(var x=0; x < props.paths[i].privacy.auth.length; x++) { %> - { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/", - { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "<%= props.paths[i].privacy.auth[x] %>", - "<%= props.paths[i].policyResourceName %>/*" - ] - ] - }, - { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/", - { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "<%= props.paths[i].privacy.auth[x] %>", - "<%= props.paths[i].policyResourceName %>" - ] - ] - } - <% if (x !== props.paths[i].privacy.auth.length - 1) { %> - , - <% } %> - <% } %> - <% if (i !== props.paths.length - 1) { %> - , - <% } %> - <% } %> - <% } %> - ] - } - ] - } - } - }, - <% } %> <% for(var i=0; i < props.paths.length; i++) { %> <%if (props.paths[i].privacy && props.paths[i].privacy.userPoolGroups) { %> <% let selectedUserPoolGroupList = Object.keys(props.paths[i].privacy.userPoolGroups); %> @@ -168,7 +61,7 @@ "execute-api:Invoke" ], "Resource": [ - + <% for(var x=0; x < props.paths[i].privacy.userPoolGroups[selectedUserPoolGroupList[j]].length; x++) { %> { "Fn::Join": [ @@ -190,10 +83,10 @@ { "Fn::If": [ "ShouldNotCreateEnvResources", - "Prod", + "Prod", { "Ref": "env" - } + } ] }, "<%= props.paths[i].privacy.userPoolGroups[selectedUserPoolGroupList[j]][x] %>", @@ -245,107 +138,6 @@ <% } %> <% } %> <% } %> - <%if (props.privacy.unauth) { %> - "PolicyAPIGW<%= props.apiName %>unauth": { - "DependsOn": [ - "<%= props.apiName %>" - ], - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyName": "PolicyAPIGW<%= props.apiName %>unauth", - "Roles": [ - {"Ref": "unauthRoleName"} - ], - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "execute-api:Invoke" - ], - "Resource": [ - <% for(var i=0; i < props.paths.length; i++) { %> - <% if (props.paths[i].privacy && props.paths[i].privacy.unauth) { %> - <% for(var x=0; x < props.paths[i].privacy.unauth.length; x++) { %> - { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/", - { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "<%= props.paths[i].privacy.unauth[x] %>", - "<%= props.paths[i].policyResourceName %>/*" - ] - ] - }, - { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/", - { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "<%= props.paths[i].privacy.unauth[x] %>", - "<%= props.paths[i].policyResourceName %>" - ] - ] - } - <% if (x !== props.paths[i].privacy.unauth.length - 1) { %> - , - <% } %> - <% } %> - <% if (i !== props.paths.length - 1) { %> - , - <% } %> - <% } %> - <% } %> - ] - } - ] - } - } - }, - <% } %> "<%= props.apiName %>": { "Type": "AWS::ApiGateway::RestApi", "Properties": { @@ -383,7 +175,7 @@ } ] ] - } + } ] }, "schemes": [ @@ -458,7 +250,7 @@ } } }, - <%if (!props.paths[i].privacy.open) { %> + <%if (!props.paths[i].privacy.open) { %> "security": [ { "sigv4": [] @@ -480,11 +272,11 @@ "Ref": "AWS::Region" }, ":lambda:path/2015-03-31/functions/", - <% if (props.paths[i].lambdaArn ) { %> - "<%= props.paths[i].lambdaArn %>", + <% if (props.paths[i].lambdaArn ) { %> + "<%= props.paths[i].lambdaArn %>", <% } else { %> { - + "Ref": "function<%= props.paths[i].lambdaFunction %>Arn" }, <% } %> @@ -565,7 +357,7 @@ } } }, - <%if (!props.paths[i].privacy.open) { %> + <%if (!props.paths[i].privacy.open) { %> "security": [ { "sigv4": [] @@ -587,11 +379,11 @@ "Ref": "AWS::Region" }, ":lambda:path/2015-03-31/functions/", - <% if (props.paths[i].lambdaArn) { %> + <% if (props.paths[i].lambdaArn) { %> "<%= props.paths[i].lambdaArn %>", <% } else { %> { - + "Ref": "function<%= props.paths[i].lambdaFunction %>Arn" }, <% } %> @@ -604,7 +396,7 @@ "type": "aws_proxy" } } - }<% if (i !== props.paths.length - 1) { %>,<% } %> + }<% if (i !== props.paths.length - 1) { %>,<% } %> <% } %> }, "securityDefinitions": { @@ -646,9 +438,9 @@ } }, - <%if (props.functionArns) { %> + <%if (props.functionArns) { %> <% for (var i=0; i < props.functionArns.length; i++) { %> - + "function<%= props.functionArns[i].lambdaFunction.replace(/[^0-9a-zA-Z]/gi, '') %>Permission<%= props.apiName %>": { "Type": "AWS::Lambda::Permission", "Properties": { @@ -683,7 +475,7 @@ }, <% } %> <% } %> - + "DeploymentAPIGW<%= props.apiName %><%= props.uuid %>": { "Type": "AWS::ApiGateway::Deployment", "Properties": { @@ -691,10 +483,10 @@ "StageName": { "Fn::If": [ "ShouldNotCreateEnvResources", - "Prod", + "Prod", { "Ref": "env" - } + } ] }, "RestApiId": { diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts index 211ea1c1fb7..fe756c786f5 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts @@ -1,3 +1,4 @@ +import { JSONUtilities } from 'amplify-cli-core'; import { serviceMetadataFor } from './utils/dynamic-imports'; import fs from 'fs-extra'; import path from 'path'; @@ -29,24 +30,14 @@ export const legacyAddResource = async (serviceWalkthroughPromise: Promise, copyCfnTemplate(context, category, answers, cfnFilename); const parameters = { ...answers }; - const cfnParameters = { - authRoleName: { - Ref: 'AuthRoleName', - }, - unauthRoleName: { - Ref: 'UnauthRoleName', - }, - }; const resourceDirPath = path.join(projectBackendDirPath, category, parameters.resourceName); fs.ensureDirSync(resourceDirPath); const parametersFilePath = path.join(resourceDirPath, parametersFileName); - let jsonString = JSON.stringify(parameters, null, 4); - fs.writeFileSync(parametersFilePath, jsonString, 'utf8'); + JSONUtilities.writeJson(parametersFilePath, parameters); const cfnParametersFilePath = path.join(resourceDirPath, cfnParametersFilename); - jsonString = JSON.stringify(cfnParameters, null, 4); - fs.writeFileSync(cfnParametersFilePath, jsonString, 'utf8'); + JSONUtilities.writeJson(cfnParametersFilePath, {}); } context.amplify.updateamplifyMetaAfterResourceAdd(category, answers.resourceName, options); return answers.resourceName; diff --git a/packages/amplify-e2e-core/src/categories/api.ts b/packages/amplify-e2e-core/src/categories/api.ts index dcaa6c86d64..ae56055411e 100644 --- a/packages/amplify-e2e-core/src/categories/api.ts +++ b/packages/amplify-e2e-core/src/categories/api.ts @@ -303,9 +303,30 @@ export function addRestApi(cwd: string, settings: any) { .sendLine('n'); } + chain.wait('Restrict API access'); + + if (settings.restrictAccess) { + chain.sendConfirmYes().wait('Who should have access'); + + if (!settings.allowGuestUsers) { + chain + .sendCarriageReturn() // Authenticated users only + .wait('What kind of access do you want for Authenticated users') + .sendLine('a'); // CRUD permissions + } else { + chain + .sendLine(KEY_DOWN_ARROW) + .sendCarriageReturn() // Authenticated and Guest users + .wait('What kind of access do you want for Authenticated users') + .sendLine('a') // CRUD permissions for authenticated users + .wait('What kind of access do you want for Guest users') + .sendLine('a'); // CRUD permissions for guest users + } + } else { + chain.sendConfirmNo(); // Do not restrict access + } + chain - .wait('Restrict API access') - .sendLine('n') .wait('Do you want to add another path') .sendLine('n') .sendEof() diff --git a/packages/amplify-e2e-core/src/utils/sdk-calls.ts b/packages/amplify-e2e-core/src/utils/sdk-calls.ts index df43cfb4f33..307f51d0189 100644 --- a/packages/amplify-e2e-core/src/utils/sdk-calls.ts +++ b/packages/amplify-e2e-core/src/utils/sdk-calls.ts @@ -12,6 +12,7 @@ import { Kinesis, CloudFormation, AmplifyBackend, + IAM, } from 'aws-sdk'; import _ from 'lodash'; @@ -286,3 +287,13 @@ export const getAmplifyBackendJobStatus = async (jobId: string, appId: string, e }) .promise(); }; + +export const listRolePolicies = async (roleName: string, region: string) => { + const service = new IAM({ region }); + return (await service.listRolePolicies({ RoleName: roleName }).promise()).PolicyNames; +}; + +export const listAttachedRolePolicies = async (roleName: string, region: string) => { + const service = new IAM({ region }); + return (await service.listAttachedRolePolicies({ RoleName: roleName }).promise()).AttachedPolicies; +}; diff --git a/packages/amplify-e2e-tests/src/__tests__/api_2.test.ts b/packages/amplify-e2e-tests/src/__tests__/api_2.test.ts index 5eaf64895cf..8230ce38b03 100644 --- a/packages/amplify-e2e-tests/src/__tests__/api_2.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/api_2.test.ts @@ -1,4 +1,12 @@ -import { amplifyPush, amplifyPushUpdate, deleteProject, initJSProjectWithProfile } from 'amplify-e2e-core'; +import { + amplifyPush, + amplifyPushUpdate, + deleteProject, + getPolicyVersion, + initJSProjectWithProfile, + listAttachedRolePolicies, + listRolePolicies, +} from 'amplify-e2e-core'; import * as path from 'path'; import { existsSync } from 'fs'; import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; @@ -375,4 +383,47 @@ describe('amplify add api (REST)', () => { await addRestApi(projRoot, { existingLambda: true }); await amplifyPushUpdate(projRoot); }); + + it('init a project, create lambda and attach multiple rest apis', async () => { + await initJSProjectWithProfile(projRoot, {}); + await addFunction(projRoot, { functionTemplate: 'Hello World' }, 'nodejs'); + await addRestApi(projRoot, { + existingLambda: true, + restrictAccess: true, + allowGuestUsers: true, + }); + await addRestApi(projRoot, { + existingLambda: true, + restrictAccess: true, + allowGuestUsers: true, + }); + await addRestApi(projRoot, { + existingLambda: true, + restrictAccess: true, + allowGuestUsers: false, + }); + await addRestApi(projRoot, { existingLambda: true }); + await amplifyPushUpdate(projRoot); + + const amplifyMeta = getProjectMeta(projRoot); + const meta = amplifyMeta.providers.awscloudformation; + const { AuthRoleName, UnauthRoleName, Region } = meta; + + expect(await listRolePolicies(AuthRoleName, Region)).toEqual([]); + expect(await listRolePolicies(UnauthRoleName, Region)).toEqual([]); + + const authPolicies = await listAttachedRolePolicies(AuthRoleName, Region); + expect(authPolicies.length).toBeGreaterThan(0); + + for (let i = 0; i < authPolicies.length; i++) { + expect(authPolicies[i].PolicyName).toMatch(/PolicyAPIGWAuth\d/); + } + + const unauthPolicies = await listAttachedRolePolicies(UnauthRoleName, Region); + expect(unauthPolicies.length).toBeGreaterThan(0); + + for (let i = 0; i < unauthPolicies.length; i++) { + expect(unauthPolicies[i].PolicyName).toMatch(/PolicyAPIGWUnauth\d/); + } + }); }); diff --git a/packages/amplify-provider-awscloudformation/src/push-resources.ts b/packages/amplify-provider-awscloudformation/src/push-resources.ts index e36ec65379d..de4a2fe5d40 100644 --- a/packages/amplify-provider-awscloudformation/src/push-resources.ts +++ b/packages/amplify-provider-awscloudformation/src/push-resources.ts @@ -39,6 +39,7 @@ import { Fn, Template } from 'cloudform-types'; import { getGqlUpdatedResource } from './graphql-transformer/utils'; import { isAmplifyAdminApp } from './utils/admin-helpers'; import { fileLogger } from './utils/aws-logger'; +import { APIGW_AUTH_STACK_LOGICAL_ID, loadApiWithPrivacyParams } from './utils/consolidate-apigw-policies'; import { createEnvLevelConstructs } from './utils/env-level-constructs'; import { NETWORK_STACK_LOGICAL_ID } from './network/stack'; @@ -698,6 +699,14 @@ function updateS3Templates(context: $TSContext, resourcesToBeUpdated: $TSAny, am } } + // Add CFN templates that are not tied to an individual resource. + const { APIGatewayAuthURL } = context.amplify.getProjectDetails()?.amplifyMeta?.providers?.[constants.ProviderName] ?? {}; + + if (APIGatewayAuthURL) { + const resourceDir = path.join((context.amplify.pathManager as any).getBackendDirPath(), 'api'); + promises.push(uploadTemplateToS3(context, resourceDir, `${APIGW_AUTH_STACK_LOGICAL_ID}.json`, 'api', '', null)); + } + return Promise.all(promises); } @@ -707,7 +716,7 @@ async function uploadTemplateToS3( cfnFile: string, category: string, resourceName: string, - amplifyMeta: $TSMeta, + amplifyMeta: $TSMeta | null, ) { const filePath = path.normalize(path.join(resourceDir, cfnFile)); const s3 = await S3.getInstance(context); @@ -726,13 +735,15 @@ async function uploadTemplateToS3( throw error; } - const templateURL = `https://s3.amazonaws.com/${projectBucket}/amplify-cfn-templates/${category}/${cfnFile}`; - const providerMetadata = amplifyMeta[category][resourceName].providerMetadata || {}; + if (amplifyMeta) { + const templateURL = `https://s3.amazonaws.com/${projectBucket}/amplify-cfn-templates/${category}/${cfnFile}`; + const providerMetadata = amplifyMeta[category][resourceName].providerMetadata || {}; - providerMetadata.s3TemplateURL = templateURL; - providerMetadata.logicalId = category + resourceName; + providerMetadata.s3TemplateURL = templateURL; + providerMetadata.logicalId = category + resourceName; - context.amplify.updateamplifyMetaAfterResourceUpdate(category, resourceName, 'providerMetadata', providerMetadata); + context.amplify.updateamplifyMetaAfterResourceUpdate(category, resourceName, 'providerMetadata', providerMetadata); + } } async function formNestedStack( @@ -760,7 +771,38 @@ async function formNestedStack( const { amplifyMeta } = projectDetails; let authResourceName: string; - const { NetworkStackS3Url } = amplifyMeta.providers[constants.ProviderName]; + const { APIGatewayAuthURL, NetworkStackS3Url } = amplifyMeta.providers[constants.ProviderName]; + + if (APIGatewayAuthURL) { + const stack = { + Type: 'AWS::CloudFormation::Stack', + Properties: { + TemplateURL: APIGatewayAuthURL, + Parameters: { + authRoleName: { + Ref: 'AuthRoleName', + }, + unauthRoleName: { + Ref: 'UnauthRoleName', + }, + env: context.exeInfo.localEnvInfo.envName, + }, + }, + }; + const apis = amplifyMeta?.api ?? {}; + + Object.keys(apis).forEach(apiName => { + const api = apis[apiName]; + + if (loadApiWithPrivacyParams(context, apiName, api)) { + stack.Properties.Parameters[apiName] = { + 'Fn::GetAtt': [api.providerMetadata.logicalId, 'Outputs.ApiId'], + }; + } + }); + + nestedStack.Resources[APIGW_AUTH_STACK_LOGICAL_ID] = stack; + } if (NetworkStackS3Url) { nestedStack.Resources[NETWORK_STACK_LOGICAL_ID] = { diff --git a/packages/amplify-provider-awscloudformation/src/resourceParams.js b/packages/amplify-provider-awscloudformation/src/resourceParams.js index a9133f78048..c0ccf7a3ce4 100644 --- a/packages/amplify-provider-awscloudformation/src/resourceParams.js +++ b/packages/amplify-provider-awscloudformation/src/resourceParams.js @@ -49,6 +49,7 @@ function loadResourceParameters(context, category, resource) { } module.exports = { + getResourceDirPath, loadResourceParameters, saveResourceParameters, }; diff --git a/packages/amplify-provider-awscloudformation/src/utils/consolidate-apigw-policies.ts b/packages/amplify-provider-awscloudformation/src/utils/consolidate-apigw-policies.ts new file mode 100644 index 00000000000..b5c57b82a68 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/utils/consolidate-apigw-policies.ts @@ -0,0 +1,274 @@ +import * as path from 'path'; +import { $TSAny, $TSContext, $TSObject, JSONUtilities } from 'amplify-cli-core'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { prepareApp } from '@aws-cdk/core/lib/private/prepare-app'; +import { ProviderName } from '../constants'; +import { getResourceDirPath } from '../resourceParams'; + +type ApiGatewayAuthStackProps = Readonly<{ + description: string; + stackName: string; + apiGateways: $TSAny[]; +}>; + +type ApiGatewayPolicyCreationState = { + apiGateway: $TSAny; + apiRef: cdk.CfnParameter; + env: cdk.CfnParameter; + path: $TSAny; + methods: $TSAny; + roleCount: number; + roleName: cdk.CfnParameter; + policyDocSize: number; + managedPolicy: iam.CfnManagedPolicy; + namePrefix: string; +}; + +export const APIGW_AUTH_STACK_LOGICAL_ID = 'APIGatewayAuthStack'; +const API_PARAMS_FILE = 'api-params.json'; +const CFN_TEMPLATE_FORMAT_VERSION = '2010-09-09'; +const MAX_MANAGED_POLICY_SIZE = 6_144; +const S3_UPLOAD_PATH = `api/${APIGW_AUTH_STACK_LOGICAL_ID}.json`; +const AUTH_ROLE_NAME = 'authRoleName'; +const UNAUTH_ROLE_NAME = 'unauthRoleName'; + +class ApiGatewayAuthStack extends cdk.Stack { + constructor(scope: cdk.Construct, id: string, props: ApiGatewayAuthStackProps) { + super(scope, id, props); + this.templateOptions.templateFormatVersion = CFN_TEMPLATE_FORMAT_VERSION; + + const authRoleName = new cdk.CfnParameter(this, AUTH_ROLE_NAME, { + type: 'String', + }); + const unauthRoleName = new cdk.CfnParameter(this, UNAUTH_ROLE_NAME, { + type: 'String', + }); + const env = new cdk.CfnParameter(this, 'env', { + type: 'String', + }); + + new cdk.CfnCondition(this, 'ShouldNotCreateEnvResources', { + expression: cdk.Fn.conditionEquals(env, 'NONE'), + }); + + let authRoleCount = 0; + let unauthRoleCount = 0; + let authPolicyDocSize = 0; + let unauthPolicyDocSize = 0; + let authManagedPolicy; + let unauthManagedPolicy; + + props.apiGateways.forEach(apiGateway => { + if (!Array.isArray(apiGateway.params.paths)) { + return; + } + + const apiRef = new cdk.CfnParameter(this, apiGateway.resourceName, { + type: 'String', + }); + + const state: ApiGatewayPolicyCreationState = { + apiGateway, + apiRef, + env, + path: null, + methods: null, + roleCount: 0, + roleName: null, + policyDocSize: 0, + managedPolicy: null, + namePrefix: '', + }; + + apiGateway.params.paths.forEach(path => { + state.path = path; + + if (apiGateway.params.privacy.auth) { + state.methods = path?.privacy?.auth ?? []; + state.roleCount = authRoleCount; + state.roleName = authRoleName; + state.policyDocSize = authPolicyDocSize; + state.managedPolicy = authManagedPolicy; + state.namePrefix = 'PolicyAPIGWAuth'; + this.createPoliciesFromResources(state); + ({ roleCount: authRoleCount, policyDocSize: authPolicyDocSize, managedPolicy: authManagedPolicy } = state); + } + + if (apiGateway.params.privacy.unauth) { + state.methods = path?.privacy?.unauth ?? []; + state.roleCount = unauthRoleCount; + state.roleName = unauthRoleName; + state.policyDocSize = unauthPolicyDocSize; + state.managedPolicy = unauthManagedPolicy; + state.namePrefix = 'PolicyAPIGWUnauth'; + this.createPoliciesFromResources(state); + ({ roleCount: unauthRoleCount, policyDocSize: unauthPolicyDocSize, managedPolicy: unauthManagedPolicy } = state); + } + }); + }); + } + + toCloudFormation() { + prepareApp(this); + return this._toCloudFormation(); + } + + private createPoliciesFromResources(options: ApiGatewayPolicyCreationState) { + const { apiGateway, apiRef, env, roleName, path, methods, namePrefix } = options; + + methods.forEach(method => { + const policySizeIncrease = computePolicySizeIncrease(method.length, path.policyResourceName.length, apiGateway.resourceName.length); + + options.policyDocSize += policySizeIncrease; + // If a managed policy hasn't been created yet, or the maximum + // managed policy size has been exceeded, then create a new policy. + if (options.roleCount === 0 || options.policyDocSize > MAX_MANAGED_POLICY_SIZE) { + // Initial size of 100 for version, statement, etc. + options.policyDocSize = 100 + policySizeIncrease; + options.roleCount++; + options.managedPolicy = createManagedPolicy(this, `${namePrefix}${options.roleCount}`, (roleName as unknown) as string); + } + + options.managedPolicy.policyDocument.Statement[0].Resource.push( + createApiResource(this.region, this.account, apiRef, env, method, `${path.policyResourceName}/*`), + createApiResource(this.region, this.account, apiRef, env, method, path.policyResourceName), + ); + }); + } +} + +function createManagedPolicy(stack: cdk.Stack, policyName: string, roleName: string): iam.CfnManagedPolicy { + return new iam.CfnManagedPolicy(stack, policyName, { + roles: [roleName], + policyDocument: { + Version: '2012-10-17', + Statement: [{ Effect: 'Allow', Action: ['execute-api:Invoke'], Resource: [] }], + }, + }); +} + +function createApiResource(region, account, api, env, method, resourceName) { + return cdk.Fn.join('', [ + 'arn:aws:execute-api:', + region, + ':', + account, + ':', + api, + '/', + (cdk.Fn.conditionIf('ShouldNotCreateEnvResources', 'Prod', env) as unknown) as string, + method, + resourceName, + ]); +} + +function computePolicySizeIncrease(methodLength: number, pathLength: number, nameLength: number): number { + // Each path + HTTP method increases the policy size by roughly: + // - 2 * ~190 for the resource boilerplate, etc. (Whitespace is not counted) + // - 2 * the length of the HTTP method (ie /POST) + // - 2 * the length of the policy resource name (ie /items) + // - 2 * the length of the API resourceName + return 380 + 2 * (methodLength + pathLength + nameLength); +} + +export function consolidateApiGatewayPolicies(context: $TSContext, stackName: string): $TSObject { + const apiGateways = []; + const { amplifyMeta } = context.amplify.getProjectDetails(); + const apis = amplifyMeta?.api ?? {}; + + Object.keys(apis).forEach(resourceName => { + const resource = apis[resourceName]; + const apiParams = loadApiWithPrivacyParams(context, resourceName, resource); + + if (!apiParams) { + return; + } + + const api = { ...resource, resourceName, params: apiParams }; + updateExistingApiCfn(context, api); + apiGateways.push(api); + }); + + if (apiGateways.length === 0) { + return {}; + } + + return createApiGatewayAuthResources(context, stackName, apiGateways); +} + +function createApiGatewayAuthResources(context: $TSContext, stackName: string, apiGateways: $TSAny): $TSObject { + const stack = new ApiGatewayAuthStack(undefined, 'Amplify', { + description: 'API Gateway policy stack created using Amplify CLI', + stackName, + apiGateways, + }); + const cfn = stack.toCloudFormation(); + const { amplify } = context; + const { DeploymentBucketName } = amplify.getProjectMeta()?.providers?.[ProviderName] ?? {}; + const cfnPath = path.join((amplify.pathManager as any).getBackendDirPath(), 'api', `${APIGW_AUTH_STACK_LOGICAL_ID}.json`); + + JSONUtilities.writeJson(cfnPath, cfn); + + return { + APIGatewayAuthURL: `https://s3.amazonaws.com/${DeploymentBucketName}/amplify-cfn-templates/${S3_UPLOAD_PATH}`, + }; +} + +export function loadApiWithPrivacyParams(context: $TSContext, name: string, resource: any): object | undefined { + if (resource.providerPlugin !== ProviderName || resource.service !== 'API Gateway') { + return; + } + + const apiParamsPath = path.join(getResourceDirPath(context, 'api', name), API_PARAMS_FILE); + const apiParams: any = JSONUtilities.readJson(apiParamsPath, { throwIfNotExist: false }) ?? {}; + + if (apiParams?.privacy?.auth === 0 && apiParams?.privacy?.unauth === 0) { + return; + } + + return apiParams; +} + +function updateExistingApiCfn(context: $TSContext, api: $TSObject): void { + const { resourceName } = api.params; + const resourceDir = getResourceDirPath(context, 'api', resourceName); + const cfnTemplate = path.join(resourceDir, `${resourceName}-cloudformation-template.json`); + const paramsFile = path.join(resourceDir, 'parameters.json'); + const cfn: any = JSONUtilities.readJson(cfnTemplate, { throwIfNotExist: false }) ?? {}; + const parameterJson = JSONUtilities.readJson(paramsFile, { throwIfNotExist: false }) ?? {}; + const parameters = cfn?.Parameters ?? {}; + const resources = cfn?.Resources ?? {}; + let modified = false; + + for (const parameterName in parameters) { + if (parameterName === AUTH_ROLE_NAME || parameterName === UNAUTH_ROLE_NAME) { + delete parameters[parameterName]; + delete parameterJson[parameterName]; + modified = true; + } + } + + // eslint-disable-next-line guard-for-in + for (const resourceName in resources) { + const resource = resources[resourceName]; + + if (resource.Type === 'AWS::IAM::Policy') { + const roles = resource?.Properties?.Roles; + + if (Array.isArray(roles) && roles.length === 1) { + const roleName = roles[0].Ref; + + if (roleName === AUTH_ROLE_NAME || roleName === UNAUTH_ROLE_NAME) { + delete resources[resourceName]; + modified = true; + } + } + } + } + + if (modified) { + JSONUtilities.writeJson(cfnTemplate, cfn); + JSONUtilities.writeJson(paramsFile, parameterJson); + } +} diff --git a/packages/amplify-provider-awscloudformation/src/utils/env-level-constructs.ts b/packages/amplify-provider-awscloudformation/src/utils/env-level-constructs.ts index 86d4f4f9194..397e1e65bee 100644 --- a/packages/amplify-provider-awscloudformation/src/utils/env-level-constructs.ts +++ b/packages/amplify-provider-awscloudformation/src/utils/env-level-constructs.ts @@ -4,6 +4,7 @@ import { S3 } from '../aws-utils/aws-s3'; import constants from '../constants'; import { NetworkStack } from '../network/stack'; import { getEnvironmentNetworkInfo } from '../network/environment-info'; +import { consolidateApiGatewayPolicies } from './consolidate-apigw-policies'; const { ProviderName: providerName } = constants; @@ -14,7 +15,11 @@ export async function createEnvLevelConstructs(context) { const updatedMeta = {}; - Object.assign(updatedMeta, await createNetworkResources(context, stackName, hasContainers)); + Object.assign( + updatedMeta, + await createNetworkResources(context, stackName, hasContainers), + consolidateApiGatewayPolicies(context, stackName), + ); context.amplify.updateProvideramplifyMeta(providerName, updatedMeta);