From 973f5b9a7e3cd1f954f0fdf5c302b27c32d3eb23 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 26 Oct 2026 17:36:15 +0530 Subject: [PATCH 1/3] Add AWS-side identity federation setup for SRE Agent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/aws/README.md | 144 +++++ .../aws/SREAgentReadOnlyAccess.json | 238 ++++++++ .../aws/SREAgentWriteAccess.json | 57 ++ .../identity-federation/aws/main.tf | 102 ++++ .../identity-federation/aws/setup.ps1 | 577 ++++++++++++++++++ .../identity-federation/aws/setup.sh | 562 +++++++++++++++++ .../aws/terraform.tfvars.example | 47 ++ .../identity-federation/aws/variables.tf | 153 +++++ .../identity-federation/aws/versions.tf | 10 + 9 files changed, 1890 insertions(+) create mode 100644 sreagent-templates/identity-federation/aws/README.md create mode 100644 sreagent-templates/identity-federation/aws/SREAgentReadOnlyAccess.json create mode 100644 sreagent-templates/identity-federation/aws/SREAgentWriteAccess.json create mode 100644 sreagent-templates/identity-federation/aws/main.tf create mode 100644 sreagent-templates/identity-federation/aws/setup.ps1 create mode 100644 sreagent-templates/identity-federation/aws/setup.sh create mode 100644 sreagent-templates/identity-federation/aws/terraform.tfvars.example create mode 100644 sreagent-templates/identity-federation/aws/variables.tf create mode 100644 sreagent-templates/identity-federation/aws/versions.tf diff --git a/sreagent-templates/identity-federation/aws/README.md b/sreagent-templates/identity-federation/aws/README.md new file mode 100644 index 000000000..5f5128a5d --- /dev/null +++ b/sreagent-templates/identity-federation/aws/README.md @@ -0,0 +1,144 @@ +# SRE Agent AWS connector setup + +This package uses Terraform to configure the AWS OIDC provider, +`SREAgentRole`, AWS MCP OAuth permission and workload policy required by the +SRE Agent AWS connector. + +An Agent resource ID uses its system-assigned managed identity. To use a +user-assigned managed identity, provide that identity's own ARM resource ID. + +## Prerequisites + +The following tools are required: + +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) +- [Terraform 1.7 or later](https://developer.hashicorp.com/terraform/install) +- [`jq`](https://jqlang.org/download/) on Linux and macOS + +Install the tools by following their official documentation links above before +running the setup. The setup scripts validate that Terraform is a stable +version 1.7 or later and that AWS CLI v2 provides +`signin create-oauth2-token-with-iam`. + +### Verify the installation + +```text +az version +aws --version +terraform version +``` + +Linux and macOS customers should also run: + +```text +jq --version +``` + +## Windows + +### 1. Login + +Open PowerShell in the directory containing this README and the Terraform +files: + +```powershell +az login +aws login +``` + +If your organization uses AWS SSO or another AWS authentication method, use +that instead of `aws login`. Terraform uses the active AWS credentials. + +### 2. Run the setup script + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\setup.ps1 +``` + +When prompted: + +1. Enter the full ARM resource ID of the managed identity or SRE Agent. +2. Select `read-only` or `read-write` from the displayed permission levels. +3. Review the selected policies, their descriptions and the discovered identity, once confirmed enter `y` to apply or `n` to cancel. + +For unattended execution: + +```powershell +.\setup.ps1 ` + -ResourceId '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/my-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity' ` + -allowed-permissions read-write ` + -skip-approval true +``` + +## Linux or macOS + +### 1. Login + +Open Bash in the directory containing this README and the Terraform files: + +```bash +az login +aws login +``` + +If your organization uses AWS SSO or another AWS authentication method, use +that instead of `aws login`. Terraform uses the active AWS credentials. + +### 2. Run the setup script + +```bash +chmod +x setup.sh +./setup.sh +``` + +When prompted: + +1. Enter the full ARM resource ID of the managed identity or SRE Agent. +2. Select `read-only` or `read-write` from the displayed permission levels. +3. Review the selected policies, their descriptions and the discovered identity, once confirmed enter `y` to apply or `n` to cancel. + +For unattended execution: + +```bash +./setup.sh \ + --resource-id '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/my-group/providers/Microsoft.App/agents/my-agent' \ + --allowed-permissions=read-write \ + --skip-approval=true +``` + +## Sample SREAgent workload policies + +The following samples are ready-to-use, account-wide permission sets with no +resource placeholders. They target common investigation and operational tasks, +not every AWS service or every possible debugging operation. They do not grant +AWS's `aidevops:*` product-management permissions. + +| Sample policy | What it enables | Intended use | +| --- | --- | --- | +| [`SREAgentReadOnlyAccess`](SREAgentReadOnlyAccess.json) | Inspect AWS resources, IAM configuration, logs, metrics and traces; run diagnostic queries; list and read S3 objects and versions, DynamoDB items, Parameter Store values and Secrets Manager secrets; decrypt data with KMS; inspect Systems Manager connection status. | Standalone investigation and data-read policy. Includes sensitive data and credential access, but no interactive execution or database connections. | +| [`SREAgentWriteAccess`](SREAgentWriteAccess.json) | Open ECS Exec and Systems Manager sessions, access the EKS Kubernetes API and connect through RDS IAM authentication; start, stop and reboot EC2/RDS resources; reboot ElastiCache clusters; adjust scaling, ECS services, EKS node groups and Lambda concurrency; invalidate CloudFront caches; stop or redrive Step Functions executions. | Optional write and interactive-debugging supplement to ReadOnlyAccess. These permissions apply to every identity trusted by the role. | + +## What the setup script does + +- Reads the selected managed identity Tenant ID, Client ID and Principal ID + from Azure +- Reads the active AWS account ID +- Checks the tenant's shared IAM OIDC provider +- Imports that provider into the local Terraform state when it already exists +- Preserves every existing provider audience and adds the selected managed + identity Client ID when missing +- Checks `SREAgentRole` and imports it into the local Terraform state when it + already exists +- Preserves the role's existing trust statements and adds one separate + statement for the selected managed identity +- Writes those identity values to a local Terraform variables file +- Runs `terraform init` +- Runs `terraform plan` +- Creates or updates the tenant OIDC provider through Terraform +- Creates or updates `SREAgentRole` through Terraform +- Configures its web-identity trust policy +- Attaches `AWSMCPSignInOAuthAccessPolicy` +- Creates or updates `SREAgentReadOnlyAccess`, and `SREAgentWriteAccess` only + for `read-write` \ No newline at end of file diff --git a/sreagent-templates/identity-federation/aws/SREAgentReadOnlyAccess.json b/sreagent-templates/identity-federation/aws/SREAgentReadOnlyAccess.json new file mode 100644 index 000000000..8008b5bec --- /dev/null +++ b/sreagent-templates/identity-federation/aws/SREAgentReadOnlyAccess.json @@ -0,0 +1,238 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SREAgentResourceDiscovery", + "Effect": "Allow", + "Action": [ + "access-analyzer:GetAnalyzer", + "access-analyzer:List*", + "acm:DescribeCertificate", + "acm:ListCertificates", + "application-autoscaling:Describe*", + "autoscaling:Describe*", + "backup:Describe*", + "backup:List*", + "batch:DescribeComputeEnvironments", + "batch:DescribeJobDefinitions", + "batch:DescribeJobQueues", + "batch:DescribeJobs", + "batch:List*", + "cloudformation:Describe*", + "cloudformation:GetStackPolicy", + "cloudformation:GetTemplate", + "cloudformation:List*", + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:List*", + "config:BatchGetResourceConfig", + "config:Describe*", + "config:GetResourceConfigHistory", + "config:List*", + "config:SelectResourceConfig", + "dynamodb:Describe*", + "dynamodb:List*", + "ec2:Describe*", + "ecr:Describe*", + "ecr:GetLifecyclePolicy", + "ecr:GetRepositoryPolicy", + "ecr:List*", + "ecs:Describe*", + "ecs:List*", + "eks:Describe*", + "eks:List*", + "elasticache:Describe*", + "elasticache:List*", + "elasticbeanstalk:Describe*", + "elasticbeanstalk:List*", + "elasticfilesystem:Describe*", + "elasticloadbalancing:Describe*", + "es:Describe*", + "es:List*", + "events:Describe*", + "events:List*", + "health:Describe*", + "iam:GetAccountAuthorizationDetails", + "iam:GetInstanceProfile", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfiles", + "iam:ListPolicies", + "iam:ListRolePolicies", + "iam:ListRoles", + "kafka:Describe*", + "kafka:List*", + "kinesis:Describe*", + "kinesis:List*", + "kms:DescribeKey", + "kms:GetKeyPolicy", + "kms:ListAliases", + "kms:ListKeys", + "lambda:GetAlias", + "lambda:GetEventSourceMapping", + "lambda:GetFunctionConfiguration", + "lambda:GetFunctionConcurrency", + "lambda:GetPolicy", + "lambda:List*", + "organizations:DescribeOrganization", + "organizations:ListAccounts", + "rds:Describe*", + "rds:List*", + "redshift:Describe*", + "resource-explorer-2:Search", + "resource-groups:GetGroup", + "resource-groups:List*", + "route53:GetHealthCheck", + "route53:GetHealthCheckStatus", + "route53:GetHostedZone", + "route53:List*", + "route53resolver:GetResolverRule", + "route53resolver:List*", + "s3:GetBucketLocation", + "s3:GetBucketLogging", + "s3:GetBucketNotification", + "s3:GetBucketPolicy", + "s3:GetBucketPolicyStatus", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketTagging", + "s3:GetBucketVersioning", + "s3:GetEncryptionConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetReplicationConfiguration", + "s3:ListAllMyBuckets", + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + "secretsmanager:ListSecrets", + "servicequotas:Get*", + "servicequotas:List*", + "sns:GetSubscriptionAttributes", + "sns:GetTopicAttributes", + "sns:List*", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + "sqs:List*", + "ssm:Describe*", + "ssm:GetCommandInvocation", + "ssm:GetDocument", + "ssm:List*", + "states:Describe*", + "states:GetExecutionHistory", + "states:List*", + "sts:DecodeAuthorizationMessage", + "tag:GetResources", + "wafv2:GetLoggingConfiguration", + "wafv2:GetWebACL", + "wafv2:List*" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentTelemetryAndDiagnosticQueries", + "Effect": "Allow", + "Action": [ + "application-signals:GetService", + "application-signals:GetServiceLevelObjective", + "application-signals:List*", + "ce:GetCostAndUsage", + "ce:GetCostForecast", + "ce:GetDimensionValues", + "ce:GetTags", + "cloudtrail:Describe*", + "cloudtrail:GetEventDataStore", + "cloudtrail:GetQueryResults", + "cloudtrail:GetTrail", + "cloudtrail:GetTrailStatus", + "cloudtrail:List*", + "cloudtrail:LookupEvents", + "cloudtrail:StartQuery", + "cloudwatch:Describe*", + "cloudwatch:GetDashboard", + "cloudwatch:GetInsightRuleReport", + "cloudwatch:GetMetricData", + "cloudwatch:GetMetricStatistics", + "cloudwatch:List*", + "logs:Describe*", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:GetLogGroupFields", + "logs:GetQueryResults", + "logs:List*", + "logs:StartQuery", + "logs:StopQuery", + "pi:DescribeDimensionKeys", + "pi:GetDimensionKeyDetails", + "pi:GetResourceMetadata", + "pi:GetResourceMetrics", + "xray:BatchGetTraces", + "xray:GetGroups", + "xray:GetServiceGraph", + "xray:GetTraceSummaries" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentApiGatewayConfiguration", + "Effect": "Allow", + "Action": "apigateway:GET", + "Resource": [ + "arn:aws:apigateway:*::/restapis", + "arn:aws:apigateway:*::/restapis/*", + "arn:aws:apigateway:*::/apis", + "arn:aws:apigateway:*::/apis/*", + "arn:aws:apigateway:*::/domainnames", + "arn:aws:apigateway:*::/domainnames/*" + ] + }, + { + "Sid": "SREAgentListAwsLogObjects", + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": "arn:aws:s3:::*", + "Condition": { + "StringLike": { + "s3:prefix": [ + "AWSLogs/*", + "*/AWSLogs/*" + ] + } + } + }, + { + "Sid": "SREAgentReadWorkloadData", + "Effect": "Allow", + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:ListBucket", + "s3:ListBucketVersions" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentReadCredentialsAndDecrypt", + "Effect": "Allow", + "Action": [ + "kms:Decrypt", + "secretsmanager:BatchGetSecretValue", + "secretsmanager:GetSecretValue", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentReadSessionStatus", + "Effect": "Allow", + "Action": "ssm:GetConnectionStatus", + "Resource": "*" + } + ] +} diff --git a/sreagent-templates/identity-federation/aws/SREAgentWriteAccess.json b/sreagent-templates/identity-federation/aws/SREAgentWriteAccess.json new file mode 100644 index 000000000..08b942753 --- /dev/null +++ b/sreagent-templates/identity-federation/aws/SREAgentWriteAccess.json @@ -0,0 +1,57 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SREAgentRestartAndScaleCompute", + "Effect": "Allow", + "Action": [ + "application-autoscaling:RegisterScalableTarget", + "autoscaling:SetDesiredCapacity", + "ec2:RebootInstances", + "ec2:StartInstances", + "ec2:StopInstances", + "ecs:UpdateService", + "eks:UpdateNodegroupConfig", + "lambda:PutFunctionConcurrency", + "lambda:PutProvisionedConcurrencyConfig" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentRestartDatabasesAndCaches", + "Effect": "Allow", + "Action": [ + "elasticache:RebootCacheCluster", + "rds:RebootDBInstance", + "rds:StartDBCluster", + "rds:StartDBInstance", + "rds:StopDBCluster", + "rds:StopDBInstance" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentRecoverWorkflowsAndInvalidateCache", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateInvalidation", + "states:RedriveExecution", + "states:StopExecution" + ], + "Resource": "*" + }, + { + "Sid": "SREAgentInteractiveWorkloadAccess", + "Effect": "Allow", + "Action": [ + "ecs:ExecuteCommand", + "eks:AccessKubernetesApi", + "rds-db:connect", + "ssm:ResumeSession", + "ssm:StartSession", + "ssm:TerminateSession" + ], + "Resource": "*" + } + ] +} diff --git a/sreagent-templates/identity-federation/aws/main.tf b/sreagent-templates/identity-federation/aws/main.tf new file mode 100644 index 000000000..ea6f4f3d9 --- /dev/null +++ b/sreagent-templates/identity-federation/aws/main.tf @@ -0,0 +1,102 @@ +locals { + azure_sre_application_id = "59f0a04a-b322-4310-adc9-39ac41e9631e" + aws_mcp_signin_policy = "arn:aws:iam::aws:policy/AWSMCPSignInOAuthAccessPolicy" + + entra_issuer = "https://login.microsoftonline.com/${var.managed_identity.tenant_id}/v2.0" + issuer_condition_prefix = trimprefix(local.entra_issuer, "https://") + + oidc_provider_audiences = sort(distinct(concat( + [var.managed_identity.client_id], + tolist(var.additional_oidc_provider_audiences) + ))) + + trust_string_equals = { + "${local.issuer_condition_prefix}:aud" = var.managed_identity.client_id + "${local.issuer_condition_prefix}:oaud" = local.azure_sre_application_id + "${local.issuer_condition_prefix}:sub" = var.managed_identity.principal_id + } + + managed_identity_trust_statement = { + Sid = "TrustManagedIdentity${replace(lower(var.managed_identity.client_id), "-", "")}" + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { Federated = aws_iam_openid_connect_provider.entra.arn } + Condition = { StringEquals = local.trust_string_equals } + } + + role_trust_statements = concat( + var.existing_role_trust_statements, + [local.managed_identity_trust_statement] + ) + + role_policy_arns = setunion(var.policy_arns, toset([local.aws_mcp_signin_policy])) +} + +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +resource "terraform_data" "configuration_validation" { + input = { + aws_account_id = data.aws_caller_identity.current.account_id + identity_type = var.managed_identity.type + identity_resource = var.managed_identity.resource_id + provider_audiences = local.oidc_provider_audiences + } + + lifecycle { + precondition { + condition = data.aws_partition.current.partition == "aws" + error_message = "This setup supports the commercial AWS partition only." + } + + precondition { + condition = length(local.oidc_provider_audiences) <= 100 + error_message = "An IAM OIDC provider supports at most 100 client IDs." + } + } +} + +resource "aws_iam_openid_connect_provider" "entra" { + url = local.entra_issuer + client_id_list = local.oidc_provider_audiences + + depends_on = [terraform_data.configuration_validation] + + lifecycle { + ignore_changes = [tags] + } +} + +resource "aws_iam_role" "entra" { + name = var.role_name + path = var.role_path + description = var.role_description + max_session_duration = var.max_session_duration + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = local.role_trust_statements + }) + + lifecycle { + ignore_changes = [ + permissions_boundary, + tags, + ] + } +} + +resource "aws_iam_role_policy_attachment" "managed" { + for_each = local.role_policy_arns + + role = aws_iam_role.entra.name + policy_arn = each.value +} + +resource "aws_iam_role_policy" "workload" { + for_each = var.workload_policies + + name = each.key + role = aws_iam_role.entra.id + policy = jsonencode(each.value) +} diff --git a/sreagent-templates/identity-federation/aws/setup.ps1 b/sreagent-templates/identity-federation/aws/setup.ps1 new file mode 100644 index 000000000..50e70a891 --- /dev/null +++ b/sreagent-templates/identity-federation/aws/setup.ps1 @@ -0,0 +1,577 @@ +[CmdletBinding()] +param( + [string]$ResourceId, + [Alias('allowed-permissions')] + [string]$AllowedPermissions, + [Alias('skip-approval')] + [ValidateSet('true', 'false')] + [string]$SkipApproval = 'false' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$RoleName = 'SREAgentRole' +$GeneratedVariablesFile = Join-Path $PSScriptRoot 'guided.auto.tfvars.json' +$PlanFile = Join-Path $PSScriptRoot 'tfplan' +$AgentApiVersion = '2025-05-01-preview' +$PolicyDescriptions = [ordered]@{ + SREAgentReadOnlyAccess = 'Inspect AWS resources and telemetry, read application data and secrets, and decrypt data.' + SREAgentWriteAccess = 'Open workload sessions and database connections; restart, stop, scale, or recover resources.' +} + +Write-Host '' +Write-Host 'SRE Agent AWS connector setup' -ForegroundColor Cyan +Write-Host 'This script reads the Azure managed identity and lets Terraform configure AWS.' + +if (-not $PSBoundParameters.ContainsKey('ResourceId')) { + $ResourceId = Read-Host 'Enter the full ARM resource ID of the user-assigned managed identity or SRE Agent' +} +$identityResourceId = $ResourceId.Trim() +$subscriptionPattern = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +$resourceIdPattern = "(?i)^/subscriptions/$subscriptionPattern/resourceGroups/[^/\s?#]+/providers/(?Microsoft\.ManagedIdentity/userAssignedIdentities|Microsoft\.App/agents)/[^/\s?#]+$" +if ($identityResourceId -notmatch $resourceIdPattern) { + throw 'Invalid ARM resource ID. Expected /subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/ or Microsoft.App/agents/.' +} +$identityType = if ($Matches.resourceType -ieq 'Microsoft.ManagedIdentity/userAssignedIdentities') { 'UserAssigned' } else { 'SystemAssigned' } + +if (-not $PSBoundParameters.ContainsKey('AllowedPermissions')) { + Write-Host '' + Write-Host 'Available permission levels:' -ForegroundColor Cyan + Write-Host " read-only - SREAgentReadOnlyAccess: $($PolicyDescriptions.SREAgentReadOnlyAccess)" + Write-Host " read-write - SREAgentReadOnlyAccess + SREAgentWriteAccess: $($PolicyDescriptions.SREAgentWriteAccess)" + Write-Host 'Example: -allowed-permissions read-only (or read-write)' + $AllowedPermissions = Read-Host 'Enter allowed permissions: read-only or read-write' +} +$AllowedPermissions = $AllowedPermissions.Trim().ToLowerInvariant() +if ($AllowedPermissions -cnotin @('read-only', 'read-write')) { + throw 'Invalid allowed-permissions value. Choose read-only or read-write.' +} + +$policyNames = @('SREAgentReadOnlyAccess') +if ($AllowedPermissions -eq 'read-write') { + $policyNames += 'SREAgentWriteAccess' +} +$workloadPolicies = [ordered]@{} +$policySize = 0 +$statementSids = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +foreach ($name in $policyNames) { + $policyPath = Join-Path $PSScriptRoot "$name.json" + if (-not (Test-Path -LiteralPath $policyPath -PathType Leaf)) { + throw "Policy JSON file not found: $policyPath" + } + try { + $document = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json + } + catch { + throw "Policy '$policyPath' is not valid JSON: $($_.Exception.Message)" + } + if ($null -eq $document -or + $null -eq $document.PSObject.Properties['Version'] -or + $document.Version -ne '2012-10-17' -or + $null -eq $document.PSObject.Properties['Statement'] -or + $null -eq $document.Statement -or + @($document.Statement).Count -eq 0) { + throw "Policy '$policyPath' must contain Version 2012-10-17 and nonempty Statement objects." + } + foreach ($statement in @($document.Statement)) { + if ($statement -isnot [PSCustomObject] -or + $null -eq $statement.PSObject.Properties['Effect'] -or + $statement.Effect -cnotin @('Allow', 'Deny')) { + throw "Policy '$policyPath' contains an invalid Statement object." + } + $sidProperty = $statement.PSObject.Properties['Sid'] + if ($null -ne $sidProperty -and -not $statementSids.Add([string]$sidProperty.Value)) { + throw "Duplicate policy statement Sid: $($sidProperty.Value)" + } + } + $workloadPolicies[$name] = $document + $policySize += ($document | ConvertTo-Json -Depth 100 -Compress).Length +} +if ($policySize -gt 10240) { + throw 'The workload policies exceed the 10,240-character aggregate role inline-policy limit.' +} +$policySource = $policyNames -join ', ' +Write-Host '' +Write-Host "Allowed permissions: $AllowedPermissions" +Write-Host 'Selected policies to apply:' -ForegroundColor Cyan +foreach ($name in $policyNames) { + Write-Host " $name - $($PolicyDescriptions[$name])" +} +Write-Warning 'Selected permissions are shared by every identity trusted by SREAgentRole. ReadOnlyAccess exposes data and credentials; WriteAccess permits workload changes and can cause outages.' + +function Require-Command { + param([Parameter(Mandatory)][string]$Name) + + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command '$Name' was not found. Install it and try again." + } +} + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory)][string]$Command, + [Parameter(Mandatory)][string[]]$Arguments + ) + + $stdoutFile = [IO.Path]::GetTempFileName() + $stderrFile = [IO.Path]::GetTempFileName() + try { + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $Command @Arguments 1> $stdoutFile 2> $stderrFile + $exitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + + $stdoutText = [IO.File]::ReadAllText($stdoutFile).TrimEnd() + $stderrText = [IO.File]::ReadAllText($stderrFile).TrimEnd() + $diagnosticText = (@($stdoutText, $stderrText) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + + return [PSCustomObject]@{ + ExitCode = $exitCode + StdOutText = $stdoutText + StdErrText = $stderrText + Text = $diagnosticText + } + } + finally { + Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-JsonCommand { + param( + [Parameter(Mandatory)][string]$Command, + [Parameter(Mandatory)][string[]]$Arguments + ) + + $result = Invoke-NativeCommand -Command $Command -Arguments $Arguments + if ($result.ExitCode -ne 0) { + throw "'$Command $($Arguments -join ' ')' failed: $($result.Text)" + } + + return $result.StdOutText | ConvertFrom-Json +} + +function Invoke-NativeStreamingCommand { + param( + [Parameter(Mandatory)][string]$Command, + [Parameter(Mandatory)][string[]]$Arguments + ) + + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $Command @Arguments 2>&1 | Out-Host + return $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Test-AwsNoSuchEntity { + param([Parameter(Mandatory)]$Result) + + return $Result.StdErrText -match '\(NoSuchEntity\)' +} + +function Test-TerraformStateAddress { + param([Parameter(Mandatory)][string]$Address) + + $result = Invoke-NativeCommand -Command terraform -Arguments @('state', 'list') + if ($result.ExitCode -eq 0) { + return @($result.StdOutText -split '\r?\n') -contains $Address + } + if ($result.Text -match 'No state file was found') { + return $false + } + throw "Unable to read Terraform state: $($result.Text)" +} + +function Assert-TerraformStateBinding { + param( + [Parameter(Mandatory)][string]$Address, + [Parameter(Mandatory)][string]$ExpectedArn + ) + + $result = Invoke-NativeCommand -Command terraform -Arguments @( + 'state', 'show', '-no-color', $Address + ) + if ($result.ExitCode -ne 0) { + throw "Unable to inspect the Terraform state binding for $Address`: $($result.Text)" + } + if ($result.StdOutText -notmatch '(?m)^\s*arn\s+=\s+"([^"]+)"') { + throw "Terraform state for $Address does not contain an ARN." + } + if ($Matches[1] -ne $ExpectedArn) { + throw "Terraform state address $Address is bound to $($Matches[1]), not $ExpectedArn. Use the authoritative state for the selected AWS account and resource." + } +} + +function Assert-CompatibleTools { + $terraformResult = Invoke-NativeCommand -Command terraform -Arguments @('version', '-json') + if ($terraformResult.ExitCode -ne 0) { + throw "Unable to read the Terraform version: $($terraformResult.Text)" + } + $terraformDetails = $terraformResult.StdOutText | ConvertFrom-Json + $rawTerraformVersion = [string]$terraformDetails.terraform_version + if ($rawTerraformVersion.Contains('-')) { + throw "A stable Terraform 1.7 or later release is required. Installed: $rawTerraformVersion" + } + $numericTerraformVersion = ($rawTerraformVersion -split '\+', 2)[0] + if ([version]$numericTerraformVersion -lt [version]'1.7.0') { + throw "Terraform 1.7 or later is required. Installed: $rawTerraformVersion" + } + + $awsVersionResult = Invoke-NativeCommand -Command aws -Arguments @('--version') + if ($awsVersionResult.ExitCode -ne 0 -or $awsVersionResult.Text -notmatch 'aws-cli/(\d+)\.') { + throw "Unable to determine the AWS CLI version: $($awsVersionResult.Text)" + } + if ([int]$Matches[1] -lt 2) { + throw "AWS CLI v2 is required. Installed: $($awsVersionResult.Text)" + } + + $signinResult = Invoke-NativeCommand -Command aws -Arguments @( + 'signin', 'create-oauth2-token-with-iam', + '--generate-cli-skeleton', 'input' + ) + if ($signinResult.ExitCode -ne 0) { + throw 'The installed AWS CLI does not support signin create-oauth2-token-with-iam. Upgrade AWS CLI v2.' + } +} + +foreach ($command in @('az', 'aws', 'terraform')) { + Require-Command $command +} +Assert-CompatibleTools + +Push-Location $PSScriptRoot +try { + if ($identityType -eq 'UserAssigned') { + $identity = Invoke-JsonCommand az @( + 'identity', 'show', + '--ids', $identityResourceId, + '--query', '{name:name,tenantId:tenantId,clientId:clientId,principalId:principalId}', + '--output', 'json' + ) + } + else { + $agentIdentity = Invoke-JsonCommand az @( + 'resource', 'show', + '--ids', $identityResourceId, + '--api-version', $AgentApiVersion, + '--query', '{name:name,identityType:identity.type,tenantId:identity.tenantId,principalId:identity.principalId}', + '--output', 'json' + ) + $assignedIdentityTypes = @($agentIdentity.identityType -split ',' | ForEach-Object { $_.Trim() }) + if ($assignedIdentityTypes -notcontains 'SystemAssigned' -or + [string]::IsNullOrWhiteSpace($agentIdentity.tenantId) -or + [string]::IsNullOrWhiteSpace($agentIdentity.principalId)) { + throw 'The selected SRE Agent does not have a ready system-assigned managed identity.' + } + + $clientIdResult = Invoke-NativeCommand -Command az -Arguments @( + 'ad', 'sp', 'show', + '--id', $agentIdentity.principalId, + '--query', 'appId', + '--output', 'tsv' + ) + if ($clientIdResult.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($clientIdResult.StdOutText)) { + throw "Unable to resolve the system identity Client ID from Entra. Verify directory read access and allow time for identity propagation. $($clientIdResult.Text)" + } + + $identity = [PSCustomObject]@{ + name = $agentIdentity.name + tenantId = $agentIdentity.tenantId + clientId = $clientIdResult.StdOutText.Trim() + principalId = $agentIdentity.principalId + } + } + $caller = Invoke-JsonCommand aws @( + 'sts', 'get-caller-identity', + '--no-cli-pager', + '--output', 'json' + ) + if ([string]$caller.Account -notmatch '^\d{12}$' -or [string]$caller.Arn -notmatch '^arn:([^:]+):') { + throw 'AWS returned an invalid caller identity.' + } + + $awsPartition = $Matches[1] + if ($awsPartition -ne 'aws') { + throw "This setup supports the commercial AWS partition only. Active partition: $awsPartition" + } + $issuer = "https://login.microsoftonline.com/$($identity.tenantId)/v2.0" + $providerPath = "login.microsoftonline.com/$($identity.tenantId)/v2.0" + $providerArn = "arn:$awsPartition`:iam::$($caller.Account):oidc-provider/$providerPath" + $providerResult = Invoke-NativeCommand -Command aws -Arguments @( + 'iam', 'get-open-id-connect-provider', + '--open-id-connect-provider-arn', $providerArn, + '--no-cli-pager', + '--output', 'json' + ) + $providerExists = $providerResult.ExitCode -eq 0 + if (-not $providerExists -and -not (Test-AwsNoSuchEntity $providerResult)) { + throw "Unable to inspect the OIDC provider: $($providerResult.Text)" + } + + $existingProviderAudiences = @() + if ($providerExists) { + try { + $provider = $providerResult.StdOutText | ConvertFrom-Json + $existingProviderAudiences = @($provider.ClientIDList) + } + catch { + throw "AWS returned invalid OIDC provider JSON: $($_.Exception.Message)" + } + } + $audienceExists = $existingProviderAudiences -ccontains $identity.clientId + if (-not $audienceExists -and $existingProviderAudiences.Count -ge 100) { + throw 'The existing IAM OIDC provider already has 100 audiences. Remove an unused audience before adding this managed identity.' + } + + $roleResult = Invoke-NativeCommand -Command aws -Arguments @( + 'iam', 'get-role', + '--role-name', $RoleName, + '--no-cli-pager', + '--output', 'json' + ) + $roleExists = $roleResult.ExitCode -eq 0 + if (-not $roleExists -and -not (Test-AwsNoSuchEntity $roleResult)) { + throw "Unable to inspect $RoleName`: $($roleResult.Text)" + } + + $rolePath = '/' + $roleDescription = 'Assumed by an Azure managed identity through Microsoft Entra OIDC federation.' + $maxSessionDuration = 3600 + $existingRoleTrustStatements = @() + $roleArn = "arn:$awsPartition`:iam::$($caller.Account):role/$RoleName" + if ($roleExists) { + try { + $role = ($roleResult.StdOutText | ConvertFrom-Json).Role + $roleArn = $role.Arn + $rolePath = $role.Path + $roleDescription = '' + $descriptionProperty = $role.PSObject.Properties['Description'] + if ($null -ne $descriptionProperty -and -not [string]::IsNullOrWhiteSpace($descriptionProperty.Value)) { + $roleDescription = $descriptionProperty.Value + } + $maxSessionDuration = $role.MaxSessionDuration + $trustPolicy = $role.AssumeRolePolicyDocument + if ($trustPolicy -is [string]) { + $trustPolicy = [Uri]::UnescapeDataString($trustPolicy) | ConvertFrom-Json + } + if ($null -eq $trustPolicy) { + throw 'The role does not contain an assume-role policy document.' + } + $existingRoleTrustStatements = @($trustPolicy.Statement) + } + catch { + throw "AWS returned invalid IAM role JSON: $($_.Exception.Message)" + } + } + + $trustSid = "TrustManagedIdentity$($identity.clientId.Replace('-', '').ToLowerInvariant())" + $audienceConditionKey = "$providerPath`:aud" + $subjectConditionKey = "$providerPath`:sub" + $existingRoleTrustStatements = @( + foreach ($statement in $existingRoleTrustStatements) { + $sidProperty = $statement.PSObject.Properties['Sid'] + $sid = if ($null -eq $sidProperty) { '' } else { [string]$sidProperty.Value } + $conditionProperty = $statement.PSObject.Properties['Condition'] + $stringEqualsProperty = if ($null -eq $conditionProperty) { + $null + } + elseif ($null -eq $conditionProperty.Value) { + $null + } + else { + $conditionProperty.Value.PSObject.Properties['StringEquals'] + } + $stringEquals = if ($null -eq $stringEqualsProperty) { $null } else { $stringEqualsProperty.Value } + $audienceProperty = if ($null -eq $stringEquals) { $null } else { $stringEquals.PSObject.Properties[$audienceConditionKey] } + $subjectProperty = if ($null -eq $stringEquals) { $null } else { $stringEquals.PSObject.Properties[$subjectConditionKey] } + $audienceValues = @() + if ($null -ne $audienceProperty) { + $audienceValues = @($audienceProperty.Value) + } + $subjectValues = @() + if ($null -ne $subjectProperty) { + $subjectValues = @($subjectProperty.Value) + } + $matchesIdentity = ( + $audienceValues.Count -eq 1 -and + $subjectValues.Count -eq 1 -and + [string]$audienceValues[0] -ceq [string]$identity.clientId -and + [string]$subjectValues[0] -ceq [string]$identity.principalId + ) + if ($sid -cne $trustSid -and -not $matchesIdentity) { + $statement + } + } + ) + + $additionalOidcProviderAudiences = @() + if (Test-Path -LiteralPath $GeneratedVariablesFile) { + try { + $existingVariables = Get-Content -LiteralPath $GeneratedVariablesFile -Raw | ConvertFrom-Json + $property = $existingVariables.PSObject.Properties['additional_oidc_provider_audiences'] + if ($null -ne $property -and $null -ne $property.Value) { + $additionalOidcProviderAudiences = @($property.Value) + } + if (-not $roleExists) { + $property = $existingVariables.PSObject.Properties['role_path'] + if ($null -ne $property -and -not [string]::IsNullOrWhiteSpace($property.Value)) { + $rolePath = $property.Value + } + $property = $existingVariables.PSObject.Properties['role_description'] + if ($null -ne $property -and $null -ne $property.Value) { + $roleDescription = [string]$property.Value + } + $property = $existingVariables.PSObject.Properties['max_session_duration'] + if ($null -ne $property -and $null -ne $property.Value) { + $maxSessionDuration = [int]$property.Value + } + } + } + catch { + throw "Unable to read the existing $GeneratedVariablesFile file: $($_.Exception.Message)" + } + } + if ($providerExists) { + $audienceSet = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $additionalOidcProviderAudiences = @( + foreach ($audience in $existingProviderAudiences) { + if ($audience -cne $identity.clientId -and $audienceSet.Add($audience)) { + $audience + } + } + ) + } + + $generatedVariables = [ordered]@{ + managed_identity = [ordered]@{ + type = $identityType + resource_id = $identityResourceId + name = $identity.name + tenant_id = $identity.tenantId + client_id = $identity.clientId + principal_id = $identity.principalId + } + role_name = $RoleName + role_path = $rolePath + role_description = $roleDescription + max_session_duration = $maxSessionDuration + existing_role_trust_statements = $existingRoleTrustStatements + additional_oidc_provider_audiences = $additionalOidcProviderAudiences + workload_policies = $workloadPolicies + } + [IO.File]::WriteAllText( + $GeneratedVariablesFile, + ($generatedVariables | ConvertTo-Json -Depth 100), + [Text.UTF8Encoding]::new($false) + ) + + Write-Host '' + Write-Host 'Discovered Azure identity' -ForegroundColor Cyan + Write-Host " Identity type: $identityType" + Write-Host " Managed identity: $($identity.name)" + Write-Host " Tenant ID: $($identity.tenantId)" + Write-Host " Client ID: $($identity.clientId)" + Write-Host " Principal ID: $($identity.principalId)" + Write-Host " AWS account ID: $($caller.Account)" + Write-Host " OIDC provider: $providerArn" + Write-Host '' + Write-Host 'Terraform will manage:' -ForegroundColor Cyan + Write-Host " Microsoft Entra OIDC provider: $(if ($providerExists) { 'existing; import into this Terraform state' } else { 'create' })" + Write-Host " Managed identity audience: $(if ($audienceExists) { 'already present' } else { 'add' })" + Write-Host " IAM role: $RoleName ($(if ($roleExists) { 'existing; preserve current trust and add this identity' } else { 'create' }))" + Write-Host ' AWS MCP OAuth managed policy attachment' + Write-Host " Inline policies: $policySource" + Write-Host '' + + $terraformResult = Invoke-NativeCommand -Command terraform -Arguments @('init', '-input=false', '-no-color') + if ($terraformResult.ExitCode -ne 0) { + Write-Host $terraformResult.Text + throw 'terraform init failed.' + } + + $providerInState = Test-TerraformStateAddress 'aws_iam_openid_connect_provider.entra' + if ($providerInState) { + Assert-TerraformStateBinding ` + -Address 'aws_iam_openid_connect_provider.entra' ` + -ExpectedArn $providerArn + } + elseif ($providerExists) { + Write-Host 'Importing the existing OIDC provider into the local Terraform state...' + $terraformResult = Invoke-NativeCommand -Command terraform -Arguments @( + 'import', '-input=false', '-no-color', + 'aws_iam_openid_connect_provider.entra', $providerArn + ) + if ($terraformResult.ExitCode -ne 0) { + Write-Host $terraformResult.Text + throw 'Unable to import the existing OIDC provider.' + } + } + + $roleInState = Test-TerraformStateAddress 'aws_iam_role.entra' + if ($roleInState -and $roleExists) { + Assert-TerraformStateBinding ` + -Address 'aws_iam_role.entra' ` + -ExpectedArn $roleArn + } + elseif ($roleExists) { + Write-Host "Importing the existing $RoleName role into the local Terraform state..." + $terraformResult = Invoke-NativeCommand -Command terraform -Arguments @( + 'import', '-input=false', '-no-color', + 'aws_iam_role.entra', $RoleName + ) + if ($terraformResult.ExitCode -ne 0) { + Write-Host $terraformResult.Text + throw "Unable to import the existing $RoleName role." + } + } + + $terraformResult = Invoke-NativeCommand -Command terraform -Arguments @( + 'plan', '-input=false', '-no-color', "-out=$PlanFile" + ) + if ($terraformResult.ExitCode -ne 0) { + Write-Host $terraformResult.Text + throw 'terraform plan failed. Review the Terraform output and the local state before rerunning.' + } + + Write-Host '' + if ($SkipApproval -ieq 'false') { + $confirmation = (Read-Host 'Proceed with these AWS changes? [y/n]').Trim().ToLowerInvariant() + if ($confirmation -ne 'y') { + Write-Host 'No AWS changes were applied.' + Remove-Item -LiteralPath $PlanFile -Force -ErrorAction SilentlyContinue + exit 0 + } + } + else { + Write-Host 'skip-approval=true; applying without confirmation.' + } + + $applyExitCode = Invoke-NativeStreamingCommand -Command terraform -Arguments @( + 'apply', '-input=false', '-auto-approve', $PlanFile + ) + if ($applyExitCode -ne 0) { + throw 'terraform apply failed.' + } + Remove-Item -LiteralPath $PlanFile -Force -ErrorAction SilentlyContinue + + Write-Host '' + Write-Host 'AWS setup completed.' -ForegroundColor Green + Write-Host "Role: $RoleName" + Write-Host '' + Write-Host "To review the SRE Agent inline policies, open IAM > Roles > $RoleName > Permissions in the AWS Console: $policySource." +} +finally { + Pop-Location +} diff --git a/sreagent-templates/identity-federation/aws/setup.sh b/sreagent-templates/identity-federation/aws/setup.sh new file mode 100644 index 000000000..ced718d28 --- /dev/null +++ b/sreagent-templates/identity-federation/aws/setup.sh @@ -0,0 +1,562 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +role_name="SREAgentRole" +generated_variables_file="$script_dir/guided.auto.tfvars.json" +plan_file="$script_dir/tfplan" +agent_api_version="2025-05-01-preview" +identity_resource_id="" +allowed_permissions="" +skip_approval="false" +resource_id_provided=false +permissions_provided=false +skip_approval_provided=false + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +policy_description() { + case "$1" in + SREAgentReadOnlyAccess) + printf 'Inspect AWS resources and telemetry, read application data and secrets, and decrypt data.' + ;; + SREAgentWriteAccess) + printf 'Open workload sessions and database connections; restart, stop, scale, or recover resources.' + ;; + esac +} + +while (($# > 0)); do + option="$1" + case "$option" in + --resource-id|--allowed-permissions|--skip-approval) + (($# >= 2)) && [[ "$2" != --* ]] || fail "Missing value for $option." + value="$2" + shift 2 + ;; + --resource-id=*|--allowed-permissions=*|--skip-approval=*) + value="${option#*=}" + option="${option%%=*}" + shift + ;; + --help|-h) + printf 'Usage: ./setup.sh [--resource-id ] [--allowed-permissions read-only|read-write] [--skip-approval true|false]\n' + printf 'read-only selects SREAgentReadOnlyAccess; read-write selects both access policies.\n' + printf 'skip-approval defaults to false (y/n confirmation). Set true to apply without confirmation. Missing inputs are still prompted.\n' + exit 0 + ;; + *) + fail "Unknown argument: $1. Use --help for usage." + ;; + esac + case "$option" in + --resource-id) + [[ "$resource_id_provided" == false ]] || fail "Duplicate --resource-id argument." + identity_resource_id="$value" + resource_id_provided=true + ;; + --allowed-permissions) + [[ "$permissions_provided" == false ]] || fail "Duplicate --allowed-permissions argument." + allowed_permissions="$value" + permissions_provided=true + ;; + --skip-approval) + [[ "$skip_approval_provided" == false ]] || fail "Duplicate --skip-approval argument." + skip_approval="$value" + skip_approval_provided=true + ;; + esac +done +skip_approval="${skip_approval,,}" +[[ "$skip_approval" == true || "$skip_approval" == false ]] \ + || fail "Invalid skip-approval value. Choose true or false." + +printf '\nSRE Agent AWS connector setup\n' +printf 'This script reads the Azure managed identity and lets Terraform configure AWS.\n\n' +if [[ "$resource_id_provided" == false ]]; then + read -r -p 'Enter the full ARM resource ID of the user-assigned managed identity or SRE Agent: ' identity_resource_id \ + || fail "An ARM resource ID is required." +fi +identity_resource_id="$(trim "$identity_resource_id")" +subscription_pattern='[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +resource_id_pattern="^/subscriptions/$subscription_pattern/resourcegroups/[^/[:space:]?#]+/providers/(microsoft\\.managedidentity/userassignedidentities|microsoft\\.app/agents)/[^/[:space:]?#]+$" +[[ "${identity_resource_id,,}" =~ $resource_id_pattern ]] \ + || fail "Invalid ARM resource ID. Expected /subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/ or Microsoft.App/agents/." +if [[ "${BASH_REMATCH[1]}" == "microsoft.managedidentity/userassignedidentities" ]]; then + identity_type="UserAssigned" +else + identity_type="SystemAssigned" +fi + +if [[ "$permissions_provided" == false ]]; then + printf '\nAvailable permission levels:\n' + printf ' read-only - SREAgentReadOnlyAccess: %s\n' "$(policy_description SREAgentReadOnlyAccess)" + printf ' read-write - SREAgentReadOnlyAccess + SREAgentWriteAccess: %s\n' "$(policy_description SREAgentWriteAccess)" + printf 'Example: --allowed-permissions read-only (or read-write)\n' + read -r -p 'Enter allowed permissions: read-only or read-write: ' allowed_permissions \ + || fail "An allowed-permissions value is required. Choose read-only or read-write." +fi +allowed_permissions="$(trim "$allowed_permissions")" +allowed_permissions="${allowed_permissions,,}" +[[ "$allowed_permissions" == read-only || "$allowed_permissions" == read-write ]] \ + || fail "Invalid allowed-permissions value. Choose read-only or read-write." +policy_names=(SREAgentReadOnlyAccess) +if [[ "$allowed_permissions" == read-write ]]; then + policy_names+=(SREAgentWriteAccess) +fi + +decode_rfc3986() { + local encoded="$1" + printf '%b' "${encoded//%/\\x}" +} + +terraform_supported() { + local version + local major + local minor + local patch + local suffix + + version="$(terraform version 2>/dev/null | head -n 1)" || return 1 + [[ "$version" =~ ^Terraform\ v([0-9]+)\.([0-9]+)\.([0-9]+)([-+][^[:space:]]+)?$ ]] || return 1 + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + suffix="${BASH_REMATCH[4]:-}" + + [[ "$suffix" != -* ]] || return 1 + ((major > 1)) || + ((major == 1 && minor > 7)) || + ((major == 1 && minor == 7 && patch >= 0)) +} + +aws_cli_supported() { + local version + + version="$(aws --version 2>&1)" || return 1 + [[ "$version" =~ aws-cli/([0-9]+)\. ]] || return 1 + ((BASH_REMATCH[1] >= 2)) || return 1 + AWS_PAGER="" aws signin create-oauth2-token-with-iam \ + --generate-cli-skeleton input >/dev/null 2>&1 +} + +for command in az aws terraform jq; do + command -v "$command" >/dev/null 2>&1 || fail "Required command not found: $command" +done +terraform_supported || fail "Terraform 1.7 or later is required." +aws_cli_supported || fail "AWS CLI v2 with signin create-oauth2-token-with-iam is required." + +cd "$script_dir" + +policy_files=() +for name in "${policy_names[@]}"; do + policy_path="$script_dir/$name.json" + [[ -f "$policy_path" ]] || fail "Policy JSON file not found: $policy_path" + jq -e ' + type == "object" and .Version == "2012-10-17" + and (.Statement | if type == "object" then [.] else . end + | type == "array" and length > 0 + and all(.[]; type == "object" and (.Effect == "Allow" or .Effect == "Deny"))) + ' "$policy_path" >/dev/null \ + || fail "Policy '$policy_path' must be valid JSON with Version 2012-10-17 and nonempty Statement objects." + policy_files+=("$policy_path") +done +workload_policies="$( + jq -cs --arg names "${policy_names[*]}" ' + ($names | split(" ")) as $names | + to_entries | map({key: $names[.key], value: .value}) | from_entries + ' "${policy_files[@]}" +)" || fail "Unable to load the selected policy files." +jq -e ' + [.[].Statement | if type == "array" then .[] else . end | select(has("Sid")) | .Sid] | + length == (unique | length) +' >/dev/null <<<"$workload_policies" || fail "Duplicate policy statement Sid in the selected policies." +jq -e '[.[] | tojson | length] | add <= 10240' >/dev/null <<<"$workload_policies" \ + || fail "The workload policies exceed the 10,240-character aggregate role inline-policy limit." +policy_source="$(IFS=', '; printf '%s' "${policy_names[*]}")" +printf '\nAllowed permissions: %s\n' "$allowed_permissions" +printf 'Selected policies to apply:\n' +for name in "${policy_names[@]}"; do + printf ' %s - %s\n' "$name" "$(policy_description "$name")" +done +printf 'WARNING: Selected permissions are shared by every identity trusted by SREAgentRole. ReadOnlyAccess exposes data and credentials; WriteAccess permits workload changes and can cause outages.\n' + +case "$identity_type" in + UserAssigned) + identity_json="$( + az identity show \ + --ids "$identity_resource_id" \ + --query '{name:name,tenantId:tenantId,clientId:clientId,principalId:principalId}' \ + --output json + )" || fail "Unable to read the managed identity. Run az login and verify access." + ;; + SystemAssigned) + agent_identity_json="$( + az resource show \ + --ids "$identity_resource_id" \ + --api-version "$agent_api_version" \ + --query '{name:name,identityType:identity.type,tenantId:identity.tenantId,principalId:identity.principalId}' \ + --output json + )" || fail "Unable to read the SRE Agent resource. Run az login and verify access." + + jq -e ' + (.identityType // "" | split(",") | map(gsub("^\\s+|\\s+$"; "")) | index("SystemAssigned") != null) + and (.tenantId | type == "string" and length > 0) + and (.principalId | type == "string" and length > 0) + ' >/dev/null <<<"$agent_identity_json" \ + || fail "The selected SRE Agent does not have a ready system-assigned managed identity." + + principal_id="$(jq -er '.principalId' <<<"$agent_identity_json")" + client_id="$( + az ad sp show \ + --id "$principal_id" \ + --query appId \ + --output tsv + )" || fail "Unable to resolve the system identity Client ID from Entra. Verify directory read access and allow time for identity propagation." + [[ -n "$client_id" ]] \ + || fail "The system identity service principal did not return a Client ID." + + identity_json="$( + jq -n \ + --arg name "$(jq -er '.name' <<<"$agent_identity_json")" \ + --arg tenant_id "$(jq -er '.tenantId' <<<"$agent_identity_json")" \ + --arg client_id "$client_id" \ + --arg principal_id "$principal_id" \ + '{ + name: $name, + tenantId: $tenant_id, + clientId: $client_id, + principalId: $principal_id + }' + )" + ;; +esac + +identity_name="$(jq -er '.name' <<<"$identity_json")" +tenant_id="$(jq -er '.tenantId' <<<"$identity_json")" +client_id="$(jq -er '.clientId' <<<"$identity_json")" +principal_id="$(jq -er '.principalId' <<<"$identity_json")" + +caller_json="$(AWS_PAGER="" aws sts get-caller-identity --no-cli-pager --output json)" \ + || fail "Unable to read the AWS caller identity. Sign in to AWS and try again." +aws_account_id="$(jq -er '.Account | select(test("^[0-9]{12}$"))' <<<"$caller_json")" \ + || fail "AWS returned an invalid account ID." +caller_arn="$(jq -er '.Arn' <<<"$caller_json")" +[[ "$caller_arn" =~ ^arn:([^:]+): ]] || fail "AWS returned an invalid caller ARN." +aws_partition="${BASH_REMATCH[1]}" +[[ "$aws_partition" == "aws" ]] \ + || fail "This setup supports the commercial AWS partition only. Active partition: $aws_partition" + +issuer="https://login.microsoftonline.com/$tenant_id/v2.0" +provider_path="login.microsoftonline.com/$tenant_id/v2.0" +provider_arn="arn:$aws_partition:iam::$aws_account_id:oidc-provider/$provider_path" + +provider_exists=false +provider_json='{}' +provider_error_file="$(mktemp)" +if provider_json="$( + AWS_PAGER="" aws iam get-open-id-connect-provider \ + --open-id-connect-provider-arn "$provider_arn" \ + --no-cli-pager \ + --output json 2>"$provider_error_file" +)"; then + provider_exists=true +elif grep -q '(NoSuchEntity)' "$provider_error_file"; then + provider_json='{}' +else + cat "$provider_error_file" >&2 + rm -f "$provider_error_file" + fail "Unable to inspect the OIDC provider." +fi +rm -f "$provider_error_file" + +existing_provider_audiences="$(jq -c '[.ClientIDList[]?]' <<<"$provider_json")" \ + || fail "AWS returned invalid OIDC provider JSON." +audience_exists=false +if jq -e --arg client_id "$client_id" \ + '.ClientIDList // [] | index($client_id) != null' >/dev/null <<<"$provider_json"; then + audience_exists=true +fi +existing_audience_count="$(jq -r '.ClientIDList // [] | length' <<<"$provider_json")" +if [[ "$audience_exists" == false && "$existing_audience_count" -ge 100 ]]; then + fail "The existing IAM OIDC provider already has 100 audiences. Remove an unused audience before adding this managed identity." +fi + +role_exists=false +role_json='{}' +role_error_file="$(mktemp)" +if role_json="$( + AWS_PAGER="" aws iam get-role \ + --role-name "$role_name" \ + --no-cli-pager \ + --output json 2>"$role_error_file" +)"; then + role_exists=true +elif grep -q '(NoSuchEntity)' "$role_error_file"; then + role_json='{}' +else + cat "$role_error_file" >&2 + rm -f "$role_error_file" + fail "Unable to inspect $role_name." +fi +rm -f "$role_error_file" + +role_path="$(jq -r '.Role.Path // "/"' <<<"$role_json")" +role_description="$( + jq -r ' + if .Role == null then + "Assumed by an Azure managed identity through Microsoft Entra OIDC federation." + else + .Role.Description // "" + end + ' \ + <<<"$role_json" +)" +max_session_duration="$(jq -r '.Role.MaxSessionDuration // 3600' <<<"$role_json")" +role_arn="$(jq -r --arg fallback "arn:$aws_partition:iam::$aws_account_id:role/$role_name" '.Role.Arn // $fallback' <<<"$role_json")" + +role_trust_policy='{}' +if [[ "$role_exists" == true ]]; then + role_trust_policy_type="$( + jq -r '.Role.AssumeRolePolicyDocument | type' <<<"$role_json" + )" + if [[ "$role_trust_policy_type" == "object" ]]; then + role_trust_policy="$( + jq -c '.Role.AssumeRolePolicyDocument' <<<"$role_json" + )" + elif [[ "$role_trust_policy_type" == "string" ]]; then + encoded_role_trust_policy="$( + jq -r '.Role.AssumeRolePolicyDocument' <<<"$role_json" + )" + role_trust_policy="$(decode_rfc3986 "$encoded_role_trust_policy")" + jq -e 'type == "object"' >/dev/null <<<"$role_trust_policy" \ + || fail "AWS returned an invalid encoded IAM role trust policy." + else + fail "The existing IAM role does not contain a valid trust policy." + fi +fi + +trust_sid="TrustManagedIdentity$(tr '[:upper:]' '[:lower:]' <<<"${client_id//-/}")" +audience_condition_key="$provider_path:aud" +subject_condition_key="$provider_path:sub" +existing_role_trust_statements="$( + jq -c \ + --arg sid "$trust_sid" \ + --arg audience_key "$audience_condition_key" \ + --arg subject_key "$subject_condition_key" \ + --arg client_id "$client_id" \ + --arg principal_id "$principal_id" \ + ' + def exactly_matches($expected): + if type == "array" then + length == 1 and .[0] == $expected + else + . == $expected + end; + + [ + ( + .Statement + | if . == null then [] elif type == "array" then . else [.] end + )[] + | select( + (.Sid // "") != $sid + and ( + ( + ((.Condition.StringEquals[$audience_key] // null) | exactly_matches($client_id)) + and ((.Condition.StringEquals[$subject_key] // null) | exactly_matches($principal_id)) + ) + | not + ) + ) + ]' <<<"$role_trust_policy" +)" + +additional_audiences='[]' +if [[ -f "$generated_variables_file" ]]; then + jq -e . "$generated_variables_file" >/dev/null \ + || fail "The existing guided.auto.tfvars.json file is not valid JSON." + additional_audiences="$( + jq -c '.additional_oidc_provider_audiences // []' "$generated_variables_file" + )" + if [[ "$role_exists" == false ]]; then + role_path="$(jq -r '.role_path // "/"' "$generated_variables_file")" + role_description="$( + jq -r '.role_description // "Assumed by an Azure managed identity through Microsoft Entra OIDC federation."' \ + "$generated_variables_file" + )" + max_session_duration="$(jq -r '.max_session_duration // 3600' "$generated_variables_file")" + fi +fi +if [[ "$provider_exists" == true ]]; then + additional_audiences="$( + jq -c --arg client_id "$client_id" \ + '[.ClientIDList[]? | select(. != $client_id)] | unique | sort' \ + <<<"$provider_json" + )" +fi + +jq -n \ + --arg identity_type "$identity_type" \ + --arg resource_id "$identity_resource_id" \ + --arg name "$identity_name" \ + --arg tenant_id "$tenant_id" \ + --arg client_id "$client_id" \ + --arg principal_id "$principal_id" \ + --arg role_name "$role_name" \ + --arg role_path "$role_path" \ + --arg role_description "$role_description" \ + --argjson max_session_duration "$max_session_duration" \ + --argjson existing_role_trust_statements "$existing_role_trust_statements" \ + --argjson additional_audiences "$additional_audiences" \ + --argjson workload_policies "$workload_policies" \ + '{ + managed_identity: { + type: $identity_type, + resource_id: $resource_id, + name: $name, + tenant_id: $tenant_id, + client_id: $client_id, + principal_id: $principal_id + }, + role_name: $role_name, + role_path: $role_path, + role_description: $role_description, + max_session_duration: $max_session_duration, + existing_role_trust_statements: $existing_role_trust_statements, + additional_oidc_provider_audiences: $additional_audiences, + workload_policies: $workload_policies + }' >"$generated_variables_file" +chmod 600 "$generated_variables_file" + +printf '\nDiscovered Azure identity\n' +printf ' Identity type: %s\n' "$identity_type" +printf ' Managed identity: %s\n' "$identity_name" +printf ' Tenant ID: %s\n' "$tenant_id" +printf ' Client ID: %s\n' "$client_id" +printf ' Principal ID: %s\n' "$principal_id" +printf ' AWS account ID: %s\n' "$aws_account_id" +printf ' OIDC provider: %s\n' "$provider_arn" + +printf '\nTerraform will manage:\n' +if [[ "$provider_exists" == true ]]; then + printf ' Microsoft Entra OIDC provider: existing; import into this Terraform state\n' +else + printf ' Microsoft Entra OIDC provider: create\n' +fi +if [[ "$audience_exists" == true ]]; then + printf ' Managed identity audience: already present\n' +else + printf ' Managed identity audience: add\n' +fi + if [[ "$role_exists" == true ]]; then + printf ' IAM role: %s (existing; preserve current trust and add this identity)\n' "$role_name" + else + printf ' IAM role: %s (create)\n' "$role_name" + fi +printf ' AWS MCP OAuth managed policy attachment\n' +printf ' Inline policies: %s\n' "$policy_source" + +if ! terraform_output="$(terraform init -input=false -no-color 2>&1)"; then + printf '%s\n' "$terraform_output" >&2 + fail "terraform init failed." +fi + +if ! state_output="$(terraform state list 2>&1)"; then + if [[ "$state_output" == *"No state file was found"* ]]; then + state_output="" + else + printf '%s\n' "$state_output" >&2 + fail "Unable to read Terraform state." + fi +fi + +if grep -Fxq 'aws_iam_openid_connect_provider.entra' <<<"$state_output"; then + if ! provider_state="$( + terraform state show -no-color aws_iam_openid_connect_provider.entra 2>&1 + )"; then + printf '%s\n' "$provider_state" >&2 + fail "Unable to inspect the Terraform OIDC provider state binding." + fi + state_provider_arn="$( + sed -nE 's/^[[:space:]]*arn[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ + <<<"$provider_state" | + head -n 1 + )" + [[ -n "$state_provider_arn" ]] \ + || fail "Terraform state for aws_iam_openid_connect_provider.entra does not contain an ARN." + [[ "$state_provider_arn" == "$provider_arn" ]] \ + || fail "Terraform state is bound to $state_provider_arn, not $provider_arn. Use the authoritative state for the selected AWS account and Entra tenant." +elif [[ "$provider_exists" == true ]]; then + printf 'Importing the existing OIDC provider into the local Terraform state...\n' + if ! terraform_output="$( + terraform import -input=false -no-color \ + aws_iam_openid_connect_provider.entra "$provider_arn" 2>&1 + )"; then + printf '%s\n' "$terraform_output" >&2 + fail "Unable to import the existing OIDC provider." + fi +fi + +if [[ "$role_exists" == true ]] && + grep -Fxq 'aws_iam_role.entra' <<<"$state_output"; then + if ! role_state="$(terraform state show -no-color aws_iam_role.entra 2>&1)"; then + printf '%s\n' "$role_state" >&2 + fail "Unable to inspect the Terraform IAM role state binding." + fi + state_role_arn="$( + sed -nE 's/^[[:space:]]*arn[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ + <<<"$role_state" | + head -n 1 + )" + [[ -n "$state_role_arn" ]] \ + || fail "Terraform state for aws_iam_role.entra does not contain an ARN." + [[ "$state_role_arn" == "$role_arn" ]] \ + || fail "Terraform state is bound to $state_role_arn, not $role_arn. Use the authoritative state for the selected AWS account and role." +elif [[ "$role_exists" == true ]]; then + printf 'Importing the existing %s role into the local Terraform state...\n' "$role_name" + if ! terraform_output="$( + terraform import -input=false -no-color aws_iam_role.entra "$role_name" 2>&1 + )"; then + printf '%s\n' "$terraform_output" >&2 + fail "Unable to import the existing $role_name role." + fi +fi + +if ! terraform_output="$( + terraform plan -input=false -no-color -out="$plan_file" 2>&1 +)"; then + printf '%s\n' "$terraform_output" >&2 + fail "terraform plan failed. Review the Terraform output and the local state before rerunning." +fi + +printf '\n' +if [[ "$skip_approval" == false ]]; then + confirmation="" + read -r -p 'Proceed with these AWS changes? [y/n]: ' confirmation || true + confirmation="$(trim "$confirmation")" + if [[ "${confirmation,,}" != "y" ]]; then + printf 'No AWS changes were applied.\n' + rm -f "$plan_file" + exit 0 + fi +else + printf 'skip-approval=true; applying without confirmation.\n' +fi + +terraform apply -input=false -auto-approve "$plan_file" +rm -f "$plan_file" + +printf '\nAWS setup completed.\n' +printf 'Role: %s\n\n' "$role_name" +printf 'To review the SRE Agent inline policies, open IAM > Roles > %s > Permissions in the AWS Console: %s.\n' "$role_name" "$policy_source" diff --git a/sreagent-templates/identity-federation/aws/terraform.tfvars.example b/sreagent-templates/identity-federation/aws/terraform.tfvars.example new file mode 100644 index 000000000..17f1f0f5b --- /dev/null +++ b/sreagent-templates/identity-federation/aws/terraform.tfvars.example @@ -0,0 +1,47 @@ +# User-assigned identity example: +managed_identity = { + type = "UserAssigned" + resource_id = "/subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/" + name = "" + tenant_id = "" + client_id = "" + principal_id = "" +} + +# System-assigned identity example: +# managed_identity = { +# type = "SystemAssigned" +# resource_id = "/subscriptions//resourceGroups//providers/Microsoft.App/agents/" +# name = "" +# tenant_id = "" +# client_id = "" +# principal_id = "" +# } + +role_name = "SREAgentRole" + +# Populated automatically by the guided setup when the role already exists. +# Each managed identity receives its own trust statement. +existing_role_trust_statements = [] + +# Add client IDs only when this Terraform state owns a shared OIDC provider. +additional_oidc_provider_audiences = [] + +# AWSMCPSignInOAuthAccessPolicy is attached automatically. Add only additional +# managed policies needed by SRE Agent operator scripts. +policy_arns = [] + +# Define separate named inline policies. The guided scripts populate this map. +# Use jsondecode(file(...)) in a .tf file; .tfvars files require literal values. +workload_policies = { + SREAgentReadOnlyAccess = { + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "ec2:DescribeInstances" + Resource = "*" + } + ] + } +} diff --git a/sreagent-templates/identity-federation/aws/variables.tf b/sreagent-templates/identity-federation/aws/variables.tf new file mode 100644 index 000000000..449f11b99 --- /dev/null +++ b/sreagent-templates/identity-federation/aws/variables.tf @@ -0,0 +1,153 @@ +variable "managed_identity" { + description = "Azure managed identity facts used to configure the AWS OIDC trust." + type = object({ + type = string + resource_id = string + name = string + tenant_id = string + client_id = string + principal_id = string + }) + nullable = false + + validation { + condition = contains(["UserAssigned", "SystemAssigned"], var.managed_identity.type) + error_message = "managed_identity.type must be UserAssigned or SystemAssigned." + } + + validation { + condition = ( + var.managed_identity.type == "UserAssigned" + ? can(regex( + "(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/resourceGroups/[^/]+/providers/Microsoft\\.ManagedIdentity/userAssignedIdentities/[^/]+$", + var.managed_identity.resource_id + )) + : can(regex( + "(?i)^/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/resourceGroups/[^/]+/providers/Microsoft\\.App/agents/[^/]+$", + var.managed_identity.resource_id + )) + ) + error_message = "managed_identity.resource_id must match the selected identity type." + } + + validation { + condition = alltrue([ + for value in [ + var.managed_identity.tenant_id, + var.managed_identity.client_id, + var.managed_identity.principal_id, + ] : + can(regex("(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", value)) + ]) + error_message = "managed_identity tenant_id, client_id, and principal_id must be UUIDs." + } + + validation { + condition = length(trimspace(var.managed_identity.name)) > 0 + error_message = "managed_identity.name must not be empty." + } +} + +variable "role_name" { + description = "Name of the AWS IAM role used by the SRE Agent AWS connector." + type = string + default = "SREAgentRole" + nullable = false + + validation { + condition = can(regex("^[A-Za-z0-9+=,.@_-]{1,64}$", var.role_name)) + error_message = "role_name must be 1-64 characters using AWS IAM role-name characters." + } +} + +variable "role_path" { + description = "IAM path assigned to the role." + type = string + default = "/" + + validation { + condition = ( + var.role_path == "/" + || ( + length(var.role_path) <= 512 + && can(regex("^/[!-~]+/$", var.role_path)) + ) + ) + error_message = "role_path must be / or an AWS IAM path of at most 512 printable ASCII characters." + } +} + +variable "role_description" { + description = "Description assigned to the IAM role." + type = string + default = "Assumed by an Azure managed identity through Microsoft Entra OIDC federation." +} + +variable "max_session_duration" { + description = "Maximum role session duration in seconds." + type = number + default = 3600 + + validation { + condition = var.max_session_duration >= 3600 && var.max_session_duration <= 43200 + error_message = "max_session_duration must be between 3600 and 43200 seconds." + } +} + +variable "existing_role_trust_statements" { + description = "Existing IAM role trust-policy statements to preserve when adding the selected managed identity." + type = any + default = [] +} + +variable "additional_oidc_provider_audiences" { + description = "Additional client IDs to register when this Terraform state owns a shared OIDC provider." + type = set(string) + default = [] + + validation { + condition = alltrue([ + for audience in var.additional_oidc_provider_audiences : + length(audience) >= 1 && length(audience) <= 255 + ]) + error_message = "Each additional OIDC provider audience must contain 1-255 characters." + } +} + +variable "policy_arns" { + description = "Additional AWS managed or customer-managed policies attached to the role. AWSMCPSignInOAuthAccessPolicy is always attached." + type = set(string) + default = [] + + validation { + condition = alltrue([ + for arn in var.policy_arns : + can(regex("^arn:[^:]+:iam::([0-9]{12}|aws):policy/.+$", arn)) + ]) + error_message = "Each policy_arns value must be an AWS managed or customer-managed IAM policy ARN." + } +} + +variable "workload_policies" { + description = "Inline IAM policies keyed by their names. Specify the desired policies explicitly." + type = any + nullable = false + + validation { + condition = ( + can(keys(var.workload_policies)) + && try(alltrue([ + for name, policy in var.workload_policies : + can(regex("^[A-Za-z0-9+=,.@_-]{1,128}$", name)) + && policy.Version == "2012-10-17" + && length(policy.Statement) > 0 + ]), false) + ) + error_message = "workload_policies must map valid IAM policy names to policy objects containing Version 2012-10-17 and nonempty Statement." + } + + validation { + condition = try(sum([for policy in values(var.workload_policies) : length(jsonencode(policy))]) <= 10240, false) + error_message = "The workload policies exceed the 10,240-character aggregate role inline-policy limit." + } +} diff --git a/sreagent-templates/identity-federation/aws/versions.tf b/sreagent-templates/identity-federation/aws/versions.tf new file mode 100644 index 000000000..a4aa671f0 --- /dev/null +++ b/sreagent-templates/identity-federation/aws/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.81.0, < 7.0.0" + } + } +} From bacbb10f2ac22c34804aa866d3b591f80b334399 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 27 Oct 2026 13:45:39 +0530 Subject: [PATCH 2/3] Simplify AWS federation setup prerequisites Remove the AWS CLI signin capability check and unused Terraform variables example, and update the setup documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/aws/README.md | 4 +- .../identity-federation/aws/setup.ps1 | 7 --- .../identity-federation/aws/setup.sh | 6 +-- .../aws/terraform.tfvars.example | 47 ------------------- 4 files changed, 3 insertions(+), 61 deletions(-) delete mode 100644 sreagent-templates/identity-federation/aws/terraform.tfvars.example diff --git a/sreagent-templates/identity-federation/aws/README.md b/sreagent-templates/identity-federation/aws/README.md index 5f5128a5d..5ba4a7594 100644 --- a/sreagent-templates/identity-federation/aws/README.md +++ b/sreagent-templates/identity-federation/aws/README.md @@ -17,9 +17,7 @@ The following tools are required: - [`jq`](https://jqlang.org/download/) on Linux and macOS Install the tools by following their official documentation links above before -running the setup. The setup scripts validate that Terraform is a stable -version 1.7 or later and that AWS CLI v2 provides -`signin create-oauth2-token-with-iam`. +running the setup. ### Verify the installation diff --git a/sreagent-templates/identity-federation/aws/setup.ps1 b/sreagent-templates/identity-federation/aws/setup.ps1 index 50e70a891..f97ba683c 100644 --- a/sreagent-templates/identity-federation/aws/setup.ps1 +++ b/sreagent-templates/identity-federation/aws/setup.ps1 @@ -237,13 +237,6 @@ function Assert-CompatibleTools { throw "AWS CLI v2 is required. Installed: $($awsVersionResult.Text)" } - $signinResult = Invoke-NativeCommand -Command aws -Arguments @( - 'signin', 'create-oauth2-token-with-iam', - '--generate-cli-skeleton', 'input' - ) - if ($signinResult.ExitCode -ne 0) { - throw 'The installed AWS CLI does not support signin create-oauth2-token-with-iam. Upgrade AWS CLI v2.' - } } foreach ($command in @('az', 'aws', 'terraform')) { diff --git a/sreagent-templates/identity-federation/aws/setup.sh b/sreagent-templates/identity-federation/aws/setup.sh index ced718d28..10e444db8 100644 --- a/sreagent-templates/identity-federation/aws/setup.sh +++ b/sreagent-templates/identity-federation/aws/setup.sh @@ -145,16 +145,14 @@ aws_cli_supported() { version="$(aws --version 2>&1)" || return 1 [[ "$version" =~ aws-cli/([0-9]+)\. ]] || return 1 - ((BASH_REMATCH[1] >= 2)) || return 1 - AWS_PAGER="" aws signin create-oauth2-token-with-iam \ - --generate-cli-skeleton input >/dev/null 2>&1 + ((BASH_REMATCH[1] >= 2)) } for command in az aws terraform jq; do command -v "$command" >/dev/null 2>&1 || fail "Required command not found: $command" done terraform_supported || fail "Terraform 1.7 or later is required." -aws_cli_supported || fail "AWS CLI v2 with signin create-oauth2-token-with-iam is required." +aws_cli_supported || fail "AWS CLI v2 is required." cd "$script_dir" diff --git a/sreagent-templates/identity-federation/aws/terraform.tfvars.example b/sreagent-templates/identity-federation/aws/terraform.tfvars.example deleted file mode 100644 index 17f1f0f5b..000000000 --- a/sreagent-templates/identity-federation/aws/terraform.tfvars.example +++ /dev/null @@ -1,47 +0,0 @@ -# User-assigned identity example: -managed_identity = { - type = "UserAssigned" - resource_id = "/subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/" - name = "" - tenant_id = "" - client_id = "" - principal_id = "" -} - -# System-assigned identity example: -# managed_identity = { -# type = "SystemAssigned" -# resource_id = "/subscriptions//resourceGroups//providers/Microsoft.App/agents/" -# name = "" -# tenant_id = "" -# client_id = "" -# principal_id = "" -# } - -role_name = "SREAgentRole" - -# Populated automatically by the guided setup when the role already exists. -# Each managed identity receives its own trust statement. -existing_role_trust_statements = [] - -# Add client IDs only when this Terraform state owns a shared OIDC provider. -additional_oidc_provider_audiences = [] - -# AWSMCPSignInOAuthAccessPolicy is attached automatically. Add only additional -# managed policies needed by SRE Agent operator scripts. -policy_arns = [] - -# Define separate named inline policies. The guided scripts populate this map. -# Use jsondecode(file(...)) in a .tf file; .tfvars files require literal values. -workload_policies = { - SREAgentReadOnlyAccess = { - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = "ec2:DescribeInstances" - Resource = "*" - } - ] - } -} From fb8ed21d8f9911efbe75f9296d9d4cadc8e47fcb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 27 Oct 2026 15:03:38 +0530 Subject: [PATCH 3/3] Clarify AWS identity federation setup instructions Document download steps, required AWS permissions, and guidance for reviewing and adapting workload policy JSON files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity-federation/aws/README.md | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/sreagent-templates/identity-federation/aws/README.md b/sreagent-templates/identity-federation/aws/README.md index 5ba4a7594..2da71e372 100644 --- a/sreagent-templates/identity-federation/aws/README.md +++ b/sreagent-templates/identity-federation/aws/README.md @@ -11,6 +11,7 @@ user-assigned managed identity, provide that identity's own ARM resource ID. The following tools are required: +- [Git](https://git-scm.com/downloads) to clone this repository - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) - [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) - [Terraform 1.7 or later](https://developer.hashicorp.com/terraform/install) @@ -35,20 +36,33 @@ jq --version ## Windows -### 1. Login +### 1. Download the files -Open PowerShell in the directory containing this README and the Terraform -files: +Open PowerShell 7 or later: + +```powershell +git clone https://github.com/microsoft/sre-agent.git +cd sre-agent\sreagent-templates\identity-federation\aws +``` + +### 2. Login + +From the same PowerShell window, sign in to the Azure tenant containing your +managed identity or SRE Agent, then sign in to the target AWS account: + +The AWS account permissions must allow you to create or update OIDC identity +providers and their audiences, create or update IAM roles and trust +relationships, manage inline policies, and attach managed policies. ```powershell az login aws login ``` -If your organization uses AWS SSO or another AWS authentication method, use -that instead of `aws login`. Terraform uses the active AWS credentials. +Terraform uses the active AWS credentials to configure identity federation in +that account. -### 2. Run the setup script +### 3. Run the setup script ```powershell Set-ExecutionPolicy -Scope Process Bypass @@ -72,19 +86,31 @@ For unattended execution: ## Linux or macOS -### 1. Login +### 1. Download the files + +```bash +git clone https://github.com/microsoft/sre-agent.git +cd sre-agent/sreagent-templates/identity-federation/aws +``` + +### 2. Login + +From the same Bash session, sign in to the Azure tenant containing your managed +identity or SRE Agent, then sign in to the target AWS account: -Open Bash in the directory containing this README and the Terraform files: +The AWS account permissions must allow you to create or update OIDC identity +providers and their audiences, create or update IAM roles and trust +relationships, manage inline policies, and attach managed policies. ```bash az login aws login ``` -If your organization uses AWS SSO or another AWS authentication method, use -that instead of `aws login`. Terraform uses the active AWS credentials. +Terraform uses the active AWS credentials to +configure identity federation in that account. -### 2. Run the setup script +### 3. Run the setup script ```bash chmod +x setup.sh @@ -110,8 +136,14 @@ For unattended execution: The following samples are ready-to-use, account-wide permission sets with no resource placeholders. They target common investigation and operational tasks, -not every AWS service or every possible debugging operation. They do not grant -AWS's `aidevops:*` product-management permissions. +not every AWS service or every possible debugging operation. + +> **Note:** These sample policies may not be exhaustive, and the permissions your +> workload needs may change over time. Review +> [`SREAgentReadOnlyAccess.json`](SREAgentReadOnlyAccess.json) and +> [`SREAgentWriteAccess.json`](SREAgentWriteAccess.json), and add or update +> permissions as needed before running the setup. Grant only the permissions +> required for your workload. | Sample policy | What it enables | Intended use | | --- | --- | --- |