diff --git a/fast/stages/1-vpcsc/README.md b/fast/stages/1-vpcsc/README.md
index 8befaace3..8bc0e86f4 100644
--- a/fast/stages/1-vpcsc/README.md
+++ b/fast/stages/1-vpcsc/README.md
@@ -310,8 +310,8 @@ Some references that might be useful in setting up this stage:
| [ingress_policies](variables.tf#L134) | Ingress policy definitions that can be referenced in perimeters. | map(object({…})) | | {} | |
| [logging](variables-fast.tf#L25) | Log writer identities for organization / folders. | object({…}) | | null | 0-bootstrap |
| [outputs_location](variables.tf#L176) | Path where providers, tfvars files, and lists for the following stages are written. Leave empty to disable. | string | | null | |
-| [perimeters](variables.tf#L182) | Perimeter definitions. | map(object({…})) | | {} | |
-| [resource_discovery](variables.tf#L215) | Automatic discovery of perimeter projects. | object({…}) | | {} | |
+| [perimeters](variables.tf#L182) | Perimeter definitions. | map(object({…})) | | {} | |
+| [resource_discovery](variables.tf#L216) | Automatic discovery of perimeter projects. | object({…}) | | {} | |
| [root_node](variables-fast.tf#L45) | Root node for the hierarchy, if running in tenant mode. | string | | null | 0-bootstrap |
## Outputs
diff --git a/fast/stages/1-vpcsc/variables.tf b/fast/stages/1-vpcsc/variables.tf
index a77f78df3..d2e475ee9 100644
--- a/fast/stages/1-vpcsc/variables.tf
+++ b/fast/stages/1-vpcsc/variables.tf
@@ -182,8 +182,10 @@ variable "outputs_location" {
variable "perimeters" {
description = "Perimeter definitions."
type = map(object({
- description = optional(string)
- title = optional(string)
+ description = optional(string)
+ ignore_resource_changes = optional(bool, false)
+ title = optional(string)
+ use_explicit_dry_run_spec = optional(bool, false)
spec = optional(object({
access_levels = optional(list(string))
egress_policies = optional(list(string))
@@ -206,7 +208,6 @@ variable "perimeters" {
enable_restriction = optional(bool, true)
}))
}))
- use_explicit_dry_run_spec = optional(bool, false)
}))
nullable = false
default = {}
diff --git a/modules/vpc-sc/README.md b/modules/vpc-sc/README.md
index 31704e0a1..8436eaf7e 100644
--- a/modules/vpc-sc/README.md
+++ b/modules/vpc-sc/README.md
@@ -112,6 +112,10 @@ module "test" {
### Perimeters
+Perimeters are defined via `perimeters` variable, or the dedicated factory.
+
+Perimeters by default manage all their attributes authoritatively. To have perimeter resources managed externally (e.g. from the project factory) set the perimeter-level attribute `ignore_resource_changes` at the perimeter level.
+
```hcl
module "test" {
source = "./fabric/modules/vpc-sc"
@@ -388,6 +392,7 @@ to:
| [iam.tf](./iam.tf) | IAM bindings | google_access_context_manager_access_policy_iam_binding ยท google_access_context_manager_access_policy_iam_member |
| [main.tf](./main.tf) | Module-level locals and resources. | google_access_context_manager_access_policy |
| [outputs.tf](./outputs.tf) | Module outputs. | |
+| [perimeters-additive.tf](./perimeters-additive.tf) | Regular service perimeter resources which ignore resource changes. | google_access_context_manager_service_perimeter |
| [perimeters.tf](./perimeters.tf) | Regular service perimeter resources. | google_access_context_manager_service_perimeter |
| [variables.tf](./variables.tf) | Module variables. | |
| [versions.tf](./versions.tf) | Version pins. | |
@@ -405,8 +410,8 @@ to:
| [iam_bindings](variables.tf#L149) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} |
| [iam_bindings_additive](variables.tf#L164) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} |
| [ingress_policies](variables.tf#L179) | Ingress policy definitions that can be referenced in perimeters. | map(object({…})) | | {} |
-| [perimeters](variables.tf#L221) | Regular service perimeters. | map(object({…})) | | {} |
-| [project_id_search_scope](variables.tf#L254) | Set this to an organization or folder ID to use Cloud Asset Inventory to automatically translate project ids to numbers. | string | | null |
+| [perimeters](variables.tf#L221) | Regular service perimeters. | map(object({…})) | | {} |
+| [project_id_search_scope](variables.tf#L255) | Set this to an organization or folder ID to use Cloud Asset Inventory to automatically translate project ids to numbers. | string | | null |
## Outputs
diff --git a/modules/vpc-sc/factory.tf b/modules/vpc-sc/factory.tf
index 962b0ac45..dddb567c7 100644
--- a/modules/vpc-sc/factory.tf
+++ b/modules/vpc-sc/factory.tf
@@ -95,8 +95,9 @@ locals {
perimeters = {
for k, v in local._data.perimeters :
k => {
- description = try(v.description, null)
- title = try(v.title, null)
+ description = try(v.description, null)
+ ignore_resource_changes = try(v.ignore_resource_changes, false)
+ title = try(v.title, null)
spec = !can(v.spec) ? null : merge(v.spec, {
access_levels = try(v.spec.access_levels, [])
egress_policies = try(v.spec.egress_policies, [])
diff --git a/modules/vpc-sc/outputs.tf b/modules/vpc-sc/outputs.tf
index 00f8b76d2..f954b7054 100644
--- a/modules/vpc-sc/outputs.tf
+++ b/modules/vpc-sc/outputs.tf
@@ -46,5 +46,8 @@ output "id" {
output "perimeters" {
description = "Regular service perimeter resources."
- value = google_access_context_manager_service_perimeter.regular
+ value = merge(
+ google_access_context_manager_service_perimeter.regular,
+ google_access_context_manager_service_perimeter.additive
+ )
}
diff --git a/modules/vpc-sc/perimeters-additive.tf b/modules/vpc-sc/perimeters-additive.tf
new file mode 100644
index 000000000..bc562b938
--- /dev/null
+++ b/modules/vpc-sc/perimeters-additive.tf
@@ -0,0 +1,405 @@
+/**
+ * Copyright 2025 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 Regular service perimeter resources which ignore resource changes.
+
+resource "google_access_context_manager_service_perimeter" "additive" {
+ for_each = {
+ for k, v in local.perimeters : k => v if v.ignore_resource_changes
+ }
+ parent = "accessPolicies/${local.access_policy}"
+ name = "accessPolicies/${local.access_policy}/servicePerimeters/${each.key}"
+ description = each.value.description
+ title = coalesce(each.value.title, each.key)
+ perimeter_type = "PERIMETER_TYPE_REGULAR"
+ use_explicit_dry_run_spec = each.value.use_explicit_dry_run_spec
+ dynamic "spec" {
+ for_each = each.value.spec == null ? [] : [each.value.spec]
+ iterator = spec
+ content {
+ access_levels = (
+ spec.value.access_levels == null ? null : [
+ for k in spec.value.access_levels :
+ try(google_access_context_manager_access_level.basic[k].id, k)
+ ]
+ )
+ resources = flatten([
+ for r in spec.value.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ restricted_services = flatten([
+ for r in coalesce(spec.value.restricted_services, []) :
+ lookup(var.factories_config.context.service_sets, r, [r])
+ ])
+
+ dynamic "egress_policies" {
+ for_each = spec.value.egress_policies == null ? [] : [
+ for k in spec.value.egress_policies :
+ merge(local.egress_policies[k], { key = k })
+ ]
+ iterator = policy
+ content {
+ title = coalesce(policy.value.title, policy.value.key)
+ dynamic "egress_from" {
+ for_each = policy.value.from == null ? [] : [""]
+ content {
+ identity_type = policy.value.from.identity_type
+ identities = flatten([
+ for i in policy.value.from.identities :
+ lookup(var.factories_config.context.identity_sets, i, [i])
+ ])
+ source_restriction = (
+ length(policy.value.from.access_levels) > 0 || length(policy.value.from.resources) > 0
+ ? "SOURCE_RESTRICTION_ENABLED"
+ : "SOURCE_RESTRICTION_DISABLED"
+ )
+ dynamic "sources" {
+ for_each = policy.value.from.access_levels
+ iterator = access_level
+ content {
+ access_level = try(
+ google_access_context_manager_access_level.basic[access_level.value].id,
+ access_level.value
+ )
+ }
+ }
+ dynamic "sources" {
+ for_each = flatten([
+ for r in policy.value.from.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ iterator = resource
+ content {
+ resource = resource.value
+ }
+ }
+ }
+ }
+ dynamic "egress_to" {
+ for_each = policy.value.to == null ? [] : [""]
+ content {
+ external_resources = policy.value.to.external_resources
+ resources = flatten([
+ for r in policy.value.to.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+
+ roles = policy.value.to.roles
+ dynamic "operations" {
+ for_each = toset(policy.value.to.operations)
+ iterator = o
+ content {
+ service_name = o.value.service_name
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.method_selectors, []))
+ content {
+ method = method_selectors.key
+ }
+ }
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.permission_selectors, []))
+ content {
+ permission = method_selectors.key
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ dynamic "ingress_policies" {
+ for_each = spec.value.ingress_policies == null ? [] : [
+ for k in spec.value.ingress_policies :
+ merge(local.ingress_policies[k], { key = k })
+ ]
+ iterator = policy
+ content {
+ title = coalesce(policy.value.title, policy.value.key)
+ dynamic "ingress_from" {
+ for_each = policy.value.from == null ? [] : [""]
+ content {
+ identity_type = policy.value.from.identity_type
+ identities = flatten([
+ for i in policy.value.from.identities :
+ lookup(var.factories_config.context.identity_sets, i, [i])
+ ])
+ dynamic "sources" {
+ for_each = toset(policy.value.from.access_levels)
+ iterator = s
+ content {
+ access_level = try(
+ google_access_context_manager_access_level.basic[s.value].id, s.value
+ )
+ }
+ }
+ dynamic "sources" {
+ for_each = flatten([
+ for r in policy.value.from.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ content {
+ resource = sources.value
+ }
+ }
+ }
+ }
+ dynamic "ingress_to" {
+ for_each = policy.value.to == null ? [] : [""]
+ content {
+ resources = flatten([
+ for r in policy.value.to.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ roles = policy.value.to.roles
+ dynamic "operations" {
+ for_each = toset(policy.value.to.operations)
+ iterator = o
+ content {
+ service_name = o.value.service_name
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.method_selectors, []))
+ content {
+ method = method_selectors.value
+ }
+ }
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.permission_selectors, []))
+ content {
+ permission = method_selectors.value
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ dynamic "vpc_accessible_services" {
+ for_each = spec.value.vpc_accessible_services == null ? [] : [""]
+ content {
+ allowed_services = flatten([
+ for r in spec.value.vpc_accessible_services.allowed_services :
+ lookup(var.factories_config.context.service_sets, r, [r])
+ ])
+ enable_restriction = spec.value.vpc_accessible_services.enable_restriction
+ }
+ }
+
+ }
+ }
+ dynamic "status" {
+ for_each = each.value.status == null ? [] : [each.value.status]
+ iterator = status
+ content {
+ access_levels = (
+ status.value.access_levels == null ? null : [
+ for k in status.value.access_levels :
+ try(google_access_context_manager_access_level.basic[k].id, k)
+ ]
+ )
+ resources = flatten([
+ for r in status.value.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ restricted_services = flatten([
+ for r in coalesce(status.value.restricted_services, []) :
+ lookup(var.factories_config.context.service_sets, r, [r])
+ ])
+
+ dynamic "egress_policies" {
+ for_each = status.value.egress_policies == null ? [] : [
+ for k in status.value.egress_policies :
+ merge(local.egress_policies[k], { key = k })
+ ]
+ iterator = policy
+ content {
+ title = coalesce(policy.value.title, policy.value.key)
+ dynamic "egress_from" {
+ for_each = policy.value.from == null ? [] : [""]
+ content {
+ identity_type = policy.value.from.identity_type
+ identities = flatten([
+ for i in policy.value.from.identities :
+ lookup(var.factories_config.context.identity_sets, i, [i])
+ ])
+ source_restriction = (
+ length(policy.value.from.access_levels) > 0 || length(policy.value.from.resources) > 0
+ ? "SOURCE_RESTRICTION_ENABLED"
+ : "SOURCE_RESTRICTION_DISABLED"
+ )
+ dynamic "sources" {
+ for_each = policy.value.from.access_levels
+ iterator = access_level
+ content {
+ access_level = try(
+ google_access_context_manager_access_level.basic[access_level.value].id,
+ access_level.value
+ )
+ }
+ }
+ dynamic "sources" {
+ for_each = flatten([
+ for r in policy.value.from.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ iterator = resource
+ content {
+ resource = resource.value
+ }
+ }
+ }
+ }
+ dynamic "egress_to" {
+ for_each = policy.value.to == null ? [] : [""]
+ content {
+ external_resources = policy.value.to.external_resources
+ resources = policy.value.to.resources
+ roles = policy.value.to.roles
+ dynamic "operations" {
+ for_each = toset(policy.value.to.operations)
+ iterator = o
+ content {
+ service_name = o.value.service_name
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.method_selectors, []))
+ content {
+ method = method_selectors.key
+ }
+ }
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.permission_selectors, []))
+ content {
+ permission = method_selectors.key
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ dynamic "ingress_policies" {
+ for_each = status.value.ingress_policies == null ? [] : [
+ for k in status.value.ingress_policies :
+ merge(local.ingress_policies[k], { key = k })
+ ]
+ iterator = policy
+ content {
+ title = coalesce(policy.value.title, policy.value.key)
+ dynamic "ingress_from" {
+ for_each = policy.value.from == null ? [] : [""]
+ content {
+ identity_type = policy.value.from.identity_type
+ identities = flatten([
+ for i in policy.value.from.identities :
+ lookup(var.factories_config.context.identity_sets, i, [i])
+ ])
+ dynamic "sources" {
+ for_each = toset(policy.value.from.access_levels)
+ iterator = s
+ content {
+ access_level = try(
+ google_access_context_manager_access_level.basic[s.value].id,
+ s.value
+ )
+ }
+ }
+ dynamic "sources" {
+ for_each = flatten([
+ for r in policy.value.from.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ content {
+ resource = sources.value
+ }
+ }
+ }
+ }
+ dynamic "ingress_to" {
+ for_each = policy.value.to == null ? [] : [""]
+ content {
+ resources = flatten([
+ for r in policy.value.to.resources : try(
+ var.factories_config.context.resource_sets[r],
+ [local.project_number[r]], [r]
+ )
+ ])
+ roles = policy.value.to.roles
+ dynamic "operations" {
+ for_each = toset(policy.value.to.operations)
+ iterator = o
+ content {
+ service_name = o.value.service_name
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.method_selectors, []))
+ content {
+ method = method_selectors.value
+ }
+ }
+ dynamic "method_selectors" {
+ for_each = toset(coalesce(o.value.permission_selectors, []))
+ content {
+ permission = method_selectors.value
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ dynamic "vpc_accessible_services" {
+ for_each = status.value.vpc_accessible_services == null ? [] : [""]
+ content {
+ allowed_services = flatten([
+ for r in status.value.vpc_accessible_services.allowed_services :
+ lookup(var.factories_config.context.service_sets, r, [r])
+ ])
+ enable_restriction = status.value.vpc_accessible_services.enable_restriction
+ }
+ }
+
+ }
+ }
+ lifecycle {
+ ignore_changes = [spec[0].resources, status[0].resources]
+ }
+ depends_on = [
+ google_access_context_manager_access_policy.default,
+ google_access_context_manager_access_level.basic
+ ]
+}
diff --git a/modules/vpc-sc/perimeters.tf b/modules/vpc-sc/perimeters.tf
index 326a0db31..32faad850 100644
--- a/modules/vpc-sc/perimeters.tf
+++ b/modules/vpc-sc/perimeters.tf
@@ -16,10 +16,6 @@
# tfdoc:file:description Regular service perimeter resources.
-# this code implements "additive" service perimeters, if "authoritative"
-# service perimeters are needed, switch to the
-# google_access_context_manager_service_perimeters resource
-
locals {
egress_policies = merge(local.data.egress_policies, var.egress_policies)
ingress_policies = merge(local.data.ingress_policies, var.ingress_policies)
@@ -27,7 +23,9 @@ locals {
}
resource "google_access_context_manager_service_perimeter" "regular" {
- for_each = local.perimeters
+ for_each = {
+ for k, v in local.perimeters : k => v if !v.ignore_resource_changes
+ }
parent = "accessPolicies/${local.access_policy}"
name = "accessPolicies/${local.access_policy}/servicePerimeters/${each.key}"
description = each.value.description
@@ -403,9 +401,6 @@ resource "google_access_context_manager_service_perimeter" "regular" {
}
}
- # lifecycle {
- # ignore_changes = [spec[0].resources, status[0].resources]
- # }
depends_on = [
google_access_context_manager_access_policy.default,
google_access_context_manager_access_level.basic
diff --git a/modules/vpc-sc/schemas/perimeters.schema.json b/modules/vpc-sc/schemas/perimeters.schema.json
index 831c175b0..f3dae8384 100644
--- a/modules/vpc-sc/schemas/perimeters.schema.json
+++ b/modules/vpc-sc/schemas/perimeters.schema.json
@@ -7,8 +7,9 @@
"description": {
"type": "string"
},
- "title": {
- "type": "string"
+ "ignore_resource_changes": {
+ "type": "boolean",
+ "default": false
},
"spec": {
"type": "object",
@@ -88,6 +89,9 @@
},
"additionalProperties": false
},
+ "title": {
+ "type": "string"
+ },
"use_explicit_dry_run_spec": {
"type": "boolean",
"default": false
@@ -113,4 +117,4 @@
]
}
}
-}
+}
\ No newline at end of file
diff --git a/modules/vpc-sc/variables.tf b/modules/vpc-sc/variables.tf
index e663adc70..0548a10a3 100644
--- a/modules/vpc-sc/variables.tf
+++ b/modules/vpc-sc/variables.tf
@@ -221,8 +221,10 @@ variable "ingress_policies" {
variable "perimeters" {
description = "Regular service perimeters."
type = map(object({
- description = optional(string)
- title = optional(string)
+ description = optional(string)
+ ignore_resource_changes = optional(bool, false)
+ title = optional(string)
+ use_explicit_dry_run_spec = optional(bool, false)
spec = optional(object({
access_levels = optional(list(string))
egress_policies = optional(list(string))
@@ -245,7 +247,6 @@ variable "perimeters" {
enable_restriction = optional(bool, true)
}))
}))
- use_explicit_dry_run_spec = optional(bool, false)
}))
default = {}
nullable = false