フューチャー技術ブログ

Casbinで始めるアクセス制御

はじめに

TIG真野です。認証認可連載の2本目です。

認証認可がテーマの中で、アクセス制御と聞くとちょっと外れているかもと思いましたが、Casbinについて紹介します。

トップページにも An authorization library that supports access control models like ACL... とあり、アクセス制御を支援する認可ライブラリだよって書いているので、OKと判断しました。

Casbinについて

ACL、RBAC、ABACなどの様々なモデルでアクセス制御を行えるライブラリです。

私が最初に存在を知ったのは、avelino/awesome-go に載っていたことからだったので、てっきりGo言語のみのライブラリかと思っていました。

実際はドキュメントを見ると、複数の言語をサポートしています。Go以外にも、Java, Node.js, PHP, Python, .NET, C#, C++, Rust, Delphi, Lua, Dart, Elixirに対応しているとのこと(言語によっては一部の機能が使えないなどあるようです。)

ドキュメントに書いてることがシンプルだったのでそのまま転載、抜粋します。

Casbinが行うこと:

  1. {subject, object, action} の形式や独自定義のカスタマイズされた形式のポリシーを適用します
  2. アクセス制御モデルとそのポリシーの保存をハンドリングします
  3. ロール・ユーザー間のマッピングとロール・ロール間のマッピング(RBACのロール階層管理
  4. root や administrator のようなスーパーユーザのサポート
  5. ルールのマッチングをサポートする複数の組み込み演算子もサポートします。 例えば、 keyMatch はリソース キー /foo/bar をパターン /foo* のマッピング

Casbinが行わないこと:

  1. 認証 (ログイン時の ユーザー名 と パスワード の検証)
  2. ユーザーまたはロールのリスト管理。 プロジェクトがこれらのエンティティを管理する方が利便性が高いと考えています。 Casbinはパスワードを保管しない

仕組みとしては、 PERM メタモデル (Policy, Effect, Request, Matchers) にもとづいて動作するとのこと。

ACL、RBAC、ABACについて

それぞれ用語だけ簡単に触れます。

  • ACL(Access Controll List)
    • アクセス制御
  • RBAC(Role Based Access Control)
    • ロールベースアクセス制御
  • ABAC(Attribute Based Access Control)
    • 属性ベースアクセス制御

アクセス制御について詳しい解説は、次のようなサイトを見ると良いと思います。

PERM メタモデル について

Casbin以外で聞いたことが無いですが(一般用語でしたらすいません)、Policy, Effect, Request, Matchersの略です。

casbin_image.png

ファイルシステムのACLのイメージ図です。ポリシー定義がそのファイルの権限を誰が持っているかのリストです。モデル定義はそれをもとにどのように動作させるかを示しています。

図だと、以下を示しています。

  • aliceはdata1を読み取りOK
  • bobはdata2を書き込みOK

sub, obj, actは だれが、を、どうする に置き換えるとイメージしやすいと思います。

触ってみる(Go)

GoでどのようにCasbinを動かすのか、触ってみます。

最初にPolicyを定義します。実用的にはPostgreSQL/MySQLといったDBやAmazon S3などに配備すると思います。そういったAdaptorも用意されています。今回は簡易的にCSVファイルを用います。

例としてRBACをイメージしています。

policy.csv
p, admin, file1, read
p, admin, file2, read
p, admin, file3, read
p, Aさん, file4, read
g, Bさん, file5, read
g, Aさん, admin

見たまんまですが、adminロールを持っている人はfile1~file3に対して権限があり、Aさんのみadminです。
また、Aさんはfile4, Bさんはfile5に権限を個別に持っています。

Goのコードとしては、モデルのロード、ポリシーのロードを行い生成する casbin.NewEnforcer() がメインどころです。このインスタンスを生成できるとあとは Enforce() で判定可能です。

package main

import (
"fmt"
"log"

"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
fileadapter "github.com/casbin/casbin/v2/persist/file-adapter"
)

func main() {

modelParam, err := model.NewModelFromString(`
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _ , _

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

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
`)
if err != nil {
log.Fatalf("NewModelFromString: %s", err)
}

enforcer, err := casbin.NewEnforcer(modelParam, fileadapter.NewAdapter("policy.csv"))
if err != nil {
log.Fatalf("NewEnforcer: %s", err)
}

in := [][]any{
{"Aさん", "file1", "read"},
{"Aさん", "file1", "write"},
{"Aさん", "file4", "read"},
{"Bさん", "file1", "read"},
}

for _, v := range in {
ok, err := enforcer.Enforce(v...)
if err != nil {
log.Fatalf("enforce: %s", err)
}
fmt.Printf("%v: %v\n", v, ok)
}

}

これを動かすと、次のように想定通りの結果を得られます。

実行結果
[Aさん file1 read]: true
[Aさん file1 write]: false
[Aさん file4 read]: true
[Bさん file1 read]: false

CasbinのAPIの使い方で言えば、判定するための実装そのものより、モデルのmatcherの書き方や、これらの定義をどのようにロードさせたり、変更があった場合に追随させるかといったところの方が難しいと思います。

matcherの文法:

adaptor:

  • https://casbin.io/docs/adapters に記載されている通り、複数のデータソースに対応しています。地味にEntsqlx といったORマッパーも対応していて細かいです
    • AutoSave ですが、enforcerは UpdatePolicy() で動的に定義を変更することが可能なため、それを自動で保存する機能です
    • 例えば、何か新しいファイルやレコードが追加された時に権限を更新→自動で永続化先まで反映してくれるといった具合です

HTTPサーバの利用できるAPIをアクセス制御する

casbin_server.drawio.png

さきほどの例だとあまりイメージが付きにくいと思うので、Web APIの URL+Method でアクセス制限する例を実装していみます。

ここでは説明のためスクラッチで書いていますが、Echo, Gin、Chiなどすでにミドルウェアで準備されています。

今回は go-chi を用いて実装します。それっぽい例を探すのが大変だっため、_examples/rest を流用しました。オリジナルのコードはそちらを参照ください。

まずはモデル定義です。

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

[policy_definition]
p = sub, obj, act

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

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

最後のmachersだけ、条件が増えています。
keyMatch はワイルドカードを許容する関数です。例えば、 /articles/*/articles/1234 とか、 /articles/1234/comments/5678 などを許容したいので利用しています。詳細はこちらを参照ください。
今回はワイルドカードで許容できるようにしたいので、 act 側はORで繋いでいます。 keyMatch だと GET, POST, DELETE などを * で許容できなかったのでこの書き方をしています。

ポリシーは今回もCSVファイルで定義します。

policy.csv
p, guest, /, *
p, guest, /ping, *
p, member, /, GET
p, member, /ping, GET
p, member, /articles, GET
p, member, /articles/*, GET
p, owner, /, GET
p, owner, /ping, GET
p, owner, /articles, *
p, owner, /articles/*, *
p, admin, /*, *

全てロールで、guest < member < owner < admin の順番で権限が強くなるイメージです。

次に、Casbinの判定をchiのミドルウェアに設定する実装イメージを書いてみました。

main.go
func main() {
flag.Parse()
r := chi.NewRouter()
r.Use(middleware.RequestID)
// 中略

// Casbinのモデル、ポリシーをロード
casbinModel, err := model.NewModelFromFile("model.conf")
if err != nil {
log.Fatalf("NewModelFromFile: %s", err)
}
enforcer, err := casbin.NewEnforcer(casbinModel, fileadapter.NewAdapter("policy.csv"))
if err != nil {
log.Fatalf("NewEnforcer: %s", err)
}

// ミドルウェアに設定
r.Use(CasbinAuthorizer(enforcer))

続いてミドルウェア本体です。かなり端折って書いています。コードコメントにも書いていますが、本来はログイン後にセッションか何かにユーザーIDを載せ、紐づくユーザーロールをDBから取得するようなイメージでいます(あるいはJSTトークンにロールを載せてもらうかなど)。取得したロールも、本来は http.Requestの context.Context に載せて引き回した方が自然かもしれませんが、簡易的にリクエストヘッダーから取っています。

main関数側で設定した casbin.Enforcer で、リクエストを検証して、OKであれば後続へ。NGであれば403を返します。

func CasbinAuthorizer(e *casbin.Enforcer) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {

// 何かしらミドルウェアの前処理(セッションやJWTトークンから取得)でロールがリクエストヘッダーに入っているものとする
role := r.Header.Get("user_role")
if role == "" {
role = "guest"
}

// casbinでリクエストを検証(ロール、URL、メソッド)
res, err := e.Enforce(role, r.URL.Path, r.Method)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if res {
next.ServeHTTP(w, r)
} else {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
}

return http.HandlerFunc(fn)
}
}

実際にcurlで試してみます。ロールをリクエストヘッダで切り替えていますが、本来はこれだと意味がないので、ログイン処理などを追加して、サーバ側でロールを判定するように改修し、クライアントがロールを指定できなくする必要があります。

# guest ロール
$ curl http://localhost:3333/ping
pong

$ curl http://localhost:3333/articles
Forbidden


# member ロール
$ curl -H 'user_role:member' http://localhost:3333/articles
[{"id":"1","user_id":100,"title":"Hi","slug":"hi","user":{"id":100,"name":"Peter","role":"collaborator"},"elapsed":10},{"id":"2","user_id":200,"title":"sup","slug":"sup","user":{"id":200,"name":"Julia","role":"collaborator"},"elapsed":10},{"id":"3","u
ser_id":300,"title":"alo","slug":"alo","elapsed":10},{"id":"4","user_id":400,"title":"bonjour","slug":"bonjour","elapsed":10},{"id":"5","user_id":500,"title":"whats up","slug":"whats-up","elapsed":10}]

$ curl -H 'user_role:member' -X DELETE http://localhost:3333/articles/1
Forbidden


# owner ロール
$ curl -H 'user_role:owner' -X DELETE http://localhost:3333/articles/1
{"id":"1","user_id":100,"title":"Hi","slug":"hi","user":{"id":100,"name":"Peter","role":"collaborator"},"elapsed":10}

$ curl -H 'user_role:owner' http://localhost:3333/admin
Forbidden


# admin ロール
$ curl -H 'user_role:admin' http://localhost:3333/admin
admin: index

ロールに権限があれば、操作が成功していることが分かります。

もっと細かい制御をするためには

上記の実装例だと、例えばある記事の削除は、作成したユーザー自身 も削除できるようにしたい、といった要望には対応できません(ユーザー単位でロールを作ればもちろん可能ですが、もはやそれはロールの意味が無いですよね)。

Casbinでどう実現するかですが、今のURLの構造でがんばるのであれば、articlesが追加されるごとにユーザーIDとマッピングさせた policy.csv のレコードに相当するデータを釣っていくことです。Casbinの機能であれば、Priority Modelで多段の権限を判定できるので、参考になるかもしれません。

また、この実装例のURL階層だと、ワイルドカードが使いにくいので、 /users/<user_id>/articles/<article id> といった階層を工夫すると policy のメンテナンスをシンプルに抑えることもできるかと思います。

まとめ

気になっていたCasbinの触りについてまとめました。ドキュメントサイトも新しくなっていますし、採用事例もちょくちょく聞きますし、プロダクション運用にも耐えうる品質だと思っています。

すこし気になっているのは、2022.10.3時点だと v3.0.0-beta.7 がタグ付けされています。もうすぐv3系がリリースされる予感があり、APIの互換性がすこし崩れるのかも?と推測しています。(とは言え、ドキュメントページも整理されていますし、すでにv1→v2で移行しているので、根本からそこまで変わらないのでは?と思っていますが)。このあたりは新規に採用する時に留意したほうが良いと思います。

アクセス制限は自前で作り込むと面倒な割に、ユーザーからは当たり前品質の扱いをされがちだと思うので、こういった既存プロダクトにうまくのっかれると良いと思います。

参考