Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

Amazon Verified Permissionsとgolangで認可処理を実装してみた

こんにちは、2023年5月にバックエンドエンジニアとしてジョインした yamanoi です。
最近は Cloudflare スタックに注目しており、新機能を触ったりアップデートを眺めたりしています。

今回は先日 GA (一般利用可能)になった AWS のサービス Amazon Verified Permissions を、 golang で実装した簡単なサンプルを交えて紹介したいと思います。

Amazon Verified Permissions とは

Amazon Verified Permissions とは、ポリシーベースの認可処理の実装を肩代わりしてくれるマネージドサービスです。

ロールベースのアクセス制御(RBAC)や属性ベースのアクセス制御(ABAC)を実現することができ、アプリケーションから認可の処理を一部分離することが可能になります。

Amazon Verified Permissions ではポリシー言語として Cedar 言語を採用しており、この言語を用いてアプリケーションに必要な認可のルールを記述していきます。
*ポリシー言語というのはAWSのIAM Policyのような認可に関するポリシーを記述するための言語となっています。

この記事では、サービスの使いどころ、 Cedar 言語の使い方、サンプルアプリケーションの実装までを解説していきたいと思います。

従来の認可処理

認可処理は従来サービスの開発する開発者自身で実装することが多いと思います。 認可処理を実装する課題点として以下のようなものが挙げられます。

  • 複雑性の問題
  • セキュリティのリスク
  • パフォーマンスへの影響

そんな認可処理を一部置き換えてくれるサービスが AWS Verified Permissions になります。

認可処理の実装については Authorization Academy という資料が非常に分かりやすく参考になります。 日本語でわかりやすく解説している記事はこちら zenn.dev

この資料でも言及されていますが、 認可処理は極力アプリケーションのロジックから切り離して実装することで複雑度を下げることが可能です。
中でもインターフェースとして認可の判断(Decision)と適用(Enforcement)に分離するという考え方があります。

https://assets.website-files.com/5f1483105c9a72fd0a3b662a/60516eaf35e59b6c28d3f34c_WFrzEd5ZWHDKF7jFh7Px7-0sPYCEMmosPJRKCeuy4QSjg9EoIfKGThEg-0OkBVFUZBELsirpT61LL1a57VyheldVOBH9BGBJ2c4cxJhcY2Hw6B4xkDjo34SQOLS9Nsz7QjYms6ZJ.png 出典: Authorization Academy - What is Authorization?

Amazon Verified Permissions は、ここで言う認可の判断(Decision)を行う際に利用できます。 そしてアプリケーションはその結果を受け、どう適用(Enforcement)させるかに集中できます。

Cedar 言語の使い方

Amazon Verified Permissions で利用される Cedar 言語は、 Rust で開発しているオープンソースの言語となります。
公式サイト: https://www.cedarpolicy.com/en

公式サイトにはチュートリアルに加えて Playground もあるため、サクッと動作を確認することができます。

それでは Cedar 言語の書き方を紹介していきたいと思います。

基本的な記述方法

permit (
    principal == PhotoApp::User::"alice",
    action == PhotoApp::Action::"viewPhoto",
    resource == PhotoApp::Photo::"vacationPhoto.jpg"
);

Cedar 言語では上記のように適用させたいポリシーを書いていきます。

こちらの例は
principal(PhotoApp::User::"alice")resource(PhotoApp::Photo::"vacationPhoto.jpg")に対して action(PhotoApp::Action::"viewPhoto")を行うことを許可するポリシーとなります。

普段 AWS を使っている方であれば、 IAM Policy と似ているため直感的に理解しやすいかと思います。
Cedar では認可に関するポリシーをまず permit または forbid で指定します。
何も記載されていない場合は権限が無いものとして扱われます。
IAM Policy の Deny と同様に forbid のルールは permit ルールを上書きするため、絶対に許可したくない場合等に利用することができます。
次に principal, action, resource と記述し認可の具体的な内容を記載していきます。

RBAC の例

permit(
  principal in Role::"vacationPhotoJudges",
  action == Action::"view",
  resource == Photo::"vacationPhoto94.jpg"
);
// entity
[
   {
        "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": []
    }
]

ロールベースアクセスを実現するには principal に特定のユーザーを指定するのではなく、ロールを指定 する必要があります。
RBAC を実現するには User が複数の Role をもてるような principal の構造にします。
そして、 principal に許可する Role を in で指定します。

ABAC の例

permit(
  principal,
  action == Action::"view",
  resource
)
when {resource.accessLevel == "public" && principal.location == "USA"};
// entity
[
    {
        "uid": {
            "type": "Photo",
            "id": "VacationPhoto94.jpg"
        },
        "attrs": {
            "accessLevel": "public"
        },
        "parents": []
    },
    {
        "uid": {
            "type": "User",
            "id": "alice"
        },
        "attrs": {
            "location": "USA"
        },
        "parents": []
    }
]

principal や resource に何も指定しない場合は、全ての principal または resource に対してアクセスを許可することができます。
さらに when 句で条件を指定してあげることで、 principal や resource に対して付与している属性ベースで権限を絞り込む事ができます。
これまでの説明に出てきた "PhotoApp**" のようなものは、事前に Schema として定義することができます。

他にも様々な記述の仕方や仕様があります。
さらに詳しい内容は公式ドキュメントに記載しているので、気になった方はぜひ覗いてみてください。
https://docs.cedarpolicy.com/.

golang で動かしてみる

実際にサンプルプロジェクトを題材に AWS Verified Permissions を利用して、
認可処理を golang で実装したいと思います。

今回は PhotoFlash というサンプルプロジェクトを利用します。
PhotoFlash とは、個々のユーザーが写真やアルバムを共有できるサービスとなります。
https://docs.aws.amazon.com/ja_jp/verifiedpermissions/latest/userguide/getting-started-first-policy-store.html

1. ポリシーストアを AWS コンソールから作成する

サンプルポリシーストアから PhotoFlash を選択して作成します。

ポリシーストアを作成すると以下のスキーマが作成されます。

JSON によるスキーマ定義はこちら

{
    "PhotoFlash": {
        "entityTypes": {
            "Album": {
                "memberOfTypes": [
                    "Album",
                    "Account"
                ],
                "shape": {
                    "attributes": {
                        "Name": {
                            "type": "String"
                        }
                    },
                    "type": "Record"
                }
            },
            "User": {
                "shape": {
                    "attributes": {
                        "Email": {
                            "type": "String"
                        },
                        "Account": {
                            "name": "PhotoFlash::Account",
                            "required": true,
                            "type": "Entity"
                        }
                    },
                    "type": "Record"
                },
                "memberOfTypes": [
                    "FriendGroup"
                ]
            },
            "FriendGroup": {
                "shape": {
                    "attributes": {
                        "Name": {
                            "type": "String"
                        }
                    },
                    "type": "Record"
                },
                "memberOfTypes": [
                    "Account"
                ]
            },
            "Account": {
                "memberOfTypes": [],
                "shape": {
                    "type": "Record",
                    "attributes": {
                        "Name": {
                            "type": "String"
                        }
                    }
                }
            },
            "Photo": {
                "memberOfTypes": [
                    "Album",
                    "Account"
                ],
                "shape": {
                    "attributes": {
                        "IsPrivate": {
                            "type": "Boolean"
                        },
                        "Name": {
                            "type": "String"
                        }
                    },
                    "type": "Record"
                }
            }
        },
        "actions": {
            "SetPrivacyFlag": {
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    }
                },
                "memberOf": [
                    {
                        "id": "ManageAccount",
                        "type": "PhotoFlash::Action"
                    }
                ]
            },
            "CloseAccount": {
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "resourceTypes": [
                        "Account"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    }
                },
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "ManageAccount"
                    }
                ]
            },
            "LimitedPhotoAccess": {
                "appliesTo": {
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "Record",
                        "attributes": {}
                    },
                    "principalTypes": [
                        "User"
                    ]
                }
            },
            "ManageAccount": {
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "context": {
                        "type": "Record",
                        "attributes": {}
                    },
                    "resourceTypes": [
                        "Account",
                        "Photo"
                    ]
                }
            },
            "ManageFriendGroup": {
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "ManageAccount"
                    }
                ],
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "resourceTypes": [
                        "FriendGroup"
                    ],
                    "context": {
                        "type": "Record",
                        "attributes": {}
                    }
                }
            },
            "DeletePhoto": {
                "appliesTo": {
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "resourceTypes": [
                        "Photo"
                    ],
                    "principalTypes": [
                        "User"
                    ]
                },
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "ManageAccount"
                    }
                ]
            },
            "SharePhoto": {
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "resourceTypes": [
                        "Photo"
                    ]
                },
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "FullPhotoAccess"
                    }
                ]
            },
            "BlockAccountAccess": {
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "ManageAccount"
                    }
                ],
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "resourceTypes": [
                        "Account"
                    ]
                }
            },
            "ViewPhoto": {
                "appliesTo": {
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "principalTypes": [
                        "User"
                    ]
                },
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "LimitedPhotoAccess"
                    },
                    {
                        "id": "FullPhotoAccess",
                        "type": "PhotoFlash::Action"
                    }
                ]
            },
            "UploadPhoto": {
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "ManageAccount"
                    }
                ],
                "appliesTo": {
                    "resourceTypes": [
                        "Photo"
                    ],
                    "principalTypes": [
                        "User"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    }
                }
            },
            "FullPhotoAccess": {
                "appliesTo": {
                    "principalTypes": [
                        "User"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "resourceTypes": [
                        "Photo"
                    ]
                }
            },
            "CommentPhoto": {
                "memberOf": [
                    {
                        "id": "FullPhotoAccess",
                        "type": "PhotoFlash::Action"
                    }
                ],
                "appliesTo": {
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "principalTypes": [
                        "User"
                    ]
                }
            },
            "ManageAlbum": {
                "appliesTo": {
                    "context": {
                        "attributes": {},
                        "type": "Record"
                    },
                    "resourceTypes": [
                        "Album"
                    ],
                    "principalTypes": [
                        "User"
                    ]
                },
                "memberOf": [
                    {
                        "type": "PhotoFlash::Action",
                        "id": "ManageAccount"
                    }
                ]
            }
        }
    }
}

ポリシーは、テンプレートを含め5つ作成されます。

permit (
    principal,
    action in PhotoFlash::Action::"FullPhotoAccess",
    resource
)
when { resource in principal.Account };

permit (
    principal,
    action in PhotoFlash::Action::"ManageAccount",
    resource
)
when { resource in principal.Account };

permit (
    principal in ?principal,
    action in PhotoFlash::Action::"LimitedPhotoAccess",
    resource in ?resource
)
unless { resource.IsPrivate };

forbid (
    principal == ?principal,
    action,
    resource in ?resource
);

permit (
    principal in ?principal,
    action in PhotoFlash::Action::"FullPhotoAccess",
    resource == ?resource
)
unless { resource.IsPrivate };

3. サンプルアプリケーションの実装

package main

import (
    "fmt"
    "os"
    "github.com/aws/aws-sdk-go/service/verifiedpermissions"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/gin-gonic/gin"
)

type user struct {
    id string
}

type account struct {
    id string
}

type photo struct {
    id string
    owner string
}

const PERMISSION_ALLOW = "ALLOW"
var currentUser = user{id: "test"}
var currentAccount = account{id: "test"}
var sess = session.Must(session.NewSession())
var vp = verifiedpermissions.New(sess, aws.NewConfig().WithRegion("ap-northeast-1"))
var policyStoreId = os.Getenv("POLICY_STORE_ID")
var photos = map[string]photo{
    "1": photo{id: "1", owner: "test"},
    "2": photo{id: "2", owner: "test"},
    "3": photo{id: "3", owner: "other"},
}

type authorizedHandler interface {
    getAction(c *gin.Context) string
    getResource(c *gin.Context) (string, string)
    getHandler() gin.HandlerFunc
    getEntities(c *gin.Context) []*verifiedpermissions.EntityItem
}

type uploadPhotoHandler struct {}

func (h *uploadPhotoHandler) getAction(c *gin.Context) string {
    return "UploadPhoto"
}
func (h *uploadPhotoHandler) getResource(c *gin.Context) (string, string) {
    return "PhotoFlash::Photo", "dummy"
}
func (h *uploadPhotoHandler) getEntities(c *gin.Context) []*verifiedpermissions.EntityItem {
    id := "dummy"

    return []*verifiedpermissions.EntityItem{
        {
            Attributes: map[string]*verifiedpermissions.AttributeValue{
                "Account": &verifiedpermissions.AttributeValue{
                    EntityIdentifier: &verifiedpermissions.EntityIdentifier{
                        EntityType: aws.String("PhotoFlash::Account"),
                        EntityId: aws.String(currentAccount.id),
                    },
                },
            },
            Identifier: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::User"),
                EntityId: aws.String(currentUser.id),
            },
            Parents: []*verifiedpermissions.EntityIdentifier{},
        },
        {
            Identifier: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::Photo"),
                EntityId: aws.String(id),
            },
            Parents: []*verifiedpermissions.EntityIdentifier{
                {
                    EntityType: aws.String("PhotoFlash::Account"),
                    EntityId: aws.String(currentAccount.id),
                },
            },
        },
    }
}
func (h *uploadPhotoHandler) getHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        // storageやDBへの書き込みをここで行う
        c.JSON(200, gin.H{
            "message": "upload photo successful!",
        })
    }
}

type viewPhotoHandler struct {}
func (h *viewPhotoHandler) getAction(c *gin.Context) string {
    return "ViewPhoto"
}
func (h *viewPhotoHandler) getResource(c *gin.Context) (string, string) {
    return "PhotoFlash::Photo", c.Param("id")
}
func (h *viewPhotoHandler) getEntities(c *gin.Context) []*verifiedpermissions.EntityItem {
    photo, ok := photos[c.Param("id")]
    if !ok {
        return []*verifiedpermissions.EntityItem{}
    }

    return []*verifiedpermissions.EntityItem{
        {
            Attributes: map[string]*verifiedpermissions.AttributeValue{
                "Account": &verifiedpermissions.AttributeValue{
                    EntityIdentifier: &verifiedpermissions.EntityIdentifier{
                        EntityType: aws.String("PhotoFlash::Account"),
                        EntityId: aws.String(currentAccount.id),
                    },
                },
            },
            Identifier: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::User"),
                EntityId: aws.String(currentUser.id),
            },
            Parents: []*verifiedpermissions.EntityIdentifier{},
        },
        {
            Identifier: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::Photo"),
                EntityId: aws.String(photo.id),
            },
            Parents: []*verifiedpermissions.EntityIdentifier{
                {
                    EntityType: aws.String("PhotoFlash::Account"),
                    EntityId: aws.String(photo.owner),
                },
            },
        },
    }
}
func (h *viewPhotoHandler) getHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        // DBから取得した画像のURLを返す
        photoUrl := "<https://dummy.com/>" + c.Param("id") + "/dummy.jpg"
        c.JSON(200, gin.H{
            "image_url": photoUrl,
        })
    }
}

func authHandler(handler authorizedHandler) gin.HandlerFunc {
    return func(c *gin.Context) {
        rt, rs := handler.getResource(c)
        o, err := vp.IsAuthorized(&verifiedpermissions.IsAuthorizedInput{
            Action: &verifiedpermissions.ActionIdentifier{
                ActionId: aws.String(handler.getAction(c)),
                ActionType: aws.String("PhotoFlash::Action"),
            },
            Resource: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String(rt),
                EntityId: aws.String(rs),
            },
            Principal: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::User"),
                EntityId: aws.String(currentUser.id),
            },
            Entities: &verifiedpermissions.EntitiesDefinition{
                EntityList: handler.getEntities(c),
            },
            PolicyStoreId: aws.String(policyStoreId),
        })
        if err != nil {
            fmt.Printf("err: %s \\\\n", err)
            c.AbortWithStatus(400)
            return
        }

        if *o.Decision != PERMISSION_ALLOW {
            fmt.Printf("err: %v \\\\n", o)
            c.AbortWithStatus(403)
            return
        }

        (handler.getHandler())(c)
        c.Next()
    }
}

func main() {
    r := gin.Default()

    r.POST("/photo/upload", authHandler(&uploadPhotoHandler{}))
    r.GET("/photo/:id", authHandler(&viewPhotoHandler{}))
    r.Run()
}

フレームワークは gin を利用して、写真のアップロード POST: /photo/upload と写真の取得 GET: /photo/:id を実装しています。
サンプルのため、認証処理は省略し currentUser, currentAccount にて認証済みユーザーの情報が取れる前提です。

各エンドポイント(handler)ごとに、認可に必要なデータ(Action, Resource, Entity)を取得できるようにしています。(Principal は今回リクエストユーザーに固定)

type viewPhotoHandler struct {}
func (h *viewPhotoHandler) getAction(c *gin.Context) string {
    return "ViewPhoto"
}
func (h *viewPhotoHandler) getResource(c *gin.Context) (string, string) {
    return "PhotoFlash::Photo", c.Param("id")
}
func (h *viewPhotoHandler) getEntities(c *gin.Context) []*verifiedpermissions.EntityItem {
    photo, ok := photos[c.Param("id")]
    if !ok {
        return []*verifiedpermissions.EntityItem{}
    }

    return []*verifiedpermissions.EntityItem{
        {
            Attributes: map[string]*verifiedpermissions.AttributeValue{
                "Account": &verifiedpermissions.AttributeValue{
                    EntityIdentifier: &verifiedpermissions.EntityIdentifier{
                        EntityType: aws.String("PhotoFlash::Account"),
                        EntityId: aws.String(currentAccount.id),
                    },
                },
            },
            Identifier: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::User"),
                EntityId: aws.String(currentUser.id),
            },
            Parents: []*verifiedpermissions.EntityIdentifier{},
        },
        {
            Identifier: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::Photo"),
                EntityId: aws.String(photo.id),
            },
            Parents: []*verifiedpermissions.EntityIdentifier{
                {
                    EntityType: aws.String("PhotoFlash::Account"),
                    EntityId: aws.String(photo.owner),
                },
            },
        },
    }
}
func (h *viewPhotoHandler) getHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        // DBから取得した画像のURLを返す
        photoUrl := "<https://dummy.com/>" + c.Param("id") + "/dummy.jpg"
        c.JSON(200, gin.H{
            "image_url": photoUrl,
        })
    }
}

取得したデータを IsAuthorized メソッドに渡し、認可が通るかを判定します。
認可が通らなかった場合は 403 を返し処理を終了させています。
IsAuthorized メソッドに渡している PolicyStoreId はポリシーストアを作成した際に割り振られるIDになっており、 AWS コンソールから取得できます。

func authMiddleware(handler authorizedHandler) gin.HandlerFunc {
    return func(c *gin.Context) {
        rt, rs := handler.getResource(c)
        o, err := vp.IsAuthorized(&verifiedpermissions.IsAuthorizedInput{
            Action: &verifiedpermissions.ActionIdentifier{
                ActionId: aws.String(handler.getAction(c)),
                ActionType: aws.String("PhotoFlash::Action"),
            },
            Resource: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String(rt),
                EntityId: aws.String(rs),
            },
            Principal: &verifiedpermissions.EntityIdentifier{
                EntityType: aws.String("PhotoFlash::User"),
                EntityId: aws.String(currentUser.id),
            },
            Entities: &verifiedpermissions.EntitiesDefinition{
                EntityList: handler.getEntities(c),
            },
            PolicyStoreId: aws.String(policyStoreId),
        })
        if err != nil {
            fmt.Printf("err: %s \\\\n", err)
            c.AbortWithStatus(400)
            return
        }

        if *o.Decision != PERMISSION_ALLOW {
            fmt.Printf("err: %v \\\\n", o)
            c.AbortWithStatus(403)
            return
        }

        (handler.getHandler())(c)
        c.Next()
    }
}

実際にリクエストを送ってみると、
owner が自分ではない、id が 3 の photo にアクセスしようとすると 403 が返ります。

最後に

まだ GA したばかりのサービスのため情報が少ないですが、少し複雑な認可処理でも柔軟に表現できました。
今後、新規で認可処理を実装する際の選択肢の 1 つとして考えていきたいです!