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