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
を指定して名前が重複しないようにしています。またlifecycle
でcreateBeforeDestroy: 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との違いとして上記のポイントが理解できているとスムーズに構築できそうです。