こんにちは、2023年5月にバックエンドエンジニアとしてジョインした yamanoi です。 今回は先日 GA (一般利用可能)になった AWS のサービス Amazon Verified Permissions を、 golang で実装した簡単なサンプルを交えて紹介したいと思います。 Amazon Verified Permissions とは、ポリシーベースの認可処理の実装を肩代わりしてくれるマネージドサービスです。 ロールベースのアクセス制御(RBAC)や属性ベースのアクセス制御(ABAC)を実現することができ、アプリケーションから認可の処理を一部分離することが可能になります。 Amazon Verified Permissions ではポリシー言語として Cedar 言語を採用しており、この言語を用いてアプリケーションに必要な認可のルールを記述していきます。 この記事では、サービスの使いどころ、 Cedar 言語の使い方、サンプルアプリケーションの実装までを解説していきたいと思います。 認可処理は従来サービスの開発する開発者自身で実装することが多いと思います。
認可処理を実装する課題点として以下のようなものが挙げられます。 そんな認可処理を一部置き換えてくれるサービスが AWS Verified Permissions になります。 認可処理の実装については Authorization Academy という資料が非常に分かりやすく参考になります。
日本語でわかりやすく解説している記事はこちら
zenn.dev この資料でも言及されていますが、
認可処理は極力アプリケーションのロジックから切り離して実装することで複雑度を下げることが可能です。 Amazon Verified Permissions は、ここで言う認可の判断(Decision)を行う際に利用できます。
そしてアプリケーションはその結果を受け、どう適用(Enforcement)させるかに集中できます。 Amazon Verified Permissions で利用される Cedar 言語は、 Rust で開発しているオープンソースの言語となります。 公式サイトにはチュートリアルに加えて Playground もあるため、サクッと動作を確認することができます。 それでは Cedar 言語の書き方を紹介していきたいと思います。 Cedar 言語では上記のように適用させたいポリシーを書いていきます。 こちらの例は 普段 AWS を使っている方であれば、 IAM Policy と似ているため直感的に理解しやすいかと思います。 ロールベースアクセスを実現するには principal に特定のユーザーを指定するのではなく、ロールを指定 する必要があります。 principal や resource に何も指定しない場合は、全ての principal または resource に対してアクセスを許可することができます。 他にも様々な記述の仕方や仕様があります。 実際にサンプルプロジェクトを題材に AWS Verified Permissions を利用して、 今回は PhotoFlash というサンプルプロジェクトを利用します。 サンプルポリシーストアから PhotoFlash を選択して作成します。
ポリシーストアを作成すると以下のスキーマが作成されます。
ポリシーは、テンプレートを含め5つ作成されます。 フレームワークは gin を利用して、写真のアップロード 各エンドポイント(handler)ごとに、認可に必要なデータ(Action, Resource, Entity)を取得できるようにしています。(Principal は今回リクエストユーザーに固定) 取得したデータを 実際にリクエストを送ってみると、 まだ GA したばかりのサービスのため情報が少ないですが、少し複雑な認可処理でも柔軟に表現できました。
最近は Cloudflare スタックに注目しており、新機能を触ったりアップデートを眺めたりしています。Amazon Verified Permissions とは
*ポリシー言語というのはAWSのIAM Policyのような認可に関するポリシーを記述するための言語となっています。
従来の認可処理
中でもインターフェースとして認可の判断(Decision)と適用(Enforcement)に分離するという考え方があります。
出典: Authorization Academy - What is Authorization?
Cedar 言語の使い方
公式サイト: https://www.cedarpolicy.com/en基本的な記述方法
permit (
principal == PhotoApp::User::"alice",
action == PhotoApp::Action::"viewPhoto",
resource == PhotoApp::Photo::"vacationPhoto.jpg"
);
principal(PhotoApp::User::"alice")
が
resource(PhotoApp::Photo::"vacationPhoto.jpg")
に対して
action(PhotoApp::Action::"viewPhoto")
を行うことを許可するポリシーとなります。
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": []
}
]
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": []
}
]
さらに when 句で条件を指定してあげることで、 principal や resource に対して付与している属性ベースで権限を絞り込む事ができます。
これまでの説明に出てきた "PhotoApp**" のようなものは、事前に Schema として定義することができます。
さらに詳しい内容は公式ドキュメントに記載しているので、気になった方はぜひ覗いてみてください。
https://docs.cedarpolicy.com/.golang で動かしてみる
認可処理を golang で実装したいと思います。
PhotoFlash とは、個々のユーザーが写真やアルバムを共有できるサービスとなります。
https://docs.aws.amazon.com/ja_jp/verifiedpermissions/latest/userguide/getting-started-first-policy-store.html1. ポリシーストアを AWS コンソールから作成する
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"
}
]
}
}
}
}
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()
}
POST: /photo/upload
と写真の取得 GET: /photo/:id
を実装しています。
サンプルのため、認証処理は省略し currentUser, currentAccount にて認証済みユーザーの情報が取れる前提です。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 が返ります。最後に
今後、新規で認可処理を実装する際の選択肢の 1 つとして考えていきたいです!