entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Scala を利用した社内向けサービスの管理画面に Casbin を導入しました!

はじめましての方ははじめまして。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 をインスタンス化しています。 また、 JDBCAdapterclose() メソッドを実装しているので 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])
  }
}

あとは @InjectAuthorityService のインスタンスを呼び出せば各種制御メソッドを利用できるようになります。

発生した問題

これで、ローカルでは 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がマージされました。

github.com

おわりに

Casbin を利用することで、アクセス制御の実装にかかるコストを非常に軽減することができました。Java のライブラリを Scala で利用するにあたりいくつかのアクシデントはあったものの、実装も簡単におこなえたのでみなさんもぜひ利用してみてください!

icon
javakky
決済周りの改善を中心に働いている2021年入社エンジニア。その名の通りJavaが好きなことで有名(?)で、最近はScalaを使える部署へ入ったらしい。