Amazon Verified Permissionsで新品な認可を試してみる。

## はじめに

Cognitoの開発者ガイドを見ていると、`Amazon Verified Permissions`（AVP）を使った認可の仕組みの記事がありました。

ちょっと便利そうなので検証してみます。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-authorization-with-avp.html

## Amazon Verified Permissionsって？

概要はそのまま転記します。

> Amazon Verified Permissions は、証明可能な正しい Cedar ポリシー言語を使用するフルマネージド型の認可サービスであるため、より安全なアプリケーションを構築できます。Verified Permissions を使用すると、開発者は 認可を外部化し、ポリシーのマネジメントを一元化することで、アプリケーションを迅速に構築できます。

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

### Cedarで「誰が」「何を」「どのリソースに」の認可

AVPはCedar（シーダー）言語を使った、「誰が」「何を」「どのリソースに」を基本に認可を行います。

なお、Cedar言語は、Amazonが開発したポリシー記述専用の言語のようです。

以下みたいな直観的な書き方で、認可の制御を行うことができ、これを`ポリシー`と呼びます。

```
permit(
  principal is MyApp::User, // ユーザーは（誰が）
  action == MyApp::Action::"view", // 閲覧出来る(何を)
  resource is MyApp::Photo // 写真を（どのリソースに）
)
when {
  resource.owner == principal // リソースのオーナーが自身の場合
};
```

また、Cedar内の`MyApp::User`などの記載は下記`スキーマ`で定義しておきます。

```
{
  "MyApp": {
    "entityTypes": { 
      "User": {},
      "Photo": {
        "shape": {
          "type": "Record",
          "attributes": {
            "owner": { "type": "Entity", "name": "User" }
          }
        }
      }
    },
    "actions": {
      "view": {},
      "upload": {},
      "delete": {}
    }
  }
}
```

## Cognito IDプールを使った認可との違い

「認可」と言えばCognitoのIDプールですが、IDプールでの認可はIAMポリシーで表現できるレベルの認可となり、AWSリソースへの認可が基本となってきます。

その一方で、AVPはAWSリソースよりは、ビジネスロジック寄りな認可が可能です。

## やってみよう！

### API Gateway と ID プロバイダーによるセットアップ

今回はシンプルなCognitoとAPI Gatewayとの統合で試してみます。

Cedarやを意識することなく、簡単にセットアップできるみたいです。

![スクリーンショット 2025-05-22 5.48.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/fd8b7359-4ed5-48f5-9517-fa22ab8d5cb6.png)

・・・新品です？

### API Gatewayの指定

案内に従って、API Gatewayを設定します。

![スクリーンショット 2025-05-22 5.48.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/05812f58-8c7b-40d9-8748-50e8535a4051.png)

今回は以下のようなAPIのエンドポイントとし、`Operator`の属性のメンバーはユーザ情報を取得はできるけど、登録はできない。みたいなケースを想定して作ってみます。

![スクリーンショット 2025-05-23 5.27.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/0ded9789-a425-48bd-9081-3d84bc4cf08b.png)

### IDソースを指定

認証元を指定します。

今回は前述の通り、Cognitoで認証します。

![スクリーンショット 2025-05-22 5.48.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/b65157f1-5ce6-40c3-8b57-1bba2ab94c33.png)

また、AVPで利用するトークンを選択できます。

おすすめはアクセストークンとなっていますが、IDトークンのカスタム属性による認可もしたいので、今回はIDトークンを選びました。

![スクリーンショット 2025-05-22 5.49.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/4a22d83d-4ae8-4a94-b2f4-e01c266caa5e.png)

なお、今回のセットアップ手順はCognitoユーザグループごとの認可設定となりますが、グループ情報はID・アクセストークンどちらにも含まれています。

![スクリーンショット 2025-05-23 5.35.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/341436ea-caad-43fa-ae55-276212b2c3a5.png)

### アクションをグループに割り当て

次に「誰に」「何を」を設定していきます。

![スクリーンショット 2025-05-23 5.39.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/78566a42-98f1-441b-8a00-974e827e7a6e.png)

今回は、`operator`のグループは`get/user`のみ許可するという権限にしました。


### デプロイ

最後にデプロイします。

ここの説明見ると分かりますが、この仕組みは裏で認可をしてくれるわけではなく、Lambdaオーソライザの作成を代行してくれるみたいな仕組みです。

![スクリーンショット 2025-05-22 5.50.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/a449a98d-e2fd-4901-b828-caf4ab3b6780.png)

デプロイ開始後は以下の表示の通り、進捗を見ることができますが、最後の２つはいつまで待っても、進行中のままです。

最大1時間かかるとか記載もあるのですが、体感数分レベルです。
（AVPは全体的にUIが少し変ですね。）

![スクリーンショット 2025-05-23 5.44.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/d70f7558-31bb-405f-9dce-11bc90b70015.png)

#### スキーマ

デプロイ後に、各種作成した内容を確認してみます。

まずはスキーマ。

```
{
    "VerifiedPermissions": {
        "entityTypes": {
            "User": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                },
                "memberOfTypes": [
                    "UserGroup"
                ]
            },
            "UserGroup": {
                "shape": {
                    "attributes": {},
                    "type": "Record"
                }
            },
            "Application": {
                "shape": {
                    "attributes": {},
                    "type": "Record"
                }
            }
        },
        "actions": {
            "get /user": {
                "appliesTo": {
                    "context": {
                        "type": "Record",
                        "attributes": {}
                    },
                    "principalTypes": [
                        "User"
                    ],
                    "resourceTypes": [
                        "Application"
                    ]
                }
            },
            "post /user": {
                "appliesTo": {
                    "context": {
                        "type": "Record",
                        "attributes": {}
                    },
                    "principalTypes": [
                        "User"
                    ],
                    "resourceTypes": [
                        "Application"
                    ]
                }
            }
        }
    }
}
```

#### ポリシー

次はポリシー。

```
permit (
    principal in
        VerifiedPermissions::UserGroup::"ap-northeast-1_XXXXXXXX|operator",
    action in [VerifiedPermissions::Action::"get /user"],
    resource
);
```

#### Lamdbaオーソライザ

最後にAPI Gatewayを見てみると、`AVPAuthorizer`というオーソライザができてました。

![スクリーンショット 2025-05-23 5.48.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/ac5e6aa0-1bcf-4aa3-b66f-8ee3d159515e.png)

`Authorization`にIDトークンを入れると良いみたい。

![スクリーンショット 2025-05-23 5.49.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3894042/03bca4be-792c-45f3-a697-c088ff6d8daf.png)

なお、オーソライザは以下のようなソースでした。(Node.js 20.x)

```javascript:index.js
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,
};

```

### 試してみる。

早速試してみます。

### Authorizationヘッダなし

Authorizationヘッダなしでリクエストしてみます。

```
% curl --request GET 'https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/user' \
--header 'Content-Type: application/json' \

{"message":"Unauthorized"}
```

`Unauthorized`でした。

そもそもLambdaオーソライザの前で弾かれていますね。

### 不正なAuthorizationヘッダ

適当なAuthorizationヘッダを付与してみます。

```
% ID_TOKEN="dummy"
curl --request GET 'https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/user' \
--header 'Content-Type: application/json' \
--header "Authorization: $ID_TOKEN"

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

`User is not authorized to access this resource with an explicit deny`でした。

Lambdaオーソライザで拒否されてますね。

### 正しいAuthorizationヘッダ

ユーザグループ`oparator`に属したユーザでログインし、取得したIDトークンでリクエストしてみます。

```
% ID_TOKEN=$(aws cognito-idp initiate-auth \
  --client-id 111112222223333344444 \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters USERNAME=user1,PASSWORD=password \
  | jq -r '.AuthenticationResult.IdToken')

curl --request GET 'https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/user' \
--header 'Content-Type: application/json' \
--header "Authorization: $ID_TOKEN"

{
  "id": 1,
  "type": "dog",
  "price": 99.99
}%  
```

きちんと応答が返ってきました！

次に`curl --request GET`のところを`POST`にしてみます。

```
curl --request POST 'https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/user' \
--header 'Content-Type: application/json' \
--header "Authorization: $ID_TOKEN"

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

正しく拒否されていそうです！

## まとめ

今回はAmazon Verified Permissionsを使った認可でした。
SDKが提供されているので、いろんな場面でこの認可が活用できそうです！

今の所、AWSリソースに関する認可以外はAVP使っても良さそうです！

誰かのお役にたてると嬉しいです〜！