Skip to content

Having API Gateway sign a proxied request destined for OpenSearch in CloudFormation

0

Hi there,

I have a CloudFormation setup that I hope to use to automatically index documents in OpenSearch. I've set up an ingestion bucket in S3; when a file's dropped in the bucket, it triggers a Lambda function, which slightly transforms the data before sending it to OpenSearch. I was having trouble applying a sigv4 signature to the indexing request when it's sent to OpenSearch (kept getting a mismatch error), so I abandoned that in favour of proxying everything through API Gateway. While the signature error is now gone, I keep getting a "User: anonymous is not authorized to perform: es:ESHttpPut because no resource-based policy allows the es:ESHttpPut action" error.

My OpenSearch policy explicitly allows Put (and the other verbs, since I also want to be able to query OpenSearch through the proxy) for API Gateway, though:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAPIGatewayAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT_ID:role/ingest-app-APIGatewayRole"
      },
      "Action": [
        "es:ESHttpGet",
        "es:ESHttpPost",
        "es:ESHttpPut",
        "es:ESHttpDelete"
      ],
      "Resource": "arn:aws:es:REGION:ACCOUNT_ID:domain/ingest-app-domain/*"
    }
  ]
}

Here's my (rather long, sorry) CloudFormation. OpenSearch is administered manually. I can't for the life of me figure this out. Any advice would be much appreciated.

AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Full stack: S3 to Lambda to API Gateway to OpenSearch

Parameters:
  ProjectName:
    Type: String
    Default: "ingest-app"
  GitHubRepo:
    Type: String
    Description: "Format: owner/repo"
  GitHubBranch:
    Type: String
    Default: "main"
  GitHubToken:
    Type: String
    NoEcho: true
  OpenSearchDomainEndpoint:
    Type: String
    Description: "OpenSearch domain endpoint (https://...)"
  OpenSearchDomainName:
    Type: String
    Description: "OpenSearch domain name"

Resources:

  ## IAM Roles
  IngestLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-IngestLambdaRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: LambdaPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject
                Resource: "*"
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"

  APIGatewayRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-APIGatewayRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: apigateway.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: OpenSearchPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - es:ESHttpPut
                  - es:ESHttpPost
                  - es:ESHttpGet
                Resource: !Sub "arn:${AWS::Partition}:es:${AWS::Region}:${AWS::AccountId}:domain/${OpenSearchDomainName}/*"

  ## Lambda
  IngestLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${ProjectName}-IngestLambda"
      Runtime: nodejs18.x
      Handler: index.handler
      Role: !GetAtt IngestLambdaRole.Arn
      Timeout: 30
      Environment:
        Variables:
          API_GATEWAY_ENDPOINT: !Sub "https://${Gateway}.execute-api.${AWS::Region}.amazonaws.com/prod"
      Code:
        ZipFile: |
          const https = require('https');
          const { URL } = require('url');
          const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');

          const apiUrl = process.env.API_GATEWAY_ENDPOINT;

          exports.handler = async (event) => {
            const record = event.Records[0];
            const bucket = record.s3.bucket.name;
            const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
            const [jurisdiction, filename] = key.split('/');
            const date = filename.replace('.txt', '');

            const client = new S3Client();
            const s3Res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
            const body = await streamToString(s3Res.Body);

            const doc = { jurisdiction, date, body };

            console.log(key);

            await sendToApiGateway(doc, key.replace(/\//g, '_').replace('.txt', '').replace(/-/g, '_'));
          };

          async function sendToApiGateway(doc, id) {
            console.log(`${apiUrl}/text-index/_create/${id}`);
            const url = new URL(`${apiUrl}/text-index/_create/${id}`);
            const json = JSON.stringify(doc);

            const options = {
              method: 'PUT',
              hostname: url.hostname,
              path: url.pathname,
              headers: {
                'Content-Type': 'application/json',
                'Content-Length': Buffer.byteLength(json),
              }
            };

            return new Promise((resolve, reject) => {
              const req = https.request(options, (res) => {
                res.setEncoding('utf8');
                let data = '';
                res.on('data', (chunk) => {
                  console.log(`OpenSearch response chunk: ${chunk}`);
                  data += chunk;
                });
                res.on('end', () => {
                  console.log(`OpenSearch response status: ${res.statusCode}`);
                  console.log(`OpenSearch response body: ${data}`);
                  if (res.statusCode >= 200 && res.statusCode < 300) {
                    resolve();
                  } else {
                    reject(new Error(`OpenSearch error: ${res.statusCode} - ${data}`));
                  }
                });
              });

              req.on('error', (err) => {
                console.error(`Request failed: ${err}`);
                reject(err);
              });

              req.write(json);
              req.end();
            });
          }

          function streamToString(stream) {
            return new Promise((resolve, reject) => {
              const chunks = [];
              stream.on('data', (chunk) => chunks.push(chunk));
              stream.on('error', reject);
              stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
            });
          }

  LambdaInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref IngestLambdaFunction
      Action: lambda:InvokeFunction
      Principal: s3.amazonaws.com

  ## S3
  IngestBucket:
    Type: AWS::S3::Bucket
    Properties:
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: "s3:ObjectCreated:*"
            Function: !GetAtt IngestLambdaFunction.Arn

  ## API Gateway
  Gateway:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub "${ProjectName}-APIGateway"

  GatewayRootMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      ResourceId: !GetAtt Gateway.RootResourceId
      RestApiId: !Ref Gateway
      HttpMethod: ANY
      AuthorizationType: NONE
      Integration:
        IntegrationHttpMethod: "ANY"
        Type: "HTTP_PROXY"
        Uri: !Sub "${OpenSearchDomainEndpoint}/"

  GatewayProxy:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt Gateway.RootResourceId
      RestApiId: !Ref Gateway
      PathPart: "{proxy+}"

  GatewayProxyMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      ResourceId: !Ref GatewayProxy
      RestApiId: !Ref Gateway
      HttpMethod: ANY
      AuthorizationType: NONE
      RequestParameters:
          method.request.path.proxy: true
      Integration:
        IntegrationHttpMethod: "ANY"
        Type: HTTP_PROXY
        Uri: !Sub "${OpenSearchDomainEndpoint}/{proxy}"
        RequestParameters:
          integration.request.path.proxy: "method.request.path.proxy"
        Credentials: !GetAtt APIGatewayRole.Arn

  GatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - GatewayRootMethod
      - GatewayProxyMethod
    Properties:
      RestApiId: !Ref Gateway

  # Create a CloudWatch Logs Log Group for API Gateway logs
  GatewayLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/apigateway/${ProjectName}"
      RetentionInDays: 14

  # Create an explicit API Gateway Stage with logging settings
  GatewayStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      StageName: prod
      DeploymentId: !Ref GatewayDeployment
      RestApiId: !Ref Gateway
      MethodSettings:
        - DataTraceEnabled: true
          HttpMethod: "*"
          LoggingLevel: INFO
          ResourcePath: "/*"
      AccessLogSetting:
        DestinationArn: !GetAtt GatewayLogGroup.Arn
        Format: >
          { "requestId": "$context.requestId", "ip": "$context.identity.sourceIp", "caller": "$context.identity.caller", "user": "$context.identity.user", "requestTime": "$context.requestTime", "httpMethod": "$context.httpMethod", "resourcePath": "$context.resourcePath", "status": "$context.status", "protocol": "$context.protocol", "responseLength": "$context.responseLength", "integrationStatus": "$context.integration.status", "integrationError": "$context.integration.error", "integrationLatency": "$context.integration.latency", "integrationRequestId": "$context.integration.requestId", "integrationService": "$context.integration.service", "integrationPath": "$context.integration.path" }

Outputs:
  BucketName:
    Value: !Ref IngestBucket
  ApiInvokeURL:
    Value: !Sub "https://${Gateway}.execute-api.${AWS::Region}.amazonaws.com/prod"
  IngestLambdaName:
    Value: !Ref IngestLambdaFunction
  GatewayValue:
    Value: !Ref Gateway
  OpenSearchDomainEndpoint:
    Value: !Ref OpenSearchDomainEndpoint
2 Answers
0
Accepted Answer

Turns out the problem was that API Gateway isn't able to talk directly to OpenSearch for some reason, so instead I went Web <-> API Gateway <-> Lambda <-> OpenSearch. Here's a CloudFormation that worked for others curious to replicate this:

AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Web -> API Gateway -> Lambda -> OpenSearch stack + Amplify-hosted frontend and S3-based ingestion

Parameters:
  ProjectName:
    Type: String
  GitHubRepo:
    Type: String
    Description: "Format: owner/repo"
  GitHubBranch:
    Type: String
    Default: "main"
  GitHubToken:
    Type: String
    NoEcho: true
  OpenSearchDomainName:
    Type: String
    Description: "OpenSearch domain name"
  OpenSearchDomainEndpoint:
    Type: String
    Description: "OpenSearch domain endpoint (https://...)"
  LambdaCodeBucket:
    Type: String
    Description: "S3 bucket for Lambda code"
  IndexLambdaKey:
    Type: String
  SearchLambdaKey:
    Type: String

Resources:

  # IAM Roles

  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-LambdaRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: LambdaPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject # for S3 ingestion access
                Resource: "*"
              - Effect: Allow
                Action:
                  - es:ESHttp*  # for OpenSearch access
                Resource: !Sub "arn:${AWS::Partition}:es:${AWS::Region}:${AWS::AccountId}:domain/${OpenSearchDomainName}/*"
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"

  AmplifyServiceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-AmplifyServiceRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: amplify.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: AmplifySSRPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cloudformation:*
                  - s3:*
                  - iam:PassRole
                  - lambda:*
                  - logs:*
                  - ec2:Describe*
                  - ec2:CreateNetworkInterface
                  - ec2:DeleteNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                Resource: "*"

  APIGatewayRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-APIGatewayRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: apigateway.amazonaws.com
            Action: sts:AssumeRole

  # S3

  IngestBucket:
    Type: AWS::S3::Bucket
    DependsOn: LambdaInvokePermissionForIndexing
    Properties:
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: "s3:ObjectCreated:*"
            Function: !GetAtt IndexLambda.Arn

  # Lambda

  LambdaInvokePermissionForIndexing:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref IndexLambda
      Action: lambda:InvokeFunction
      Principal: s3.amazonaws.com

  LambdaInvokePermissionForSearching:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref SearchLambda
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${Gateway}/*/*/*"

  IndexLambda:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs22.x
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 30
      Environment:
        Variables:
          OPENSEARCH_ENDPOINT: !Ref OpenSearchDomainEndpoint
      Code:
        S3Bucket: !Ref LambdaCodeBucket
        S3Key: !Ref IndexLambdaKey

  # Lambda to proxy requests to OpenSearch

  SearchLambda:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs22.x
      Handler: search.handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 30
      Environment:
        Variables:
          OPENSEARCH_ENDPOINT: !Ref OpenSearchDomainEndpoint
      Code:
        S3Bucket: !Ref LambdaCodeBucket
        S3Key: !Ref SearchLambdaKey

  # API Gateway

  Gateway:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub "${ProjectName}-APIGateway"

  GatewaySearchResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt Gateway.RootResourceId
      PathPart: "search"
      RestApiId: !Ref Gateway

  GatewaySearchProxyResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !Ref GatewaySearchResource
      PathPart: "{proxy+}"
      RestApiId: !Ref Gateway

  GatewaySearchMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      AuthorizationType: NONE
      HttpMethod: ANY
      ResourceId: !Ref GatewaySearchProxyResource
      RestApiId: !Ref Gateway
      RequestParameters:
        method.request.path.proxy: true
      Integration:
        IntegrationHttpMethod: POST
        Type: AWS_PROXY
        Uri: !Sub "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SearchLambda.Arn}/invocations"

  GatewayIndexResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt Gateway.RootResourceId
      PathPart: "index"
      RestApiId: !Ref Gateway

  GatewayIndexProxyResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !Ref GatewayIndexResource
      PathPart: "{proxy+}"
      RestApiId: !Ref Gateway

  GatewayIndexMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      HttpMethod: ANY
      AuthorizationType: NONE
      ResourceId: !Ref GatewayIndexProxyResource
      RestApiId: !Ref Gateway
      RequestParameters:
        method.request.path.proxy: true
      Integration:
        IntegrationHttpMethod: POST
        Type: AWS_PROXY
        Uri: !Sub "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${IndexLambda.Arn}/invocations"

  GatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - GatewayIndexMethod
      - GatewaySearchMethod
    Properties:
      RestApiId: !Ref Gateway
      StageName: prod

  # Amplify

  AmplifyApp:
    Type: AWS::Amplify::App
    Properties:
      Name: !Ref ProjectName
      Repository: !Sub "https://github.com/${GitHubRepo}"
      OauthToken: !Ref GitHubToken
      IAMServiceRole: !GetAtt AmplifyServiceRole.Arn
      EnvironmentVariables:
        - Name: API_GATEWAY_ENDPOINT
          Value: !Sub "https://${Gateway}.execute-api.${AWS::Region}.amazonaws.com/prod"

  AmplifyBranch:
    Type: AWS::Amplify::Branch
    Properties:
      AppId: !GetAtt AmplifyApp.AppId
      BranchName: !Ref GitHubBranch

Outputs:
  BucketName:
    Value: !Ref IngestBucket
  AmplifyURL:
    Value: !GetAtt AmplifyApp.DefaultDomain
  ApiInvokeURL:
    Value: !Sub "https://${Gateway}.execute-api.${AWS::Region}.amazonaws.com/prod"
  IndexLambdaName:
    Value: !Ref IndexLambda
  SearchLambdaName:
    Value: !Ref SearchLambda
  GatewayValue:
    Value: !Ref Gateway
  OpenSearchDomainEndpoint:
    Value: !Ref OpenSearchDomainEndpoint

answered 2 years ago

0

Based on your setup and the error message you're receiving, there are a few things to consider:

  1. The error message suggests that the request is being treated as anonymous, which indicates that the API Gateway is not successfully signing the request to OpenSearch. This could be due to the way the integration is set up in your API Gateway method.

  2. In your CloudFormation template, you're using an HTTP_PROXY integration type for the API Gateway method. However, for AWS services like OpenSearch that require request signing, you should use the AWS_PROXY integration type instead.

  3. The IAM role for API Gateway (APIGatewayRole) has the correct permissions, but it's not being used effectively due to the integration type.

To resolve this issue, try the following changes to your CloudFormation template:

  1. Change the Integration Type: In the GatewayProxyMethod resource, change the Integration Type from HTTP_PROXY to AWS_PROXY.

  2. Update the Uri: Change the Uri to use the arn format for OpenSearch.

  3. Remove the Credentials field: With AWS_PROXY, you don't need to specify the Credentials explicitly.

Here's how the updated GatewayProxyMethod should look:

GatewayProxyMethod:
  Type: AWS::ApiGateway::Method
  Properties:
    ResourceId: !Ref GatewayProxy
    RestApiId: !Ref Gateway
    HttpMethod: ANY
    AuthorizationType: NONE
    Integration:
      IntegrationHttpMethod: POST
      Type: AWS_PROXY
      Uri: !Sub "arn:aws:apigateway:${AWS::Region}:es:path/${OpenSearchDomainName}/{proxy}"
    RequestParameters:
      method.request.path.proxy: true

Also, ensure that your OpenSearch domain's access policy allows the API Gateway's execution role. You may need to update the Principal in your OpenSearch policy to include the API Gateway's execution role ARN.

These changes should allow API Gateway to properly sign the requests to OpenSearch using SigV4, resolving the authorization error you're encountering.
Sources
AWS::ApiGateway::Method - AWS CloudFormation
Amazon OpenSearch Service ML connectors for AWS services - Amazon OpenSearch Service

answered 2 years ago

  • Unfortunately, this won't work for me because the requests are originating with both Lambda and an Amplify web app.

You are not logged in. Log in to post an answer.

A good answer clearly answers the question and provides constructive feedback and encourages professional growth in the question asker.