はじめましての方ははじめまして。21新卒エンジニアの Javakky です。 この度、社内向けサービスの管理画面に権限管理機能を実装するにあたり、 Casbin というアクセス制御ライブラリを利用しましたので導入までの流れをレポートしていきます。
Casbin とは
Casbin とはオープンソースで開発されているアクセス制御ライブラリで、Go・Java・Node.js・PHPなどさまざまな言語で実装されています。
Casbin の行うアクセス制御は、 アクセス制御モデル と アクセス制御ポリシー の 2種類の設定からなります。 アクセス制御モデルとは主体 (ユーザー・権限など) と対象物 (ページなど) の対応について定めたルールのことです。Casbin では、 アクセス制御リスト (ACL) や ロールベースアクセス制御 (RBAC) 、 属性ベースのアクセス制御 (ABAC) などに対応しており、 .conf ファイルで設定することができます。 ポリシーは、具体的に誰にどの権限が付与されているのかを表すルールです。Casbin では、 Adapter とよばれる実装を介して任意のストレージに保存されます。
インストール
今回利用した環境は Scala 2.13、Play Framework 2.8、 MySQL 5.7 となっております。 まずは、 build.sbt に必要な依存関係を追加していきます。
jCasbin:Casbin の Java 実装です。 Scala 実装はなさそうだったため、こちらを利用しました。
JDBC Adapter for jCasbin:jCasbin で設定したポリシーを、 jdbc を利用して DB ヘ保存するためのアダプターです。
project.settings( libraryDependencies ++= Seq( "org.casbin" % "jcasbin" % "1.18.0", "org.casbin" % "jdbc-adapter" % "2.1.2", // casbin の依存関係に要求されるためこのバージョンで固定 "commons-collections" % "commons-collections" % "3.2.2", jdbc ) )
モデルを定義する
Play アプリケーションの構造 に記載されている通り、外部設定ファイルであるモデルコンフィグは /conf
ディレクトリに作成しました。
今回は RBACモデルを採用し、許可または拒否の設定を元に設定を行いました。管理者という概念も採用したのですが、管理画面の認証周りの実装の都合上 root ユーザーによるログインという概念を適用できなかったため、対象 root
への権限をもつユーザーは他のすべての対象への権限を持つ、というように設定しました。
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act, eft [role_definition] g = _, _ [policy_effect] e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) [matchers] m = g(r.sub, p.sub) && (r.obj == p.obj || p.obj == "all") && (r.act == p.act || p.act == "all")
オブジェクトを生成してDIする
jCasbin では制御操作を持った Enforcer に、モデルを表現する Model、ポリシーストレージへのアクセス操作を持つ Adapter を紐づけることでアクセス制御操作を行うことができます。今回は、 Enforcer をラップした AuthorityService と、conf ファイルから生成した Model、Adapter を生成するビルダーを作成してDIに設定しました。
まずは Model についてです。先ほど作成した conf/rbac_model.conf
を読み込んでインスタンスを作成していきます。
org.casbin.jcasbin.main.CoreEnforcer
には Model newModel(String modelPath, String unused)
というファイルパスからモデルを作成してくれるメソッドが存在するのですが、こちらの利用には注意が必要です。このメソッドは FileInputStream
によって設定したパスからファイルを読み込んでくれるのですが、実は .jar にパックしたコンフィグファイルについては読み込むことができません。
もし、 .jar 内に含めた .conf ファイルを参照したい場合、以下のように getResourceAsStream()
からファイル内容を読み込んだ上でモデルを作成してやる必要があります。
import java.io.{BufferedReader, InputStreamReader} import java.util.stream.Collectors import scala.util.Using import org.casbin.jcasbin.main.CoreEnforcer.newModel import org.casbin.jcasbin.model.Model object CasbinModel { // デプロイする時 (立ち上げ時) 以外で `rbac_model.conf` は変更されないため val private val readLineAllModelConf: String = { Using( new BufferedReader(new InputStreamReader(getClass.getClassLoader.getResourceAsStream("conf/rbac_model.conf"))) ) { reader => reader.lines().collect(Collectors.joining("\n")) }.getOrElse(sys.error("rbac_model.confの読み込みに失敗しました")) } // model を使い回すと内部 List が複数スレッドから変更されて壊れるので def def model: Model = newModel( readLineAllModelConf ) }
次に Adapter の生成周りを作っていきます。
まずは MySQL ヘ接続を行うための設定を渡すことで JDBCAdapter
をインスタンス化しています。
また、 JDBCAdapter
は close()
メソッドを実装しているので Using
を利用したクローズ処理が行えるよう、 java.io.Closeable
を Mixin しておきます。モデルと異なり、インスタンスそのものではなく Builder
を紐づけているのは、呼び出すたびに DB へのコネクションを作り直したいためです。
import java.io.Closeable import org.casbin.adapter.JDBCAdapter import org.casbin.jcasbin.persist.Adapter class CasbinAdapterBuilderImpl extends CasbinAdapterBuilder { override def create: Adapter with Closeable = { // ここの driver, url, username, password は DI を介して `.conf` ファイルから読み込んだものを渡しています new JDBCAdapter(driver, url, username, password) with Closeable } }
最後に、 AuthorityService
を実装していきます。
Adapter
と同様に Enforcer
にも java.io.Closeable
を Mixin したら、 Using
の中で処理を実行できるようにします。また、 def apply[A](func: Enforcer => A): A
を定義することによって、 UsingEnforcer { enforcer => /* 処理 */ }
と書くだけで Enforcer
を利用することができるようにしました。
import java.io.Closeable import javax.inject.Inject import scala.jdk.CollectionConverters.ListHasAsScala import scala.util.{Failure, Success, Using} import org.casbin.jcasbin.main.Enforcer import org.casbin.jcasbin.model.Model class AdminAuthorityServiceImpl @Inject() ( casbinAdapterBuilder: CasbinAdapterBuilder ) extends AdminAuthorityService { private object UsingEnforcer { def apply[A](func: Enforcer => A): A = { Using(createEnforcer)(func) match { case Success(value) => value case Failure(exception) => throw exception } } } private def createEnforcer: Enforcer with Closeable = { val enforcer = new Enforcer( CasbinModel.model, casbinAdapterBuilder.create ) with Closeable { override def close(): Unit = { this.getAdapter.asInstanceOf[Closeable].close() } } enforcer.loadPolicy() enforcer } /* * ユーザーが個別で権限を持っているか判定する。 */ override def hasPermission( name: String, permission: Permission ): Boolean = { UsingEnforcer { enforcer => enforcer .getPermissionsForUser(name) .asScala .exists( e => e.size() >= 4 && e.get(1) == permission.feature.id && e.get(2) == permission.operation.id && e.get(3) == if(permission.allow) “allow” else “deny” ) } } // 各種ラップメソッド }
最後に Scala Dependency Injection に沿って紐付けを設定していきます。
import com.google.inject.AbstractModule class Module extends AbstractModule { override def configure(): Unit = { bind(classOf[CasbinAdapterBuilder]).to(classOf[CasbinAdapterBuilderImpl]) bind(classOf[AuthorityService]).to(classOf[AuthorityServiceImpl]) } }
あとは @Inject
で AuthorityService
のインスタンスを呼び出せば各種制御メソッドを利用できるようになります。
発生した問題
これで、ローカルでは Casbin を利用した権限管理が使用できるようになりました。しかし、本番環境にデプロイしようと思ったところで一つ問題が発生しました。アプリケーションサーバーからの本番のDBへの CREATE 文実行が許可されていないということでエラーになってしまったのです。JDBCAdapter for jCasbin は DB へポリシーを保存する際テーブルを自動で作成する機能があるのですが、こちらが CREATE 文を実行してしまうのでエラーになっていた、というわけです。
そこで、 org.casbin.adapter.JDBCBaseAdapter#migrate
を参考に、以下のようなテーブルを事前に作成することにしました。
CREATE TABLE `casbin_rule` ( `id` int NOT NULL AUTO_INCREMENT, `ptype` varchar(100) NOT NULL, `v0` varchar(100) DEFAULT NULL, `v1` varchar(100) DEFAULT NULL, `v2` varchar(100) DEFAULT NULL, `v3` varchar(100) DEFAULT NULL, `v4` varchar(100) DEFAULT NULL, `v5` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
さらに、 JDBCAdapter for jCasbin は、テーブルの存在確認を CREATE 文に IF NOT EXISTS ${テーブル名}
を付与することで行っていたのですが、 (DB が MySQL の場合のみ) SHOW TABLES LIKE
を利用した確認を事前に行うように実装し、PRがマージされました。
おわりに
Casbin を利用することで、アクセス制御の実装にかかるコストを非常に軽減することができました。Java のライブラリを Scala で利用するにあたりいくつかのアクシデントはあったものの、実装も簡単におこなえたのでみなさんもぜひ利用してみてください!