TL;DR
- GitHubの権限をTerraform化して「このリポジトリはボードと本人だけアクセス可能」とコードに書いていました。それは付与方針にすぎませんでした
- 使っていたリソースが非authoritative(1関係ずつを管理する型)だったため、手で足されたユーザーやチームはplanに出ず、applyでも消えません
- 宣言を人間は「許可集合 = {A, B}」と読みたくなります。実際の意味は「AとBが存在することを保証する。Cが存在しないことは保証しない」でした
- アクセスを絞るリポジトリだけauthoritativeなリソース(複数形)へ移しました。宣言に無い付与はplanに削除として出るようになります
- ただし repo単位の付与しか強制できません。org全体の既定権限、org owner、organization role、そしてGitHub Appのinstallationは別経路です。最終的に6経路の列挙で確認しています
- 移行は
importブロックとremovedブロックで宣言だけで完結させました。lifecycle { destroy = false }を忘れると実際の権限が消えます
観測日: 2026-08-20 〜 2026-08-21
はじめに
連載の4本目です。1本目に書いた工程のうち、「planに出ないものは人間にも見えない」という限界の話です。
Identity as Codeをやっていると、必ずこの壁に当たると思います。宣言的に書いたものが、実は排他ではない。
Declarative ≠ Authoritative
(宣言的であることは、排他的であることを意味しない)
Terraformのproviderのドキュメントには書いてあります。書いてあるのに読み違えます。理由は、人間が「宣言」という言葉から自然に受け取る意味と、リソースの実際の意味がずれているからです。
1. 何を作っていたか
メンバー個人用のprivateリポジトリがいくつかあります。個人の作業ノートを置く場所で、性質上、本人と経営ボード以外に見せない前提です。
Terraformのコードには、こう書いていました(実際のリソース名などは伏せています)。
resource "github_team_repository" "board_on_personal_repo" {
repository = github_repository.personal_repo.name
team_id = github_team.board.id
permission = "admin"
}
resource "github_repository_collaborator" "owner_on_personal_repo" {
repository = github_repository.personal_repo.name
username = "<本人>"
permission = "push"
}
READMEにも「アクセスはボードと本人のみ」と書いていました。
そして、これは間違っていました。正確には、書いてあることは正しいが、読み方が間違っていた のです。
2. 人間が読む意味と、リソースの意味
上のコードを人間が読むと、こう受け取ります。
許可集合 = { ボード, 本人 }
実際の意味はこうです。
ボードがadminを持つことを保証する。
本人がwriteを持つことを保証する。
それ以外の付与が存在しないことは、何も保証しない。
github_team_repository と github_repository_collaborator(どちらも単数形)は、1つの関係だけを管理するリソース です。Terraformが管理しているのは「この team とこの repo の間の関係」1件であって、「この repo の権限の全体」ではありません。
なので、誰かが手でcollaboratorを1人足すと、こうなります。
- planには何も出ません。Terraformの管理下にない関係なので、差分として認識されません
- applyしても消えません
- 再planもクリーンなまま
No changesです
つまり1本目に書いた工程のうち、planの確認・再planのno-op確認が、両方とも通ります。工程を全部通っても検出できません。
これがこの記事で一番書きたいことです。
「書いていないものは禁止される」とは限らない。
宣言的な管理をしていると、コードが仕様書に見えてきます。コードを読めば現状が分かる、という感覚になる。しかし非authoritativeなリソースで書かれた宣言は、仕様書ではなく発注書です。「これを付けてください」と書いてあるだけで、「他は付けないでください」とは言っていません。
3. 非authoritativeを選んだのは、それ自体は正しかった
弁護もしておきます。非authoritativeを選んだこと自体は、当時の判断としては妥当でした。
もともとGitHubのメンバーシップ(org参加・チーム所属)はTerraform管理外にしていて、手作業で追加していました。そこからIaC管理下に移す段階で、非authoritativeなリソースを選びました。
理由は段階移行ができるからです。
- 非authoritativeなら、コードに書いていない既存の手動メンバーに影響しない
- 1人ずつコードへ取り込める
- 混在運用ができる
もしいきなりauthoritativeなリソース(org全体の集合を宣言する型)を入れたら、コードに書き漏らした人が全員org から追い出されます。数十人の権限を一度に正確に書き起こすのは現実的ではありません。
なので、メンバーシップの管理としては非authoritativeが正解でした。
問題は、そのリソースで「アクセスを絞る」を表現しようとしたことです。用途が違いました。
| やりたいこと | 適切なリソース |
|---|---|
| この人をこのチームに入れる(他に影響しない) | 非authoritative(単数形) |
| このリポジトリに触れるのはこの集合だけ(排他) | authoritative(複数形) |
4. authoritativeへ移した
アクセスを絞る必要があるリポジトリ(個人ノート4件+ボード用1件の計5件)だけ、github_repository_collaborators(複数形)へ寄せました。
複数形は、そのリポジトリのcollaboratorとteamの完全集合を1リソースで宣言します。宣言に無い付与はplanに削除として出て、applyで消えます。
resource "github_repository_collaborators" "personal_repo" {
repository = github_repository.personal_repo.name
team {
team_id = github_team.board.slug
permission = "admin"
}
user {
username = "<本人>"
permission = "push"
}
}
単数形と複数形が1文字違いなのが罠です。collaborator と collaborators。意味は「1関係」と「全集合」で、まったく違います。
同じリポジトリに単数形と複数形を併存させてはいけません。 互いに上書きし合います。移行対象のリポジトリは付与をすべて複数形に寄せて、単数形を書き戻さないようコードにコメントを残しました。
移行の作り
やりたいのはstateの付け替えだけで、実際の権限は変えたくありません。宣言だけで完結させました。
-
importブロックで複数形をstateに取り込む(5件) -
removedブロック +lifecycle { destroy = false }で単数形をstateから外す(9件)
destroy = false が要点です。
これが無いと、Terraformは単数形リソースをdestroyします。stateから外すだけのつもりが、実際の付与(ボードのadmin、本人のwrite)が消えます。
removed ブロックは「stateから取り除く」宣言ですが、既定では実リソースの削除を伴います。destroy = false を書いて初めて「stateからだけ外す」になります。
移行前に、実際の付与が宣言と一致していることを確認しました。明示チームはボードだけ、direct collaboratorは本人だけ。ここが一致していないと、import後にplanが差分を出して、applyで実権限が動きます。
踏んだ罠: team_id はslugで渡す
最初 github_team.board.id(数値ID)で書きました。import後のplanが5件すべて「teamブロックの削除 + 追加」を出しました。
原因は、import後のstateがGitHubの返すslug(board-team のような文字列)を保持することでした。.slug に変えたら差分が消えました。
そして単数形の github_team_repository は数値IDを取ります。同じproviderの中で、単数形と複数形で流儀が違います。
これは仕様を読んで気づいたのではなく、planを取ったら差分が出たので調べて分かりました。1本目に書いた「planの意味を人間が確認する」が効いた例です。差分が出た時点で「stateの付け替えだけのはずなのに、なぜteamブロックが差し替わるのか」と止まれました。
5. それでも縛れないもの — 6経路の列挙
authoritativeにしても、強制できるのは repo単位の付与 だけです。次の経路はこのリソースの外にあります。
| 経路 | 内容 |
|---|---|
| org の base permission | organizationの全メンバーが全repoに対して持つ既定権限。ここが read 以上だと、repo側の宣言と無関係に全員が読める |
| org owner | ownerは全リポジトリに到達できる。repo側の付与とは独立 |
| organization role |
all_repo_admin のような組織ロール。teamやuserに割り当てると、repo側の宣言を越えて権限が及ぶ |
| GitHub App の installation | これが盲点でした |
最後のものについて書きます。
GitHub Appのinstallationは、collaborator APIの対象外です。したがってproviderも同期しません。authoritativeなリソースを入れても、planには一切現れません。
そして実際に確認したら、organizationに全リポジトリを対象とするAppが4件入っており、そのうち2件は contents:write を持っていました。つまり個人ノートのリポジトリにも書けます。
これは独立LLMレビュー(codex)の指摘で発覚しました。「authoritativeにしたから排他が強制される」とREADMEに書こうとしたところで、「Appは対象外では」と指摘が入りました。
「これで縛れた」と書こうとした瞬間が、一番危ないタイミングでした。
結果、「ボードと本人のみ」の確認は、コードを読むことではなく6経路の列挙で行う手順にしました。コードのコメントに手順を残しています。
- orgのbase permissionが
noneであること - org ownerの一覧(ownerは全repoに到達できる。全員がボードか)
- organization roleのteam・user割当(想定外のロールが空か)
- repoのteamとdirect collaboratorの全列挙(このリソースが宣言と一致させる範囲)
- 非ボードアカウントの実効権限が
noneであること - GitHub App installationの対象repoとrepository permissions(このリソースの管理外なので、宣言では止まらない)
5番目が重要です。1〜4は「設定の列挙」ですが、5番目は「実効権限の問い合わせ」です。GitHubは特定ユーザーの特定リポジトリに対する実効権限を返すAPIを持っているので、経路を全部辿るのではなく結果を直接聞けます。
これは1本目に書いた「実環境で確認する」そのものです。設定の列挙は経路の網羅性に依存しますが、実効権限の問い合わせは結果を見ます。 網羅性の証明が要らないぶん強い証拠です。
6. Test planに「わざと壊す」を入れた
authoritativeになったことを、どうやって確認するか。
PRのTest planに、こう書きました。
試しに手で collaborator を1人足し、
terraform plan が削除として検出することを確認する。
(確認後は plan どおり apply して戻す)
これは連載2本目の「security controlそのものを試験する」と同じ発想です。関門を入れたら、実際に止まるかを見ます。
「authoritativeなリソースに変えた」は設定の話です。「宣言に無い付与が検出される」は挙動の話です。 前者から後者は自動的には出てきません。実際に足して、検出されるところまで見て、初めて言えます。
前の記事で書いたとおり、ルールを足した直後に実は検出できていなかったという経験があるので、ここは省略しない工程にしました。
7. この移行のplanが、3本目の実例になった
余談ですが、このPRのplanはこうなりました。
Plan: 5 to import, 1 to add, 16 to change, 1 to destroy.
自分の変更は 5 to import だけです。残りは、mergeされているがapplyされていない別の2本のPRに由来していました。片方はリポジトリの改名で、URLが変わる不可逆に近い操作です。
素でapplyすると、それも一緒に実行されます。
なのでこのPRはその場ではapplyせず、先行の2本の状況を解消してからplanを取り直す方針にしました。詳しくは3本目に書いたとおりです。
権限を絞る作業をしているときに、無関係なリポジトリ改名を巻き込むのは最悪です。 planを1件ずつ見る習慣が、ここでも効きました。
まとめ
- Declarative ≠ Authoritative。 宣言的に書いたことは、排他を意味しない
- 非authoritativeなリソースの宣言は仕様書ではなく発注書である。「他は付けないで」とは言っていない
- 手で足された付与は、planに出ず、applyでも消えず、再planもクリーンに通る。工程を全部通っても検出できない
- 用途で選ぶ。段階移行したいなら非authoritative、排他を強制したいならauthoritative
- 単数形と複数形は1文字違いで意味が違う。同じリソースに併存させてはいけない
-
removedブロックにはlifecycle { destroy = false }を書く。忘れると実権限が消える - import後のstateはAPIが返す形式(slug)を保持する。単数形と複数形で流儀が違うことがある
- authoritativeにしても強制できるのはrepo単位の付与だけ。 org側の経路とGitHub Appは別
- 「これで縛れた」と書こうとする瞬間が一番危ない。そこに独立レビューを当てる
- 設定の列挙は経路の網羅性に依存する。実効権限の問い合わせは結果を見るので強い
- 関門を入れたら、わざと壊して検出されるかを確認する
Identity as Codeで一番怖いのは、コードが正しく、planがクリーンで、それでも想定外の人がアクセスできる という状態が成立することです。
そして、その状態はどの機械的なチェックにも引っかかりません。引っかからないので、気づく方法は「宣言の意味を疑う」しかありません。
私はTerraformをほぼ知りませんが、この件で学んだのは構文ではなく、リソースが何を保証すると言っているのかを読む ということでした。providerのドキュメントに authoritative と書いてあるかどうかは、権限管理においては構文よりずっと重い情報です。
この記事は 七夕研究所 Qiita Organization の記事です。開発と運用で実際に踏んだものを順次書いています。