• 作成:
  • 更新:

異なるAWSアカウントにAPIを提供して、そのアカウント限定でAPIを実行出来る、IAM認証による簡易な制限の方法

あまり大規模にスケールする方法では無いですし、破られてもDoSされる程度の問題しかない場合なので使っている方法なので、大規模だったりものすごい機密情報を扱ってる場合は真似することはオススメしません。

前提

何かしらの便利機能を提供するAPIサーバを開発しています。これを他のプロジェクトに利用してもらいたいですが、 AWSアカウントをそれを使うプロジェクト全てで共有するとリソースが膨大な量になって錯乱しますし、変にいじれる権限を初心者も含む全員に与えたくないです。

よって複数のAWSアカウント(この「アカウント」はAWSのほぼ独立した環境のことで、IAMユーザなどのことではない)を作り、 APIサーバは他のプロジェクトが使っているAWSアカウントの特定のロールならAPIを利用できるようにしたいです。

ここで利用者は基本的に社内の人間や社外でも直にやり取りできる程度の数だと前提に置きます。

ソリューション(仮)

API GatewayをAPIサーバの前に置いてIAM認証で使えるようにします。

Amazon Cognitoなどを持ち出すほどの膨大なアカウントは今の所考慮しなくて良いとします。

API Gatewayの問題点

タイムアウトやサイズ制限が結構厳しく、 API Gatewayを前提にするとS3にデータ置くので後からそれを見てもらう少し汚い回避方法が将来必要になるかもしれません。

全部LambdaにしてAWS WAFとかでIAM認証出来ないかなとかも考えています。それかユーザ数が増えてコードベースでの管理が困難になったら、 Amazon Cognitoなどを使うことも検討しています。

とりあえずは現状のIAM認証について考えます。

方法

多分色々と方法はあると思うんですが、私が今使っている方法はこれです。

構成

構成図

API提供側にAPI実行を許可するRoleであるApiAllowByIamRoleStagのようなRoleを作り、 API利用者側が使うRoleであるLambdaRoleDeveのようなRole名を聞き出して、 CDKによって、

return new iam.Role(this, roleId, {
  roleName,
  assumedBy: new iam.CompositePrincipal(
    new iam.ArnPrincipal(`arn:aws:iam::${callerAccountId}:role/LambdaRoleDeve`),
  ),
});

のように指定してRoleを生成します。

ここでroleIdはCfnのリソース名に使うための論理idでしかないので、 ApiAllowByIamRoleとか好きな名前を使いましょう。我々はスタックごとStageを分けているため、ここにStagとかのステージ名は入りません。

callerAccountIdにはAPI呼び出し側のAWSアカウントID(数値のもの)を入力します。

このRoleを生成する機能は独立した関数(今回はCDKの都合上thisを使うためメソッドになります)にした方がわかりやすいでしょう。

/** 特定の外部のソースからapiにアクセスすることを許可する。 */
apiAllowByIamRole(stage: Stage): iam.Role {
  const roleId = "ApiAllowByIamRole";
  const roleName = `ApiAllowByIamRole${toTitleCase(stage)}`;
  const callerAccountId = "dummy aws account id"
  switch (stage) {
    // こちらにとってはstagでも外部開発者にとってはstagはdeveで検証する対象なため、
    // 広く公開範囲を取ってしまいます。
    case "deve":
    case "stag":
      return new iam.Role(this, roleId, {
        roleName,
        assumedBy: new iam.CompositePrincipal(
          new iam.ArnPrincipal(`arn:aws:iam::${callerAccountId}:role/LambdaRoleDeve`),
        ),
      });
    case "prod":
      return new iam.Role(this, roleId, {
        roleName,
        assumedBy: new iam.CompositePrincipal(
          new iam.ArnPrincipal(`arn:aws:iam::${callerAccountId}:role/LambdaRoleProd`),
        ),
      });
    default:
      throw new Error(stage satisfies never);
  }
}

このようにしておけば、 APIを呼び出して利用する側の人たちは開発中にDeveやStagにアクセスできますし、 Prodで誤ってStagにアクセスすることも無いでしょう。

API利用側に開発中DeveではなくStagにアクセスするように求めているのは、 DeveはAPI開発側で大胆に色々試行錯誤するのに使うため、ぶっ壊れることがそこそこあるため、 API利用側の開発を阻害してしまうのを防ぐために、 Stagとしてある程度安定して動くことを確認したものを渡したいからです。 Deveでも許可して大胆に変更した時にAPI利用側でも問題ないことを確認しやすくしたり、 Stagが一時的にぶっ壊れた時に開発を継続しやすくしています。

さて生成したRoleにAPI Gatewayの呼び出しを許可する必要があります。

まず、

/** このHTTP API全体のRouteにアクセス出来るArn。 */
produceAllRouteArn(): string {
  return `arn:aws:execute-api:${this.region}:${this.account}:${this.httpApi.apiId}/*`;
}

/** `HttpRoute#grantInvoke`がルートへのアクセスしか許可しないため、自前でポリシーを作って割り当てる。 */
grantInvokeAll(grantee: iam.IGrantable) {
  if (!(this.authorizer instanceof HttpIamAuthorizer)) {
    throw new Error("To use grantInvokeAll, you must use IAM authorization");
  }
  return iam.Grant.addToPrincipal({
    grantee,
    actions: ["execute-api:Invoke"],
    resourceArns: [this.produceAllRouteArn()],
  });
}

のようなメソッドを作って、

this.httpApiStack.grantInvokeAll(role);

のように許可しています。

呼び出し方

以上の構成が既にされていることを前提に動かします。

実際の呼び出すコードをPythonを使って例示します。なんでPythonなのかは向こうが使ってるだけでよく分かりません。

本当はboto3だけで動かそうと思ったんですが、リクエストに署名するだけでやたらと面倒そうだったので素直にパッケージを使うことにしました。

import json
import urllib.request

import boto3
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
from requests_aws4auth import AWS4Auth

region_name = "ap-northeast-1"
sts_client = boto3.client(
    "sts",
    region_name,
    endpoint_url="https://sts.ap-northeast-1.amazonaws.com",
)
endpoint_host = "server.stag.foo.example.com"

path = "/hoge"
url = "https://" + endpoint_host + path
data = {"foo": "bar"}
credentials = sts_client.assume_role(
    RoleArn="arn:aws:iam::000000000000:role/ApiAllowByIamRoleStag",
    RoleSessionName="ApiAllowByIamRoleStag",
)["Credentials"]
auth = AWS4Auth(
    credentials["AccessKeyId"],
    credentials["SecretAccessKey"],
    region_name,
    "execute-api",
    session_token=credentials["SessionToken"],
)
print(requests.put(url, auth=auth, json=data).json())

のような形が一つの例です。

ここで重要なのはLambda(別にECSとかでも良いけれど)の属するRoleがApiAllowByIamRoleStagassume_roleしていることです。よって呼び出し側は少なくともそのリソースにassume_roleする権限を、コードを実行するRoleに与えないといけません。またそのRoleはLambdaRoleDeveのように、呼び出される側のAWSアカウントでAPIを実行することを許可されていないといけません。

参考文献