iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Amazon Verified Permissionsで、API Gateway + Cognito構成のAPIの認可設定をする

はじめに

こんにちは、iimonでエンジニアをしているhogeです。

普段通り生活していたら、ふと思い立ったかのように API Gateway + Cognito構成のサーバレスのアプリケーションに認可を組み込む方法が気になることってありますよね。

AWSの最近のアップデートでAmazon Verified Permissionsを使って、Amazon Cognitoグループに対して簡単に認可を設定できるようになったようだったので、試してみたいと思います。

https://aws.amazon.com/about-aws/whats-new/2024/04/amazon-cognito-customers-access-apis-verified-permissions/

Amazon Verified Permissionsとは

簡単に説明すると、ポリシーを事前に登録することで、アプリケーションの認可をアプリケーションの外側で行うことができるサービスです。

https://aws.amazon.com/verified-permissions/

ポリシーはCedarというAWSOSSのポリシー言語で記述します。

permit(
  principal == User::"alice", 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource == Photo::"VacationPhoto94.jpg"
);

例えば上記のポリシーは、aliceがvacationPhoto94.jpg という写真を表示、編集、削除することを許可します。

IAMポリシーのように主に以下の3つの要素で許可・拒否していくかを書いていくことになります。

  • principal
    • Alice
  • action
    • view,edit,deny,delete
  • resource
    • VacationPhoto94.jpg

API Gateway + Cognitoでバックエンドを作成する

API GatewayREST APIでリソースを作成

mockで以下のリソースを作成しました。

/admin/hoge : ANY

curl https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/hoge
{
    "message":"hoge"
}

/public/fuga : ANY

curl https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/public/fuga
{
    "message":"fuga"
}

Cognitoにユーザプールを作成する

  • admin、developersの2つのグループを作成
  • test1,test2の2つのユーザを作成
  • test1をadminグループに追加
  • test2をdevelopersグループに追加

Amazon Verified Permissionsでポリシーストアを作成する

作成する際の起動オプションに「API Gateway とID プロバイダーによるセットアップ」があるので、これを選択する

作成したAPI Gatewayとステージを選択して「APIをインポート」を押す 自動的にAPI Gatewayのメソッド・リソースを検出し、CederのアクションAPI Gatewayのリソースをマッピングしてくれる cognitoのユーザプールを選択します(cognito以外のidpも選択できるが、グループ単位で認可を行う機能はcognitoでしか使えない) ユーザグループを選択肢、マッピングしたCederアクションに対して許可するか拒否するかを設定する。この設定がポリシードキュメントに反映される。

以下のように設定

  • adminは全てのAPIを許可
  • developersはpublic/以下のみ許可 この設定でポリシーストアを作成すると、cloudfortmationによってLambdaのAuthorizerなどが生成されます。

    動作確認

cognitoの認証で認証されていないユーザはAPIへアクセスできなくなった(401)

curl -i  https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/public/fuga
HTTP/2 401
date: Tue, 11 Jun 2024 01:55:45 GMT
content-type: application/json
content-length: 26
x-amzn-requestid: 374f8fab-14fc-4d49-a349-ae77bfe2846d
x-amzn-errortype: UnauthorizedException
x-amz-apigw-id: ZLg5PG9YtjMENIg=

{"message":"Unauthorized"}%                                                                                                                                                                                                               

cognitoのアクセストークンを取得

test1token=`aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH --client-id $client_id  --auth-parameters USERNAME=test1,PASSWORD=$PASSWORD | jq -r '.AuthenticationResult.AccessToken'`
test2token=`aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH --client-id $client_id  --auth-parameters USERNAME=test2,PASSWORD=$PASSWORD | jq -r '.AuthenticationResult.AccessToken'`

認証されたユーザにアクセスできるようになったので、どちらのグループからも許可されているpublic/ のエンドポイントにアクセスしてみる

どちらもアクセスできる

curl -i -H "Authorization:$test1token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/public/fuga

HTTP/2 200
date: Tue, 11 Jun 2024 02:30:35 GMT
content-type: application/json
content-length: 24
x-amzn-requestid: 39f11321-c4df-418e-adf0-b049451c1e3c
x-amz-apigw-id: ZLl_iH_RNjMEZ9A=
x-amzn-trace-id: Root=1-6667b6c9-5cdeb5fa07cd04460fbf5359;Parent=4951c4ec2b145d6f;Sampled=0;lineage=06faa94e:0

{
    "message":"fuga"
}%                    
curl -i -H "Authorization:$test2token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/public/fuga
HTTP/2 200
date: Tue, 11 Jun 2024 02:31:44 GMT
content-type: application/json
content-length: 24
x-amzn-requestid: 60a42197-2ecc-4b6e-a6cb-f1ce0c222df7
x-amz-apigw-id: ZLmKlHj0tjMEMUg=
x-amzn-trace-id: Root=1-6667b710-31d98cfd2750715f74a2ab5f;Parent=7ee3c071aa80beb3;Sampled=0;lineage=06faa94e:0

{
    "message":"fuga"
}%               

次にtest1の所属しているadminグループのみアクセスが許可されている/admin/hogeにアクセスしてみる

test1はアクセスできる

curl -i -H "Authorization:$test1token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/hoge

HTTP/2 200
date: Tue, 11 Jun 2024 02:33:01 GMT
content-type: application/json
content-length: 24
x-amzn-requestid: 27976cdb-f2ed-4326-a9d5-26c2b06dfb88
x-amz-apigw-id: ZLmWgGmntjMEB0w=
x-amzn-trace-id: Root=1-6667b75c-4a3bbe422850c44e6fa1382d;Parent=213d9b02594070b1;Sampled=0;lineage=06faa94e:0

{
    "message":"hoge"
}%                  

test2は認可が通らず403エラーが返される

curl -i -H "Authorization:$test2token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/hoge
HTTP/2 403
date: Tue, 11 Jun 2024 02:33:20 GMT
content-type: application/json
content-length: 82
x-amzn-requestid: 9856853a-6fd9-4ad3-9de9-9e7fc880d26f
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: ZLmZoHMztjMEvBQ=

{"Message":"User is not authorized to access this resource with an explicit deny"}%                                                                                                                                                       

上記のようにAmazon Verified Permissionsで設定した通りの期待した通りの挙動になりました!

Verified Permissionsのセットアップで作成されたリソースを確認する

今回の構成では以下のリソースが作成されます

cognito,apigw,backendのリソースは自分で作っていますが、認可部分のAuthorizerとVerified Permissionsのドキュメントは自動で生成されています。

verified permissions

以下を検証するverified permissionsのCederのドキュメントが作成されている

permit(
  principal in test::UserGroup::"ap-northeast-1_Ltf4Zs6sc|developers",
  action in [ test::Action::"get /public/fuga", test::Action::"put /public/fuga", kterasihblogtest::Action::"delete /public/fuga", test::Action::"post /public/fuga", test::Action::"patch /public/fuga" ],
  resource
  );
permit (
    principal in test::UserGroup::"ap-northeast-1_Ltf4Zs6sc|admin",
    action in
        [test::Action::"delete /admin/hoge",
         test::Action::"delete /public/fuga",
         test::Action::"get /admin/hoge",
         test::Action::"get /public/fuga",
         test::Action::"patch /admin/hoge",
         test::Action::"patch /public/fuga",
         test::Action::"post /admin/hoge",
         test::Action::"post /public/fuga",
         test::Action::"put /admin/hoge",
         test::Action::"put /public/fuga"
         ],
    resource
);

Lambda Authorizer

API Gatewayを確認すると、認可の部分にLambda Authorizerが関連付けられている lambdaを確認すると以下の環境変数が自動的に設定されている

ENDPOINT 
NAMESPACE
POLICY_STORE_ID
TOKEN_TYPE

Authorizer側ではロジックを持たず、verified permissionsにリクエストして認可結果を返す、汎用的なものになっている

コード

const { VerifiedPermissions } = require('@aws-sdk/client-verifiedpermissions');
const policyStoreId = process.env.POLICY_STORE_ID;
const namespace = process.env.NAMESPACE;
const tokenType = process.env.TOKEN_TYPE;
const resourceType = `${namespace}::Application`;
const resourceId = namespace;
const actionType = `${namespace}::Action`;

const verifiedpermissions = !!process.env.ENDPOINT
  ? new VerifiedPermissions({
    endpoint: `https://${process.env.ENDPOINT}ford.${process.env.AWS_REGION}.amazonaws.com`,
  })
  : new VerifiedPermissions();

function getContextMap(event) {
  const hasPathParameters = Object.keys(event.pathParameters).length > 0;
  const hasQueryString = Object.keys(event.queryStringParameters).length > 0;
  if (!hasPathParameters && !hasQueryString) {
    return undefined;
  }
  const pathParametersObj = !hasPathParameters ? {} : {
    pathParameters: {
      // transform regular map into smithy format
      record: Object.keys(event.pathParameters).reduce((acc, pathParamKey) => {
        return {
          ...acc,
          [pathParamKey]: {
            string: event.pathParameters[pathParamKey]
          }
        }
      }, {}),
    }
  };
  const queryStringObj = !hasQueryString ? {} : {
    queryStringParameters: {
      // transform regular map into smithy format
      record: Object.keys(event.queryStringParameters).reduce((acc, queryParamKey) => {
        return {
          ...acc,
          [queryParamKey]: {
            string: event.queryStringParameters[queryParamKey]
          }
        }
      }, {}),
    }
  };
  return {
    contextMap: {
      ...queryStringObj,
      ...pathParametersObj,
    }
  };
}

async function handler(event, context) {
  // https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html
  // > Header names and query parameters are processed in a case-sensitive way.
  // https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2
  // > header field names MUST be converted to lowercase prior to their encoding in HTTP/2
  // curl defaults to HTTP/2
  let bearerToken =
    event.headers?.Authorization || event.headers?.authorization;
  if (bearerToken?.toLowerCase().startsWith('bearer ')) {
    // per https://www.rfc-editor.org/rfc/rfc6750#section-2.1 "Authorization" header should contain:
    //  "Bearer" 1*SP b64token
    // however, match behavior of COGNITO_USER_POOLS authorizer allowing "Bearer" to be optional
    bearerToken = bearerToken.split(' ')[1];
  }
  try {
    const parsedToken = JSON.parse(Buffer.from(bearerToken.split('.')[1], 'base64').toString());
    const actionId = `${event.requestContext.httpMethod.toLowerCase()} ${event.requestContext.resourcePath}`;

    const input = {
      [tokenType]: bearerToken,
      policyStoreId: policyStoreId,
      action: {
        actionType: actionType,
        actionId: actionId,
      },
      resource: {
        entityType: resourceType,
        entityId: resourceId
      },
      context: getContextMap(event),
    };

    const authResponse = await verifiedpermissions.isAuthorizedWithToken(input);
    console.log('Decision from AVP:', authResponse.decision);
    let principalId = `${parsedToken.iss.split('/')[3]}|${parsedToken.sub}`;
    if (authResponse.principal) {
      const principalEidObj = authResponse.principal;
      principalId = `${principalEidObj.entityType}::"${principalEidObj.entityId}"`;
    }

    return {
      principalId,
      policyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: 'execute-api:Invoke',
            Effect: authResponse.decision.toUpperCase() === 'ALLOW' ? 'Allow' : 'Deny',
            Resource: event.methodArn
          }
        ]
      },
      context: {
        actionId,
      }
    }
  } catch (e) {
    console.log('Error: ', e);
    return {
      principalId: '',
      policyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: 'execute-api:Invoke',
            Effect: 'Deny',
            Resource: event.methodArn
          }
        ]
      },
      context: {}
    }
  }
}

module.exports = {
  handler,
};

APIリソースを追加する場合

/admin/miyoリソースを追加しました /admin/miyo にauthorizerを追加します どちらのユーザからも認可が失敗してアクセスができなくなります

curl -i -H "Authorization:$test1token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/miyo
HTTP/2 403
date: Tue, 11 Jun 2024 04:32:42 GMT
content-type: application/json
content-length: 82
x-amzn-requestid: 36f7cc77-9ce3-4ff6-8dbc-bec8fb87bb38
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: ZL34vG52tjMElEg=

{"Message":"User is not authorized to access this resource with an explicit deny"}%                                                                                                                                                       

curl -i -H "Authorization:$test1token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/miyo
HTTP/2 403
date: Tue, 11 Jun 2024 04:32:47 GMT
content-type: application/json
content-length: 82
x-amzn-requestid: e21d1357-607a-4cc6-a7b2-1da70baa7883
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: ZL35ZGzwNjMEQcQ=

{"Message":"User is not authorized to access this resource with an explicit deny"}%                                                                                                                                                       

スキーマを更新する ポリシードキュメントを更新してadminグループにadmin/miyoアクションを追加

permit (
    principal in test::UserGroup::"ap-northeast-1_Ltf4Zs6sc|admin",
    action in
        [test::Action::"delete /admin/hoge",
         test::Action::"delete /public/fuga",
         test::Action::"get /admin/hoge",
         test::Action::"get /public/fuga",
         test::Action::"patch /admin/hoge",
         test::Action::"patch /public/fuga",
         test::Action::"post /admin/hoge",
         test::Action::"post /public/fuga",
         test::Action::"put /admin/hoge",
         test::Action::"put /public/fuga",
         test::Action::"get /admin/miyo" //追加

         ],
    resource
);

adminグループからはアクセスできたが、developerグループのユーザからはアクセスできなくなった

curl -i -H "Authorization:$test1token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/miyo
HTTP/2 200
date: Tue, 11 Jun 2024 04:43:54 GMT
content-type: application/json
content-length: 4
x-amzn-requestid: 63028081-1794-495f-88c1-6af177e2081a
x-amz-apigw-id: ZL5hsGkbNjMEiIg=
x-amzn-trace-id: Root=1-6667d60a-1719edd27c3047a0246aab6b;Parent=27a8de5298edadea;Sampled=0;lineage=06faa94e:0

miyo%                    

curl -i -H "Authorization:$test2token" \
https://gd4l55l53d.execute-api.ap-northeast-1.amazonaws.com/test/admin/miyo
HTTP/2 403
date: Tue, 11 Jun 2024 04:43:49 GMT
content-type: application/json
content-length: 82
x-amzn-requestid: e2a30755-2fde-4fb1-b1c0-83b7dfd0cd9a
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: ZL5gvHZktjMEYnQ=

{"Message":"User is not authorized to access this resource with an explicit deny"}%                                                                                                                                                       

verified permissionsを触ってみた感想

以下のユースケースなら効果的に使えそうだと個人的には思いました

基本的な認可の管理

  • ユーザやグループに対して具体的な操作(読み取り、書き込み、削除)などの権限を付与するような基本的な認可の管理
  • 複雑な条件が必要な認可の場合はアプリケーション内で認可を行ったほうが良さそう
    • 例:ユーザの状態や外部システムからのデータに基づく動的な認可

RBAC(ロールベースアクセス制御)

  • 開発者、管理者、エンドユーザなどの異なるロールに対するアクセスレベルの設定

条件付きポリシー

  • 特定のIPの範囲、デバイスタイプ、時間帯、ユーザの情報などの情報を指定して条件付きポリシーを書くのは得意そう
permit(
  principal,
  action == Action::"view",
  resource
)
when {resource.client_ip == "222.222.222.222" && principal.location == "USA"};

ちなみに料金はけっこうお高い

1 か月あたり 最初の 4,000 万件のリクエス 承認リクエスト1件あたり0.00015ドル
次の 1 か月あたり 6,000 万件のリクエス オーソリゼーションリクエスト1件あたり0.000075ドル
毎月 1 億件を超えるリクエス オーソリゼーションリクエスト1件あたり0.00004ドル
ポリシー管理リクエス ポリシー管理リクエスト1件あたり0.00004ドル

月に150万リクエストの場合、150 万 * 0.00015 USD = 225USDになる

さいごに

複雑な認可要件がある場合、対応できるかはわからないですが、アプリケーションのアクセスコントロールをポリシーで容易に実装できるのはとても便利だと思いました。特にVerified Permissionsの自動セットアップ機能は既存のサーバレスアプリケーションに認可を追加したい場合には採用しやすいと思いました。

弊社では現在エンジニアを募集しております!

この記事を読んで少しでも弊社に興味を持っていただけましたら是非カジュアル面談でお話ししましょう!!お話しできるのを楽しみにしています!! Wantedly / Green