diff --git a/README.md b/README.md index 245a68e4..58c532bb 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ You can add a delay using terraform's [`null_resource`](https://www.terraform.io |------|-------------|------|---------|:--------:| | parent\_id | The parent of this AccessPolicy in the Cloud Resource Hierarchy. As of now, only organization are accepted as parent. | `string` | n/a | yes | | policy\_name | The policy's name. | `string` | n/a | yes | +| scopes | Folder or project on which this policy is applicable. Format: 'folders/FOLDER\_ID' or 'projects/PROJECT\_NUMBER' | `list(string)` | `[]` | no | ## Outputs diff --git a/build/int.cloudbuild.yaml b/build/int.cloudbuild.yaml index c8d75f53..29b2db67 100644 --- a/build/int.cloudbuild.yaml +++ b/build/int.cloudbuild.yaml @@ -73,6 +73,54 @@ steps: - verify-simple-example-bridge-local name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'source /usr/local/bin/task_helper_functions.sh && kitchen_do destroy simple-example-bridge-local'] +# scoped example with ingress rule +- id: init-scoped-example-with-ingress-rule + waitFor: + - destroy-simple-example-bridge-local + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithIngressRule --stage init --verbose'] + +- id: apply-scoped-example-with-ingress-rule + waitFor: + - init-scoped-example-with-ingress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithIngressRule --stage apply --verbose'] + +- id: verify-scoped-example-with-ingress-rule + waitFor: + - apply-scoped-example-with-ingress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithIngressRule --stage verify --verbose'] + +- id: destroy-scoped-example-with-ingress-rule + waitFor: + - verify-scoped-example-with-ingress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithIngressRule --stage destroy --verbose'] +# scoped example with egress rule +- id: init-scoped-example-with-egress-rule + waitFor: + - destroy-scoped-example-with-ingress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithEgressRule --stage init --verbose'] + +- id: apply-scoped-example-with-egress-rule + waitFor: + - init-scoped-example-with-egress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithEgressRule --stage apply --verbose'] + +- id: verify-scoped-example-with-egress-rule + waitFor: + - apply-scoped-example-with-egress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithEgressRule --stage verify --verbose'] + +- id: destroy-scoped-example-with-egress-rule + waitFor: + - verify-scoped-example-with-egress-rule + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestScopedExampleWithEgressRule --stage destroy --verbose'] tags: - 'ci' - 'integration' diff --git a/examples/scoped_example_with_egress_rule/README.md b/examples/scoped_example_with_egress_rule/README.md new file mode 100644 index 00000000..cf8b9034 --- /dev/null +++ b/examples/scoped_example_with_egress_rule/README.md @@ -0,0 +1,50 @@ +# Scoped Example with Egress Rule + +This example illustrates how to use the `vpc-service-controls` module to configure a scoped org policy, a regular perimeter and external storage buckets that can be access in it from inside read only via egress rule. + +# Requirements + +1. Make sure you've gone through the root [Requirement Section](../../README.md#requirements) on any project in your organization. +2. If you need to run integration tests for this example, select a second project in your organization. The project you already configured will be referred as the protected project that will be inside of the regular service perimeter. The second project will be the public project, which will be outside of the regular service perimeter. +3. Grant the service account the following permissions on the public project: + - roles/storage.Admin + +You may use the following gcloud commands: + `gcloud projects add-iam-policy-binding --member=serviceAccount: --role=roles/storage.Admin` + +1. Enable Storage API on the protected project. +2. If you want to run the integration tests for this example, repeat step #3 and #4 on the protected project. + + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| access\_level\_name | Access level name of the Access Policy. | `string` | `"terraform_members_e"` | no | +| buckets\_names | Buckets Names as list of strings | `list(string)` |
[
"bucket1-e",
"bucket2-e"
]
| no | +| buckets\_prefix | Bucket Prefix | `string` | `"test-bucket-e"` | no | +| members | An allowed list of members (users, service accounts). The signed-in identity originating the request must be a part of one of the provided members. If not specified, a request may come from any user (logged in/not logged in, etc.). Formats: user:{emailid}, serviceAccount:{emailid} | `list(string)` | n/a | yes | +| parent\_id | The parent of this AccessPolicy in the Cloud Resource Hierarchy. As of now, only organization are accepted as parent. | `string` | n/a | yes | +| perimeter\_name | Perimeter name of the Access Policy.. | `string` | `"regular_perimeter_e"` | no | +| policy\_name | The policy's name. | `string` | n/a | yes | +| protected\_project\_ids | Project id and number of the project INSIDE the regular service perimeter. This map variable expects an "id" for the project id and "number" key for the project number. | `object({ id = string, number = number })` | n/a | yes | +| public\_project\_ids | Project id and number of the project OUTSIDE the regular service perimeter. This map variable expects an "id" for the project id and "number" key for the project number. | `object({ id = string, number = number })` | n/a | yes | +| regions | The request must originate from one of the provided countries/regions. Format: A valid ISO 3166-1 alpha-2 code. | `list(string)` | `[]` | no | +| scopes | Folder or project on which this policy is applicable. Format: 'folders/FOLDER\_ID' or 'projects/PROJECT\_NUMBER' | `list(string)` | `[]` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| policy\_id | Resource name of the AccessPolicy. | +| policy\_name | Name of the parent policy | +| protected\_project\_id | Project id of the project INSIDE the regular service perimeter | +| service\_perimeter\_name | Service perimeter name | + + + +To provision this example, run the following from within this directory: +- `terraform init` to get the plugins +- `terraform plan` to see the infrastructure plan +- `terraform apply` to apply the infrastructure build +- `terraform destroy` to destroy the built infrastructure diff --git a/examples/scoped_example_with_egress_rule/main.tf b/examples/scoped_example_with_egress_rule/main.tf new file mode 100644 index 00000000..448ac214 --- /dev/null +++ b/examples/scoped_example_with_egress_rule/main.tf @@ -0,0 +1,131 @@ +/** + * 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. + */ + +module "access_context_manager_policy" { + source = "terraform-google-modules/vpc-service-controls/google" + version = "~> 7.1" + + parent_id = var.parent_id + policy_name = var.policy_name + scopes = var.scopes + + depends_on = [module.gcs_buckets] +} + +module "access_level_members" { + source = "terraform-google-modules/vpc-service-controls/google//modules/access_level" + version = "~> 7.1" + + description = "Simple Example Access Level" + policy = module.access_context_manager_policy.policy_id + name = var.access_level_name + members = var.members + regions = var.regions +} + +resource "time_sleep" "wait_for_members" { + create_duration = "90s" + destroy_duration = "90s" + + depends_on = [module.access_level_members] +} + +module "regular_service_perimeter_1" { + source = "terraform-google-modules/vpc-service-controls/google//modules/regular_service_perimeter" + version = "~> 7.1" + + + policy = module.access_context_manager_policy.policy_id + perimeter_name = var.perimeter_name + + description = "Perimeter shielding bigquery project" + resources = [var.protected_project_ids["number"]] + access_levels = [module.access_level_members.name] + + restricted_services = ["bigquery.googleapis.com", "storage.googleapis.com"] + + egress_policies = [ + { + title = "Read outside buckets from project" + from = { + sources = { + resources = ["projects/${var.protected_project_ids["number"]}"] + }, + identity_type = "ANY_SERVICE_ACCOUNT" + } + to = { + resources = [ + "projects/${var.public_project_ids["number"]}" + ] + operations = { + "storage.googleapis.com" = { + methods = [ + "google.storage.objects.get", + "google.storage.objects.list" + ] + } + } + } + }, + { + title = "Use permissions for Big Query access" # See https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions + from = { + sources = { + resources = ["projects/${var.protected_project_ids["number"]}"] + }, + identity_type = "ANY_SERVICE_ACCOUNT" + } + to = { + resources = [ + "projects/${var.public_project_ids["number"]}" + ] + operations = { + "bigquery.googleapis.com" = { + permissions = [ + "bigquery.datasets.get", + "bigquery.models.getData", + "bigquery.models.getMetadata", + "bigquery.models.list", + "bigquery.tables.get", + "bigquery.tables.getData", + "bigquery.tables.list" + ] + } + } + } + }, + ] + + shared_resources = { + all = [var.protected_project_ids["number"]] + } + + depends_on = [ + module.gcs_buckets, + time_sleep.wait_for_members + ] +} + +module "gcs_buckets" { + source = "terraform-google-modules/cloud-storage/google" + version = "~> 10.0" + project_id = var.public_project_ids["id"] + names = var.buckets_names + randomize_suffix = true + prefix = var.buckets_prefix + set_admin_roles = true + admins = var.members +} diff --git a/examples/scoped_example_with_egress_rule/outputs.tf b/examples/scoped_example_with_egress_rule/outputs.tf new file mode 100644 index 00000000..31e20ef6 --- /dev/null +++ b/examples/scoped_example_with_egress_rule/outputs.tf @@ -0,0 +1,36 @@ +/** + * 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. + */ + +output "policy_id" { + description = "Resource name of the AccessPolicy." + value = module.access_context_manager_policy.policy_id +} + +output "policy_name" { + description = "Name of the parent policy" + value = var.policy_name +} + + +output "protected_project_id" { + description = "Project id of the project INSIDE the regular service perimeter" + value = var.protected_project_ids["id"] +} + +output "service_perimeter_name" { + description = "Service perimeter name" + value = module.regular_service_perimeter_1.perimeter_name +} diff --git a/examples/scoped_example_with_egress_rule/variables.tf b/examples/scoped_example_with_egress_rule/variables.tf new file mode 100644 index 00000000..1d8ecc15 --- /dev/null +++ b/examples/scoped_example_with_egress_rule/variables.tf @@ -0,0 +1,76 @@ +/** + * 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 "parent_id" { + description = "The parent of this AccessPolicy in the Cloud Resource Hierarchy. As of now, only organization are accepted as parent." + type = string +} + +variable "policy_name" { + description = "The policy's name." + type = string +} + +variable "protected_project_ids" { + description = "Project id and number of the project INSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + +variable "public_project_ids" { + description = "Project id and number of the project OUTSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + +variable "members" { + description = "An allowed list of members (users, service accounts). The signed-in identity originating the request must be a part of one of the provided members. If not specified, a request may come from any user (logged in/not logged in, etc.). Formats: user:{emailid}, serviceAccount:{emailid}" + type = list(string) +} + +variable "regions" { + description = "The request must originate from one of the provided countries/regions. Format: A valid ISO 3166-1 alpha-2 code." + type = list(string) + default = [] +} + +variable "perimeter_name" { + description = "Perimeter name of the Access Policy.." + type = string + default = "regular_perimeter_e" +} + +variable "access_level_name" { + description = "Access level name of the Access Policy." + type = string + default = "terraform_members_e" +} + +variable "buckets_prefix" { + description = "Bucket Prefix" + type = string + default = "test-bucket-e" +} + +variable "buckets_names" { + description = "Buckets Names as list of strings" + type = list(string) + default = ["bucket1-e", "bucket2-e"] +} + +variable "scopes" { + description = "Folder or project on which this policy is applicable. Format: 'folders/FOLDER_ID' or 'projects/PROJECT_NUMBER'" + type = list(string) + default = [] +} diff --git a/examples/simple_example_with_ingress_rule/README.md b/examples/scoped_example_with_ingress_rule/README.md similarity index 84% rename from examples/simple_example_with_ingress_rule/README.md rename to examples/scoped_example_with_ingress_rule/README.md index dd2e942c..0a2f79a3 100644 --- a/examples/simple_example_with_ingress_rule/README.md +++ b/examples/scoped_example_with_ingress_rule/README.md @@ -1,5 +1,7 @@ # Simple Example with Ingress Rule -This example illustrates how to use the `vpc-service-controls` module to configure an org policy, a regular perimeter with storage buckets that can be access in it from outside read only via ingress rule. + +This example illustrates how to use the `vpc-service-controls` module to configure a scoped org policy, a regular perimeter with storage buckets that can be access in it from outside read only via ingress rule. + # Requirements 1. Make sure you've gone through the root [Requirement Section](../../README.md#requirements) on any project in your organization. @@ -26,8 +28,10 @@ You may use the following gcloud commands: | perimeter\_name | Perimeter name of the Access Policy.. | `string` | `"regular_perimeter_1"` | no | | policy\_name | The policy's name. | `string` | n/a | yes | | protected\_project\_ids | Project id and number of the project INSIDE the regular service perimeter. This map variable expects an "id" for the project id and "number" key for the project number. | `object({ id = string, number = number })` | n/a | yes | +| public\_project\_ids | Project id and number of the project OUTSIDE the regular service perimeter. This map variable expects an "id" for the project id and "number" key for the project number. | `object({ id = string, number = number })` | n/a | yes | | read\_bucket\_identities | List of all identities should get read access on bucket | `list(string)` | `[]` | no | | regions | The request must originate from one of the provided countries/regions. Format: A valid ISO 3166-1 alpha-2 code. | `list(string)` | `[]` | no | +| scopes | Folder or project on which this policy is applicable. Format: 'folders/FOLDER\_ID' or 'projects/PROJECT\_NUMBER' | `list(string)` | `[]` | no | ## Outputs @@ -36,6 +40,7 @@ You may use the following gcloud commands: | policy\_id | Resource name of the AccessPolicy. | | policy\_name | Name of the parent policy | | protected\_project\_id | Project id of the project INSIDE the regular service perimeter | +| service\_perimeter\_name | Service perimeter name | diff --git a/examples/simple_example_with_ingress_rule/main.tf b/examples/scoped_example_with_ingress_rule/main.tf similarity index 60% rename from examples/simple_example_with_ingress_rule/main.tf rename to examples/scoped_example_with_ingress_rule/main.tf index 7fd4a203..b63363a0 100644 --- a/examples/simple_example_with_ingress_rule/main.tf +++ b/examples/scoped_example_with_ingress_rule/main.tf @@ -16,15 +16,18 @@ module "access_context_manager_policy" { source = "terraform-google-modules/vpc-service-controls/google" - version = "~> 7.0" + version = "~> 7.1" parent_id = var.parent_id policy_name = var.policy_name + scopes = var.scopes + + depends_on = [module.gcs_buckets] } module "access_level_members" { source = "terraform-google-modules/vpc-service-controls/google//modules/access_level" - version = "~> 7.0" + version = "~> 7.1" description = "Simple Example Access Level" policy = module.access_context_manager_policy.policy_id @@ -33,17 +36,16 @@ module "access_level_members" { regions = var.regions } -resource "null_resource" "wait_for_members" { - provisioner "local-exec" { - command = "sleep 60" - } +resource "time_sleep" "wait_for_members" { + create_duration = "90s" + destroy_duration = "90s" depends_on = [module.access_level_members] } module "regular_service_perimeter_1" { source = "terraform-google-modules/vpc-service-controls/google//modules/regular_service_perimeter" - version = "~> 7.0" + version = "~> 7.1" policy = module.access_context_manager_policy.policy_id perimeter_name = var.perimeter_name @@ -56,19 +58,46 @@ module "regular_service_perimeter_1" { ingress_policies = [ { - "from" = { - "sources" = { + title = "Allow Access from everywhere" + from = { + sources = { access_levels = ["*"] # Allow Access from everywhere }, - "identities" = var.read_bucket_identities + identities = var.read_bucket_identities + + } + to = { + resources = [ + "*" + ] + operations = { + "storage.googleapis.com" = { + methods = [ + "google.storage.objects.get", + "google.storage.objects.list" + ] + } + } + } + }, + + + { + title = "Allow Access from project" + from = { + sources = { + resources = ["projects/${var.public_project_ids["number"]}"] # Allow Access from project + }, + identity_type = "ANY_SERVICE_ACCOUNT" + } - "to" = { - "resources" = [ + to = { + resources = [ "*" ] - "operations" = { + operations = { "storage.googleapis.com" = { - "methods" = [ + methods = [ "google.storage.objects.get", "google.storage.objects.list" ] @@ -76,6 +105,25 @@ module "regular_service_perimeter_1" { } } }, + { + title = "without from source" + from = { + identities = var.read_bucket_identities + } + to = { + resources = [ + "projects/${var.protected_project_ids["number"]}" + ] + operations = { + "storage.googleapis.com" = { + methods = [ + "google.storage.objects.get", + "google.storage.objects.list" + ] + } + } + } + } ] shared_resources = { @@ -83,15 +131,15 @@ module "regular_service_perimeter_1" { } depends_on = [ - module.gcs_buckets + module.gcs_buckets, + time_sleep.wait_for_members ] } - module "gcs_buckets" { source = "terraform-google-modules/cloud-storage/google" version = "~> 10.0" - project_id = var.protected_project_ids["id"] + project_id = var.public_project_ids["id"] names = var.buckets_names randomize_suffix = true prefix = var.buckets_prefix diff --git a/examples/simple_example_with_ingress_rule/outputs.tf b/examples/scoped_example_with_ingress_rule/outputs.tf similarity index 87% rename from examples/simple_example_with_ingress_rule/outputs.tf rename to examples/scoped_example_with_ingress_rule/outputs.tf index 835b6fb3..b471426c 100644 --- a/examples/simple_example_with_ingress_rule/outputs.tf +++ b/examples/scoped_example_with_ingress_rule/outputs.tf @@ -24,6 +24,10 @@ output "policy_name" { value = var.policy_name } +output "service_perimeter_name" { + description = "Service perimeter name" + value = module.regular_service_perimeter_1.perimeter_name +} output "protected_project_id" { description = "Project id of the project INSIDE the regular service perimeter" diff --git a/examples/simple_example_with_ingress_rule/variables.tf b/examples/scoped_example_with_ingress_rule/variables.tf similarity index 83% rename from examples/simple_example_with_ingress_rule/variables.tf rename to examples/scoped_example_with_ingress_rule/variables.tf index 5cae444d..70b6aa50 100644 --- a/examples/simple_example_with_ingress_rule/variables.tf +++ b/examples/scoped_example_with_ingress_rule/variables.tf @@ -29,6 +29,11 @@ variable "protected_project_ids" { type = object({ id = string, number = number }) } +variable "public_project_ids" { + description = "Project id and number of the project OUTSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + variable "members" { description = "An allowed list of members (users, service accounts). The signed-in identity originating the request must be a part of one of the provided members. If not specified, a request may come from any user (logged in/not logged in, etc.). Formats: user:{emailid}, serviceAccount:{emailid}" type = list(string) @@ -69,3 +74,9 @@ variable "buckets_names" { type = list(string) default = ["bucket1", "bucket2"] } + +variable "scopes" { + description = "Folder or project on which this policy is applicable. Format: 'folders/FOLDER_ID' or 'projects/PROJECT_NUMBER'" + type = list(string) + default = [] +} diff --git a/main.tf b/main.tf index 06c19ee0..cbb1b4cc 100644 --- a/main.tf +++ b/main.tf @@ -16,6 +16,8 @@ resource "google_access_context_manager_access_policy" "access_policy" { provider = google - parent = "organizations/${var.parent_id}" - title = var.policy_name + + title = var.policy_name + parent = "organizations/${var.parent_id}" + scopes = var.scopes } diff --git a/modules/regular_service_perimeter/README.md b/modules/regular_service_perimeter/README.md index d31e3485..caef91e2 100644 --- a/modules/regular_service_perimeter/README.md +++ b/modules/regular_service_perimeter/README.md @@ -98,10 +98,10 @@ module "regular_service_perimeter_1" { | access\_levels | A list of AccessLevel resource names that allow resources within the ServicePerimeter to be accessed from the internet. AccessLevels listed must be in the same policy as this ServicePerimeter. Referencing a nonexistent AccessLevel is a syntax error. If no AccessLevel names are listed, resources within the perimeter can only be accessed via GCP calls with request origins within the perimeter. Example: 'accessPolicies/MY\_POLICY/accessLevels/MY\_LEVEL'. For Service Perimeter Bridge, must be empty. | `list(string)` | `[]` | no | | access\_levels\_dry\_run | (Dry-run) A list of AccessLevel resource names that allow resources within the ServicePerimeter to be accessed from the internet. AccessLevels listed must be in the same policy as this ServicePerimeter. Referencing a nonexistent AccessLevel is a syntax error. If no AccessLevel names are listed, resources within the perimeter can only be accessed via GCP calls with request origins within the perimeter. Example: 'accessPolicies/MY\_POLICY/accessLevels/MY\_LEVEL'. For Service Perimeter Bridge, must be empty. If set, a dry-run policy will be set. | `list(string)` | `[]` | no | | description | Description of the regular perimeter | `string` | n/a | yes | -| egress\_policies | A list of all [egress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#egress-rules-reference), each list object has a `from` and `to` value that describes egress\_from and egress\_to.

Example: `[{ from={ identities=[], identity_type="ID_TYPE" }, to={ resources=[], operations={ "SRV_NAME"={ OP_TYPE=[] }}}}]`

Valid Values:
`ID_TYPE` = `null` or `IDENTITY_TYPE_UNSPECIFIED` (only allow indentities from list); `ANY_IDENTITY`; `ANY_USER_ACCOUNT`; `ANY_SERVICE_ACCOUNT`
`SRV_NAME` = "`*`" (allow all services) or [Specific Services](https://cloud.google.com/vpc-service-controls/docs/supported-products#supported_products)
`OP_TYPE` = [methods](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) or [permissions](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) |
list(object({
from = any
to = any
}))
| `[]` | no | -| egress\_policies\_dry\_run | A list of all [egress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#egress-rules-reference), each list object has a `from` and `to` value that describes egress\_from and egress\_to. Use same formatting as `egress_policies`. |
list(object({
from = any
to = any
}))
| `[]` | no | -| ingress\_policies | A list of all [ingress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#ingress-rules-reference), each list object has a `from` and `to` value that describes ingress\_from and ingress\_to.

Example: `[{ from={ sources={ resources=[], access_levels=[] }, identities=[], identity_type="ID_TYPE" }, to={ resources=[], operations={ "SRV_NAME"={ OP_TYPE=[] }}}}]`

Valid Values:
`ID_TYPE` = `null` or `IDENTITY_TYPE_UNSPECIFIED` (only allow indentities from list); `ANY_IDENTITY`; `ANY_USER_ACCOUNT`; `ANY_SERVICE_ACCOUNT`
`SRV_NAME` = "`*`" (allow all services) or [Specific Services](https://cloud.google.com/vpc-service-controls/docs/supported-products#supported_products)
`OP_TYPE` = [methods](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) or [permissions](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) |
list(object({
from = any
to = any
}))
| `[]` | no | -| ingress\_policies\_dry\_run | A list of all [ingress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#ingress-rules-reference), each list object has a `from` and `to` value that describes ingress\_from and ingress\_to. Use same formatting as `ingress_policies`. |
list(object({
from = any
to = any
}))
| `[]` | no | +| egress\_policies | A list of all [egress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#egress-rules-reference), each list object has a `from` and `to` value that describes egress\_from and egress\_to.

Example: `[{ from={ identities=[], identity_type="ID_TYPE" }, to={ resources=[], operations={ "SRV_NAME"={ OP_TYPE=[] }}}}]`

Valid Values:
`ID_TYPE` = `null` or `IDENTITY_TYPE_UNSPECIFIED` (only allow indentities from list); `ANY_IDENTITY`; `ANY_USER_ACCOUNT`; `ANY_SERVICE_ACCOUNT`
`SRV_NAME` = "`*`" (allow all services) or [Specific Services](https://cloud.google.com/vpc-service-controls/docs/supported-products#supported_products)
`OP_TYPE` = [methods](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) or [permissions](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) |
list(object({
title = optional(string, null)
from = object({
sources = optional(object({
resources = optional(list(string), [])
access_levels = optional(list(string), [])
}), {}),
identity_type = optional(string, null)
identities = optional(list(string), null)
})
to = object({
operations = optional(map(object({
methods = optional(list(string), [])
permissions = optional(list(string), [])
})), {}),
roles = optional(list(string), null)
resources = optional(list(string), ["*"])
external_resources = optional(list(string), [])
})
}))
| `[]` | no | +| egress\_policies\_dry\_run | A list of all [egress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#egress-rules-reference), each list object has a `from` and `to` value that describes egress\_from and egress\_to. Use same formatting as `egress_policies`. |
list(object({
title = optional(string, null)
from = object({
sources = optional(object({
resources = optional(list(string), [])
access_levels = optional(list(string), [])
}), {}),
identity_type = optional(string, null)
identities = optional(list(string), null)
})
to = object({
operations = optional(map(object({
methods = optional(list(string), [])
permissions = optional(list(string), [])
})), {}),
roles = optional(list(string), null)
resources = optional(list(string), ["*"])
external_resources = optional(list(string), [])
})
}))
| `[]` | no | +| ingress\_policies | A list of all [ingress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#ingress-rules-reference), each list object has a `from` and `to` value that describes ingress\_from and ingress\_to.

Example: `[{ from={ sources={ resources=[], access_levels=[] }, identities=[], identity_type="ID_TYPE" }, to={ resources=[], operations={ "SRV_NAME"={ OP_TYPE=[] }}}}]`

Valid Values:
`ID_TYPE` = `null` or `IDENTITY_TYPE_UNSPECIFIED` (only allow indentities from list); `ANY_IDENTITY`; `ANY_USER_ACCOUNT`; `ANY_SERVICE_ACCOUNT`
`SRV_NAME` = "`*`" (allow all services) or [Specific Services](https://cloud.google.com/vpc-service-controls/docs/supported-products#supported_products)
`OP_TYPE` = [methods](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) or [permissions](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) |
list(object({
title = optional(string, null)
from = object({
sources = optional(object({
resources = optional(list(string), [])
access_levels = optional(list(string), [])
}), {}),
identity_type = optional(string, null)
identities = optional(list(string), null)
})
to = object({
operations = optional(map(object({
methods = optional(list(string), [])
permissions = optional(list(string), [])
})), {}),
roles = optional(list(string), null)
resources = optional(list(string), ["*"])
})
}))
| `[]` | no | +| ingress\_policies\_dry\_run | A list of all [ingress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#ingress-rules-reference), each list object has a `from` and `to` value that describes ingress\_from and ingress\_to. Use same formatting as `ingress_policies`. |
list(object({
title = optional(string, null)
from = object({
sources = optional(object({
resources = optional(list(string), [])
access_levels = optional(list(string), [])
}), {}),
identity_type = optional(string, null)
identities = optional(list(string), null)
})
to = object({
operations = optional(map(object({
methods = optional(list(string), [])
permissions = optional(list(string), [])
})), {}),
roles = optional(list(string), null)
resources = optional(list(string), ["*"])
})
}))
| `[]` | no | | perimeter\_name | Name of the perimeter. Should be one unified string. Must only be letters, numbers and underscores | `string` | n/a | yes | | policy | Name of the parent policy | `string` | n/a | yes | | resource\_keys | A list of keys to use for the Terraform state. The order should correspond to var.resources and the keys must not be dynamically computed. If `null`, var.resources will be used as keys. | `list(string)` | `null` | no | diff --git a/modules/regular_service_perimeter/main.tf b/modules/regular_service_perimeter/main.tf index ddf37cf7..cd5a0ab4 100644 --- a/modules/regular_service_perimeter/main.tf +++ b/modules/regular_service_perimeter/main.tf @@ -72,7 +72,10 @@ resource "google_access_context_manager_service_perimeter" "regular_service_peri ignore_changes = [ status[0].resources, status[0].ingress_policies, # Allows ingress policies to be managed by google_access_context_manager_service_perimeter_ingress_policy resources - status[0].egress_policies # Allows egress policies to be managed by google_access_context_manager_service_perimeter_egress_policy resources + status[0].egress_policies, # Allows egress policies to be managed by google_access_context_manager_service_perimeter_egress_policy resources + spec[0].resources, + spec[0].ingress_policies, # Allows dry-run ingress policies to be managed by google_access_context_manager_service_perimeter_ingress_policy resources + spec[0].egress_policies # Allows dry-run egress policies to be managed by google_access_context_manager_service_perimeter_egress_policy resources ] } } diff --git a/modules/regular_service_perimeter/variables.tf b/modules/regular_service_perimeter/variables.tf index c8635e29..a59d163f 100644 --- a/modules/regular_service_perimeter/variables.tf +++ b/modules/regular_service_perimeter/variables.tf @@ -77,34 +77,79 @@ variable "shared_resources" { default = { all = [] } } - ## Have to solve it like this don't want use optional flag because is still experimental variable "egress_policies" { description = "A list of all [egress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#egress-rules-reference), each list object has a `from` and `to` value that describes egress_from and egress_to.\n\nExample: `[{ from={ identities=[], identity_type=\"ID_TYPE\" }, to={ resources=[], operations={ \"SRV_NAME\"={ OP_TYPE=[] }}}}]`\n\nValid Values:\n`ID_TYPE` = `null` or `IDENTITY_TYPE_UNSPECIFIED` (only allow indentities from list); `ANY_IDENTITY`; `ANY_USER_ACCOUNT`; `ANY_SERVICE_ACCOUNT`\n`SRV_NAME` = \"`*`\" (allow all services) or [Specific Services](https://cloud.google.com/vpc-service-controls/docs/supported-products#supported_products)\n`OP_TYPE` = [methods](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) or [permissions](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions)" type = list(object({ - from = any - to = any + title = optional(string, null) + from = object({ + sources = optional(object({ + resources = optional(list(string), []) + access_levels = optional(list(string), []) + }), {}), + identity_type = optional(string, null) + identities = optional(list(string), null) + }) + to = object({ + operations = optional(map(object({ + methods = optional(list(string), []) + permissions = optional(list(string), []) + })), {}), + roles = optional(list(string), null) + resources = optional(list(string), ["*"]) + external_resources = optional(list(string), []) + }) })) default = [] } ## Have to solve it like this don't want use optional flag because is still experimental -variable "ingress_policies" { +variable "ingress_policies" { # TODO description = "A list of all [ingress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#ingress-rules-reference), each list object has a `from` and `to` value that describes ingress_from and ingress_to.\n\nExample: `[{ from={ sources={ resources=[], access_levels=[] }, identities=[], identity_type=\"ID_TYPE\" }, to={ resources=[], operations={ \"SRV_NAME\"={ OP_TYPE=[] }}}}]`\n\nValid Values:\n`ID_TYPE` = `null` or `IDENTITY_TYPE_UNSPECIFIED` (only allow indentities from list); `ANY_IDENTITY`; `ANY_USER_ACCOUNT`; `ANY_SERVICE_ACCOUNT`\n`SRV_NAME` = \"`*`\" (allow all services) or [Specific Services](https://cloud.google.com/vpc-service-controls/docs/supported-products#supported_products)\n`OP_TYPE` = [methods](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions) or [permissions](https://cloud.google.com/vpc-service-controls/docs/supported-method-restrictions)" type = list(object({ - from = any - to = any + title = optional(string, null) + from = object({ + sources = optional(object({ + resources = optional(list(string), []) + access_levels = optional(list(string), []) + }), {}), + identity_type = optional(string, null) + identities = optional(list(string), null) + }) + to = object({ + operations = optional(map(object({ + methods = optional(list(string), []) + permissions = optional(list(string), []) + })), {}), + roles = optional(list(string), null) + resources = optional(list(string), ["*"]) + }) })) default = [] } - ## Have to solve it like this don't want use optional flag because is still experimental variable "egress_policies_dry_run" { description = "A list of all [egress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#egress-rules-reference), each list object has a `from` and `to` value that describes egress_from and egress_to. Use same formatting as `egress_policies`." type = list(object({ - from = any - to = any + title = optional(string, null) + from = object({ + sources = optional(object({ + resources = optional(list(string), []) + access_levels = optional(list(string), []) + }), {}), + identity_type = optional(string, null) + identities = optional(list(string), null) + }) + to = object({ + operations = optional(map(object({ + methods = optional(list(string), []) + permissions = optional(list(string), []) + })), {}), + roles = optional(list(string), null) + resources = optional(list(string), ["*"]) + external_resources = optional(list(string), []) + }) })) default = [] } @@ -113,8 +158,23 @@ variable "egress_policies_dry_run" { variable "ingress_policies_dry_run" { description = "A list of all [ingress policies](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules#ingress-rules-reference), each list object has a `from` and `to` value that describes ingress_from and ingress_to. Use same formatting as `ingress_policies`." type = list(object({ - from = any - to = any + title = optional(string, null) + from = object({ + sources = optional(object({ + resources = optional(list(string), []) + access_levels = optional(list(string), []) + }), {}), + identity_type = optional(string, null) + identities = optional(list(string), null) + }) + to = object({ + operations = optional(map(object({ + methods = optional(list(string), []) + permissions = optional(list(string), []) + })), {}), + roles = optional(list(string), null) + resources = optional(list(string), ["*"]) + }) })) default = [] } diff --git a/modules/regular_service_perimeter/vpc-sc-policies.tf b/modules/regular_service_perimeter/vpc-sc-policies.tf index 93aec13b..f062e3e4 100644 --- a/modules/regular_service_perimeter/vpc-sc-policies.tf +++ b/modules/regular_service_perimeter/vpc-sc-policies.tf @@ -18,31 +18,33 @@ resource "google_access_context_manager_service_perimeter_ingress_policy" "ingre for_each = { for k, v in var.ingress_policies : k => v } perimeter = google_access_context_manager_service_perimeter.regular_service_perimeter.name - title = "Ingress Policy ${each.key}" + title = coalesce(each.value["title"], "Ingress Policy ${each.key}") ingress_from { dynamic "sources" { for_each = merge( - { for k, v in lookup(lookup(each.value["from"], "sources", {}), "resources", []) : v => "resource" }, - { for k, v in lookup(lookup(each.value["from"], "sources", {}), "access_levels", []) : v => "access_level" }) + { for k, v in each.value["from"]["sources"]["resources"] : v => "resource" }, + { for k, v in each.value["from"]["sources"]["access_levels"] : v => "access_level" } + ) content { resource = sources.value == "resource" ? sources.key : null access_level = sources.value == "access_level" ? sources.key != "*" ? "accessPolicies/${var.policy}/accessLevels/${sources.key}" : "*" : null } } - identity_type = lookup(each.value["from"], "identity_type", null) - identities = lookup(each.value["from"], "identities", null) + identity_type = each.value["from"]["identity_type"] + identities = each.value["from"]["identities"] } ingress_to { - resources = lookup(each.value["to"], "resources", ["*"]) + resources = each.value["to"]["resources"] dynamic "operations" { - for_each = lookup(each.value["to"], "operations", []) + for_each = each.value["to"]["operations"] content { service_name = operations.key dynamic "method_selectors" { for_each = operations.key != "*" ? merge( - { for v in lookup(operations.value, "methods", []) : v => "method" }, - { for v in lookup(operations.value, "permissions", []) : v => "permission" }) : {} + { for v in operations.value["methods"] : v => "method" }, + { for v in operations.value["permissions"] : v => "permission" } + ) : {} content { method = method_selectors.value == "method" ? method_selectors.key : null permission = method_selectors.value == "permission" ? method_selectors.key : null @@ -60,30 +62,35 @@ resource "google_access_context_manager_service_perimeter_egress_policy" "egress for_each = { for k, v in var.egress_policies : k => v } perimeter = google_access_context_manager_service_perimeter.regular_service_perimeter.name - title = "Egress Policy ${each.key}" + title = coalesce(each.value["title"], "Egress Policy ${each.key}") egress_from { - identity_type = lookup(each.value["from"], "identity_type", null) - identities = lookup(each.value["from"], "identities", null) + identity_type = each.value["from"]["identity_type"] + identities = each.value["from"]["identities"] dynamic "sources" { - for_each = { for k, v in lookup(lookup(each.value["from"], "sources", {}), "access_levels", []) : v => "access_level" } + for_each = merge( + { for k, v in each.value["from"]["sources"]["resources"] : v => "resource" }, + { for k, v in each.value["from"]["sources"]["access_levels"] : v => "access_level" } + ) content { + resource = sources.value == "resource" ? sources.key : null access_level = sources.value == "access_level" ? sources.key != "*" ? "accessPolicies/${var.policy}/accessLevels/${sources.key}" : "*" : null } } - source_restriction = lookup(each.value["from"], "sources", null) != null ? "SOURCE_RESTRICTION_ENABLED" : null + source_restriction = each.value["from"]["sources"] != {} ? "SOURCE_RESTRICTION_ENABLED" : null } egress_to { - resources = lookup(each.value["to"], "resources", ["*"]) - external_resources = lookup(each.value["to"], "external_resources", []) + resources = each.value["to"]["resources"] + external_resources = each.value["to"]["external_resources"] dynamic "operations" { - for_each = lookup(each.value["to"], "operations", []) + for_each = each.value["to"]["operations"] content { service_name = operations.key dynamic "method_selectors" { for_each = operations.key != "*" ? merge( - { for v in lookup(operations.value, "methods", []) : v => "method" }, - { for v in lookup(operations.value, "permissions", []) : v => "permission" }) : {} + { for v in operations.value["methods"] : v => "method" }, + { for v in operations.value["permissions"] : v => "permission" } + ) : {} content { method = method_selectors.value == "method" ? method_selectors.key : null permission = method_selectors.value == "permission" ? method_selectors.key : null @@ -105,31 +112,33 @@ resource "google_access_context_manager_service_perimeter_dry_run_ingress_policy for_each = { for k, v in var.ingress_policies_dry_run : k => v } perimeter = google_access_context_manager_service_perimeter.regular_service_perimeter.name - title = "Ingress Policy ${each.key}" + title = coalesce(each.value["title"], "Ingress Policy ${each.key}") ingress_from { dynamic "sources" { for_each = merge( - { for k, v in lookup(lookup(each.value["from"], "sources", {}), "resources", []) : v => "resource" }, - { for k, v in lookup(lookup(each.value["from"], "sources", {}), "access_levels", []) : v => "access_level" }) + { for k, v in each.value["from"]["sources"]["resources"] : v => "resource" }, + { for k, v in each.value["from"]["sources"]["access_levels"] : v => "access_level" } + ) content { resource = sources.value == "resource" ? sources.key : null access_level = sources.value == "access_level" ? sources.key != "*" ? "accessPolicies/${var.policy}/accessLevels/${sources.key}" : "*" : null } } - identity_type = lookup(each.value["from"], "identity_type", null) - identities = lookup(each.value["from"], "identities", null) + identity_type = each.value["from"]["identity_type"] + identities = each.value["from"]["identities"] } ingress_to { - resources = lookup(each.value["to"], "resources", ["*"]) + resources = each.value["to"]["resources"] dynamic "operations" { - for_each = lookup(each.value["to"], "operations", []) + for_each = each.value["to"]["operations"] content { service_name = operations.key dynamic "method_selectors" { for_each = operations.key != "*" ? merge( - { for v in lookup(operations.value, "methods", []) : v => "method" }, - { for v in lookup(operations.value, "permissions", []) : v => "permission" }) : {} + { for v in operations.value["methods"] : v => "method" }, + { for v in operations.value["permissions"] : v => "permission" } + ) : {} content { method = method_selectors.value == "method" ? method_selectors.key : null permission = method_selectors.value == "permission" ? method_selectors.key : null @@ -147,29 +156,35 @@ resource "google_access_context_manager_service_perimeter_dry_run_egress_policy" for_each = { for k, v in var.egress_policies_dry_run : k => v } perimeter = google_access_context_manager_service_perimeter.regular_service_perimeter.name - title = "Egress Policy ${each.key}" + title = coalesce(each.value["title"], "Egress Policy ${each.key}") egress_from { - identity_type = lookup(each.value["from"], "identity_type", null) - identities = lookup(each.value["from"], "identities", null) + identity_type = each.value["from"]["identity_type"] + identities = each.value["from"]["identities"] dynamic "sources" { - for_each = { for k, v in lookup(lookup(each.value["from"], "sources", {}), "access_levels", []) : v => "access_level" } + for_each = merge( + { for k, v in each.value["from"]["sources"]["resources"] : v => "resource" }, + { for k, v in each.value["from"]["sources"]["access_levels"] : v => "access_level" } + ) content { + resource = sources.value == "resource" ? sources.key : null access_level = sources.value == "access_level" ? sources.key != "*" ? "accessPolicies/${var.policy}/accessLevels/${sources.key}" : "*" : null } } - source_restriction = lookup(each.value["from"], "sources", null) != null ? "SOURCE_RESTRICTION_ENABLED" : null + source_restriction = each.value["from"]["sources"] != {} ? "SOURCE_RESTRICTION_ENABLED" : null } egress_to { - resources = lookup(each.value["to"], "resources", ["*"]) + resources = each.value["to"]["resources"] + external_resources = each.value["to"]["external_resources"] dynamic "operations" { - for_each = lookup(each.value["to"], "operations", []) + for_each = each.value["to"]["operations"] content { service_name = operations.key dynamic "method_selectors" { for_each = operations.key != "*" ? merge( - { for v in lookup(operations.value, "methods", []) : v => "method" }, - { for v in lookup(operations.value, "permissions", []) : v => "permission" }) : {} + { for v in operations.value["methods"] : v => "method" }, + { for v in operations.value["permissions"] : v => "permission" } + ) : {} content { method = method_selectors.value == "method" ? method_selectors.key : null permission = method_selectors.value == "permission" ? method_selectors.key : null diff --git a/test/fixtures/scoped_example_with_egress_rule/main.tf b/test/fixtures/scoped_example_with_egress_rule/main.tf new file mode 100644 index 00000000..b2bdc838 --- /dev/null +++ b/test/fixtures/scoped_example_with_egress_rule/main.tf @@ -0,0 +1,38 @@ + +/** + * 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. + */ + +resource "random_string" "suffix" { + length = 6 + special = false + upper = false +} + +module "example" { + source = "../../../examples/scoped_example_with_egress_rule" + + parent_id = var.parent_id + policy_name = "int_test_vpc_sc_rules_e_${random_string.suffix.result}" + scopes = var.scopes + + protected_project_ids = var.protected_project_ids + public_project_ids = var.public_project_ids + members = var.members + + buckets_prefix = "egress" + buckets_names = ["directional-rules"] + regions = ["US"] +} diff --git a/test/fixtures/scoped_example_with_egress_rule/outputs.tf b/test/fixtures/scoped_example_with_egress_rule/outputs.tf new file mode 100644 index 00000000..92b74e51 --- /dev/null +++ b/test/fixtures/scoped_example_with_egress_rule/outputs.tf @@ -0,0 +1,36 @@ +/** + * 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. + */ + +output "policy_id" { + description = "Resource name of the AccessPolicy." + value = module.example.policy_id +} + +output "policy_name" { + description = "Name of the parent policy" + value = module.example.policy_name +} + + +output "protected_project_id" { + description = "Project id of the project INSIDE the regular service perimeter" + value = module.example.protected_project_id +} + +output "service_perimeter_name" { + description = "Service perimeter name" + value = module.example.service_perimeter_name +} diff --git a/test/fixtures/scoped_example_with_egress_rule/variables.tf b/test/fixtures/scoped_example_with_egress_rule/variables.tf new file mode 100644 index 00000000..77f603db --- /dev/null +++ b/test/fixtures/scoped_example_with_egress_rule/variables.tf @@ -0,0 +1,41 @@ +/** + * 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 "parent_id" { + description = "The parent of this AccessPolicy in the Cloud Resource Hierarchy. As of now, only organization are accepted as parent." + type = string +} + +variable "protected_project_ids" { + description = "Project id and number of the project INSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + +variable "public_project_ids" { + description = "Project id and number of the project OUTSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + +variable "members" { + description = "An allowed list of members (users, service accounts). The signed-in identity originating the request must be a part of one of the provided members. If not specified, a request may come from any user (logged in/not logged in, etc.). Formats: user:{emailid}, serviceAccount:{emailid}" + type = list(string) +} + +variable "scopes" { + description = "Folder or project on which this policy is applicable. Format: 'folders/FOLDER_ID' or 'projects/PROJECT_NUMBER'" + type = list(string) + default = [] +} diff --git a/test/fixtures/scoped_example_with_ingress_rule/main.tf b/test/fixtures/scoped_example_with_ingress_rule/main.tf new file mode 100644 index 00000000..9b6003c6 --- /dev/null +++ b/test/fixtures/scoped_example_with_ingress_rule/main.tf @@ -0,0 +1,39 @@ + +/** + * 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. + */ + +resource "random_string" "suffix" { + length = 6 + special = false + upper = false +} + +module "example" { + source = "../../../examples/scoped_example_with_ingress_rule" + + parent_id = var.parent_id + policy_name = "int_test_vpc_sc_rules_i_${random_string.suffix.result}" + scopes = var.scopes + + protected_project_ids = var.protected_project_ids + public_project_ids = var.public_project_ids + members = var.members + + read_bucket_identities = var.members + buckets_prefix = "ingress" + buckets_names = ["directional-rules"] + regions = ["US"] +} diff --git a/test/fixtures/scoped_example_with_ingress_rule/outputs.tf b/test/fixtures/scoped_example_with_ingress_rule/outputs.tf new file mode 100644 index 00000000..c7446fc0 --- /dev/null +++ b/test/fixtures/scoped_example_with_ingress_rule/outputs.tf @@ -0,0 +1,35 @@ +/** + * 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. + */ + +output "policy_id" { + description = "Resource name of the AccessPolicy." + value = module.example.policy_id +} + +output "policy_name" { + description = "Name of the parent policy" + value = module.example.policy_name +} + +output "service_perimeter_name" { + description = "Service perimeter name" + value = module.example.service_perimeter_name +} + +output "protected_project_id" { + description = "Project id of the project INSIDE the regular service perimeter" + value = module.example.protected_project_id +} diff --git a/test/fixtures/scoped_example_with_ingress_rule/variables.tf b/test/fixtures/scoped_example_with_ingress_rule/variables.tf new file mode 100644 index 00000000..77f603db --- /dev/null +++ b/test/fixtures/scoped_example_with_ingress_rule/variables.tf @@ -0,0 +1,41 @@ +/** + * 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 "parent_id" { + description = "The parent of this AccessPolicy in the Cloud Resource Hierarchy. As of now, only organization are accepted as parent." + type = string +} + +variable "protected_project_ids" { + description = "Project id and number of the project INSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + +variable "public_project_ids" { + description = "Project id and number of the project OUTSIDE the regular service perimeter. This map variable expects an \"id\" for the project id and \"number\" key for the project number." + type = object({ id = string, number = number }) +} + +variable "members" { + description = "An allowed list of members (users, service accounts). The signed-in identity originating the request must be a part of one of the provided members. If not specified, a request may come from any user (logged in/not logged in, etc.). Formats: user:{emailid}, serviceAccount:{emailid}" + type = list(string) +} + +variable "scopes" { + description = "Folder or project on which this policy is applicable. Format: 'folders/FOLDER_ID' or 'projects/PROJECT_NUMBER'" + type = list(string) + default = [] +} diff --git a/test/integration/scoped_example_with_egress_rule/scoped_example_with_egress_rule_test.go b/test/integration/scoped_example_with_egress_rule/scoped_example_with_egress_rule_test.go new file mode 100644 index 00000000..720a22c0 --- /dev/null +++ b/test/integration/scoped_example_with_egress_rule/scoped_example_with_egress_rule_test.go @@ -0,0 +1,113 @@ +// 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. + +package scoped_example_with_egress_rule_test + +import ( + "fmt" + "testing" + "time" + + "github.com/tidwall/gjson" + + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/gcloud" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/tft" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/stretchr/testify/assert" +) + +var ( + RetryableTransientErrors = map[string]string{ + // Editing VPC Service Controls is eventually consistent. + ".*Error 403.*Request is prohibited by organization's policy.*vpcServiceControlsUniqueIdentifier.*": "Request is prohibited by organization's policy.", + } +) + +// getResultFieldStrSlice parses a field of a results list into a string slice +func GetResultFieldStrSlice(rs []gjson.Result, field string) []string { + s := make([]string, 0) + for _, r := range rs { + s = append(s, r.Get(field).String()) + } + return s +} + +func TestScopedExampleWithEgressRule(t *testing.T) { + + expectedPermissions := []string{ + "bigquery.datasets.get", + "bigquery.models.getData", + "bigquery.models.getMetadata", + "bigquery.models.list", + "bigquery.tables.get", + "bigquery.tables.getData", + "bigquery.tables.list", + } + + expectedMethods := []string{ + "google.storage.objects.get", + "google.storage.objects.list", + } + + setup := tft.NewTFBlueprintTest(t, + tft.WithTFDir("../../setup"), + ) + protectedProjectNumber := terraform.OutputMap(t, setup.GetTFOptions(), "protected_project_ids")["number"] + publicProjectNumber := terraform.OutputMap(t, setup.GetTFOptions(), "public_project_ids")["number"] + + bpt := tft.NewTFBlueprintTest(t, + tft.WithRetryableTerraformErrors(RetryableTransientErrors, 6, 2*time.Minute), + ) + + bpt.DefineVerify(func(assert *assert.Assertions) { + bpt.DefaultVerify(assert) + + policyID := bpt.GetStringOutput("policy_id") + scopedPolicy := gcloud.Runf(t, "access-context-manager policies describe %s", policyID) + assert.Equal(fmt.Sprintf("projects/%s", protectedProjectNumber), scopedPolicy.Get("scopes").Array()[0].String(), "scoped project should be %s", protectedProjectNumber) + + servicePerimeterLink := fmt.Sprintf("accessPolicies/%s/servicePerimeters/%s", policyID, bpt.GetStringOutput("service_perimeter_name")) + servicePerimeter := gcloud.Runf(t, "access-context-manager perimeters describe %s --policy %s", servicePerimeterLink, policyID) + + egressPolicies := servicePerimeter.Get("status.egressPolicies").Array() + for _, rule := range egressPolicies { + + from := rule.Get("egressFrom") + assert.Equal("ANY_SERVICE_ACCOUNT", from.Get("identityType").String(), "identityType should be ANY_SERVICE_ACCOUNT") + assert.Equal("SOURCE_RESTRICTION_ENABLED", from.Get("sourceRestriction").String(), "source restriction should be enabled") + assert.Equal(fmt.Sprintf("projects/%s", protectedProjectNumber), from.Get("sources").Array()[0].Get("resource").String(), "source project should be %s", protectedProjectNumber) + + to := rule.Get("egressTo") + resource := to.Get("resources").Array()[0] + assert.Equal(fmt.Sprintf("projects/%s", publicProjectNumber), resource.String(), "to public project should be %s", publicProjectNumber) + operation := to.Get("operations").Array()[0] + if operation.Get("serviceName").String() == "storage.googleapis.com" { + methods := GetResultFieldStrSlice(operation.Get("methodSelectors").Array(), "method") + for _, expected := range expectedMethods { + assert.Contains(methods, expected) + } + assert.Equal("Read outside buckets from project", rule.Get("title").String(), "check title") + } + + if operation.Get("serviceName").String() == "bigquery.googleapis.com" { + permissions := GetResultFieldStrSlice(operation.Get("methodSelectors").Array(), "permission") + for _, expected := range expectedPermissions { + assert.Contains(permissions, expected) + } + assert.Equal("Use permissions for Big Query access", rule.Get("title").String(), "check title") + } + } + }) + bpt.Test() +} diff --git a/test/integration/scoped_example_with_ingress_rule/scoped_example_with_ingress_rule_test.go b/test/integration/scoped_example_with_ingress_rule/scoped_example_with_ingress_rule_test.go new file mode 100644 index 00000000..b39f5aae --- /dev/null +++ b/test/integration/scoped_example_with_ingress_rule/scoped_example_with_ingress_rule_test.go @@ -0,0 +1,111 @@ +// 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. + +package scoped_example_with_ingress_rule_test + +import ( + "fmt" + "testing" + "time" + + "github.com/tidwall/gjson" + + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/gcloud" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/tft" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/stretchr/testify/assert" +) + +var ( + RetryableTransientErrors = map[string]string{ + // Editing VPC Service Controls is eventually consistent. + ".*Error 403.*Request is prohibited by organization's policy.*vpcServiceControlsUniqueIdentifier.*": "Request is prohibited by organization's policy.", + ".*Error 400.*Invalid Directional Policies set in Perimeter.*Only resources protected by this Service Perimeter can be put in IngressTo.resources.*": "Only resources protected by this Service Perimeter can be put in IngressTo.resources.", + } +) + +// getResultFieldStrSlice parses a field of a results list into a string slice +func GetResultFieldStrSlice(rs []gjson.Result, field string) []string { + s := make([]string, 0) + for _, r := range rs { + s = append(s, r.Get(field).String()) + } + return s +} + +func TestScopedExampleWithIngressRule(t *testing.T) { + + expectedMethods := []string{ + "google.storage.objects.get", + "google.storage.objects.list", + } + + setup := tft.NewTFBlueprintTest(t, + tft.WithTFDir("../../setup"), + ) + protectedProjectNumber := terraform.OutputMap(t, setup.GetTFOptions(), "protected_project_ids")["number"] + publicProjectNumber := terraform.OutputMap(t, setup.GetTFOptions(), "public_project_ids")["number"] + + bpt := tft.NewTFBlueprintTest(t, + tft.WithRetryableTerraformErrors(RetryableTransientErrors, 6, 2*time.Minute), + ) + + bpt.DefineVerify(func(assert *assert.Assertions) { + bpt.DefaultVerify(assert) + + policyID := bpt.GetStringOutput("policy_id") + scopedPolicy := gcloud.Runf(t, "access-context-manager policies describe %s", policyID) + assert.Equal(fmt.Sprintf("projects/%s", protectedProjectNumber), scopedPolicy.Get("scopes").Array()[0].String(), "scoped project should be %s", protectedProjectNumber) + + servicePerimeterLink := fmt.Sprintf("accessPolicies/%s/servicePerimeters/%s", policyID, bpt.GetStringOutput("service_perimeter_name")) + servicePerimeter := gcloud.Runf(t, "access-context-manager perimeters describe %s --policy %s", servicePerimeterLink, policyID) + + ingressPolicies := servicePerimeter.Get("status.ingressPolicies").Array() + for _, rule := range ingressPolicies { + + from := rule.Get("ingressFrom") + // Cases + if rule.Get("title").String() == "Allow Access from everywhere" { + assert.Equal("*", from.Get("sources").Array()[0].Get("accessLevel").String(), "accessLevel should be '*'") + } + if rule.Get("title").String() == "without from source" { + assert.NotEmpty(from.Get("identities").Array()) + } + if rule.Get("title").String() == "Allow Access from project" { + assert.Equal("ANY_SERVICE_ACCOUNT", from.Get("identityType").String(), "identityType should be ANY_SERVICE_ACCOUNT") + assert.Equal(fmt.Sprintf("projects/%s", publicProjectNumber), from.Get("sources").Array()[0].Get("resource").String(), "source project should be %s", publicProjectNumber) + } + + to := rule.Get("ingressTo") + operation := to.Get("operations").Array()[0] + assert.Equal("storage.googleapis.com", operation.Get("serviceName").String(), "service should be storage.googleapis.com") + methods := GetResultFieldStrSlice(operation.Get("methodSelectors").Array(), "method") + for _, expected := range expectedMethods { + assert.Contains(methods, expected) + } + //cases + resource := to.Get("resources").Array()[0] + if rule.Get("title").String() == "Allow Access from everywhere" { + assert.Equal("*", resource.String(), "should be all projects '*'") + } + if rule.Get("title").String() == "without from source" { + assert.Equal(fmt.Sprintf("projects/%s", protectedProjectNumber), resource.String(), "to protected project should be %s", protectedProjectNumber) + } + if rule.Get("title").String() == "Allow Access from project" { + assert.Equal("*", resource.String(), "should be all projects *") + } + } + }) + bpt.Test() +} diff --git a/test/setup/iam.tf b/test/setup/iam.tf index be7fb53e..ed93a26b 100644 --- a/test/setup/iam.tf +++ b/test/setup/iam.tf @@ -55,6 +55,18 @@ resource "google_project_iam_member" "bq_roles_1" { member = "serviceAccount:${google_service_account.int_test.email}" } +resource "google_project_iam_member" "storage_roles_0" { + project = module.project-vpc-service-controls-policy-0.project_id + role = "roles/storage.admin" + member = "serviceAccount:${google_service_account.int_test.email}" +} + +resource "google_project_iam_member" "storage_roles_1" { + project = module.project-vpc-service-controls-policy-1.project_id + role = "roles/storage.admin" + member = "serviceAccount:${google_service_account.int_test.email}" +} + resource "google_service_account_key" "int_test" { service_account_id = google_service_account.int_test.id } diff --git a/test/setup/main.tf b/test/setup/main.tf index 262103e7..a75c6478 100644 --- a/test/setup/main.tf +++ b/test/setup/main.tf @@ -52,7 +52,8 @@ module "project-vpc-service-controls-policy-0" { activate_apis = [ "bigquery.googleapis.com", - "bigquerystorage.googleapis.com" + "bigquerystorage.googleapis.com", + "storage.googleapis.com", ] } @@ -68,6 +69,7 @@ module "project-vpc-service-controls-policy-1" { activate_apis = [ "bigquery.googleapis.com", - "bigquerystorage.googleapis.com" + "bigquerystorage.googleapis.com", + "storage.googleapis.com", ] } diff --git a/test/setup/outputs.tf b/test/setup/outputs.tf index fa2ea5c0..da0ccf68 100644 --- a/test/setup/outputs.tf +++ b/test/setup/outputs.tf @@ -52,3 +52,7 @@ output "members" { "serviceAccount:${google_service_account.test_policy[1].email}" ] } + +output "scopes" { + value = ["projects/${module.project-vpc-service-controls-policy-0.project_number}"] +} diff --git a/variables.tf b/variables.tf index cad2917c..5eed90cb 100644 --- a/variables.tf +++ b/variables.tf @@ -23,3 +23,9 @@ variable "policy_name" { description = "The policy's name." type = string } + +variable "scopes" { + description = "Folder or project on which this policy is applicable. Format: 'folders/FOLDER_ID' or 'projects/PROJECT_NUMBER'" + type = list(string) + default = [] +}