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 に設定します。
- OIDC トークンの検証
- DB からユーザー情報とロールを取得
-
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