Google Cloud の組織ポリシーを dry-run から enforce へ段階的に昇格させた進め方と落とし穴
セキュリティ対策の一環として、Google Cloud の組織ポリシー(Organization Policy)を組織全体に入れていきました。dry-run(監査モード)に対応した制約はまず dry-run で入れて違反を観測し、問題がないことを確かめてから enforce に昇格させています。dry-run に対応していない制約は、棚卸しと例外の敷設を先に済ませてから直接 enforce しました。
この記事では、その進め方と、拒否を Slack に通知する仕組み、実際に踏んだ落とし穴(gcp.resourceLocations を enforce したら SCC Standard が自動で非アクティブ化された件、UBLA を enforce する前に自動作成バケットを処理した件)をまとめます。
※ 本記事は 2026年11月10日時点の内容です。
結論
| 項目 | 内容 |
|---|---|
| dry-run できる制約 | custom constraint、managed constraint(*.managed.*)、一部の legacy constraint のみ |
| dry-run できる制約の進め方 | live spec を enforce = FALSE、dry_run_spec を enforce = TRUE で入れる → 違反を観測 → 例外を先に敷設 → dry_run_spec を spec に置き換えて昇格 |
| dry-run できない制約の進め方 | Cloud Asset Inventory(CAI)で組織横断の棚卸し → 必要なプロジェクトに上書きを先に作る → 直接 enforce |
| 昇格の判断材料 | dry-run 違反ログ、監査ログアーカイブの全数スキャン、CAI 棚卸し、陽性対照(わざと違反させるプローブ) |
| 拒否の通知 | 制約の種類によって拒否ログの構造が違う。3 種類のフィルタで拾い、関数側で制約名を取り出して通知する |
| 落とし穴 1 | gcp.resourceLocations を enforce すると、自動アクティベートされた SCC Standard が非アクティブ化されることがある |
| 落とし穴 2 | storage.uniformBucketLevelAccess を enforce する前に、App Engine / Cloud Build / Vertex AI が自動作成した UBLA 無効バケットを処理しておく |
対象にした制約
Google の secure-by-default ベースライン(2024年5月3日以降に作成された組織には既定で enforce される)と照らし合わせて、古い組織では未適用だったものを中心に選びました。
| 制約 | 種類 | dry-run | 導入方式 |
|---|---|---|---|
iam.managed.disableServiceAccountKeyCreation(SA キー作成禁止) | managed | 可 | dry-run → enforce |
iam.managed.disableServiceAccountKeyUpload(SA キーのアップロード禁止) | managed | 可 | dry-run → enforce |
iam.managed.preventPrivilegedBasicRolesForDefaultServiceAccounts(デフォルト SA への基本ロール付与禁止) | managed | 可 | dry-run → enforce |
compute.managed.restrictProtocolForwardingCreationForTypes(プロトコル転送の制限) | managed(パラメータ付き) | 可 | dry-run → enforce |
run.managed.requireInvokerIam(Cloud Run の invoker IAM チェック無効化の禁止) | managed | 可 | dry-run → enforce |
| ドメイン制限共有(custom constraint) | custom | 可 | dry-run → enforce |
essentialcontacts.managed.allowedContactDomains(Essential Contacts の宛先ドメイン制限) | managed(パラメータ付き) | 可 | 棚卸しで対象 0 件のため直接 enforce |
gcp.resourceLocations(国内 + global のみ) | legacy list | 不可 | 棚卸し → 例外 → 直接 enforce |
run.allowedIngress(ingress=all の禁止) | legacy list | 不可 | 棚卸し → 直接 enforce |
storage.uniformBucketLevelAccess(UBLA の強制) | legacy boolean | 不可 | 自動作成バケットを処理 → 直接 enforce |
dry-run に対応する制約は、Test organization policies の Limitations に、custom constraint、managed constraint、および一部の legacy managed constraint(Restrict service usage / Restrict endpoint usage / Restrict TLS versions / Restrict TLS cipher suites)だけだと明記されています。それ以外の制約で dry-run ポリシーを作ろうとするとエラーになります。gcp.resourceLocations や storage.uniformBucketLevelAccess のような legacy constraint は dry-run できないので、別の進め方が必要でした。
なお、ドメイン制限共有の既製の制約 iam.allowedPolicyMemberDomains は legacy list constraint で dry-run できません(Restricting identities by domain)。managed 版の iam.managed.allowedPolicyMembers なら dry-run できますが、今回は dry-run できる custom constraint で書き直して入れています。
dry-run できる制約: 監査モード → 観測 → 昇格
監査モードで入れる
live の spec は enforce = "FALSE"(何もしない)にして、dry_run_spec 側だけ enforce = "TRUE" にします。
1 | resource "google_org_policy_policy" "disable_sa_key_upload" { |
dry_run_spec だけを書いて spec を省略したところ、API が DryRun feature is not available for the resource で拒否しました。そのため live spec を no-op で併記しています(この挙動の公式な記載は確認できていません)。
パラメータを持つ managed constraint は、values ではなく parameters で値を渡します。プロトコル転送を INTERNAL だけに制限する例です。
1 | resource "google_org_policy_policy" "restrict_protocol_forwarding" { |
Essential Contacts の宛先ドメイン制限も同じ書き方です(allowedDomains = ["@example.com"])。こちらは CAI で Contact が組織全体で 0 件だったため、監査モードを経ずに直接 enforce しました。
ドメイン制限共有の custom constraint は、公式ドキュメントのユースケース例「Only allow organization members to be granted roles」とほぼ同じ CEL です。
1 | resource "google_org_policy_custom_constraint" "domain_restricted_sharing" { |
組織の principal set には Workspace の ID、組織の Workforce Identity Pool、組織内の全 SA と Workload Identity Pool、組織内リソースのサービスエージェントが含まれる旨が公式ドキュメントに書かれているので、WIF やサービスエージェントへの付与は拒否されません。ただし、custom constraint の name は後から変えられない(変更すると作り直しになる)ので、最初に付ける名前は慎重に決めたほうがよいです。導入初期は公開エンドポイントのために allUsers / allAuthenticatedUsers を許可する条件を入れていて、名前にもそれが表れていました。公開エンドポイントを解消したうえで、enforce に昇格する変更と同時にこの条件を削除しており、今は名前だけがその名残として残っています。
観測する
dry-run 違反は Cloud Audit Logs の policy ログ(cloudaudit.googleapis.com%2Fpolicy)に、OrgPolicyDryRunAuditMetadata として dryRunResult: "DENIED" / liveResult: "ALLOWED" で記録されます(Test organization policies)。これを後述の通知関数で Slack に流し、制約によって約 1 週間〜約 3 ヶ月観測しました。
dry-run 違反の件数だけでは判断材料として弱いので、昇格前には次の 4 つを揃えました。
- dry-run 違反の通知: 監査期間中の違反がゼロ、または既知のもの(陽性対照など)だけであること
- 監査ログアーカイブの全数スキャン: 組織シンク(
include_children = true)で GCS に保存している activity ログを数ヶ月分さかのぼり、対象操作(CreateServiceAccountKey、UploadServiceAccountKey、allUsersを含むSetIamPolicyなど)の発生源を洗い出す。dry-run 導入より前の操作も見える - CAI の組織横断棚卸し: 既存リソースのうち、enforce 後の更新で引っかかりうるもの(
invokerIamDisabled=trueの Cloud Run、ForwardingRule など)を数える - 陽性対照: 検証用プロジェクトでわざと違反させ、ログに記録されて Slack まで通知が届くことを確認する。「違反ゼロ」が「検知できていない」ではないことの裏付けになる
陽性対照は意外なところでも役に立ちました。デフォルト SA への基本ロール付与禁止は、監査期間中に Compute Engine API を有効化したプロジェクトで、Google のサービスエージェントがデフォルト SA に editor を自動付与したことが dry-run 違反として通知されました。enforce 後は新しく API を有効化してもデフォルト SA に editor が付かなくなるので、その editor を前提にした手順がないかを確認する材料になりました。
例外を先に敷設してから昇格する
業務上どうしても違反する操作が必要なプロジェクトは、昇格の前にプロジェクト単位の上書きを作ります。例外側では live spec と dry_run_spec の両方を上書きしておくと、既知・許容済みの操作で dry-run 違反の通知が鳴り続けずに済みます(詳細は IAM Deny Policy の記事 に書きました)。
昇格は、dry_run_spec を消して spec 側を enforce = "TRUE" にするだけです。
1 | resource "google_org_policy_policy" "disable_sa_key_upload" { |
ここで紹介した制約はいずれも新規の作成・更新だけを評価し、既存のキーやバインディング、転送ルールには遡及しません。SA キー作成禁止を enforce しても、既存の USER_MANAGED キーは動き続けます。キーの扱い全般は SA キーの記事 にまとめています。
dry-run できない制約: 棚卸し → 例外 → 直接 enforce
gcp.resourceLocations は、CAI で組織全体のリソース(数万件)のロケーションを棚卸しし、asia-northeast1 以外に実リソースを持つプロジェクトを洗い出しました。そうしたプロジェクトには上書きを先に作ってから、組織レベルを enforce しています。
1 | locals { |
この制約が効くのは新規作成のリソースだけで、既存リソースはそのまま動き続けます(Restricting resource locations)。許可値の設計で気をつけたのは次の点です。
in:jp-locationsは東京と大阪の value group。国内 DR を見越して大阪も含めたglobalは必須。自動レプリケーションの Secret Manager シークレットは、globalが許可されていないと作成に失敗する(Services that support restricting resource locations)- BigQuery の US マルチリージョンや App Engine(
us-central、作成後にリージョンを変えられない)を使っているプロジェクトは、新規作成やオートスケールで引っかかりうるので上書きで許可する - 上書き側にも組織レベルの許可値を明示しておく。組織側と上書き側のどちらが先に反映されても、国内リージョンが拒否される期間ができない
run.allowedIngress(internal / internal-and-cloud-load-balancing のみ許可)も legacy list constraint で dry-run できません。CAI で ingress=all の Cloud Run が実質 0 件であることを確かめてから、例外なしで直接 enforce しました。storage.uniformBucketLevelAccess については落とし穴の節で書きます。
拒否を通知する仕組み
dry-run 違反だけでなく、enforce 後の拒否も Slack に通知しています。enforce 後に「誰かの作業が止まった」ことを、問い合わせが来る前に把握するためです。構成は、組織シンク → Pub/Sub → Cloud Run functions(Node.js)→ Slack です。
制約の種類によって拒否ログの構造が違う
実際に拒否させて採取したログを比べると、制約の種類によって構造がまったく違いました。
| 種類 | 記録先 | 拒否の手がかり | 制約名 |
|---|---|---|---|
| dry-run 違反 | policy ログ | protoPayload.metadata.dryRunResult="DENIED" | metadata.constraint |
| managed constraint の enforce 拒否 | activity ログ(元操作と同じ methodName) | status.code=9 と policyViolationInfo.orgPolicyViolationInfo.violationInfo[] | violationInfo[].constraint(constraints/...) |
| legacy constraint の enforce 拒否 | activity ログ | status.code=9 と status.message だけ(policyViolationInfo も status.details もない) | status.message 内の constraints/... |
| custom constraint の enforce 拒否 | activity ログ | status.code=9 と status.message。サービスによっては status.details[].reason="CUSTOM_ORG_POLICY_VIOLATION" | status.message 内の customConstraints/... |
legacy constraint の拒否は、たとえば US にバケットを作ろうとすると status.message に 'us' violates constraint 'constraints/gcp.resourceLocations' とだけ入っていました。最初は violationInfo の有無だけで拾っていたので、これを取りこぼしていました。
ViolationInfo のフィールド(constraint / checkedValue / errorMessage / policyType)は AuditLog のリファレンスで確認できました。一方、どの種類の制約でどのフィールドが埋まるかは公式ドキュメントで明確な記載を確認できず、上の表は自分の環境で採取したログに基づいています。記録先についても、Test organization policies の表では live ポリシーで拒否された場合にも Organization Policy の監査ログが生成されるとされていますが、dry_run_spec を外して enforce した後の SA キー作成の拒否は、自環境では元操作と同じ methodName の activity ログに記録され、policy ログには見当たりませんでした。
また、IAM の custom constraint で組織リソース自体の setIamPolicy が拒否された場合は監査ログが生成されない、と公式ドキュメントに書かれています。この経路の拒否はログベースの通知では拾えません。
シンクのフィルタ
3 系統をまとめて拾います。status.message 側は「code=9 かつ制約名を含む」に絞り、ほかの FAILED_PRECONDITION を拾わないようにしています。
1 | locals { |
custom constraint の制約名は customConstraints/ で始まりますが、Logging のクエリ言語は正規表現と論理演算子を除いて大文字小文字を区別しない(Logging query language)ため、"constraints/" のままで custom constraint の拒否も拾えました(gcloud logging read でも確認)。
関数側で分類する
困ったのは関数側です。JavaScript の文字列比較は大文字小文字を区別するので、constraints/ だけを探していると custom constraint の拒否で制約名が unknown になりました。さらに、プロジェクト IAM への付与が拒否されたログを 「強い権限が付与されました (SetIamPolicy)」と誤って通知していました。失敗した SetIamPolicy も、成功時と同じ methodName で activity ログに記録されるためです。
最終的には次のような分類にしました(実装を簡略化したものです)。
1 | const CONSTRAINT_NAME_PATTERN = /\b(?:customConstraints|constraints)\/[A-Za-z0-9._-]+/g; |
ポイントは次のとおりです。
- 制約名は
violationInfoとstatus.messageの両方から取り、customConstraints/も対象にする - Org Policy の拒否判定を、
SetIamPolicyやCreateServiceAccountKeyの「成功」通知より先に評価する status.codeが 0 以外のSetIamPolicyは「付与されました」ではなく「失敗しました」として通知する- 採取した実ログをそのままテストのフィクスチャにし、CI で
node --testを回す。修正前のコードでフィクスチャが fail することを確認してから直した
落とし穴 1: gcp.resourceLocations で SCC Standard が非アクティブ化された
gcp.resourceLocations を enforce してから約 1 週間後、Terraform の plan / apply が全部止まりました。Security Command Center の NotificationConfig を refresh したところ、SCC v2 API が組織全体で Security Command Center Legacy has been permanently disabled の 400 を返すようになっていたのが原因です。
これは仕様で、公式ドキュメントに、Standard tier が自動でアクティベートされていた組織で resource locations を制限する組織ポリシーを有効にすると、ポリシーのデプロイから 7 日以内に Security Command Center が自動で非アクティブ化されることがある、と明記されています。組織ポリシーより先にデータ所在地の設定を変えていた場合、自動アクティベート後かつポリシーのデプロイ前に Vulnerability Assessment for Google Cloud を有効にしていた場合、自動アクティベート後に Premium / Enterprise にアップグレードしていた場合は対象外とされています。
- 状態の確認:
gcloud scc notifications list --organization=ORG_ID --location=globalが上記の 400 を返せば非アクティブ化されています - 復旧: Console から手動で再アクティベートします。既存の findings は
globalに保存されているので、データ所在地はglobalのまま選ばないと既存の findings にアクセスできなくなります(公式ドキュメントに記載あり) - 手動で再アクティベートしたあとは、
gcp.resourceLocationsを enforce したままでも非アクティブ化は再発していません。ただ、手動アクティベートなら自動非アクティブ化の対象外になる、とは公式ドキュメントに明記されておらず(手動アクティベート時はデータ所在地の設定が resource locations ポリシーで制限されない、という記載はあります)、自分の環境での観測です
これから gcp.resourceLocations を入れるなら、enforce の前に SCC のアクティベート方式とデータ所在地の設定を確認しておくのがよいと思います。
落とし穴 2: UBLA を enforce する前に自動作成バケットを処理する
storage.uniformBucketLevelAccess を組織で enforce したのは、オブジェクト ACL 経由の外部共有を塞ぐためです。ドメイン制限共有の custom constraint は IAM の AllowPolicy しか評価しないので、Cloud Storage の ACL による共有は防げません(公式ドキュメントにも、ACL による既定の付与は対象外と書かれています)。
この制約が拒否するのは「UBLA を無効にした新規バケットの作成」と「既存バケットの UBLA 無効化」で、UBLA が無効の既存バケットはそのまま動きます(Organization policy constraints)。検証用プロジェクトでは次の結果になりました。
| 作成リクエスト | enforce 後の結果 |
|---|---|
UBLA を指定しない(gcloud builds submit が作る _cloudbuild バケットと同じ形) | 成功。UBLA は自動で有効になる |
UBLA の無効(iamConfiguration.uniformBucketLevelAccess.enabled: false)を明示する | HTTP 412 で拒否 |
既存バケットに影響はないとはいえ、UBLA 無効のまま残すと ACL の抜け道も残ります。Terraform 管理のバケットはすべて uniform_bucket_level_access = true でしたが、残っていたのは App Engine / Cloud Build / Vertex AI がサービス側で自動作成したバケット(<project>.appspot.com、staging.<project>.appspot.com、<project>_cloudbuild、cloud-ai-platform-*)でした。
これらは import ブロックで Terraform に取り込み、UBLA だけを有効化しました。
1 | import { |
- 既存の設定(location、storage class、ライフサイクル、CORS)は取り込んだときの値をそのまま書き、plan の差分が
uniform_bucket_level_accessのfalse -> trueだけになることを確認する - 事前に、バケットの ACL が
project-owners/editors/viewersだけで IAM(legacyBucketOwner/legacyBucketReader)と同等であること、Data Access ログで想定外のアクセスがないことを確認する。オブジェクト ACL(アップロードした個人やサービスエージェントへの OWNER)は効かなくなるため、自動作成元のサービスエージェントがプロジェクトの IAM で必要なロールを持っているかも見ておく - UBLA は有効化から 90 日以内なら無効に戻せる(Uniform bucket-level access)。90 日を過ぎると戻せない。また同じドキュメントでは、無効化の要件の 1 つにバケットが
storage.uniformBucketLevelAccess制約の対象でないことが挙げられているので、組織で enforce した後は、そのバケットを制約の対象から外さない限り 90 日以内でも戻せない - apply 後は
importブロックを削除する。担当プロダクトのリポジトリで管理を続けるならリソース定義は残し、管理したくないならremovedブロックで state から外す
1 | removed { |
ひとつハマったのが、課金が無効なプロジェクトのバケットです。plan(読み取り)は通るのに、apply で Error 403: The billing account for the owning project is disabled in state absent, accountDisabled になりました。import は state に入ったものの UBLA の更新だけが拒否され、そのままでは main の apply が毎回失敗するため、removed で state から外しました。自環境では、課金が無効なプロジェクトのバケットは UBLA の更新がこの 403 で拒否され、課金を有効にしない限り変更できない状態でした(Cloud Storage の公式ドキュメントでこの挙動の明記は確認できていません)。plan が通っても apply で失敗しうるので、事前に gcloud billing projects describe で課金の状態を確認しておくのがよいです。
まとめ
- dry-run できるのは custom constraint、managed constraint、一部の legacy constraint だけ。できない制約は CAI 棚卸しと例外の先行敷設で代える
- dry-run は「live spec を no-op、
dry_run_specを enforce」で入れ、違反ログ・監査ログの全数スキャン・CAI 棚卸し・陽性対照を揃えてから昇格する - enforce 後の拒否ログは、managed / legacy / custom で構造が違う。
violationInfoだけでなくstatus.message(customConstraints/を含む)も見て、失敗したSetIamPolicyを付与と区別する gcp.resourceLocationsを enforce すると、自動アクティベートされた SCC Standard が 7 日以内に非アクティブ化されることがある。Console からglobalで手動再アクティベートして復旧するstorage.uniformBucketLevelAccessの前に、サービスが自動作成した UBLA 無効バケットを import して有効化する。課金無効のプロジェクトでは自環境では更新できなかった
参考になれば幸いです。
参考
- Test organization policies (dry-run)
- Organization policy constraints
- Secure by default organizations
- Use custom organization policies for IAM
- Restricting identities by domain
- Restricting resource locations
- Services that support restricting resource locations
- Security Command Center: Standard tier の移行と自動非アクティブ化
- Uniform bucket-level access
- Logging query language
- Cloud Run ingress
- Allowing public (unauthenticated) access - Cloud Run
- AuditLog ViolationInfo reference
- google_org_policy_policy
