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
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 45bbf8796..45af6eecd 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)
@@ -232,6 +234,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:
@@ -654,11 +720,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. | |
@@ -668,12 +736,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({…})) | | {} |
@@ -684,8 +752,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 a73cefdf3..139b42528 100644
--- a/modules/project-factory/README.md
+++ b/modules/project-factory/README.md
@@ -512,6 +512,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
```
@@ -579,6 +593,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:
@@ -726,9 +753,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#L106) | 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#L125) | 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 caf52382b..5c6ea9dcb 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,12 +124,26 @@ module "projects-iam" {
kms_keys = local.ctx.kms_keys
iam_principals = local.ctx_iam_principals
})
+<<<<<<< HEAD
factories_config = { for k, v in each.value.factories_config : k => v if k == "observability" }
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", {})
iam_by_principals_additive = lookup(each.value, "iam_by_principals_additive", {})
+=======
+ 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", {})
+ iam_by_principals_additive = lookup(each.value, "iam_by_principals_additive", {})
+ pam_entitlements = try(each.value.pam_entitlements, {})
+>>>>>>> origin/master
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 0eedab517..228c10c06 100644
--- a/modules/project-factory/schemas/project.schema.json
+++ b/modules/project-factory/schemas/project.schema.json
@@ -127,6 +127,9 @@
"org_policies": {
"type": "string"
},
+ "org_policies": {
+ "type": "string"
+ },
"quotas": {
"type": "string"
},
@@ -156,6 +159,9 @@
"labels": {
"type": "object"
},
+ "pam_entitlements": {
+ "$ref": "#/$defs/pam_entitlements"
+ },
"log_buckets": {
"type": "object",
"additionalProperties": false,
@@ -883,6 +889,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 6c908d654..2394111b4 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))*
@@ -274,3 +275,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 4b959a7ae..80ae2a27f 100644
--- a/modules/project-factory/variables-projects.tf
+++ b/modules/project-factory/variables-projects.tf
@@ -179,7 +179,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 fba6147fc..21e280f43 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)
@@ -1386,6 +1388,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.
@@ -1871,6 +1926,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 |
@@ -1878,6 +1934,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 | |
@@ -1889,7 +1946,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 |
@@ -1901,14 +1958,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) | | {} |
@@ -1917,24 +1974,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