Amazon Verified PermissionsをAWS CLIで環境構築してみました


# 記事の概要

AWSにおける認可のサービスAmazon Verified Permissions（以下、Verified Permissions）が2023年6月にGAとなりました。
アプリケーションの認可機能の効率的な実現が期待できるサービスとのことで、実際にVerified Permissionsの環境を構築して機能を確認してみました。構築の手段にはWebコンソールではなくCLIを利用しています。


# Amazon Verified PermissionsとCedar言語とは

Amazon Verified Permissionsはアプリケーションのリソースに対するアクセス権限を管理するための仕組みです。
AWSで認可機能というとIAMもありますが、IAMはAWSサービスのリソースの認可、Verified Permissionsは自前のアプリケーション内のリソースの認可という住み分けになります。

Verified Permissionsの機能は、後述のCedarという言語を用いて定義されたポリシを用いてリソースへのアクセス許可、禁止を行うというものになっています。
Verified Permissionsの詳細については[AWSの公式ドキュメント](https://docs.aws.amazon.com/ja_jp/verifiedpermissions/latest/userguide/what-is-avp.html)を見て頂く方が良いかと思います。

Cedar言語は**effect**、**scope**、**conditions**の3つの要素でポリシを定義します。

<dl>
  <dt>effect</dt>
  <dd>ポリシが認可に与える影響を示しています。permit (許可) またはforbid (禁止) のどちらかの値であり必須の項目です</dd>
  <dt>scope</dt>
  <dd>ポリシの適用対象を示しています。Principal (Resourceに対するActionを実施する主体)、Resource (PrincipalによるActionの対象)、Action (Resourceに対する操作) を構成要素に持ちます。こちらも必須の項目です。</dd>
  <dt>conditions</dt>
  <dd>ポリシが適用される状況を絞り込む際に指定します。こちらはオプションの項目です。</dd>
</dl>


Cedarポリシの例を以下に示します。
"permit"の部分がeffect、()で囲まれる部分がscope、"unless"以降の部分がconditionsです。
この例では、( )で囲まれる部分でユーザー"bob" がリソース"trip" に対し
て"view"と"comment"の操作をすることを許可しています。また、"unless"以降の部分でリソー
ス"trip"のtagが"private"以外でなければ許可されないことを示しています。

![cedarポリシの例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/26522315-21fa-18c4-a658-7b1e60d17989.png)


# CLIで実際に試してみました

## 状況設定

今回は以下の構成でアプリとAWS環境を構築しました。

![構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/b2264e08-1c98-c3b5-0917-e50a00db6f37.png)

* 認証基盤にCognito User Poolを利用します
    * ユーザープールにread_only_userとread_update_userの二人のユーザーを作成します
* Spring Bootアプリの認可にVerified Permissionsを利用します
    * Spring Bootアプリはローカル環境で実行します
    * read_only_userは/user/read APIのみアクセス可能で、read_update_userは/user/read と /user/update APIにアクセス可能とします


# 実際に使用したCLIスクリプト

## Cognitoユーザープールの設定

まず、USERというグループにread_only_userとread_update_userのユーザー2名を持つUser Poolを作成します。<>部分は自分で入力内容 を決めます。


~~~ text
export USER_POOL_NAME=<ユーザープール名>
export USER_POOL_ID=$(aws cognito-idp create-user-pool --pool-name $USER_POOL_NAME --query 'UserPool.Id' --output text)

export GROUP_USER=USER

aws cognito-idp create-group --group-name $GROUP_USER --user-pool-id $USER_POOL_ID

export TEMP_PWD=<パスワード>
export USERNAME1=read_only_user
aws cognito-idp admin-create-user --user-pool-id $USER_POOL_ID --username $USERNAME1 --temporary-password $TEMP_PWD
aws cognito-idp admin-add-user-to-group --user-pool-id $USER_POOL_ID --username $USERNAME1 --group-name $GROUP_USER

export USERNAME2=read_update_user
aws cognito-idp admin-create-user --user-pool-id $USER_POOL_ID --username $USERNAME2 --temporary-password $TEMP_PWD
aws cognito-idp admin-add-user-to-group --user-pool-id $USER_POOL_ID --username $USERNAME2 --group-name $GROUP_USER 

~~~

次に、ユーザープールのドメインとアプリクライアントを作成します。<ドメイン名>も自分で名前を考えて入力します。

~~~ text
export DOMAIN_NAME=<ドメイン名>
aws cognito-idp create-user-pool-domain --domain $DOMAIN_NAME --user-pool-id $USER_POOL_ID

aws cognito-idp create-user-pool-client --user-pool-id $USER_POOL_ID --client-name SpringBootApp \
--supported-identity-providers COGNITO --allowed-o-auth-flows code \
--allowed-o-auth-scopes "email" "openid" "phone" --allowed-o-auth-flows-user-pool-client \
--generate-secret --callback-urls http://localhost:8080/login/oauth2/code/cognito

export APP_CLIENT_ID=$(aws cognito-idp list-user-pool-clients --user-pool-id $USER_POOL_ID --query 'UserPoolClients[?ClientName==`SpringBootApp`].ClientId' | jq -r '.[0]')
~~~

これでCognitoの設定は完了です。アプリクライアントIDは後で使用するので変数APP_CLIENT_IDにexportしておきます。

## Amazon Verified Permissionsの設定

まずポリシストアを作成します。ポリシストアIDを変数POICY_STORE_IDにexportしておきます。

~~~ text
export POLICY_STORE_ID=$(aws verifiedpermissions create-policy-store --validation-settings "mode=STRICT" --query 'policyStoreId' --output text)
~~~

次に、ポリシストアのスキーマを作成します。ここでは事前に以下のJSONファイルを用意しておきます。ファイル名はschema.jsonとします。

~~~ json
{"cedarJson":
    "{\"SpringApp\":
        {\"entityTypes\":
            {\"Application\":
                {\"shape\":{\"attributes\":{},\"type\":\"Record\"}},
             \"User\":
                {\"shape\":{\"type\":\"Record\",\"attributes\":
                    {\"sub\":   {\"type\":\"String\",\"required\":true}}}}},
             \"actions\":
                {\"/user/update\":{\"appliesTo\":
                    {\"principalTypes\":[\"User\"],\"resourceTypes\":[\"Application\"]}},
                \"/user/read\":{\"appliesTo\":
                    {\"resourceTypes\":[\"Application\"],\"principalTypes\":[\"User\"]}}
            }
        }
    }"
}
~~~

このスキーマはVerified Permissionsに設定するプリンシパル、リソース、およびアクションを定義しています。この例ではプリンシパルタイプがUser、リソースがApplication、アクションが/user/readと/user/updateになります。

このschema.jsonを利用して、Verified Permissionsのスキーマを以下のコマンドで作成します。
~~~ text
aws verifiedpermissions put-schema --definition file://schema.json --policy-store $POLICY_STORE_ID
~~~

次に、ポリシストアのアイデンティティソース (今回はCognito User Poolを利用) を作成します。

事前に、以下の構成ファイルを作成しておきます。ファイル名はconfig.txtとします。
~~~ text
{
    "cognitoUserPoolConfiguration": {
        "userPoolArn": "<USER_POOL_ARN>",
        "clientIds":["<APP_CLIENT_ID>"]
    }
}
~~~

そして、以下のコマンドを実行してポリシストアを設定します。ユーザープールARNをコンソール等で事前に調べておいてUSER_POOL_ARNに割り当てます。
~~~ text
export USER_POOL_ARN=<arn:aws:cognito-idp:ap-northeast-1:<AWS_ACCOUNT_ID>:userpool\\/ap-northeast-1_xxxxxxxxx>
sed -i "s/<USER_POOL_ARN>/$USER_POOL_ARN/g" config.txt
sed -i "s/<APP_CLIENT_ID>/$APP_CLIENT_ID/g" config.txt

aws verifiedpermissions create-identity-source --configuration file://config.txt \
--principal-entity-type "User" --policy-store-id $POLICY_STORE_ID
~~~

最後にポリシを作成します。ポリシは、スキーマ内のプリンシパルタイプ、リソースタイプ、およびアクションにより定義されます。

ここではまず以下のテキストファイルを作成してファイル名をread-only-policy.txtとしておきます。

~~~ text
{
    "static": {
        "description":  "Grant read_only_user user only access to /user/read API",
        "statement": "permit(principal in SpringApp::User::\"read_only_user\", action in SpringApp::Action::\"/user/read\", resource);"
    }
}
~~~

同様に以下をread-update-policy.txtとして作成します。以下ではactionを定義していませんが、このような場合は任意のアクションを実行可能となります。

~~~ text
{
    "static": {
        "description":  "Grant read_update_user user access to all API",
        "statement": "permit(principal in SpringApp::User::\"read_update_user\", action, resource);"
    }
}
~~~

そして以下のコマンドを実行してポリシを作成します。

~~~ text
# Create policy for read_only_user user that only access to /user/read API
aws verifiedpermissions create-policy --definition file://read-only-policy.txt --policy-store-id $POLICY_STORE_ID

# Create policy for read_update_user user that can access to all API
aws verifiedpermissions create-policy --definition file://read-update-policy.txt --policy-store-id $POLICY_STORE_ID
~~~

この結果、以下のポリシがそれぞれ作成されれば完了です。ポリシはAWSのコンソールから確認できます。

* read-onlyのポリシ

![read-onlyのポリシ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/503d5ddb-0313-bfa5-ae7c-e7791f06dbda.png)


* read-updateのポリシ

![read-updateのポリシ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/2a2da529-944a-d156-bab4-69d078a53194.png)


## Spring Bootアプリの設定

Spring BootアプリにVerified Permissionsを使えるようにするための設定をします。
Spring Bootアプリの作り方についてはここでは割愛します。また、アプリの認証部分はSpring Securityを利用していますがこの使い方についても割愛します。

まずCognitoと連携するためのプロパティを設定します。.propertiesファイルの場合は以下の通りに設定します。なお、今回の試行ではアプリをlocalhost:8080で起動していますが、環境に合わせて適宜設定を書き換える必要があります。また、<>で示した箇所はコンソール等で確認して書き換えます。

~~~ text
server.port=8080

spring.security.oauth2.client.registration.cognito.client-id=<Cognitoに登録したアプリのクライアントID>
spring.security.oauth2.client.registration.cognito.client-secret=<Cognitoに登録したアプリのクライアントシークレット>
spring.security.oauth2.client.registration.cognito.scope=openid
spring.security.oauth2.client.registration.cognito.redirect-uri=http://localhost:8080/login/oauth2/code/cognito
spring.security.oauth2.client.registration.cognito.clientName=SpringBootApp
spring.security.oauth2.client.registration.cognito.authorization-grant-type=authorization_code
spring.security.oauth2.client.provider.cognito.issuerUri=<CognitoユーザープールのURI>
policyStoreId=<Verified PermissionsのポリシストアのID>
~~~

次に、Verified Permissionsを用いた認可処理を実装します。ここでは事前にVerified PermissionsのSDKを利用できるようにしておく必要があります。mavenの場合は以下の依存関係をpom.xmlに追加します。

~~~ xml
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>verifiedpermissions</artifactId>
</dependency>
~~~

SDKを利用してVerified Permissionsによる認可処理を行うPermissions.javaを以下の通りに作成します。isAuthorizedメソッド内で呼び出しているverifiedPermissionsClient.isAuthorizedによってAmazon Verified Permissionsに認証情報が投げられ、ポリシの適用結果を受け取る仕組みになっています。

~~~ java
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import software.amazon.awssdk.services.verifiedpermissions.VerifiedPermissionsClient;
import software.amazon.awssdk.services.verifiedpermissions.model.*;

import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Properties;

public class Permission {
    private static Properties properties;

    static {
        loadProperties();
    }
    private static void loadProperties() {
        InputStream inputStream = Permission.class.getClassLoader().getResourceAsStream("application.properties");
        properties = new Properties();
        try {
            properties.load(inputStream);
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    public static String getProperty(String key) {
        return properties.getProperty(key);
    }
    private static VerifiedPermissionsClient verifiedPermissionsClient = VerifiedPermissionsClient.create();

    public static boolean isAuthorized(String user, String actions, String resources) {

        EntityIdentifier principal = EntityIdentifier.builder()
                .entityType("SpringApp::User")
                .entityId(user)
                .build();

        ActionIdentifier action = ActionIdentifier.builder()
                .actionType("SpringApp::Action")
                .actionId(actions)
                .build();

        EntityIdentifier resource = EntityIdentifier.builder()
                .entityType("SpringApp::Application")
                .entityId(resources)
                .build();

        IsAuthorizedRequest authorizationRequest = IsAuthorizedRequest.builder()
                .principal(principal)
                .action(action)
                .resource(resource)
                .policyStoreId(getProperty("policyStoreId"))
                .build();

        IsAuthorizedResponse authorizationResult = verifiedPermissionsClient.isAuthorized(authorizationRequest);

        Decision decision = Decision.fromValue(authorizationResult.decisionAsString());

        if (decision.toString().equals("ALLOW")) {
            return true;
        } else {
            return false;
        }
    }

    public static boolean authorizationPermission (Authentication authentication, HttpServletRequest request){

        DefaultOidcUser defaultOidcUser = (DefaultOidcUser) authentication.getPrincipal();

        Map<String, Object> userAttributes = defaultOidcUser.getAttributes();

        if(isAuthorized((String)userAttributes.get("cognito:username"), request.getServletPath(), "1")){
            return true;
        }else {
            return false;
        }
    }
}
~~~

最後にControllerクラスに以下のメソッドを追加します。Permissions.javaで定義したauthorizationPermissionメソッドを実行して、その結果に応じて/user/read および /user/update API実行時の処理を分岐します。以下の処理では、権限がある場合はread.htmlやupdate.htmlに正しく遷移し、権限が無い場合はerror.htmlに遷移する仕組みとなっています。これらの.htmlファイルのソースについてはここでは割愛しますが、後述の試行結果のところで画面表示をご覧ください。

~~~ java
    @GetMapping("user/read")
    public String userRead(Model model, Authentication authentication, HttpServletRequest request) {

        String response = "You have Read Permission";

        System.out.println(request.getServletPath());
        model.addAttribute("response", response);

        if(Permission.authorizationPermission(authentication, request)){
            return "read";
        }else {
            return "error";
        }
    }

    @GetMapping("user/update")
    public String userUpdate(Model model, Authentication authentication, HttpServletRequest request) {

        String response = "You have Update Permission";

        System.out.println(request.getServletPath());
        model.addAttribute("response", response);

        if(Permission.authorizationPermission(authentication, request)){
            return "update";
        }else {
            return "error";
        }
    }
~~~

# 試行結果

アプリケーションを実行しlocalhost:8080にアクセスするとCognito User Poolによるログイン画面に遷移します。

![ログイン画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/1a6faee4-4f7c-2a2a-24fc-338a5b174d98.png)

まずread_only_userでログインし、localhost:8080/user/readにアクセスしてみます。アクセス権限があるので問題なくアクセスできます。

![read画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/c279859c-3fb2-6297-46fd-aec86b26dc3b.png)


次にlocalhost:8080/user/updateにアクセスしてみます。read_only_userはupdateの権限が無いのでエラーになります。

![updateエラー画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/e9b08e79-6494-a772-cf04-de5628f2365d.png)


今度はread_update_userでログインし、localhost:8080/user/readにアクセスしてみます。アクセス権限があるので問題なくアクセスできます。

![read画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/83a788af-cf53-f5f7-0672-18707bf985c8.png)


次にlocalhost:8080/user/updateにアクセスしてみます。read_update_userはupdateも権限があるので問題なくアクセスできました。

![update画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524613/55e3c04f-2491-5488-3066-d8b4528dfceb.png)



以上の結果から、Verified Permissionsに定義したポリシに基づいてアクセス権限を設定できていることが確認できました。

# 感想

Verified Permissionsを一通り試行してみました。Verified Permissionsはポリシの考え方を理解し自分で作成できるだけの知識が必要であるためハードルは低くないと感じました。また、Verified Permissionsの認可機能はポリシの適合を判断する部分のみであり、結果を用いてリソースへのアクセスを実際にコントロールする部分は自分で実装する必要があります。

主なユースケースとしてはアプリケーションの外でポリシを一元管理する場合に向いていると思われます。特に複数のアプリケーションの認可ポリシを一元管理する場合に有効ではないでしょうか。例えば、企業の中で多くのアプリケーションが稼働していて、それらに対して全社で統一したポリシの運用が必要となる場合に役立つと考えられます。

また、Amazon API GatewayのLambda Authorizerで認可を行う場合はVerified Permissionsを利用することで認可を実現しやすくなると思われます。