• 作成:

amazonkaとservant-clientを組み合わせてIAM認証を有効にしたAPI Gatewayのカスタムドメインにリクエストを投げる

問題

Haskellにはamazonkaという非公式のAWS SDKがあります。これはPython向けのSDKであるBoto3から自動生成されたものなので、大抵の操作を行うことが出来ます。

しかしAPI GatewayをAPIの生成とかで操作するならともかく、デフォルトでexecute-apiになるAPIを呼び出す方法がよく分かりませんでした。

もちろん単純なAPI Gatewayならば単にHTTPで呼び出せば良いだけなのですが、今回API Gatewayを入れたのはIAM認証をしたかったからなので、認証データを入れないといけないわけですね。

しかもカスタムドメインを使っているため、そのままフルパスをパスに入れても当然404 Not Foundになってしまいます。

REST API 用に API Gateway で生成された JavaScript SDK を使用する - Amazon API Gateway とかを見るとSDKを生成させて呼び出すのが推奨らしく、それに対応してないamazonkaで呼び出しする方法がパッとは見つかりませんでした。

ドキュメントやGitHub issueを見る限りBoto3でも直接サポートはしていないようで、別のライブラリやちょっとしたコードで署名してrequestsのヘッダーに載せたりしているようです。 Convenience function for execute-api calls · Issue #1246 · boto/boto3

test-invoke-methodはその名の通りテスト用なので本番には使いたくありません。

他にもNLBではなくALBをバックエンドにしたかったので、 REST APIではなくHTTP APIを使うために、まだAWS CDKでalphaである aws-apigatewayv2-alphaを使ったり、 feat(apigatewayv2): Create HTTP APIs from an OpenAPI specification by miguel-a-calles-mba · Pull Request #20815 · aws/aws-cdkに出ているOpenAPI向けの取り込みコードを頑張ってbackportとして移植しようと頑張りましたが、それは今回とは別の話ですね。

curlとシェルスクリプトなら簡単にできる

参考: 一時的な認証情報を使用した IAM 認証 - Amazon Neptune

最近curlにAWSのサポートが入ったので、 jqと組み合わせれば割と簡単に行うことが出来ます。

#!/bin/bash
set -eu
# AWSに環境変数`$AWS_PROFILE`など、aws cliが理解出来る形式でログイン出来るようになっている必要があります。
# jq, batが必要です。

# aws stsで一時的なキーを発行する。
creds_json=$(aws sts assume-role --role-arn foo --role-session-name bar)
AWS_ACCESS_KEY_ID=$(echo "$creds_json"|jq .Credentials.AccessKeyId|tr -d '"')
AWS_SECRET_ACCESS_KEY=$(echo "$creds_json"|jq .Credentials.SecretAccessKey|tr -d '"')
AWS_SESSION_TOKEN=$(echo "$creds_json"|jq .Credentials.SessionToken|tr -d '"')

AWS_DEFAULT_REGION='ap-northeast-1'
SERVICE="execute-api"

curl \
  -X POST \
  -H 'Content-Type: application/json' \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  --aws-sigv4 "aws:amz:${AWS_DEFAULT_REGION}:${SERVICE}" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
  -d "{\"source\": \"$*\"}" \
  hoge-endpoint|
  jq|
  bat --language json

テスト用スクリプトはこんな感じで置いておくことにします。これまでこのようなテスト用スクリプトは沢山使いました。今後使うかは分かりませんが、他にテストを向ける時のベースのスクリプトになってくれるかもしれませんし。

さてcurlでこのように呼び出せるということは、原理的には署名可能なはずなので、やっていきます。

こだわり

単体で署名を実現するパッケージは複数あるようですが、すぐにエコシステムについていけなくて代替を探すのが目に見えているため、出来るだけamazonkaと既に依存しているパッケージとコンパクトな自分で理解している関数で済ませたいと思います。

amazonkaはバージョンv2

amazonkaのバージョンは現在まだリリースされていないv2のものを使っています。

なぜまだリリースされていないバージョンを使ったのかというと、 AWS SigV4 for non-service endpoints · Issue #763 · brendanhay/amazonka を見たりしてもしかしてv2で解決されてるのではという疑念を抱いたので、どっちが原因かわからないのは嫌だなと思ってアップデートしました。どうせ今の開発はもう少し続くのでいつかはアップデートするので破壊的変更に備えておいたほうが良いですしね。

でも結論的には多分v1でも変わらなかったと思います。

署名

スマートに解決するのを放棄すれば逆に気持ちは楽になってきて、いくらか時間を使ってamazonkaの内部構造を読めば、署名自体は適当にServiceを作ってやれば出来るんだなと理解しました。

それが分かればそこまで実装は難しくありません。雑定義になりますが。

難しいところはRequestの系列が、

  • Network.HTTP.Client.Request
  • Amazonka.Request
  • Servant.Client.Core.Request.Request

と3種類ぐらいを変換しつつ使わなければいけなくなったことぐらいですかね。

{-# LANGUAGE FlexibleContexts  #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
module AWS.ApiGatewayExecute.Sign (awsSignToHeader) where

import           Amazonka                               as AWS
import           Amazonka.APIGatewayManagementAPI.Types as AWS.ApiGwMaApi
import qualified Network.HTTP.Client                    as N
import qualified Network.HTTP.Types.Method              as N
import           RIO
import qualified RIO.ByteString                         as B
import           Servant.Client

-- | 純粋空間で引数から認証済みの`Request`を生成する。
awsSignToHeader
  :: ToBody body
  => AuthEnv -> UTCTime -> BaseUrl -> Region -> body -> N.Request -> N.Request
awsSignToHeader authEnv signingTime baseUrl region body req =
  let awsRequest = httpRequestToAwsRequest baseUrl region body req
      Signed _meta signedReq = requestSign awsRequest authEnv region signingTime
  in signedReq

-- | `Network.HTTP.Client.Request`のデータを変換して`Amazonka.Request`にして`requestSign`で署名する準備を整える。
-- `Network.HTTP.Client.Request`が`Network.HTTP.RequestBody`を含んでいるのに、
-- `body`を引数で別に取っているのは重複しているように見えるが、
-- `Network.HTTP.RequestBody`はバリアントによって純粋空間で取得できるとは限らないため、
-- 呼び出し側でどうにか出来る余地を残す。
httpRequestToAwsRequest
  :: ToBody body
  => BaseUrl -> Region -> body -> N.Request -> AWS.Request a
httpRequestToAwsRequest baseUrl region body req =
  AWS.Request
  { _requestService = baseUrlExecuteApiService baseUrl region
  , _requestMethod = fromRight (error "Could not parse request method.") $ N.parseMethod $ N.method req
  , _requestPath = rawPath $ N.path req
  , _requestQuery = parseQueryString $ fromMaybe "" $ B.stripPrefix "?" $ N.queryString req
  , _requestHeaders = N.requestHeaders req
  , _requestBody = toBody body
  }

-- | `BaseUrl`からある程度導出出来るカスタムドメインでのサービス定義。
baseUrlExecuteApiService :: BaseUrl -> Region -> Service
baseUrlExecuteApiService BaseUrl{baseUrlScheme, baseUrlHost, baseUrlPort} =
  customDomainExecuteApiService (fromString baseUrlHost) (baseUrlScheme == Https) baseUrlPort

-- | カスタムドメインでのAPI Gatewayのサービス定義。
customDomainExecuteApiService :: ByteString -> Bool -> Int -> Region -> Service
customDomainExecuteApiService customEndpointHost customEndpointSecure customEndpointPort region =
  executeApiService $ const $ Endpoint
  { _endpointHost = customEndpointHost
  , _endpointSecure = customEndpointSecure
  , _endpointPort = customEndpointPort
  , _endpointScope = toBS region
  }

-- | API GatewayのIAM認証突破を行うためのサービス定義。
-- API Gatewayではカスタムドメインが定義できるため、`Endpoint`は引数で定義出来るようにする。
executeApiService :: (Region -> Endpoint) -> Service
executeApiService endpoint =
  Service
  { _serviceAbbrev = "APIGatewayExecuteAPI"
  , _serviceSigner = _serviceSigner AWS.ApiGwMaApi.defaultService
  , _serviceEndpointPrefix = "execute-api"
  , _serviceSigningName = "execute-api"
  , _serviceVersion = _serviceVersion AWS.ApiGwMaApi.defaultService
  , _serviceEndpoint = endpoint
  , _serviceTimeout = Just 70
  , _serviceCheck = statusSuccess
  , _serviceError = parseJSONError "APIGatewayExecuteAPI"
  , _serviceRetry = _serviceRetry AWS.ApiGwMaApi.defaultService
  }

署名の追加

より強い問題はこれを、 servant-client: Automatic derivation of querying functions for servant と組み合わせる時に発生しました。

完全なリクエストからヘッダに署名を追加する仕組みの都合上、 Authの仕組みで予めヘッダに署名を入れるわけにもいかないので、 makeClientRequestでhookして署名を追加するしか無いのですが、 RequestBodyの一部のバリアントがストリームライブラリを使っているのでIOを持ち込まないと完全に取得できず、 makeClientRequestは純粋関数でなくてはいけないため苦悩しました。

元がTextとか固定済みのパラメータのため、ストリームの出現は観測出来なかったため、部分関数にして黙ってクラッシュするかを天秤に掛けて、諦めてunsafePerformIOを使うことにしました。

多分Haskell人生で初めて学習目的とかデバッグ目的ではなく、 unsafePerformIOを自発的に書いたと思います。出来れば書きたくなかった。何か良い方法があれば教えて下さい。

-- | AWSへ認証ありでリクエストを送る。
toRIOAws :: (HasLogFunc env, HasManager env, HasAwsEnv env) => BaseUrl -> ClientM a -> RIO env a
toRIOAws baseUrl clientM = do
  awsEnv <- view awsEnvL
  let auth = runIdentity $ envAuth awsEnv
  signingTime <- getCurrentTime
  let region = envRegion awsEnv
  withAuth auth $ \authEnv -> do
    let makeClientRequest _baseUrl servantRequest =
          let body = case S.requestBody servantRequest of
                Nothing                            -> ""
                Just (requestBodyMain, _mediaType) -> getRequestBody requestBodyMain
              httpRequest = defaultMakeClientRequest baseUrl servantRequest
          in awsSignToHeader authEnv signingTime baseUrl region body httpRequest
    manager' <- view managerL
    let policy = fullJitterBackoffOfCustomPolicy
        clientEnv = ClientEnv
          { manager = manager'
          , baseUrl
          , cookieJar = Nothing
          , makeClientRequest
          }
    res <- retryingForNetwork policy $ const $ liftIO $ runClientM clientM clientEnv
    either logErrorAndThrowM return res

-- | `RequestBody`を`ByteString`にする。
-- `makeClientRequest`内部で動かないといけないので、純粋関数にする必要がある。
-- よって`IO`が扱えない。
-- `unsafePerformIO`でごまかしている。
getRequestBody :: S.RequestBody -> ByteString
getRequestBody (S.RequestBodyLBS x)    = convert x
getRequestBody (S.RequestBodyBS  x)    = x
-- この`unsafePerformIO`めちゃくちゃ消したいし、ちゃんと動くかどうかの検証もしてない。
-- ただ部分関数にして単にクラッシュするのと、どちらがマシかは微妙なラインだと思うので、とりあえずやっつけ実装を書いている。
getRequestBody (S.RequestBodySource x) = unsafePerformIO $ do
  runSimpleApp $ logWarn "call getRequestBody/unsafePerformIO"
  e <- runExceptT $ S.runSourceT x
  case e of
    Left  l -> error l
    Right r -> return $ convert $ mconcat r

貢献

これ出来ればamazonkaだけでサクッと実現出来れば良いなと思っているのですが、送るとしたらどういうパッケージ空間になるのか分かりません。