From fe4a78c2e85961739a91e14288984d28d05e0545 Mon Sep 17 00:00:00 2001 From: Ishita Asthana Date: Mon, 2 Nov 2026 16:17:28 +0530 Subject: [PATCH 1/4] Split GCP access into readSRE and writeSRE custom roles Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/gcp/README.md | 91 ++++- .../identity-federation/gcp/main.tf | 370 ++---------------- .../identity-federation/gcp/readSRE.json | 122 ++++++ .../identity-federation/gcp/setup.ps1 | 105 +++-- .../identity-federation/gcp/setup.sh | 100 +++-- .../gcp/tests/roles.tftest.hcl | 108 +++++ .../gcp/tests/test_setup.py | 245 ++++++++++++ .../identity-federation/gcp/variables.tf | 8 +- .../identity-federation/gcp/writeSRE.json | 98 +++++ 9 files changed, 837 insertions(+), 410 deletions(-) create mode 100644 sreagent-templates/identity-federation/gcp/readSRE.json create mode 100644 sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl create mode 100644 sreagent-templates/identity-federation/gcp/tests/test_setup.py create mode 100644 sreagent-templates/identity-federation/gcp/writeSRE.json diff --git a/sreagent-templates/identity-federation/gcp/README.md b/sreagent-templates/identity-federation/gcp/README.md index c956f389b..54aa963bc 100644 --- a/sreagent-templates/identity-federation/gcp/README.md +++ b/sreagent-templates/identity-federation/gcp/README.md @@ -1,7 +1,7 @@ # SRE Agent GCP connector setup This package uses Terraform to configure the GCP Workload Identity pool, -OIDC provider, `SREAgentRole` custom IAM role, and role assignment required by +OIDC provider, `readSRE` and `writeSRE` custom IAM roles, and separate role assignments required by the SRE Agent GCP connector. Provide the user-assigned managed identity's ARM resource ID, not the SRE Agent @@ -17,8 +17,24 @@ cd sre-agent\sreagent-templates\identity-federation\gcp ``` On Linux or macOS, use `cd sre-agent/sreagent-templates/identity-federation/gcp`. -Follow the steps below from that folder. Keep the setup scripts and all `.tf` -files together. +Follow the steps below from that folder. Keep the setup scripts, all `.tf` +files, and both role JSON files together. + +## Permission catalogs + +Review the two custom roles independently: + +| Role / file | Access | Assigned for | +| --- | --- | --- | +| [`readSRE`](./readSRE.json) | Resource discovery, metadata, logs, metrics, and Storage object reads | `read-only` and `read-write` | +| [`writeSRE`](./writeSRE.json) | Workload changes and operation-status reads needed by those changes | `read-write` only, separately from `readSRE` | + +Both custom roles are created at the selected scope. The supplied managed +identity receives only `readSRE` for `read-only`, or two separate IAM memberships +for `read-write`. The write role does not duplicate the read catalog. At +organization scope, Terraform additionally includes +`resourcemanager.projects.list` in `readSRE`; that permission is not applicable +to a project-level custom role. ## Prerequisites @@ -70,7 +86,7 @@ inaccessible project fails. ### 2. Run setup -Keep `setup.ps1` and all `.tf` files together. Replace the example resource ID: +Keep `setup.ps1`, all `.tf` files, and both role JSON files together. Replace the example resource ID: ```powershell .\setup.ps1 ` @@ -103,7 +119,7 @@ gcloud config get project ### 2. Run setup -Keep `setup.sh` and all `.tf` files together. Replace the example resource ID: +Keep `setup.sh`, all `.tf` files, and both role JSON files together. Replace the example resource ID: ```bash chmod +x setup.sh @@ -120,15 +136,20 @@ The Bash parameters match the PowerShell parameters above. Omit - Resolves the tenant and principal/object IDs with `az identity show --ids`; resolves the project ID/number from `gcloud`. -- Discovers the existing pool, provider, custom role, and role assignment for +- Discovers the existing pool, provider, custom roles, and role assignments for the selected identity. Generates Terraform import blocks for these resources and enabled APIs; imports the role assignment only if it has no IAM condition. Pool/provider imports use the configured project ID to avoid replacement; discovery and federated principal names use the project number. -- Extends compatible provider trust and unions existing role permissions with - the requested [permission catalogs](./main.tf). +- Extends compatible provider trust and reconciles each custom role to its + separate JSON permission catalog. Existing permissions are not unioned across + roles or access modes. - Ensures IAM, Resource Manager, Service Account Credentials and STS APIs are enabled; applies an additive IAM member without replacing other bindings. +- Imports obsolete memberships for the selected identity into temporary + Terraform resources, then removes their configuration so their deletion + appears in the same approval plan as the new assignments. Import itself + does not change remote IAM policy. - Runs `terraform init` and `apply` in a unique temporary CLI workspace, then deletes the workspace and local files without running `terraform destroy`. @@ -138,7 +159,15 @@ The Bash parameters match the PowerShell parameters above. Omit | --- | --- | --- | | Workload Identity pool | `sre-agent-pool` | `SREAgentPool` | | OIDC provider | `sre-agent-provider` | `SREAgentProvider` | -| Custom IAM role | `SREAgentRole` | `SREAgentRole` | +| Read custom IAM role | `readSRE` | `readSRE` | +| Write custom IAM role | `writeSRE` | `writeSRE` | + +Project scope is the default. For organization-wide roles and assignments, +use `-AssignmentScope organization -OrganizationId ` in PowerShell, or +`--assignment-scope organization --organization-id ` in Bash. This requires +organization-level custom-role and IAM administration permissions; grants are +inherited by descendant resources. The workload identity pool remains in the +configured project. | OIDC field | Value | | --- | --- | @@ -160,9 +189,31 @@ Terraform can delete or replace the pool, provider and role during apply. With approval skipped, these changes run without confirmation; GCP soft-delete retention still applies. -**Shared permissions:** `existing UNION read UNION write` (write only for -`read-write`). All role assignees receive the union; `read-only` never downgrades -existing write access. The catalogs are not administrator roles. +**Shared role definitions, separate grants:** permissions in each named role +are reconciled exactly to its catalog (plus organization-only project listing +for `readSRE`). Updating a catalog affects all assignees of that role; it does +not add write access to `readSRE`. Review the plan before applying, especially +if roles with these IDs already exist. + +### Downgrades and migration from `SREAgentRole` + +Rerunning with `read-only` removes the selected identity's unconditional +`writeSRE` membership at the selected scope. Other identities' memberships +are preserved. Rerunning with `read-write` ensures both memberships exist. + +For either mode, setup replaces the selected identity's unconditional legacy +`SREAgentRole` membership with the requested new memberships. The legacy role +and its other assignees are left unchanged. If a conditional `writeSRE` grant +must be removed, or a conditional legacy grant must be migrated, setup stops +before applying changes and asks you to resolve that grant through your +approved process. + +**Boundary:** setup does not revoke unrelated roles, grants at other scopes, +or access inherited from an organization, folder, or group. A `read-only` +selection therefore guarantees only the assignments managed here, not the +identity's total effective access. Review inherited and external grants +separately. No running workload is changed by setup, but write permissions +allow workload changes; use your production approval and rollout process. ## State and reruns @@ -171,11 +222,27 @@ Workspace cleanup runs on success, cancellation and apply failure. retains the local directory/state and reports its path. Failed applies can leave partial cloud changes; rerun to rediscover/import them. +Terraform now returns `assigned_role_names` (a list) instead of the legacy +single `role_name` output. Direct Terraform users with retained legacy state +must migrate the selected membership and retire its old state/configuration +through their normal reviewed workflow; do not apply this configuration over +an old shared-role state without reviewing deletions. + +## Local regression checks + +`terraform init` followed by `terraform test` runs the mocked project and +organization role/assignment tests without changing cloud resources. +`python tests/test_setup.py` exercises both setup entry points using local +CLI doubles, including legacy migration, downgrades, conditional grants, and +import failures. The script tests require Python, PowerShell 7, Bash, and `jq` +on `PATH`; they do not require Azure or GCP credentials. + ## References - [Azure CLI identity lookup](https://learn.microsoft.com/cli/azure/identity#az-identity-show) - [gcloud configured project](https://cloud.google.com/sdk/gcloud/reference/config/get) - [Google Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds) - [Additive Terraform IAM members](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam) +- [Terraform import and subsequent deletion of resources absent from configuration](https://developer.hashicorp.com/terraform/cli/import/usage) - [Terraform Google provider authentication](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference) - [Terraform workspace deletion semantics](https://developer.hashicorp.com/terraform/cli/commands/workspace/delete) diff --git a/sreagent-templates/identity-federation/gcp/main.tf b/sreagent-templates/identity-federation/gcp/main.tf index 348c2e22a..2981bb931 100644 --- a/sreagent-templates/identity-federation/gcp/main.tf +++ b/sreagent-templates/identity-federation/gcp/main.tf @@ -54,342 +54,47 @@ locals { # The pool name contains its hosting project number, not the project ID. principal_id = "principal://iam.googleapis.com/projects/${var.project_number}/locations/global/workloadIdentityPools/sre-agent-pool/subject/${lower(var.azure_tenant_id)}::${lower(var.uami_object_id)}" organization_scope = var.assignment_scope == "organization" - role_permissions = setunion( - var.existing_role_permissions, - toset(local.read_permissions), - # Project listing on a parent is only applicable to organization-level roles. - local.organization_scope ? toset(["resourcemanager.projects.list"]) : toset([]), - var.access_mode == "read-write" ? toset(local.write_permissions) : toset([]), - ) - - # ==================== READ PERMISSIONS ==================== - read_permissions = [ - # Mandatory permission for the project to appear in gcloud projects list. - "resourcemanager.projects.get", - - # IAM policy read access - "resourcemanager.projects.getIamPolicy", - "iam.roles.list", - - # Cloud Storage buckets and objects - "storage.buckets.get", - "storage.buckets.list", - "storage.objects.get", - "storage.objects.list", - - # Cloud Run services, revisions, and jobs - "run.services.get", - "run.services.list", - "run.revisions.get", - "run.revisions.list", - "run.jobs.get", - "run.jobs.list", - - # Compute Engine VM instances - "compute.instances.get", - "compute.instances.list", - - # Compute Engine disks - "compute.disks.get", - "compute.disks.list", - - # Google Kubernetes Engine (GKE) clusters - "container.clusters.get", - "container.clusters.list", - "container.operations.get", - "container.operations.list", - - # Cloud SQL instances and databases - "cloudsql.instances.get", - "cloudsql.instances.list", - "cloudsql.databases.get", - "cloudsql.databases.list", - - # Pub/Sub topics and subscriptions - "pubsub.topics.get", - "pubsub.topics.list", - "pubsub.subscriptions.get", - "pubsub.subscriptions.list", - - # BigQuery Metadata Viewer (roles/bigquery.metadataViewer; project reads included above) - "bigquery.datasets.get", - "bigquery.datasets.getIamPolicy", - "bigquery.models.getMetadata", - "bigquery.models.list", - "bigquery.propertyGraphs.get", - "bigquery.propertyGraphs.list", - "bigquery.routines.get", - "bigquery.routines.list", - "bigquery.tables.get", - "bigquery.tables.getIamPolicy", - "bigquery.tables.list", - "dataplex.projects.search", - - # Cloud Functions - "cloudfunctions.functions.get", - "cloudfunctions.functions.list", - "cloudfunctions.locations.list", - - # VPC networks and firewall rules - "compute.networks.get", - "compute.networks.list", - "compute.firewalls.get", - "compute.firewalls.list", - - # Artifact Registry repositories - "artifactregistry.repositories.get", - "artifactregistry.repositories.list", - "artifactregistry.locations.list", - - # Service Usage - "serviceusage.services.list", - - # Cloud Logging: log entries, log metrics, and sinks - "logging.logEntries.list", - "logging.logs.list", - "logging.logMetrics.get", - "logging.logMetrics.list", - "logging.sinks.get", - "logging.sinks.list", - - # Cloud Monitoring: metrics, dashboards, and alert policies - "monitoring.timeSeries.list", - "monitoring.metricDescriptors.get", - "monitoring.metricDescriptors.list", - "monitoring.dashboards.get", - "monitoring.dashboards.list", - "monitoring.alertPolicies.get", - "monitoring.alertPolicies.list", - - # Load balancing: forwarding rules, backends, health checks, and proxies - "compute.forwardingRules.get", - "compute.forwardingRules.list", - "compute.globalForwardingRules.get", - "compute.globalForwardingRules.list", - "compute.backendServices.get", - "compute.backendServices.list", - "compute.regionBackendServices.get", - "compute.regionBackendServices.list", - "compute.backendBuckets.get", - "compute.backendBuckets.list", - "compute.healthChecks.get", - "compute.healthChecks.list", - "compute.httpHealthChecks.get", - "compute.httpHealthChecks.list", - "compute.httpsHealthChecks.get", - "compute.httpsHealthChecks.list", - "compute.urlMaps.get", - "compute.urlMaps.list", - "compute.regionUrlMaps.get", - "compute.regionUrlMaps.list", - "compute.targetHttpProxies.get", - "compute.targetHttpProxies.list", - "compute.targetHttpsProxies.get", - "compute.targetHttpsProxies.list", - "compute.targetTcpProxies.get", - "compute.targetTcpProxies.list", - "compute.targetSslProxies.get", - "compute.targetSslProxies.list", - - # Network infrastructure: IP addresses, routers, routes, and subnets - "compute.addresses.get", - "compute.addresses.list", - "compute.globalAddresses.get", - "compute.globalAddresses.list", - "compute.routers.get", - "compute.routers.list", - "compute.routes.get", - "compute.routes.list", - "compute.subnetworks.get", - "compute.subnetworks.list", - - # Cloud DNS: managed zones, DNS records, and changes - "dns.managedZones.get", - "dns.managedZones.list", - "dns.resourceRecordSets.get", - "dns.resourceRecordSets.list", - "dns.changes.get", - "dns.changes.list", - - # Memorystore for Redis instances - "redis.instances.get", - "redis.instances.list", - - # Memorystore for Memcached instances - "memcache.instances.get", - "memcache.instances.list", - - # Filestore instances - "file.instances.get", - "file.instances.list", - ] - - # ==================== WRITE PERMISSIONS ==================== - write_permissions = [ - # Cloud Storage buckets and objects - "storage.buckets.create", - "storage.buckets.update", - "storage.objects.create", - "storage.objects.update", - - # Cloud Run services and jobs - "run.services.create", - "run.services.update", - "run.jobs.create", - "run.jobs.update", - - # Compute Engine VM instances - "compute.instances.create", - "compute.instances.update", - "compute.instances.start", - "compute.instances.stop", - "compute.zoneOperations.get", - "compute.regionOperations.get", - "compute.globalOperations.get", - - # Compute Engine disks - "compute.disks.create", - "compute.disks.update", - - # Google Kubernetes Engine (GKE) clusters - "container.clusters.create", - "container.clusters.update", - - # Cloud SQL instances - "cloudsql.instances.create", - "cloudsql.instances.update", - - # Pub/Sub topics and subscriptions - "pubsub.topics.create", - "pubsub.topics.update", - "pubsub.topics.attachSubscription", - "pubsub.subscriptions.create", - "pubsub.subscriptions.update", - - # BigQuery tables (metadata only; no table data access) - "bigquery.tables.create", - "bigquery.tables.update", - - # Cloud Functions - "cloudfunctions.functions.create", - "cloudfunctions.functions.update", - - # VPC networks and firewall rules - "compute.networks.create", - "compute.networks.update", - "compute.firewalls.create", - "compute.firewalls.update", - - # Artifact Registry repositories - "artifactregistry.repositories.create", - "artifactregistry.repositories.update", - - # Cloud Logging: log metrics and sinks - "logging.logMetrics.create", - "logging.logMetrics.update", - "logging.sinks.create", - "logging.sinks.update", - - # Cloud Monitoring: dashboards and alert policies - "monitoring.dashboards.create", - "monitoring.dashboards.update", - "monitoring.alertPolicies.create", - "monitoring.alertPolicies.update", - - # Load balancing: forwarding rules, backends, health checks, and proxies - "compute.forwardingRules.create", - "compute.forwardingRules.update", - "compute.globalForwardingRules.create", - "compute.globalForwardingRules.update", - "compute.backendServices.create", - "compute.backendServices.update", - "compute.regionBackendServices.create", - "compute.regionBackendServices.update", - "compute.backendBuckets.create", - "compute.backendBuckets.update", - "compute.healthChecks.create", - "compute.healthChecks.update", - "compute.httpHealthChecks.create", - "compute.httpHealthChecks.update", - "compute.httpsHealthChecks.create", - "compute.httpsHealthChecks.update", - "compute.urlMaps.create", - "compute.urlMaps.update", - "compute.regionUrlMaps.create", - "compute.regionUrlMaps.update", - "compute.targetHttpProxies.create", - "compute.targetHttpProxies.update", - "compute.targetHttpsProxies.create", - "compute.targetHttpsProxies.update", - "compute.targetTcpProxies.create", - "compute.targetTcpProxies.update", - "compute.targetSslProxies.create", - "compute.targetSslProxies.update", - - # Network infrastructure: IP addresses, routers, routes, and subnets - "compute.addresses.create", - "compute.globalAddresses.create", - "compute.routers.create", - "compute.routers.update", - "compute.routes.create", - "compute.subnetworks.create", - "compute.subnetworks.update", - "compute.subnetworks.use", - - # Cloud DNS: managed zones and DNS records - "dns.managedZones.create", - "dns.managedZones.update", - "dns.resourceRecordSets.create", - "dns.resourceRecordSets.update", - "dns.changes.create", - - # Memorystore for Redis instances - "redis.instances.create", - "redis.instances.update", - - # Memorystore for Memcached instances - "memcache.instances.create", - "memcache.instances.update", - - # Filestore instances - "file.instances.create", - "file.instances.update", - ] + role_definitions = { + readSRE = jsondecode(file("${path.module}/readSRE.json")) + writeSRE = jsondecode(file("${path.module}/writeSRE.json")) + } + role_permissions = { + for id, definition in local.role_definitions : id => setunion( + toset(definition.includedPermissions), + # Project listing on a parent is only applicable to organization-level roles. + id == "readSRE" && local.organization_scope ? toset(["resourcemanager.projects.list"]) : toset([]), + ) + } + assigned_role_ids = var.access_mode == "read-write" ? toset(["readSRE", "writeSRE"]) : toset(["readSRE"]) } -resource "google_project_iam_custom_role" "sre_agent_role" { - count = local.organization_scope ? 0 : 1 +resource "google_project_iam_custom_role" "sre_agent" { + for_each = local.organization_scope ? {} : local.role_definitions project = var.project_id - role_id = "SREAgentRole" - title = "SREAgentRole" - description = "Shared SRE Agent resource access." - stage = "GA" + role_id = each.key + title = each.value.title + description = each.value.description + stage = each.value.stage - permissions = local.role_permissions + permissions = local.role_permissions[each.key] depends_on = [google_project_service.required] - - lifecycle { - ignore_changes = [description, stage] - } } -resource "google_organization_iam_custom_role" "sre_agent_role" { - count = local.organization_scope ? 1 : 0 +resource "google_organization_iam_custom_role" "sre_agent" { + for_each = local.organization_scope ? local.role_definitions : {} org_id = var.organization_id - role_id = "SREAgentRole" - title = "SREAgentRole" - description = "Shared SRE Agent resource access." - stage = "GA" - permissions = local.role_permissions + role_id = each.key + title = each.value.title + description = each.value.description + stage = each.value.stage + permissions = local.role_permissions[each.key] depends_on = [google_project_service.required] lifecycle { - ignore_changes = [description, stage] - precondition { condition = var.organization_id != "" error_message = "organization_id is required when assignment_scope is organization." @@ -397,29 +102,32 @@ resource "google_organization_iam_custom_role" "sre_agent_role" { } } -resource "google_project_iam_member" "sre_agent_role_assignment" { - count = local.organization_scope ? 0 : 1 +resource "google_project_iam_member" "sre_agent" { + for_each = local.organization_scope ? toset([]) : local.assigned_role_ids project = var.project_id - role = google_project_iam_custom_role.sre_agent_role[0].name + role = google_project_iam_custom_role.sre_agent[each.key].name member = local.principal_id depends_on = [google_iam_workload_identity_pool_provider.azure] } -resource "google_organization_iam_member" "sre_agent_role_assignment" { - count = local.organization_scope ? 1 : 0 +resource "google_organization_iam_member" "sre_agent" { + for_each = local.organization_scope ? local.assigned_role_ids : toset([]) org_id = var.organization_id - role = google_organization_iam_custom_role.sre_agent_role[0].name + role = google_organization_iam_custom_role.sre_agent[each.key].name member = local.principal_id depends_on = [google_iam_workload_identity_pool_provider.azure] } -output "role_name" { - description = "Full resource name of the custom role, assigned to the federated managed identity at the selected scope." - value = local.organization_scope ? google_organization_iam_custom_role.sre_agent_role[0].name : google_project_iam_custom_role.sre_agent_role[0].name +output "assigned_role_names" { + description = "Custom roles separately assigned to the federated managed identity at the selected scope." + value = [ + for id in sort(tolist(local.assigned_role_ids)) : + local.organization_scope ? google_organization_iam_custom_role.sre_agent[id].name : google_project_iam_custom_role.sre_agent[id].name + ] } output "workload_identity_provider" { @@ -428,6 +136,6 @@ output "workload_identity_provider" { } output "principal_id" { - description = "Federated IAM member assigned the custom role." + description = "Federated IAM member assigned the selected custom roles." value = local.principal_id } diff --git a/sreagent-templates/identity-federation/gcp/readSRE.json b/sreagent-templates/identity-federation/gcp/readSRE.json new file mode 100644 index 000000000..7dd683d2f --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/readSRE.json @@ -0,0 +1,122 @@ +{ + "title": "readSRE", + "description": "SRE Agent resource discovery, metadata, logs, and metrics access.", + "stage": "GA", + "includedPermissions": [ + "resourcemanager.projects.get", + "resourcemanager.projects.getIamPolicy", + "iam.roles.list", + "storage.buckets.get", + "storage.buckets.list", + "storage.objects.get", + "storage.objects.list", + "run.services.get", + "run.services.list", + "run.revisions.get", + "run.revisions.list", + "run.jobs.get", + "run.jobs.list", + "compute.instances.get", + "compute.instances.list", + "compute.disks.get", + "compute.disks.list", + "container.clusters.get", + "container.clusters.list", + "container.operations.get", + "container.operations.list", + "cloudsql.instances.get", + "cloudsql.instances.list", + "cloudsql.databases.get", + "cloudsql.databases.list", + "pubsub.topics.get", + "pubsub.topics.list", + "pubsub.subscriptions.get", + "pubsub.subscriptions.list", + "bigquery.datasets.get", + "bigquery.datasets.getIamPolicy", + "bigquery.models.getMetadata", + "bigquery.models.list", + "bigquery.propertyGraphs.get", + "bigquery.propertyGraphs.list", + "bigquery.routines.get", + "bigquery.routines.list", + "bigquery.tables.get", + "bigquery.tables.getIamPolicy", + "bigquery.tables.list", + "dataplex.projects.search", + "cloudfunctions.functions.get", + "cloudfunctions.functions.list", + "cloudfunctions.locations.list", + "compute.networks.get", + "compute.networks.list", + "compute.firewalls.get", + "compute.firewalls.list", + "artifactregistry.repositories.get", + "artifactregistry.repositories.list", + "artifactregistry.locations.list", + "serviceusage.services.list", + "logging.logEntries.list", + "logging.logs.list", + "logging.logMetrics.get", + "logging.logMetrics.list", + "logging.sinks.get", + "logging.sinks.list", + "monitoring.timeSeries.list", + "monitoring.metricDescriptors.get", + "monitoring.metricDescriptors.list", + "monitoring.dashboards.get", + "monitoring.dashboards.list", + "monitoring.alertPolicies.get", + "monitoring.alertPolicies.list", + "compute.forwardingRules.get", + "compute.forwardingRules.list", + "compute.globalForwardingRules.get", + "compute.globalForwardingRules.list", + "compute.backendServices.get", + "compute.backendServices.list", + "compute.regionBackendServices.get", + "compute.regionBackendServices.list", + "compute.backendBuckets.get", + "compute.backendBuckets.list", + "compute.healthChecks.get", + "compute.healthChecks.list", + "compute.httpHealthChecks.get", + "compute.httpHealthChecks.list", + "compute.httpsHealthChecks.get", + "compute.httpsHealthChecks.list", + "compute.urlMaps.get", + "compute.urlMaps.list", + "compute.regionUrlMaps.get", + "compute.regionUrlMaps.list", + "compute.targetHttpProxies.get", + "compute.targetHttpProxies.list", + "compute.targetHttpsProxies.get", + "compute.targetHttpsProxies.list", + "compute.targetTcpProxies.get", + "compute.targetTcpProxies.list", + "compute.targetSslProxies.get", + "compute.targetSslProxies.list", + "compute.addresses.get", + "compute.addresses.list", + "compute.globalAddresses.get", + "compute.globalAddresses.list", + "compute.routers.get", + "compute.routers.list", + "compute.routes.get", + "compute.routes.list", + "compute.subnetworks.get", + "compute.subnetworks.list", + "dns.managedZones.get", + "dns.managedZones.list", + "dns.resourceRecordSets.get", + "dns.resourceRecordSets.list", + "dns.changes.get", + "dns.changes.list", + "redis.instances.get", + "redis.instances.list", + "memcache.instances.get", + "memcache.instances.list", + "file.instances.get", + "file.instances.list" + ] +} diff --git a/sreagent-templates/identity-federation/gcp/setup.ps1 b/sreagent-templates/identity-federation/gcp/setup.ps1 index 29437c6c6..14a9a96e3 100644 --- a/sreagent-templates/identity-federation/gcp/setup.ps1 +++ b/sreagent-templates/identity-federation/gcp/setup.ps1 @@ -14,7 +14,7 @@ Set-StrictMode -Version Latest $SetupRoot = $PSScriptRoot $PoolId = 'sre-agent-pool' $ProviderId = 'sre-agent-provider' -$RoleId = 'SREAgentRole' +$RoleIds = @('readSRE', 'writeSRE') $Audience = '59f0a04a-b322-4310-adc9-39ac41e9631e' $UuidPattern = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' @@ -256,7 +256,6 @@ function Invoke-GcpSetup { $providerName = "$poolName/providers/$ProviderId" # Import stores this project segment in the replacement-only project field. $poolImportId = "projects/$projectId/locations/global/workloadIdentityPools/$PoolId" - $roleName = "$scopeParent/roles/$RoleId" $principal = "principal://iam.googleapis.com/$poolName/subject/${tenantId}::$objectId" $pools = Invoke-SetupJson gcloud @( 'iam', 'workload-identity-pools', 'list', '--location=global', @@ -288,30 +287,50 @@ function Invoke-GcpSetup { } $roles = Invoke-SetupJson gcloud @('iam', 'roles', 'list', $scopeFlag, '--show-deleted', '--format=json', '--quiet') - $role = @($roles | Where-Object { $_.name -ceq $roleName }) - $existingPermissions = @() - if ($role.Count -gt 0) { - Assert-ActiveResource $role[0] $RoleId - $details = Invoke-SetupJson gcloud @('iam', 'roles', 'describe', $RoleId, $scopeFlag, '--format=json', '--quiet') - Assert-ActiveResource $details $RoleId - if (-not $details.Contains('includedPermissions')) { - throw 'The existing custom role response has no includedPermissions; refusing to replace unknown permissions.' + foreach ($roleId in $RoleIds) { + $roleName = "$scopeParent/roles/$roleId" + $role = @($roles | Where-Object { $_.name -ceq $roleName }) + if ($role.Count -gt 0) { + Assert-ActiveResource $role[0] $roleId + $details = Invoke-SetupJson gcloud @('iam', 'roles', 'describe', $roleId, $scopeFlag, '--format=json', '--quiet') + Assert-ActiveResource $details $roleId + $imports.Add((New-ImportBlock "google_${scope}_iam_custom_role.sre_agent[`"$roleId`"]" $roleName)) } - $existingPermissions = @($details.includedPermissions) - $imports.Add((New-ImportBlock "google_${scope}_iam_custom_role.sre_agent_role[0]" $roleName)) } + $assignedRoleIds = @('readSRE') + if ($accessMode -eq 'read-write') { + $assignedRoleIds += 'writeSRE' + } + $revokedRoles = @{} $policy = Invoke-SetupJson gcloud @( $(if ($scope -eq 'project') { 'projects' } else { 'organizations' }), 'get-iam-policy', $(if ($scope -eq 'project') { $projectId } else { $organizationId }), '--format=json', '--quiet' ) foreach ($binding in @($policy['bindings'])) { - if ($null -ne $binding -and $binding.role -ceq $roleName -and - -not $binding.Contains('condition') -and @($binding.members) -ccontains $principal) { - $scopeId = if ($scope -eq 'project') { $projectId } else { $organizationId } - $imports.Add((New-ImportBlock "google_${scope}_iam_member.sre_agent_role_assignment[0]" "$scopeId $roleName $principal")) - break + if ($null -eq $binding -or @($binding.members) -cnotcontains $principal) { + continue + } + foreach ($roleId in @('readSRE', 'writeSRE', 'SREAgentRole')) { + $roleName = "$scopeParent/roles/$roleId" + if ($binding.role -cne $roleName) { + continue + } + $remove = $roleId -eq 'SREAgentRole' -or ($roleId -eq 'writeSRE' -and $accessMode -eq 'read-only') + if ($binding.Contains('condition')) { + if ($remove) { + throw "The selected identity has a conditional $roleId assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade or migrate it." + } + continue + } + if ($remove) { + $revokedRoles[$roleId] = $roleName + } + else { + $scopeId = if ($scope -eq 'project') { $projectId } else { $organizationId } + $imports.Add((New-ImportBlock "google_${scope}_iam_member.sre_agent[`"$roleId`"]" "$scopeId $roleName $principal")) + } } } foreach ($service in @('iam.googleapis.com', 'cloudresourcemanager.googleapis.com', 'iamcredentials.googleapis.com', 'sts.googleapis.com')) { @@ -321,22 +340,23 @@ function Invoke-GcpSetup { } $variables = [ordered]@{ - project_id = $projectId - project_number = $projectNumber - azure_tenant_id = $tenantId - uami_object_id = $objectId - access_mode = $accessMode - assignment_scope = $scope - organization_id = $organizationId - attribute_condition = $trust.Condition - existing_role_permissions = $existingPermissions + project_id = $projectId + project_number = $projectNumber + azure_tenant_id = $tenantId + uami_object_id = $objectId + access_mode = $accessMode + assignment_scope = $scope + organization_id = $organizationId + attribute_condition = $trust.Condition } Write-Host "Azure tenant: $tenantId" Write-Host "Managed identity object ID: $objectId" Write-Host "Provider: $providerName" - Write-Host "Role: $roleName" - Write-Host "Permissions to add: $accessMode; preserve $($existingPermissions.Count) existing permissions." - Write-Warning 'All SREAgentRole assignees share the merged permissions. Read-only does not downgrade an existing read-write role.' + Write-Host "Roles to assign: $(($assignedRoleIds | ForEach-Object { "$scopeParent/roles/$_" }) -join ', ')" + if ($revokedRoles.Count -gt 0) { + Write-Warning "Removing only this identity's assignments to: $($revokedRoles.Values -join ', '). Other identities and the legacy role definition are preserved." + } + Write-Warning 'readSRE and writeSRE permissions are reconciled to their JSON catalogs for all assignees. Unrelated or inherited IAM grants are not removed.' Write-Warning 'Run setups sequentially. Do not adopt resources concurrently managed by another Terraform state. The temporary named workspace will be deleted WITHOUT destroying GCP resources.' $workspace = "sre-agent-gcp-$([Guid]::NewGuid().ToString('N'))" @@ -354,8 +374,20 @@ function Invoke-GcpSetup { [void](New-Item -ItemType Directory -Path $runDirectory) $directoryCreated = $true Copy-Item -Path (Join-Path $SetupRoot '*.tf') -Destination $runDirectory + Copy-Item -LiteralPath (Join-Path $SetupRoot 'readSRE.json'), (Join-Path $SetupRoot 'writeSRE.json') -Destination $runDirectory [IO.File]::WriteAllText((Join-Path $runDirectory 'guided.auto.tfvars.json'), ($variables | ConvertTo-Json -Depth 20)) [IO.File]::WriteAllText((Join-Path $runDirectory 'imports.tf'), ($imports -join "`n")) + if ($revokedRoles.Count -gt 0) { + $revocationResource = @{ + for_each = $revokedRoles + role = '${each.value}' + member = $principal + } + $revocationResource[$(if ($scope -eq 'project') { 'project' } else { 'org_id' })] = + $(if ($scope -eq 'project') { $projectId } else { $organizationId }) + $revocations = @{ resource = @{ "google_${scope}_iam_member" = @{ revoked = $revocationResource } } } + [IO.File]::WriteAllText((Join-Path $runDirectory 'revocations.tf.json'), ($revocations | ConvertTo-Json -Depth 20)) + } foreach ($name in $environmentNames) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } @@ -371,14 +403,25 @@ function Invoke-GcpSetup { Write-Host (Invoke-SetupCommand terraform @('init', '-input=false', '-no-color')) Write-Host (Invoke-SetupCommand terraform @('workspace', 'new', $workspace)) $workspaceCreated = $true + if ($revokedRoles.Count -gt 0) { + $scopeId = if ($scope -eq 'project') { $projectId } else { $organizationId } + foreach ($roleId in $revokedRoles.Keys) { + Write-Host (Invoke-SetupCommand terraform @( + 'import', '-input=false', '-no-color', + "google_${scope}_iam_member.revoked[`"$roleId`"]", "$scopeId $($revokedRoles[$roleId]) $principal" + )) + } + # Removing configuration after import schedules only these memberships for deletion in the reviewed apply. + Remove-Item -LiteralPath (Join-Path $runDirectory 'revocations.tf.json') + } if ([bool]::Parse($SkipApproval)) { - Write-Warning 'Approval is explicitly skipped. Terraform will apply all proposed changes without confirmation, including any provider trust reset or shared-role permission expansion.' + Write-Warning 'Approval is explicitly skipped. Terraform will apply all proposed changes without confirmation, including trust resets, role permission changes, and selected identity assignment removals.' } Invoke-TerraformApply -SkipApproval ([bool]::Parse($SkipApproval)) $outputs = Invoke-SetupJson terraform @('output', '-json') Write-Host "`nCopy this Workload Identity Provider into the SRE Agent GCP connector:" -ForegroundColor Green Write-Host $outputs.workload_identity_provider.value - Write-Host "Role: $($outputs.role_name.value)" + Write-Host "Assigned roles: $($outputs.assigned_role_names.value -join ', ')" Write-Host "Principal: $($outputs.principal_id.value)" } finally { diff --git a/sreagent-templates/identity-federation/gcp/setup.sh b/sreagent-templates/identity-federation/gcp/setup.sh index bf1fd9206..6dc074efe 100644 --- a/sreagent-templates/identity-federation/gcp/setup.sh +++ b/sreagent-templates/identity-federation/gcp/setup.sh @@ -4,7 +4,7 @@ set -euo pipefail setup_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" pool_id="sre-agent-pool" provider_id="sre-agent-provider" -role_id="SREAgentRole" +role_ids=(readSRE writeSRE) audience="59f0a04a-b322-4310-adc9-39ac41e9631e" uuid_pattern='[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' @@ -209,13 +209,16 @@ main() { warn "Organization-level access is inherited by descendant resources." fi - local pool_name provider_name pool_import_id role_name principal pools pool providers provider roles role details - local imports="" existing_permissions="[]" trust condition policy has_assignment service + local pool_name provider_name pool_import_id role_name role_id principal pools pool providers provider roles role details + local imports="" trust condition policy has_assignment has_conditional_assignment service + local assigned_role_ids=(readSRE) revoked_roles='{}' + if [[ "$access_mode" == "read-write" ]]; then + assigned_role_ids+=(writeSRE) + fi pool_name="projects/$project_number/locations/global/workloadIdentityPools/$pool_id" provider_name="$pool_name/providers/$provider_id" # Import stores this project segment in the replacement-only project field. pool_import_id="projects/$project_id/locations/global/workloadIdentityPools/$pool_id" - role_name="$scope_parent/roles/$role_id" principal="principal://iam.googleapis.com/$pool_name/subject/$tenant_id::$object_id" pools="$(run_json gcloud iam workload-identity-pools list --location=global \ --show-deleted "$project_flag" --format=json --quiet)" @@ -242,32 +245,51 @@ main() { fi roles="$(run_json gcloud iam roles list "$scope_flag" --show-deleted --format=json --quiet)" - role="$(jq -c --arg name "$role_name" 'map(select(.name == $name)) | .[0] // null' <<<"$roles")" - if [[ "$role" != "null" ]]; then - assert_active_resource "$role" "$role_id" - details="$(run_json gcloud iam roles describe "$role_id" "$scope_flag" --format=json --quiet)" - assert_active_resource "$details" "$role_id" - jq -e '.includedPermissions | type == "array"' >/dev/null <<<"$details" \ - || fail "The existing role has no includedPermissions array; refusing to replace unknown permissions." - existing_permissions="$(jq -c '.includedPermissions' <<<"$details")" - append_import "google_${scope}_iam_custom_role.sre_agent_role[0]" "$role_name" - fi + for role_id in "${role_ids[@]}"; do + role_name="$scope_parent/roles/$role_id" + role="$(jq -c --arg name "$role_name" 'map(select(.name == $name)) | .[0] // null' <<<"$roles")" + if [[ "$role" != "null" ]]; then + assert_active_resource "$role" "$role_id" + details="$(run_json gcloud iam roles describe "$role_id" "$scope_flag" --format=json --quiet)" + assert_active_resource "$details" "$role_id" + append_import "google_${scope}_iam_custom_role.sre_agent[\"$role_id\"]" "$role_name" + fi + done policy="$(run_json gcloud "$policy_kind" get-iam-policy "$scope_id" --format=json --quiet)" - has_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' - any(.bindings[]?; .role == $role and (has("condition") | not) and - (.members | index($member) != null))' <<<"$policy")" - if [[ "$has_assignment" == true ]]; then - append_import "google_${scope}_iam_member.sre_agent_role_assignment[0]" "$scope_id $role_name $principal" - fi + for role_id in readSRE writeSRE SREAgentRole; do + role_name="$scope_parent/roles/$role_id" + has_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' + any(.bindings[]?; .role == $role and (has("condition") | not) and + (.members | index($member) != null))' <<<"$policy")" + if [[ "$role_id" == SREAgentRole || ( "$role_id" == writeSRE && "$access_mode" == read-only ) ]]; then + has_conditional_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' + any(.bindings[]?; .role == $role and has("condition") and + (.members | index($member) != null))' <<<"$policy")" + [[ "$has_conditional_assignment" == false ]] \ + || fail "The selected identity has a conditional $role_id assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade or migrate it." + if [[ "$has_assignment" == true ]]; then + revoked_roles="$(jq -c --arg id "$role_id" --arg name "$role_name" '. + {($id): $name}' <<<"$revoked_roles")" + fi + elif [[ "$has_assignment" == true ]]; then + append_import "google_${scope}_iam_member.sre_agent[\"$role_id\"]" "$scope_id $role_name $principal" + fi + done for service in iam.googleapis.com cloudresourcemanager.googleapis.com iamcredentials.googleapis.com sts.googleapis.com; do if jq -e --arg service "$service" 'index($service) != null' >/dev/null <<<"$enabled_services"; then append_import "google_project_service.required[\"$service\"]" "$project_id/$service" fi done - printf 'Azure tenant: %s\nManaged identity object ID: %s\nProvider: %s\nRole: %s\nPermissions to add: %s; preserve %s existing permissions.\n' \ - "$tenant_id" "$object_id" "$provider_name" "$role_name" "$access_mode" "$(jq length <<<"$existing_permissions")" - warn "All SREAgentRole assignees share merged permissions. Read-only does not downgrade an existing read-write role." + printf 'Azure tenant: %s\nManaged identity object ID: %s\nProvider: %s\n' \ + "$tenant_id" "$object_id" "$provider_name" + printf 'Role to assign: %s/roles/%s\n' "$scope_parent" "${assigned_role_ids[0]}" + if [[ "$access_mode" == read-write ]]; then + printf 'Role to assign: %s/roles/%s\n' "$scope_parent" "${assigned_role_ids[1]}" + fi + if [[ "$revoked_roles" != '{}' ]]; then + warn "Removing only this identity's assignments to: $(jq -r '[.[]] | join(", ")' <<<"$revoked_roles"). Other identities and the legacy role definition are preserved." + fi + warn "readSRE and writeSRE permissions are reconciled to their JSON catalogs for all assignees. Unrelated or inherited IAM grants are not removed." warn "Run setups sequentially. Do not concurrently manage these resources from another Terraform state. The temporary workspace is deleted WITHOUT destroying GCP resources." umask 077 @@ -278,16 +300,25 @@ main() { trap 'exit 130' INT trap 'exit 143' TERM cp "$setup_root"/*.tf "$run_directory/" + cp "$setup_root/readSRE.json" "$setup_root/writeSRE.json" "$run_directory/" jq -n --arg project_id "$project_id" --arg project_number "$project_number" \ --arg tenant "$tenant_id" --arg object "$object_id" --arg mode "$access_mode" \ - --arg scope "$scope" --arg organization "$organization_id" --arg condition "$condition" \ - --argjson permissions "$existing_permissions" '{ + --arg scope "$scope" --arg organization "$organization_id" --arg condition "$condition" '{ project_id: $project_id, project_number: $project_number, azure_tenant_id: $tenant, uami_object_id: $object, access_mode: $mode, assignment_scope: $scope, organization_id: $organization, - attribute_condition: $condition, existing_role_permissions: $permissions + attribute_condition: $condition }' >"$run_directory/guided.auto.tfvars.json" printf '%s' "$imports" >"$run_directory/imports.tf" + if [[ "$revoked_roles" != '{}' ]]; then + jq -n --arg scope "$scope" --arg scope_id "$scope_id" --arg member "$principal" \ + --argjson roles "$revoked_roles" '{ + resource: {("google_" + $scope + "_iam_member"): {revoked: { + for_each: $roles, role: "${each.value}", member: $member, + (if $scope == "project" then "project" else "org_id" end): $scope_id + }}} + }' >"$run_directory/revocations.tf.json" + fi unset TF_WORKSPACE TF_DATA_DIR GOOGLE_OAUTH_ACCESS_TOKEN GOOGLE_CREDENTIALS \ GOOGLE_CLOUD_KEYFILE_JSON GCLOUD_KEYFILE_JSON GOOGLE_IMPERSONATE_SERVICE_ACCOUNT GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token --quiet)" || fail "Unable to obtain the gcloud access token." @@ -297,8 +328,19 @@ main() { terraform init -input=false -no-color terraform workspace new "$workspace" workspace_created=true + if [[ "$revoked_roles" != '{}' ]]; then + for role_id in SREAgentRole writeSRE; do + role_name="$(jq -r --arg id "$role_id" '.[$id] // empty' <<<"$revoked_roles")" + if [[ -n "$role_name" ]]; then + terraform import -input=false -no-color "google_${scope}_iam_member.revoked[\"$role_id\"]" \ + "$scope_id $role_name $principal" || fail "Unable to import the $role_id assignment for removal." + fi + done + # Removing configuration schedules only these memberships for deletion in the reviewed apply. + rm -- revocations.tf.json + fi if [[ "$skip_approval" == true ]]; then - warn "Approval is explicitly skipped, including any provider trust reset or shared-role permission expansion." + warn "Approval is explicitly skipped, including trust resets, role permission changes, and selected identity assignment removals." terraform apply -no-color -input=false -auto-approve=true \ || fail "Terraform apply failed. No destroy is run; inspect the output before rerunning." else @@ -309,9 +351,9 @@ main() { outputs="$(run_json terraform output -json)" provider_output="$(jq -er '.workload_identity_provider.value' <<<"$outputs")" \ || fail "Terraform output is missing workload_identity_provider." - role_output="$(jq -er '.role_name.value' <<<"$outputs")" || fail "Terraform output is missing role_name." + role_output="$(jq -er '.assigned_role_names.value | join(", ")' <<<"$outputs")" || fail "Terraform output is missing assigned_role_names." principal_output="$(jq -er '.principal_id.value' <<<"$outputs")" || fail "Terraform output is missing principal_id." - printf '\nCopy this Workload Identity Provider into the SRE Agent GCP connector:\n%s\nRole: %s\nPrincipal: %s\n' \ + printf '\nCopy this Workload Identity Provider into the SRE Agent GCP connector:\n%s\nAssigned roles: %s\nPrincipal: %s\n' \ "$provider_output" "$role_output" "$principal_output" } diff --git a/sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl b/sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl new file mode 100644 index 000000000..30153bc90 --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl @@ -0,0 +1,108 @@ +mock_provider "google" {} + +variables { + project_id = "sre-test-project" + project_number = "123456789012" + azure_tenant_id = "11111111-1111-1111-1111-111111111111" + uami_object_id = "22222222-2222-2222-2222-222222222222" + attribute_condition = "assertion.oid == '22222222-2222-2222-2222-222222222222'" +} + +run "project_read_only" { + command = apply + + assert { + condition = toset(keys(google_project_iam_custom_role.sre_agent)) == toset(["readSRE", "writeSRE"]) + error_message = "Both project custom roles must exist even for read-only access." + } + + assert { + condition = toset(keys(google_project_iam_member.sre_agent)) == toset(["readSRE"]) && length(google_organization_iam_member.sre_agent) == 0 + error_message = "Read-only must assign only readSRE at the selected scope." + } + + assert { + condition = ( + google_project_iam_custom_role.sre_agent["readSRE"].permissions == toset(jsondecode(file("readSRE.json")).includedPermissions) && + google_project_iam_custom_role.sre_agent["writeSRE"].permissions == toset(jsondecode(file("writeSRE.json")).includedPermissions) && + length(setintersection(local.role_permissions.readSRE, local.role_permissions.writeSRE)) == 0 + ) + error_message = "Role permissions must exactly match their separate, disjoint catalogs." + } + + assert { + condition = google_project_iam_member.sre_agent["readSRE"].member == local.principal_id && google_project_iam_member.sre_agent["readSRE"].role == google_project_iam_custom_role.sre_agent["readSRE"].name + error_message = "The read role must be bound to the supplied federated identity." + } +} + +run "project_read_write" { + command = apply + + variables { + access_mode = "read-write" + } + + assert { + condition = toset(keys(google_project_iam_member.sre_agent)) == toset(["readSRE", "writeSRE"]) + error_message = "Read-write must assign both project roles separately." + } + + assert { + condition = alltrue([for id, binding in google_project_iam_member.sre_agent : binding.member == local.principal_id && binding.role == google_project_iam_custom_role.sre_agent[id].name]) + error_message = "Each role must be assigned separately to the same supplied identity." + } + + assert { + condition = output.assigned_role_names == [google_project_iam_custom_role.sre_agent["readSRE"].name, google_project_iam_custom_role.sre_agent["writeSRE"].name] + error_message = "Outputs must report both assigned roles." + } +} + +run "project_downgrade" { + command = apply + + assert { + condition = toset(keys(google_project_iam_member.sre_agent)) == toset(["readSRE"]) && length(google_project_iam_custom_role.sre_agent) == 2 + error_message = "A downgrade must remove only the write membership, not its shared custom role." + } +} + +run "organization_read_write" { + command = apply + + variables { + access_mode = "read-write" + assignment_scope = "organization" + organization_id = "987654321" + } + + assert { + condition = length(google_project_iam_custom_role.sre_agent) == 0 && length(google_project_iam_member.sre_agent) == 0 && length(google_organization_iam_custom_role.sre_agent) == 2 + error_message = "Organization mode must create both organization roles and no project roles or grants." + } + + assert { + condition = toset(keys(google_organization_iam_member.sre_agent)) == toset(["readSRE", "writeSRE"]) && alltrue([for id, binding in google_organization_iam_member.sre_agent : binding.member == local.principal_id && binding.role == google_organization_iam_custom_role.sre_agent[id].name]) + error_message = "Both organization roles must be separately assigned to the supplied identity." + } + + assert { + condition = google_organization_iam_custom_role.sre_agent["readSRE"].permissions == setunion(toset(jsondecode(file("readSRE.json")).includedPermissions), toset(["resourcemanager.projects.list"])) + error_message = "Organization project discovery must be included only in the read role." + } +} + +run "organization_downgrade" { + command = apply + + variables { + assignment_scope = "organization" + organization_id = "987654321" + } + + assert { + condition = toset(keys(google_organization_iam_member.sre_agent)) == toset(["readSRE"]) && length(google_organization_iam_custom_role.sre_agent) == 2 + error_message = "Organization read-only must retain both role definitions but only the read assignment." + } +} diff --git a/sreagent-templates/identity-federation/gcp/tests/test_setup.py b/sreagent-templates/identity-federation/gcp/tests/test_setup.py new file mode 100644 index 000000000..6f25d879a --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/tests/test_setup.py @@ -0,0 +1,245 @@ +"""Exercise both setup entry points with local CLI doubles; no cloud access.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +TENANT = "11111111-1111-1111-1111-111111111111" +OBJECT = "22222222-2222-2222-2222-222222222222" +RESOURCE = ( + f"/subscriptions/{TENANT}/resourceGroups/test/" + "providers/Microsoft.ManagedIdentity/userAssignedIdentities/test" +) +PROJECT = "sre-test-project" +POOL = "projects/123456789012/locations/global/workloadIdentityPools/sre-agent-pool" +PRINCIPAL = f"principal://iam.googleapis.com/{POOL}/subject/{TENANT}::{OBJECT}" + + +def cli_double(command, args): + case = json.loads(os.environ["GCP_SETUP_TEST_CASE"]) + scope = case["scope"] + scope_id = PROJECT if scope == "project" else "987654321" + parent = f"{'projects' if scope == 'project' else 'organizations'}/{scope_id}" + output = Path(os.environ["GCP_SETUP_TEST_OUTPUT"]) + with (output / "commands.jsonl").open("a", encoding="utf-8") as log: + log.write(json.dumps([command, *args]) + "\n") + + if command == "az": + return {"tenantId": TENANT, "principalId": OBJECT} + if command == "gcloud": + if args[:2] == ["auth", "list"]: + return [{"account": "test@example.invalid"}] + if args[:3] == ["config", "get", "project"]: + return PROJECT + if args[:2] == ["projects", "describe"]: + return {"projectId": PROJECT, "projectNumber": "123456789012"} + if args[:2] == ["services", "list"]: + return [ + {"config": {"name": name}} + for name in ("serviceusage.googleapis.com", "iam.googleapis.com") + ] + if args[:3] == ["iam", "workload-identity-pools", "list"]: + return [] + if args[:3] == ["iam", "roles", "list"]: + return ( + [{"name": f"{parent}/roles/{role}"} for role in ("readSRE", "writeSRE")] + if case["existing"] + else [] + ) + if args[:3] == ["iam", "roles", "describe"]: + return {"name": f"{parent}/roles/{args[3]}", "stage": "GA"} + if args[1:2] == ["get-iam-policy"]: + bindings = [] + if case["existing"]: + for role in ("readSRE", "writeSRE", "SREAgentRole"): + binding = { + "role": f"{parent}/roles/{role}", + "members": ["user:other@example.invalid"], + } + if not case.get("other_only"): + binding["members"].append(PRINCIPAL) + if role == case.get("conditional"): + binding["condition"] = { + "title": "test", + "expression": "true", + } + bindings.append(binding) + return {"bindings": bindings} + if args[:2] == ["auth", "print-access-token"]: + return "local-test-token" + if command == "terraform": + if args[:1] == ["version"]: + return {"terraform_version": "1.7.0"} + if args[:1] == ["import"]: + config = json.loads(Path("revocations.tf.json").read_text()) + with (output / "revocations.json").open("w", encoding="utf-8") as target: + json.dump(config, target) + if case.get("import_failure"): + print("Simulated import failure", file=sys.stderr) + sys.exit(1) + return "" + if args[:1] == ["apply"]: + if Path("revocations.tf.json").exists(): + raise AssertionError("Revocation configuration must be removed before apply") + for name in ("guided.auto.tfvars.json", "imports.tf", "readSRE.json", "writeSRE.json"): + shutil.copyfile(name, output / name) + return "" + if args[:1] == ["output"]: + roles = ["readSRE"] + (["writeSRE"] if case["mode"] == "read-write" else []) + return { + "assigned_role_names": {"value": [f"{parent}/roles/{role}" for role in roles]}, + "workload_identity_provider": {"value": f"{POOL}/providers/sre-agent-provider"}, + "principal_id": {"value": PRINCIPAL}, + } + if args[:1] == ["init"] or args[:1] == ["workspace"]: + return "" + raise AssertionError(f"Unexpected command: {command} {args}") + + +class SetupTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.pwsh = shutil.which("pwsh") + cls.bash = shutil.which("bash") + cls.python = sys.executable + if not cls.pwsh or not cls.bash or not shutil.which("jq"): + raise RuntimeError("Tests require PowerShell 7, Bash, Python, and jq on PATH") + + def run_setup(self, shell, **case): + with tempfile.TemporaryDirectory(prefix="gcp-setup-test-") as directory: + temp = Path(directory) + bin_dir = temp / "bin" + bin_dir.mkdir() + stub = Path(__file__).resolve() + for name in ("az", "gcloud", "terraform"): + # PowerShell uses .cmd doubles on Windows; Bash uses executable shell doubles. + if os.name == "nt": + (bin_dir / f"{name}.cmd").write_text( + f'@"{self.python}" "{stub}" --stub {name} %*\n', + encoding="utf-8", + ) + launcher = bin_dir / name + launcher.write_text( + '#!/usr/bin/env bash\n' + f'exec "{Path(self.python).as_posix()}" "{stub.as_posix()}" --stub {name} "$@"\n', + encoding="utf-8", + ) + launcher.chmod(0o755) + env = os.environ.copy() + env["PATH"] = str(bin_dir) + os.pathsep + env["PATH"] + env["GCP_SETUP_TEST_CASE"] = json.dumps(case) + env["GCP_SETUP_TEST_OUTPUT"] = str(temp) + if shell == "pwsh": + args = [ + self.pwsh, "-NoProfile", "-File", str(ROOT / "setup.ps1"), + "-ResourceId", RESOURCE, "-AllowedPermissions", case["mode"], + "-SkipApproval", "true", "-AssignmentScope", case["scope"], + ] + if case["scope"] == "organization": + args += ["-OrganizationId", "987654321"] + else: + args = [ + self.bash, (ROOT / "setup.sh").as_posix(), + "--resource-id", RESOURCE, "--allowed-permissions", case["mode"], + "--skip-approval", "true", "--assignment-scope", case["scope"], + ] + if case["scope"] == "organization": + args += ["--organization-id", "987654321"] + result = subprocess.run(args, env=env, text=True, capture_output=True, timeout=90) + files = { + name: (temp / name).read_text(encoding="utf-8-sig") + for name in ( + "commands.jsonl", "guided.auto.tfvars.json", "imports.tf", + "revocations.json", "readSRE.json", "writeSRE.json", + ) + if (temp / name).exists() + } + return result, files + + def test_assignment_modes_and_migration(self): + for shell in ("pwsh", "bash"): + for scope in ("project", "organization"): + for mode in ("read-only", "read-write"): + for existing in (False, True): + with self.subTest(shell=shell, scope=scope, mode=mode, existing=existing): + result, files = self.run_setup( + shell, scope=scope, mode=mode, existing=existing, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + variables = json.loads(files["guided.auto.tfvars.json"]) + self.assertEqual(variables["access_mode"], mode) + self.assertEqual(variables["assignment_scope"], scope) + self.assertEqual(variables["uami_object_id"], OBJECT) + self.assertNotIn("existing_role_permissions", variables) + for role in ("readSRE", "writeSRE"): + self.assertEqual( + json.loads(files[f"{role}.json"]), + json.loads((ROOT / f"{role}.json").read_text()), + ) + address = f'google_{scope}_iam_custom_role.sre_agent["{role}"]' + self.assertEqual(address in files["imports.tf"], existing) + address = f'google_{scope}_iam_member.sre_agent["{role}"]' + self.assertEqual( + address in files["imports.tf"], + existing and (role == "readSRE" or mode == "read-write"), + ) + if existing: + resource = json.loads(files["revocations.json"])["resource"] + revoked = resource[f"google_{scope}_iam_member"]["revoked"] + self.assertEqual(revoked["member"], PRINCIPAL) + self.assertEqual( + set(revoked["for_each"]), + {"SREAgentRole", "writeSRE"} if mode == "read-only" else {"SREAgentRole"}, + ) + commands = [json.loads(line) for line in files["commands.jsonl"].splitlines()] + imports = [c for c in commands if c[:2] == ["terraform", "import"]] + self.assertEqual(len(imports), len(revoked["for_each"])) + self.assertTrue(all(PRINCIPAL in c[-1] for c in imports)) + self.assertTrue(all("user:other" not in c[-1] for c in imports)) + else: + self.assertNotIn("revocations.json", files) + + def test_other_identities_are_not_revoked(self): + for shell in ("pwsh", "bash"): + with self.subTest(shell=shell): + result, files = self.run_setup( + shell, scope="project", mode="read-only", existing=True, other_only=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("revocations.json", files) + self.assertNotIn("iam_member.sre_agent", files["imports.tf"]) + + def test_conditional_grants_block_unsafe_removal(self): + for shell in ("pwsh", "bash"): + for role in ("writeSRE", "SREAgentRole"): + with self.subTest(shell=shell, role=role): + result, files = self.run_setup( + shell, scope="project", mode="read-only", existing=True, conditional=role, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"conditional {role} assignment", result.stdout + result.stderr) + self.assertNotIn("guided.auto.tfvars.json", files) + + def test_import_failure_stops_apply(self): + for shell in ("pwsh", "bash"): + with self.subTest(shell=shell): + result, files = self.run_setup( + shell, scope="project", mode="read-only", existing=True, import_failure=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("guided.auto.tfvars.json", files) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--stub": + value = cli_double(sys.argv[2], sys.argv[3:]) + print(value if isinstance(value, str) else json.dumps(value)) + else: + unittest.main() diff --git a/sreagent-templates/identity-federation/gcp/variables.tf b/sreagent-templates/identity-federation/gcp/variables.tf index 3817ea064..2149a89f7 100644 --- a/sreagent-templates/identity-federation/gcp/variables.tf +++ b/sreagent-templates/identity-federation/gcp/variables.tf @@ -43,14 +43,8 @@ variable "attribute_condition" { } } -variable "existing_role_permissions" { - description = "Live role permissions to preserve when adding the requested access mode." - type = set(string) - default = [] -} - variable "access_mode" { - description = "Permissions to add; read-only never removes existing write permissions." + description = "Assign readSRE for read-only access, or readSRE and writeSRE separately for read-write access." type = string default = "read-only" diff --git a/sreagent-templates/identity-federation/gcp/writeSRE.json b/sreagent-templates/identity-federation/gcp/writeSRE.json new file mode 100644 index 000000000..1e9dc509b --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/writeSRE.json @@ -0,0 +1,98 @@ +{ + "title": "writeSRE", + "description": "SRE Agent workload changes; assign together with readSRE.", + "stage": "GA", + "includedPermissions": [ + "storage.buckets.create", + "storage.buckets.update", + "storage.objects.create", + "storage.objects.update", + "run.services.create", + "run.services.update", + "run.jobs.create", + "run.jobs.update", + "compute.instances.create", + "compute.instances.update", + "compute.instances.start", + "compute.instances.stop", + "compute.zoneOperations.get", + "compute.regionOperations.get", + "compute.globalOperations.get", + "compute.disks.create", + "compute.disks.update", + "container.clusters.create", + "container.clusters.update", + "cloudsql.instances.create", + "cloudsql.instances.update", + "pubsub.topics.create", + "pubsub.topics.update", + "pubsub.topics.attachSubscription", + "pubsub.subscriptions.create", + "pubsub.subscriptions.update", + "bigquery.tables.create", + "bigquery.tables.update", + "cloudfunctions.functions.create", + "cloudfunctions.functions.update", + "compute.networks.create", + "compute.networks.update", + "compute.firewalls.create", + "compute.firewalls.update", + "artifactregistry.repositories.create", + "artifactregistry.repositories.update", + "logging.logMetrics.create", + "logging.logMetrics.update", + "logging.sinks.create", + "logging.sinks.update", + "monitoring.dashboards.create", + "monitoring.dashboards.update", + "monitoring.alertPolicies.create", + "monitoring.alertPolicies.update", + "compute.forwardingRules.create", + "compute.forwardingRules.update", + "compute.globalForwardingRules.create", + "compute.globalForwardingRules.update", + "compute.backendServices.create", + "compute.backendServices.update", + "compute.regionBackendServices.create", + "compute.regionBackendServices.update", + "compute.backendBuckets.create", + "compute.backendBuckets.update", + "compute.healthChecks.create", + "compute.healthChecks.update", + "compute.httpHealthChecks.create", + "compute.httpHealthChecks.update", + "compute.httpsHealthChecks.create", + "compute.httpsHealthChecks.update", + "compute.urlMaps.create", + "compute.urlMaps.update", + "compute.regionUrlMaps.create", + "compute.regionUrlMaps.update", + "compute.targetHttpProxies.create", + "compute.targetHttpProxies.update", + "compute.targetHttpsProxies.create", + "compute.targetHttpsProxies.update", + "compute.targetTcpProxies.create", + "compute.targetTcpProxies.update", + "compute.targetSslProxies.create", + "compute.targetSslProxies.update", + "compute.addresses.create", + "compute.globalAddresses.create", + "compute.routers.create", + "compute.routers.update", + "compute.routes.create", + "compute.subnetworks.create", + "compute.subnetworks.update", + "compute.subnetworks.use", + "dns.managedZones.create", + "dns.managedZones.update", + "dns.resourceRecordSets.create", + "dns.resourceRecordSets.update", + "dns.changes.create", + "redis.instances.create", + "redis.instances.update", + "memcache.instances.create", + "memcache.instances.update", + "file.instances.create", + "file.instances.update" + ] +} From 3693e139470aabd09fce2ac3bfb254265385a693 Mon Sep 17 00:00:00 2001 From: Ishita Asthana Date: Mon, 2 Nov 2026 16:29:56 +0530 Subject: [PATCH 2/4] Preserve existing GCP custom role permissions during setup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/gcp/README.md | 37 +++++--- .../identity-federation/gcp/main.tf | 7 ++ .../identity-federation/gcp/setup.ps1 | 26 ++++-- .../identity-federation/gcp/setup.sh | 14 +++- .../gcp/tests/preserve-permissions.tftest.hcl | 84 +++++++++++++++++++ .../gcp/tests/test_setup.py | 33 +++++++- .../identity-federation/gcp/variables.tf | 11 +++ 7 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl diff --git a/sreagent-templates/identity-federation/gcp/README.md b/sreagent-templates/identity-federation/gcp/README.md index 54aa963bc..353e60027 100644 --- a/sreagent-templates/identity-federation/gcp/README.md +++ b/sreagent-templates/identity-federation/gcp/README.md @@ -31,7 +31,7 @@ Review the two custom roles independently: Both custom roles are created at the selected scope. The supplied managed identity receives only `readSRE` for `read-only`, or two separate IAM memberships -for `read-write`. The write role does not duplicate the read catalog. At +for `read-write`. The write catalog does not duplicate the read catalog. At organization scope, Terraform additionally includes `resourcemanager.projects.list` in `readSRE`; that permission is not applicable to a project-level custom role. @@ -141,9 +141,12 @@ The Bash parameters match the PowerShell parameters above. Omit and enabled APIs; imports the role assignment only if it has no IAM condition. Pool/provider imports use the configured project ID to avoid replacement; discovery and federated principal names use the project number. -- Extends compatible provider trust and reconciles each custom role to its - separate JSON permission catalog. Existing permissions are not unioned across - roles or access modes. +- Extends compatible provider trust and fetches each existing role's + `includedPermissions`. Each role receives the union of its own existing + permissions and its own JSON catalog; missing catalog permissions are added, + and manual additions are retained. Permissions are never copied between roles. + If an existing role's permissions cannot be read as an array of nonempty + strings, setup fails before applying changes. - Ensures IAM, Resource Manager, Service Account Credentials and STS APIs are enabled; applies an additive IAM member without replacing other bindings. - Imports obsolete memberships for the selected identity into temporary @@ -189,11 +192,16 @@ Terraform can delete or replace the pool, provider and role during apply. With approval skipped, these changes run without confirmation; GCP soft-delete retention still applies. -**Shared role definitions, separate grants:** permissions in each named role -are reconciled exactly to its catalog (plus organization-only project listing -for `readSRE`). Updating a catalog affects all assignees of that role; it does -not add write access to `readSRE`. Review the plan before applying, especially -if roles with these IDs already exist. +**Shared role definitions, additive permissions:** each named role retains +its existing permissions and adds missing permissions from its own catalog +(plus organization-only project listing for `readSRE`). Existing titles, +descriptions, and launch stages are also left unchanged. Removing a permission +from a catalog does not revoke it from an existing role. + +Manual additions affect all assignees of that role. In particular, if you +manually add a write permission to `readSRE`, it remains available to identities +assigned only that role; the `read-only` selection does not sanitize custom +permissions. Review existing permissions and the plan before applying. ### Downgrades and migration from `SREAgentRole` @@ -228,13 +236,20 @@ must migrate the selected membership and retire its old state/configuration through their normal reviewed workflow; do not apply this configuration over an old shared-role state without reviewing deletions. +The scripts pass a per-role `existing_role_permissions` map into Terraform. +Direct Terraform users must populate this map from the live roles before each +apply to preserve manual permission additions. Run setup sequentially and do +not edit roles between discovery and apply: the union uses the discovery +snapshot, not a transaction that merges concurrent role edits. + ## Local regression checks `terraform init` followed by `terraform test` runs the mocked project and organization role/assignment tests without changing cloud resources. `python tests/test_setup.py` exercises both setup entry points using local -CLI doubles, including legacy migration, downgrades, conditional grants, and -import failures. The script tests require Python, PowerShell 7, Bash, and `jq` +CLI doubles, including per-role permission preservation, invalid permission +responses, legacy migration, downgrades, conditional grants, and import +failures. The script tests require Python, PowerShell 7, Bash, and `jq` on `PATH`; they do not require Azure or GCP credentials. ## References diff --git a/sreagent-templates/identity-federation/gcp/main.tf b/sreagent-templates/identity-federation/gcp/main.tf index 2981bb931..16d8c0a5f 100644 --- a/sreagent-templates/identity-federation/gcp/main.tf +++ b/sreagent-templates/identity-federation/gcp/main.tf @@ -60,6 +60,7 @@ locals { } role_permissions = { for id, definition in local.role_definitions : id => setunion( + lookup(var.existing_role_permissions, id, toset([])), toset(definition.includedPermissions), # Project listing on a parent is only applicable to organization-level roles. id == "readSRE" && local.organization_scope ? toset(["resourcemanager.projects.list"]) : toset([]), @@ -80,6 +81,10 @@ resource "google_project_iam_custom_role" "sre_agent" { permissions = local.role_permissions[each.key] depends_on = [google_project_service.required] + + lifecycle { + ignore_changes = [title, description, stage] + } } resource "google_organization_iam_custom_role" "sre_agent" { @@ -95,6 +100,8 @@ resource "google_organization_iam_custom_role" "sre_agent" { depends_on = [google_project_service.required] lifecycle { + ignore_changes = [title, description, stage] + precondition { condition = var.organization_id != "" error_message = "organization_id is required when assignment_scope is organization." diff --git a/sreagent-templates/identity-federation/gcp/setup.ps1 b/sreagent-templates/identity-federation/gcp/setup.ps1 index 14a9a96e3..5dc0ac22e 100644 --- a/sreagent-templates/identity-federation/gcp/setup.ps1 +++ b/sreagent-templates/identity-federation/gcp/setup.ps1 @@ -287,6 +287,7 @@ function Invoke-GcpSetup { } $roles = Invoke-SetupJson gcloud @('iam', 'roles', 'list', $scopeFlag, '--show-deleted', '--format=json', '--quiet') + $existingRolePermissions = @{} foreach ($roleId in $RoleIds) { $roleName = "$scopeParent/roles/$roleId" $role = @($roles | Where-Object { $_.name -ceq $roleName }) @@ -294,6 +295,12 @@ function Invoke-GcpSetup { Assert-ActiveResource $role[0] $roleId $details = Invoke-SetupJson gcloud @('iam', 'roles', 'describe', $roleId, $scopeFlag, '--format=json', '--quiet') Assert-ActiveResource $details $roleId + if (-not $details.Contains('includedPermissions') -or + $details.includedPermissions -isnot [array] -or + @($details.includedPermissions | Where-Object { $_ -isnot [string] -or [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { + throw "The existing $roleId response has no valid includedPermissions array; refusing to replace unknown permissions." + } + $existingRolePermissions[$roleId] = @($details.includedPermissions) $imports.Add((New-ImportBlock "google_${scope}_iam_custom_role.sre_agent[`"$roleId`"]" $roleName)) } } @@ -340,14 +347,15 @@ function Invoke-GcpSetup { } $variables = [ordered]@{ - project_id = $projectId - project_number = $projectNumber - azure_tenant_id = $tenantId - uami_object_id = $objectId - access_mode = $accessMode - assignment_scope = $scope - organization_id = $organizationId - attribute_condition = $trust.Condition + project_id = $projectId + project_number = $projectNumber + azure_tenant_id = $tenantId + uami_object_id = $objectId + access_mode = $accessMode + assignment_scope = $scope + organization_id = $organizationId + attribute_condition = $trust.Condition + existing_role_permissions = $existingRolePermissions } Write-Host "Azure tenant: $tenantId" Write-Host "Managed identity object ID: $objectId" @@ -356,7 +364,7 @@ function Invoke-GcpSetup { if ($revokedRoles.Count -gt 0) { Write-Warning "Removing only this identity's assignments to: $($revokedRoles.Values -join ', '). Other identities and the legacy role definition are preserved." } - Write-Warning 'readSRE and writeSRE permissions are reconciled to their JSON catalogs for all assignees. Unrelated or inherited IAM grants are not removed.' + Write-Warning 'Each role retains its existing permissions and adds missing permissions from its own JSON catalog. Manual additions apply to all assignees of that role, even in read-only mode. Unrelated or inherited IAM grants are not removed.' Write-Warning 'Run setups sequentially. Do not adopt resources concurrently managed by another Terraform state. The temporary named workspace will be deleted WITHOUT destroying GCP resources.' $workspace = "sre-agent-gcp-$([Guid]::NewGuid().ToString('N'))" diff --git a/sreagent-templates/identity-federation/gcp/setup.sh b/sreagent-templates/identity-federation/gcp/setup.sh index 6dc074efe..630691d5d 100644 --- a/sreagent-templates/identity-federation/gcp/setup.sh +++ b/sreagent-templates/identity-federation/gcp/setup.sh @@ -245,6 +245,7 @@ main() { fi roles="$(run_json gcloud iam roles list "$scope_flag" --show-deleted --format=json --quiet)" + local existing_role_permissions='{}' for role_id in "${role_ids[@]}"; do role_name="$scope_parent/roles/$role_id" role="$(jq -c --arg name "$role_name" 'map(select(.name == $name)) | .[0] // null' <<<"$roles")" @@ -252,6 +253,12 @@ main() { assert_active_resource "$role" "$role_id" details="$(run_json gcloud iam roles describe "$role_id" "$scope_flag" --format=json --quiet)" assert_active_resource "$details" "$role_id" + jq -e '.includedPermissions | if type == "array" then + all(.[]; if type == "string" then test("\\S") else false end) + else false end' >/dev/null <<<"$details" \ + || fail "The existing $role_id response has no valid includedPermissions array; refusing to replace unknown permissions." + existing_role_permissions="$(jq -c --arg id "$role_id" --argjson role "$details" \ + '. + {($id): $role.includedPermissions}' <<<"$existing_role_permissions")" append_import "google_${scope}_iam_custom_role.sre_agent[\"$role_id\"]" "$role_name" fi done @@ -289,7 +296,7 @@ main() { if [[ "$revoked_roles" != '{}' ]]; then warn "Removing only this identity's assignments to: $(jq -r '[.[]] | join(", ")' <<<"$revoked_roles"). Other identities and the legacy role definition are preserved." fi - warn "readSRE and writeSRE permissions are reconciled to their JSON catalogs for all assignees. Unrelated or inherited IAM grants are not removed." + warn "Each role retains its existing permissions and adds missing permissions from its own JSON catalog. Manual additions apply to all assignees of that role, even in read-only mode. Unrelated or inherited IAM grants are not removed." warn "Run setups sequentially. Do not concurrently manage these resources from another Terraform state. The temporary workspace is deleted WITHOUT destroying GCP resources." umask 077 @@ -303,11 +310,12 @@ main() { cp "$setup_root/readSRE.json" "$setup_root/writeSRE.json" "$run_directory/" jq -n --arg project_id "$project_id" --arg project_number "$project_number" \ --arg tenant "$tenant_id" --arg object "$object_id" --arg mode "$access_mode" \ - --arg scope "$scope" --arg organization "$organization_id" --arg condition "$condition" '{ + --arg scope "$scope" --arg organization "$organization_id" --arg condition "$condition" \ + --argjson permissions "$existing_role_permissions" '{ project_id: $project_id, project_number: $project_number, azure_tenant_id: $tenant, uami_object_id: $object, access_mode: $mode, assignment_scope: $scope, organization_id: $organization, - attribute_condition: $condition + attribute_condition: $condition, existing_role_permissions: $permissions }' >"$run_directory/guided.auto.tfvars.json" printf '%s' "$imports" >"$run_directory/imports.tf" if [[ "$revoked_roles" != '{}' ]]; then diff --git a/sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl b/sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl new file mode 100644 index 000000000..295789037 --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl @@ -0,0 +1,84 @@ +mock_provider "google" {} + +variables { + project_id = "sre-test-project" + project_number = "123456789012" + azure_tenant_id = "11111111-1111-1111-1111-111111111111" + uami_object_id = "22222222-2222-2222-2222-222222222222" + attribute_condition = "assertion.oid == '22222222-2222-2222-2222-222222222222'" + existing_role_permissions = { + readSRE = ["compute.projects.get", "compute.instances.get", "compute.projects.get"] + writeSRE = ["compute.instances.delete", "compute.instances.update"] + } +} + +run "project_preserves_each_role" { + command = apply + + assert { + condition = alltrue([ + for id, role in google_project_iam_custom_role.sre_agent : + role.permissions == setunion(var.existing_role_permissions[id], toset(jsondecode(file("${id}.json")).includedPermissions)) + ]) + error_message = "Each project role must preserve its own manual permissions and add all missing catalog permissions without borrowing from the other role." + } +} + +run "project_upgrade_preserves_permissions" { + command = apply + + variables { + access_mode = "read-write" + } + + assert { + condition = alltrue([ + for id, role in google_project_iam_custom_role.sre_agent : + role.permissions == setunion(var.existing_role_permissions[id], toset(jsondecode(file("${id}.json")).includedPermissions)) + ]) + error_message = "Changing access mode must not replace either role's permissions." + } +} + +run "organization_preserves_each_role" { + command = apply + + variables { + assignment_scope = "organization" + organization_id = "987654321" + access_mode = "read-write" + } + + assert { + condition = alltrue([ + for id, role in google_organization_iam_custom_role.sre_agent : + role.permissions == setunion( + var.existing_role_permissions[id], + toset(jsondecode(file("${id}.json")).includedPermissions), + id == "readSRE" ? toset(["resourcemanager.projects.list"]) : toset([]), + ) + ]) + error_message = "Each organization role must preserve only its own existing permissions; project listing belongs only to readSRE." + } +} + +run "organization_downgrade_preserves_permissions" { + command = apply + + variables { + assignment_scope = "organization" + organization_id = "987654321" + } + + assert { + condition = alltrue([ + for id, role in google_organization_iam_custom_role.sre_agent : + role.permissions == setunion( + var.existing_role_permissions[id], + toset(jsondecode(file("${id}.json")).includedPermissions), + id == "readSRE" ? toset(["resourcemanager.projects.list"]) : toset([]), + ) + ]) + error_message = "A downgrade must preserve manual permissions in both role definitions, including the unassigned write role." + } +} diff --git a/sreagent-templates/identity-federation/gcp/tests/test_setup.py b/sreagent-templates/identity-federation/gcp/tests/test_setup.py index 6f25d879a..c026576d8 100644 --- a/sreagent-templates/identity-federation/gcp/tests/test_setup.py +++ b/sreagent-templates/identity-federation/gcp/tests/test_setup.py @@ -20,6 +20,10 @@ PROJECT = "sre-test-project" POOL = "projects/123456789012/locations/global/workloadIdentityPools/sre-agent-pool" PRINCIPAL = f"principal://iam.googleapis.com/{POOL}/subject/{TENANT}::{OBJECT}" +EXISTING_PERMISSIONS = { + "readSRE": ["compute.projects.get", "compute.instances.get"], + "writeSRE": ["compute.instances.delete", "compute.instances.update"], +} def cli_double(command, args): @@ -54,7 +58,12 @@ def cli_double(command, args): else [] ) if args[:3] == ["iam", "roles", "describe"]: - return {"name": f"{parent}/roles/{args[3]}", "stage": "GA"} + details = {"name": f"{parent}/roles/{args[3]}", "stage": "GA"} + if not case.get("missing_permissions"): + details["includedPermissions"] = case.get( + "permissions", EXISTING_PERMISSIONS[args[3]], + ) + return details if args[1:2] == ["get-iam-policy"]: bindings = [] if case["existing"]: @@ -177,7 +186,10 @@ def test_assignment_modes_and_migration(self): self.assertEqual(variables["access_mode"], mode) self.assertEqual(variables["assignment_scope"], scope) self.assertEqual(variables["uami_object_id"], OBJECT) - self.assertNotIn("existing_role_permissions", variables) + self.assertEqual( + variables["existing_role_permissions"], + EXISTING_PERMISSIONS if existing else {}, + ) for role in ("readSRE", "writeSRE"): self.assertEqual( json.loads(files[f"{role}.json"]), @@ -236,6 +248,23 @@ def test_import_failure_stops_apply(self): self.assertNotEqual(result.returncode, 0) self.assertNotIn("guided.auto.tfvars.json", files) + def test_unknown_permissions_stop_apply(self): + for shell in ("pwsh", "bash"): + for invalid in ( + {"missing_permissions": True}, + {"permissions": None}, + {"permissions": "compute.projects.get"}, + {"permissions": [None]}, + {"permissions": [" "]}, + ): + with self.subTest(shell=shell, invalid=invalid): + result, files = self.run_setup( + shell, scope="project", mode="read-only", existing=True, **invalid, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("refusing to replace unknown permissions", result.stdout + result.stderr) + self.assertNotIn("guided.auto.tfvars.json", files) + if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "--stub": diff --git a/sreagent-templates/identity-federation/gcp/variables.tf b/sreagent-templates/identity-federation/gcp/variables.tf index 2149a89f7..b1e4c27c0 100644 --- a/sreagent-templates/identity-federation/gcp/variables.tf +++ b/sreagent-templates/identity-federation/gcp/variables.tf @@ -43,6 +43,17 @@ variable "attribute_condition" { } } +variable "existing_role_permissions" { + description = "Live permissions fetched separately for each existing custom role by the setup scripts; preserved when adding catalog permissions." + type = map(set(string)) + default = {} + + validation { + condition = alltrue([for id in keys(var.existing_role_permissions) : contains(["readSRE", "writeSRE"], id)]) + error_message = "existing_role_permissions keys must be readSRE or writeSRE." + } +} + variable "access_mode" { description = "Assign readSRE for read-only access, or readSRE and writeSRE separately for read-write access." type = string From 50f552e52e95df378cf8754acbe4ea6ed3e42f8e Mon Sep 17 00:00:00 2001 From: Ishita Asthana Date: Mon, 2 Nov 2026 16:43:01 +0530 Subject: [PATCH 3/4] Simplify GCP setup documentation and remove legacy migration and tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/gcp/README.md | 96 +----- .../identity-federation/gcp/setup.ps1 | 8 +- .../identity-federation/gcp/setup.sh | 18 +- .../gcp/tests/preserve-permissions.tftest.hcl | 84 ------ .../gcp/tests/roles.tftest.hcl | 108 ------- .../gcp/tests/test_setup.py | 274 ------------------ 6 files changed, 22 insertions(+), 566 deletions(-) delete mode 100644 sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl delete mode 100644 sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl delete mode 100644 sreagent-templates/identity-federation/gcp/tests/test_setup.py diff --git a/sreagent-templates/identity-federation/gcp/README.md b/sreagent-templates/identity-federation/gcp/README.md index 353e60027..b7a6cf82f 100644 --- a/sreagent-templates/identity-federation/gcp/README.md +++ b/sreagent-templates/identity-federation/gcp/README.md @@ -20,22 +20,6 @@ On Linux or macOS, use `cd sre-agent/sreagent-templates/identity-federation/gcp` Follow the steps below from that folder. Keep the setup scripts, all `.tf` files, and both role JSON files together. -## Permission catalogs - -Review the two custom roles independently: - -| Role / file | Access | Assigned for | -| --- | --- | --- | -| [`readSRE`](./readSRE.json) | Resource discovery, metadata, logs, metrics, and Storage object reads | `read-only` and `read-write` | -| [`writeSRE`](./writeSRE.json) | Workload changes and operation-status reads needed by those changes | `read-write` only, separately from `readSRE` | - -Both custom roles are created at the selected scope. The supplied managed -identity receives only `readSRE` for `read-only`, or two separate IAM memberships -for `read-write`. The write catalog does not duplicate the read catalog. At -organization scope, Terraform additionally includes -`resourcemanager.projects.list` in `readSRE`; that permission is not applicable -to a project-level custom role. - ## Prerequisites - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), @@ -141,18 +125,11 @@ The Bash parameters match the PowerShell parameters above. Omit and enabled APIs; imports the role assignment only if it has no IAM condition. Pool/provider imports use the configured project ID to avoid replacement; discovery and federated principal names use the project number. -- Extends compatible provider trust and fetches each existing role's - `includedPermissions`. Each role receives the union of its own existing - permissions and its own JSON catalog; missing catalog permissions are added, - and manual additions are retained. Permissions are never copied between roles. - If an existing role's permissions cannot be read as an array of nonempty - strings, setup fails before applying changes. +- Extends compatible provider trust and adds missing catalog permissions to + each role without removing its existing permissions. - Ensures IAM, Resource Manager, Service Account Credentials and STS APIs are enabled; applies an additive IAM member without replacing other bindings. -- Imports obsolete memberships for the selected identity into temporary - Terraform resources, then removes their configuration so their deletion - appears in the same approval plan as the new assignments. Import itself - does not change remote IAM policy. +- Removes the selected identity's `writeSRE` assignment when `read-only` is requested. - Runs `terraform init` and `apply` in a unique temporary CLI workspace, then deletes the workspace and local files without running `terraform destroy`. @@ -165,13 +142,6 @@ The Bash parameters match the PowerShell parameters above. Omit | Read custom IAM role | `readSRE` | `readSRE` | | Write custom IAM role | `writeSRE` | `writeSRE` | -Project scope is the default. For organization-wide roles and assignments, -use `-AssignmentScope organization -OrganizationId ` in PowerShell, or -`--assignment-scope organization --organization-id ` in Bash. This requires -organization-level custom-role and IAM administration permissions; grants are -inherited by descendant resources. The workload identity pool remains in the -configured project. - | OIDC field | Value | | --- | --- | | Issuer | `https://login.microsoftonline.com//v2.0` | @@ -192,36 +162,13 @@ Terraform can delete or replace the pool, provider and role during apply. With approval skipped, these changes run without confirmation; GCP soft-delete retention still applies. -**Shared role definitions, additive permissions:** each named role retains -its existing permissions and adds missing permissions from its own catalog -(plus organization-only project listing for `readSRE`). Existing titles, -descriptions, and launch stages are also left unchanged. Removing a permission -from a catalog does not revoke it from an existing role. - -Manual additions affect all assignees of that role. In particular, if you -manually add a write permission to `readSRE`, it remains available to identities -assigned only that role; the `read-only` selection does not sanitize custom -permissions. Review existing permissions and the plan before applying. - -### Downgrades and migration from `SREAgentRole` - -Rerunning with `read-only` removes the selected identity's unconditional -`writeSRE` membership at the selected scope. Other identities' memberships -are preserved. Rerunning with `read-write` ensures both memberships exist. - -For either mode, setup replaces the selected identity's unconditional legacy -`SREAgentRole` membership with the requested new memberships. The legacy role -and its other assignees are left unchanged. If a conditional `writeSRE` grant -must be removed, or a conditional legacy grant must be migrated, setup stops -before applying changes and asks you to resolve that grant through your -approved process. - -**Boundary:** setup does not revoke unrelated roles, grants at other scopes, -or access inherited from an organization, folder, or group. A `read-only` -selection therefore guarantees only the assignments managed here, not the -identity's total effective access. Review inherited and external grants -separately. No running workload is changed by setup, but write permissions -allow workload changes; use your production approval and rollout process. +Existing role permissions and metadata are preserved; only missing catalog +permissions are added. Manual additions apply to all assignees, including +write permissions added to `readSRE`. + +`read-only` assigns `readSRE` and removes the selected identity's `writeSRE` +assignment; `read-write` assigns both. Conditional grants requiring removal +block setup; other identities and unrelated or inherited access are unchanged. ## State and reruns @@ -230,27 +177,7 @@ Workspace cleanup runs on success, cancellation and apply failure. retains the local directory/state and reports its path. Failed applies can leave partial cloud changes; rerun to rediscover/import them. -Terraform now returns `assigned_role_names` (a list) instead of the legacy -single `role_name` output. Direct Terraform users with retained legacy state -must migrate the selected membership and retire its old state/configuration -through their normal reviewed workflow; do not apply this configuration over -an old shared-role state without reviewing deletions. - -The scripts pass a per-role `existing_role_permissions` map into Terraform. -Direct Terraform users must populate this map from the live roles before each -apply to preserve manual permission additions. Run setup sequentially and do -not edit roles between discovery and apply: the union uses the discovery -snapshot, not a transaction that merges concurrent role edits. - -## Local regression checks - -`terraform init` followed by `terraform test` runs the mocked project and -organization role/assignment tests without changing cloud resources. -`python tests/test_setup.py` exercises both setup entry points using local -CLI doubles, including per-role permission preservation, invalid permission -responses, legacy migration, downgrades, conditional grants, and import -failures. The script tests require Python, PowerShell 7, Bash, and `jq` -on `PATH`; they do not require Azure or GCP credentials. +Run setups sequentially; do not edit roles between discovery and apply. ## References @@ -258,6 +185,5 @@ on `PATH`; they do not require Azure or GCP credentials. - [gcloud configured project](https://cloud.google.com/sdk/gcloud/reference/config/get) - [Google Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds) - [Additive Terraform IAM members](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam) -- [Terraform import and subsequent deletion of resources absent from configuration](https://developer.hashicorp.com/terraform/cli/import/usage) - [Terraform Google provider authentication](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference) - [Terraform workspace deletion semantics](https://developer.hashicorp.com/terraform/cli/commands/workspace/delete) diff --git a/sreagent-templates/identity-federation/gcp/setup.ps1 b/sreagent-templates/identity-federation/gcp/setup.ps1 index 5dc0ac22e..54637d971 100644 --- a/sreagent-templates/identity-federation/gcp/setup.ps1 +++ b/sreagent-templates/identity-federation/gcp/setup.ps1 @@ -319,15 +319,15 @@ function Invoke-GcpSetup { if ($null -eq $binding -or @($binding.members) -cnotcontains $principal) { continue } - foreach ($roleId in @('readSRE', 'writeSRE', 'SREAgentRole')) { + foreach ($roleId in $RoleIds) { $roleName = "$scopeParent/roles/$roleId" if ($binding.role -cne $roleName) { continue } - $remove = $roleId -eq 'SREAgentRole' -or ($roleId -eq 'writeSRE' -and $accessMode -eq 'read-only') + $remove = $roleId -eq 'writeSRE' -and $accessMode -eq 'read-only' if ($binding.Contains('condition')) { if ($remove) { - throw "The selected identity has a conditional $roleId assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade or migrate it." + throw "The selected identity has a conditional $roleId assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade it." } continue } @@ -362,7 +362,7 @@ function Invoke-GcpSetup { Write-Host "Provider: $providerName" Write-Host "Roles to assign: $(($assignedRoleIds | ForEach-Object { "$scopeParent/roles/$_" }) -join ', ')" if ($revokedRoles.Count -gt 0) { - Write-Warning "Removing only this identity's assignments to: $($revokedRoles.Values -join ', '). Other identities and the legacy role definition are preserved." + Write-Warning "Removing only this identity's assignments to: $($revokedRoles.Values -join ', '). Other identities' assignments are preserved." } Write-Warning 'Each role retains its existing permissions and adds missing permissions from its own JSON catalog. Manual additions apply to all assignees of that role, even in read-only mode. Unrelated or inherited IAM grants are not removed.' Write-Warning 'Run setups sequentially. Do not adopt resources concurrently managed by another Terraform state. The temporary named workspace will be deleted WITHOUT destroying GCP resources.' diff --git a/sreagent-templates/identity-federation/gcp/setup.sh b/sreagent-templates/identity-federation/gcp/setup.sh index 630691d5d..41fa11ad3 100644 --- a/sreagent-templates/identity-federation/gcp/setup.sh +++ b/sreagent-templates/identity-federation/gcp/setup.sh @@ -263,17 +263,17 @@ main() { fi done policy="$(run_json gcloud "$policy_kind" get-iam-policy "$scope_id" --format=json --quiet)" - for role_id in readSRE writeSRE SREAgentRole; do + for role_id in "${role_ids[@]}"; do role_name="$scope_parent/roles/$role_id" has_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' any(.bindings[]?; .role == $role and (has("condition") | not) and (.members | index($member) != null))' <<<"$policy")" - if [[ "$role_id" == SREAgentRole || ( "$role_id" == writeSRE && "$access_mode" == read-only ) ]]; then + if [[ "$role_id" == writeSRE && "$access_mode" == read-only ]]; then has_conditional_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' any(.bindings[]?; .role == $role and has("condition") and (.members | index($member) != null))' <<<"$policy")" [[ "$has_conditional_assignment" == false ]] \ - || fail "The selected identity has a conditional $role_id assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade or migrate it." + || fail "The selected identity has a conditional $role_id assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade it." if [[ "$has_assignment" == true ]]; then revoked_roles="$(jq -c --arg id "$role_id" --arg name "$role_name" '. + {($id): $name}' <<<"$revoked_roles")" fi @@ -294,7 +294,7 @@ main() { printf 'Role to assign: %s/roles/%s\n' "$scope_parent" "${assigned_role_ids[1]}" fi if [[ "$revoked_roles" != '{}' ]]; then - warn "Removing only this identity's assignments to: $(jq -r '[.[]] | join(", ")' <<<"$revoked_roles"). Other identities and the legacy role definition are preserved." + warn "Removing only this identity's assignments to: $(jq -r '[.[]] | join(", ")' <<<"$revoked_roles"). Other identities' assignments are preserved." fi warn "Each role retains its existing permissions and adds missing permissions from its own JSON catalog. Manual additions apply to all assignees of that role, even in read-only mode. Unrelated or inherited IAM grants are not removed." warn "Run setups sequentially. Do not concurrently manage these resources from another Terraform state. The temporary workspace is deleted WITHOUT destroying GCP resources." @@ -337,13 +337,9 @@ main() { terraform workspace new "$workspace" workspace_created=true if [[ "$revoked_roles" != '{}' ]]; then - for role_id in SREAgentRole writeSRE; do - role_name="$(jq -r --arg id "$role_id" '.[$id] // empty' <<<"$revoked_roles")" - if [[ -n "$role_name" ]]; then - terraform import -input=false -no-color "google_${scope}_iam_member.revoked[\"$role_id\"]" \ - "$scope_id $role_name $principal" || fail "Unable to import the $role_id assignment for removal." - fi - done + role_name="$(jq -er '.writeSRE' <<<"$revoked_roles")" + terraform import -input=false -no-color "google_${scope}_iam_member.revoked[\"writeSRE\"]" \ + "$scope_id $role_name $principal" || fail "Unable to import the writeSRE assignment for removal." # Removing configuration schedules only these memberships for deletion in the reviewed apply. rm -- revocations.tf.json fi diff --git a/sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl b/sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl deleted file mode 100644 index 295789037..000000000 --- a/sreagent-templates/identity-federation/gcp/tests/preserve-permissions.tftest.hcl +++ /dev/null @@ -1,84 +0,0 @@ -mock_provider "google" {} - -variables { - project_id = "sre-test-project" - project_number = "123456789012" - azure_tenant_id = "11111111-1111-1111-1111-111111111111" - uami_object_id = "22222222-2222-2222-2222-222222222222" - attribute_condition = "assertion.oid == '22222222-2222-2222-2222-222222222222'" - existing_role_permissions = { - readSRE = ["compute.projects.get", "compute.instances.get", "compute.projects.get"] - writeSRE = ["compute.instances.delete", "compute.instances.update"] - } -} - -run "project_preserves_each_role" { - command = apply - - assert { - condition = alltrue([ - for id, role in google_project_iam_custom_role.sre_agent : - role.permissions == setunion(var.existing_role_permissions[id], toset(jsondecode(file("${id}.json")).includedPermissions)) - ]) - error_message = "Each project role must preserve its own manual permissions and add all missing catalog permissions without borrowing from the other role." - } -} - -run "project_upgrade_preserves_permissions" { - command = apply - - variables { - access_mode = "read-write" - } - - assert { - condition = alltrue([ - for id, role in google_project_iam_custom_role.sre_agent : - role.permissions == setunion(var.existing_role_permissions[id], toset(jsondecode(file("${id}.json")).includedPermissions)) - ]) - error_message = "Changing access mode must not replace either role's permissions." - } -} - -run "organization_preserves_each_role" { - command = apply - - variables { - assignment_scope = "organization" - organization_id = "987654321" - access_mode = "read-write" - } - - assert { - condition = alltrue([ - for id, role in google_organization_iam_custom_role.sre_agent : - role.permissions == setunion( - var.existing_role_permissions[id], - toset(jsondecode(file("${id}.json")).includedPermissions), - id == "readSRE" ? toset(["resourcemanager.projects.list"]) : toset([]), - ) - ]) - error_message = "Each organization role must preserve only its own existing permissions; project listing belongs only to readSRE." - } -} - -run "organization_downgrade_preserves_permissions" { - command = apply - - variables { - assignment_scope = "organization" - organization_id = "987654321" - } - - assert { - condition = alltrue([ - for id, role in google_organization_iam_custom_role.sre_agent : - role.permissions == setunion( - var.existing_role_permissions[id], - toset(jsondecode(file("${id}.json")).includedPermissions), - id == "readSRE" ? toset(["resourcemanager.projects.list"]) : toset([]), - ) - ]) - error_message = "A downgrade must preserve manual permissions in both role definitions, including the unassigned write role." - } -} diff --git a/sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl b/sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl deleted file mode 100644 index 30153bc90..000000000 --- a/sreagent-templates/identity-federation/gcp/tests/roles.tftest.hcl +++ /dev/null @@ -1,108 +0,0 @@ -mock_provider "google" {} - -variables { - project_id = "sre-test-project" - project_number = "123456789012" - azure_tenant_id = "11111111-1111-1111-1111-111111111111" - uami_object_id = "22222222-2222-2222-2222-222222222222" - attribute_condition = "assertion.oid == '22222222-2222-2222-2222-222222222222'" -} - -run "project_read_only" { - command = apply - - assert { - condition = toset(keys(google_project_iam_custom_role.sre_agent)) == toset(["readSRE", "writeSRE"]) - error_message = "Both project custom roles must exist even for read-only access." - } - - assert { - condition = toset(keys(google_project_iam_member.sre_agent)) == toset(["readSRE"]) && length(google_organization_iam_member.sre_agent) == 0 - error_message = "Read-only must assign only readSRE at the selected scope." - } - - assert { - condition = ( - google_project_iam_custom_role.sre_agent["readSRE"].permissions == toset(jsondecode(file("readSRE.json")).includedPermissions) && - google_project_iam_custom_role.sre_agent["writeSRE"].permissions == toset(jsondecode(file("writeSRE.json")).includedPermissions) && - length(setintersection(local.role_permissions.readSRE, local.role_permissions.writeSRE)) == 0 - ) - error_message = "Role permissions must exactly match their separate, disjoint catalogs." - } - - assert { - condition = google_project_iam_member.sre_agent["readSRE"].member == local.principal_id && google_project_iam_member.sre_agent["readSRE"].role == google_project_iam_custom_role.sre_agent["readSRE"].name - error_message = "The read role must be bound to the supplied federated identity." - } -} - -run "project_read_write" { - command = apply - - variables { - access_mode = "read-write" - } - - assert { - condition = toset(keys(google_project_iam_member.sre_agent)) == toset(["readSRE", "writeSRE"]) - error_message = "Read-write must assign both project roles separately." - } - - assert { - condition = alltrue([for id, binding in google_project_iam_member.sre_agent : binding.member == local.principal_id && binding.role == google_project_iam_custom_role.sre_agent[id].name]) - error_message = "Each role must be assigned separately to the same supplied identity." - } - - assert { - condition = output.assigned_role_names == [google_project_iam_custom_role.sre_agent["readSRE"].name, google_project_iam_custom_role.sre_agent["writeSRE"].name] - error_message = "Outputs must report both assigned roles." - } -} - -run "project_downgrade" { - command = apply - - assert { - condition = toset(keys(google_project_iam_member.sre_agent)) == toset(["readSRE"]) && length(google_project_iam_custom_role.sre_agent) == 2 - error_message = "A downgrade must remove only the write membership, not its shared custom role." - } -} - -run "organization_read_write" { - command = apply - - variables { - access_mode = "read-write" - assignment_scope = "organization" - organization_id = "987654321" - } - - assert { - condition = length(google_project_iam_custom_role.sre_agent) == 0 && length(google_project_iam_member.sre_agent) == 0 && length(google_organization_iam_custom_role.sre_agent) == 2 - error_message = "Organization mode must create both organization roles and no project roles or grants." - } - - assert { - condition = toset(keys(google_organization_iam_member.sre_agent)) == toset(["readSRE", "writeSRE"]) && alltrue([for id, binding in google_organization_iam_member.sre_agent : binding.member == local.principal_id && binding.role == google_organization_iam_custom_role.sre_agent[id].name]) - error_message = "Both organization roles must be separately assigned to the supplied identity." - } - - assert { - condition = google_organization_iam_custom_role.sre_agent["readSRE"].permissions == setunion(toset(jsondecode(file("readSRE.json")).includedPermissions), toset(["resourcemanager.projects.list"])) - error_message = "Organization project discovery must be included only in the read role." - } -} - -run "organization_downgrade" { - command = apply - - variables { - assignment_scope = "organization" - organization_id = "987654321" - } - - assert { - condition = toset(keys(google_organization_iam_member.sre_agent)) == toset(["readSRE"]) && length(google_organization_iam_custom_role.sre_agent) == 2 - error_message = "Organization read-only must retain both role definitions but only the read assignment." - } -} diff --git a/sreagent-templates/identity-federation/gcp/tests/test_setup.py b/sreagent-templates/identity-federation/gcp/tests/test_setup.py deleted file mode 100644 index c026576d8..000000000 --- a/sreagent-templates/identity-federation/gcp/tests/test_setup.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Exercise both setup entry points with local CLI doubles; no cloud access.""" - -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile -import unittest - - -ROOT = Path(__file__).resolve().parents[1] -TENANT = "11111111-1111-1111-1111-111111111111" -OBJECT = "22222222-2222-2222-2222-222222222222" -RESOURCE = ( - f"/subscriptions/{TENANT}/resourceGroups/test/" - "providers/Microsoft.ManagedIdentity/userAssignedIdentities/test" -) -PROJECT = "sre-test-project" -POOL = "projects/123456789012/locations/global/workloadIdentityPools/sre-agent-pool" -PRINCIPAL = f"principal://iam.googleapis.com/{POOL}/subject/{TENANT}::{OBJECT}" -EXISTING_PERMISSIONS = { - "readSRE": ["compute.projects.get", "compute.instances.get"], - "writeSRE": ["compute.instances.delete", "compute.instances.update"], -} - - -def cli_double(command, args): - case = json.loads(os.environ["GCP_SETUP_TEST_CASE"]) - scope = case["scope"] - scope_id = PROJECT if scope == "project" else "987654321" - parent = f"{'projects' if scope == 'project' else 'organizations'}/{scope_id}" - output = Path(os.environ["GCP_SETUP_TEST_OUTPUT"]) - with (output / "commands.jsonl").open("a", encoding="utf-8") as log: - log.write(json.dumps([command, *args]) + "\n") - - if command == "az": - return {"tenantId": TENANT, "principalId": OBJECT} - if command == "gcloud": - if args[:2] == ["auth", "list"]: - return [{"account": "test@example.invalid"}] - if args[:3] == ["config", "get", "project"]: - return PROJECT - if args[:2] == ["projects", "describe"]: - return {"projectId": PROJECT, "projectNumber": "123456789012"} - if args[:2] == ["services", "list"]: - return [ - {"config": {"name": name}} - for name in ("serviceusage.googleapis.com", "iam.googleapis.com") - ] - if args[:3] == ["iam", "workload-identity-pools", "list"]: - return [] - if args[:3] == ["iam", "roles", "list"]: - return ( - [{"name": f"{parent}/roles/{role}"} for role in ("readSRE", "writeSRE")] - if case["existing"] - else [] - ) - if args[:3] == ["iam", "roles", "describe"]: - details = {"name": f"{parent}/roles/{args[3]}", "stage": "GA"} - if not case.get("missing_permissions"): - details["includedPermissions"] = case.get( - "permissions", EXISTING_PERMISSIONS[args[3]], - ) - return details - if args[1:2] == ["get-iam-policy"]: - bindings = [] - if case["existing"]: - for role in ("readSRE", "writeSRE", "SREAgentRole"): - binding = { - "role": f"{parent}/roles/{role}", - "members": ["user:other@example.invalid"], - } - if not case.get("other_only"): - binding["members"].append(PRINCIPAL) - if role == case.get("conditional"): - binding["condition"] = { - "title": "test", - "expression": "true", - } - bindings.append(binding) - return {"bindings": bindings} - if args[:2] == ["auth", "print-access-token"]: - return "local-test-token" - if command == "terraform": - if args[:1] == ["version"]: - return {"terraform_version": "1.7.0"} - if args[:1] == ["import"]: - config = json.loads(Path("revocations.tf.json").read_text()) - with (output / "revocations.json").open("w", encoding="utf-8") as target: - json.dump(config, target) - if case.get("import_failure"): - print("Simulated import failure", file=sys.stderr) - sys.exit(1) - return "" - if args[:1] == ["apply"]: - if Path("revocations.tf.json").exists(): - raise AssertionError("Revocation configuration must be removed before apply") - for name in ("guided.auto.tfvars.json", "imports.tf", "readSRE.json", "writeSRE.json"): - shutil.copyfile(name, output / name) - return "" - if args[:1] == ["output"]: - roles = ["readSRE"] + (["writeSRE"] if case["mode"] == "read-write" else []) - return { - "assigned_role_names": {"value": [f"{parent}/roles/{role}" for role in roles]}, - "workload_identity_provider": {"value": f"{POOL}/providers/sre-agent-provider"}, - "principal_id": {"value": PRINCIPAL}, - } - if args[:1] == ["init"] or args[:1] == ["workspace"]: - return "" - raise AssertionError(f"Unexpected command: {command} {args}") - - -class SetupTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.pwsh = shutil.which("pwsh") - cls.bash = shutil.which("bash") - cls.python = sys.executable - if not cls.pwsh or not cls.bash or not shutil.which("jq"): - raise RuntimeError("Tests require PowerShell 7, Bash, Python, and jq on PATH") - - def run_setup(self, shell, **case): - with tempfile.TemporaryDirectory(prefix="gcp-setup-test-") as directory: - temp = Path(directory) - bin_dir = temp / "bin" - bin_dir.mkdir() - stub = Path(__file__).resolve() - for name in ("az", "gcloud", "terraform"): - # PowerShell uses .cmd doubles on Windows; Bash uses executable shell doubles. - if os.name == "nt": - (bin_dir / f"{name}.cmd").write_text( - f'@"{self.python}" "{stub}" --stub {name} %*\n', - encoding="utf-8", - ) - launcher = bin_dir / name - launcher.write_text( - '#!/usr/bin/env bash\n' - f'exec "{Path(self.python).as_posix()}" "{stub.as_posix()}" --stub {name} "$@"\n', - encoding="utf-8", - ) - launcher.chmod(0o755) - env = os.environ.copy() - env["PATH"] = str(bin_dir) + os.pathsep + env["PATH"] - env["GCP_SETUP_TEST_CASE"] = json.dumps(case) - env["GCP_SETUP_TEST_OUTPUT"] = str(temp) - if shell == "pwsh": - args = [ - self.pwsh, "-NoProfile", "-File", str(ROOT / "setup.ps1"), - "-ResourceId", RESOURCE, "-AllowedPermissions", case["mode"], - "-SkipApproval", "true", "-AssignmentScope", case["scope"], - ] - if case["scope"] == "organization": - args += ["-OrganizationId", "987654321"] - else: - args = [ - self.bash, (ROOT / "setup.sh").as_posix(), - "--resource-id", RESOURCE, "--allowed-permissions", case["mode"], - "--skip-approval", "true", "--assignment-scope", case["scope"], - ] - if case["scope"] == "organization": - args += ["--organization-id", "987654321"] - result = subprocess.run(args, env=env, text=True, capture_output=True, timeout=90) - files = { - name: (temp / name).read_text(encoding="utf-8-sig") - for name in ( - "commands.jsonl", "guided.auto.tfvars.json", "imports.tf", - "revocations.json", "readSRE.json", "writeSRE.json", - ) - if (temp / name).exists() - } - return result, files - - def test_assignment_modes_and_migration(self): - for shell in ("pwsh", "bash"): - for scope in ("project", "organization"): - for mode in ("read-only", "read-write"): - for existing in (False, True): - with self.subTest(shell=shell, scope=scope, mode=mode, existing=existing): - result, files = self.run_setup( - shell, scope=scope, mode=mode, existing=existing, - ) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - variables = json.loads(files["guided.auto.tfvars.json"]) - self.assertEqual(variables["access_mode"], mode) - self.assertEqual(variables["assignment_scope"], scope) - self.assertEqual(variables["uami_object_id"], OBJECT) - self.assertEqual( - variables["existing_role_permissions"], - EXISTING_PERMISSIONS if existing else {}, - ) - for role in ("readSRE", "writeSRE"): - self.assertEqual( - json.loads(files[f"{role}.json"]), - json.loads((ROOT / f"{role}.json").read_text()), - ) - address = f'google_{scope}_iam_custom_role.sre_agent["{role}"]' - self.assertEqual(address in files["imports.tf"], existing) - address = f'google_{scope}_iam_member.sre_agent["{role}"]' - self.assertEqual( - address in files["imports.tf"], - existing and (role == "readSRE" or mode == "read-write"), - ) - if existing: - resource = json.loads(files["revocations.json"])["resource"] - revoked = resource[f"google_{scope}_iam_member"]["revoked"] - self.assertEqual(revoked["member"], PRINCIPAL) - self.assertEqual( - set(revoked["for_each"]), - {"SREAgentRole", "writeSRE"} if mode == "read-only" else {"SREAgentRole"}, - ) - commands = [json.loads(line) for line in files["commands.jsonl"].splitlines()] - imports = [c for c in commands if c[:2] == ["terraform", "import"]] - self.assertEqual(len(imports), len(revoked["for_each"])) - self.assertTrue(all(PRINCIPAL in c[-1] for c in imports)) - self.assertTrue(all("user:other" not in c[-1] for c in imports)) - else: - self.assertNotIn("revocations.json", files) - - def test_other_identities_are_not_revoked(self): - for shell in ("pwsh", "bash"): - with self.subTest(shell=shell): - result, files = self.run_setup( - shell, scope="project", mode="read-only", existing=True, other_only=True, - ) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertNotIn("revocations.json", files) - self.assertNotIn("iam_member.sre_agent", files["imports.tf"]) - - def test_conditional_grants_block_unsafe_removal(self): - for shell in ("pwsh", "bash"): - for role in ("writeSRE", "SREAgentRole"): - with self.subTest(shell=shell, role=role): - result, files = self.run_setup( - shell, scope="project", mode="read-only", existing=True, conditional=role, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn(f"conditional {role} assignment", result.stdout + result.stderr) - self.assertNotIn("guided.auto.tfvars.json", files) - - def test_import_failure_stops_apply(self): - for shell in ("pwsh", "bash"): - with self.subTest(shell=shell): - result, files = self.run_setup( - shell, scope="project", mode="read-only", existing=True, import_failure=True, - ) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn("guided.auto.tfvars.json", files) - - def test_unknown_permissions_stop_apply(self): - for shell in ("pwsh", "bash"): - for invalid in ( - {"missing_permissions": True}, - {"permissions": None}, - {"permissions": "compute.projects.get"}, - {"permissions": [None]}, - {"permissions": [" "]}, - ): - with self.subTest(shell=shell, invalid=invalid): - result, files = self.run_setup( - shell, scope="project", mode="read-only", existing=True, **invalid, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("refusing to replace unknown permissions", result.stdout + result.stderr) - self.assertNotIn("guided.auto.tfvars.json", files) - - -if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "--stub": - value = cli_double(sys.argv[2], sys.argv[3:]) - print(value if isinstance(value, str) else json.dumps(value)) - else: - unittest.main() From 197167840bfd60461bc768d545a91fd8ad6d1bfe Mon Sep 17 00:00:00 2001 From: Ishita Asthana Date: Mon, 2 Nov 2026 16:46:44 +0530 Subject: [PATCH 4/4] Rename GCP custom roles to SRE Agent access roles Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/gcp/README.md | 13 +++++++------ ...{readSRE.json => SREAgentReadOnlyAccess.json} | 2 +- .../{writeSRE.json => SREAgentWriteAccess.json} | 4 ++-- .../identity-federation/gcp/main.tf | 8 ++++---- .../identity-federation/gcp/setup.ps1 | 10 +++++----- .../identity-federation/gcp/setup.sh | 16 ++++++++-------- .../identity-federation/gcp/variables.tf | 6 +++--- 7 files changed, 30 insertions(+), 29 deletions(-) rename sreagent-templates/identity-federation/gcp/{readSRE.json => SREAgentReadOnlyAccess.json} (99%) rename sreagent-templates/identity-federation/gcp/{writeSRE.json => SREAgentWriteAccess.json} (98%) diff --git a/sreagent-templates/identity-federation/gcp/README.md b/sreagent-templates/identity-federation/gcp/README.md index b7a6cf82f..1c49d4b4e 100644 --- a/sreagent-templates/identity-federation/gcp/README.md +++ b/sreagent-templates/identity-federation/gcp/README.md @@ -1,7 +1,8 @@ # SRE Agent GCP connector setup This package uses Terraform to configure the GCP Workload Identity pool, -OIDC provider, `readSRE` and `writeSRE` custom IAM roles, and separate role assignments required by +OIDC provider, `SREAgentReadOnlyAccess` and `SREAgentWriteAccess` custom IAM roles, +and separate role assignments required by the SRE Agent GCP connector. Provide the user-assigned managed identity's ARM resource ID, not the SRE Agent @@ -129,7 +130,7 @@ The Bash parameters match the PowerShell parameters above. Omit each role without removing its existing permissions. - Ensures IAM, Resource Manager, Service Account Credentials and STS APIs are enabled; applies an additive IAM member without replacing other bindings. -- Removes the selected identity's `writeSRE` assignment when `read-only` is requested. +- Removes the selected identity's `SREAgentWriteAccess` assignment when `read-only` is requested. - Runs `terraform init` and `apply` in a unique temporary CLI workspace, then deletes the workspace and local files without running `terraform destroy`. @@ -139,8 +140,8 @@ The Bash parameters match the PowerShell parameters above. Omit | --- | --- | --- | | Workload Identity pool | `sre-agent-pool` | `SREAgentPool` | | OIDC provider | `sre-agent-provider` | `SREAgentProvider` | -| Read custom IAM role | `readSRE` | `readSRE` | -| Write custom IAM role | `writeSRE` | `writeSRE` | +| Read custom IAM role | `SREAgentReadOnlyAccess` | `SREAgentReadOnlyAccess` | +| Write custom IAM role | `SREAgentWriteAccess` | `SREAgentWriteAccess` | | OIDC field | Value | | --- | --- | @@ -164,9 +165,9 @@ retention still applies. Existing role permissions and metadata are preserved; only missing catalog permissions are added. Manual additions apply to all assignees, including -write permissions added to `readSRE`. +write permissions added to `SREAgentReadOnlyAccess`. -`read-only` assigns `readSRE` and removes the selected identity's `writeSRE` +`read-only` assigns `SREAgentReadOnlyAccess` and removes the selected identity's `SREAgentWriteAccess` assignment; `read-write` assigns both. Conditional grants requiring removal block setup; other identities and unrelated or inherited access are unchanged. diff --git a/sreagent-templates/identity-federation/gcp/readSRE.json b/sreagent-templates/identity-federation/gcp/SREAgentReadOnlyAccess.json similarity index 99% rename from sreagent-templates/identity-federation/gcp/readSRE.json rename to sreagent-templates/identity-federation/gcp/SREAgentReadOnlyAccess.json index 7dd683d2f..94245eb06 100644 --- a/sreagent-templates/identity-federation/gcp/readSRE.json +++ b/sreagent-templates/identity-federation/gcp/SREAgentReadOnlyAccess.json @@ -1,5 +1,5 @@ { - "title": "readSRE", + "title": "SREAgentReadOnlyAccess", "description": "SRE Agent resource discovery, metadata, logs, and metrics access.", "stage": "GA", "includedPermissions": [ diff --git a/sreagent-templates/identity-federation/gcp/writeSRE.json b/sreagent-templates/identity-federation/gcp/SREAgentWriteAccess.json similarity index 98% rename from sreagent-templates/identity-federation/gcp/writeSRE.json rename to sreagent-templates/identity-federation/gcp/SREAgentWriteAccess.json index 1e9dc509b..ca77739e1 100644 --- a/sreagent-templates/identity-federation/gcp/writeSRE.json +++ b/sreagent-templates/identity-federation/gcp/SREAgentWriteAccess.json @@ -1,6 +1,6 @@ { - "title": "writeSRE", - "description": "SRE Agent workload changes; assign together with readSRE.", + "title": "SREAgentWriteAccess", + "description": "SRE Agent workload changes; assign together with SREAgentReadOnlyAccess.", "stage": "GA", "includedPermissions": [ "storage.buckets.create", diff --git a/sreagent-templates/identity-federation/gcp/main.tf b/sreagent-templates/identity-federation/gcp/main.tf index 16d8c0a5f..904a31c85 100644 --- a/sreagent-templates/identity-federation/gcp/main.tf +++ b/sreagent-templates/identity-federation/gcp/main.tf @@ -55,18 +55,18 @@ locals { principal_id = "principal://iam.googleapis.com/projects/${var.project_number}/locations/global/workloadIdentityPools/sre-agent-pool/subject/${lower(var.azure_tenant_id)}::${lower(var.uami_object_id)}" organization_scope = var.assignment_scope == "organization" role_definitions = { - readSRE = jsondecode(file("${path.module}/readSRE.json")) - writeSRE = jsondecode(file("${path.module}/writeSRE.json")) + SREAgentReadOnlyAccess = jsondecode(file("${path.module}/SREAgentReadOnlyAccess.json")) + SREAgentWriteAccess = jsondecode(file("${path.module}/SREAgentWriteAccess.json")) } role_permissions = { for id, definition in local.role_definitions : id => setunion( lookup(var.existing_role_permissions, id, toset([])), toset(definition.includedPermissions), # Project listing on a parent is only applicable to organization-level roles. - id == "readSRE" && local.organization_scope ? toset(["resourcemanager.projects.list"]) : toset([]), + id == "SREAgentReadOnlyAccess" && local.organization_scope ? toset(["resourcemanager.projects.list"]) : toset([]), ) } - assigned_role_ids = var.access_mode == "read-write" ? toset(["readSRE", "writeSRE"]) : toset(["readSRE"]) + assigned_role_ids = var.access_mode == "read-write" ? toset(["SREAgentReadOnlyAccess", "SREAgentWriteAccess"]) : toset(["SREAgentReadOnlyAccess"]) } resource "google_project_iam_custom_role" "sre_agent" { diff --git a/sreagent-templates/identity-federation/gcp/setup.ps1 b/sreagent-templates/identity-federation/gcp/setup.ps1 index 54637d971..a55d9b24f 100644 --- a/sreagent-templates/identity-federation/gcp/setup.ps1 +++ b/sreagent-templates/identity-federation/gcp/setup.ps1 @@ -14,7 +14,7 @@ Set-StrictMode -Version Latest $SetupRoot = $PSScriptRoot $PoolId = 'sre-agent-pool' $ProviderId = 'sre-agent-provider' -$RoleIds = @('readSRE', 'writeSRE') +$RoleIds = @('SREAgentReadOnlyAccess', 'SREAgentWriteAccess') $Audience = '59f0a04a-b322-4310-adc9-39ac41e9631e' $UuidPattern = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' @@ -305,9 +305,9 @@ function Invoke-GcpSetup { } } - $assignedRoleIds = @('readSRE') + $assignedRoleIds = @('SREAgentReadOnlyAccess') if ($accessMode -eq 'read-write') { - $assignedRoleIds += 'writeSRE' + $assignedRoleIds += 'SREAgentWriteAccess' } $revokedRoles = @{} $policy = Invoke-SetupJson gcloud @( @@ -324,7 +324,7 @@ function Invoke-GcpSetup { if ($binding.role -cne $roleName) { continue } - $remove = $roleId -eq 'writeSRE' -and $accessMode -eq 'read-only' + $remove = $roleId -eq 'SREAgentWriteAccess' -and $accessMode -eq 'read-only' if ($binding.Contains('condition')) { if ($remove) { throw "The selected identity has a conditional $roleId assignment. Remove it through your approved process before rerunning; setup cannot safely downgrade it." @@ -382,7 +382,7 @@ function Invoke-GcpSetup { [void](New-Item -ItemType Directory -Path $runDirectory) $directoryCreated = $true Copy-Item -Path (Join-Path $SetupRoot '*.tf') -Destination $runDirectory - Copy-Item -LiteralPath (Join-Path $SetupRoot 'readSRE.json'), (Join-Path $SetupRoot 'writeSRE.json') -Destination $runDirectory + Copy-Item -LiteralPath (Join-Path $SetupRoot 'SREAgentReadOnlyAccess.json'), (Join-Path $SetupRoot 'SREAgentWriteAccess.json') -Destination $runDirectory [IO.File]::WriteAllText((Join-Path $runDirectory 'guided.auto.tfvars.json'), ($variables | ConvertTo-Json -Depth 20)) [IO.File]::WriteAllText((Join-Path $runDirectory 'imports.tf'), ($imports -join "`n")) if ($revokedRoles.Count -gt 0) { diff --git a/sreagent-templates/identity-federation/gcp/setup.sh b/sreagent-templates/identity-federation/gcp/setup.sh index 41fa11ad3..2e19d0a9d 100644 --- a/sreagent-templates/identity-federation/gcp/setup.sh +++ b/sreagent-templates/identity-federation/gcp/setup.sh @@ -4,7 +4,7 @@ set -euo pipefail setup_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" pool_id="sre-agent-pool" provider_id="sre-agent-provider" -role_ids=(readSRE writeSRE) +role_ids=(SREAgentReadOnlyAccess SREAgentWriteAccess) audience="59f0a04a-b322-4310-adc9-39ac41e9631e" uuid_pattern='[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' @@ -211,9 +211,9 @@ main() { local pool_name provider_name pool_import_id role_name role_id principal pools pool providers provider roles role details local imports="" trust condition policy has_assignment has_conditional_assignment service - local assigned_role_ids=(readSRE) revoked_roles='{}' + local assigned_role_ids=(SREAgentReadOnlyAccess) revoked_roles='{}' if [[ "$access_mode" == "read-write" ]]; then - assigned_role_ids+=(writeSRE) + assigned_role_ids+=(SREAgentWriteAccess) fi pool_name="projects/$project_number/locations/global/workloadIdentityPools/$pool_id" provider_name="$pool_name/providers/$provider_id" @@ -268,7 +268,7 @@ main() { has_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' any(.bindings[]?; .role == $role and (has("condition") | not) and (.members | index($member) != null))' <<<"$policy")" - if [[ "$role_id" == writeSRE && "$access_mode" == read-only ]]; then + if [[ "$role_id" == SREAgentWriteAccess && "$access_mode" == read-only ]]; then has_conditional_assignment="$(jq -r --arg role "$role_name" --arg member "$principal" ' any(.bindings[]?; .role == $role and has("condition") and (.members | index($member) != null))' <<<"$policy")" @@ -307,7 +307,7 @@ main() { trap 'exit 130' INT trap 'exit 143' TERM cp "$setup_root"/*.tf "$run_directory/" - cp "$setup_root/readSRE.json" "$setup_root/writeSRE.json" "$run_directory/" + cp "$setup_root/SREAgentReadOnlyAccess.json" "$setup_root/SREAgentWriteAccess.json" "$run_directory/" jq -n --arg project_id "$project_id" --arg project_number "$project_number" \ --arg tenant "$tenant_id" --arg object "$object_id" --arg mode "$access_mode" \ --arg scope "$scope" --arg organization "$organization_id" --arg condition "$condition" \ @@ -337,9 +337,9 @@ main() { terraform workspace new "$workspace" workspace_created=true if [[ "$revoked_roles" != '{}' ]]; then - role_name="$(jq -er '.writeSRE' <<<"$revoked_roles")" - terraform import -input=false -no-color "google_${scope}_iam_member.revoked[\"writeSRE\"]" \ - "$scope_id $role_name $principal" || fail "Unable to import the writeSRE assignment for removal." + role_name="$(jq -er '.SREAgentWriteAccess' <<<"$revoked_roles")" + terraform import -input=false -no-color "google_${scope}_iam_member.revoked[\"SREAgentWriteAccess\"]" \ + "$scope_id $role_name $principal" || fail "Unable to import the SREAgentWriteAccess assignment for removal." # Removing configuration schedules only these memberships for deletion in the reviewed apply. rm -- revocations.tf.json fi diff --git a/sreagent-templates/identity-federation/gcp/variables.tf b/sreagent-templates/identity-federation/gcp/variables.tf index b1e4c27c0..da840d687 100644 --- a/sreagent-templates/identity-federation/gcp/variables.tf +++ b/sreagent-templates/identity-federation/gcp/variables.tf @@ -49,13 +49,13 @@ variable "existing_role_permissions" { default = {} validation { - condition = alltrue([for id in keys(var.existing_role_permissions) : contains(["readSRE", "writeSRE"], id)]) - error_message = "existing_role_permissions keys must be readSRE or writeSRE." + condition = alltrue([for id in keys(var.existing_role_permissions) : contains(["SREAgentReadOnlyAccess", "SREAgentWriteAccess"], id)]) + error_message = "existing_role_permissions keys must be SREAgentReadOnlyAccess or SREAgentWriteAccess." } } variable "access_mode" { - description = "Assign readSRE for read-only access, or readSRE and writeSRE separately for read-write access." + description = "Assign SREAgentReadOnlyAccess for read-only access, or SREAgentReadOnlyAccess and SREAgentWriteAccess separately for read-write access." type = string default = "read-only"