diff --git a/fast/stages/1-resman/README.md b/fast/stages/1-resman/README.md
index 7e5577e9a..3111cd8b0 100644
--- a/fast/stages/1-resman/README.md
+++ b/fast/stages/1-resman/README.md
@@ -327,18 +327,18 @@ terraform apply
| [organization](variables-fast.tf#L135) | Organization details. | object({…}) | ✓ | | 0-bootstrap |
| [prefix](variables-fast.tf#L165) | Prefix used for resources that need unique names. Use 9 characters or less. | string | ✓ | | 0-bootstrap |
| [custom_roles](variables-fast.tf#L54) | Custom roles defined at the org level, in key => id format. | object({…}) | | null | 0-bootstrap |
-| [factories_config](variables.tf#L20) | Configuration for the resource factories or external data. | object({…}) | | {} | |
+| [factories_config](variables.tf#L20) | Configuration for the resource factories or external data. | object({…}) | | {} | |
| [fast_addon](variables-addons.tf#L17) | FAST addons configurations for stages 2. Keys are used as short names for the add-on resources. | map(object({…})) | | {} | |
| [fast_stage_2](variables-stages.tf#L17) | FAST stages 2 configurations. | map(object({…})) | | {} | |
| [fast_stage_3](variables-stages.tf#L117) | FAST stages 3 configurations. | map(object({…})) | | {} | |
| [groups](variables-fast.tf#L93) | Group names or IAM-format principals to grant organization-level permissions. If just the name is provided, the 'group:' principal and organization domain are interpolated. | object({…}) | | {} | 0-bootstrap |
| [locations](variables-fast.tf#L109) | Optional locations for GCS, BigQuery, and logging buckets created here. | object({…}) | | {} | 0-bootstrap |
| [org_policy_tags](variables-fast.tf#L153) | Organization policy tags. | object({…}) | | {} | 0-bootstrap |
-| [outputs_location](variables.tf#L37) | Enable writing provider, tfvars and CI/CD workflow files to local filesystem. Leave null to disable. | string | | null | |
-| [resource_names](variables.tf#L43) | Resource names overrides for specific resources. Stage names are interpolated via `$${name}`. Prefix is always set via code, except where noted in the variable type. | object({…}) | | {} | |
+| [outputs_location](variables.tf#L38) | Enable writing provider, tfvars and CI/CD workflow files to local filesystem. Leave null to disable. | string | | null | |
+| [resource_names](variables.tf#L44) | Resource names overrides for specific resources. Stage names are interpolated via `$${name}`. Prefix is always set via code, except where noted in the variable type. | object({…}) | | {} | |
| [root_node](variables-fast.tf#L171) | Root node for the hierarchy, if running in tenant mode. | string | | null | 0-bootstrap |
-| [tag_names](variables.tf#L63) | Customized names for resource management tags. | object({…}) | | {} | |
-| [tags](variables.tf#L77) | Custom secure tags by key name. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | |
+| [tag_names](variables.tf#L64) | Customized names for resource management tags. | object({…}) | | {} | |
+| [tags](variables.tf#L78) | Custom secure tags by key name. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | |
| [top_level_folders](variables-toplevel-folders.tf#L17) | Additional top-level folders. Keys are used for service account and bucket names, values implement the folders module interface with the addition of the 'automation' attribute. | map(object({…})) | | {} | |
## Outputs
diff --git a/fast/stages/1-resman/main.tf b/fast/stages/1-resman/main.tf
index 4d20a18cc..665a5a3bb 100644
--- a/fast/stages/1-resman/main.tf
+++ b/fast/stages/1-resman/main.tf
@@ -75,6 +75,9 @@ locals {
top_level_service_accounts = {
for k, v in module.top-level-sa : k => try(v.email)
}
+ top_level_service_accounts_iam = {
+ for k, v in local.top_level_service_accounts : k => "serviceAccount:${v}"
+ }
# leaving this here to document how to get self identity in a stage
# automation_resman_sa = try(
# data.google_client_openid_userinfo.provider_identity[0].email, null
diff --git a/fast/stages/1-resman/organization.tf b/fast/stages/1-resman/organization.tf
index 63e062923..b6e02e9d9 100644
--- a/fast/stages/1-resman/organization.tf
+++ b/fast/stages/1-resman/organization.tf
@@ -42,8 +42,24 @@ module "organization" {
factories_config = {
tags = var.factories_config.tags
context = {
- tag_keys = var.factories_config.context.tag_keys
- tag_values = var.factories_config.context.tag_values
+ iam_principals = merge(
+ var.factories_config.context.iam_principals,
+ local.top_level_service_accounts_iam,
+ local.stage_service_accounts_iam
+ )
+ tag_keys = merge(
+ var.factories_config.context.tag_keys,
+ {
+ (var.org_policy_tags.key_name) = var.org_policy_tags.key_id
+ }
+ )
+ tag_values = merge(
+ var.factories_config.context.tag_values,
+ {
+ for k, v in var.org_policy_tags.values :
+ "${var.org_policy_tags.key_name}/${k}" => v
+ }
+ )
}
}
# do not assign tagViewer or tagUser roles here on tag keys and values as
diff --git a/fast/stages/1-resman/schemas/tags.schema.json b/fast/stages/1-resman/schemas/tags.schema.json
new file mode 120000
index 000000000..87081ef23
--- /dev/null
+++ b/fast/stages/1-resman/schemas/tags.schema.json
@@ -0,0 +1 @@
+../../../../modules/organization/schemas/tags.schema.json
\ No newline at end of file
diff --git a/fast/stages/1-resman/schemas/tags.schema.md b/fast/stages/1-resman/schemas/tags.schema.md
new file mode 100644
index 000000000..d33e47b15
--- /dev/null
+++ b/fast/stages/1-resman/schemas/tags.schema.md
@@ -0,0 +1,60 @@
+# Resource Manager Tags
+
+
+
+## Properties
+
+*additional properties: false*
+
+- **name**: *string*
+- **description**: *string*
+- **id**: *string*
+- **network**: *string*
+- **iam**: *reference([iam](#refs-iam))*
+- **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))*
+- **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))*
+- **values**: *object*
+
*additional properties: false*
+ - **`^[a-z-][a-z0-9-]+$`**: *object*
+
*additional properties: false*
+ - **name**: *string*
+ - **description**: *string*
+ - **id**: *string*
+ - **iam**: *reference([iam](#refs-iam))*
+ - **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))*
+ - **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))*
+
+## Definitions
+
+- **iam**: *object*
+
*additional properties: false*
+ - **`^roles/`**: *array*
+ - items: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+- **iam_bindings**: *object*
+
*additional properties: false*
+ - **`^[a-z0-9_-]+$`**: *object*
+
*additional properties: false*
+ - **members**: *array*
+ - items: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+ - **role**: *string*
+
*pattern: ^roles/*
+ - **condition**: *object*
+
*additional properties: false*
+ - ⁺**expression**: *string*
+ - ⁺**title**: *string*
+ - **description**: *string*
+- **iam_bindings_additive**: *object*
+
*additional properties: false*
+ - **`^[a-z0-9_-]+$`**: *object*
+
*additional properties: false*
+ - **member**: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+ - **role**: *string*
+
*pattern: ^[a-zA-Z0-9_/]+$*
+ - **condition**: *object*
+
*additional properties: false*
+ - ⁺**expression**: *string*
+ - ⁺**title**: *string*
+ - **description**: *string*
diff --git a/fast/stages/1-resman/variables.tf b/fast/stages/1-resman/variables.tf
index d0abaf90d..8bb0a96d7 100644
--- a/fast/stages/1-resman/variables.tf
+++ b/fast/stages/1-resman/variables.tf
@@ -25,9 +25,10 @@ variable "factories_config" {
tags = optional(string, "data/tags")
top_level_folders = optional(string, "data/top-level-folders")
context = optional(object({
- org_policies = optional(map(map(string)), {})
- tag_keys = optional(map(string), {})
- tag_values = optional(map(string), {})
+ iam_principals = optional(map(string), {})
+ org_policies = optional(map(map(string)), {})
+ tag_keys = optional(map(string), {})
+ tag_values = optional(map(string), {})
}), {})
})
nullable = false
diff --git a/modules/net-vpc/schemas/subnet.schema.md b/modules/net-vpc/schemas/subnet.schema.md
index 0022471c6..1d735e38e 100644
--- a/modules/net-vpc/schemas/subnet.schema.md
+++ b/modules/net-vpc/schemas/subnet.schema.md
@@ -23,8 +23,8 @@
- **ipv6**: *object*
*additional properties: false*
- **access_type**: *string*
- - +**ipv6_only**: *boolean*
-- ⁺**ip_collection**: *string*
+ - **ipv6_only**: *boolean*
+- **ip_collection**: *string*
- **name**: *string*
- ⁺**region**: *string*
- **psc**: *boolean*
diff --git a/modules/organization/README.md b/modules/organization/README.md
index 4ed1b120a..fa35cec52 100644
--- a/modules/organization/README.md
+++ b/modules/organization/README.md
@@ -529,13 +529,24 @@ module "org" {
organization_id = var.organization_id
factories_config = {
tags = "data/tags"
+ context = {
+ iam_principals = {
+ gcp-admins = "group:gcp-organization-admins@example.com"
+ }
+ tag_keys = {
+ foo = "tagKeys/1234567890"
+ }
+ tag_values = {
+ "foo/bar" = "tagValues/789012345"
+ }
+ }
}
}
-# tftest modules=1 resources=4 files=cost-center inventory=tags-factory.yaml
+# tftest modules=1 resources=7 files=0,1 inventory=tags-factory.yaml
```
```yaml
-# tftest-file id=cost-center path=data/tags/cost-center.yaml
+# tftest-file id=0 path=data/tags/cost-center.yaml
description: "Tag for internal cost allocation."
iam:
@@ -548,6 +559,26 @@ values:
description: "Marketing department."
```
+```yaml
+# tftest-file id=1 path=data/tags/foo.yaml
+
+description: "Tag that shows context expansion."
+id: foo
+iam:
+ "roles/resourcemanager.tagViewer":
+ - "group:finance-team@example.com"
+ - gcp-admins
+values:
+ bar:
+ id: foo/bar
+ description: "This tag value will be reused."
+ iam:
+ "roles/resourcemanager.tagViewer":
+ - gcp-admins
+ baz:
+ description: "This tag value will be created."
+```
+
## Files
@@ -571,11 +602,11 @@ values:
| name | description | type | required | default |
|---|---|:---:|:---:|:---:|
-| [organization_id](variables.tf#L99) | Organization id in organizations/nnnnnn format. | string | ✓ | |
+| [organization_id](variables.tf#L100) | 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({…}) | | {} |
-| [firewall_policy](variables.tf#L48) | Hierarchical firewall policies to associate to the organization. | object({…}) | | null |
+| [factories_config](variables.tf#L31) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} |
+| [firewall_policy](variables.tf#L49) | Hierarchical firewall policies to associate to the organization. | object({…}) | | null |
| [iam](variables-iam.tf#L17) | IAM bindings, in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} |
| [iam_bindings](variables-iam.tf#L24) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} |
| [iam_bindings_additive](variables-iam.tf#L39) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} |
@@ -586,8 +617,8 @@ values:
| [logging_settings](variables-logging.tf#L35) | Default settings for logging resources. | object({…}) | | null |
| [logging_sinks](variables-logging.tf#L45) | Logging sinks to create for the organization. | 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#L57) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} |
-| [org_policy_custom_constraints](variables.tf#L85) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} |
+| [org_policies](variables.tf#L58) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} |
+| [org_policy_custom_constraints](variables.tf#L86) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} |
| [tag_bindings](variables-tags.tf#L81) | Tag bindings for this organization, in key => tag value id format. | map(string) | | {} |
| [tags](variables-tags.tf#L88) | 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({…})) | | {} |
diff --git a/modules/organization/schemas/tags.schema.json b/modules/organization/schemas/tags.schema.json
new file mode 100644
index 000000000..08312c959
--- /dev/null
+++ b/modules/organization/schemas/tags.schema.json
@@ -0,0 +1,155 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Resource Manager Tags",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "network": {
+ "type": "string"
+ },
+ "iam": {
+ "$ref": "#/$defs/iam"
+ },
+ "iam_bindings": {
+ "$ref": "#/$defs/iam_bindings"
+ },
+ "iam_bindings_additive": {
+ "$ref": "#/$defs/iam_bindings_additive"
+ },
+ "values": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^[a-z-][a-z0-9-]+$": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "iam": {
+ "$ref": "#/$defs/iam"
+ },
+ "iam_bindings": {
+ "$ref": "#/$defs/iam_bindings"
+ },
+ "iam_bindings_additive": {
+ "$ref": "#/$defs/iam_bindings_additive"
+ }
+ }
+ }
+ }
+ }
+ },
+ "$defs": {
+ "iam": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^roles/": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])"
+ }
+ }
+ }
+ },
+ "iam_bindings": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^[a-z0-9_-]+$": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "members": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])"
+ }
+ },
+ "role": {
+ "type": "string",
+ "pattern": "^roles/"
+ },
+ "condition": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "expression",
+ "title"
+ ],
+ "properties": {
+ "expression": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "iam_bindings_additive": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^[a-z0-9_-]+$": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "member": {
+ "type": "string",
+ "pattern": "^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])"
+ },
+ "role": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_/]+$"
+ },
+ "condition": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "expression",
+ "title"
+ ],
+ "properties": {
+ "expression": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/organization/schemas/tags.schema.md b/modules/organization/schemas/tags.schema.md
new file mode 100644
index 000000000..d33e47b15
--- /dev/null
+++ b/modules/organization/schemas/tags.schema.md
@@ -0,0 +1,60 @@
+# Resource Manager Tags
+
+
+
+## Properties
+
+*additional properties: false*
+
+- **name**: *string*
+- **description**: *string*
+- **id**: *string*
+- **network**: *string*
+- **iam**: *reference([iam](#refs-iam))*
+- **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))*
+- **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))*
+- **values**: *object*
+
*additional properties: false*
+ - **`^[a-z-][a-z0-9-]+$`**: *object*
+
*additional properties: false*
+ - **name**: *string*
+ - **description**: *string*
+ - **id**: *string*
+ - **iam**: *reference([iam](#refs-iam))*
+ - **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))*
+ - **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))*
+
+## Definitions
+
+- **iam**: *object*
+
*additional properties: false*
+ - **`^roles/`**: *array*
+ - items: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+- **iam_bindings**: *object*
+
*additional properties: false*
+ - **`^[a-z0-9_-]+$`**: *object*
+
*additional properties: false*
+ - **members**: *array*
+ - items: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+ - **role**: *string*
+
*pattern: ^roles/*
+ - **condition**: *object*
+
*additional properties: false*
+ - ⁺**expression**: *string*
+ - ⁺**title**: *string*
+ - **description**: *string*
+- **iam_bindings_additive**: *object*
+
*additional properties: false*
+ - **`^[a-z0-9_-]+$`**: *object*
+
*additional properties: false*
+ - **member**: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+ - **role**: *string*
+
*pattern: ^[a-zA-Z0-9_/]+$*
+ - **condition**: *object*
+
*additional properties: false*
+ - ⁺**expression**: *string*
+ - ⁺**title**: *string*
+ - **description**: *string*
diff --git a/modules/organization/tags.tf b/modules/organization/tags.tf
index 6e024f19b..0c42519cc 100644
--- a/modules/organization/tags.tf
+++ b/modules/organization/tags.tf
@@ -1,11 +1,11 @@
/**
- * Copyright 2023 Google LLC
+ * 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
+ * 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,
@@ -43,9 +43,15 @@ locals {
_tags_merged = merge(local._factory_tags_data, var.tags, var.network_tags)
tags = {
for k, v in local._tags_merged : k => {
- description = v.description
- iam = v.iam
- iam_bindings = v.iam_bindings
+ description = v.description
+ iam = {
+ for ik, iv in v.iam : ik => coalesce(iv, [])
+ }
+ iam_bindings = {
+ for ik, iv in v.iam_bindings : ik => merge(iv, {
+ members = coalesce(iv.members, [])
+ })
+ }
iam_bindings_additive = v.iam_bindings_additive
network = lookup(v, "network", null)
id = try(coalesce(
@@ -54,9 +60,15 @@ locals {
), null)
values = {
for vk, vv in lookup(v, "values", {}) : vk => {
- description = vv.description
- iam = vv.iam
- iam_bindings = vv.iam_bindings
+ description = vv.description
+ iam = {
+ for ik, iv in vv.iam : ik => coalesce(iv, [])
+ }
+ iam_bindings = {
+ for ik, iv in vv.iam_bindings : ik => merge(iv, {
+ members = coalesce(iv.members, [])
+ })
+ }
iam_bindings_additive = vv.iam_bindings_additive
id = try(coalesce(
lookup(vv, "id", null),
@@ -185,9 +197,10 @@ resource "google_tags_tag_key_iam_binding" "default" {
: each.value.tag_id
)
role = each.value.role
- members = coalesce(
- local.tags[each.value.tag]["iam"][each.value.role], []
- )
+ members = [
+ for v in local.tags[each.value.tag]["iam"][each.value.role] :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_key_iam_binding" "bindings" {
@@ -198,9 +211,10 @@ resource "google_tags_tag_key_iam_binding" "bindings" {
: each.value.tag_id
)
role = local.tags[each.value.tag]["iam_bindings"][each.value.binding].role
- members = (
- local.tags[each.value.tag]["iam_bindings"][each.value.binding].members
- )
+ members = [
+ for v in local.tags[each.value.tag]["iam_bindings"][each.value.binding].members :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_key_iam_member" "bindings" {
@@ -210,8 +224,12 @@ resource "google_tags_tag_key_iam_member" "bindings" {
? google_tags_tag_key.default[each.value.tag].id
: each.value.tag_id
)
- role = local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].role
- member = local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].member
+ role = local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].role
+ member = lookup(
+ var.factories_config.context.iam_principals,
+ local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].member,
+ local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].member
+ )
}
# values
@@ -235,10 +253,10 @@ resource "google_tags_tag_value_iam_binding" "default" {
: each.value.id
)
role = each.value.role
- members = coalesce(
- local.tags[each.value.tag]["values"][each.value.name]["iam"][each.value.role],
- []
- )
+ members = [
+ for v in local.tags[each.value.tag]["values"][each.value.name]["iam"][each.value.role] :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_value_iam_binding" "bindings" {
@@ -251,9 +269,10 @@ resource "google_tags_tag_value_iam_binding" "bindings" {
role = (
local.tags[each.value.tag]["values"][each.value.name]["iam_bindings"][each.value.binding].role
)
- members = (
- local.tags[each.value.tag]["values"][each.value.name]["iam_bindings"][each.value.binding].members
- )
+ members = [
+ for v in local.tags[each.value.tag]["values"][each.value.name]["iam_bindings"][each.value.binding].members :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_value_iam_member" "bindings" {
@@ -266,7 +285,9 @@ resource "google_tags_tag_value_iam_member" "bindings" {
role = (
local.tags[each.value.tag]["values"][each.value.name]["iam_bindings_additive"][each.value.binding].role
)
- member = (
+ member = lookup(
+ var.factories_config.context.iam_principals,
+ local.tags[each.value.tag]["values"][each.value.name]["iam_bindings_additive"][each.value.binding].member,
local.tags[each.value.tag]["values"][each.value.name]["iam_bindings_additive"][each.value.binding].member
)
}
diff --git a/modules/organization/variables.tf b/modules/organization/variables.tf
index 89440f3b3..6abd6644f 100644
--- a/modules/organization/variables.tf
+++ b/modules/organization/variables.tf
@@ -36,9 +36,10 @@ variable "factories_config" {
org_policy_custom_constraints = optional(string)
tags = optional(string)
context = optional(object({
- org_policies = optional(map(map(string)), {})
- tag_keys = optional(map(string), {})
- tag_values = optional(map(string), {})
+ iam_principals = optional(map(string), {})
+ org_policies = optional(map(map(string)), {})
+ tag_keys = optional(map(string), {})
+ tag_values = optional(map(string), {})
}), {})
})
nullable = false
diff --git a/modules/project/README.md b/modules/project/README.md
index 79a94c39e..dca39cfb1 100644
--- a/modules/project/README.md
+++ b/modules/project/README.md
@@ -891,7 +891,6 @@ module "project" {
}
```
-
## 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.
@@ -1067,7 +1066,7 @@ module "project" {
## Project-scoped Tags
-To create project-scoped secure tags, use the `tags` and `network_tags` attributes.
+To create project-scoped secure tags, use the `tags` and `network_tags` attributes. Tags can also be created via a factory, refer to the [organization module documentation](../organization/README.md#tags-factory) for an example.
```hcl
module "project" {
@@ -1730,7 +1729,7 @@ alerts:
| name | description | type | required | default |
|---|---|:---:|:---:|:---:|
-| [name](variables.tf#L118) | Project name and id suffix. | string | ✓ | |
+| [name](variables.tf#L119) | Project name and id suffix. | string | ✓ | |
| [alerts](variables-observability.tf#L17) | Monitoring alerts. | map(object({…})) | | {} |
| [auto_create_network](variables.tf#L17) | Whether to create the default network for the project. | bool | | false |
| [billing_account](variables.tf#L23) | Billing account id. | string | | null |
@@ -1741,14 +1740,14 @@ alerts:
| [default_service_account](variables.tf#L56) | Project default service account setting: can be one of `delete`, `deprivilege`, `disable`, or `keep`. | string | | "keep" |
| [deletion_policy](variables.tf#L69) | Deletion policy setting for this project. | string | | "DELETE" |
| [descriptive_name](variables.tf#L80) | Name of the project name. Used for project name instead of `name` variable. | string | | null |
-| [factories_config](variables.tf#L86) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} |
+| [factories_config](variables.tf#L86) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} |
| [iam](variables-iam.tf#L17) | Authoritative IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} |
| [iam_bindings](variables-iam.tf#L24) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} |
| [iam_bindings_additive](variables-iam.tf#L39) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} |
| [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. | map(list(string)) | | {} |
| [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. | map(list(string)) | | {} |
-| [labels](variables.tf#L105) | Resource labels. | map(string) | | {} |
-| [lien_reason](variables.tf#L112) | If non-empty, creates a project lien with this description. | string | | null |
+| [labels](variables.tf#L106) | Resource labels. | map(string) | | {} |
+| [lien_reason](variables.tf#L113) | If non-empty, creates a project lien with this description. | string | | null |
| [log_scopes](variables-observability.tf#L117) | Log scopes under this project. | map(object({…})) | | {} |
| [logging_data_access](variables-observability.tf#L127) | Control activation of data access logs. The special 'allServices' key denotes configuration for all services. | map(object({…})) | | {} |
| [logging_exclusions](variables-observability.tf#L138) | Logging exclusions for this project in the form {NAME -> FILTER}. | map(string) | | {} |
@@ -1757,22 +1756,22 @@ alerts:
| [metric_scopes](variables-observability.tf#L216) | 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({…})) | | {} |
| [notification_channels](variables-observability.tf#L223) | Monitoring notification channels. | map(object({…})) | | {} |
-| [org_policies](variables.tf#L123) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} |
-| [parent](variables.tf#L151) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null |
-| [prefix](variables.tf#L161) | Optional prefix used to generate project id and name. | string | | null |
-| [project_reuse](variables.tf#L171) | Reuse existing project if not null. If name and number are not passed in, a data source is used. | object({…}) | | null |
+| [org_policies](variables.tf#L124) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} |
+| [parent](variables.tf#L152) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null |
+| [prefix](variables.tf#L162) | Optional prefix used to generate project id and name. | string | | null |
+| [project_reuse](variables.tf#L172) | Reuse existing project if not null. If name and number are not passed in, a data source is used. | object({…}) | | null |
| [quotas](variables-quotas.tf#L17) | Service quota configuration. | map(object({…})) | | {} |
-| [service_agents_config](variables.tf#L191) | Automatic service agent configuration options. | object({…}) | | {} |
-| [service_config](variables.tf#L201) | Configure service API activation. | object({…}) | | {…} |
-| [service_encryption_key_ids](variables.tf#L213) | Service Agents to be granted encryption/decryption permissions over Cloud KMS encryption keys. Format {SERVICE_AGENT => [KEY_ID]}. | map(list(string)) | | {} |
-| [services](variables.tf#L220) | Service APIs to enable. | list(string) | | [] |
-| [shared_vpc_host_config](variables.tf#L226) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null |
-| [shared_vpc_service_config](variables.tf#L236) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} |
-| [skip_delete](variables.tf#L273) | Deprecated. Use deletion_policy. | bool | | null |
+| [service_agents_config](variables.tf#L192) | Automatic service agent configuration options. | object({…}) | | {} |
+| [service_config](variables.tf#L202) | Configure service API activation. | object({…}) | | {…} |
+| [service_encryption_key_ids](variables.tf#L214) | Service Agents to be granted encryption/decryption permissions over Cloud KMS encryption keys. Format {SERVICE_AGENT => [KEY_ID]}. | map(list(string)) | | {} |
+| [services](variables.tf#L221) | Service APIs to enable. | list(string) | | [] |
+| [shared_vpc_host_config](variables.tf#L227) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null |
+| [shared_vpc_service_config](variables.tf#L237) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} |
+| [skip_delete](variables.tf#L274) | Deprecated. Use deletion_policy. | bool | | null |
| [tag_bindings](variables-tags.tf#L81) | Tag bindings for this project, in key => tag value id format. | map(string) | | null |
| [tags](variables-tags.tf#L88) | 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({…})) | | {} |
-| [universe](variables.tf#L285) | GCP universe where to deploy the project. The prefix will be prepended to the project id. | object({…}) | | null |
-| [vpc_sc](variables.tf#L294) | VPC-SC configuration for the project, use when `ignore_changes` for resources is set in the VPC-SC module. | object({…}) | | null |
+| [universe](variables.tf#L286) | GCP universe where to deploy the project. The prefix will be prepended to the project id. | object({…}) | | null |
+| [vpc_sc](variables.tf#L295) | VPC-SC configuration for the project, use when `ignore_changes` for resources is set in the VPC-SC module. | object({…}) | | null |
## Outputs
diff --git a/modules/project/schemas/tags.schema.json b/modules/project/schemas/tags.schema.json
new file mode 120000
index 000000000..e647f3143
--- /dev/null
+++ b/modules/project/schemas/tags.schema.json
@@ -0,0 +1 @@
+../../organization/schemas/tags.schema.json
\ No newline at end of file
diff --git a/modules/project/schemas/tags.schema.md b/modules/project/schemas/tags.schema.md
new file mode 100644
index 000000000..d33e47b15
--- /dev/null
+++ b/modules/project/schemas/tags.schema.md
@@ -0,0 +1,60 @@
+# Resource Manager Tags
+
+
+
+## Properties
+
+*additional properties: false*
+
+- **name**: *string*
+- **description**: *string*
+- **id**: *string*
+- **network**: *string*
+- **iam**: *reference([iam](#refs-iam))*
+- **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))*
+- **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))*
+- **values**: *object*
+
*additional properties: false*
+ - **`^[a-z-][a-z0-9-]+$`**: *object*
+
*additional properties: false*
+ - **name**: *string*
+ - **description**: *string*
+ - **id**: *string*
+ - **iam**: *reference([iam](#refs-iam))*
+ - **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))*
+ - **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))*
+
+## Definitions
+
+- **iam**: *object*
+
*additional properties: false*
+ - **`^roles/`**: *array*
+ - items: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+- **iam_bindings**: *object*
+
*additional properties: false*
+ - **`^[a-z0-9_-]+$`**: *object*
+
*additional properties: false*
+ - **members**: *array*
+ - items: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+ - **role**: *string*
+
*pattern: ^roles/*
+ - **condition**: *object*
+
*additional properties: false*
+ - ⁺**expression**: *string*
+ - ⁺**title**: *string*
+ - **description**: *string*
+- **iam_bindings_additive**: *object*
+
*additional properties: false*
+ - **`^[a-z0-9_-]+$`**: *object*
+
*additional properties: false*
+ - **member**: *string*
+
*pattern: ^(?:domain:|group:|serviceAccount:|user:|principal:|principalSet:|[a-z])*
+ - **role**: *string*
+
*pattern: ^[a-zA-Z0-9_/]+$*
+ - **condition**: *object*
+
*additional properties: false*
+ - ⁺**expression**: *string*
+ - ⁺**title**: *string*
+ - **description**: *string*
diff --git a/modules/project/tags.tf b/modules/project/tags.tf
index d70a0f9aa..1d9f8d793 100644
--- a/modules/project/tags.tf
+++ b/modules/project/tags.tf
@@ -1,5 +1,5 @@
/**
- * Copyright 2023 Google LLC
+ * 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.
@@ -43,9 +43,15 @@ locals {
_tags_merged = merge(local._factory_tags_data, var.tags, var.network_tags)
tags = {
for k, v in local._tags_merged : k => {
- description = v.description
- iam = v.iam
- iam_bindings = v.iam_bindings
+ description = v.description
+ iam = {
+ for ik, iv in v.iam : ik => coalesce(iv, [])
+ }
+ iam_bindings = {
+ for ik, iv in v.iam_bindings : ik => merge(iv, {
+ members = coalesce(iv.members, [])
+ })
+ }
iam_bindings_additive = v.iam_bindings_additive
network = lookup(v, "network", null)
id = try(coalesce(
@@ -54,9 +60,15 @@ locals {
), null)
values = {
for vk, vv in lookup(v, "values", {}) : vk => {
- description = vv.description
- iam = vv.iam
- iam_bindings = vv.iam_bindings
+ description = vv.description
+ iam = {
+ for ik, iv in vv.iam : ik => coalesce(iv, [])
+ }
+ iam_bindings = {
+ for ik, iv in vv.iam_bindings : ik => merge(iv, {
+ members = coalesce(iv.members, [])
+ })
+ }
iam_bindings_additive = vv.iam_bindings_additive
id = try(coalesce(
lookup(vv, "id", null),
@@ -69,7 +81,7 @@ locals {
_tag_iam = flatten([
for k, v in local.tags : [
for role in keys(lookup(v, "iam", {})) : {
- # we cycle on keys here so we don't risk injecting dynamic values
+ # We cycle on keys here so we don't risk injecting dynamic values.
role = role
tag = k
tag_id = lookup(v, "id", null)
@@ -185,9 +197,10 @@ resource "google_tags_tag_key_iam_binding" "default" {
: each.value.tag_id
)
role = each.value.role
- members = coalesce(
- local.tags[each.value.tag]["iam"][each.value.role], []
- )
+ members = [
+ for v in local.tags[each.value.tag]["iam"][each.value.role] :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_key_iam_binding" "bindings" {
@@ -198,9 +211,10 @@ resource "google_tags_tag_key_iam_binding" "bindings" {
: each.value.tag_id
)
role = local.tags[each.value.tag]["iam_bindings"][each.value.binding].role
- members = (
- local.tags[each.value.tag]["iam_bindings"][each.value.binding].members
- )
+ members = [
+ for v in local.tags[each.value.tag]["iam_bindings"][each.value.binding].members :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_key_iam_member" "bindings" {
@@ -210,8 +224,12 @@ resource "google_tags_tag_key_iam_member" "bindings" {
? google_tags_tag_key.default[each.value.tag].id
: each.value.tag_id
)
- role = local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].role
- member = local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].member
+ role = local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].role
+ member = lookup(
+ var.factories_config.context.iam_principals,
+ local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].member,
+ local.tags[each.value.tag]["iam_bindings_additive"][each.value.binding].member
+ )
}
# values
@@ -235,10 +253,10 @@ resource "google_tags_tag_value_iam_binding" "default" {
: each.value.id
)
role = each.value.role
- members = coalesce(
- local.tags[each.value.tag]["values"][each.value.name]["iam"][each.value.role],
- []
- )
+ members = [
+ for v in local.tags[each.value.tag]["values"][each.value.name]["iam"][each.value.role] :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_value_iam_binding" "bindings" {
@@ -251,9 +269,10 @@ resource "google_tags_tag_value_iam_binding" "bindings" {
role = (
local.tags[each.value.tag]["values"][each.value.name]["iam_bindings"][each.value.binding].role
)
- members = (
- local.tags[each.value.tag]["values"][each.value.name]["iam_bindings"][each.value.binding].members
- )
+ members = [
+ for v in local.tags[each.value.tag]["values"][each.value.name]["iam_bindings"][each.value.binding].members :
+ lookup(var.factories_config.context.iam_principals, v, v)
+ ]
}
resource "google_tags_tag_value_iam_member" "bindings" {
@@ -266,7 +285,9 @@ resource "google_tags_tag_value_iam_member" "bindings" {
role = (
local.tags[each.value.tag]["values"][each.value.name]["iam_bindings_additive"][each.value.binding].role
)
- member = (
+ member = lookup(
+ var.factories_config.context.iam_principals,
+ local.tags[each.value.tag]["values"][each.value.name]["iam_bindings_additive"][each.value.binding].member,
local.tags[each.value.tag]["values"][each.value.name]["iam_bindings_additive"][each.value.binding].member
)
}
diff --git a/modules/project/variables.tf b/modules/project/variables.tf
index 61b35984f..223ceea1c 100644
--- a/modules/project/variables.tf
+++ b/modules/project/variables.tf
@@ -92,6 +92,7 @@ variable "factories_config" {
quotas = optional(string)
tags = optional(string)
context = optional(object({
+ iam_principals = optional(map(string), {})
notification_channels = optional(map(string), {})
org_policies = optional(map(map(string)), {})
tag_keys = optional(map(string), {})
diff --git a/tests/modules/organization/examples/tags-factory.yaml b/tests/modules/organization/examples/tags-factory.yaml
index e3f27a66c..9f9fa4690 100644
--- a/tests/modules/organization/examples/tags-factory.yaml
+++ b/tests/modules/organization/examples/tags-factory.yaml
@@ -25,18 +25,37 @@ values:
members:
- group:finance-team@example.com
role: roles/resourcemanager.tagViewer
+ module.org.google_tags_tag_key_iam_binding.default["foo:roles/resourcemanager.tagViewer"]:
+ condition: []
+ members:
+ - group:finance-team@example.com
+ - group:gcp-organization-admins@example.com
+ role: roles/resourcemanager.tagViewer
+ tag_key: tagKeys/1234567890
module.org.google_tags_tag_value.default["cost-center/engineering"]:
description: Engineering department.
short_name: engineering
timeouts: null
module.org.google_tags_tag_value.default["cost-center/marketing"]:
- description: "Marketing department."
+ description: Marketing department.
short_name: marketing
timeouts: null
+ module.org.google_tags_tag_value.default["foo/baz"]:
+ description: This tag value will be created.
+ parent: tagKeys/1234567890
+ short_name: baz
+ timeouts: null
+ module.org.google_tags_tag_value_iam_binding.default["foo/bar:roles/resourcemanager.tagViewer"]:
+ condition: []
+ members:
+ - group:gcp-organization-admins@example.com
+ role: roles/resourcemanager.tagViewer
+ tag_value: tagValues/789012345
counts:
google_tags_tag_key: 1
- google_tags_tag_key_iam_binding: 1
- google_tags_tag_value: 2
+ google_tags_tag_key_iam_binding: 2
+ google_tags_tag_value: 3
+ google_tags_tag_value_iam_binding: 1
modules: 1
- resources: 4
+ resources: 7