🔐

CasbinポリシーをPostgreSQLで管理する - Go + Ent + Atlasの実装例

に公開

はじめに

Casbinは、Go、Java、Node.js、Python など多くの言語に対応したオープンソースの認可ライブラリです。RBAC(ロールベースアクセス制御)、ABAC(属性ベースアクセス制御)、ACL など、様々なアクセス制御モデルをサポートしています。

Casbin は強力な認可ライブラリですが、多くのチュートリアルでは CSV ファイルでポリシーを管理する例が中心です。しかし、実際のプロダクション環境では以下の課題に直面します。

  • チーム開発: ポリシー変更をコードレビューしたい
  • デプロイ: 環境ごとに一貫したポリシーを適用したい
  • 監査: いつ・誰が・どのポリシーを変更したか追跡したい
  • 動的更新: アプリケーション再起動なしでポリシーを変更したい

本記事では、これらの課題を解決するためのPostgreSQL 統合とマイグレーションによるポリシー管理を実装例とともに解説します。

技術スタック

技術 バージョン 用途
Go 1.24 言語
Echo v4 Web フレームワーク
Casbin v2.132.0 認可ライブラリ
Ent v0.14.4 ORM
ent-adapter v1.1.0 Casbin の DB アダプター
PostgreSQL 17.4 データベース
Atlas v0.31.1 マイグレーションツール

アーキテクチャ概要

全体像を把握するため、リクエストの流れを図示します。

1. Casbin モデルの設計

model.conf

Casbin の動作を定義するモデルファイルです。

# config/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 && regexMatch(r.obj, p.obj) && (p.act == "*" || r.act == p.act || matchMethod(p.act, r.act))

各セクションの解説

request_definition(リクエスト定義)

r = sub, obj, act
要素 意味 実際の値の例
sub Subject(主体) admin, operator, reader
obj Object(対象) /api/v1/members, /api/v1/products
act Action(操作) GET, POST, PUT, DELETE

policy_definition(ポリシー定義)

p = sub, obj, act, eft

eft(Effect)を追加することで、Allow/Deny 両方のルールを定義できます。

policy_effect(ポリシー効果)

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

AllowAndDeny 方式を採用しています。判定ロジックは以下の通りです。

アクセス許可 = allowルールが1つ以上マッチ AND denyルールが1つもマッチしない
なぜ AllowAndDeny 方式を選んだのか

この方式により、以下のような柔軟な制御が可能になります。

  • 「基本は全許可だが、特定パスだけ拒否」
  • 「操作者は読み書き可能だが、削除だけは不可」
  • 「外部連携ユーザーは特定エンドポイントのみアクセス可能」

allow のみの方式だと、許可ルールを全て列挙する必要があり、メンテナンスコストが高くなります。

matchers(マッチング条件)

m = r.sub == p.sub && regexMatch(r.obj, p.obj) && (p.act == "*" || r.act == p.act || matchMethod(p.act, r.act))
条件 説明
r.sub == p.sub ロールが一致
regexMatch(r.obj, p.obj) パスが正規表現でマッチ
p.act == "*" 全メソッド許可
r.act == p.act メソッドが一致
matchMethod(p.act, r.act) カスタム関数で複数メソッドをチェック

2. PostgreSQL 統合(ent-adapter)

なぜ DB ストレージが必要か

方式 メリット デメリット
CSV ファイル シンプル、起動が速い 動的更新不可、バージョン管理しにくい
PostgreSQL 動的更新可、監査可能、トランザクション対応 セットアップが必要

スキーマ定義(Ent)

// ent/schema/casbinrule.go

package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/dialect/entsql"
    "entgo.io/ent/schema"
    "entgo.io/ent/schema/field"
)

type CasbinRule struct {
    ent.Schema
}

func (CasbinRule) Fields() []ent.Field {
    return []ent.Field{
        field.String("Ptype").
            Default("").
            Comment("p=ポリシー、g=グループ"),
        field.String("V0").
            Default("").
            Comment("ロールコード"),
        field.String("V1").
            Default("").
            Comment("エンドポイントパターン"),
        field.String("V2").
            Default("").
            Comment("HTTPメソッド"),
        field.String("V3").
            Default("").
            Comment("Effect(allow or deny)"),
        field.String("V4").Default(""),
        field.String("V5").Default(""),
    }
}

func (CasbinRule) Edges() []ent.Edge {
    return nil
}

func (CasbinRule) Mixin() []ent.Mixin {
    return []ent.Mixin{
        TimeMixin{}, // created_at, updated_at を追加
    }
}

func (CasbinRule) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entsql.Annotation{Table: "casbin_rules"},
        entsql.WithComments(true),
        schema.Comment("Casbinアクセス制御ルール"),
    }
}

Enforcer 初期化

まず、設定ファイル(config.go)の例を示します。

// config/config.go

package config

import (
    "fmt"
    "log"
    "os"
    "strconv"

    "github.com/joho/godotenv"
)

type DatabaseConfig struct {
    Driver         string
    User           string
    Password       string
    Host           string
    Port           string
    Name           string
    ConnectTimeout int
}

type Config struct {
    Database       DatabaseConfig
    UserContextKey string
}

// cfgはグローバル変数またはパッケージ変数として定義
var cfg *Config

func InitConfig() error {
    env := getEnv("APP_ENV", "development")

    // .envファイルを読み込み
    if err := godotenv.Load(); err != nil {
        if env == "development" {
            log.Printf("Warning: failed to load .env file: %v. Continuing with environment variables.", err)
        }
    }

    connectTimeout, err := strconv.Atoi(getEnv("DB_CONNECT_TIMEOUT", "10"))
    if err != nil {
        return fmt.Errorf("invalid DB_CONNECT_TIMEOUT: %w", err)
    }

    // 必須設定値の検証
    dbPassword := getEnv("DB_PASSWORD", "")
    if dbPassword == "" && env == "production" {
        return fmt.Errorf("DB_PASSWORD is required in production environment")
    }

    cfg = &Config{
        Database: DatabaseConfig{
            Driver:         getEnv("DB_DRIVER", "postgres"),
            User:           getEnv("DB_USER", "postgres"),
            Password:       dbPassword,
            Host:           getEnv("DB_HOST", "localhost"),
            Port:           getEnv("DB_PORT", "5432"),
            Name:           getEnv("DB_NAME", "mydb"),
            ConnectTimeout: connectTimeout,
        },
        UserContextKey: getEnv("USER_CONTEXT_KEY", "user_context"),
    }

    return nil
}

// getEnv は環境変数を取得し、存在しない場合はデフォルト値を返す
func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

func GetConfig() *Config {
    return cfg
}

次に、infra/authorization.goの実装です。

// infra/authorization.go

package infra

import (
    "fmt"
    "net/http"
    "strings"

    "github.com/casbin/casbin/v2"
    entadapter "github.com/casbin/ent-adapter"
    "github.com/labstack/echo/v4/middleware"
    "github.com/rs/zerolog"

    "your-project/config" // configパッケージをインポート
    "your-project/models" // modelsパッケージをインポート(UserContext型用)
)

var cfg = config.GetConfig() // config.goから設定を取得

// buildDSN はPostgreSQL接続文字列を構築する
func buildDSN() string {
    return fmt.Sprintf(
        "user=%s password=%s host=%s port=%s dbname=%s sslmode=disable connect_timeout=%d",
        cfg.Database.User,
        cfg.Database.Password,
        cfg.Database.Host,
        cfg.Database.Port,
        cfg.Database.Name,
        cfg.Database.ConnectTimeout,
    )
}

// NewEnforcer はCasbin Enforcerを初期化する
func NewEnforcer(log *zerolog.Logger, confPath string) (*casbin.Enforcer, error) {
    // DB接続文字列を構築
    dsn := buildDSN()

    // ent-adapterを作成
    adapter, err := entadapter.NewAdapter(cfg.Database.Driver, dsn)
    if err != nil {
        return nil, fmt.Errorf("failed to create adapter: %w", err)
    }

    // Enforcerを作成
    e, err := casbin.NewEnforcer(confPath, adapter)
    if err != nil {
        return nil, fmt.Errorf("failed to create enforcer: %w", err)
    }

    // カスタム関数を登録
    e.AddFunction("matchMethod", matchMethodFunc)

    // ポリシーをDBから読み込み
    if err := e.LoadPolicy(); err != nil {
        return nil, fmt.Errorf("failed to load policy: %w", err)
    }

    rules, _ := e.GetPolicy()
    log.Info().
        Int("policy_count", len(rules)).
        Msg("Enforcer initialized")

    return e, nil
}

// matchMethodFunc は複数メソッドをカンマ区切りで指定可能にする
// 例: "GET,POST,PUT" に対して "GET" がマッチするか判定
func matchMethodFunc(args ...interface{}) (interface{}, error) {
    if len(args) != 2 {
        return false, nil
    }

    allowedMethods, ok1 := args[0].(string) // ポリシー側: "GET,POST,PUT"
    requestMethod, ok2 := args[1].(string)  // リクエスト側: "GET"
    if !ok1 || !ok2 {
        return false, nil
    }

    for _, m := range strings.Split(allowedMethods, ",") {
        if strings.TrimSpace(m) == requestMethod {
            return true, nil
        }
    }
    return false, nil
}
カスタム関数 matchMethod の用途

通常、Casbin ではアクションの指定は単一メソッドか*(全許可)のみです。しかし、「GET, POST, PUT は許可するが DELETE は不可」といった要件では、3 つのポリシーを書く必要があります。

-- カスタム関数なしの場合(3行必要)
('p', 'operator', '^/api/v1/.*$', 'GET', 'allow'),
('p', 'operator', '^/api/v1/.*$', 'POST', 'allow'),
('p', 'operator', '^/api/v1/.*$', 'PUT', 'allow')

-- カスタム関数ありの場合(1行で済む)
('p', 'operator', '^/api/v1/.*$', 'GET,POST,PUT', 'allow')

ポリシー数が減り、管理が楽になります。

3. マイグレーションによるポリシー管理

なぜマイグレーションで管理するのか

メリット 説明
バージョン管理 Git でポリシー変更履歴を追跡
コードレビュー PR でポリシー変更をレビュー可能
環境一貫性 全環境で同じポリシーを適用
ロールバック 問題発生時に以前のポリシーに戻せる

マイグレーションファイル

-- ent/migrate/migrations/20250823039300_create_casbin_rules.sql

-- テーブル作成
CREATE TABLE "casbin_rules"
(
    "id"         bigint            NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "created_at" timestamptz       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updated_at" timestamptz       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "version"    bigint            NOT NULL DEFAULT 1,
    "ptype"      character varying NOT NULL DEFAULT '',
    "v0"         character varying NOT NULL DEFAULT '',
    "v1"         character varying NOT NULL DEFAULT '',
    "v2"         character varying NOT NULL DEFAULT '',
    "v3"         character varying NOT NULL DEFAULT '',
    "v4"         character varying NOT NULL DEFAULT '',
    "v5"         character varying NOT NULL DEFAULT '',
    PRIMARY KEY ("id")
);

-- 初期ポリシー投入
-- 外部API連携ロール: 特定エンドポイントのみ許可
INSERT INTO casbin_rules (ptype, v0, v1, v2, v3)
VALUES
  ('p', 'external_report_reader', '^/api/v1/reports$', 'GET', 'allow'),
  ('p', 'external_report_reader', '^/api/v1/reports/export$', 'GET', 'allow');

-- 管理者系ロール: 全エンドポイント許可
INSERT INTO casbin_rules (ptype, v0, v1, v2, v3)
VALUES
  ('p', 'admin', '^/api/v1/.*$', '*', 'allow'),
  ('p', 'owner', '^/api/v1/.*$', '*', 'allow'),
  ('p', 'operator', '^/api/v1/.*$', '*', 'allow'),
  ('p', 'reader', '^/api/v1/.*$', '*', 'allow');

ポリシー設計のポイント

正規表現パターン

パターン 説明 マッチ例
^/api/v1/.*$ 全 API パス /api/v1/products, /api/v1/reports/123
^/api/v1/reports$ 完全一致 /api/v1/reports のみ
^/api/v1/members/.*$ 配下全て /api/v1/members/123, /api/v1/members/123/roles
^/api/v1/products/[0-9]+$ ID 付きパス /api/v1/products/123

ロール別ポリシー設計例

-- 管理者: 全権限
('p', 'admin', '^/api/v1/.*$', '*', 'allow')

-- 閲覧者: GETのみ
('p', 'reader', '^/api/v1/.*$', 'GET', 'allow')

-- 操作者: 読み書きOK、削除NG(カスタム関数使用)
('p', 'operator', '^/api/v1/.*$', 'GET,POST,PUT,PATCH', 'allow')

-- 外部連携: 特定エンドポイントのみ
('p', 'external', '^/api/v1/reports$', 'GET', 'allow')
('p', 'external', '^/api/v1/reports/export$', 'GET', 'allow')

4. ACL ミドルウェアの実装

ミドルウェアチェーン

// bootstrap/server.go

func (s *Server) Setup(db *ent.Client) error {
    // Enforcer初期化
    enforcer, err := infra.NewEnforcer(s.log, "config/casbin/model.conf")
    if err != nil {
        return err
    }

    // ミドルウェアチェーン(順序が重要!)
    s.echo.Use(middleware.RequestID())                     // 1. リクエストID付与
    s.echo.Use(middleware.Recover())                       // 2. パニックリカバリ
    s.echo.Use(infra.Authentication(&authCfg, verifier))   // 3. 認証 → UserContext設定
    s.echo.Use(infra.Authorization(&authzCfg, enforcer))   // 4. 認可 → Casbinチェック

    return nil
}

ACL ミドルウェア

// infra/authorization.go

// AuthzConfig は認可ミドルウェアの設定
type AuthzConfig struct {
    Skipper middleware.Skipper
    Log     *zerolog.Logger
}

// VerifyAccess はEnforcerでアクセス権限をチェックする
func VerifyAccess(
    ctx echo.Context,
    e *casbin.Enforcer,
    role string,
    method string,
    path string,
) (bool, error) {
    // Enforce引数: sub(role), obj(path), act(method) の順序
    // model.confの r = sub, obj, act に対応
    ok, err := e.Enforce(role, path, method)
    if err != nil {
        ctx.Logger().Error("Enforce Error: ", err)
        return false, err
    }
    return ok, nil
}

func Authorization(authzCfg *AuthzConfig, e *casbin.Enforcer) echo.MiddlewareFunc {
    if authzCfg.Skipper == nil {
        authzCfg.Skipper = middleware.DefaultSkipper
    }
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(ctx echo.Context) error {
            // Skipper処理
            if authzCfg.Skipper(ctx) {
                return next(ctx)
            }

            // 認可スキップパスの判定
            if shouldSkipAuthz(ctx.Request().URL.Path) {
                return next(ctx)
            }

            // UserContextを取得
            // cfgは別ファイル(例: config.go)で定義されたグローバル変数またはパッケージ変数
            userCtxVal := ctx.Get(cfg.UserContextKey)
            if userCtxVal == nil {
                return ctx.NoContent(http.StatusUnauthorized) // 401
            }

            userCtx, ok := userCtxVal.(*models.UserContext)
            if !ok {
                return ctx.NoContent(http.StatusUnauthorized) // 401
            }

            // ロールコードが空の場合は401
            if userCtx.RoleCode == "" {
                authzCfg.Log.Warn().
                    Str("email", userCtx.Email).
                    Msg("Authorization: RoleCode is empty")
                return ctx.NoContent(http.StatusUnauthorized) // 401
            }

            // Enforcerで認可チェック
            permitted, err := VerifyAccess(
                ctx,
                e,
                userCtx.RoleCode,         // sub: ロール
                ctx.Request().Method,     // act: メソッド
                ctx.Request().URL.Path,   // obj: パス
            )

            // エラーまたは許可されない場合は403
            if err != nil || !permitted {
                return ctx.NoContent(http.StatusForbidden) // 403
            }

            return next(ctx)
        }
    }
}

// shouldSkipAuthz は認可をスキップするパスを判定(前方一致)
func shouldSkipAuthz(path string) bool {
    excludePaths := []string{
        "/assets",       // 静的ファイル
        "/api/v1/ping",  // ヘルスチェック
        "/api/v1/auth",  // 認証エンドポイント
        "/swagger",      // Swagger UI
    }

    for _, prefix := range excludePaths {
        if strings.HasPrefix(path, prefix) {
            return true
        }
    }
    return false
}

HTTP ステータスコードの使い分け

ステータス 状況 説明
401 Unauthorized 認証失敗 UserContext がない、ロールが空
403 Forbidden 認可失敗 認証済みだが権限がない

5. 認証との連携

UserContext の流れ

認証ミドルウェアの役割

認証ミドルウェアでは以下の処理を行い、UserContextを Context に設定します。

  1. OIDC トークンの検証
  2. DB からユーザー情報とロールを取得
  3. UserContext.RoleCodeにロールコードを設定
// models/user_context.go

package models

// UserContext は認証ミドルウェアで設定されるユーザー情報
// ACLミドルウェアで使用される
type UserContext struct {
    Subject   string
    ExpiresAt int
    Email     string
    RoleCode  string // ← ACLミドルウェアで使用
}

6. 運用 Tips

動的ポリシー更新

アプリケーション再起動なしでポリシーを更新する例です。

// ポリシー追加
func InsertRule(e *casbin.Enforcer, role, path, method, effect string) error {
    added, err := e.AddPolicy(role, path, method, effect)
    if err != nil {
        return err
    }
    if !added {
        return errors.New("rule already exists")
    }
    return nil
}

// ポリシー削除
func DeleteRule(e *casbin.Enforcer, role, path, method, effect string) error {
    removed, err := e.RemovePolicy(role, path, method, effect)
    if err != nil {
        return err
    }
    if !removed {
        return errors.New("rule not found")
    }
    return nil
}

// DBの変更を再読み込み
func RefreshRules(e *casbin.Enforcer) error {
    return e.LoadPolicy()
}

大量ポリシー時の注意点

ポリシー数が増加すると、LoadPolicy()による全件読み込みがパフォーマンスのボトルネックになります。数万件規模になると、API レスポンスが 2 秒以上かかるケースも報告されています。

対策: LoadFilteredPolicy の活用

必要なポリシーのみを読み込むLoadFilteredPolicyを使用します。

// 特定ロールのポリシーのみ読み込み
// 注意: ent-adapterのLoadFilteredPolicyの実装はバージョンによって異なる可能性があります
// 実際のAPIに合わせて調整してください
filter := &entadapter.Filter{
    Ptype: []string{"p"},
    V0:    []string{"admin", "operator"},
}
if err := e.LoadFilteredPolicy(filter); err != nil {
    return err
}

ロギング

認可失敗時のログ出力例です。問題調査に役立ちます。

if !permitted {
    log.Warn().
        Str("role", userCtx.RoleCode).
        Str("path", ctx.Request().URL.Path).
        Str("method", ctx.Request().Method).
        Str("email", userCtx.Email).
        Msg("Access denied by authorization")
}

出力例:

{
  "level": "warn",
  "role": "reader",
  "path": "/api/v1/members/123",
  "method": "DELETE",
  "email": "user@example.com",
  "message": "Access denied by authorization"
}

まとめ

本記事では、Casbin の実運用における以下のポイントを解説しました。

項目 従来のアプローチ 本記事のアプローチ
ポリシーストレージ CSV ファイル PostgreSQL (ent-adapter)
ポリシー管理 手動編集 マイグレーション(Git 管理)
認証連携 なし/別実装 OIDC と統合したミドルウェアチェーン
Allow/Deny allow のみ AllowAndDeny 方式
メソッド指定 単一 カスタム関数で複数対応

Casbin は柔軟な認可ライブラリですが、プロダクション運用ではDB 統合とマイグレーション管理が重要になります。

本記事の実装が、皆さんのプロジェクトの参考になれば幸いです。

参考リンク

Discussion