3ヶ月間悩み続けた権限管理の問題
5人の開発者でWebアプリを開発しており、システムはNode.jsで書かれた約8つのマイクロサービスで構成されていました。最初は権限管理がシンプルでした:管理者はすべてを操作でき、一般ユーザーはそうでない。RBAC(ロールベースアクセス制御)にJWTを組み合わせればそれで十分でした。
ところがビジネスロジックが複雑化してきました。顧客からの要件:「マネージャーは自分の部門のレポートのみ閲覧可能で、他部門は不可。ただしリージョナルマネージャーであれば、担当エリア内のすべての部門を閲覧できる。ただしaudit中の部門XはAuditorのみアクセス可能。」
そのロジックを全部JWTミドルウェアに押し込もうとしました。2週間後、auth.middleware.jsは600行に膨れ上がり、ネストしたif-elseだらけになり、誰も修正を怖がるようになりました。新しい要件が追加されるたびにチーム全員が「どこを変えたら何が壊れるか分からない」と頭を抱える状態でした。
RBACではなぜ不十分なのか
RBACはロール(役割)に基づいて権限を付与します。問題は、現実のアクセス制御はロールだけに依存しないということです — 属性(attributes)にも依存します:
- ユーザーの属性:部門、リージョン、レベル、契約状態…
- リソースの属性:状態、オーナー、分類、作成日…
- 環境の属性:アクセス時間、IP、デバイス…
このように多くの属性を組み合わせた権限ロジックが必要になると、RBACでは数十もの新しいロールを作成しなければなりません(regional-manager-excluding-audit…命名するだけでも一苦労です)。あるいはすべてコードに詰め込むか — それはメンテナンス地獄への一本道です。
ABAC(属性ベースアクセス制御)はまさにこの問題を解決します:権限は硬直したロールではなく、属性の組み合わせに基づいたポリシー(policy)によって定義されます。
試した3つのアプローチ
1. アプリ内でABACエンジンを自作する
policy-engine.jsというモジュールを作り、ポリシーをJSONまたはJSオブジェクトで定義し、ランタイムで評価する方法です。素早く実装できますが、ポリシーがコード内に埋め込まれているため、ポリシーを変更するたびにアプリの再デプロイが必要です。マイクロサービスでは各サービスがそれぞれのポリシーを持つことになり、同期が取れなくなります。
2. Casbinを使う
Casbinは強力な権限管理ライブラリでABACをサポートしています。ポリシーは.confと.csvファイルに保存されます。前者よりは改善されていますが、複雑な条件が多数ネストされたポリシーが必要になると、Casbinの構文は読みにくくなり、デバッグも非常に辛くなります。
3. Open Policy Agent(OPA)— 現在採用している方法
OPAは独立したポリシーエンジンで、サイドカーまたは独立したサービスとして動作します。ポリシーはRego言語で記述され、宣言的でテストしやすく、監査も容易です。最も重要な点:ポリシーはアプリケーションコードから完全に分離されています。ポリシーの変更にサービスの再デプロイは不要です。
6ヶ月間本番環境で運用した結果、OPAを選んで後悔はありません。
OPAとNode.jsでの実際の実装
ステップ1:DockerでOPAを起動する
OPAはHTTPサービスとして動作し、マイクロサービスはAPIを呼び出して権限確認を行います:
# docker-compose.yml
services:
opa:
image: openpolicyagent/opa:latest
ports:
- "8181:8181"
command:
- "run"
- "--server"
- "--log-format=json"
- "/policies"
volumes:
- ./policies:/policies
ステップ2:Regoでポリシーを書く
policies/reports.regoファイルを作成します — レポートサービス用のポリシーです:
package reports
import future.keywords.if
import future.keywords.in
# デフォルトは拒否
default allow := false
# AuditorはAudit中のすべてのレポートを閲覧可能
allow if {
input.user.role == "auditor"
input.resource.status == "under_audit"
input.action == "read"
}
# リージョナルマネージャーは自分のエリアのレポートを閲覧可能
allow if {
input.user.role == "manager"
input.user.level == "regional"
input.action == "read"
input.resource.region == input.user.region
input.resource.status != "under_audit"
}
# 部門マネージャーは自部門のレポートのみ閲覧可能
allow if {
input.user.role == "manager"
input.user.level == "department"
input.action == "read"
input.resource.department_id == input.user.department_id
input.resource.status != "under_audit"
}
ポリシーは非技術系のステークホルダーでも読んで理解できるほど明確です — これがRegoの最大の魅力です。
ステップ3:Node.jsミドルウェアへの統合
OPA APIを呼び出すヘルパーを作成します:
// lib/opa-client.js
const axios = require('axios');
const OPA_URL = process.env.OPA_URL || 'http://opa:8181';
async function checkPermission(policyPath, input) {
try {
const res = await axios.post(
`${OPA_URL}/v1/data/${policyPath}`,
{ input },
{ timeout: 500 } // OPAが落ちた場合は即座に失敗させる
);
return res.data.result?.allow === true;
} catch (err) {
console.error('[OPA] Policy check failed:', err.message);
return false; // フェイルクローズ:OPAが応答しない場合は拒否する
}
}
module.exports = { checkPermission };
次にExpressミドルウェアで使用します:
// middleware/authorize.js
const { checkPermission } = require('../lib/opa-client');
function authorize(policyPath, getResource) {
return async (req, res, next) => {
const user = req.user; // 検証済みJWTから取得
const resource = await getResource(req);
const allowed = await checkPermission(policyPath, {
user: {
id: user.id,
role: user.role,
level: user.level,
department_id: user.department_id,
region: user.region,
},
resource,
action: req.method === 'GET' ? 'read' : 'write',
});
if (!allowed) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { authorize };
ルートで使用します:
// routes/reports.js
const { authorize } = require('../middleware/authorize');
const Report = require('../models/report');
router.get(
'/reports/:id',
authenticate, // JWTを検証
authorize('reports/allow', async (req) => {
const report = await Report.findById(req.params.id);
return {
department_id: report.department_id,
region: report.region,
status: report.status,
};
}),
async (req, res) => {
const report = await Report.findById(req.params.id);
res.json(report);
}
);
ステップ4:デプロイ前にポリシーをテストする
OPAには組み込みのテストフレームワークがあります — これがCasbinより優先して使う理由です:
# policies/reports_test.rego
package reports
test_auditor_can_read_audit_report if {
allow with input as {
"user": {"role": "auditor"},
"resource": {"status": "under_audit"},
"action": "read"
}
}
test_dept_manager_cannot_read_other_dept if {
not allow with input as {
"user": {"role": "manager", "level": "department", "department_id": "D1"},
"resource": {"department_id": "D2", "status": "active"},
"action": "read"
}
}
# テストを実行
docker run --rm -v $(pwd)/policies:/policies \
openpolicyagent/opa:latest test /policies -v
ポリシーを変更するたびに、プッシュ前にテストスイートを実行します。CI/CDへの統合は15分もあれば完了します。
運用時の注意事項
レイテンシ:リクエストごとにOPAへのHTTP呼び出しが1回追加されます(同じネットワーク内であれば約1〜5ms)。速度が重要なリクエストには、OPAバンドルキャッシングを使用するか、プロセス内でライブラリとしてOPAを実行してください(Go、Python、Wasm向けのSDKが利用可能です)。
OPAは単一障害点になりえます:OPAのレプリカを最低2つ起動し、ヘルスチェックを設定し、ミドルウェアはfail-open(許可)ではなくfail-closed(OPAが応答しない場合は拒否)にする必要があります。
監査ログ:OPAはデシジョンログをサポートしています — すべてのallow/deny決定がinputとともにログに記録されます。コンプライアンスとデバッグにとって非常に価値ある機能です。
5人チームの最近のWebアプリプロジェクトでこの手法を採用した結果、生産性が明らかに向上しました。具体的には、ビジネスが権限ロジックの変更を要求したとき、以前のように600行のミドルウェアコードを読む必要はなくなり、.regoファイルを修正してテストを実行し、プッシュするだけで済みます。各サービスの内部実装を理解する必要はありません。ポリシーを書く人とフィーチャーを実装する人が完全に独立して作業できます。
OPAを使うべき時、使わない方がいい時
OPAが適している場面:サービスが多数ある、ポリシーが複雑で頻繁に変更される、監査証跡が必要、チームの複数人がポリシーを管理している。
アプリに2〜3つのシンプルなロールしかなく、拡張計画もないのであれば、JWT claimsを使ったRBACで十分です — 過剰設計は避けましょう。OPAはスタックに依存関係を追加するものであり、その依存関係をシステムと一緒に運用、監視、スケールする必要があります。
しかしもし今、ネストしたif-elseだらけの600行のミドルウェアファイルをデバッグしているのであれば…おそらく見直しのタイミングが来ています。
