📚

GolangとCasbinでAPIのアクセス制御(RBAC)を実装する

2023/07/20に公開

株式会社CastingONEでバックエンドエンジニアをしている村上です。

最近のマイブームは育爪で、こちらのネイルエンビーを使いはじめました。なんかいい感じになってる気がします。

アクセス制御を実装しました

弊社SaaSのAPIに、GoとCasbinを使ったアクセス制御を導入しました。備忘録として、どんな感じの実装が可能なのかと所感をまとめたいと思います。

実装するもの

今回は、下記の条件で実装してみます。

  • ロールベースアクセス制御(RBAC)
  • ユーザーに複数のロールを設定できる
  • 明示的な拒否と暗黙的な拒否の考慮

構成

下記のファイル構成で実装してみました。

.
├── casbin.go
├── casbin_model.conf
├── casbin_policy.json
└── main.go

main.go

エンドポイントを3つ用意しています。ミドルウェアのauthorization(casbin.goに定義)を通すことで、エンドポイントへのアクセスを許可するか判断します。

main.go
package main

import (
	"fmt"
	"net/http"

	"github.com/go-chi/chi/v5"
)

func main() {
	r := chi.NewRouter()

	// アクセス制御のミドルウェア
	r.Use(authorization) 

	// 下記のエンドポイントは、ユーザーの権限によってアクセス可能にしたり不可にしたりする
	r.Get("/employees", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "GET /employees")
	})
	r.Post("/employees", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "POST /employees")
	})
	r.Get("/companies", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "GET /companies")
	})

	http.ListenAndServe(":8080", r)
}

casbin_policy.json

ミドルウェアで使うCasbin専用のファイルで、ロール(V0)がエンドポイント(V1〜V2)にアクセスできるかどうか(V3)を設定します。今回は3つのロールを定義しています。

  • fuga: /employeesへのアクセスが許可される。HTTPメソッドは不問
  • hoge: POST /employeesへのアクセスが拒否される
  • piyo: 全てのエンドポイントへのアクセスが許可される
casbin_policy.json
[
    {"PType":"p","V0":"fuga","V1":"/employees","V2":"*", "V3":"allow"},
    {"PType":"p","V0":"hoge","V1":"/employees","V2":"POST", "V3":"deny"},

    {"PType":"p","V0":"piyo","V1":"*","V2":"*", "V3":"allow"}
]

casbin_model.conf

なかなか取っ付きにくいのですが、ざっくり以下の設定をしています。

casbin_model.conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act, eft

[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))

[matchers]
m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")

request_definition

CasbinのEnforceEx(アクセスの許可/拒否を判定するメソッド)に渡すパラメータの順番を指定しています。

  • sub: だれが(今回はロール)
  • obj: 何に
  • act: 何をする

を表します。第1引数をsub、第2引数をobj、第3引数をactにしてEnforceExを実行すると、許可/拒否の判定が戻り値として返ってきます。REST APIの場合は、URLのパスをobj、HTTPメソッドをactにすることが多いと思います。

policy_definition

casbin_policy.jsonにおけるフィールドの順番を指定しています。sub, obj, act, eftの順番で指定しているため、これに合わせてcasbin_policy.jsonのV0〜V3を設定します。eftは許可/拒否を表します。

今回は以下のようになります。

  • V0 = sub:だれが(今回はロール)
  • V1 = obj:何に
  • V2 = act:何をする
  • V3 = eft:許可/拒否

policy_effect

eft(許可/拒否)を決めるロジックを指定します。CasbinのEnforceExに渡す sub, obj, act に該当する設定をcasbin_policy.jsonの中で走査して、「1件でも許可されている かつ 1件も拒否されていない」場合にアクセスを許可しています。

これによって、ユーザーに複数のロールが設定されている場合の「明示的な拒否と暗黙的な拒否」を考慮しています(下記ページの「明示的な拒否と暗黙的な拒否の違い」を参照)
https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_policies_evaluation-logic.html

matchers

CasbinのEnforceExに渡す sub, obj, act をもとに、casbin_policy.jsonのうちどの設定に該当するかを決めるルールを書いています。sub、obj、act を全て一致させる条件を書くことが多いと思います。

casbin.go

ここまで来たら、あとはリクエストから sub(ロール)、obj(パス)、act(HTTPメソッド)を取得して、CasbinのEnforceExに渡すとeft(許可/拒否)の判定ができます。

EnforceExの第1戻り値として許可/拒否を表すbool値、第2戻り値としてcasbin_policy.jsonにおける該当する設定のV0〜V3がsliceで返ってきます。複数のロールで判定が異なるときに拒否を優先したい(明示的な拒否)のと、明示的に許可/拒否の設定がされていないときは拒否にしたい(暗黙的な拒否)ので、ループを回しながら第1戻り値と第2戻り値をもとに判定しています。

casbin.go
package main

import (
	_ "embed"
	"log"
	"net/http"

	"github.com/casbin/casbin/v2"
	"github.com/casbin/casbin/v2/model"
	jsonadapter "github.com/casbin/json-adapter/v2"
)

//go:embed casbin_model.conf
var casbinModel string

//go:embed casbin_policy.json
var casbinPolicy []byte

func authorization(next http.Handler) http.Handler {
	m, err := model.NewModelFromString(casbinModel)
	if err != nil {
		log.Fatal(err)
	}

	a := jsonadapter.NewAdapter(&casbinPolicy)
	e, err := casbin.NewEnforcer(m, a)
	if err != nil {
		log.Fatal(err)
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// TODO: DB等からユーザーが持つロールを取得する
		userRoles := []string{
			"fuga",
			"hoge",
		}

		authorized := false
		obj := r.URL.Path
		act := r.Method

		for _, sub := range userRoles {
			allowed, reason, err := e.EnforceEx(sub, obj, act)
			if err != nil {
				http.Error(w, "error", http.StatusInternalServerError)
				return
			}

			// 明示的な許可
			if allowed {
				authorized = true
				continue
			}

			// 暗黙的な拒否
			if len(reason) == 0 {
				continue
			}

			// 明示的な拒否
			if reason[3] == "deny" {
				authorized = false
				break
			}
		}

		if !authorized {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}

		next.ServeHTTP(w, r)
	})
}

実際に各エンドポイントにリクエストを送ってみると、fugaとhogeのロールを持っているため、GET /employeesへのアクセスのみ許可されます。POST /employeesはcasbin_policy.jsonの2行目(許可)と3行目(拒否)の両方に該当しますが、拒否の方が強いため拒否で上書きされます(明示的な拒否)

GET /companiesについては、casbin_policy.jsonのいずれの行にも該当しないため、暗黙的な拒否となります。

$ curl -X GET localhost:8080/employees 
GET /employees
$ curl -X POST localhost:8080/employees
forbidden
$ curl -X GET localhost:8080/companies 
forbidden

ちなみに、ロールをpiyoだけに変更すると全てのエンドポイントにアクセスできるようになります。casbin_policy.jsonのV1〜V2を *、V3をallowにすることで、全てのエンドポイントへのアクセスを許可しています。

$ curl -X GET localhost:8080/employees 
GET /employees
$ curl -X POST localhost:8080/employees
POST /employees
$ curl -X GET localhost:8080/companies 
GET /companies

Casbinを使ってみた感想

アクセス制御初心者でもそれなりに仕上がる

公式ドキュメントを読み込む必要があったので、最初はすこし難しく感じました。

ただ、アクセス制御の実装が初めてでもそれなりに仕上がったのは、Casbinが敷いてくれているレールのおかげだと思っています。スクラッチで実装していたら、間違いなく変な方向に行っていました。

modelとpolicyのシミュレーションができて良かった

Casbinを使う上ではmodelとpolicyが根幹になるのですが、公式サイトにシミュレーションができるページがあったので、すごくはかどりました。「ここをこう変えたらこうなるんだ」といった感じで、お手軽に検証できました。ありがとうございます。

https://casbin.org/editor

テストコードの書き方

ミドルウェア単体のユニットテストを書くのも大事ですが、実際のエンドポイントとpolicyが必ず一致する(あるいは一致していないときに気づける)ようにした方が良いなと思いました。

諸事情でエンドポイントのHTTPメソッドを変更することがあったのですが、policyもそれに合わせるのを忘れてしまい、権限がないのにエンドポイントにアクセスできるバグが発生しました。そのまま本番環境にリリースされていたら、セキュリティ事故になっていた可能性があるので、注意が必要なところです。

おわりに

弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

https://www.wantedly.com/projects/1130967

Discussion