From 0759cf545487b6361a5e182c1ba03cca0e6d20f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Niesiob=C4=99dzki?= Date: Mon, 20 Oct 2025 11:22:14 +0000 Subject: [PATCH 1/2] fix Terraform version linter --- .github/workflows/linting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index f15ac7e90..ab892e7d8 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -114,7 +114,7 @@ jobs: egrep -v "(required_version|module_name)" default-versions.tofu > /tmp/versions.tofu diff -rub /tmp/versions.tf /tmp/versions.tofu DIFF_EC=$? - [[ "${DIFF_EC}" -eq "0" || -z "${OUTPUT_TF}" || -z "${OUTPUT_TOFU}" ]] + [[ "${DIFF_EC}" -eq "0" && -z "${OUTPUT_TF}" && -z "${OUTPUT_TOFU}" ]] - name: Check for diverging files id: duplicates From 48f6b4cd49733ae4b048f20ae72b4dffe1e5425d Mon Sep 17 00:00:00 2001 From: Julio Castillo Date: Mon, 20 Oct 2025 14:50:37 +0200 Subject: [PATCH 2/2] Add PAM support (#3438) * PAM first pass * Add factory and extend to organization * Extend to project, add examples * Add additionalProperties to all objects * Fix boilerplate * Expose pam_entitlements to project-factory * Fix readme * Move entitlements to second folder/project pass * extend tests * Fix readme * Remove timeouts from inventories --- modules/folder/README.md | 71 ++++++++- modules/folder/pam.tf | 142 ++++++++++++++++++ .../schemas/pam-entitlements.schema.json | 115 ++++++++++++++ .../folder/schemas/pam-entitlements.schema.md | 43 ++++++ modules/folder/service-agents.tf | 10 +- modules/folder/variables-pam.tf | 52 +++++++ modules/folder/variables.tf | 7 +- modules/organization/README.md | 79 +++++++++- modules/organization/pam.tf | 142 ++++++++++++++++++ .../schemas/pam-entitlements.schema.json | 115 ++++++++++++++ .../schemas/pam-entitlements.schema.md | 43 ++++++ modules/organization/service-agents.tf | 11 +- modules/organization/variables-pam.tf | 52 +++++++ modules/organization/variables.tf | 1 + modules/project-factory/README.md | 31 +++- modules/project-factory/folders.tf | 47 ++++-- modules/project-factory/projects.tf | 15 +- .../schemas/folder.schema.json | 122 ++++++++++++++- .../project-factory/schemas/folder.schema.md | 35 +++++ .../schemas/project.schema.json | 122 ++++++++++++++- .../project-factory/schemas/project.schema.md | 34 +++++ modules/project-factory/variables-folders.tf | 26 +++- modules/project-factory/variables-projects.tf | 26 +++- modules/project/README.md | 92 +++++++++--- modules/project/pam.tf | 141 +++++++++++++++++ .../schemas/pam-entitlements.schema.json | 115 ++++++++++++++ .../schemas/pam-entitlements.schema.md | 43 ++++++ modules/project/variables-pam.tf | 52 +++++++ modules/project/variables.tf | 1 + tests/modules/folder/context.tfvars | 17 +++ tests/modules/folder/context.yaml | 36 ++++- tests/modules/organization/context.tfvars | 17 +++ tests/modules/organization/context.yaml | 38 ++++- tests/modules/project/context.tfvars | 17 +++ tests/modules/project/context.yaml | 58 ++++++- .../project_factory/examples/example.yaml | 110 ++++++++------ 36 files changed, 1962 insertions(+), 116 deletions(-) create mode 100644 modules/folder/pam.tf create mode 100644 modules/folder/schemas/pam-entitlements.schema.json create mode 100644 modules/folder/schemas/pam-entitlements.schema.md create mode 100644 modules/folder/variables-pam.tf create mode 100644 modules/organization/pam.tf create mode 100644 modules/organization/schemas/pam-entitlements.schema.json create mode 100644 modules/organization/schemas/pam-entitlements.schema.md create mode 100644 modules/organization/variables-pam.tf create mode 100644 modules/project/pam.tf create mode 100644 modules/project/schemas/pam-entitlements.schema.json create mode 100644 modules/project/schemas/pam-entitlements.schema.md create mode 100644 modules/project/variables-pam.tf diff --git a/modules/folder/README.md b/modules/folder/README.md index a644e9af2..7a134aab3 100644 --- a/modules/folder/README.md +++ b/modules/folder/README.md @@ -6,6 +6,8 @@ This module allows the creation and management of folders, including support for - [Basic example with IAM bindings](#basic-example-with-iam-bindings) - [IAM](#iam) - [Assured Workload Folder](#assured-workload-folder) +- [Privileged Access Manager (PAM) Entitlements](#privileged-access-manager-pam-entitlements) + - [Privileged Access Manager (PAM) Entitlements Factory](#privileged-access-manager-pam-entitlements-factory) - [Organization policies](#organization-policies) - [Organization Policy Factory](#organization-policy-factory) - [Hierarchical Firewall Policy Attachments](#hierarchical-firewall-policy-attachments) @@ -89,6 +91,56 @@ module "folder" { # tftest modules=1 resources=3 inventory=assured-workload.yaml ``` +## Privileged Access Manager (PAM) Entitlements + +[Privileged Access Manager](https://cloud.google.com/iam/docs/privileged-access-manager-overview) entitlements can be defined via the `pam_entitlements` variable. + +Note that using PAM entitlements requires specific roles to be granted to the users and groups that will be using them. For more information, see the [official documentation](https://cloud.google.com/iam/docs/pam-permissions-and-setup#before-you-begin). + +Additionally, the Privileged Access Manager Service Agent must be created and granted the `roles/privilegedaccessmanager.folderServiceAgent` role. The service agent is not created automatically, and you can find the `gcloud` command to create it in the `service_agents` output of this module. For more information on service agents, see the [official documentation](https://cloud.google.com/iam/docs/service-agents). Refer to the [organization module's documentation](../organization/README.md#privileged-access-manager-pam-entitlements) for an example on how to grant the required role. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Networking" + deletion_protection = false + pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + eligible_users = ["group:gcp-network-admins@example.com"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" }, + ] + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["group:gcp-organization-admins@example.com"] + }] + } + } + } +} +``` + +### Privileged Access Manager (PAM) Entitlements Factory + +PAM entitlements can be loaded from a directory containing YAML files where each file defines one or more entitlements. The structure of the YAML files is exactly the same as the `pam_entitlements` variable. + +Note that entitlements defined via `pam_entitlements` take precedence over those in the factory. In other words, if you specify the same entitlement in a YAML file and in the `pam_entitlements` variable, the latter will take priority. + +```hcl +module "folder" { + source = "./fabric/modules/folder" + parent = var.folder_id + name = "Folder name" + factories_config = { + pam_entitlements = "configs/pam-entitlements/" + } +} +``` + ## Organization policies To manage organization policies, the `orgpolicy.googleapis.com` service should be enabled in the quota project. @@ -472,11 +524,13 @@ module "folder" { | [main.tf](./main.tf) | Module-level locals and resources. | google_assured_workloads_workload · google_compute_firewall_policy_association · google_essential_contacts_contact · google_folder | | [organization-policies.tf](./organization-policies.tf) | Folder-level organization policies. | google_org_policy_policy | | [outputs.tf](./outputs.tf) | Module outputs. | | +| [pam.tf](./pam.tf) | None | google_privileged_access_manager_entitlement | | [scc-sha-custom-modules.tf](./scc-sha-custom-modules.tf) | Folder-level Custom modules with Security Health Analytics. | google_scc_management_folder_security_health_analytics_custom_module | | [service-agents.tf](./service-agents.tf) | Service agents supporting resources. | | | [tags.tf](./tags.tf) | None | google_tags_tag_binding | | [variables-iam.tf](./variables-iam.tf) | None | | | [variables-logging.tf](./variables-logging.tf) | None | | +| [variables-pam.tf](./variables-pam.tf) | None | | | [variables-scc.tf](./variables-scc.tf) | None | | | [variables.tf](./variables.tf) | Module variables. | | | [versions.tf](./versions.tf) | Version pins. | | @@ -489,24 +543,25 @@ module "folder" { | [contacts](variables.tf#L70) | 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)) | | {} | | [context](variables.tf#L78) | Context-specific interpolations. | object({…}) | | {} | | [deletion_protection](variables.tf#L91) | Deletion protection setting for this folder. | bool | | false | -| [factories_config](variables.tf#L97) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | -| [firewall_policy](variables.tf#L107) | Hierarchical firewall policy to associate to this folder. | object({…}) | | null | -| [folder_create](variables.tf#L116) | Create folder. When set to false, uses id to reference an existing folder. | bool | | true | +| [factories_config](variables.tf#L97) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | +| [firewall_policy](variables.tf#L108) | Hierarchical firewall policy to associate to this folder. | object({…}) | | null | +| [folder_create](variables.tf#L117) | Create folder. When set to false, uses id to reference an existing folder. | bool | | true | | [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({…})) | | {} | | [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)) | | {} | -| [id](variables.tf#L122) | Folder ID in case you use folder_create=false. | string | | null | +| [id](variables.tf#L127) | Folder ID in case you use folder_create=false. | string | | null | | [logging_data_access](variables-logging.tf#L17) | Control activation of data access logs. The special 'allServices' key denotes configuration for all services. | map(object({…})) | | {} | | [logging_exclusions](variables-logging.tf#L28) | Logging exclusions for this folder in the form {NAME -> FILTER}. | map(string) | | {} | | [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 folder. | map(object({…})) | | {} | -| [name](variables.tf#L128) | Folder name. | string | | null | -| [org_policies](variables.tf#L134) | Organization policies applied to this folder keyed by policy name. | map(object({…})) | | {} | -| [parent](variables.tf#L162) | Parent in folders/folder_id or organizations/org_id format. | string | | null | +| [name](variables.tf#L133) | Folder name. | string | | null | +| [org_policies](variables.tf#L139) | Organization policies applied to this folder keyed by policy name. | map(object({…})) | | {} | +| [pam_entitlements](variables-pam.tf#L17) | Privileged Access Manager entitlements for this resource, keyed by entitlement ID. | map(object({…})) | | {} | +| [parent](variables.tf#L167) | Parent in folders/folder_id or organizations/org_id format. | string | | null | | [scc_sha_custom_modules](variables-scc.tf#L17) | SCC custom modules keyed by module name. | map(object({…})) | | {} | -| [tag_bindings](variables.tf#L176) | Tag bindings for this folder, in key => tag value id format. | map(string) | | null | +| [tag_bindings](variables.tf#L181) | Tag bindings for this folder, in key => tag value id format. | map(string) | | null | ## Outputs diff --git a/modules/folder/pam.tf b/modules/folder/pam.tf new file mode 100644 index 000000000..0051f19f6 --- /dev/null +++ b/modules/folder/pam.tf @@ -0,0 +1,142 @@ +/** + * 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. + */ + +locals { + _pam_entitlements_factory_path = pathexpand(coalesce(var.factories_config.pam_entitlements, "-")) + _pam_entitlements_factory_data_raw = merge([ + for f in try(fileset(local._pam_entitlements_factory_path, "*.yaml"), []) : + yamldecode(templatefile("${local._pam_entitlements_factory_path}/${f}", var.context)) + ]...) + # simulate applying defaults to data coming from yaml files + _pam_entitlements_factory_data = { + for k, v in local._pam_entitlements_factory_data_raw : k => { + max_request_duration = v.max_request_duration + eligible_users = v.eligible_users + privileged_access = [ + for pa in v.privileged_access : { + role = pa.role + condition = try(pa.condition, null) + } + ] + requester_justification_config = can(v.requester_justification_config) ? { + not_mandatory = try(v.requester_justification_config.not_mandatory, true) + unstructured = try(v.requester_justification_config.unstructured, false) + } : { + not_mandatory = false + unstructured = true + } + manual_approvals = can(v.manual_approvals) ? { + require_approver_justification = v.manual_approvals.require_approver_justification + steps = [ + for s in v.manual_approvals.steps : { + approvers = s.approvers + approvals_needed = try(s.approvals_needed, 1) + aprover_email_recipients = try(s.aprover_email_recipients, null) + } + ] + } : null + additional_notification_targets = can(v.additional_notification_targets) ? { + admin_email_recipients = try(v.additional_notification_targets.admin_email_recipients, null) + requester_email_recipients = try(v.additional_notification_targets.requester_email_recipients, null) + } : null + } + } + pam_entitlements = merge( + local._pam_entitlements_factory_data, + var.pam_entitlements + ) +} + +resource "google_privileged_access_manager_entitlement" "default" { + for_each = local.pam_entitlements + + parent = local.folder_id + location = "global" + entitlement_id = each.key + max_request_duration = each.value.max_request_duration + + eligible_users { + principals = [ + for u in each.value.eligible_users : lookup(local.ctx.iam_principals, u, u) + ] + } + + privileged_access { + gcp_iam_access { + resource_type = "cloudresourcemanager.googleapis.com/Folder" + resource = "//cloudresourcemanager.googleapis.com/${local.folder_id}" + dynamic "role_bindings" { + for_each = each.value.privileged_access + iterator = binding + content { + role = lookup(local.ctx.custom_roles, binding.value.role, binding.value.role) + condition_expression = binding.value.condition == null ? null : templatestring( + binding.value.condition, var.context.condition_vars + ) + } + } + } + } + + requester_justification_config { + dynamic "not_mandatory" { + for_each = each.value.requester_justification_config.not_mandatory ? [""] : [] + content {} + } + dynamic "unstructured" { + for_each = each.value.requester_justification_config.unstructured ? [""] : [] + content {} + } + } + + dynamic "approval_workflow" { + for_each = each.value.manual_approvals == null ? [] : [""] + content { + manual_approvals { + require_approver_justification = each.value.manual_approvals.require_approver_justification + dynamic "steps" { + for_each = each.value.manual_approvals.steps + iterator = step + content { + approvers { + principals = [ + for a in step.value.approvers : lookup(local.ctx.iam_principals, a, a) + ] + } + + approvals_needed = step.value.approvals_needed + approver_email_recipients = step.value.aprover_email_recipients + } + } + } + } + } + + dynamic "additional_notification_targets" { + for_each = each.value.additional_notification_targets == null ? [] : [""] + content { + admin_email_recipients = each.value.additional_notification_targets.admin_email_recipients + requester_email_recipients = each.value.additional_notification_targets.requester_email_recipients + } + } + + depends_on = [ + google_folder.folder, + google_folder_iam_binding.authoritative, + google_folder_iam_binding.bindings, + google_folder_iam_member.bindings + ] +} diff --git a/modules/folder/schemas/pam-entitlements.schema.json b/modules/folder/schemas/pam-entitlements.schema.json new file mode 100644 index 000000000..a2ea3fe62 --- /dev/null +++ b/modules/folder/schemas/pam-entitlements.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]{0,61}[a-z0-9]$": { + "type": "object", + "properties": { + "max_request_duration": { + "type": "string" + }, + "eligible_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "privileged_access": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "role" + ], + "additionalProperties": false + } + }, + "requester_justification_config": { + "type": "object", + "properties": { + "not_mandatory": { + "type": "boolean" + }, + "unstructured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "manual_approvals": { + "type": "object", + "properties": { + "require_approver_justification": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "approvers": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvals_needed": { + "type": "number" + }, + "approver_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "approvers" + ], + "additionalProperties": false + } + } + }, + "required": [ + "require_approver_justification", + "steps" + ], + "additionalProperties": false + }, + "additional_notification_targets": { + "type": "object", + "properties": { + "admin_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "requester_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "max_request_duration", + "eligible_users", + "privileged_access" + ], + "additionalProperties": false + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/modules/folder/schemas/pam-entitlements.schema.md b/modules/folder/schemas/pam-entitlements.schema.md new file mode 100644 index 000000000..f0805eb29 --- /dev/null +++ b/modules/folder/schemas/pam-entitlements.schema.md @@ -0,0 +1,43 @@ +# None + + + +## Properties + +*additional properties: false* + +- **`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`**: *object* +
*additional properties: false* + - ⁺**max_request_duration**: *string* + - ⁺**eligible_users**: *array* + - items: *string* + - ⁺**privileged_access**: *array* + - items: *object* +
*additional properties: false* + - ⁺**role**: *string* + - **condition**: *string* + - **requester_justification_config**: *object* +
*additional properties: false* + - **not_mandatory**: *boolean* + - **unstructured**: *boolean* + - **manual_approvals**: *object* +
*additional properties: false* + - ⁺**require_approver_justification**: *boolean* + - ⁺**steps**: *array* + - items: *object* +
*additional properties: false* + - ⁺**approvers**: *array* + - items: *string* + - **approvals_needed**: *number* + - **approver_email_recipients**: *array* + - items: *string* + - **additional_notification_targets**: *object* +
*additional properties: false* + - **admin_email_recipients**: *array* + - items: *string* + - **requester_email_recipients**: *array* + - items: *string* + +## Definitions + + diff --git a/modules/folder/service-agents.tf b/modules/folder/service-agents.tf index a26207e50..90b4a2c8a 100644 --- a/modules/folder/service-agents.tf +++ b/modules/folder/service-agents.tf @@ -18,16 +18,22 @@ locals { _sa_raw = yamldecode(file("${path.module}/service-agents.yaml")) - service_agents = { + _sa0 = { for agent in local._sa_raw : agent.name => { create_command = ( "gcloud beta services identity create --service=${agent.api} --folder=${local.folder_number}" ) display_name = agent.display_name - identity = templatestring(agent.identity, { + email = templatestring(agent.identity, { folder_number = local.folder_number }) } } + service_agents = { + for k, v in local._sa0 : + k => merge(v, { + iam_email = "serviceAccount:${v.email}" + }) + } } diff --git a/modules/folder/variables-pam.tf b/modules/folder/variables-pam.tf new file mode 100644 index 000000000..79ba6a8b9 --- /dev/null +++ b/modules/folder/variables-pam.tf @@ -0,0 +1,52 @@ +/** + * 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. + */ + +variable "pam_entitlements" { + description = "Privileged Access Manager entitlements for this resource, keyed by entitlement ID." + type = map(object({ + max_request_duration = string + eligible_users = list(string) + privileged_access = list(object({ + role = string + condition = optional(string) + })) + requester_justification_config = optional(object({ + not_mandatory = optional(bool, true) + unstructured = optional(bool, false) + }), { not_mandatory = false, unstructured = true }) + manual_approvals = optional(object({ + require_approver_justification = bool + steps = list(object({ + approvers = list(string) + approvals_needed = optional(number, 1) + aprover_email_recipients = optional(list(string)) + })) + })) + additional_notification_targets = optional(object({ + admin_email_recipients = optional(list(string)) + requester_email_recipients = optional(list(string)) + })) + })) + default = {} + nullable = false + validation { + condition = alltrue([ + for v in values(var.pam_entitlements) : + !v.requester_justification_config.not_mandatory || !v.requester_justification_config.unstructured + ]) + error_message = "Only one of 'not_mandatory' or 'unstructured' can be enabled in 'requester_justification_config'." + } +} diff --git a/modules/folder/variables.tf b/modules/folder/variables.tf index b6b675a1b..7cfc941d8 100644 --- a/modules/folder/variables.tf +++ b/modules/folder/variables.tf @@ -98,6 +98,7 @@ variable "factories_config" { description = "Paths to data files and folders that enable factory functionality." type = object({ org_policies = optional(string) + pam_entitlements = optional(string) scc_sha_custom_modules = optional(string) }) nullable = false @@ -117,6 +118,10 @@ variable "folder_create" { description = "Create folder. When set to false, uses id to reference an existing folder." type = bool default = true + validation { + condition = var.folder_create || var.id != null + error_message = "Variable `id` cannot be null when `folder_create` is false." + } } variable "id" { @@ -177,4 +182,4 @@ variable "tag_bindings" { description = "Tag bindings for this folder, in key => tag value id format." type = map(string) default = null -} \ No newline at end of file +} diff --git a/modules/organization/README.md b/modules/organization/README.md index 8adb06f4d..26d4a3819 100644 --- a/modules/organization/README.md +++ b/modules/organization/README.md @@ -21,6 +21,8 @@ To manage organization policies, the `orgpolicy.googleapis.com` service should b - [Organization Policy Factory](#organization-policy-factory) - [Organization Policy Custom Constraints](#organization-policy-custom-constraints) - [Organization Policy Custom Constraints Factory](#organization-policy-custom-constraints-factory) +- [Privileged Access Manager (PAM) Entitlements](#privileged-access-manager-pam-entitlements) + - [Privileged Access Manager (PAM) Entitlements Factory](#privileged-access-manager-pam-entitlements-factory) - [Hierarchical Firewall Policy Attachments](#hierarchical-firewall-policy-attachments) - [Log Sinks](#log-sinks) - [Data Access Logs](#data-access-logs) @@ -231,6 +233,70 @@ custom.dataprocNoMoreThan10Workers: # tftest-file id=dataproc path=configs/custom-constraints/dataproc.yaml ``` +## Privileged Access Manager (PAM) Entitlements + +[Privileged Access Manager](https://cloud.google.com/iam/docs/privileged-access-manager-overview) entitlements can be defined via the `pam_entitlements` variable. + +Note that using PAM entitlements requires specific roles to be granted to the users and groups that will be using them. For more information, see the [official documentation](https://cloud.google.com/iam/docs/pam-permissions-and-setup#before-you-begin). + +Additionally, the Privileged Access Manager Service Agent must be created and granted the `roles/privilegedaccessmanager.organizationServiceAgent` role. The service agent is not created automatically, and you can find the `gcloud` command to create it in the `service_agents` output of this module. For more information on service agents, see the [official documentation](https://cloud.google.com/iam/docs/service-agents). + +The following example shows how to grant the required role to the PAM service agent: + +```hcl +module "organization" { + source = "./fabric/modules/organization" + organization_id = var.org_id + factories_config = { + pam_entitlements = "factory/" + } + iam = { + "roles/privilegedaccessmanager.serviceAgent" = [ + module.organization.service_agents.pam.iam_email + ] + } +} +``` + +```hcl +module "org" { + source = "./fabric/modules/organization" + organization_id = var.organization_id + pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["group:gcp-organization-admins@example.com"] + }] + } + eligible_users = ["group:gcp-network-admins@example.com"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" } + ] + } + } +} +``` + +### Privileged Access Manager (PAM) Entitlements Factory + +PAM entitlements can be loaded from a directory containing YAML files where each file defines one or more entitlements. The structure of the YAML files is exactly the same as the `pam_entitlements` variable. + +Note that entitlements defined via `pam_entitlements` take precedence over those in the factory. In other words, if you specify the same entitlement in a YAML file and in the `pam_entitlements` variable, the latter will take priority. + +```hcl +module "org" { + source = "./fabric/modules/organization" + organization_id = var.organization_id + factories_config = { + pam_entitlements = "configs/pam-entitlements/" + } +} +``` + ## Hierarchical Firewall Policy Attachments Hierarchical firewall policies can be managed via the [`net-firewall-policy`](../net-firewall-policy/) module, including support for factories. Once a policy is available, attaching it to the organization can be done either in the firewall policy module itself, or here: @@ -653,11 +719,13 @@ values: | [org-policy-custom-constraints.tf](./org-policy-custom-constraints.tf) | None | google_org_policy_custom_constraint | | [organization-policies.tf](./organization-policies.tf) | Organization-level organization policies. | google_org_policy_policy | | [outputs.tf](./outputs.tf) | Module outputs. | | +| [pam.tf](./pam.tf) | None | google_privileged_access_manager_entitlement | | [scc-sha-custom-modules.tf](./scc-sha-custom-modules.tf) | Organization-level Custom modules with Security Health Analytics. | google_scc_management_organization_security_health_analytics_custom_module | | [service-agents.tf](./service-agents.tf) | Service agents supporting resources. | | | [tags.tf](./tags.tf) | Manages GCP Secure Tags, keys, values, and IAM. | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_key_iam_member · google_tags_tag_value · google_tags_tag_value_iam_binding · google_tags_tag_value_iam_member | | [variables-iam.tf](./variables-iam.tf) | None | | | [variables-logging.tf](./variables-logging.tf) | None | | +| [variables-pam.tf](./variables-pam.tf) | None | | | [variables-scc.tf](./variables-scc.tf) | None | | | [variables-tags.tf](./variables-tags.tf) | None | | | [variables.tf](./variables.tf) | Module variables. | | @@ -667,12 +735,12 @@ values: | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [organization_id](variables.tf#L114) | Organization id in organizations/nnnnnn format. | string | ✓ | | +| [organization_id](variables.tf#L115) | 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)) | | {} | | [context](variables.tf#L24) | Context-specific interpolations. | object({…}) | | {} | | [custom_roles](variables.tf#L43) | Map of role name => list of permissions to create in this project. | map(list(string)) | | {} | -| [factories_config](variables.tf#L50) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | -| [firewall_policy](variables.tf#L63) | Hierarchical firewall policies to associate to the organization. | object({…}) | | null | +| [factories_config](variables.tf#L50) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | +| [firewall_policy](variables.tf#L64) | Hierarchical firewall policies to associate to the organization. | object({…}) | | null | | [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({…})) | | {} | @@ -683,8 +751,9 @@ 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#L72) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} | -| [org_policy_custom_constraints](variables.tf#L100) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} | +| [org_policies](variables.tf#L73) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} | +| [org_policy_custom_constraints](variables.tf#L101) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} | +| [pam_entitlements](variables-pam.tf#L17) | Privileged Access Manager entitlements for this resource, keyed by entitlement ID. | map(object({…})) | | {} | | [scc_sha_custom_modules](variables-scc.tf#L17) | SCC custom modules keyed by module name. | map(object({…})) | | {} | | [tag_bindings](variables-tags.tf#L82) | Tag bindings for this organization, in key => tag value id format. | map(string) | | {} | | [tags](variables-tags.tf#L89) | 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/pam.tf b/modules/organization/pam.tf new file mode 100644 index 000000000..bc7bd6a05 --- /dev/null +++ b/modules/organization/pam.tf @@ -0,0 +1,142 @@ +/** + * 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. + */ + +locals { + _pam_entitlements_factory_path = pathexpand(coalesce(var.factories_config.pam_entitlements, "-")) + _pam_entitlements_factory_data_raw = merge([ + for f in try(fileset(local._pam_entitlements_factory_path, "*.yaml"), []) : + yamldecode(templatefile("${local._pam_entitlements_factory_path}/${f}", var.context)) + ]...) + # simulate applying defaults to data coming from yaml files + _pam_entitlements_factory_data = { + for k, v in local._pam_entitlements_factory_data_raw : k => { + max_request_duration = v.max_request_duration + eligible_users = v.eligible_users + privileged_access = [ + for pa in v.privileged_access : { + role = pa.role + condition = try(pa.condition, null) + } + ] + requester_justification_config = can(v.requester_justification_config) ? { + not_mandatory = try(v.requester_justification_config.not_mandatory, true) + unstructured = try(v.requester_justification_config.unstructured, false) + } : { + not_mandatory = false + unstructured = true + } + manual_approvals = can(v.manual_approvals) ? { + require_approver_justification = v.manual_approvals.require_approver_justification + steps = [ + for s in v.manual_approvals.steps : { + approvers = s.approvers + approvals_needed = try(s.approvals_needed, 1) + aprover_email_recipients = try(s.aprover_email_recipients, null) + } + ] + } : null + additional_notification_targets = can(v.additional_notification_targets) ? { + admin_email_recipients = try(v.additional_notification_targets.admin_email_recipients, null) + requester_email_recipients = try(v.additional_notification_targets.requester_email_recipients, null) + } : null + } + } + pam_entitlements = merge( + local._pam_entitlements_factory_data, + var.pam_entitlements + ) +} + +resource "google_privileged_access_manager_entitlement" "default" { + for_each = local.pam_entitlements + + parent = var.organization_id + location = "global" + entitlement_id = each.key + max_request_duration = each.value.max_request_duration + + eligible_users { + principals = [ + for u in each.value.eligible_users : lookup(local.ctx.iam_principals, u, u) + ] + } + + privileged_access { + gcp_iam_access { + resource_type = "cloudresourcemanager.googleapis.com/Organization" + resource = "//cloudresourcemanager.googleapis.com/${var.organization_id}" + dynamic "role_bindings" { + for_each = each.value.privileged_access + iterator = binding + content { + role = lookup(local.ctx.custom_roles, binding.value.role, binding.value.role) + condition_expression = binding.value.condition == null ? null : templatestring( + binding.value.condition, var.context.condition_vars + ) + } + } + } + } + + requester_justification_config { + dynamic "not_mandatory" { + for_each = each.value.requester_justification_config.not_mandatory ? [""] : [] + content {} + } + dynamic "unstructured" { + for_each = each.value.requester_justification_config.unstructured ? [""] : [] + content {} + } + } + + dynamic "approval_workflow" { + for_each = each.value.manual_approvals == null ? [] : [""] + content { + manual_approvals { + require_approver_justification = each.value.manual_approvals.require_approver_justification + dynamic "steps" { + for_each = each.value.manual_approvals.steps + iterator = step + content { + approvers { + principals = [ + for a in step.value.approvers : lookup(local.ctx.iam_principals, a, a) + ] + } + + approvals_needed = step.value.approvals_needed + approver_email_recipients = step.value.aprover_email_recipients + } + } + } + } + } + + dynamic "additional_notification_targets" { + for_each = each.value.additional_notification_targets == null ? [] : [""] + content { + admin_email_recipients = each.value.additional_notification_targets.admin_email_recipients + requester_email_recipients = each.value.additional_notification_targets.requester_email_recipients + } + } + + depends_on = [ + # in case we're granting the service agent permissions in the same module call + google_organization_iam_binding.authoritative, + google_organization_iam_binding.bindings, + google_organization_iam_member.bindings + ] +} diff --git a/modules/organization/schemas/pam-entitlements.schema.json b/modules/organization/schemas/pam-entitlements.schema.json new file mode 100644 index 000000000..a2ea3fe62 --- /dev/null +++ b/modules/organization/schemas/pam-entitlements.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]{0,61}[a-z0-9]$": { + "type": "object", + "properties": { + "max_request_duration": { + "type": "string" + }, + "eligible_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "privileged_access": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "role" + ], + "additionalProperties": false + } + }, + "requester_justification_config": { + "type": "object", + "properties": { + "not_mandatory": { + "type": "boolean" + }, + "unstructured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "manual_approvals": { + "type": "object", + "properties": { + "require_approver_justification": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "approvers": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvals_needed": { + "type": "number" + }, + "approver_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "approvers" + ], + "additionalProperties": false + } + } + }, + "required": [ + "require_approver_justification", + "steps" + ], + "additionalProperties": false + }, + "additional_notification_targets": { + "type": "object", + "properties": { + "admin_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "requester_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "max_request_duration", + "eligible_users", + "privileged_access" + ], + "additionalProperties": false + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/modules/organization/schemas/pam-entitlements.schema.md b/modules/organization/schemas/pam-entitlements.schema.md new file mode 100644 index 000000000..f0805eb29 --- /dev/null +++ b/modules/organization/schemas/pam-entitlements.schema.md @@ -0,0 +1,43 @@ +# None + + + +## Properties + +*additional properties: false* + +- **`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`**: *object* +
*additional properties: false* + - ⁺**max_request_duration**: *string* + - ⁺**eligible_users**: *array* + - items: *string* + - ⁺**privileged_access**: *array* + - items: *object* +
*additional properties: false* + - ⁺**role**: *string* + - **condition**: *string* + - **requester_justification_config**: *object* +
*additional properties: false* + - **not_mandatory**: *boolean* + - **unstructured**: *boolean* + - **manual_approvals**: *object* +
*additional properties: false* + - ⁺**require_approver_justification**: *boolean* + - ⁺**steps**: *array* + - items: *object* +
*additional properties: false* + - ⁺**approvers**: *array* + - items: *string* + - **approvals_needed**: *number* + - **approver_email_recipients**: *array* + - items: *string* + - **additional_notification_targets**: *object* +
*additional properties: false* + - **admin_email_recipients**: *array* + - items: *string* + - **requester_email_recipients**: *array* + - items: *string* + +## Definitions + + diff --git a/modules/organization/service-agents.tf b/modules/organization/service-agents.tf index eca1c65df..1a53db036 100644 --- a/modules/organization/service-agents.tf +++ b/modules/organization/service-agents.tf @@ -18,16 +18,23 @@ locals { _sa_raw = yamldecode(file("${path.module}/service-agents.yaml")) - service_agents = { + _sa0 = { for agent in local._sa_raw : agent.name => { create_command = ( "gcloud beta services identity create --service=${agent.api} --organization=${local.organization_id_numeric}" ) display_name = agent.display_name - identity = templatestring(agent.identity, { + email = templatestring(agent.identity, { organization_number = local.organization_id_numeric }) + } } + service_agents = { + for k, v in local._sa0 : + k => merge(v, { + iam_email = "serviceAccount:${v.email}" + }) + } } diff --git a/modules/organization/variables-pam.tf b/modules/organization/variables-pam.tf new file mode 100644 index 000000000..79ba6a8b9 --- /dev/null +++ b/modules/organization/variables-pam.tf @@ -0,0 +1,52 @@ +/** + * 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. + */ + +variable "pam_entitlements" { + description = "Privileged Access Manager entitlements for this resource, keyed by entitlement ID." + type = map(object({ + max_request_duration = string + eligible_users = list(string) + privileged_access = list(object({ + role = string + condition = optional(string) + })) + requester_justification_config = optional(object({ + not_mandatory = optional(bool, true) + unstructured = optional(bool, false) + }), { not_mandatory = false, unstructured = true }) + manual_approvals = optional(object({ + require_approver_justification = bool + steps = list(object({ + approvers = list(string) + approvals_needed = optional(number, 1) + aprover_email_recipients = optional(list(string)) + })) + })) + additional_notification_targets = optional(object({ + admin_email_recipients = optional(list(string)) + requester_email_recipients = optional(list(string)) + })) + })) + default = {} + nullable = false + validation { + condition = alltrue([ + for v in values(var.pam_entitlements) : + !v.requester_justification_config.not_mandatory || !v.requester_justification_config.unstructured + ]) + error_message = "Only one of 'not_mandatory' or 'unstructured' can be enabled in 'requester_justification_config'." + } +} diff --git a/modules/organization/variables.tf b/modules/organization/variables.tf index c10b2e59f..0c9f13fca 100644 --- a/modules/organization/variables.tf +++ b/modules/organization/variables.tf @@ -53,6 +53,7 @@ variable "factories_config" { custom_roles = optional(string) org_policies = optional(string) org_policy_custom_constraints = optional(string) + pam_entitlements = optional(string) scc_sha_custom_modules = optional(string) tags = optional(string) }) diff --git a/modules/project-factory/README.md b/modules/project-factory/README.md index 82e06e70f..711c327a5 100644 --- a/modules/project-factory/README.md +++ b/modules/project-factory/README.md @@ -497,6 +497,20 @@ parent: folders/5678901234 name: App 0 factories_config: org_policies: data/factories/org-policies + +pam_entitlements: + app-0-admins: + max_request_duration: 3600s + manual_approvals: + require_approver_justification: true + steps: + - approvers: + - group:app-0-admins@example.org + eligible_users: + - group:app-a-ops@example.org + privileged_access: + - role: roles/writer + # tftest-file id=3 path=data/hierarchy/team-a/app-0/.config.yaml schema=folder.schema.json ``` @@ -564,6 +578,19 @@ shared_vpc_service_config: - $service_agents:container-engine billing_budgets: - $billing_budgets:test-100 +pam_entitlements: + project-admins: + max_request_duration: 3600s + manual_approvals: + require_approver_justification: true + steps: + - approvers: + - group:team-a-admins@example + eligible_users: + - group:team-a-ops@example.org + privileged_access: + - role: roles/compute.admin + - role: roles/bigquery.admin tags: my-tag-key-1: values: @@ -711,9 +738,9 @@ compute.disableSerialPortAccess: | [data_defaults](variables.tf#L36) | Optional default values used when corresponding project or folder data from files are missing. | object({…}) | | {} | | [data_merges](variables.tf#L103) | Optional values that will be merged with corresponding data from files. Combines with `data_defaults`, file data, and `data_overrides`. | object({…}) | | {} | | [data_overrides](variables.tf#L122) | Optional values that override corresponding data from files. Takes precedence over file data and `data_defaults`. | object({…}) | | {} | -| [folders](variables-folders.tf#L17) | Folders data merged with factory data. | map(object({…})) | | {} | +| [folders](variables-folders.tf#L17) | Folders data merged with factory data. | map(object({…})) | | {} | | [notification_channels](variables-billing.tf#L17) | Notification channels used by budget alerts. | map(object({…})) | | {} | -| [projects](variables-projects.tf#L17) | Projects data merged with factory data. | map(object({…})) | | {} | +| [projects](variables-projects.tf#L17) | Projects data merged with factory data. | map(object({…})) | | {} | ## Outputs diff --git a/modules/project-factory/folders.tf b/modules/project-factory/folders.tf index 8ec80f424..163f1107f 100644 --- a/modules/project-factory/folders.tf +++ b/modules/project-factory/folders.tf @@ -1,5 +1,5 @@ /** - * Copyright 2024 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. @@ -56,10 +56,14 @@ module "folder-1" { for_each = { for k, v in local.folders_input : k => v if v.level == 1 } - parent = coalesce(each.value.parent, "$folder_ids:default") - name = each.value.name - factories_config = lookup(each.value, "factories_config", {}) + parent = coalesce(each.value.parent, "$folder_ids:default") + name = each.value.name + factories_config = { + org_policies = try(each.value.factories_config.org_policies, null) + scc_sha_custom_modules = try(each.value.factories_config.scc_sha_custom_modules, null) + } org_policies = lookup(each.value, "org_policies", {}) + pam_entitlements = lookup(each.value, "pam_entitlements", {}) tag_bindings = lookup(each.value, "tag_bindings", {}) logging_data_access = lookup(each.value, "logging_data_access", {}) context = local.ctx @@ -70,7 +74,11 @@ module "folder-1-iam" { for_each = { for k, v in local.folders_input : k => v if v.level == 1 } - id = module.folder-1[each.key].id + id = module.folder-1[each.key].id + factories_config = { + # we do anything that can refer to IAM and custom roles in this call + pam_entitlements = try(each.value.factories_config.pam_entitlements, null) + } folder_create = false iam = lookup(each.value, "iam", {}) iam_bindings = lookup(each.value, "iam_bindings", {}) @@ -89,9 +97,13 @@ module "folder-2" { parent = coalesce( each.value.parent, "$folder_ids:${each.value.parent_key}" ) - name = each.value.name - factories_config = lookup(each.value, "factories_config", {}) + name = each.value.name + factories_config = { + org_policies = try(each.value.factories_config.org_policies, null) + scc_sha_custom_modules = try(each.value.factories_config.scc_sha_custom_modules, null) + } org_policies = lookup(each.value, "org_policies", {}) + pam_entitlements = lookup(each.value, "pam_entitlements", {}) tag_bindings = lookup(each.value, "tag_bindings", {}) logging_data_access = lookup(each.value, "logging_data_access", {}) context = merge(local.ctx, { @@ -107,7 +119,11 @@ module "folder-2-iam" { for_each = { for k, v in local.folders_input : k => v if v.level == 2 } - id = module.folder-2[each.key].id + id = module.folder-2[each.key].id + factories_config = { + # we do anything that can refer to IAM and custom roles in this call + pam_entitlements = try(each.value.factories_config.pam_entitlements, null) + } folder_create = false iam = lookup(each.value, "iam", {}) iam_bindings = lookup(each.value, "iam_bindings", {}) @@ -129,9 +145,13 @@ module "folder-3" { parent = coalesce( each.value.parent, "$folder_ids:${each.value.parent_key}" ) - name = each.value.name - factories_config = lookup(each.value, "factories_config", {}) + name = each.value.name + factories_config = { + org_policies = try(each.value.factories_config.org_policies, null) + scc_sha_custom_modules = try(each.value.factories_config.scc_sha_custom_modules, null) + } org_policies = lookup(each.value, "org_policies", {}) + pam_entitlements = lookup(each.value, "pam_entitlements", {}) tag_bindings = lookup(each.value, "tag_bindings", {}) logging_data_access = lookup(each.value, "logging_data_access", {}) context = merge(local.ctx, { @@ -147,7 +167,11 @@ module "folder-3-iam" { for_each = { for k, v in local.folders_input : k => v if v.level == 3 } - id = module.folder-3[each.key].id + id = module.folder-3[each.key].id + factories_config = { + # we do anything that can refer to IAM and custom roles in this call + pam_entitlements = try(each.value.factories_config.pam_entitlements, null) + } folder_create = false iam = lookup(each.value, "iam", {}) iam_bindings = lookup(each.value, "iam_bindings", {}) @@ -160,4 +184,3 @@ module "folder-3-iam" { iam_principals = local.ctx_iam_principals }) } - diff --git a/modules/project-factory/projects.tf b/modules/project-factory/projects.tf index c9779df0c..e9a886b8f 100644 --- a/modules/project-factory/projects.tf +++ b/modules/project-factory/projects.tf @@ -73,7 +73,13 @@ module "projects" { }) default_service_account = try(each.value.default_service_account, "keep") descriptive_name = try(each.value.descriptive_name, null) - factories_config = { for k, v in each.value.factories_config : k => v if k != "observability" } + factories_config = { + custom_roles = try(each.value.factories_config.custom_roles, null) + org_policies = try(each.value.factories_config.org_policies, null) + quotas = try(each.value.factories_config.quotas, null) + scc_sha_custom_modules = try(each.value.factories_config.scc_sha_custom_modules, null) + tags = try(each.value.factories_config.tags, null) + } labels = merge( each.value.labels, var.data_merges.labels ) @@ -118,11 +124,16 @@ module "projects-iam" { kms_keys = local.ctx.kms_keys iam_principals = local.ctx_iam_principals }) - factories_config = { for k, v in each.value.factories_config : k => v if k == "observability" } + factories_config = { + # we do anything that can refer to IAM and custom roles in this call + observability = try(each.value.factories_config.observability, null) + pam_entitlements = try(each.value.factories_config.pam_entitlements, null) + } iam = lookup(each.value, "iam", {}) iam_bindings = lookup(each.value, "iam_bindings", {}) iam_bindings_additive = lookup(each.value, "iam_bindings_additive", {}) iam_by_principals = lookup(each.value, "iam_by_principals", {}) + pam_entitlements = try(each.value.pam_entitlements, {}) service_agents_config = { create_primary_agents = false grant_default_roles = false diff --git a/modules/project-factory/schemas/folder.schema.json b/modules/project-factory/schemas/folder.schema.json index fd266920f..778dc0572 100644 --- a/modules/project-factory/schemas/folder.schema.json +++ b/modules/project-factory/schemas/folder.schema.json @@ -71,6 +71,9 @@ "org_policies": { "type": "string" }, + "pam_entitlements": { + "type": "string" + }, "scc_sha_custom_modules": { "type": "string" } @@ -168,6 +171,9 @@ } } }, + "pam_entitlements": { + "$ref": "#/$defs/pam_entitlements" + }, "parent": { "type": "string", "pattern": "^(?:folders/[0-9]+|organizations/[0-9]+|\\$folder_ids:[a-z0-9_-]+)$" @@ -430,6 +436,120 @@ } } } + }, + "pam_entitlements": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z][a-z0-9-]{0,61}[a-z0-9]$": { + "type": "object", + "properties": { + "max_request_duration": { + "type": "string" + }, + "eligible_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "privileged_access": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "role" + ], + "additionalProperties": false + } + }, + "requester_justification_config": { + "type": "object", + "properties": { + "not_mandatory": { + "type": "boolean" + }, + "unstructured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "manual_approvals": { + "type": "object", + "properties": { + "require_approver_justification": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "approvers": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvals_needed": { + "type": "number" + }, + "approver_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "approvers" + ], + "additionalProperties": false + } + } + }, + "required": [ + "require_approver_justification", + "steps" + ], + "additionalProperties": false + }, + "additional_notification_targets": { + "type": "object", + "properties": { + "admin_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "requester_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "max_request_duration", + "eligible_users", + "privileged_access" + ], + "additionalProperties": false + } + } } } -} \ No newline at end of file +} diff --git a/modules/project-factory/schemas/folder.schema.md b/modules/project-factory/schemas/folder.schema.md index b175e3421..fe5ae2511 100644 --- a/modules/project-factory/schemas/folder.schema.md +++ b/modules/project-factory/schemas/folder.schema.md @@ -28,6 +28,7 @@ - **factories_config**: *object*
*additional properties: false* - **org_policies**: *string* + - **pam_entitlements**: *string* - **scc_sha_custom_modules**: *string* - **iam**: *reference([iam](#refs-iam))* - **iam_bindings**: *reference([iam_bindings](#refs-iam_bindings))* @@ -59,6 +60,7 @@ - **expression**: *string* - **location**: *string* - **title**: *string* +- **pam_entitlements**: *reference([pam_entitlements](#refs-pam_entitlements))* - **parent**: *string*
*pattern: ^(?:folders/[0-9]+|organizations/[0-9]+|\$folder_ids:[a-z0-9_-]+)$* - **tag_bindings**: *object* @@ -151,3 +153,36 @@
*additional properties: false* - **`^[a-z0-9-]+$`**: *array* - items: *string* +- **pam_entitlements**: *object* +
*additional properties: false* + - **`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`**: *object* +
*additional properties: false* + - ⁺**max_request_duration**: *string* + - ⁺**eligible_users**: *array* + - items: *string* + - ⁺**privileged_access**: *array* + - items: *object* +
*additional properties: false* + - ⁺**role**: *string* + - **condition**: *string* + - **requester_justification_config**: *object* +
*additional properties: false* + - **not_mandatory**: *boolean* + - **unstructured**: *boolean* + - **manual_approvals**: *object* +
*additional properties: false* + - ⁺**require_approver_justification**: *boolean* + - ⁺**steps**: *array* + - items: *object* +
*additional properties: false* + - ⁺**approvers**: *array* + - items: *string* + - **approvals_needed**: *number* + - **approver_email_recipients**: *array* + - items: *string* + - **additional_notification_targets**: *object* +
*additional properties: false* + - **admin_email_recipients**: *array* + - items: *string* + - **requester_email_recipients**: *array* + - items: *string* diff --git a/modules/project-factory/schemas/project.schema.json b/modules/project-factory/schemas/project.schema.json index 56cce3ac3..90c256ab7 100644 --- a/modules/project-factory/schemas/project.schema.json +++ b/modules/project-factory/schemas/project.schema.json @@ -109,6 +109,9 @@ "org_policies": { "type": "string" }, + "org_policies": { + "type": "string" + }, "quotas": { "type": "string" }, @@ -135,6 +138,9 @@ "labels": { "type": "object" }, + "pam_entitlements": { + "$ref": "#/$defs/pam_entitlements" + }, "log_buckets": { "type": "object", "additionalProperties": false, @@ -865,6 +871,120 @@ "type": "number" } } + }, + "pam_entitlements": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z][a-z0-9-]{0,61}[a-z0-9]$": { + "type": "object", + "properties": { + "max_request_duration": { + "type": "string" + }, + "eligible_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "privileged_access": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "role" + ], + "additionalProperties": false + } + }, + "requester_justification_config": { + "type": "object", + "properties": { + "not_mandatory": { + "type": "boolean" + }, + "unstructured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "manual_approvals": { + "type": "object", + "properties": { + "require_approver_justification": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "approvers": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvals_needed": { + "type": "number" + }, + "approver_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "approvers" + ], + "additionalProperties": false + } + } + }, + "required": [ + "require_approver_justification", + "steps" + ], + "additionalProperties": false + }, + "additional_notification_targets": { + "type": "object", + "properties": { + "admin_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "requester_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "max_request_duration", + "eligible_users", + "privileged_access" + ], + "additionalProperties": false + } + } } } -} \ No newline at end of file +} diff --git a/modules/project-factory/schemas/project.schema.md b/modules/project-factory/schemas/project.schema.md index 012ec15c6..6637490c0 100644 --- a/modules/project-factory/schemas/project.schema.md +++ b/modules/project-factory/schemas/project.schema.md @@ -48,6 +48,7 @@ - **iam_bindings_additive**: *reference([iam_bindings_additive](#refs-iam_bindings_additive))* - **iam_by_principals**: *reference([iam_by_principals](#refs-iam_by_principals))* - **labels**: *object* +- **pam_entitlements**: *reference([pam_entitlements](#refs-pam_entitlements))* - **log_buckets**: *object*
*additional properties: false* - **`^[a-z0-9-]+$`**: *reference([log_bucket](#refs-log_bucket))* @@ -269,3 +270,36 @@ - **dataset_link_id**: *string* - **description**: *string* - **retention**: *number* +- **pam_entitlements**: *object* +
*additional properties: false* + - **`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`**: *object* +
*additional properties: false* + - ⁺**max_request_duration**: *string* + - ⁺**eligible_users**: *array* + - items: *string* + - ⁺**privileged_access**: *array* + - items: *object* +
*additional properties: false* + - ⁺**role**: *string* + - **condition**: *string* + - **requester_justification_config**: *object* +
*additional properties: false* + - **not_mandatory**: *boolean* + - **unstructured**: *boolean* + - **manual_approvals**: *object* +
*additional properties: false* + - ⁺**require_approver_justification**: *boolean* + - ⁺**steps**: *array* + - items: *object* +
*additional properties: false* + - ⁺**approvers**: *array* + - items: *string* + - **approvals_needed**: *number* + - **approver_email_recipients**: *array* + - items: *string* + - **additional_notification_targets**: *object* +
*additional properties: false* + - **admin_email_recipients**: *array* + - items: *string* + - **requester_email_recipients**: *array* + - items: *string* diff --git a/modules/project-factory/variables-folders.tf b/modules/project-factory/variables-folders.tf index de49c4764..4740b1694 100644 --- a/modules/project-factory/variables-folders.tf +++ b/modules/project-factory/variables-folders.tf @@ -39,7 +39,31 @@ variable "folders" { })) })), {}) iam_by_principals = optional(map(list(string)), {}) - tag_bindings = optional(map(string), {}) + pam_entitlements = optional(map(object({ + max_request_duration = string + eligible_users = list(string) + privileged_access = list(object({ + role = string + condition = optional(string) + })) + requester_justification_config = optional(object({ + not_mandatory = optional(bool, true) + unstructured = optional(bool, false) + }), { not_mandatory = false, unstructured = true }) + manual_approvals = optional(object({ + require_approver_justification = bool + steps = list(object({ + approvers = list(string) + approvals_needed = optional(number, 1) + aprover_email_recipients = optional(list(string)) + })) + })) + additional_notification_targets = optional(object({ + admin_email_recipients = optional(list(string)) + requester_email_recipients = optional(list(string)) + })) + })), {}) + tag_bindings = optional(map(string), {}) })) nullable = false default = {} diff --git a/modules/project-factory/variables-projects.tf b/modules/project-factory/variables-projects.tf index 9d2b68ea7..2f1477485 100644 --- a/modules/project-factory/variables-projects.tf +++ b/modules/project-factory/variables-projects.tf @@ -175,7 +175,31 @@ variable "projects" { iam_by_principals = optional(map(list(string)), {}) labels = optional(map(string), {}) metric_scopes = optional(list(string), []) - name = optional(string) + pam_entitlements = optional(map(object({ + max_request_duration = string + eligible_users = list(string) + privileged_access = list(object({ + role = string + condition = optional(string) + })) + requester_justification_config = optional(object({ + not_mandatory = optional(bool, true) + unstructured = optional(bool, false) + }), { not_mandatory = false, unstructured = true }) + manual_approvals = optional(object({ + require_approver_justification = bool + steps = list(object({ + approvers = list(string) + approvals_needed = optional(number, 1) + aprover_email_recipients = optional(list(string)) + })) + })) + additional_notification_targets = optional(object({ + admin_email_recipients = optional(list(string)) + requester_email_recipients = optional(list(string)) + })) + })), {}) + name = optional(string) org_policies = optional(map(object({ inherit_from_parent = optional(bool) # for list policies only. reset = optional(bool) diff --git a/modules/project/README.md b/modules/project/README.md index 21171b03a..52a52c306 100644 --- a/modules/project/README.md +++ b/modules/project/README.md @@ -30,6 +30,8 @@ This module implements the creation and management of one GCP project including - [Custom Roles Factory](#custom-roles-factory) - [Quotas](#quotas) - [Quotas factory](#quotas-factory) +- [Privileged Access Manager (PAM) Entitlements](#privileged-access-manager-pam-entitlements) + - [Privileged Access Manager (PAM) Entitlements Factory](#privileged-access-manager-pam-entitlements-factory) - [VPC Service Controls](#vpc-service-controls) - [Default compute network tier](#default-compute-network-tier) - [Project Related Outputs](#project-related-outputs) @@ -1385,6 +1387,59 @@ cpus-ew8: # tftest-file id=quota-cpus-ew8 path=data/quotas/cpus-ew8.yaml schema=quotas.schema.json ``` +## Privileged Access Manager (PAM) Entitlements + +[Privileged Access Manager](https://cloud.google.com/iam/docs/privileged-access-manager-overview) entitlements can be defined via the `pam_entitlements` variable. + +Note that using PAM entitlements requires specific roles to be granted to the users and groups that will be using them. For more information, see the [official documentation](https://cloud.google.com/iam/docs/pam-permissions-and-setup#before-you-begin). + +Additionally, the Privileged Access Manager Service Agent must be created and granted the `roles/privilegedaccessmanager.projectServiceAgent` role. This is usually done automatically when the API is enabled. For more information on service agents, see the [official documentation](https://cloud.google.com/iam/docs/service-agents). Refer to the [organization module's documentation](../organization/README.md#privileged-access-manager-pam-entitlements) for an example on how to grant the required role. + +```hcl +module "project" { + source = "./fabric/modules/project" + billing_account = var.billing_account_id + name = "project" + parent = var.folder_id + prefix = var.prefix + pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["group:gcp-organization-admins@example.com"] + }] + } + eligible_users = ["group:gcp-network-admins@example.com"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" } + ] + } + } +} +``` + +### Privileged Access Manager (PAM) Entitlements Factory + +PAM entitlements can be loaded from a directory containing YAML files where each file defines one or more entitlements. The structure of the YAML files is exactly the same as the `pam_entitlements` variable. + +Note that entitlements defined via `pam_entitlements` take precedence over those in the factory. In other words, if you specify the same entitlement in a YAML file and in the `pam_entitlements` variable, the latter will take priority. + +```hcl +module "project" { + source = "./fabric/modules/project" + billing_account = var.billing_account_id + name = "project" + parent = var.folder_id + prefix = var.prefix + factories_config = { + pam_entitlements = "configs/pam-entitlements/" + } +} +``` + ## VPC Service Controls This module also allows managing project membership in VPC Service Controls perimeters. When using this functionality care should be taken so that perimeter management (e.g. via the `vpc-sc` module) does not try reconciling resources, to avoid permadiffs and related violations. @@ -1870,6 +1925,7 @@ alerts: | [notification-channels.tf](./notification-channels.tf) | None | google_monitoring_notification_channel | | [organization-policies.tf](./organization-policies.tf) | Project-level organization policies. | google_org_policy_policy | | [outputs.tf](./outputs.tf) | Module outputs. | | +| [pam.tf](./pam.tf) | None | google_privileged_access_manager_entitlement | | [quotas.tf](./quotas.tf) | None | google_cloud_quotas_quota_preference | | [scc-sha-custom-modules.tf](./scc-sha-custom-modules.tf) | Project-level Custom modules with Security Health Analytics. | google_scc_management_project_security_health_analytics_custom_module | | [service-agents.tf](./service-agents.tf) | Service agents supporting resources. | google_project_default_service_accounts · google_project_iam_member · google_project_service_identity | @@ -1877,6 +1933,7 @@ alerts: | [tags.tf](./tags.tf) | Manages GCP Secure Tags, keys, values, and IAM. | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_key_iam_member · google_tags_tag_value · google_tags_tag_value_iam_binding · google_tags_tag_value_iam_member | | [variables-iam.tf](./variables-iam.tf) | None | | | [variables-observability.tf](./variables-observability.tf) | None | | +| [variables-pam.tf](./variables-pam.tf) | None | | | [variables-quotas.tf](./variables-quotas.tf) | None | | | [variables-scc.tf](./variables-scc.tf) | None | | | [variables-tags.tf](./variables-tags.tf) | None | | @@ -1888,7 +1945,7 @@ alerts: | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [name](variables.tf#L132) | Project name and id suffix. | string | ✓ | | +| [name](variables.tf#L133) | 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 | @@ -1900,14 +1957,14 @@ alerts: | [default_service_account](variables.tf#L75) | Project default service account setting: can be one of `delete`, `deprivilege`, `disable`, or `keep`. | string | | "keep" | | [deletion_policy](variables.tf#L88) | Deletion policy setting for this project. | string | | "DELETE" | | [descriptive_name](variables.tf#L99) | Name of the project name. Used for project name instead of `name` variable. | string | | null | -| [factories_config](variables.tf#L105) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | +| [factories_config](variables.tf#L105) | 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#L119) | Resource labels. | map(string) | | {} | -| [lien_reason](variables.tf#L126) | If non-empty, creates a project lien with this description. | string | | null | +| [labels](variables.tf#L120) | Resource labels. | map(string) | | {} | +| [lien_reason](variables.tf#L127) | 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) | | {} | @@ -1916,24 +1973,25 @@ 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#L137) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} | -| [parent](variables.tf#L165) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null | -| [prefix](variables.tf#L179) | Optional prefix used to generate project id and name. | string | | null | -| [project_reuse](variables.tf#L189) | 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#L138) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} | +| [pam_entitlements](variables-pam.tf#L17) | Privileged Access Manager entitlements for this resource, keyed by entitlement ID. | map(object({…})) | | {} | +| [parent](variables.tf#L166) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null | +| [prefix](variables.tf#L180) | Optional prefix used to generate project id and name. | string | | null | +| [project_reuse](variables.tf#L190) | 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({…})) | | {} | | [scc_sha_custom_modules](variables-scc.tf#L17) | SCC custom modules keyed by module name. | map(object({…})) | | {} | -| [service_agents_config](variables.tf#L209) | Automatic service agent configuration options. | object({…}) | | {} | -| [service_config](variables.tf#L219) | Configure service API activation. | object({…}) | | {…} | -| [service_encryption_key_ids](variables.tf#L231) | Service Agents to be granted encryption/decryption permissions over Cloud KMS encryption keys. Format {SERVICE_AGENT => [KEY_ID]}. | map(list(string)) | | {} | -| [services](variables.tf#L238) | Service APIs to enable. | list(string) | | [] | -| [shared_vpc_host_config](variables.tf#L244) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null | -| [shared_vpc_service_config](variables.tf#L254) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} | -| [skip_delete](variables.tf#L291) | Deprecated. Use deletion_policy. | bool | | null | +| [service_agents_config](variables.tf#L210) | Automatic service agent configuration options. | object({…}) | | {} | +| [service_config](variables.tf#L220) | Configure service API activation. | object({…}) | | {…} | +| [service_encryption_key_ids](variables.tf#L232) | Service Agents to be granted encryption/decryption permissions over Cloud KMS encryption keys. Format {SERVICE_AGENT => [KEY_ID]}. | map(list(string)) | | {} | +| [services](variables.tf#L239) | Service APIs to enable. | list(string) | | [] | +| [shared_vpc_host_config](variables.tf#L245) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null | +| [shared_vpc_service_config](variables.tf#L255) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} | +| [skip_delete](variables.tf#L292) | Deprecated. Use deletion_policy. | bool | | null | | [tag_bindings](variables-tags.tf#L82) | Tag bindings for this project, in key => tag value id format. | map(string) | | null | | [tags](variables-tags.tf#L89) | 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({…})) | | {} | | [tags_config](variables-tags.tf#L154) | Fine-grained control on tag resource and IAM creation. | object({…}) | | {} | -| [universe](variables.tf#L303) | GCP universe where to deploy the project. The prefix will be prepended to the project id. | object({…}) | | null | -| [vpc_sc](variables.tf#L314) | VPC-SC configuration for the project, use when `ignore_changes` for resources is set in the VPC-SC module. | object({…}) | | null | +| [universe](variables.tf#L304) | GCP universe where to deploy the project. The prefix will be prepended to the project id. | object({…}) | | null | +| [vpc_sc](variables.tf#L315) | 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/pam.tf b/modules/project/pam.tf new file mode 100644 index 000000000..be6d805fe --- /dev/null +++ b/modules/project/pam.tf @@ -0,0 +1,141 @@ +/** + * 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. + */ + +locals { + _pam_entitlements_factory_path = pathexpand(coalesce(var.factories_config.pam_entitlements, "-")) + _pam_entitlements_factory_data_raw = merge([ + for f in try(fileset(local._pam_entitlements_factory_path, "*.yaml"), []) : + yamldecode(templatefile("${local._pam_entitlements_factory_path}/${f}", var.context)) + ]...) + # simulate applying defaults to data coming from yaml files + _pam_entitlements_factory_data = { + for k, v in local._pam_entitlements_factory_data_raw : k => { + max_request_duration = v.max_request_duration + eligible_users = v.eligible_users + privileged_access = [ + for pa in v.privileged_access : { + role = pa.role + condition = try(pa.condition, null) + } + ] + requester_justification_config = can(v.requester_justification_config) ? { + not_mandatory = try(v.requester_justification_config.not_mandatory, true) + unstructured = try(v.requester_justification_config.unstructured, false) + } : { + not_mandatory = false + unstructured = true + } + manual_approvals = can(v.manual_approvals) ? { + require_approver_justification = v.manual_approvals.require_approver_justification + steps = [ + for s in v.manual_approvals.steps : { + approvers = s.approvers + approvals_needed = try(s.approvals_needed, 1) + aprover_email_recipients = try(s.aprover_email_recipients, null) + } + ] + } : null + additional_notification_targets = can(v.additional_notification_targets) ? { + admin_email_recipients = try(v.additional_notification_targets.admin_email_recipients, null) + requester_email_recipients = try(v.additional_notification_targets.requester_email_recipients, null) + } : null + } + } + pam_entitlements = merge( + local._pam_entitlements_factory_data, + var.pam_entitlements + ) +} + +resource "google_privileged_access_manager_entitlement" "default" { + for_each = local.pam_entitlements + + parent = "projects/${local.project_id}" + location = "global" + entitlement_id = each.key + max_request_duration = each.value.max_request_duration + + eligible_users { + principals = [ + for u in each.value.eligible_users : lookup(local.ctx.iam_principals, u, u) + ] + } + + privileged_access { + gcp_iam_access { + resource_type = "cloudresourcemanager.googleapis.com/Project" + resource = "//cloudresourcemanager.googleapis.com/projects/${local.project.project_id}" + dynamic "role_bindings" { + for_each = each.value.privileged_access + iterator = binding + content { + role = lookup(local.ctx.custom_roles, binding.value.role, binding.value.role) + condition_expression = binding.value.condition == null ? null : templatestring( + binding.value.condition, var.context.condition_vars + ) + } + } + } + } + + requester_justification_config { + dynamic "not_mandatory" { + for_each = each.value.requester_justification_config.not_mandatory ? [""] : [] + content {} + } + dynamic "unstructured" { + for_each = each.value.requester_justification_config.unstructured ? [""] : [] + content {} + } + } + + dynamic "approval_workflow" { + for_each = each.value.manual_approvals == null ? [] : [""] + content { + manual_approvals { + require_approver_justification = each.value.manual_approvals.require_approver_justification + dynamic "steps" { + for_each = each.value.manual_approvals.steps + iterator = step + content { + approvers { + principals = [ + for a in step.value.approvers : lookup(local.ctx.iam_principals, a, a) + ] + } + + approvals_needed = step.value.approvals_needed + approver_email_recipients = step.value.aprover_email_recipients + } + } + } + } + } + + dynamic "additional_notification_targets" { + for_each = each.value.additional_notification_targets == null ? [] : [""] + content { + admin_email_recipients = each.value.additional_notification_targets.admin_email_recipients + requester_email_recipients = each.value.additional_notification_targets.requester_email_recipients + } + } + depends_on = [ + google_project_service.project_services, + google_project_iam_binding.authoritative, + google_project_iam_binding.bindings, + google_project_iam_member.bindings + ] +} diff --git a/modules/project/schemas/pam-entitlements.schema.json b/modules/project/schemas/pam-entitlements.schema.json new file mode 100644 index 000000000..a2ea3fe62 --- /dev/null +++ b/modules/project/schemas/pam-entitlements.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]{0,61}[a-z0-9]$": { + "type": "object", + "properties": { + "max_request_duration": { + "type": "string" + }, + "eligible_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "privileged_access": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "condition": { + "type": "string" + } + }, + "required": [ + "role" + ], + "additionalProperties": false + } + }, + "requester_justification_config": { + "type": "object", + "properties": { + "not_mandatory": { + "type": "boolean" + }, + "unstructured": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "manual_approvals": { + "type": "object", + "properties": { + "require_approver_justification": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "approvers": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvals_needed": { + "type": "number" + }, + "approver_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "approvers" + ], + "additionalProperties": false + } + } + }, + "required": [ + "require_approver_justification", + "steps" + ], + "additionalProperties": false + }, + "additional_notification_targets": { + "type": "object", + "properties": { + "admin_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + }, + "requester_email_recipients": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "max_request_duration", + "eligible_users", + "privileged_access" + ], + "additionalProperties": false + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/modules/project/schemas/pam-entitlements.schema.md b/modules/project/schemas/pam-entitlements.schema.md new file mode 100644 index 000000000..f0805eb29 --- /dev/null +++ b/modules/project/schemas/pam-entitlements.schema.md @@ -0,0 +1,43 @@ +# None + + + +## Properties + +*additional properties: false* + +- **`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`**: *object* +
*additional properties: false* + - ⁺**max_request_duration**: *string* + - ⁺**eligible_users**: *array* + - items: *string* + - ⁺**privileged_access**: *array* + - items: *object* +
*additional properties: false* + - ⁺**role**: *string* + - **condition**: *string* + - **requester_justification_config**: *object* +
*additional properties: false* + - **not_mandatory**: *boolean* + - **unstructured**: *boolean* + - **manual_approvals**: *object* +
*additional properties: false* + - ⁺**require_approver_justification**: *boolean* + - ⁺**steps**: *array* + - items: *object* +
*additional properties: false* + - ⁺**approvers**: *array* + - items: *string* + - **approvals_needed**: *number* + - **approver_email_recipients**: *array* + - items: *string* + - **additional_notification_targets**: *object* +
*additional properties: false* + - **admin_email_recipients**: *array* + - items: *string* + - **requester_email_recipients**: *array* + - items: *string* + +## Definitions + + diff --git a/modules/project/variables-pam.tf b/modules/project/variables-pam.tf new file mode 100644 index 000000000..79ba6a8b9 --- /dev/null +++ b/modules/project/variables-pam.tf @@ -0,0 +1,52 @@ +/** + * 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. + */ + +variable "pam_entitlements" { + description = "Privileged Access Manager entitlements for this resource, keyed by entitlement ID." + type = map(object({ + max_request_duration = string + eligible_users = list(string) + privileged_access = list(object({ + role = string + condition = optional(string) + })) + requester_justification_config = optional(object({ + not_mandatory = optional(bool, true) + unstructured = optional(bool, false) + }), { not_mandatory = false, unstructured = true }) + manual_approvals = optional(object({ + require_approver_justification = bool + steps = list(object({ + approvers = list(string) + approvals_needed = optional(number, 1) + aprover_email_recipients = optional(list(string)) + })) + })) + additional_notification_targets = optional(object({ + admin_email_recipients = optional(list(string)) + requester_email_recipients = optional(list(string)) + })) + })) + default = {} + nullable = false + validation { + condition = alltrue([ + for v in values(var.pam_entitlements) : + !v.requester_justification_config.not_mandatory || !v.requester_justification_config.unstructured + ]) + error_message = "Only one of 'not_mandatory' or 'unstructured' can be enabled in 'requester_justification_config'." + } +} diff --git a/modules/project/variables.tf b/modules/project/variables.tf index 828c14807..5a0c33653 100644 --- a/modules/project/variables.tf +++ b/modules/project/variables.tf @@ -108,6 +108,7 @@ variable "factories_config" { custom_roles = optional(string) observability = optional(string) org_policies = optional(string) + pam_entitlements = optional(string) quotas = optional(string) scc_sha_custom_modules = optional(string) tags = optional(string) diff --git a/tests/modules/folder/context.tfvars b/tests/modules/folder/context.tfvars index 75c270a1f..80ff9e02b 100644 --- a/tests/modules/folder/context.tfvars +++ b/tests/modules/folder/context.tfvars @@ -54,6 +54,23 @@ iam_bindings_additive = { member = "$iam_principals:myuser" } } +pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["$iam_principals:mygroup"] + }] + } + eligible_users = ["$iam_principals:mygroup"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" }, + { role = "$custom_roles:myrole_two" } + ] + } +} tag_bindings = { foo = "$tag_values:test/one" } diff --git a/tests/modules/folder/context.yaml b/tests/modules/folder/context.yaml index df95744fa..49f50e7ff 100644 --- a/tests/modules/folder/context.yaml +++ b/tests/modules/folder/context.yaml @@ -18,7 +18,6 @@ values: display_name: Test Context parent: organizations/1234567890 tags: null - timeouts: null google_folder_iam_binding.authoritative["$custom_roles:myrole_one"]: condition: [] members: @@ -47,14 +46,45 @@ values: condition: [] member: user:test-user@example.com role: organizations/366118655033/roles/myRoleTwo + google_privileged_access_manager_entitlement.default["net-admins"]: + additional_notification_targets: [] + approval_workflow: + - manual_approvals: + - require_approver_justification: true + steps: + - approvals_needed: 1 + approver_email_recipients: null + approvers: + - principals: + - group:test-group@example.com + eligible_users: + - principals: + - group:test-group@example.com + entitlement_id: net-admins + location: global + max_request_duration: 3600s + privileged_access: + - gcp_iam_access: + - resource_type: cloudresourcemanager.googleapis.com/Folder + role_bindings: + - condition_expression: null + role: roles/compute.networkAdmin + - condition_expression: null + role: roles/compute.admin + - condition_expression: null + role: organizations/366118655033/roles/myRoleTwo + requester_justification_config: + - not_mandatory: [] + unstructured: + - {} google_tags_tag_binding.binding["foo"]: tag_value: tagValues/1234567890 - timeouts: null counts: google_folder: 1 google_folder_iam_binding: 4 google_folder_iam_member: 1 + google_privileged_access_manager_entitlement: 1 google_tags_tag_binding: 1 modules: 0 - resources: 7 + resources: 8 diff --git a/tests/modules/organization/context.tfvars b/tests/modules/organization/context.tfvars index 7187be894..993ab9836 100644 --- a/tests/modules/organization/context.tfvars +++ b/tests/modules/organization/context.tfvars @@ -100,6 +100,23 @@ logging_sinks = { logging_settings = { storage_location = "$locations:default" } +pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["$iam_principals:mygroup"] + }] + } + eligible_users = ["$iam_principals:mygroup"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" }, + { role = "$custom_roles:myrole_two" } + ] + } +} tag_bindings = { foo = "$tag_values:test/one" } diff --git a/tests/modules/organization/context.yaml b/tests/modules/organization/context.yaml index 9d23c8b86..6efce634e 100644 --- a/tests/modules/organization/context.yaml +++ b/tests/modules/organization/context.yaml @@ -21,7 +21,6 @@ values: google_logging_organization_settings.default[0]: organization: '1234567890' storage_location: europe-west8 - timeouts: null google_logging_organization_sink.sink["test-bq"]: bigquery_options: - use_partitioned_tables: false @@ -107,6 +106,39 @@ values: member: user:test-user@example.com org_id: '1234567890' role: organizations/366118655033/roles/myRoleTwo + google_privileged_access_manager_entitlement.default["net-admins"]: + additional_notification_targets: [] + approval_workflow: + - manual_approvals: + - require_approver_justification: true + steps: + - approvals_needed: 1 + approver_email_recipients: null + approvers: + - principals: + - group:test-group@example.com + eligible_users: + - principals: + - group:test-group@example.com + entitlement_id: net-admins + location: global + max_request_duration: 3600s + parent: organizations/1234567890 + privileged_access: + - gcp_iam_access: + - resource: //cloudresourcemanager.googleapis.com/organizations/1234567890 + resource_type: cloudresourcemanager.googleapis.com/Organization + role_bindings: + - condition_expression: null + role: roles/compute.networkAdmin + - condition_expression: null + role: roles/compute.admin + - condition_expression: null + role: organizations/366118655033/roles/myRoleTwo + requester_justification_config: + - not_mandatory: [] + unstructured: + - {} google_project_iam_member.bucket-sinks-binding["test-logging"]: condition: - expression: resource.name.endsWith('projects/test-prod-audit-logs-0/locations/europe-west8/buckets/audit-logs') @@ -129,7 +161,6 @@ values: google_tags_tag_binding.binding["foo"]: parent: //cloudresourcemanager.googleapis.com/organizations/1234567890 tag_value: tagValues/1234567890 - timeouts: null google_tags_tag_key_iam_binding.bindings["test:tag_user"]: condition: [] members: @@ -171,6 +202,7 @@ counts: google_logging_organization_sink: 5 google_organization_iam_binding: 4 google_organization_iam_member: 1 + google_privileged_access_manager_entitlement: 1 google_project_iam_member: 2 google_pubsub_topic_iam_member: 1 google_storage_bucket_iam_member: 1 @@ -180,4 +212,4 @@ counts: google_tags_tag_value_iam_binding: 2 google_tags_tag_value_iam_member: 1 modules: 0 - resources: 23 + resources: 24 diff --git a/tests/modules/project/context.tfvars b/tests/modules/project/context.tfvars index cc262839b..bafcb3889 100644 --- a/tests/modules/project/context.tfvars +++ b/tests/modules/project/context.tfvars @@ -69,6 +69,23 @@ iam_bindings_additive = { member = "$service_agents:compute" } } +pam_entitlements = { + net-admins = { + max_request_duration = "3600s" + manual_approvals = { + require_approver_justification = true + steps = [{ + approvers = ["$iam_principals:mygroup"] + }] + } + eligible_users = ["$iam_principals:mygroup"] + privileged_access = [ + { role = "roles/compute.networkAdmin" }, + { role = "roles/compute.admin" }, + { role = "$custom_roles:myrole_two" } + ] + } +} services = [ "compute.googleapis.com" ] diff --git a/tests/modules/project/context.yaml b/tests/modules/project/context.yaml index c3c26e504..a2b4ba97a 100644 --- a/tests/modules/project/context.yaml +++ b/tests/modules/project/context.yaml @@ -15,16 +15,61 @@ values: google_access_context_manager_service_perimeter_resource.default["$vpc_sc_perimeters:default"]: perimeter_name: accessPolicies/888933661165/servicePerimeters/default - timeouts: null google_compute_shared_vpc_service_project.shared_vpc_service[0]: deletion_policy: null host_project: test-vpc-host service_project: my-project - timeouts: null google_kms_crypto_key_iam_member.service_agent_cmek["key-0.compute-system"]: condition: [] crypto_key_id: projects/kms-central-prj/locations/europe-west1/keyRings/my-keyring/cryptoKeys/ew1-compute role: roles/cloudkms.cryptoKeyEncrypterDecrypter + google_privileged_access_manager_entitlement.default["net-admins"]: + additional_notification_targets: [] + approval_workflow: + - manual_approvals: + - require_approver_justification: true + steps: + - approvals_needed: 1 + approver_email_recipients: null + approvers: + - principals: + - group:test-group@example.com + eligible_users: + - principals: + - group:test-group@example.com + entitlement_id: net-admins + location: global + max_request_duration: 3600s + parent: projects/my-project + privileged_access: + - gcp_iam_access: + - resource: //cloudresourcemanager.googleapis.com/projects/my-project + resource_type: cloudresourcemanager.googleapis.com/Project + role_bindings: + - condition_expression: null + role: roles/compute.networkAdmin + - condition_expression: null + role: roles/compute.admin + - condition_expression: null + role: organizations/366118655033/roles/myRoleTwo + requester_justification_config: + - not_mandatory: [] + unstructured: + - {} + google_project.project[0]: + auto_create_network: false + billing_account: null + deletion_policy: DELETE + effective_labels: + goog-terraform-provisioned: 'true' + folder_id: '6789012345' + labels: null + name: my-project + org_id: null + project_id: my-project + tags: null + terraform_labels: + goog-terraform-provisioned: 'true' google_project_iam_binding.authoritative["$custom_roles:myrole_one"]: condition: [] members: @@ -58,6 +103,10 @@ values: member: user:test-user@example.com project: my-project role: organizations/366118655033/roles/myRoleTwo + google_project_iam_member.bindings["sa_test"]: + condition: [] + project: my-project + role: roles/browser google_project_iam_member.service_agents["compute-system"]: condition: [] project: my-project @@ -85,10 +134,8 @@ values: disable_on_destroy: false project: my-project service: compute.googleapis.com - timeouts: null google_tags_tag_binding.binding["foo"]: tag_value: tagValues/1234567890 - timeouts: null google_tags_tag_key_iam_binding.bindings["test:tag_user"]: condition: [] members: @@ -128,6 +175,7 @@ counts: google_access_context_manager_service_perimeter_resource: 1 google_compute_shared_vpc_service_project: 1 google_kms_crypto_key_iam_member: 1 + google_privileged_access_manager_entitlement: 1 google_project: 1 google_project_iam_binding: 4 google_project_iam_member: 7 @@ -138,4 +186,4 @@ counts: google_tags_tag_value_iam_binding: 2 google_tags_tag_value_iam_member: 1 modules: 0 - resources: 23 + resources: 24 diff --git a/tests/modules/project_factory/examples/example.yaml b/tests/modules/project_factory/examples/example.yaml index 2c487a7e4..4438d0447 100644 --- a/tests/modules/project_factory/examples/example.yaml +++ b/tests/modules/project_factory/examples/example.yaml @@ -36,7 +36,6 @@ values: storage_class: STANDARD terraform_labels: goog-terraform-provisioned: 'true' - timeouts: null uniform_bucket_level_access: true versioning: - enabled: false @@ -64,7 +63,6 @@ values: email: dev-tb-app0-0-ro@test-pf-teams-iac-0.iam.gserviceaccount.com member: serviceAccount:dev-tb-app0-0-ro@test-pf-teams-iac-0.iam.gserviceaccount.com project: test-pf-teams-iac-0 - timeouts: null ? module.project-factory.module.automation-service-accounts["dev-tb-app0-0/automation/rw"].google_service_account.service_account[0] : account_id: dev-tb-app0-0-rw create_ignore_already_exists: null @@ -74,7 +72,6 @@ values: email: dev-tb-app0-0-rw@test-pf-teams-iac-0.iam.gserviceaccount.com member: serviceAccount:dev-tb-app0-0-rw@test-pf-teams-iac-0.iam.gserviceaccount.com project: test-pf-teams-iac-0 - timeouts: null module.project-factory.module.billing-budgets[0].google_billing_budget.default["test-100"]: all_updates_rule: - disable_default_iam_recipients: true @@ -103,7 +100,6 @@ values: threshold_percent: 0.5 - spend_basis: CURRENT_SPEND threshold_percent: 0.75 - timeouts: null module.project-factory.module.billing-budgets[0].google_monitoring_notification_channel.default["billing-default"]: description: null display_name: Budget email notification billing-default. @@ -113,7 +109,6 @@ values: email_address: gcp-billing-admins@example.org project: foo-billing-audit sensitive_labels: [] - timeouts: null type: email user_labels: null module.project-factory.module.folder-1-iam["team-a"].google_folder_iam_binding.authoritative["roles/viewer"]: @@ -127,24 +122,20 @@ values: display_name: Team A parent: folders/5678901234 tags: null - timeouts: null module.project-factory.module.folder-1["team-b"].google_folder.folder[0]: deletion_protection: false display_name: Team B parent: folders/5678901234 tags: null - timeouts: null module.project-factory.module.folder-1["team-c"].google_folder.folder[0]: deletion_protection: false display_name: Team C parent: folders/5678901234 tags: null - timeouts: null module.project-factory.module.folder-2["team-a/app-0"].google_folder.folder[0]: deletion_protection: false display_name: App 0 tags: null - timeouts: null module.project-factory.module.folder-2["team-a/app-0"].google_org_policy_policy.default["compute.disableSerialPortAccess"]: dry_run_spec: [] spec: @@ -157,20 +148,43 @@ values: enforce: 'FALSE' parameters: null values: [] - timeouts: null + ? module.project-factory.module.folder-2["team-a/app-0"].google_privileged_access_manager_entitlement.default["app-0-admins"] + : additional_notification_targets: [] + approval_workflow: + - manual_approvals: + - require_approver_justification: true + steps: + - approvals_needed: 1 + approver_email_recipients: null + approvers: + - principals: + - group:app-0-admins@example.org + eligible_users: + - principals: + - group:app-a-ops@example.org + entitlement_id: app-0-admins + location: global + max_request_duration: 3600s + privileged_access: + - gcp_iam_access: + - resource_type: cloudresourcemanager.googleapis.com/Folder + role_bindings: + - condition_expression: null + role: roles/writer + requester_justification_config: + - not_mandatory: [] + unstructured: + - {} module.project-factory.module.folder-2["team-b/app-0"].google_folder.folder[0]: deletion_protection: false display_name: App 0 tags: null - timeouts: null module.project-factory.module.folder-2["team-b/app-0"].google_tags_tag_binding.binding["drs-allow-all"]: tag_value: tagValues/123456 - timeouts: null ? module.project-factory.module.projects-iam["dev-ta-app0-be"].google_compute_shared_vpc_service_project.shared_vpc_service[0] : deletion_policy: null host_project: $project_ids:dev-spoke-0 service_project: test-pf-dev-ta-app0-be - timeouts: null ? module.project-factory.module.projects-iam["dev-ta-app0-be"].google_kms_crypto_key_iam_member.service_agent_cmek["key-0.compute-system"] : condition: [] crypto_key_id: projects/kms-central-prj/locations/europe-west1/keyRings/my-keyring/cryptoKeys/ew1-compute @@ -179,6 +193,37 @@ values: : condition: [] crypto_key_id: projects/kms-central-prj/locations/europe-west3/keyRings/my-keyring/cryptoKeys/europe3-gce role: roles/cloudkms.cryptoKeyEncrypterDecrypter + ? module.project-factory.module.projects-iam["dev-ta-app0-be"].google_privileged_access_manager_entitlement.default["project-admins"] + : additional_notification_targets: [] + approval_workflow: + - manual_approvals: + - require_approver_justification: true + steps: + - approvals_needed: 1 + approver_email_recipients: null + approvers: + - principals: + - group:team-a-admins@example + eligible_users: + - principals: + - group:team-a-ops@example.org + entitlement_id: project-admins + location: global + max_request_duration: 3600s + parent: projects/test-pf-dev-ta-app0-be + privileged_access: + - gcp_iam_access: + - resource: //cloudresourcemanager.googleapis.com/projects/test-pf-dev-ta-app0-be + resource_type: cloudresourcemanager.googleapis.com/Project + role_bindings: + - condition_expression: null + role: roles/compute.admin + - condition_expression: null + role: roles/bigquery.admin + requester_justification_config: + - not_mandatory: [] + unstructured: + - {} ? module.project-factory.module.projects-iam["dev-ta-app0-be"].google_project_iam_binding.authoritative["roles/cloudkms.cryptoKeyEncrypterDecrypter"] : condition: [] project: test-pf-dev-ta-app0-be @@ -204,7 +249,6 @@ values: role: roles/container.hostServiceAgentUser module.project-factory.module.projects-iam["dev-tb-app0-0"].google_compute_shared_vpc_host_project.shared_vpc_host[0]: project: test-pf-dev-tb-app0-0 - timeouts: null module.project-factory.module.projects-iam["dev-tb-app0-0"].google_project_iam_binding.authoritative["roles/owner"]: condition: [] members: @@ -238,7 +282,6 @@ values: notification_category_subscriptions: - ALL parent: projects/test-pf-dev-ta-app0-be - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_project.project[0]: auto_create_network: false billing_account: 012345-67890A-BCDEF0 @@ -261,7 +304,6 @@ values: environment: test goog-terraform-provisioned: 'true' team: team-a - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_project_iam_member.service_agents["compute-system"]: condition: [] project: test-pf-dev-ta-app0-be @@ -279,47 +321,38 @@ values: disable_on_destroy: false project: test-pf-dev-ta-app0-be service: compute.googleapis.com - timeouts: null ? module.project-factory.module.projects["dev-ta-app0-be"].google_project_service.project_services["container.googleapis.com"] : disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-ta-app0-be service: container.googleapis.com - timeouts: null ? module.project-factory.module.projects["dev-ta-app0-be"].google_project_service.project_services["stackdriver.googleapis.com"] : disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-ta-app0-be service: stackdriver.googleapis.com - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_project_service.project_services["storage.googleapis.com"]: disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-ta-app0-be service: storage.googleapis.com - timeouts: null ? module.project-factory.module.projects["dev-ta-app0-be"].google_project_service_identity.default["container.googleapis.com"] : project: test-pf-dev-ta-app0-be service: container.googleapis.com - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_tags_tag_binding.binding["context"]: tag_value: tagValues/654321 - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_tags_tag_key.default["my-tag-key-1"]: description: Managed by the Terraform project-factory module. parent: projects/test-pf-dev-ta-app0-be purpose: null purpose_data: null short_name: my-tag-key-1 - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_tags_tag_value.default["my-tag-key-1/my-value-1"]: description: My value 1 short_name: my-value-1 - timeouts: null module.project-factory.module.projects["dev-ta-app0-be"].google_tags_tag_value.default["my-tag-key-1/my-value-2"]: description: My value 3 short_name: my-value-2 - timeouts: null ? module.project-factory.module.projects["dev-ta-app0-be"].google_tags_tag_value_iam_binding.default["my-tag-key-1/my-value-2:roles/resourcemanager.tagUser"] : condition: [] members: @@ -334,7 +367,6 @@ values: notification_category_subscriptions: - ALL parent: projects/test-pf-dev-tb-app0-0 - timeouts: null module.project-factory.module.projects["dev-tb-app0-0"].google_project.project[0]: auto_create_network: false billing_account: 123456-123456-123456 @@ -351,7 +383,6 @@ values: terraform_labels: environment: test goog-terraform-provisioned: 'true' - timeouts: null module.project-factory.module.projects["dev-tb-app0-0"].google_project_iam_member.service_agents["serverless-robot-prod"]: condition: [] project: test-pf-dev-tb-app0-0 @@ -361,23 +392,19 @@ values: disable_on_destroy: false project: test-pf-dev-tb-app0-0 service: run.googleapis.com - timeouts: null ? module.project-factory.module.projects["dev-tb-app0-0"].google_project_service.project_services["stackdriver.googleapis.com"] : disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-tb-app0-0 service: stackdriver.googleapis.com - timeouts: null module.project-factory.module.projects["dev-tb-app0-0"].google_project_service.project_services["storage.googleapis.com"]: disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-tb-app0-0 service: storage.googleapis.com - timeouts: null module.project-factory.module.projects["dev-tb-app0-0"].google_project_service_identity.default["run.googleapis.com"]: project: test-pf-dev-tb-app0-0 service: run.googleapis.com - timeouts: null module.project-factory.module.projects["dev-tb-app0-1"].data.google_storage_project_service_account.gcs_sa[0]: project: test-pf-dev-tb-app0-1 user_project: null @@ -387,7 +414,6 @@ values: notification_category_subscriptions: - ALL parent: projects/test-pf-dev-tb-app0-1 - timeouts: null module.project-factory.module.projects["dev-tb-app0-1"].google_project.project[0]: auto_create_network: false billing_account: 012345-67890A-BCDEF0 @@ -410,7 +436,6 @@ values: environment: test goog-terraform-provisioned: 'true' team: team-b - timeouts: null module.project-factory.module.projects["dev-tb-app0-1"].google_project_iam_member.service_agents["container-engine-robot"]: condition: [] project: test-pf-dev-tb-app0-1 @@ -424,23 +449,19 @@ values: disable_on_destroy: false project: test-pf-dev-tb-app0-1 service: container.googleapis.com - timeouts: null ? module.project-factory.module.projects["dev-tb-app0-1"].google_project_service.project_services["stackdriver.googleapis.com"] : disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-tb-app0-1 service: stackdriver.googleapis.com - timeouts: null module.project-factory.module.projects["dev-tb-app0-1"].google_project_service.project_services["storage.googleapis.com"]: disable_dependent_services: false disable_on_destroy: false project: test-pf-dev-tb-app0-1 service: storage.googleapis.com - timeouts: null ? module.project-factory.module.projects["dev-tb-app0-1"].google_project_service_identity.default["container.googleapis.com"] : project: test-pf-dev-tb-app0-1 service: container.googleapis.com - timeouts: null module.project-factory.module.projects["teams-iac-0"].data.google_storage_project_service_account.gcs_sa[0]: project: test-pf-teams-iac-0 user_project: null @@ -450,7 +471,6 @@ values: notification_category_subscriptions: - ALL parent: projects/test-pf-teams-iac-0 - timeouts: null module.project-factory.module.projects["teams-iac-0"].google_org_policy_policy.default["compute.disableSerialPortAccess"]: dry_run_spec: [] name: projects/test-pf-teams-iac-0/policies/compute.disableSerialPortAccess @@ -465,7 +485,6 @@ values: enforce: 'FALSE' parameters: null values: [] - timeouts: null module.project-factory.module.projects["teams-iac-0"].google_project.project[0]: auto_create_network: false billing_account: 012345-67890A-BCDEF0 @@ -483,7 +502,6 @@ values: terraform_labels: environment: test goog-terraform-provisioned: 'true' - timeouts: null module.project-factory.module.projects["teams-iac-0"].google_project_iam_member.service_agents["container-engine-robot"]: condition: [] project: test-pf-teams-iac-0 @@ -497,23 +515,19 @@ values: disable_on_destroy: false project: test-pf-teams-iac-0 service: container.googleapis.com - timeouts: null ? module.project-factory.module.projects["teams-iac-0"].google_project_service.project_services["stackdriver.googleapis.com"] : disable_dependent_services: false disable_on_destroy: false project: test-pf-teams-iac-0 service: stackdriver.googleapis.com - timeouts: null module.project-factory.module.projects["teams-iac-0"].google_project_service.project_services["storage.googleapis.com"]: disable_dependent_services: false disable_on_destroy: false project: test-pf-teams-iac-0 service: storage.googleapis.com - timeouts: null module.project-factory.module.projects["teams-iac-0"].google_project_service_identity.default["container.googleapis.com"]: project: test-pf-teams-iac-0 service: container.googleapis.com - timeouts: null ? module.project-factory.module.service-accounts["dev-ta-app0-be/app-0-be"].google_project_iam_member.project-roles["$project_ids:dev-spoke-0-roles/compute.networkUser"] : condition: [] project: $project_ids:dev-spoke-0 @@ -535,7 +549,6 @@ values: email: app-0-be@test-pf-dev-ta-app0-be.iam.gserviceaccount.com member: serviceAccount:app-0-be@test-pf-dev-ta-app0-be.iam.gserviceaccount.com project: test-pf-dev-ta-app0-be - timeouts: null ? module.project-factory.module.service-accounts["dev-ta-app0-be/app-0-fe"].google_project_iam_member.project-roles["$project_ids:dev-spoke-0-roles/compute.networkUser"] : condition: [] project: $project_ids:dev-spoke-0 @@ -557,7 +570,6 @@ values: email: app-0-fe@test-pf-dev-ta-app0-be.iam.gserviceaccount.com member: serviceAccount:app-0-fe@test-pf-dev-ta-app0-be.iam.gserviceaccount.com project: test-pf-dev-ta-app0-be - timeouts: null ? module.project-factory.module.service-accounts["dev-tb-app0-0/vm-default"].google_project_iam_member.project-roles["$project_ids:dev-tb-app0-0-roles/logging.logWriter"] : condition: [] project: test-pf-dev-tb-app0-0 @@ -575,7 +587,6 @@ values: email: vm-default@test-pf-dev-tb-app0-0.iam.gserviceaccount.com member: serviceAccount:vm-default@test-pf-dev-tb-app0-0.iam.gserviceaccount.com project: test-pf-dev-tb-app0-0 - timeouts: null ? module.project-factory.module.service-accounts["dev-tb-app0-1/app-0-be"].google_project_iam_member.project-roles["$project_ids:dev-tb-app0-1-roles/logging.logWriter"] : condition: [] project: test-pf-dev-tb-app0-1 @@ -593,7 +604,6 @@ values: email: app-0-be@test-pf-dev-tb-app0-1.iam.gserviceaccount.com member: serviceAccount:app-0-be@test-pf-dev-tb-app0-1.iam.gserviceaccount.com project: test-pf-dev-tb-app0-1 - timeouts: null ? module.project-factory.module.service_accounts-iam["dev-tb-app0-0/vm-default"].data.google_service_account.service_account[0] : account_id: vm-default ? module.project-factory.module.service_accounts-iam["dev-tb-app0-0/vm-default"].google_service_account_iam_binding.authoritative["roles/iam.serviceAccountTokenCreator"] @@ -605,7 +615,6 @@ values: input: null output: null triggers_replace: null - counts: google_billing_budget: 1 google_compute_shared_vpc_host_project: 1 @@ -616,6 +625,7 @@ counts: google_kms_crypto_key_iam_member: 2 google_monitoring_notification_channel: 1 google_org_policy_policy: 2 + google_privileged_access_manager_entitlement: 2 google_project: 4 google_project_iam_binding: 6 google_project_iam_member: 21 @@ -631,5 +641,5 @@ counts: google_tags_tag_value: 2 google_tags_tag_value_iam_binding: 1 modules: 23 - resources: 88 + resources: 90 terraform_data: 1