From 88d8b9ed9b77c4b012f524ee166fa718e9cf9759 Mon Sep 17 00:00:00 2001 From: Kazuho CryerShinozuka Date: Tue, 8 Oct 2024 03:23:58 +0900 Subject: [PATCH] addToResourcePolicy and grantInvoke --- .../aws-cdk-lib/aws-apigateway/lib/restapi.ts | 86 ++++- .../aws-apigateway/test/restapi.test.ts | 350 ++++++++++++++++++ 2 files changed, 431 insertions(+), 5 deletions(-) diff --git a/packages/aws-cdk-lib/aws-apigateway/lib/restapi.ts b/packages/aws-cdk-lib/aws-apigateway/lib/restapi.ts index 32dca38262529..0a5620bae29b9 100644 --- a/packages/aws-cdk-lib/aws-apigateway/lib/restapi.ts +++ b/packages/aws-cdk-lib/aws-apigateway/lib/restapi.ts @@ -15,9 +15,9 @@ import { IResource, ResourceBase, ResourceOptions } from './resource'; import { Stage, StageOptions } from './stage'; import { UsagePlan, UsagePlanProps } from './usage-plan'; import * as cloudwatch from '../../aws-cloudwatch'; -import { IVpcEndpoint } from '../../aws-ec2'; +import * as ec2 from '../../aws-ec2'; import * as iam from '../../aws-iam'; -import { ArnFormat, CfnOutput, IResource as IResourceBase, Resource, Stack, Token, FeatureFlags, RemovalPolicy, Size } from '../../core'; +import { ArnFormat, CfnOutput, IResource as IResourceBase, Resource, Stack, Token, FeatureFlags, RemovalPolicy, Size, Lazy } from '../../core'; import { APIGATEWAY_DISABLE_CLOUDWATCH_ROLE } from '../../cx-api'; const RESTAPI_SYMBOL = Symbol.for('@aws-cdk/aws-apigateway.RestApiBase'); @@ -73,6 +73,13 @@ export interface IRestApi extends IResourceBase { * @param stage The stage (default `*`) */ arnForExecuteApi(method?: string, path?: string, stage?: string): string; + + /** + * Add a policy statement to the API's resource policy + * + * @param statement the policy statement to add + */ + addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult; } /** @@ -369,6 +376,7 @@ export abstract class RestApiBase extends Resource implements IRestApi { private _latestDeployment?: Deployment; private _domainName?: DomainName; + protected resourcePolicy?: iam.PolicyDocument protected cloudWatchAccount?: CfnAccount; constructor(scope: Construct, id: string, props: RestApiBaseProps = { }) { @@ -381,6 +389,8 @@ export abstract class RestApiBase extends Resource implements IRestApi { Object.defineProperty(this, RESTAPI_SYMBOL, { value: true }); } + public abstract addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult; + /** * Returns the URL for an HTTP path. * @@ -454,6 +464,31 @@ export abstract class RestApiBase extends Resource implements IRestApi { }); } + /** + * Add a resource policy that only allows API execution from an Interface VPC Endpoint to create a private API. + * + * @param interfaceVpcEndpoint the interface VPC endpoint to grant access to + */ + public grantInvoke(interfaceVpcEndpoint: ec2.IInterfaceVpcEndpoint): void { + this.addToResourcePolicy(new iam.PolicyStatement({ + principals: [new iam.AnyPrincipal()], + actions: ['execute-api:Invoke'], + resources: ['execute-api:/*'], + effect: iam.Effect.DENY, + conditions: { + StringNotEquals: { + 'aws:SourceVpce': interfaceVpcEndpoint.vpcEndpointId, + }, + }, + })); + this.addToResourcePolicy(new iam.PolicyStatement({ + principals: [new iam.AnyPrincipal()], + actions: ['execute-api:Invoke'], + resources: ['execute-api:/*'], + effect: iam.Effect.ALLOW, + })); + } + /** * Returns the given named metric for this API */ @@ -701,9 +736,10 @@ export class SpecRestApi extends RestApiBase { constructor(scope: Construct, id: string, props: SpecRestApiProps) { super(scope, id, props); const apiDefConfig = props.apiDefinition.bind(this); + this.resourcePolicy = props.policy; const resource = new CfnRestApi(this, 'Resource', { name: this.restApiName, - policy: props.policy, + policy: Lazy.any({ produce: () => this.resourcePolicy }), failOnWarnings: props.failOnWarnings, minimumCompressionSize: props.minCompressionSize?.toBytes(), body: apiDefConfig.inlineDefinition ?? undefined, @@ -727,6 +763,21 @@ export class SpecRestApi extends RestApiBase { this.addDomainName('CustomDomain', props.domainName); } } + + /** + * Adds a statement to the resource policy associated with this rest api. + * A resource policy will be automatically created upon the first call to `addToResourcePolicy`. + * + * Note that this does not work with imported rest api. + * + * @param statement The policy statement to add + */ + public addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { + this.resourcePolicy = this.resourcePolicy ?? new iam.PolicyDocument(); + this.resourcePolicy.addStatements(statement); + + return { statementAdded: true, policyDependable: this }; + } } /** @@ -775,6 +826,10 @@ export class RestApi extends RestApiBase { class Import extends RestApiBase { public readonly restApiId = restApiId; + public addToResourcePolicy(_statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { + return { statementAdded: false }; + } + public get root(): IResource { throw new Error('root is not configured when imported using `fromRestApiId()`. Use `fromRestApiAttributes()` API instead.'); } @@ -796,6 +851,10 @@ export class RestApi extends RestApiBase { public readonly restApiName = attrs.restApiName ?? id; public readonly restApiRootResourceId = attrs.rootResourceId; public readonly root: IResource = new RootResource(this, {}, this.restApiRootResourceId); + + public addToResourcePolicy(_statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { + return { statementAdded: false }; + } } return new Import(scope, id); @@ -824,10 +883,12 @@ export class RestApi extends RestApiBase { throw new Error('both properties minCompressionSize and minimumCompressionSize cannot be set at once.'); } + this.resourcePolicy = props.policy; + const resource = new CfnRestApi(this, 'Resource', { name: this.physicalName, description: props.description, - policy: props.policy, + policy: Lazy.any({ produce: () => this.resourcePolicy }), failOnWarnings: props.failOnWarnings, minimumCompressionSize: props.minCompressionSize?.toBytes() ?? props.minimumCompressionSize, binaryMediaTypes: props.binaryMediaTypes, @@ -855,6 +916,21 @@ export class RestApi extends RestApiBase { Object.defineProperty(this, APIGATEWAY_RESTAPI_SYMBOL, { value: true }); } + /** + * Adds a statement to the resource policy associated with this rest api. + * A resource policy will be automatically created upon the first call to `addToResourcePolicy`. + * + * Note that this does not work with imported rest api. + * + * @param statement The policy statement to add + */ + public addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { + this.resourcePolicy = this.resourcePolicy ?? new iam.PolicyDocument(); + this.resourcePolicy.addStatements(statement); + + return { statementAdded: true, policyDependable: this }; + } + /** * Adds a new model. */ @@ -938,7 +1014,7 @@ export interface EndpointConfiguration { * * @default - no ALIASes are created for the endpoint. */ - readonly vpcEndpoints?: IVpcEndpoint[]; + readonly vpcEndpoints?: ec2.IVpcEndpoint[]; } export enum ApiKeySourceType { diff --git a/packages/aws-cdk-lib/aws-apigateway/test/restapi.test.ts b/packages/aws-cdk-lib/aws-apigateway/test/restapi.test.ts index 958805b278ac2..829d83a38ff24 100644 --- a/packages/aws-cdk-lib/aws-apigateway/test/restapi.test.ts +++ b/packages/aws-cdk-lib/aws-apigateway/test/restapi.test.ts @@ -3,6 +3,8 @@ import { Template } from '../../assertions'; import { GatewayVpcEndpoint } from '../../aws-ec2'; import { App, CfnElement, CfnResource, Lazy, RemovalPolicy, Size, Stack } from '../../core'; import * as apigw from '../lib'; +import * as ec2 from '../../aws-ec2'; +import * as iam from '../../aws-iam'; describe('restapi', () => { test('minimal setup', () => { @@ -1474,4 +1476,352 @@ describe('SpecRestApi', () => { ApiKeyRequired: false, }); }); + + describe('addToResourcePolicy', () => { + test('add a statement to the resource policy for RestApi', () => { + // GIVEN + const stack = new Stack(); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET', undefined, {}); + const statement = new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + }); + + // WHEN + api.addToResourcePolicy(statement); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::RestApi', { + Policy: { + Version: '2012-10-17', + Statement: [{ + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: { + "Fn::Join": [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':*', + ] + ] + }, + }], + }, + }); + }); + + test('add a statement to the resource policy for RestApi with policy provided', () => { + // GIVEN + const stack = new Stack(); + const api = new apigw.RestApi(stack, 'Api', { + policy: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + }), + ], + }), + }); + api.root.addMethod('GET', undefined, {}); + + const additionalPolicyStatement = new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + effect: iam.Effect.DENY, + principals: [new iam.AnyPrincipal()], + conditions: { + StringNotEquals: { + "aws:SourceVpce": "vpce-1234567890abcdef0" + } + } + }); + + // WHEN + api.addToResourcePolicy(additionalPolicyStatement); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::RestApi', { + Policy: { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: { + "Fn::Join": [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':*', + ] + ] + }, + }, + { + Action: 'execute-api:Invoke', + Effect: 'Deny', + Resource: { + "Fn::Join": [ + '', + [ + "arn:", + { "Ref": "AWS::Partition" }, + ":execute-api:", + { "Ref": "AWS::Region" }, + ":", + { "Ref": "AWS::AccountId" }, + ":*" + ] + ] + }, + Condition: { + StringNotEquals: { + "aws:SourceVpce": "vpce-1234567890abcdef0" + } + } + }, + ], + }, + }); + }); + + test('add a statement to the resource policy for SpecRestApi', () => { + // GIVEN + const stack = new Stack(); + const api = new apigw.SpecRestApi(stack, 'Api', { + apiDefinition: apigw.ApiDefinition.fromInline({ foo: 'bar' }), + }); + api.root.addMethod('GET', undefined, {}); + const statement = new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + }); + + // WHEN + api.addToResourcePolicy(statement); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::RestApi', { + Policy: { + Version: '2012-10-17', + Statement: [{ + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: { + "Fn::Join": [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':*', + ] + ] + }, + }], + }, + }); + }); + + test('add a statement to the resource policy for SpecRestApi with policy provided', () => { + // GIVEN + const stack = new Stack(); + const api = new apigw.SpecRestApi(stack, 'Api', { + apiDefinition: apigw.ApiDefinition.fromInline({ foo: 'bar' }), + policy: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + }), + ], + }), + }); + api.root.addMethod('GET', undefined, {}); + + const additionalPolicyStatement = new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + effect: iam.Effect.DENY, + principals: [new iam.AnyPrincipal()], + conditions: { + StringNotEquals: { + "aws:SourceVpce": "vpce-1234567890abcdef0" + } + } + }); + + // WHEN + api.addToResourcePolicy(additionalPolicyStatement); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::RestApi', { + Policy: { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: { + "Fn::Join": [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':*', + ] + ] + }, + }, + { + Action: 'execute-api:Invoke', + Effect: 'Deny', + Resource: { + "Fn::Join": [ + '', + [ + "arn:", + { "Ref": "AWS::Partition" }, + ":execute-api:", + { "Ref": "AWS::Region" }, + ":", + { "Ref": "AWS::AccountId" }, + ":*" + ] + ] + }, + Condition: { + StringNotEquals: { + "aws:SourceVpce": "vpce-1234567890abcdef0" + } + } + }, + ], + }, + }); + }); + + test('cannot add a statement to the resource policy for imported RestApi from API ID', () => { + // GIVEN + const stack = new Stack(); + const api = apigw.RestApi.fromRestApiId(stack, 'Api', 'api-id'); + + // THEN + const result = api.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + })); + + expect(result.statementAdded).toBe(false); + }); + + test('cannot add a statement to the resource policy for imported RestApi from API Attributes', () => { + // GIVEN + const stack = new Stack(); + const api = apigw.RestApi.fromRestApiAttributes(stack, 'Api', { + restApiId: 'api-id', + rootResourceId: 'root-id', + }); + + // THEN + const result = api.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [Stack.of(stack).formatArn({ + service: 'execute-api', + resource: '*', + sep: '/', + })], + })); + + expect(result.statementAdded).toBe(false); + }); + }); + + test('add appropriate permissions by grantInvoke', () => { + // GIVEN + const stack = new Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const vpcEndpoint = vpc.addInterfaceEndpoint('APIGatewayEndpoint', { + service: ec2.InterfaceVpcEndpointAwsService.APIGATEWAY, + }); + const api = new apigw.RestApi(stack, 'my-api', { + endpointTypes: [apigw.EndpointType.PRIVATE], + }); + api.root.addMethod('GET'); + api.grantInvoke(vpcEndpoint); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::RestApi', { + Policy: { + Version: '2012-10-17', + Statement: [{ + Action: 'execute-api:Invoke', + Effect: 'Deny', + Resource: 'execute-api:/*', + Condition: { + StringNotEquals: { + "aws:SourceVpce": { + "Ref": "VPCAPIGatewayEndpoint5865ABCA" + } + } + }, + }, { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: 'execute-api:/*', + Principal: { + AWS: '*', + }, + }], + }, + }); + }) });