diff --git a/sreagent-templates/identity-federation/gcp/README.md b/sreagent-templates/identity-federation/gcp/README.md index c956f389b..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, `SREAgentRole` custom IAM role, and role assignment 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 @@ -17,8 +18,8 @@ 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. ## Prerequisites @@ -70,7 +71,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 +104,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 +121,16 @@ 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 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. +- 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`. @@ -138,7 +140,8 @@ 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 | `SREAgentReadOnlyAccess` | `SREAgentReadOnlyAccess` | +| Write custom IAM role | `SREAgentWriteAccess` | `SREAgentWriteAccess` | | OIDC field | Value | | --- | --- | @@ -160,9 +163,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 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. +Existing role permissions and metadata are preserved; only missing catalog +permissions are added. Manual additions apply to all assignees, including +write permissions added to `SREAgentReadOnlyAccess`. + +`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. ## State and reruns @@ -171,6 +178,8 @@ 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. +Run setups sequentially; do not edit roles between discovery and apply. + ## References - [Azure CLI identity lookup](https://learn.microsoft.com/cli/azure/identity#az-identity-show) diff --git a/sreagent-templates/identity-federation/gcp/SREAgentReadOnlyAccess.json b/sreagent-templates/identity-federation/gcp/SREAgentReadOnlyAccess.json new file mode 100644 index 000000000..94245eb06 --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/SREAgentReadOnlyAccess.json @@ -0,0 +1,122 @@ +{ + "title": "SREAgentReadOnlyAccess", + "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/SREAgentWriteAccess.json b/sreagent-templates/identity-federation/gcp/SREAgentWriteAccess.json new file mode 100644 index 000000000..ca77739e1 --- /dev/null +++ b/sreagent-templates/identity-federation/gcp/SREAgentWriteAccess.json @@ -0,0 +1,98 @@ +{ + "title": "SREAgentWriteAccess", + "description": "SRE Agent workload changes; assign together with SREAgentReadOnlyAccess.", + "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" + ] +} diff --git a/sreagent-templates/identity-federation/gcp/main.tf b/sreagent-templates/identity-federation/gcp/main.tf index 348c2e22a..904a31c85 100644 --- a/sreagent-templates/identity-federation/gcp/main.tf +++ b/sreagent-templates/identity-federation/gcp/main.tf @@ -54,341 +54,53 @@ 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 = { + 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 == "SREAgentReadOnlyAccess" && local.organization_scope ? toset(["resourcemanager.projects.list"]) : toset([]), + ) + } + assigned_role_ids = var.access_mode == "read-write" ? toset(["SREAgentReadOnlyAccess", "SREAgentWriteAccess"]) : toset(["SREAgentReadOnlyAccess"]) } -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] + ignore_changes = [title, 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] + ignore_changes = [title, description, stage] precondition { condition = var.organization_id != "" @@ -397,29 +109,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 +143,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/setup.ps1 b/sreagent-templates/identity-federation/gcp/setup.ps1 index 29437c6c6..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' -$RoleId = 'SREAgentRole' +$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}' @@ -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,57 @@ 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.' + $existingRolePermissions = @{} + 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 + 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)) } - $existingPermissions = @($details.includedPermissions) - $imports.Add((New-ImportBlock "google_${scope}_iam_custom_role.sre_agent_role[0]" $roleName)) } + $assignedRoleIds = @('SREAgentReadOnlyAccess') + if ($accessMode -eq 'read-write') { + $assignedRoleIds += 'SREAgentWriteAccess' + } + $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 $RoleIds) { + $roleName = "$scopeParent/roles/$roleId" + if ($binding.role -cne $roleName) { + continue + } + $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." + } + 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')) { @@ -329,14 +355,16 @@ function Invoke-GcpSetup { assignment_scope = $scope organization_id = $organizationId attribute_condition = $trust.Condition - existing_role_permissions = $existingPermissions + existing_role_permissions = $existingRolePermissions } 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' 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.' $workspace = "sre-agent-gcp-$([Guid]::NewGuid().ToString('N'))" @@ -354,8 +382,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 '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) { + $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 +411,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..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_id="SREAgentRole" +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}' @@ -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=(SREAgentReadOnlyAccess) revoked_roles='{}' + if [[ "$access_mode" == "read-write" ]]; then + assigned_role_ids+=(SREAgentWriteAccess) + 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,58 @@ 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 + 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")" + 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 | 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 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 "${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" == 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")" + [[ "$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 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' 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." umask 077 @@ -278,16 +307,26 @@ main() { trap 'exit 130' INT trap 'exit 143' TERM cp "$setup_root"/*.tf "$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" \ - --argjson permissions "$existing_permissions" '{ + --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, existing_role_permissions: $permissions }' >"$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 +336,15 @@ main() { terraform init -input=false -no-color terraform workspace new "$workspace" workspace_created=true + if [[ "$revoked_roles" != '{}' ]]; then + 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 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 +355,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/variables.tf b/sreagent-templates/identity-federation/gcp/variables.tf index 3817ea064..da840d687 100644 --- a/sreagent-templates/identity-federation/gcp/variables.tf +++ b/sreagent-templates/identity-federation/gcp/variables.tf @@ -44,13 +44,18 @@ variable "attribute_condition" { } variable "existing_role_permissions" { - description = "Live role permissions to preserve when adding the requested access mode." - type = set(string) - default = [] + 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(["SREAgentReadOnlyAccess", "SREAgentWriteAccess"], id)]) + error_message = "existing_role_permissions keys must be SREAgentReadOnlyAccess or SREAgentWriteAccess." + } } variable "access_mode" { - description = "Permissions to add; read-only never removes existing write permissions." + description = "Assign SREAgentReadOnlyAccess for read-only access, or SREAgentReadOnlyAccess and SREAgentWriteAccess separately for read-write access." type = string default = "read-only"