CDKTFで Google CloudのAPI GWにAPIキー認証&IP制限付きのAPIを構築してみた

2024.01.26

CX事業本部@大阪の岩田です。

最近Google CloudのAPI Gateway & Cloud Functionsを使ってAPIを構築しているのですが、APIの認証方式としてAPIキーに加えてIP制限をかけたいという要件があったので、CDKTFで構築してみました。

環境

今回使用した各種ライブラリ等のバージョンは以下の通りです

  • @cdktf: 0.20.2
  • @cdktf-cli: 0.20.2
  • @cdktf/provider-google: 13.2.0
  • @cdktf/provider-google-beta: 13.2.0
  • @cdktf/provider-random: 11.0.0
  • @cdktf/provider-archive: 10.0.1

ソースコード

今回は必要なGoogle Cloudの各種APIが事前に有効化されている前提で作っています。本来は以下のブログの用にAPIの有効化も含めて実装した方が良いですが、今回は割愛します。

コードの全体は以下のようになりました。

main.ts

import path = require("path");
import { Construct } from "constructs";
import { App, DataTerraformRemoteStateLocal, Fn, TerraformHclModule, TerraformOutput, TerraformStack, TerraformVariable } from "cdktf";
import { GoogleProvider } from "@cdktf/provider-google/lib/provider";
import { GoogleApiGatewayApi } from "@cdktf/provider-google-beta/lib/google-api-gateway-api";
import { GoogleBetaProvider } from "@cdktf/provider-google-beta/lib/provider";
import { GoogleApiGatewayApiConfigA } from "@cdktf/provider-google-beta/lib/google-api-gateway-api-config";
import { GoogleApiGatewayGateway } from "@cdktf/provider-google-beta/lib/google-api-gateway-gateway";
import { ApikeysKey } from "@cdktf/provider-google/lib/apikeys-key";
import { Cloudfunctions2Function } from "@cdktf/provider-google/lib/cloudfunctions2-function";
import { StorageBucket } from "@cdktf/provider-google/lib/storage-bucket";
import { StorageBucketObject } from "@cdktf/provider-google/lib/storage-bucket-object";
import { DataArchiveFile } from "@cdktf/provider-archive/lib/data-archive-file";
import { ArchiveProvider } from "@cdktf/provider-archive/lib/provider";
import { RandomProvider } from "@cdktf/provider-random/lib/provider";
import { StringResource } from "@cdktf/provider-random/lib/string-resource";

const region = 'asia-northeast1'
const apiGwStackName = 'apiGw'

class ApiGWStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const projectId = new TerraformVariable(this, "projectId", {
      type: "string",
      description: "Google Cloud ProjectId",
    }).value

    new GoogleProvider(this, 'GoogleProvider', {
      project: projectId,
      userProjectOverride: true,
      region
    });

    const googleBetaProvider = new GoogleBetaProvider(this, 'GoogleBetaProvider', {
      project: projectId,
      userProjectOverride: true,
      region,
    });

    new ArchiveProvider(this, 'archive-provider');

    const api = new GoogleApiGatewayApi(this, 'api', {
      project: projectId,
      apiId: 'api',
      provider: googleBetaProvider,
    })

    const bucket = new StorageBucket(this, 'bucket', {
      location: region,
      name: `${projectId}-function-deployment-bucket`
    })

    const helloFuncZip = new DataArchiveFile(this, 'helloFuncArchive', {
      type: 'zip',
      sourceDir: path.resolve(__dirname, 'functions', 'hello'),
      outputPath: path.resolve(__dirname, '..', 'cdktf.out', 'functions', 'out', 'hello.zip'),
    });

    const helloFuncZipObj  = new StorageBucketObject(this, 'funcDeployment', {
      bucket: bucket.name,
      name: 'hello-func.zip',
      source: helloFuncZip.outputPath
    })

    const helloFunc = new Cloudfunctions2Function(this, 'helloFunc', {
      project: projectId,
      location: region,
      name: 'helloFunc',
      buildConfig: {
        runtime: 'nodejs18',
        source: {
          storageSource: {
            bucket: bucket.name,
            object: helloFuncZipObj.name
          }
        },
        entryPoint: 'helloHttp'
      }
    })

    const apiDefFile = Fn.templatefile(path.resolve(__dirname, './swagger.yaml'), {
      region,
      projectId,
      helloFuncName: helloFunc.name
    })

    const apiConfig = new GoogleApiGatewayApiConfigA(this, 'apiConfig', {
      project: projectId,
      provider: googleBetaProvider,
      api: api.apiId,
      apiConfigIdPrefix: 'api-config',
      openapiDocuments: [
        {
          document: {
            contents: Fn.base64encode(apiDefFile),
            path: './swagger.yaml',
          }
        }
      ],
      lifecycle: {
        createBeforeDestroy: true
      },
      dependsOn: [helloFunc]
    })

    new GoogleApiGatewayGateway(this, 'apiGw', {
      provider: googleBetaProvider, 
      apiConfig: apiConfig.id ,
      gatewayId: 'api-gw',
    })

    new TerraformOutput(this, "apiManagedService", {
      value: api.managedService
    });
  }
}

class ApiKeyStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const projectId = new TerraformVariable(this, "projectId", {
      type: "string",
      description: "Google Cloud ProjectId",
    }).value


    new RandomProvider(this, 'RandomProvider')
    new GoogleProvider(this, 'GoogleProvider', {
      project: projectId,
      userProjectOverride: true,
      region
    });
    new GoogleBetaProvider(this, 'GoogleBetaProvider', {
      project: projectId,
      userProjectOverride: true,
      region,
    });

    const apiGwState = new DataTerraformRemoteStateLocal(this, 'apiGwStackState', {
      path: path.resolve(__dirname, `terraform.${apiGwStackName}.tfstate`)
    })
    const apiManagedService = apiGwState.getString('apiManagedService')

    new TerraformHclModule(this, 'enable-apis', {
      source: 'terraform-google-modules/project-factory/google//modules/project_services',
      variables: {
        project_id: projectId,
        enable_apis: true,
        activate_apis: [
          apiManagedService
        ]
      },
    });

    const apiKeySuffix = new StringResource(this, 'apiKeySuffix', {
      length: 8,
      special: false,
      upper: false,
    }).result

    // https://github.com/hashicorp/terraform-provider-google/issues/11726
    new ApikeysKey(this,'apiKey', {
      project: projectId,
      name: `hello-api-${apiKeySuffix}`,
      displayName: `hello-api API Key ${apiKeySuffix}`,
      restrictions: {
        serverKeyRestrictions: {
          allowedIps: ['<アクセスを許可するCIDRブロック>']
        },
        apiTargets: [{
          service: apiManagedService
        }]
      }
    })
  }
}

const app = new App();
const apiGwStack = new ApiGWStack(app, apiGwStackName);
const apiKeyStack = new ApiKeyStack(app, "apiKey");
apiKeyStack.addDependency(apiGwStack)
app.synth();

上記CDKTFの同一階層にswagger.yamlというファイル名で以下のAPI定義を置いています

swagger.yaml

swagger: '2.0'
info:
  title: sample-api
  description: Sample API on API Gateway with a Google Cloud Functions backend
  version: 1.0.0
schemes:
  - https
produces:
  - application/json
paths:
  /hello:
    get:
      summary: Greet a user
      operationId: hello
      x-google-backend:
        address: https://${region}-${projectId}.cloudfunctions.net/${helloFuncName}
      responses:
        '200':
          description: A successful response
          schema:
            type: string
security:
  - apiKey: []            
securityDefinitions:
  apiKey:
    type: apiKey
    name: x-api-key
    in: header

ポイントとしてapiKeyによる認証を定義していますが、リクエストヘッダーを利用して認証する場合、ヘッダー名はx-api-keyである必要があります。このファイルは純粋なYAMLファイルではなく、Terraformのtemplatefile関数を利用してプロジェクトID等の値を動的に埋め込めるようにしています。

OpenAPI ドキュメントのセキュリティ定義オブジェクトで API キーを指定する場合、Endpoints では次のいずれかのスキームが必要です。

name は key、in は query です。 name は api_key、in は query です。 name は x-api-key、in は header です。

OpenAPI 機能の制限 | OpenAPI を使用した Cloud Endpoints | Google Cloud

デプロイするCloud Functionsのコードはfunctions/helloというディレクトリ以下に以下の内容で作成しています。

index.js

const functions = require('@google-cloud/functions-framework');

functions.http('helloHttp', (req, res) => {
  res.send(`Hello ${req.query.name || req.body.name || 'World'}!`);
});

package.json

{
    "name": "hello",
    "version": "1.0.0",
    "main": "index.js",
    "dependencies": {
      "@google-cloud/functions-framework": "^3.0.0"
    }
}

ポイントの解説

以後はコードのポイントとなる部分について解説していきます。まず今回の構成はStackを2つに分割し、スタック間に依存関係を設定しています。

const app = new App();
const apiGwStack = new ApiGWStack(app, apiGwStackName);
const apiKeyStack = new ApiKeyStack(app, "apiKey");
apiKeyStack.addDependency(apiGwStack)
app.synth();

全てを1つのスタックで処理してしまうと、Project API ActivationモジュールでAPIを有効化する際にThe "for_each" set includes values derived from resource attributes that cannot be determined until apply...というエラーが発生するためです。このエラーを回避するため、スタックを分割することでnew TerraformHclModule(this, 'enable-apis', {...でAPIを有効化する際、対象APIのサービス名が確実に解決されるよう調整しています。スタック間でのパラメータの受け渡しについてはDataTerraformRemoteStateLocalを利用していますが、実際に業務で利用する際はstateファイルの保存先としてGCSバケットを利用するのが良いでしょう。

APIの構成をデプロイする箇所は以下のように記述しています。

    const apiConfig = new GoogleApiGatewayApiConfigA(this, 'apiConfig', {
      project: projectId,
      provider: googleBetaProvider,
      api: api.apiId,
      apiConfigIdPrefix: 'api-config',
      openapiDocuments: [
        {
          document: {
            contents: Fn.base64encode(apiDefFile),
            path: './swagger.yaml',
          }
        }
      ],
      lifecycle: {
        createBeforeDestroy: true
      },
      dependsOn: [helloFunc]
    })

一度API Gatewayをデプロイした後に構成を変更して再デプロイできるようにapiConfigIdではなくapiConfigIdPrefixを指定して名前が重複しないようにしています。またlifecyclecreateBeforeDestroy: trueを指定することで、API Gatewayが利用中のAPI構成を削除しようとしてエラーになることを回避しています。

APIキーを作成する部分では、サフィックスとしてランダムな文字列を付与しています。

    const apiKeySuffix = new StringResource(this, 'apiKeySuffix', {
      length: 8,
      special: false,
      upper: false,
    }).result

    // https://github.com/hashicorp/terraform-provider-google/issues/11726
    new ApikeysKey(this,'apiKey', {
      project: projectId,
      name: `hello-api-${apiKeySuffix}`,
      displayName: `hello-api API Key ${apiKeySuffix}`,
      restrictions: {
        serverKeyRestrictions: {
          allowedIps: ['<アクセスを許可するCIDRブロック>']
        },
        apiTargets: [{
          service: apiManagedService
        }]
      }
    })

これはAPIキーが完全に削除されるまでは30日を要するという仕様によってAPIキーの再作成時にエラーが発生することを抑止しています。APIキーの名前を固定化してしまうと、開発段階のトライ&エラーなどでAPIキーを削除→再作成しようとした際に、存在するキーを作成しようとしてエラーになる可能性があります。

API キーを誤って削除した場合、キーを削除してから 30 日以内であれば、そのキーの削除を取り消す(復元する)ことができます。30 日を経過すると、API キーの削除を取り消すことはできません。

API キーを使用して認証する | Google Cloud

今回アクセスを許可するCIDRブロックはオンコードで記述していますが、業務利用する際は別ファイルにCIDRの定義を切り出したりコマンドラインオプション等でvariableを指定する方が良いでしょう。

動作確認

では実際にデプロイして動作確認してみましょう。2つのスタックをまとめてデプロイします。

TF_VAR_projectId=<プロジェクトID> npm run cdktf -- deploy apiGw apiKey

デプロイ完了後API GWのエンドポイントにcurlでリクエストしてみます。まずはAPIキー無しで

curl https://api-gw-35mul75l.an.gateway.dev/hello
{"code":401,"message":"UNAUTHENTICATED: Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API."}

401エラーになりました

次にAPIキー有りかつAPIキーの利用を許可されていないGIPからリクエストしてみます

curl https://api-gw-35mul75l.an.gateway.dev/hello -H "x-api-key: AIzaSyATop7iUnk2Vw7lnkK50kyEIUMSqDj9Yy0"
{"message":"PERMISSION_DENIED: IP address blocked.","code":403}

IP制限によりアクセスがブロックされました。

最後にAPIキー有りかつAPIキーの利用を許可されたGIPからリクエストしてみます

curl https://api-gw-35mul75l.an.gateway.dev/hello -H "x-api-key: AIzaSyATop7iUnk2Vw7lnkK50kyEIUMSqDj9Yy0"
Hello World!

無事に正常系のレスポンスが得られました!

まとめ

筆者は普段AWSのAPI GWをよく触っているのですが、Google CloudのAPI GatewayはAWSと考え方が違う部分があり、最初はその部分に躓きました。

  • APIキーを利用するためにAPIライブラリから対象のAPIを有効化する必要がある
  • IP制限をかける対象はAPI GatewayではなくAPIキーになる

AWSとの違いとして上記のポイントが理解できているとスムーズに構築できそうです。

参考