GolangとCasbinでABACを実装する

# はじめに
認可専用のライブラリである[Casbin](https://casbin.org/)を使って、[ABAC](https://en.wikipedia.org/wiki/Attribute-based_access_control)を実装してみる。Casbinでは、単純にルールを羅列するのではなく、[eval()という組み込み関数](https://casbin.org/ja/docs/abac#scaling-the-model-for-complex-and-large-numbers-of-abac-rules)を使用することで、任意の認可ロジックを簡潔に組み込むことができる。なお、CasbinそのものやRBAC/ABAC等の認可の概念に関する説明は本記事ではしない。

eval()では、[公式ドキュメントの例](https://casbin.org/ja/docs/abac/#scaling-the-model-for-complex-and-large-numbers-of-abac-rules)のように直接ロジックを渡すこともできるし、[`AddFunction`というAPI](https://casbin.org/ja/docs/management-api#addfunction)を使ってGoで記述した任意の関数を登録し、それをポリシー内で呼び出すこともできる。今回は後者の方法を使い、DBへのアクセスが必要な権限管理のロジック、という想定で実装してみる。

# 実装するシナリオ
今回は、病院で使用するシステムを想定し、「自分が担当している患者のデータにのみアクセスできる」という要件を実装する。

# 実装
## モデルの定義
`model.conf`ファイルにモデルを定義する。

```shell
[request_definition]
r = user, obj, method

[policy_definition]
p = path, method, rule

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

[matchers]
m = keyMatch5(r.obj.Path, p.path) && (r.method == p.method || p.method == "*") && eval(p.rule)
```

## ポリシー
`policy.csv`にポリシーを記述する。

```csv
p, /patient/{patient_id}/record, *, "is_assigned_patient(r.user.ID, r.obj.ID)"
```

## APIと認可用ミドルウェア
```go
package main

import (
	"fmt"
	"net/http"
	"strconv"

	"github.com/casbin/casbin/v2"
	"github.com/labstack/echo/v4"
)

var enforcer *casbin.Enforcer

func main() {
	e := echo.New()

	var err error
	enforcer, err = casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		panic(err)
	}

	enforcer.EnableLog(true)

	// 任意の関数をCasbinに登録する
	enforcer.AddFunction("is_assigned_patient", func(args ...interface{}) (interface{}, error) {
		// NOTE: Casbinは文字列型のみ対応している https://github.com/casbin/casbin/issues/113
		doctorIDStr, ok := args[0].(string)
		if !ok {
			return false, fmt.Errorf("failed to get doctorID from casbin policy")
		}

		patientIDStr, ok := args[1].(string)
		if !ok {
			return false, fmt.Errorf("failed to get patientID from casbin policy")
		}

		doctorID, err := strconv.ParseUint(doctorIDStr, 10, 64)
		if err != nil {
			return false, fmt.Errorf("failed to parse doctorID: %w", err)
		}

		patientID, err := strconv.ParseUint(patientIDStr, 10, 64)
		if err != nil {
			return false, fmt.Errorf("failed to parse patientID: %w", err)
		}

        // DBから、リクエストを送った医者が担当している患者かどうかを取得する(今回はモック)
		return isPatientAssignedToDoctor(int(doctorID), int(patientID)), nil
	})

	e.Use(authorize)
	e.GET("/patient/:patient_id/record", func(c echo.Context) error {
		return c.String(http.StatusOK, "Access Allowed\n\n")
	})
	e.Logger.Fatal(e.Start(":1323"))
}

type User struct {
	ID int
}

type Object struct {
	ID   int
	Path string
}

// 認可をミドルウェアで実施する
func authorize(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		path := c.Request().URL.Path
		method := c.Request().Method

		userIDStr := c.QueryParam("user_id")
		userID, err := strconv.Atoi(userIDStr)
		if err != nil {
			return echo.NewHTTPError(http.StatusBadRequest, err.Error())
		}

		objectIDStr := c.Param("patient_id")
		objectID, err := strconv.Atoi(objectIDStr)
		if err != nil {
			return echo.NewHTTPError(http.StatusBadRequest, err.Error())
		}

		obj := Object{ID: objectID, Path: path}

		ok, err := enforcer.Enforce(User{ID: userID}, obj, method)
		if err != nil {
			return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
		}
		if ok {
			return next(c)
		}
		return c.String(http.StatusForbidden, "Access denied\n\n")
	}
}

func isPatientAssignedToDoctor(doctorID int, patientID int) bool {
	if doctorID == 1 && patientID == 1 {
		return true
	}
	return false
}
```

注意点として、[CasbinのポリシーはString型にしか対応していない](https://github.com/casbin/casbin/issues/113)。文字列型以外を渡すと正しく判定できないことがあるので注意が必要。

# 動作確認
```shell
// 自分が担当している患者の情報は取得できる
$ curl 'http://localhost:1323/patient/1/record?user_id=1'
Access Allowed

// 自分が担当していない患者の情報は取得できない
$ curl 'http://localhost:1323/patient/2/record?user_id=1'
Access denied
```

# 参考文献
- https://casbin.org/ja/docs/abac
- https://future-architect.github.io/articles/20221004a/