Add IAM deny policies support (#3970)

* Added IAM denial policies

* Moved default to empty, removed trys, added condition vars to expression

* remove redundant null checks

* reduce line length

* boilerplate and principal context expansion

* update readmes

* add explicit validation against null values

* add context tests

* Add missing license headers to examples

---------

Co-authored-by: Julio Castillo <jccb@google.com>
This commit is contained in:
kovagoadam
2026-05-21 02:38:06 +00:00
committed by GitHub
parent 36ca3c33a5
commit 1907c38e22
41 changed files with 1829 additions and 10 deletions

View File

@@ -23,6 +23,7 @@ This module allows the creation and management of folders, including support for
- [Cloud Asset Search](#cloud-asset-search)
- [Cloud Asset Inventory Feeds](#cloud-asset-inventory-feeds)
- [Tags](#tags)
- [IAM Deny Policies](#iam-deny-policies)
- [Files](#files)
- [Variables](#variables)
- [Outputs](#outputs)
@@ -726,6 +727,54 @@ module "folder" {
# tftest modules=2 resources=5 inventory=tags.yaml e2e serial
```
## IAM Deny Policies
[IAM Deny policies](https://cloud.google.com/iam/docs/deny-overview) allow you to set centralized guardrails that prevent principals from using specific permissions within the folder and all of its descendants, regardless of the roles they have been granted.
You can define Deny policies using the `iam_deny_policies` variable. Each policy requires you to specify the principals and permissions to deny. You can optionally define exception principals, exception permissions, and conditions to tailor the restriction.
Note that IAM Deny policies require a specific prefix for principal definitions (e.g., `principalSet://goog/public:all` or `principalSet://goog/group/group-email@example.com`), and permissions must be prefixed with the service fully qualified domain name (e.g., `iam.googleapis.com/serviceAccountKeys.create`).
```hcl
module "folder" {
source = "./fabric/modules/folder"
parent = var.folder_id
name = "Folder name"
iam_deny_policies = {
"prevent-key-creation" = {
display_name = "Prevent SA key creation"
rules = [
{
description = "Deny service account key creation to all except the folder admin group."
denied_principals = ["principalSet://goog/public:all"]
denied_permissions = ["iam.googleapis.com/serviceAccountKeys.create"]
exception_principals = [
"principalSet://goog/group/gcp-folder-admins@example.com"
]
}
]
}
"conditional-delete-deny" = {
display_name = "Conditional instance deletion deny"
rules = [
{
description = "Deny deletion of compute instances based on resource tags."
denied_principals = ["principalSet://goog/public:all"]
denied_permissions = ["compute.googleapis.com/instances.delete"]
denial_condition = {
title = "prevent_prod_deletion"
description = "Prevent deletion of instances tagged as production."
expression = "resource.matchTag('123456789012/environment', 'prod')"
}
}
]
}
}
}
# tftest modules=1 resources=3 inventory=iam-deny-policies.yaml
```
<!-- TFDOC OPTS files:1 -->
<!-- BEGIN TFDOC -->
## Files
@@ -733,6 +782,7 @@ module "folder" {
| name | description | resources |
|---|---|---|
| [assets.tf](./assets.tf) | None | <code>google_cloud_asset_folder_feed</code> |
| [deny-policies.tf](./deny-policies.tf) | IAM Deny policies. | <code>google_iam_deny_policy</code> |
| [iam.tf](./iam.tf) | IAM bindings. | <code>google_folder_iam_binding</code> · <code>google_folder_iam_member</code> |
| [logging.tf](./logging.tf) | Log sinks and supporting resources. | <code>google_bigquery_dataset_iam_member</code> · <code>google_folder_iam_audit_config</code> · <code>google_logging_folder_exclusion</code> · <code>google_logging_folder_settings</code> · <code>google_logging_folder_sink</code> · <code>google_project_iam_member</code> · <code>google_pubsub_topic_iam_member</code> · <code>google_storage_bucket_iam_member</code> |
| [main.tf](./main.tf) | Module-level locals and resources. | <code>google_assured_workloads_workload</code> · <code>google_compute_firewall_policy_association</code> · <code>google_essential_contacts_contact</code> · <code>google_folder</code> · <code>google_kms_autokey_config</code> |
@@ -770,6 +820,7 @@ module "folder" {
| [iam_by_principals](variables-iam.tf#L61) | Authoritative IAM binding in {PRINCIPAL => [ROLES]} format. Principals need to be statically defined to avoid errors. Merged internally with the `iam` variable. | <code>map&#40;list&#40;string&#41;&#41;</code> | | <code>&#123;&#125;</code> |
| [iam_by_principals_additive](variables-iam.tf#L54) | Additive IAM binding in {PRINCIPAL => [ROLES]} format. Principals need to be statically defined to avoid errors. Merged internally with the `iam_bindings_additive` variable. | <code>map&#40;list&#40;string&#41;&#41;</code> | | <code>&#123;&#125;</code> |
| [iam_by_principals_conditional](variables-iam.tf#L68) | Authoritative IAM binding in {PRINCIPAL => {roles = [roles], condition = {cond}}} format. Principals need to be statically defined to avoid errors. Condition is required. | <code>map&#40;object&#40;&#123;&#8230;&#125;&#41;&#41;</code> | | <code>&#123;&#125;</code> |
| [iam_deny_policies](variables-iam.tf#L98) | IAM Deny policies to be applied to the folder. | <code>map&#40;object&#40;&#123;&#8230;&#125;&#41;&#41;</code> | | <code>&#123;&#125;</code> |
| [id](variables.tf#L236) | Folder ID in case you use folder_create=false. | <code>string</code> | | <code>null</code> |
| [logging_data_access](variables-logging.tf#L17) | Control activation of data access logs. The special 'allServices' key denotes configuration for all services. | <code>map&#40;object&#40;&#123;&#8230;&#125;&#41;&#41;</code> | | <code>&#123;&#125;</code> |
| [logging_exclusions](variables-logging.tf#L28) | Logging exclusions for this folder in the form {NAME -> FILTER}. | <code>map&#40;string&#41;</code> | | <code>&#123;&#125;</code> |

View File

@@ -0,0 +1,52 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
# tfdoc:file:description IAM Deny policies.
resource "google_iam_deny_policy" "default" {
for_each = var.iam_deny_policies
parent = urlencode("cloudresourcemanager.googleapis.com/folders/${local.folder_number}")
name = each.key
display_name = each.value.display_name
dynamic "rules" {
for_each = each.value.rules
iterator = rule
content {
description = rule.value.description
deny_rule {
denied_principals = [
for p in rule.value.denied_principals : lookup(local.ctx.iam_principals, p, p)
]
dynamic "denial_condition" {
for_each = rule.value.denial_condition == null ? [] : [""]
content {
title = rule.value.denial_condition.title
expression = templatestring(
rule.value.denial_condition.expression, var.context.condition_vars
)
description = rule.value.denial_condition.description
location = rule.value.denial_condition.location
}
}
denied_permissions = rule.value.denied_permissions
exception_principals = [
for p in rule.value.exception_principals : lookup(local.ctx.iam_principals, p, p)
]
exception_permissions = rule.value.exception_permissions
}
}
}
}

View File

@@ -94,3 +94,39 @@ variable "iam_by_principals_conditional" {
error_message = "IAM bindings with the same condition title must have identical expressions and descriptions."
}
}
variable "iam_deny_policies" {
description = "IAM Deny policies to be applied to the folder."
type = map(object({
display_name = optional(string)
rules = list(object({
description = optional(string)
denied_principals = list(string)
denied_permissions = list(string)
denial_condition = optional(object({
expression = string
title = optional(string)
description = optional(string)
location = optional(string)
}))
exception_principals = optional(list(string), [])
exception_permissions = optional(list(string), [])
}))
}))
default = {}
nullable = false
validation {
# Ensure denied_principals and denied_permissions are explicitly not null
# (to prevent HCL evaluation errors in loops) and contain at least one
# element (required by the GCP API).
condition = alltrue(flatten([
for k, v in var.iam_deny_policies : [
for r in v.rules : (
try(length(r.denied_principals) > 0, false) &&
try(length(r.denied_permissions) > 0, false)
)
]
]))
error_message = "Each rule in iam_deny_policies must have at least one denied principal and one denied permission."
}
}