diff --git a/modules/organization/README.md b/modules/organization/README.md index 0af82e69..dfbac180 100644 --- a/modules/organization/README.md +++ b/modules/organization/README.md @@ -480,6 +480,7 @@ module "org" { | [organization-policies.tf](./organization-policies.tf) | Organization-level organization policies. | google_org_policy_policy | | [outputs.tf](./outputs.tf) | Module outputs. | | | [tags.tf](./tags.tf) | None | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_value · google_tags_tag_value_iam_binding | +| [variables-tags.tf](./variables-tags.tf) | None | | | [variables.tf](./variables.tf) | Module variables. | | | [versions.tf](./versions.tf) | Version pins. | | @@ -487,7 +488,7 @@ module "org" { | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [organization_id](variables.tf#L211) | Organization id in organizations/nnnnnn format. | string | ✓ | | +| [organization_id](variables.tf#L189) | Organization id in organizations/nnnnnn format. | string | ✓ | | | [contacts](variables.tf#L17) | List of essential contacts for this resource. Must be in the form EMAIL -> [NOTIFICATION_TYPES]. Valid notification types are ALL, SUSPENSION, SECURITY, TECHNICAL, BILLING, LEGAL, PRODUCT_UPDATES. | map(list(string)) | | {} | | [custom_roles](variables.tf#L24) | Map of role name => list of permissions to create in this project. | map(list(string)) | | {} | | [factories_config](variables.tf#L31) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | @@ -499,11 +500,11 @@ module "org" { | [logging_data_access](variables.tf#L95) | Control activation of data access logs. Format is service => { log type => [exempted members]}. The special 'allServices' key denotes configuration for all services. | map(map(list(string))) | | {} | | [logging_exclusions](variables.tf#L110) | Logging exclusions for this organization in the form {NAME -> FILTER}. | map(string) | | {} | | [logging_sinks](variables.tf#L117) | Logging sinks to create for the organization. | map(object({…})) | | {} | -| [network_tags](variables.tf#L148) | Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | -| [org_policies](variables.tf#L170) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} | -| [org_policy_custom_constraints](variables.tf#L197) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} | -| [tag_bindings](variables.tf#L220) | Tag bindings for this organization, in key => tag value id format. | map(string) | | {} | -| [tags](variables.tf#L227) | Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | +| [network_tags](variables-tags.tf#L17) | Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | +| [org_policies](variables.tf#L148) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} | +| [org_policy_custom_constraints](variables.tf#L175) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} | +| [tag_bindings](variables-tags.tf#L45) | Tag bindings for this organization, in key => tag value id format. | map(string) | | {} | +| [tags](variables-tags.tf#L52) | Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | ## Outputs diff --git a/modules/organization/variables-tags.tf b/modules/organization/variables-tags.tf new file mode 100644 index 00000000..4688504d --- /dev/null +++ b/modules/organization/variables-tags.tf @@ -0,0 +1,79 @@ +/** + * Copyright 2023 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. + */ + +variable "network_tags" { + description = "Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level." + type = map(object({ + description = optional(string, "Managed by the Terraform organization module.") + iam = optional(map(list(string)), {}) + id = optional(string) + network = string # project_id/vpc_name + values = optional(map(object({ + description = optional(string, "Managed by the Terraform organization module.") + iam = optional(map(list(string)), {}) + })), {}) + })) + nullable = false + default = {} + validation { + condition = ( + alltrue([ + for k, v in var.network_tags : v != null + ]) && + # all values are non-null + alltrue(flatten([ + for k, v in var.network_tags : [for k2, v2 in v.values : v2 != null] + ])) + ) + error_message = "Use an empty map instead of null as value." + } +} + +variable "tag_bindings" { + description = "Tag bindings for this organization, in key => tag value id format." + type = map(string) + default = {} + nullable = false +} + +variable "tags" { + description = "Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level." + type = map(object({ + description = optional(string, "Managed by the Terraform organization module.") + iam = optional(map(list(string)), {}) + id = optional(string) + values = optional(map(object({ + description = optional(string, "Managed by the Terraform organization module.") + iam = optional(map(list(string)), {}) + id = optional(string) + })), {}) + })) + nullable = false + default = {} + validation { + condition = ( + # all keys are non-null + alltrue([ + for k, v in var.tags : v != null + ]) && + # all values are non-null + alltrue(flatten([ + for k, v in var.tags : [for k2, v2 in v.values : v2 != null] + ])) + ) + error_message = "Use an empty map instead of null as value." + } +} diff --git a/modules/organization/variables.tf b/modules/organization/variables.tf index 59d3ccf6..61e76d0f 100644 --- a/modules/organization/variables.tf +++ b/modules/organization/variables.tf @@ -145,28 +145,6 @@ variable "logging_sinks" { } } -variable "network_tags" { - description = "Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level." - type = map(object({ - description = optional(string, "Managed by the Terraform organization module.") - iam = optional(map(list(string)), {}) - id = optional(string) - network = string # project_id/vpc_name - values = optional(map(object({ - description = optional(string, "Managed by the Terraform organization module.") - iam = optional(map(list(string)), {}) - })), {}) - })) - nullable = false - default = {} - validation { - condition = alltrue([ - for k, v in var.network_tags : v != null - ]) - error_message = "Use an empty map instead of null as value." - } -} - variable "org_policies" { description = "Organization policies applied to this organization keyed by policy name." type = map(object({ @@ -216,39 +194,3 @@ variable "organization_id" { error_message = "The organization_id must in the form organizations/nnn." } } - -variable "tag_bindings" { - description = "Tag bindings for this organization, in key => tag value id format." - type = map(string) - default = {} - nullable = false -} - -variable "tags" { - description = "Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level." - type = map(object({ - description = optional(string, "Managed by the Terraform organization module.") - iam = optional(map(list(string)), {}) - id = optional(string) - values = optional(map(object({ - description = optional(string, "Managed by the Terraform organization module.") - iam = optional(map(list(string)), {}) - id = optional(string) - })), {}) - })) - nullable = false - default = {} - validation { - condition = ( - # all keys are non-null - alltrue([ - for k, v in var.tags : v != null - ]) && - # all values are non-null - alltrue(flatten([ - for k, v in var.tags : [for k2, v2 in v.values : v2 != null] - ])) - ) - error_message = "Use an empty map instead of null as value." - } -} diff --git a/modules/project/README.md b/modules/project/README.md index d9b33e1e..cd9999d6 100644 --- a/modules/project/README.md +++ b/modules/project/README.md @@ -18,7 +18,8 @@ This module implements the creation and management of one GCP project including - [Log Sinks](#log-sinks) - [Data Access Logs](#data-access-logs) - [Cloud KMS Encryption Keys](#cloud-kms-encryption-keys) -- [Tags](#tags) +- [Attaching Tags](#attaching-tags) +- [Project-scoped Tags](#project-scoped-tags) - [Outputs](#outputs) - [Managing project related configuration without creating it](#managing-project-related-configuration-without-creating-it) - [Files](#files) @@ -655,9 +656,9 @@ module "project" { # tftest modules=1 resources=6 e2e ``` -## Tags +## Attaching Tags -Refer to the [Creating and managing tags](https://cloud.google.com/resource-manager/docs/tags/tags-creating-and-managing) documentation for details on usage. +You can attach secure tags to a project with the `tag_bindings` attribute ```hcl module "org" { @@ -686,6 +687,41 @@ module "project" { # tftest modules=2 resources=6 ``` +## Project-scoped Tags + +To create project-scoped secure tags, use the `tags` and `network_tags` attributes. + +```hcl +module "project" { + source = "./fabric/modules/project" + name = "project" + parent = var.folder_id + tags = { + mytag1 = {} + mytag2 = { + iam = { + "roles/resourcemanager.tagAdmin" = ["user:admin@example.com"] + } + values = { + myvalue1 = {} + myvalue2 = { + iam = { + "roles/resourcemanager.tagUser" = ["user:user@example.com"] + } + } + } + } + } + network_tags = { + my_net_tag = { + network = "${var.project_id}/${var.vpc.name}" + } + } +} +# tftest modules=1 resources=8 +``` + + ## Outputs Most of this module's outputs depend on its resources, to allow Terraform to compute all dependencies required for the project to be correctly configured. This allows you to reference outputs like `project_id` in other modules or resources without having to worry about setting `depends_on` blocks manually. @@ -935,7 +971,8 @@ module "bucket" { | [outputs.tf](./outputs.tf) | Module outputs. | | | [service-accounts.tf](./service-accounts.tf) | Service identities and supporting resources. | google_kms_crypto_key_iam_member · google_project_default_service_accounts · google_project_iam_member · google_project_service_identity | | [shared-vpc.tf](./shared-vpc.tf) | Shared VPC project-level configuration. | google_compute_shared_vpc_host_project · google_compute_shared_vpc_service_project · google_compute_subnetwork_iam_member · google_project_iam_member | -| [tags.tf](./tags.tf) | None | google_tags_tag_binding | +| [tags.tf](./tags.tf) | None | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_value · google_tags_tag_value_iam_binding | +| [variables-tags.tf](./variables-tags.tf) | None | | | [variables.tf](./variables.tf) | Module variables. | | | [versions.tf](./versions.tf) | Version pins. | | | [vpc-sc.tf](./vpc-sc.tf) | VPC-SC project-level perimeter configuration. | google_access_context_manager_service_perimeter_resource | @@ -963,6 +1000,7 @@ module "bucket" { | [logging_exclusions](variables.tf#L151) | Logging exclusions for this project in the form {NAME -> FILTER}. | map(string) | | {} | | [logging_sinks](variables.tf#L158) | Logging sinks to create for this project. | map(object({…})) | | {} | | [metric_scopes](variables.tf#L189) | List of projects that will act as metric scopes for this project. | list(string) | | [] | +| [network_tags](variables-tags.tf#L17) | Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | | [org_policies](variables.tf#L201) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} | | [parent](variables.tf#L228) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null | | [prefix](variables.tf#L238) | Optional prefix used to generate project id and name. | string | | null | @@ -975,7 +1013,8 @@ module "bucket" { | [shared_vpc_host_config](variables.tf#L292) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null | | [shared_vpc_service_config](variables.tf#L301) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} | | [skip_delete](variables.tf#L329) | Allows the underlying resources to be destroyed without destroying the project itself. | bool | | false | -| [tag_bindings](variables.tf#L335) | Tag bindings for this project, in key => tag value id format. | map(string) | | null | +| [tag_bindings](variables-tags.tf#L45) | Tag bindings for this project, in key => tag value id format. | map(string) | | null | +| [tags](variables-tags.tf#L51) | Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | ## Outputs diff --git a/modules/project/tags.tf b/modules/project/tags.tf index 683143bd..4f2a6664 100644 --- a/modules/project/tags.tf +++ b/modules/project/tags.tf @@ -1,5 +1,5 @@ /** - * Copyright 2022 Google LLC + * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,115 @@ * limitations under the License. */ +locals { + _tag_values = flatten([ + for tag, attrs in local.tags : [ + for value, value_attrs in attrs.values : { + description = value_attrs.description, + key = "${tag}/${value}" + id = try(value_attrs.id, null) + name = value + roles = keys(value_attrs.iam) + tag = tag + tag_id = attrs.id + tag_network = try(attrs.network, null) != null + } + ] + ]) + _tag_values_iam = flatten([ + for key, value_attrs in local.tag_values : [ + for role in value_attrs.roles : { + id = value_attrs.id + key = value_attrs.key + name = value_attrs.name + role = role + tag = value_attrs.tag + } + ] + ]) + _tags_iam = flatten([ + for tag, attrs in local.tags : [ + for role in keys(attrs.iam) : { + role = role + tag = tag + tag_id = attrs.id + } + ] + ]) + tag_values = { + for t in local._tag_values : t.key => t + } + tag_values_iam = { + for t in local._tag_values_iam : "${t.key}:${t.role}" => t + } + tags = merge(var.tags, var.network_tags) + tags_iam = { + for t in local._tags_iam : "${t.tag}:${t.role}" => t + } +} + +# keys + +resource "google_tags_tag_key" "default" { + for_each = { for k, v in local.tags : k => v if v.id == null } + parent = "projects/${local.project.project_id}" + purpose = ( + lookup(each.value, "network", null) == null ? null : "GCE_FIREWALL" + ) + purpose_data = ( + lookup(each.value, "network", null) == null ? null : { network = each.value.network } + ) + short_name = each.key + description = each.value.description + # depends_on = [ + # google_organization_iam_binding.authoritative, + # google_organization_iam_binding.bindings, + # google_organization_iam_member.bindings + # ] +} + +resource "google_tags_tag_key_iam_binding" "default" { + for_each = local.tags_iam + tag_key = ( + each.value.tag_id == null + ? google_tags_tag_key.default[each.value.tag].id + : each.value.tag_id + ) + role = each.value.role + members = coalesce( + local.tags[each.value.tag]["iam"][each.value.role], [] + ) +} + +# values + +resource "google_tags_tag_value" "default" { + for_each = { for k, v in local.tag_values : k => v if v.id == null } + parent = ( + each.value.tag_id == null + ? google_tags_tag_key.default[each.value.tag].id + : each.value.tag_id + ) + short_name = each.value.name + description = each.value.description +} + +resource "google_tags_tag_value_iam_binding" "default" { + for_each = local.tag_values_iam + tag_value = ( + each.value.id == null + ? google_tags_tag_value.default[each.value.key].id + : each.value.id + ) + role = each.value.role + members = coalesce( + local.tags[each.value.tag]["values"][each.value.name]["iam"][each.value.role], + [] + ) +} + +# bindings + resource "google_tags_tag_binding" "binding" { for_each = coalesce(var.tag_bindings, {}) parent = "//cloudresourcemanager.googleapis.com/projects/${local.project.number}" diff --git a/modules/project/variables-tags.tf b/modules/project/variables-tags.tf new file mode 100644 index 00000000..8914aae6 --- /dev/null +++ b/modules/project/variables-tags.tf @@ -0,0 +1,78 @@ +/** + * Copyright 2023 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. + */ + +variable "network_tags" { + description = "Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level." + type = map(object({ + description = optional(string, "Managed by the Terraform project module.") + iam = optional(map(list(string)), {}) + id = optional(string) + network = string # project_id/vpc_name + values = optional(map(object({ + description = optional(string, "Managed by the Terraform project module.") + iam = optional(map(list(string)), {}) + })), {}) + })) + nullable = false + default = {} + validation { + condition = ( + alltrue([ + for k, v in var.network_tags : v != null + ]) && + # all values are non-null + alltrue(flatten([ + for k, v in var.network_tags : [for k2, v2 in v.values : v2 != null] + ])) + ) + error_message = "Use an empty map instead of null as value." + } +} + +variable "tag_bindings" { + description = "Tag bindings for this project, in key => tag value id format." + type = map(string) + default = null +} + +variable "tags" { + description = "Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level." + type = map(object({ + description = optional(string, "Managed by the Terraform project module.") + iam = optional(map(list(string)), {}) + id = optional(string) + values = optional(map(object({ + description = optional(string, "Managed by the Terraform project module.") + iam = optional(map(list(string)), {}) + id = optional(string) + })), {}) + })) + nullable = false + default = {} + validation { + condition = ( + # all keys are non-null + alltrue([ + for k, v in var.tags : v != null + ]) && + # all values are non-null + alltrue(flatten([ + for k, v in var.tags : [for k2, v2 in v.values : v2 != null] + ])) + ) + error_message = "Use an empty map instead of null as value." + } +} diff --git a/modules/project/variables.tf b/modules/project/variables.tf index 064dd275..e17e44f6 100644 --- a/modules/project/variables.tf +++ b/modules/project/variables.tf @@ -331,9 +331,3 @@ variable "skip_delete" { type = bool default = false } - -variable "tag_bindings" { - description = "Tag bindings for this project, in key => tag value id format." - type = map(string) - default = null -}