Claude CodeでOPAのRego認可ポリシーを書く — opa testで境界ケースを固める
RBACの権限表をRegoに起こし、allowとdenyの境界ケースをopa testで固めます。Claude Codeに回させる指示、ルールを壊して検出力を測る手順、claude -pでCIに載せる権限設定まで。
RBACの権限表をOPA(Open Policy Agent)のRegoに起こす作業は、Claude Codeに任せやすい部類です。ただし任せ方を間違えると、通るだけで何も守らないテストが量産されます。要になるのは3点です。権限表そのものはClaudeに書き換えさせないこと、opa test の結果を完了条件にすること、テストが本当に効いているかをルールを壊して確かめることです。
この記事では、3ロールのRBAC表を題材に、Regoの認可ルールとテストを作り、claude -p でCIに載せるところまでを順に組みます。
権限表をRegoに起こす前に決めておくこと
Regoの認可ルールは、呼び出し側が渡す input の形に依存します。先にここを固定しないと、Claudeが毎回違う形の input を想定してルールを書き、テストと実装がずれます。
題材の権限表は次のとおりです。ロールは3つ、操作は read / write / delete の3つ、加えて「ユーザーと同じテナントのリソースだけ」という制約を付けます。
| ロール | read | write | delete |
|---|---|---|---|
| viewer | read可 | write不可 | delete不可 |
| editor | read可 | write可 | delete不可 |
| admin | read可 | write可 | delete可 |
input はリポジトリ内の docs/rbac.md に、次の形で書いておきます。
{
"user": { "roles": ["editor"], "tenant": "t1" },
"action": "write",
"resource": { "tenant": "t1" }
}この docs/rbac.md が仕様の正です。Claudeに読ませる対象であって、直させる対象ではありません。後の手順で、編集を禁止する設定を入れます。
ルールとテストの最小構成を作る
Regoのテストは、ルール名が test_ で始まる通常のRegoルールです。OPAの公式ドキュメントは、テストを _test を付けたパッケージに置くことを勧めています。必須ではありません。テストが未定義または true 以外を返すとFAIL、実行時エラーならERROR、todo_ で始まるテストはSKIPPEDと報告されます。
権限表をRegoにすると、たとえば次の形になります(例示です。構文はOPAの公式例に合わせています)。
package authz
default allow := false
permissions := {
"viewer": {"read"},
"editor": {"read", "write"},
"admin": {"read", "write", "delete"},
}
allow if {
some role in input.user.roles
input.action in permissions[role]
input.resource.tenant == input.user.tenant
}default allow := false を置く理由は、拒否を「未定義」でなく明示的な false にするためです。未定義のままでも not authz.allow のテストは通りますが、allow の値を比較するテストを書くときに扱いが揃います。
公式の入門例では、with input as {...} で input を差し替えて authz.allow を評価し、拒否側は not authz.allow with input as ... と書いています。ファイルは同じディレクトリに置き、そのディレクトリで opa test . -v を実行します。
Claude Codeに作らせて、opa testを回させる
Claudeへの指示は、完了条件をコマンドで書きます。「テストを書いて」だけでは、書いた時点で終わってしまいます。
claude -p "docs/rbac.md の権限表を authz/authz.rego に起こし、\
authz/authz_test.rego に allow と deny の境界ケースを書いて。\
最後に opa test authz -v --fail-on-empty を実行し、\
全件 PASS になるまで authz/ 配下だけを直して" \
--allowedTools "Read" "Edit(authz/**)" "Bash(opa test *)" "Bash(opa fmt *)"--allowedTools に並べた規則は、Claudeが確認なしに使える道具を決めます。Bash(opa test *) のように、* をサブコマンドの後ろに置くと、opa test で始まるコマンドだけが対象になります。opa run や opa eval は許可されません。Edit(authz/**) は authz/ 配下の編集だけを許可します。
さらに、権限表の書き換えを止めます。プロジェクトの .claude/settings.json に、deny規則を置きます。
{
"permissions": {
"deny": ["Edit(docs/rbac.md)"]
}
}deny規則は、allow規則より優先されます。広いdenyの内側にallowで例外を切り出すことはできません。仕様書を守る側は、この性質を使えます。
同じ内容を毎回プロンプトに書くのが重いなら、CLAUDE.md に短く置きます。ただし CLAUDE.md の記述はClaudeが守る可能性が高いだけで、強制ではありません。守らせたい線は、上のdeny規則のように設定側で止めます。
## 認可ポリシー(authz/)
- 仕様は docs/rbac.md。ここは編集しない
- ルール変更のたびに opa test authz -v --fail-on-empty を実行する
- テストを緩めて通さない。落ちたらルール側を直す境界ケースは表に並べて網羅する
認可のテストで漏れやすいのは、拒否側です。許可されるケースは1つ書けば動きますが、拒否は「何が足りないか」の数だけあります。
OPAのテストは、ルールの見出しにケース名の変数を付けると、1つのテストルールで複数のケースを回せます。結果は empty + filled: PASS のようにケース名つきで出ます。これをRBAC表に使います。
package authz_test
import data.authz
test_allow[note] if {
some note, tc in {
"viewer can read": {
"input": {"user": {"roles": ["viewer"], "tenant": "t1"}, "action": "read", "resource": {"tenant": "t1"}},
"want": true,
},
"viewer cannot write": {
"input": {"user": {"roles": ["viewer"], "tenant": "t1"}, "action": "write", "resource": {"tenant": "t1"}},
"want": false,
},
"editor cannot delete": {
"input": {"user": {"roles": ["editor"], "tenant": "t1"}, "action": "delete", "resource": {"tenant": "t1"}},
"want": false,
},
"admin can delete": {
"input": {"user": {"roles": ["admin"], "tenant": "t1"}, "action": "delete", "resource": {"tenant": "t1"}},
"want": true,
},
"admin of another tenant is denied": {
"input": {"user": {"roles": ["admin"], "tenant": "t2"}, "action": "delete", "resource": {"tenant": "t1"}},
"want": false,
},
"unknown role is denied": {
"input": {"user": {"roles": ["guest"], "tenant": "t1"}, "action": "read", "resource": {"tenant": "t1"}},
"want": false,
},
"no roles is denied": {
"input": {"user": {"roles": [], "tenant": "t1"}, "action": "read", "resource": {"tenant": "t1"}},
"want": false,
},
"missing action is denied": {
"input": {"user": {"roles": ["admin"], "tenant": "t1"}, "resource": {"tenant": "t1"}},
"want": false,
},
}
got := authz.allow with input as tc.input
got == tc.want
}ケース名を英語にしているのは、opa test の出力にそのまま出るためです。Claudeに「拒否側の境界を足して」と頼むときは、観点を渡すと外しにくくなります。
- 権限表の空白マス(viewerのwriteなど)を全部拒否ケースにする
- 別テナントのadminのように、ロールは足りていて条件だけ外れるケース
- 未知のロール、空のロール配列、
actionの欠落といった入力の欠け - 複数ロールを持つユーザーで、一方だけが許可される場合
最後の複数ロールは、上の表にまだ入っていません。Claudeが足りないケースを自分で見つけて足せるかどうかが、指示の良し悪しの見分けどころになります。
通ったテストを疑う: ルールを壊して失敗を見る
全件PASSは、テストがルールを守っている証拠にはなりません。テストが緩ければ、ルールを壊しても通ります。OPAの公式ドキュメントも、入門例で最初のルールを消して opa test がFAILを返すことを確かめています。この「壊して落ちるか」を、Claudeにやらせます。
claude -p "authz/authz.rego を1行ずつ壊して(テナント比較を消す、\
permissions の delete を editor に足す、など)、そのたびに \
opa test authz --fail-on-empty を実行して。落ちなかった変更があれば、\
それを落とすケースを authz_test.rego に足し、最後にルールを元に戻して" \
--allowedTools "Read" "Edit(authz/**)" "Bash(opa test *)"壊した変更で落ちなかったものが、テストの穴です。テナント比較を消しても通るなら、別テナントのケースが足りていません。
数字で見る手段もあります。opa test --coverage --format=json は、評価された行と評価されなかった行を返します。公式の例では、test_get_user_allowed を外すと、対応するルールの行が not_covered に入り、example.rego のカバレッジが85.7%になっています。評価されない行は、ルール本体が一度も真にならなかったか、式が一度も評価されなかったことを示します。
JSON出力の末尾にはファイルをまたいだ全体の coverage もあり、公式の例では92.3でした。Claudeにこの値を読ませ、not_covered の行に対応するケースを足させる使い方ができます。
テストが落ちた理由が読み取りにくいときは、opa test . --var-values が使えます。失敗した式に含まれる変数と参照の値が、式の下に並んで表示されます。公式の例では、"jane" に対して ["write"] が返っていたことが、そのまま読み取れます。Claudeに失敗ログを渡すときも、このオプションを付けたほうが原因に近い情報が届きます。
一部のテストだけ回す・外部データを差し替える
ルールが増えると、全件を毎回回すのは遅くなります。opa test は --run(-r)に正規表現を渡すと、名前が一致するテストだけを評価します。正規表現はre2の構文です。Claudeに1つのケースを直させるときは、opa test authz --run 'test_allow' のように範囲を絞らせると、ログが短くなります。最後は必ず絞り込みなしの全件実行で締めます。
まだ実装のない仕様を書き留めたいときは、テスト名を todo_ で始めます。実行はされず、SKIPPEDとして数えられます。権限表に行を足したが、ルールが追いついていない段階の目印になります。
ロールの一覧をOPAの外のデータ(data)から読む構成なら、テストでは with data.roles as {...} で差し替えます。with は input だけでなく、データの文書や関数も置き換えられます。差し替える関数は、元の関数と引数の数を揃える決まりです。Claudeにモックを書かせる場合は、本番のデータに依存せずに境界を試せるよう、小さな固定データを使うよう指示します。
claude -pでCIに載せる
claude -p は対話なしで実行する形です。成功すると終了コード0、実行が失敗すると0以外を返すので、CIの判定に使えます。ただし、Claudeに直させるジョブと、テストの合否を決めるジョブは分けます。
合否は opa test 自身の終了コードで決めます。Claudeの「直りました」という文面を信じない構成にするためです。
# 合否はopa testだけが決める(Claudeは関与しない)
opa test authz -v --fail-on-empty--fail-on-empty が重要です。テスト名を test_ と書き間違えると、公式の説明どおり opa test は実行0件でも成功します。このフラグを付けると、0件実行が失敗になります。CIでテストが実際に走っていることの保証になります。
ポリシー変更のPRで、落ちたときだけClaudeに修正を試みさせる場合は、権限を最小にします。
claude -p "opa test authz -v の失敗ログを読み、authz/ 配下を直して" \
--permission-mode dontAsk \
--allowedTools "Read" "Edit(authz/**)" "Bash(opa test *)"dontAsk は、承認が必要になる呼び出しを自動で拒否するモードです。許可リストに入れた道具は動きます。CIには誰も承認する人がいないので、この組み合わせが合います。claude -p はモードが何も指定されなければ手動承認のモードで始まりますが、承認する人がいない実行では、承認が必要な呼び出しは拒否されます。
つまずきやすい点
落とし穴
テストをルールに合わせて書き換えられる
opa test が全件PASSなら完了、という指示だけだと、Claudeが落ちたテストの期待値を書き換えて通すことがあります。仕様書をdenyで守り、「落ちたらルール側を直す」を指示に入れ、最後に仕様書との差分を人が見ます。
- 仕様書を編集禁止にしても、
Bashで書き換えられる経路は残ります。広いBash許可を出さず、Bash(opa test *)のようにサブコマンドで絞ります inputの形が仕様書とずれると、拒否側のテストが「たまたま通る」ことがあります。未定義になる入力は拒否と同じ結果になるためです。inputの形は仕様書に書いておきます- テストの結果を後続の処理で読むなら、
opa test --format=jsonが使えます。結果には各テストのfailやerrorの項目が入ります
認可ルールの変更は、テストが緩んでも気づきにくい種類の変更です。Claudeに書かせるときほど、仕様を人が持ち、合否を機械が決め、テストの検出力を壊して測るという分担が効きます。非対話実行の基本はclaude -pオプションの解説に、CIで落ちたテストを直させる構成はCIのテスト失敗を自動修正する手順にまとめています。表で入力を網羅する発想は、Goのテーブル駆動テストと同じ形です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。