Claude Media
Claude CodeでOPAのRego認可ポリシーを書く — opa testで境界ケースを固める

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つ、加えて「ユーザーと同じテナントのリソースだけ」という制約を付けます。

ロールreadwritedelete
viewerread可write不可delete不可
editorread可write可delete不可
adminread可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のテーブル駆動テストと同じ形です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. openpolicyagent.org/docs/latest/policy-testing
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn