💨

SpiceDBを使ってAPIの認可制御をしてみる

2022/09/11に公開

はじめに

SpiceDBは以前の記事で紹介した大規模認可基盤ZanzibarのOSS実装です.本記事ではSpiceDBを使って認可制御することでZanzibarで提案されている認可制御の仕組みを試してみたいと思います.

SpiceDBによる認可制御の流れ

認可制御に登場するエンティティとして,権限の対象となるオブジェクト,権限を付与されるサブジェクト,権限内容のパーミッションの3つがあります.SpiceDBを利用して認可制御するには以下の手順で行います.

  1. 認可に関わるエンティティを定義するスキーマの作成
    オブジェクト,サブジェクトの定義と,オブジェクトに対する権限をどのサブジェクトが持つかといったスキーマの定義をします.リレーショナルデータベースにおけるテーブル定義に位置します.
  2. エンティティ間の関係(オブジェクト,サブジェクト,パーミッション)の作成
    定義したスキーマにもとづき,具体的なオブジェクトに対するサブジェクトがどういった権限を持つかといった登録を行います.リレーショナルデータベースにおけるデータ登録と同等です.
  3. 認可の検証
    認可の検証では,あるサブジェクトがあるオブジェクトの権限を持つかどうかの検証を,登録されたスキーマ及びエンティティ間の関係から検証します.

試行するシナリオ

本記事では権限制御する対象としてブログ作成するWeb APIを想定します.具体的には以下のような要件があるとします.

  • 登録ユーザはブログの作成,編集,取得ができる
  • ブログの編集ができるのはブログ作成者のみ
  • 全てのユーザは自分および他人が作成したブログの取得ができる

これくらいの要件であればZanzibarが提案する柔軟な認可要件を使うまでもないですが,まずは使ってみるということで簡単な例で試行してみます.

SpiceDBによるAPIの認可制御

コードは下に置いています.
https://github.com/manaty226/sample-app-with-spicedb

対象リソースとなるAPIの概要

ブログ作成APIとしてつぎの2つのエンドポイントに対するメソッドを定義しています.

authorized := r.Group("/", gin.BasicAuth(cfg.AuthnUsers))
authorized.POST("/blogs", a.Handle)
authorized.GET("/blogs/:id", g.Handle)
authorized.PUT("/blogs/:id", u.Handle)

/blogsエンドポイントはPOSTメソッドをサポートしており,本エンドポイントにデータをポストすることでブログの作成を行います./blogs/:idエンドポイントはGETとPUTメソッドをサポートしており,それぞれブログの取得と編集に対応しています.また,各エンドポイントはBASIC認証によりユーザ認証しています.

スキーマの登録

上記の要件を表現するスキーマを以下のように定義しました.

definition user{}

definition blog {
  relation reader: user | user:*
  relation writer: user

  permission write = writer
  permission read = reader + writer
}

登場するエンティティとして,サブジェクトであるuser,オブジェクトであるblog,権限のwritereadを定義しています.また,blogのリレーション登録において,readerwriterという役割をuserサブジェクトが持ちうることを定義した上で,write権限を持つのはwriterで,read権限を持つのはreaderwriterであることを定義しています.

エンティティ間の関係の作成

登録ユーザによりPOSTメソッドでブログが作成されると,作成されたブログの認可権限を設定する必要があります.以下のコードで示すように,ブログが作成されると,作成したユーザに対してwriterの関係を作成し,全てのユーザに対してreaderの関係を作成します.これにより,先ほど登録したスキーマにおけるwriterreaderが誰であるかを具体的に登録できたことになります.

func (a *AddBlog) Handle(c *gin.Context) {
	...()...
	// Set authorization user
	id := fmt.Sprintf("%d", b.ID)
	user := c.MustGet(gin.AuthUserKey).(string)
	// ブログ作成者に作成したブログに対するwriterの関係を作成する
	if err := (*a.Authorizer).CreateUserPermission("blog", id, user, "writer"); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	// 全てのユーザに作成したブログに対するreaderの関係を作成する
	if err := (*a.Authorizer).CreateUserPermission("blog", id, "*", "reader"); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"id": b.ID})
}

SpiceDBへの実際の登録コードは以下のようになります.SpiceDBの開発元のAuthzedから提供されているgRPCのクライアントライブラリを利用して書き込みます.書き込みリクエストに対するレスポンスとして返ってくるZedTokenはZanzibarにおけるZookieに相当しており,ZedTokenを次回のリクエストに付与することで読み取り一貫性を保つことができます.

import 	pb "github.com/authzed/authzed-go/proto/authzed/api/v1"

func (s *spiceClient) CreateRelation(object, subject entity.Object, relation string) error {
	obj := &pb.ObjectReference{ObjectType: string(object.Type), ObjectId: object.ID}
	sub := &pb.SubjectReference{Object: &pb.ObjectReference{ObjectType: string(subject.Type), ObjectId: subject.ID}}
	request := &pb.WriteRelationshipsRequest{Updates: []*pb.RelationshipUpdate{
		{
			Operation: pb.RelationshipUpdate_OPERATION_TOUCH,
			Relationship: &pb.Relationship{
				Resource: obj,
				Relation: relation,
				Subject:  sub,
			},
		},
	},
	}
	resp, err := s.client.WriteRelationships(context.Background(), request)
	if err != nil {
		return err
	}
	s.zedToken = resp.WrittenAt
	return nil
}

権限の確認

/blogs/:idエンドポイントのGETとPUTメソッドでは,各ブログに対する登録ユーザの権限を確認します.
下の例はPUTメソッドに対するハンドラであり,ハンドラレベルではHTTPメソッドを受け渡していますが,サービス層以下でアクセスしてきたユーザが書き込み権限を保持するかをチェックしています.

func (u *UpdateBlog) Handle(c *gin.Context) {
	// ユーザが書き込み権限を保持しているか確認
	user := c.MustGet(gin.AuthUserKey).(string)
	ok, err := (*u.Authorizer).CheckPermission("blog", c.Param("id"), user, "PUT")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if !ok {
		c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("not authorized to update the requested blog.")})
		return
	}
	...()...
}

SpiceDBに対して権限の有無を確認するコードは以下になります.こちらもAuthzedが提供するライブラリを利用してgRPCでSpiceDBに問い合わせます.

func (s spiceClient) CheckPermission(object, subject entity.Object, action entity.Action) (bool, error) {
	obj := &pb.ObjectReference{ObjectType: string(object.Type), ObjectId: object.ID}
	sub := &pb.SubjectReference{Object: &pb.ObjectReference{ObjectType: string(subject.Type), ObjectId: subject.ID}}

	// Set consistency level. When no ZedToken exists, must set not to use the ZedToken.
	var consistency *v1.Consistency
	if s.zedToken == nil {
		consistency = &v1.Consistency{
			Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true},
		}
	} else {
		consistency = &v1.Consistency{
			Requirement: &v1.Consistency_AtLeastAsFresh{AtLeastAsFresh: s.zedToken},
		}
	}

	resp, err := s.client.CheckPermission(context.Background(), &pb.CheckPermissionRequest{
		Resource:    obj,
		Permission:  string(action),
		Subject:     sub,
		Consistency: consistency,
	})
	if err != nil {
		return false, err
	}
	isPermitted := resp.GetPermissionship() == pb.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION
	return isPermitted, nil
}

実行結果

# Taroがブログを作成しID=1が振られる
$ curl -u Taro:Taro -X POST http://localhost:3000/blogs -d '{ "title": "test", "content": "test" }'    
{"id":1}

# 全てのユーザに読み取り権限があるのでHanakoがブログを取得できる
$ curl -u Hanako:Hanako http://localhost:3000/blogs/1
{"id":1,"title":"test","content":"test"}

# 作成者であるTaroだけが書き込み権限をもつので,Hanakoはブログを編集できない
$ curl -u Hanako:Hanako -X PUT http://localhost:3000/blogs/1 -d '{ "title": "test", "content": "test" }'
{"error":"not authorized to update the requested blog."}  

# 作成者のTaroはブログを編集できる
$ curl -u Taro:Taro -X PUT http://localhost:3000/blogs/1 -d '{ "title": "test", "content": "test" }'    
{"id":1,"title":"test","content":"test"}

おわりに

SpiceDBを使った認可制御をAPIに組み込んで試してみました.今回は簡単な認可要件を想定しましたが,実装から認可制御の具体的な処理をAPIの処理から分離できると事業のスケールに合わせて認可制御が複雑な要件に変化してもAPI処理部分の認可にまつわるコードのシンプルさは保てるのではないでしょうか.

Discussion