Cedar(Amazon Verified Permissionsのポリシーのための言語)のチュートリアルを読んだ

Amazon Verified PermissionsとはAWSが提供するアクセス管理サービス(認可エンジンとも)です。 ざっくり言うと、アプリケーションでの操作を許可するか拒否するかを評価してくれます。

Amazon Verified Permissionsについては、以前にカンファレンスの動画を見てその内容をまとめたり、リンクをまとめたりしました。気になる方は最初にそちらを読んでください。

s1r-j.hatenablog.com

さて、Amazon Verified Permissionsは、誰かが何かに対してどうするときにそれを許可するまたは拒否するということが書かれたポリシーをもとに評価をおこないます。このポリシーはCedarという言語で書かれています。

今回はCedarのチュートリアルをやったので、一部日本語訳をしながら備忘録としてまとめておきます。

チュートリアルの内容

1. ポリシー構造

Cedarでは、権限(permission)はポリシーステートメント(policy statement)の集まりとして表現されます 各ポリシーステートメントは、定義されたコンテキストでユーザ(またはプリンシパル)がリソースに対しておこなう動作を許可するまたは禁止するルールです。この形式はPARCモデルと呼ばれます。

AWS再入門ブログリレー AWS Identity and Access Management (IAM)編 | DevelopersIO

ポリシー言語にはEffectとPrincipal、Action、Resources、Conditionで構成されており、それぞれの頭文字をとってPARCモデルといったりもしています。

全てのポリシーステートメントは、エフェクト(effect)とスコープ(scope)を含まなければなりません。

  • エフェクトは、permit(許可)かforbid(禁止)ポリシーを指定します
  • スコープは、エフェクトが適用されるプリンシパル(principal)、アクション(action)、リソース(resource)を指定します
  • オプションで、ステートメントwhenまたはunless句を使うことで1つ以上の条件(condition)を含めることもできます

下のポリシー例では、

  • このポリシーには、エフェクトとスコープが書かれている
  • エフェクトはpermitである
  • スコープは、aliceというUserタイプのプリシパル、updateというアクション、「VacationPhoto94.jpg」というPhotoタイプのリソースを指定している

つまり、このポリシーはaliceというUserが「VacationPhoto94.jpg」というPhotoを更新することを許可しています。ちなみに、このポリシーは条件(後述)を持っていません。

permit(
  principal == User::"alice", 
  action    == Action::"update", 
  resource  == Photo::"VacationPhoto94.jpg"
);

プリンシパルとリソースは、タイプ(type)とIDの組み合わせによって一意に決定されます。上述の例のプリンシパルでは、「User」がタイプで、「alice」がIDになります。ポリシーがプリンシパルまたはリソースを参照する際には、毎回タイプとIDの両方を呼び出す必要があります。 タイプとIDの組み合わせによって一意に決定されたものはエンティティと呼ばれます。プリンシパル、アクション、リソースの全てがCedarではエンティティと表現され、Userタイプのalice、Actionタイプのupdate、PhotoタイプのVacationPhoto94.jpgはエンティティです。

また、タイプはアプリ開発者が自由に名称をつけることができます。上の例ではUserを使っていますが、、PersonCustomerのようにすることができます。

CedarポリシーはCedar評価エンジンで利用されます。認可リクエスト(authorization request)が承認(Allow)されるには少なくとも1つの該当した許可ステートメントが存在することかつ該当する禁止ステートメントが存在しないことが必要です。ポリシーが該当するという状況は、認可リクエスト内の値がスコープと合い、かつ全ての条件が合っていることを指します。

認可リクエストを評価するにはCedarライブラリを使うことができますが、現在Rustにのみ対応しています。正体的には、FFIインタフェースを提供していく予定です。

Foreign function interfaceとは - わかりやすく解説 Weblio辞書

Foreign function interface(フォーリン・ファンクション・インターフェイスFFI)とは、あるプログラミング言語から他のプログラミング言語で定義された関数などを利用するための機構。主に高水準言語からC/C++などの関数やメソッドを呼び出し、OS固有の機能などを利用するために使用されることが多い。

2. 禁止ポリシー

禁止ポリシーは許可ポリシーよりも優先されます。下のように同じ条件でポリシーを書くと禁止ポリシーが優先され、認可リクエストは拒否(Deny)と評価されます。

permit(
  principal == User::"alice", 
  action    == Action::"view", 
  resource  == Photo::"VacationPhoto94.jpg"
);

forbid(
  principal == User::"blice", 
  action    == Action::"view", 
  resource  == Photo::"VacationPhoto94.jpg"
);

AWS IAMポリシーと同じようになっており、優先度の高い順でいくと、

明示的な拒否(認可リクエストが禁止ポリシーが該当している) → 明示的な許可(認可リクエストが許可ポリシーが該当している) → 暗黙的な拒否(認可リクエストにいずれのポリシーも該当していない)

ということです。

3. セット(Sets)

スコープに複数の値をとることができます。下の例のポリシーでは、アクションにセットを使って複数の値を設定しています。

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

このポリシーは、aliceというUserが「VacationPhoto94.jpg」というPhotoを閲覧、編集、削除することを許可しています。

アクションにセットを定義することができますが、プリンシパルとリソースには定義することができません。

  • セット内の項目の順序は承認の決定に影響しますか?
    • 影響しない
  • プリンシパルのユーザーのセットになるようにスコープを編集すると、構文チェッカーはどのようなエラーを返しますか?
    • 構文チェッカーからのエラー:poorly formed: expected single entity uid or template slot, got a set of entity uids
  • セット内の値が重複してもエラーにはならなかった

4. 未定義のスコープ

セットはアクションには使えますが、プリンシパルやリソースに使うことはできません。

プリンシパルやリソースといったエンティティで広い範囲に該当するポリシーを書くには2つの方法があります。 方法の1つはプリンシパル、リソース、アクションを指定せず、スコープを完全に未定義にすることです。未定義の場合、全てのエンティティに適用されるとみなされます。

下の例ではプリンシパルを未定義にしています。

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

さらに、下のようにアクションとリソースも未定義にして何もかもを許可する寛容すぎるポリシーをつくることができます。

permit(
  principal, 
  action, 
  resource
);

また、未定義のスコープを使って禁止ポリシーを書くこともできます。

forbid(
  principal, 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource == Photo::"vacationPhoto.jpg"
);

5. RBACのためのグループ

ポリシーのスコープの該当範囲を広くする方法の2つ目は、エンティティのグループを定義することです。プリンシパルのグループによって、ロールを使った権限の管理ができるようになります。

permit(
  principal in Role::"vacationPhotoJudges",
  action == Action::"view",
  resource == Photo::"vacationPhoto94.jpg"
);

上の例は、グループとしてロールを用いています。ここでのRole予約語ではないため、UserGroupなど他のエンティティタイプを使うことができます。

グループを使ったポリシーで認可リクエストを評価するには、プリンシパルがこのグループのメンバーであるかを知る必要があります。そのため、アプリケーションは認可リクエストの一部として関連するグループメンバーシップ情報を評価エンジンに提供する必要があります。

エンティティタイプUserのBobは、エンティティタイプRoleのvacationPhotoJudgesと同じくエンティティタイプRoleのjuniorPhotographerJudgesを親に持ちます。

let entities_json = r#"[
    {
        "uid": {
            "type": "User",
            "id": "Bob"
        },
        "attrs": {},
        "parents": [
            {
                "type": "Role",
                "id": "vacationPhotoJudges"
            },
            {
                "type": "Role",
                "id": "juniorPhotographerJudges"
            }
        ]
    },
    {
        "uid": {
            "type": "Role",
            "id": "vacationPhotoJudges"
        },
        "attrs": {},
        "parents": []
    },
    {
        "uid": {
            "type": "Role",
            "id": "juniorPhotographerJudges"
        },
        "attrs": {},
        "parents": []
    }
]"#;
let entities = Entities::from_json_str(entities_json, None).expect("entity parse error");

6. ABACその1

下のポリシーは、条件(condition)を設定したものです。このポリシーでは、どのプリンシパルもどのリソースを閲覧する(Action::"view")ことができますが、それはリソースの属性(attribute)accessLevelが「public」である場合かつプリンシパルの属性locationが「USA」である場合としています。

permit(
  principal,
  action == Action::"view",
  resource
)
when {resource.accessLevel == "public" && principal.location == "USA"};

演算子について

  • 論理積&&)以外に論理和||)が利用できます
  • 等価(==)以外に不等価(!=)が利用できます

属性の型について

  • String
    • 上述のポリシー例で利用しています
  • Boolean
  • Integer
  • Entity ID
    • 例としてはUser::"Alice"
  • Sets
    • 値の集合、[]で表現されます

7. ABACその2

前段落のポリシーでは、属性を静的な値で評価していました。

この段落のポリシーでは、動的な値で評価をおこなっています。リソースの属性ownerプリンシパルの属性idと一致する場合に、どのリソースでも閲覧する(Action::"view")こと、編集する(Action::"edit")こと、削除する(Action::"delete")ことが許可されます。

permit(
  principal, 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource 
)
when {
  resource.owner == principal.id
};

8. 条件とコンテキスト

ここまでのポリシーはプリンシパルやリソースの属性を評価してきましたが、ここではそれ以外の、プリンシパルやリソースとは無関係な値をつかったポリシーの書き方を紹介します。

プリンシパルやリソースとは無関係な値をポリシーで使うには、コンテキスト(context)を利用します。コンテキストによって、ポリシーの条件(condition)で追加データを参照できるようになります。コンテキストに追加されるデータ例としては、JWT形式のIDトークンやリクエスト時にしかわからないもの(送信元IPアドレスなど)、必ずしもデータベースに保存されていないものです。

コンテキストをつかったポリシーを下に例示しています。 ポリシーの意味は、aliceというUserがflower.jpgというPhotoを更新する(Action::"update")こと、削除する(Action::"delete")ことを、多要素認証(mfa_authenticated)され、かつ送信元IPアドレスrequest_client_ip)が「222.222.222.222」のときに許可される、というものです。

permit(
    principal in User::"alice", 
    action in [Action::"update", Action::"delete"],
    resource == Photo::"flower.jpg")
when {
    context.mfa_authenticated == true &&
    context.request_client_ip == "222.222.222.222"
};

コンテキストにはアプリケーションが値を設定してあげる必要があります(下のRustの実装参照)。 また、ユーザが詐称できるような値(リクエストパラメータに入っているユーザIDをそのまま使うなど)をポリシーに使うことは避け、署名・検証されたJWT形式のIDトークンからユーザIDを取得するなどの対応をおこなう。

let principal = EntityUid::from_str("User::\"alice\"").expect("entity parse error");
let action = EntityUid::from_str("Action::\"update\"").expect("entity parse error");
let resource = EntityUid::from_str("Photo::\"flower.jpg\"").expect("entity parse error");

let context_json_val: serde_json::value::Value = serde_json::json!({
    "mfa_authenticated": true,
    "request_client_ip": "222.222.222.222",
    "oidc_scope": "profile"
});
let context = Context::from_json_value(context_json_val, None).unwrap();

let query: Query = Query::new(Some(principal), Some(action), Some(resource), context);

エンティティはあくまでリソースとプリンシパルに関するデータを提供するために使われます。コンテキストはそれ以外の他のデータをポリシーで評価するために使われます。

9. スキーマ

プリンシパルやリソースといったエンティティが増えていくと、ポリシーとそれらエンティティの間で構造や名称にズレが生じてエラーが発生するリスクがあります。

これを解消するためにスキーマがあります。スキーマは、エンティティタイプの名称と構造の宣言です。 このエンティティ定義には属性の一覧を含むことができ、各属性は名称とデータ型を定義できます。

スキーマJSON形式で定義します。スキーマは、最上位層のキーが名前空間(namespace)となり、その下に3つの主なプロパティが存在するという形式です。

名前空間とは:名前空間を使うことでスキーマはユニークに定義されます。複数のスキーマに存在する同じ名称の要素を区別することができます(例:HRApp::FileMedicalRecordsApp::File)。

主なプロパティ

  • entityTypesプリンシパルの型とリソースの型を定義します。各エンティティタイプはその特徴を表すシェイプ(shape)を定義します。このシェイプではCedarがサポートするデータタイプを指定し、より複雑な構造を定義します。また、オプションでmemberOfというプロパティによって階層構造を定義します(例:UserはGroupのメンバーである、PhotoはAlbumに属するなど)。
  • actionsプリンシパルがリソースに対して潜在的に実行できる操作を定義します。各アクションではその名称とアクションが適用されるリソースとプリンシパルの一覧を定義します。
  • commonTypesスキーマの複雑なレコード型の型エイリアスを定義するオプションのプロパティです。コードの再利用性を高めるために利用します。下の例ではPersonTypeを定義しておき、Userエンティティでシェイプとして再利用しています。
{
    "PhotoApp": {
        "commonTypes": {
            "PersonType": {
                "type": "Record",
                "attributes": {
                    "age": {
                        "type": "Long"
                    },
                    "name": {
                        "type": "String"
                    }
                }
            },
            "ContextType": {
                "type": "Record",
                "attributes": {
                    "ip": {
                        "type": "Extension",
                        "name": "ipaddr"
                    }
                }
            }
        },
        "entityTypes": {
            "User": {
                "shape": {
                    "type": "PersonType",
                    "attributes": {
                        "employeeId": {
                            "type": "String"
                        }
                    }
                },
                "memberOfTypes": [
                    "UserGroup"
                ]
            },
            "UserGroup": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                }
            },
            "Photo": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                },
                "memberOfTypes": [
                    "Album"
                ]
            },
            "Album": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                }
            }
        },
        "actions": {
            "viewPhoto": {
                "appliesTo": {
                    "principalTypes": [
                        "User",
                        "UserGroup"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "ContextType"
                    }
                }
            },
            "createPhoto": {
                "appliesTo": {
                    "principalTypes": [
                        "User",
                        "UserGroup"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "ContextType"
                    }
                }
            },
            "listPhotos": {
                "appliesTo": {
                    "principalTypes": [
                        "User",
                        "UserGroup"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "ContextType"
                    }
                }
            }
        }
    }
}

10. ポリシーテンプレート

これまで見てきたポリシーはプリンシパルとリソースが指定されており、静的なポリシー(static policy)と呼ばれます。

静的なポリシーはすぐに認可決定に利用できますが、プリンシパルとリソースが既に指定されてしまっているため、同じアクションでもプリンシパルとリソースが異なるポリシーをたくさん作る必要が出てきます。

ポリシーテンプレート(policy template)はこの問題を解決します。 ポリシーテンプレートではポリシー内のプリンシパルおよびまたはリソースにプレースホルダが使われており、このプレースホルダに特定の値を与えることでテンプレートからポリシーを作成する(インスタンス化する)ことができます。

ポリシーテンプレートの例(?principal?resourceプレースホルダ):

permit(
    principal == ?principal, 
    action in [Action::"readFile", Action::"writeFile"] 
    resource  == ?resource
  );

プレースホルダーには制約条件があります。

  • ポリシーのヘッダ部分でしか利用できない
  • 演算子==またはinの右側でしか利用することができない
  • アクションでは利用できない
  • whenおよびunless句では利用できない

用語集

その他不明な用語があれば、下記を参照してください。

Terms & concepts | Cedar Policy Language Version 2.3 Reference Guide

おわりに

思ったよりもポリシー記述はシンプルな気がします。カンファレンスでもシンプルに表現できることを念頭にしているとあったのでその効果はあったということでしょうか。

Amazon Verified Permissionsで評価をおこなうには、SDKを使ってユーザ情報などを渡す必要があります。Amazon Verified Permissionsを利用する前にデータベースにアクセスして、送信元IPをコンテキストとして格納してとか結構手間がかかる、という印象があります。

RustのSDKがある以外に、JavaFFIがあるらしいです(cedar-policy/cedar-java: Java bindings for the Cedar language)。
実装を見てみればこのあたり色々わかってくるかもしれません。

機会があれば、実際のアプリケーションで利用してみたいです(やらなさそう)。