diff --git a/tests/e2e/single/account_test.go b/tests/e2e/single/account_test.go new file mode 100644 index 000000000..2f2378d0b --- /dev/null +++ b/tests/e2e/single/account_test.go @@ -0,0 +1,1186 @@ +//go:build e2e + +package single + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// phase8Acct_AccountScoping ports run-e2e.sh Phase 8 EC2 Account Scoping +// (lines 1915–2624). Validates that EC2 resources (instances, volumes, key +// pairs, snapshots, VPCs, IGWs, EIGWs) are isolated between tenant accounts +// created via `spx admin account create`. +// +// Three admin accounts are created (Team Alpha, Team Beta, Team Gamma). All +// resources are launched through their scoped *AWSClient (statically-credentialed, +// bypasses AWS_PROFILE). A single t.Cleanup at the outer function tears down +// every resource across all three accounts regardless of which sub-step +// failed — every delete is best-effort / not-found tolerant. There is no +// `spx admin account delete` so the account rows themselves persist beyond +// the test; the carousel entry is local-only state and goes out of scope +// with the function. +// +// Sub-steps: +// +// Step1_AccountSetup — create Alpha + Beta, smoke-test auth +// Step2_InstanceScoping — instance describe + cross-account ops blocked +// Step3_VolumeScoping — volume describe + attach/detach/modify +// Step4_KeyPairScoping — key pair namespace + cross-account delete no-op +// Step5_SnapshotScoping — snapshot describe + OwnerId + delete +// Step6_VPCSubnetScoping — VPC/subnet describe + cross-account ops +// Step7_IGWEIGWScoping — IGW/EIGW describe + attach/detach +// Step8_AccountSettings — EBS-encryption-by-default per-tenant +// Step9_GlobalResources — regions/AZs/instance types identical +// Step10_EdgeCases — empty Gamma + non-existent resource ids +// Step11_Cleanup — explicit best-effort teardown (also runs via t.Cleanup) +func phase8Acct_AccountScoping(t *testing.T, fix *Fixture) { + harness.Phase(t, "Phase 8 (acct) — EC2 Account Scoping") + + require.NotEmpty(t, fix.AMIID, "Phase 4 must populate fix.AMIID") + require.NotEmpty(t, fix.InstanceType, "Phase 2 must populate fix.InstanceType") + require.NotEmpty(t, fix.AZName, "Phase 2 must populate fix.AZName") + + carousel := harness.NewAccountCarousel() + + // State threaded across Steps. The teardown below references whatever + // is populated at failure time; zero values are skipped. + var ( + alphaInst, betaInst string + alphaVol, betaVol string + alphaSnap, betaSnap string + alphaVPC, betaVPC string + alphaSubnet, betaSubnet string + alphaIGW, betaIGW string + alphaEIGW, betaEIGW string + alphaKeyNames = []string{} + betaKeyNames = []string{} + alphaEncryptionLeftEnabled bool + betaEncryptionLeftEnabled bool + ) + + // t.Cleanup runs LIFO regardless of which Step failed. Every call below + // swallows not-found errors so a partial Step 11 run + this cleanup is + // idempotent. Step 11's explicit cleanup is duplicated here intentionally: + // the bash script's `2>/dev/null || true` pattern translates to "ignore + // any error" in Go, which is exactly what these closures do. + t.Cleanup(func() { + harness.Step(t, "Phase 8 acct cleanup (deferred)") + alpha := carousel.Get("spx-team-alpha") + beta := carousel.Get("spx-team-beta") + + // Terminate instances. + if alpha != nil && alphaInst != "" { + _, _ = alpha.Client.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(alphaInst)}, + }) + } + if beta != nil && betaInst != "" { + _, _ = beta.Client.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(betaInst)}, + }) + } + // Wait for termination so volumes / snapshots release cleanly. + if alpha != nil && alphaInst != "" { + waitTerminated(t, alpha.Client, alphaInst) + } + if beta != nil && betaInst != "" { + waitTerminated(t, beta.Client, betaInst) + } + + // Snapshots. + if alpha != nil && alphaSnap != "" { + _, _ = alpha.Client.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: aws.String(alphaSnap), + }) + } + if beta != nil && betaSnap != "" { + _, _ = beta.Client.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: aws.String(betaSnap), + }) + } + + // Volumes (give a moment for detach-on-terminate to settle). + time.Sleep(3 * time.Second) + if alpha != nil && alphaVol != "" { + _, _ = alpha.Client.EC2.DeleteVolume(&ec2.DeleteVolumeInput{ + VolumeId: aws.String(alphaVol), + }) + } + if beta != nil && betaVol != "" { + _, _ = beta.Client.EC2.DeleteVolume(&ec2.DeleteVolumeInput{ + VolumeId: aws.String(betaVol), + }) + } + + // Key pairs. + if alpha != nil { + for _, k := range alphaKeyNames { + _, _ = alpha.Client.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{ + KeyName: aws.String(k), + }) + } + } + if beta != nil { + for _, k := range betaKeyNames { + _, _ = beta.Client.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{ + KeyName: aws.String(k), + }) + } + } + + // EIGWs. + if alpha != nil && alphaEIGW != "" { + _, _ = alpha.Client.EC2.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: aws.String(alphaEIGW), + }) + } + if beta != nil && betaEIGW != "" { + _, _ = beta.Client.EC2.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: aws.String(betaEIGW), + }) + } + + // IGWs (must detach before delete). + if alpha != nil && alphaIGW != "" { + if alphaVPC != "" { + _, _ = alpha.Client.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + VpcId: aws.String(alphaVPC), + }) + } + _, _ = alpha.Client.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + }) + } + if beta != nil && betaIGW != "" { + _, _ = beta.Client.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{ + InternetGatewayId: aws.String(betaIGW), + }) + } + + // Subnets. + if alpha != nil && alphaSubnet != "" { + _, _ = alpha.Client.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{ + SubnetId: aws.String(alphaSubnet), + }) + } + if beta != nil && betaSubnet != "" { + _, _ = beta.Client.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{ + SubnetId: aws.String(betaSubnet), + }) + } + + // VPCs. + if alpha != nil && alphaVPC != "" { + _, _ = alpha.Client.EC2.DeleteVpc(&ec2.DeleteVpcInput{ + VpcId: aws.String(alphaVPC), + }) + } + if beta != nil && betaVPC != "" { + _, _ = beta.Client.EC2.DeleteVpc(&ec2.DeleteVpcInput{ + VpcId: aws.String(betaVPC), + }) + } + + // Reset EBS encryption defaults if Step 8 left them on. + if alpha != nil && alphaEncryptionLeftEnabled { + _, _ = alpha.Client.EC2.DisableEbsEncryptionByDefault(&ec2.DisableEbsEncryptionByDefaultInput{}) + } + if beta != nil && betaEncryptionLeftEnabled { + _, _ = beta.Client.EC2.DisableEbsEncryptionByDefault(&ec2.DisableEbsEncryptionByDefaultInput{}) + } + }) + + // --------------------------------------------------------------------- + // Step 1: Account Setup (bash 1928–1965) + // --------------------------------------------------------------------- + t.Run("Step1_AccountSetup", func(t *testing.T) { + harness.Step(t, "create account 'Team Alpha'") + alphaInfo := harness.SpxAdminAccountCreate(t, "Team Alpha", "") + require.NotEmpty(t, alphaInfo.AccountID, "alpha AccountID") + require.NotEmpty(t, alphaInfo.AccessKeyID, "alpha AccessKeyID") + alpha := carousel.Add(t, fix.Env, "spx-team-alpha", alphaInfo) + harness.Detail(t, "alpha_account", alphaInfo.AccountID, "alpha_key", alphaInfo.AccessKeyID) + + harness.Step(t, "create account 'Team Beta'") + betaInfo := harness.SpxAdminAccountCreate(t, "Team Beta", "") + require.NotEmpty(t, betaInfo.AccountID, "beta AccountID") + require.NotEmpty(t, betaInfo.AccessKeyID, "beta AccessKeyID") + beta := carousel.Add(t, fix.Env, "spx-team-beta", betaInfo) + harness.Detail(t, "beta_account", betaInfo.AccountID, "beta_key", betaInfo.AccessKeyID) + + // Auth smoke-test — bash just gates on exit 0. + _, err := alpha.Client.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "alpha describe-instances (auth check)") + _, err = beta.Client.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "beta describe-instances (auth check)") + }) + + // Bail out if Step 1 didn't seat both profiles — every subsequent step + // derefs them. Step 11 still runs via t.Cleanup with whatever state exists. + alpha := carousel.Get("spx-team-alpha") + beta := carousel.Get("spx-team-beta") + if alpha == nil || beta == nil { + t.Fatalf("Step 1 did not register both alpha and beta carousel entries") + } + + // --------------------------------------------------------------------- + // Step 2: Instance Scoping (bash 1969–2088) + // --------------------------------------------------------------------- + t.Run("Step2_InstanceScoping", func(t *testing.T) { + const alphaInstKey = "alpha-instance-key" + const betaInstKey = "beta-instance-key" + + harness.Step(t, "create per-account key pairs") + _, err := alpha.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(alphaInstKey)}) + require.NoError(t, err, "alpha create-key-pair") + alphaKeyNames = append(alphaKeyNames, alphaInstKey) + _, err = beta.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(betaInstKey)}) + require.NoError(t, err, "beta create-key-pair") + betaKeyNames = append(betaKeyNames, betaInstKey) + + harness.Step(t, "alpha run-instances") + alphaRun, err := alpha.Client.EC2.RunInstances(&ec2.RunInstancesInput{ + ImageId: aws.String(fix.AMIID), + InstanceType: aws.String(fix.InstanceType), + KeyName: aws.String(alphaInstKey), + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + }) + require.NoError(t, err, "alpha run-instances") + require.NotEmpty(t, alphaRun.Instances, "alpha run-instances: no instances") + alphaInst = aws.StringValue(alphaRun.Instances[0].InstanceId) + require.NotEmpty(t, alphaInst, "alpha InstanceId empty") + harness.Detail(t, "alpha_instance", alphaInst) + + harness.Step(t, "beta run-instances") + betaRun, err := beta.Client.EC2.RunInstances(&ec2.RunInstancesInput{ + ImageId: aws.String(fix.AMIID), + InstanceType: aws.String(fix.InstanceType), + KeyName: aws.String(betaInstKey), + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + }) + require.NoError(t, err, "beta run-instances") + require.NotEmpty(t, betaRun.Instances, "beta run-instances: no instances") + betaInst = aws.StringValue(betaRun.Instances[0].InstanceId) + require.NotEmpty(t, betaInst, "beta InstanceId empty") + harness.Detail(t, "beta_instance", betaInst) + + harness.WaitForInstanceState(t, alpha.Client, alphaInst, "running") + harness.WaitForInstanceState(t, beta.Client, betaInst, "running") + + harness.Step(t, "alpha sees only own instances") + alphaIDs := describeInstanceIDs(t, alpha.Client) + assert.NotContains(t, alphaIDs, betaInst, "alpha saw beta's instance") + + harness.Step(t, "beta sees only own instances") + betaIDs := describeInstanceIDs(t, beta.Client) + assert.NotContains(t, betaIDs, alphaInst, "beta saw alpha's instance") + + harness.Step(t, "alpha OwnerId matches alpha account") + alphaDesc, err := alpha.Client.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "alpha describe-instances") + require.NotEmpty(t, alphaDesc.Reservations, "alpha reservations empty") + assert.Equal(t, alpha.AccountID, aws.StringValue(alphaDesc.Reservations[0].OwnerId), + "alpha OwnerId mismatch") + + harness.Step(t, "cross-account stop alpha->beta blocked") + harness.ExpectError(t, "InvalidInstanceID.NotFound", func() error { + _, err := alpha.Client.EC2.StopInstances(&ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(betaInst)}, + }) + return err + }) + + harness.Step(t, "cross-account terminate beta->alpha blocked") + harness.ExpectError(t, "InvalidInstanceID.NotFound", func() error { + _, err := beta.Client.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(alphaInst)}, + }) + return err + }) + + harness.Step(t, "cross-account reboot alpha->beta blocked") + harness.ExpectError(t, "InvalidInstanceID.NotFound", func() error { + _, err := alpha.Client.EC2.RebootInstances(&ec2.RebootInstancesInput{ + InstanceIds: []*string{aws.String(betaInst)}, + }) + return err + }) + + // Stop alpha for cross-account start/modify/console assertions. + harness.Step(t, "stop alpha instance for cross-account start/modify/console tests") + _, err = alpha.Client.EC2.StopInstances(&ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(alphaInst)}, + }) + require.NoError(t, err, "alpha stop-instances") + harness.WaitForInstanceState(t, alpha.Client, alphaInst, "stopped") + + harness.Step(t, "cross-account start beta->alpha blocked") + harness.ExpectError(t, "InvalidInstanceID.NotFound", func() error { + _, err := beta.Client.EC2.StartInstances(&ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(alphaInst)}, + }) + return err + }) + + harness.Step(t, "cross-account modify-instance-attribute beta->alpha blocked") + harness.ExpectError(t, "InvalidInstanceID.NotFound", func() error { + _, err := beta.Client.EC2.ModifyInstanceAttribute(&ec2.ModifyInstanceAttributeInput{ + InstanceId: aws.String(alphaInst), + InstanceType: &ec2.AttributeValue{Value: aws.String("t2.small")}, + }) + return err + }) + + harness.Step(t, "cross-account get-console-output beta->alpha blocked") + harness.ExpectError(t, "InvalidInstanceID.NotFound", func() error { + _, err := beta.Client.EC2.GetConsoleOutput(&ec2.GetConsoleOutputInput{ + InstanceId: aws.String(alphaInst), + }) + return err + }) + + // Restart alpha for Step 3 (attach test needs it running). + harness.Step(t, "restart alpha instance") + _, err = alpha.Client.EC2.StartInstances(&ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(alphaInst)}, + }) + require.NoError(t, err, "alpha start-instances") + harness.WaitForInstanceState(t, alpha.Client, alphaInst, "running") + }) + + // --------------------------------------------------------------------- + // Step 3: Volume Scoping (bash 2092–2146) + // --------------------------------------------------------------------- + t.Run("Step3_VolumeScoping", func(t *testing.T) { + harness.Step(t, "alpha create-volume") + av, err := alpha.Client.EC2.CreateVolume(&ec2.CreateVolumeInput{ + AvailabilityZone: aws.String(fix.AZName), + Size: aws.Int64(10), + VolumeType: aws.String("gp3"), + }) + require.NoError(t, err, "alpha create-volume") + alphaVol = aws.StringValue(av.VolumeId) + require.NotEmpty(t, alphaVol) + harness.Detail(t, "alpha_vol", alphaVol) + + harness.Step(t, "beta create-volume") + bv, err := beta.Client.EC2.CreateVolume(&ec2.CreateVolumeInput{ + AvailabilityZone: aws.String(fix.AZName), + Size: aws.Int64(10), + VolumeType: aws.String("gp3"), + }) + require.NoError(t, err, "beta create-volume") + betaVol = aws.StringValue(bv.VolumeId) + require.NotEmpty(t, betaVol) + harness.Detail(t, "beta_vol", betaVol) + + harness.WaitForVolumeState(t, alpha.Client, alphaVol, "available") + harness.WaitForVolumeState(t, beta.Client, betaVol, "available") + + harness.Step(t, "alpha sees only own volumes") + alphaVols := describeVolumeIDs(t, alpha.Client) + assert.NotContains(t, alphaVols, betaVol, "alpha saw beta's volume") + + harness.Step(t, "cross-account describe-volume by id blocked") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := alpha.Client.EC2.DescribeVolumes(&ec2.DescribeVolumesInput{ + VolumeIds: []*string{aws.String(betaVol)}, + }) + return err + }) + + harness.Step(t, "cross-account delete-volume blocked") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := beta.Client.EC2.DeleteVolume(&ec2.DeleteVolumeInput{ + VolumeId: aws.String(alphaVol), + }) + return err + }) + + harness.Step(t, "cross-account attach-volume (beta attaches alpha's vol) blocked") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := beta.Client.EC2.AttachVolume(&ec2.AttachVolumeInput{ + VolumeId: aws.String(alphaVol), + InstanceId: aws.String(betaInst), + Device: aws.String("/dev/sdf"), + }) + return err + }) + + // Attach alpha's volume to alpha's instance, then test cross-account detach. + harness.Step(t, "alpha attach-volume to own instance") + _, err = alpha.Client.EC2.AttachVolume(&ec2.AttachVolumeInput{ + VolumeId: aws.String(alphaVol), + InstanceId: aws.String(alphaInst), + Device: aws.String("/dev/sdf"), + }) + require.NoError(t, err, "alpha attach-volume") + harness.WaitForVolumeState(t, alpha.Client, alphaVol, "in-use") + + harness.Step(t, "cross-account detach-volume blocked") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := beta.Client.EC2.DetachVolume(&ec2.DetachVolumeInput{ + VolumeId: aws.String(alphaVol), + }) + return err + }) + + harness.Step(t, "cross-account modify-volume blocked") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := beta.Client.EC2.ModifyVolume(&ec2.ModifyVolumeInput{ + VolumeId: aws.String(alphaVol), + Size: aws.Int64(20), + }) + return err + }) + + // Detach so Step 11 / cleanup can delete the volume cleanly. + harness.Step(t, "alpha detach own volume") + _, err = alpha.Client.EC2.DetachVolume(&ec2.DetachVolumeInput{ + VolumeId: aws.String(alphaVol), + }) + require.NoError(t, err, "alpha detach-volume") + harness.WaitForVolumeState(t, alpha.Client, alphaVol, "available") + }) + + // --------------------------------------------------------------------- + // Step 4: Key Pair Scoping (bash 2150–2206) + // --------------------------------------------------------------------- + t.Run("Step4_KeyPairScoping", func(t *testing.T) { + const alphaKey = "alpha-key" + const betaKey = "beta-key" + const sharedKey = "shared-name" + const importedKey = "imported-key" + + harness.Step(t, "alpha create-key-pair %s", alphaKey) + _, err := alpha.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(alphaKey)}) + require.NoError(t, err, "alpha create-key-pair %s", alphaKey) + alphaKeyNames = append(alphaKeyNames, alphaKey) + alphaKeyID := describeKeyPairID(t, alpha.Client, alphaKey) + require.NotEmpty(t, alphaKeyID, "alpha key-pair id") + + harness.Step(t, "beta create-key-pair %s", betaKey) + _, err = beta.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(betaKey)}) + require.NoError(t, err, "beta create-key-pair %s", betaKey) + betaKeyNames = append(betaKeyNames, betaKey) + + harness.Step(t, "alpha sees only own keys") + alphaKeys := describeKeyPairNames(t, alpha.Client) + assert.NotContains(t, alphaKeys, betaKey, "alpha saw beta's key") + + // Same name in both accounts — different KeyPairIds. + harness.Step(t, "namespace isolation: %s in both accounts", sharedKey) + _, err = alpha.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(sharedKey)}) + require.NoError(t, err, "alpha create-key-pair %s", sharedKey) + alphaKeyNames = append(alphaKeyNames, sharedKey) + _, err = beta.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(sharedKey)}) + require.NoError(t, err, "beta create-key-pair %s", sharedKey) + betaKeyNames = append(betaKeyNames, sharedKey) + alphaShared := describeKeyPairID(t, alpha.Client, sharedKey) + betaShared := describeKeyPairID(t, beta.Client, sharedKey) + require.NotEmpty(t, alphaShared, "alpha shared key id") + require.NotEmpty(t, betaShared, "beta shared key id") + assert.NotEqual(t, alphaShared, betaShared, "same KeyPairId across accounts") + + // Cross-account delete is idempotent (no error) but must not affect the other account. + harness.Step(t, "beta deletes alpha-key (idempotent, no cross-account effect)") + _, _ = beta.Client.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(alphaKey)}) + assert.Equal(t, alphaKeyID, describeKeyPairID(t, alpha.Client, alphaKey), + "beta's delete affected alpha's key") + + // Import key pair is also account-scoped. + harness.Step(t, "alpha import-key-pair %s", importedKey) + _, err = alpha.Client.EC2.ImportKeyPair(&ec2.ImportKeyPairInput{ + KeyName: aws.String(importedKey), + PublicKeyMaterial: generateImportPubKey(t), + }) + require.NoError(t, err, "alpha import-key-pair") + alphaKeyNames = append(alphaKeyNames, importedKey) + + betaKeys := describeKeyPairNames(t, beta.Client) + assert.NotContains(t, betaKeys, importedKey, "beta saw alpha's imported key") + }) + + // --------------------------------------------------------------------- + // Step 5: Snapshot Scoping (bash 2210–2250) + // --------------------------------------------------------------------- + t.Run("Step5_SnapshotScoping", func(t *testing.T) { + require.NotEmpty(t, alphaVol, "Step 3 must populate alphaVol") + require.NotEmpty(t, betaVol, "Step 3 must populate betaVol") + + harness.Step(t, "alpha create-snapshot") + as, err := alpha.Client.EC2.CreateSnapshot(&ec2.CreateSnapshotInput{ + VolumeId: aws.String(alphaVol), + Description: aws.String("Alpha snapshot"), + }) + require.NoError(t, err, "alpha create-snapshot") + alphaSnap = aws.StringValue(as.SnapshotId) + require.NotEmpty(t, alphaSnap) + harness.Detail(t, "alpha_snap", alphaSnap) + + harness.Step(t, "beta create-snapshot") + bs, err := beta.Client.EC2.CreateSnapshot(&ec2.CreateSnapshotInput{ + VolumeId: aws.String(betaVol), + Description: aws.String("Beta snapshot"), + }) + require.NoError(t, err, "beta create-snapshot") + betaSnap = aws.StringValue(bs.SnapshotId) + require.NotEmpty(t, betaSnap) + harness.Detail(t, "beta_snap", betaSnap) + + harness.Step(t, "alpha sees only own snapshots (owner-ids=self)") + alphaSnaps, err := alpha.Client.EC2.DescribeSnapshots(&ec2.DescribeSnapshotsInput{ + OwnerIds: []*string{aws.String("self")}, + }) + require.NoError(t, err, "alpha describe-snapshots") + var alphaIDs []string + for _, s := range alphaSnaps.Snapshots { + alphaIDs = append(alphaIDs, aws.StringValue(s.SnapshotId)) + } + assert.NotContains(t, alphaIDs, betaSnap, "alpha saw beta's snapshot") + + // OwnerId verification. + require.NotEmpty(t, alphaSnaps.Snapshots, "alpha snapshots empty") + assert.Equal(t, alpha.AccountID, aws.StringValue(alphaSnaps.Snapshots[0].OwnerId), + "alpha snapshot OwnerId mismatch") + + // Cross-account delete — bash expects UnauthorizedOperation (NOT the + // usual NotFound). This is a Snapshot-specific gateway behavior. + harness.Step(t, "cross-account delete-snapshot blocked (UnauthorizedOperation)") + harness.ExpectError(t, "UnauthorizedOperation", func() error { + _, err := beta.Client.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: aws.String(alphaSnap), + }) + return err + }) + + harness.Step(t, "cross-account create-snapshot of alpha's volume blocked") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := beta.Client.EC2.CreateSnapshot(&ec2.CreateSnapshotInput{ + VolumeId: aws.String(alphaVol), + Description: aws.String("stolen"), + }) + return err + }) + }) + + // --------------------------------------------------------------------- + // Step 6: VPC/Subnet Scoping (bash 2254–2313) + // --------------------------------------------------------------------- + t.Run("Step6_VPCSubnetScoping", func(t *testing.T) { + harness.Step(t, "alpha create-vpc 10.0.0.0/16") + av, err := alpha.Client.EC2.CreateVpc(&ec2.CreateVpcInput{ + CidrBlock: aws.String("10.0.0.0/16"), + }) + require.NoError(t, err, "alpha create-vpc") + alphaVPC = aws.StringValue(av.Vpc.VpcId) + require.NotEmpty(t, alphaVPC) + harness.Detail(t, "alpha_vpc", alphaVPC) + + harness.Step(t, "beta create-vpc 10.0.0.0/16 (same CIDR, no conflict)") + bv, err := beta.Client.EC2.CreateVpc(&ec2.CreateVpcInput{ + CidrBlock: aws.String("10.0.0.0/16"), + }) + require.NoError(t, err, "beta create-vpc") + betaVPC = aws.StringValue(bv.Vpc.VpcId) + require.NotEmpty(t, betaVPC) + harness.Detail(t, "beta_vpc", betaVPC) + + harness.Step(t, "alpha describe-vpcs isolation") + alphaVPCs, err := alpha.Client.EC2.DescribeVpcs(&ec2.DescribeVpcsInput{}) + require.NoError(t, err, "alpha describe-vpcs") + assert.NotContains(t, vpcIDs(alphaVPCs.Vpcs), betaVPC, "alpha saw beta's VPC") + + harness.Step(t, "cross-account describe-vpc by id blocked") + harness.ExpectError(t, "InvalidVpcID.NotFound", func() error { + _, err := alpha.Client.EC2.DescribeVpcs(&ec2.DescribeVpcsInput{ + VpcIds: []*string{aws.String(betaVPC)}, + }) + return err + }) + + harness.Step(t, "cross-account delete-vpc blocked") + harness.ExpectError(t, "InvalidVpcID.NotFound", func() error { + _, err := beta.Client.EC2.DeleteVpc(&ec2.DeleteVpcInput{ + VpcId: aws.String(alphaVPC), + }) + return err + }) + + harness.Step(t, "alpha create-subnet 10.0.1.0/24") + as, err := alpha.Client.EC2.CreateSubnet(&ec2.CreateSubnetInput{ + VpcId: aws.String(alphaVPC), + CidrBlock: aws.String("10.0.1.0/24"), + }) + require.NoError(t, err, "alpha create-subnet") + alphaSubnet = aws.StringValue(as.Subnet.SubnetId) + require.NotEmpty(t, alphaSubnet) + + harness.Step(t, "beta create-subnet 10.0.1.0/24") + bs, err := beta.Client.EC2.CreateSubnet(&ec2.CreateSubnetInput{ + VpcId: aws.String(betaVPC), + CidrBlock: aws.String("10.0.1.0/24"), + }) + require.NoError(t, err, "beta create-subnet") + betaSubnet = aws.StringValue(bs.Subnet.SubnetId) + require.NotEmpty(t, betaSubnet) + + harness.Step(t, "alpha describe-subnets isolation") + alphaSubnets, err := alpha.Client.EC2.DescribeSubnets(&ec2.DescribeSubnetsInput{}) + require.NoError(t, err, "alpha describe-subnets") + var alphaSubnetIDs []string + for _, s := range alphaSubnets.Subnets { + alphaSubnetIDs = append(alphaSubnetIDs, aws.StringValue(s.SubnetId)) + } + assert.NotContains(t, alphaSubnetIDs, betaSubnet, "alpha saw beta's subnet") + + harness.Step(t, "cross-account create-subnet in other VPC blocked") + harness.ExpectError(t, "InvalidVpcID.NotFound", func() error { + _, err := beta.Client.EC2.CreateSubnet(&ec2.CreateSubnetInput{ + VpcId: aws.String(alphaVPC), + CidrBlock: aws.String("10.0.2.0/24"), + }) + return err + }) + + harness.Step(t, "cross-account delete-subnet blocked") + harness.ExpectError(t, "InvalidSubnetID.NotFound", func() error { + _, err := beta.Client.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{ + SubnetId: aws.String(alphaSubnet), + }) + return err + }) + }) + + // --------------------------------------------------------------------- + // Step 7: IGW + EIGW Scoping (bash 2317–2403) + // --------------------------------------------------------------------- + t.Run("Step7_IGWEIGWScoping", func(t *testing.T) { + require.NotEmpty(t, alphaVPC, "Step 6 must populate alphaVPC") + require.NotEmpty(t, betaVPC, "Step 6 must populate betaVPC") + + harness.Step(t, "alpha create-internet-gateway") + ai, err := alpha.Client.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) + require.NoError(t, err, "alpha create-internet-gateway") + alphaIGW = aws.StringValue(ai.InternetGateway.InternetGatewayId) + require.NotEmpty(t, alphaIGW) + harness.Detail(t, "alpha_igw", alphaIGW) + + harness.Step(t, "beta create-internet-gateway") + bi, err := beta.Client.EC2.CreateInternetGateway(&ec2.CreateInternetGatewayInput{}) + require.NoError(t, err, "beta create-internet-gateway") + betaIGW = aws.StringValue(bi.InternetGateway.InternetGatewayId) + require.NotEmpty(t, betaIGW) + harness.Detail(t, "beta_igw", betaIGW) + + harness.Step(t, "alpha describe-internet-gateways isolation") + alphaIGWs, err := alpha.Client.EC2.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{}) + require.NoError(t, err, "alpha describe-internet-gateways") + var alphaIGWIDs []string + for _, ig := range alphaIGWs.InternetGateways { + alphaIGWIDs = append(alphaIGWIDs, aws.StringValue(ig.InternetGatewayId)) + } + assert.NotContains(t, alphaIGWIDs, betaIGW, "alpha saw beta's IGW") + + harness.Step(t, "cross-account describe IGW by id blocked") + harness.ExpectError(t, "InvalidInternetGatewayID.NotFound", func() error { + _, err := alpha.Client.EC2.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{ + InternetGatewayIds: []*string{aws.String(betaIGW)}, + }) + return err + }) + + harness.Step(t, "cross-account delete IGW blocked") + harness.ExpectError(t, "InvalidInternetGatewayID.NotFound", func() error { + _, err := beta.Client.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + }) + return err + }) + + harness.Step(t, "cross-account attach IGW (alpha attaches beta's IGW) blocked") + harness.ExpectError(t, "InvalidInternetGatewayID.NotFound", func() error { + _, err := alpha.Client.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{ + InternetGatewayId: aws.String(betaIGW), + VpcId: aws.String(alphaVPC), + }) + return err + }) + + // Attach alpha's IGW to alpha's VPC, then verify beta can't detach. + harness.Step(t, "alpha attach own IGW to own VPC") + _, err = alpha.Client.EC2.AttachInternetGateway(&ec2.AttachInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + VpcId: aws.String(alphaVPC), + }) + require.NoError(t, err, "alpha attach-internet-gateway") + + harness.Step(t, "cross-account detach IGW blocked") + harness.ExpectError(t, "InvalidInternetGatewayID.NotFound", func() error { + _, err := beta.Client.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + VpcId: aws.String(alphaVPC), + }) + return err + }) + + // EIGW + harness.Step(t, "alpha create-egress-only-internet-gateway") + ae, err := alpha.Client.EC2.CreateEgressOnlyInternetGateway(&ec2.CreateEgressOnlyInternetGatewayInput{ + VpcId: aws.String(alphaVPC), + }) + require.NoError(t, err, "alpha create-eigw") + alphaEIGW = aws.StringValue(ae.EgressOnlyInternetGateway.EgressOnlyInternetGatewayId) + require.NotEmpty(t, alphaEIGW) + harness.Detail(t, "alpha_eigw", alphaEIGW) + + harness.Step(t, "beta create-egress-only-internet-gateway") + be, err := beta.Client.EC2.CreateEgressOnlyInternetGateway(&ec2.CreateEgressOnlyInternetGatewayInput{ + VpcId: aws.String(betaVPC), + }) + require.NoError(t, err, "beta create-eigw") + betaEIGW = aws.StringValue(be.EgressOnlyInternetGateway.EgressOnlyInternetGatewayId) + require.NotEmpty(t, betaEIGW) + harness.Detail(t, "beta_eigw", betaEIGW) + + harness.Step(t, "alpha describe-eigws isolation") + alphaEIGWs, err := alpha.Client.EC2.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{}) + require.NoError(t, err, "alpha describe-eigws") + var alphaEIGWIDs []string + for _, ig := range alphaEIGWs.EgressOnlyInternetGateways { + alphaEIGWIDs = append(alphaEIGWIDs, aws.StringValue(ig.EgressOnlyInternetGatewayId)) + } + assert.NotContains(t, alphaEIGWIDs, betaEIGW, "alpha saw beta's EIGW") + + // Cross-account EIGW delete — bash uses `expect_error "" ... || true` + // (any error or none is acceptable). The hard assertion is that + // alpha's EIGW survives. + _, _ = beta.Client.EC2.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: aws.String(alphaEIGW), + }) + check, err := alpha.Client.EC2.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{}) + require.NoError(t, err, "alpha describe-eigws (post cross-account delete attempt)") + var stillThere bool + for _, ig := range check.EgressOnlyInternetGateways { + if aws.StringValue(ig.EgressOnlyInternetGatewayId) == alphaEIGW { + stillThere = true + break + } + } + assert.True(t, stillThere, "alpha's EIGW was deleted by beta") + + // Cross-account EIGW creation in other's VPC — bash also accepts any + // error or none; the existence of alpha's EIGW is the contract. + _, _ = beta.Client.EC2.CreateEgressOnlyInternetGateway(&ec2.CreateEgressOnlyInternetGatewayInput{ + VpcId: aws.String(alphaVPC), + }) + }) + + // --------------------------------------------------------------------- + // Step 8: Account Settings (bash 2407–2435) + // --------------------------------------------------------------------- + t.Run("Step8_AccountSettings", func(t *testing.T) { + harness.Step(t, "alpha enable-ebs-encryption-by-default") + _, err := alpha.Client.EC2.EnableEbsEncryptionByDefault(&ec2.EnableEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "alpha enable-ebs-encryption") + alphaEncryptionLeftEnabled = true + + betaEnc, err := beta.Client.EC2.GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "beta get-ebs-encryption") + assert.False(t, aws.BoolValue(betaEnc.EbsEncryptionByDefault), + "alpha's encryption setting leaked to beta") + + // Independent toggle: enable beta, disable alpha. + _, err = beta.Client.EC2.EnableEbsEncryptionByDefault(&ec2.EnableEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "beta enable-ebs-encryption") + betaEncryptionLeftEnabled = true + _, err = alpha.Client.EC2.DisableEbsEncryptionByDefault(&ec2.DisableEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "alpha disable-ebs-encryption") + alphaEncryptionLeftEnabled = false + + alphaEnc, err := alpha.Client.EC2.GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "alpha get-ebs-encryption") + betaEnc, err = beta.Client.EC2.GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "beta get-ebs-encryption") + assert.False(t, aws.BoolValue(alphaEnc.EbsEncryptionByDefault), "alpha encryption should be off") + assert.True(t, aws.BoolValue(betaEnc.EbsEncryptionByDefault), "beta encryption should be on") + + // Reset beta — bash explicitly disables before moving on. + _, err = beta.Client.EC2.DisableEbsEncryptionByDefault(&ec2.DisableEbsEncryptionByDefaultInput{}) + require.NoError(t, err, "beta disable-ebs-encryption (reset)") + betaEncryptionLeftEnabled = false + }) + + // --------------------------------------------------------------------- + // Step 9: Global Resources (bash 2439–2472) + // --------------------------------------------------------------------- + t.Run("Step9_GlobalResources", func(t *testing.T) { + harness.Step(t, "describe-regions identical across accounts") + alphaRegions := describeRegionNames(t, alpha.Client) + betaRegions := describeRegionNames(t, beta.Client) + assert.Equal(t, alphaRegions, betaRegions, "regions differ between accounts") + + harness.Step(t, "describe-availability-zones identical across accounts") + alphaAZs := describeAZNames(t, alpha.Client) + betaAZs := describeAZNames(t, beta.Client) + assert.Equal(t, alphaAZs, betaAZs, "AZs differ between accounts") + + harness.Step(t, "describe-instance-types identical across accounts") + alphaTypes := describeInstanceTypeNames(t, alpha.Client) + betaTypes := describeInstanceTypeNames(t, beta.Client) + assert.Equal(t, alphaTypes, betaTypes, "instance types differ between accounts") + }) + + // --------------------------------------------------------------------- + // Step 10: Edge Cases (bash 2476–2542) + // --------------------------------------------------------------------- + t.Run("Step10_EdgeCases", func(t *testing.T) { + harness.Step(t, "create empty Gamma account") + gammaInfo := harness.SpxAdminAccountCreate(t, "Team Gamma", "") + gamma := carousel.Add(t, fix.Env, "spx-team-gamma", gammaInfo) + harness.Detail(t, "gamma_account", gammaInfo.AccountID) + + gInsts, err := gamma.Client.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "gamma describe-instances") + var gInstIDs []string + for _, r := range gInsts.Reservations { + for _, i := range r.Instances { + gInstIDs = append(gInstIDs, aws.StringValue(i.InstanceId)) + } + } + assert.Empty(t, gInstIDs, "gamma has instances") + + // Volume check is skipped — bash comments "root-account volumes + // (empty TenantID) are visible to all accounts by design". + + gKeys := describeKeyPairNames(t, gamma.Client) + assert.Empty(t, gKeys, "gamma has key pairs") + + gSnaps, err := gamma.Client.EC2.DescribeSnapshots(&ec2.DescribeSnapshotsInput{ + OwnerIds: []*string{aws.String("self")}, + }) + require.NoError(t, err, "gamma describe-snapshots") + assert.Empty(t, gSnaps.Snapshots, "gamma has snapshots") + + // Root isolation: a key pair created on the root profile must not + // appear under alpha. Also verify root cannot see alpha's instance. + const rootKey = "root-scoping-key" + harness.Step(t, "root create-key-pair %s", rootKey) + _, err = fix.AWS.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(rootKey)}) + require.NoError(t, err, "root create-key-pair %s", rootKey) + t.Cleanup(func() { + _, _ = fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(rootKey)}) + }) + + alphaKeys := describeKeyPairNames(t, alpha.Client) + assert.NotContains(t, alphaKeys, rootKey, "alpha saw root's key pair") + + rootInsts := describeInstanceIDs(t, fix.AWS) + assert.NotContains(t, rootInsts, alphaInst, "root saw tenant instance") + + _, _ = fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(rootKey)}) + + // Non-existent ids — same error code as cross-account. + harness.Step(t, "non-existent volume id -> InvalidVolume.NotFound") + harness.ExpectError(t, "InvalidVolume.NotFound", func() error { + _, err := alpha.Client.EC2.DeleteVolume(&ec2.DeleteVolumeInput{ + VolumeId: aws.String("vol-00000000000000000"), + }) + return err + }) + + harness.Step(t, "non-existent snapshot id -> InvalidSnapshot.NotFound") + harness.ExpectError(t, "InvalidSnapshot.NotFound", func() error { + _, err := alpha.Client.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: aws.String("snap-00000000000000000"), + }) + return err + }) + }) + + // --------------------------------------------------------------------- + // Step 11: EC2 Account Scoping Cleanup (bash 2546–2624) + // --------------------------------------------------------------------- + // Bash performs an explicit ordered teardown here. We do the same — but + // the outer t.Cleanup also runs (idempotently) so a Step 11 failure + // won't leak. Keeping Step 11 in-line preserves the bash structure and + // the explicit assertions a triager expects when reading the log. + t.Run("Step11_Cleanup", func(t *testing.T) { + harness.Step(t, "terminate alpha instance") + if alphaInst != "" { + _, _ = alpha.Client.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(alphaInst)}, + }) + } + harness.Step(t, "terminate beta instance") + if betaInst != "" { + _, _ = beta.Client.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(betaInst)}, + }) + } + if alphaInst != "" { + waitTerminated(t, alpha.Client, alphaInst) + } + if betaInst != "" { + waitTerminated(t, beta.Client, betaInst) + } + + harness.Step(t, "delete snapshots") + if alphaSnap != "" { + _, _ = alpha.Client.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: aws.String(alphaSnap), + }) + alphaSnap = "" + } + if betaSnap != "" { + _, _ = beta.Client.EC2.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: aws.String(betaSnap), + }) + betaSnap = "" + } + + time.Sleep(3 * time.Second) + + harness.Step(t, "delete volumes") + if alphaVol != "" { + _, _ = alpha.Client.EC2.DeleteVolume(&ec2.DeleteVolumeInput{ + VolumeId: aws.String(alphaVol), + }) + alphaVol = "" + } + if betaVol != "" { + _, _ = beta.Client.EC2.DeleteVolume(&ec2.DeleteVolumeInput{ + VolumeId: aws.String(betaVol), + }) + betaVol = "" + } + + harness.Step(t, "delete key pairs") + for _, k := range alphaKeyNames { + _, _ = alpha.Client.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(k)}) + } + alphaKeyNames = nil + for _, k := range betaKeyNames { + _, _ = beta.Client.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(k)}) + } + betaKeyNames = nil + + harness.Step(t, "delete EIGWs") + if alphaEIGW != "" { + _, _ = alpha.Client.EC2.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: aws.String(alphaEIGW), + }) + alphaEIGW = "" + } + if betaEIGW != "" { + _, _ = beta.Client.EC2.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: aws.String(betaEIGW), + }) + betaEIGW = "" + } + + harness.Step(t, "detach + delete IGWs") + if alphaIGW != "" { + if alphaVPC != "" { + _, _ = alpha.Client.EC2.DetachInternetGateway(&ec2.DetachInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + VpcId: aws.String(alphaVPC), + }) + } + _, _ = alpha.Client.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{ + InternetGatewayId: aws.String(alphaIGW), + }) + alphaIGW = "" + } + if betaIGW != "" { + _, _ = beta.Client.EC2.DeleteInternetGateway(&ec2.DeleteInternetGatewayInput{ + InternetGatewayId: aws.String(betaIGW), + }) + betaIGW = "" + } + + harness.Step(t, "delete subnets") + if alphaSubnet != "" { + _, _ = alpha.Client.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{ + SubnetId: aws.String(alphaSubnet), + }) + alphaSubnet = "" + } + if betaSubnet != "" { + _, _ = beta.Client.EC2.DeleteSubnet(&ec2.DeleteSubnetInput{ + SubnetId: aws.String(betaSubnet), + }) + betaSubnet = "" + } + + harness.Step(t, "delete VPCs") + if alphaVPC != "" { + _, _ = alpha.Client.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(alphaVPC)}) + alphaVPC = "" + } + if betaVPC != "" { + _, _ = beta.Client.EC2.DeleteVpc(&ec2.DeleteVpcInput{VpcId: aws.String(betaVPC)}) + betaVPC = "" + } + + // Clear the instance IDs so the outer t.Cleanup doesn't redundantly + // terminate them (a second terminate on a terminated id is a no-op + // at the gateway, but skipping the round-trip keeps logs clean). + alphaInst = "" + betaInst = "" + }) +} + +// waitTerminated polls until id reaches "terminated" or vanishes (NotFound +// is treated as success — some gateway versions garbage-collect terminated +// rows). Bounded by a short timeout so a stuck termination surfaces fast. +func waitTerminated(t *testing.T, c *harness.AWSClient, id string) { + t.Helper() + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(id)}, + }) + if err != nil { + if aerr, ok := err.(awserr.Error); ok && aerr.Code() == "InvalidInstanceID.NotFound" { + return + } + // Other errors — keep polling until the deadline. + } else if len(out.Reservations) > 0 && len(out.Reservations[0].Instances) > 0 { + state := aws.StringValue(out.Reservations[0].Instances[0].State.Name) + if state == "terminated" { + return + } + } + time.Sleep(2 * time.Second) + } + // Don't fail the test from cleanup — log and move on. + t.Logf("waitTerminated: %s did not reach terminated within 60s", id) +} + +// describeInstanceIDs returns all instance ids visible to c across every +// reservation. Used for cross-account visibility assertions. +func describeInstanceIDs(t *testing.T, c *harness.AWSClient) []string { + t.Helper() + out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "describe-instances") + var ids []string + for _, r := range out.Reservations { + for _, i := range r.Instances { + ids = append(ids, aws.StringValue(i.InstanceId)) + } + } + return ids +} + +// describeVolumeIDs returns all volume ids visible to c. +func describeVolumeIDs(t *testing.T, c *harness.AWSClient) []string { + t.Helper() + out, err := c.EC2.DescribeVolumes(&ec2.DescribeVolumesInput{}) + require.NoError(t, err, "describe-volumes") + ids := make([]string, 0, len(out.Volumes)) + for _, v := range out.Volumes { + ids = append(ids, aws.StringValue(v.VolumeId)) + } + return ids +} + +// describeKeyPairNames returns key pair names visible to c. +func describeKeyPairNames(t *testing.T, c *harness.AWSClient) []string { + t.Helper() + out, err := c.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) + require.NoError(t, err, "describe-key-pairs") + names := make([]string, 0, len(out.KeyPairs)) + for _, kp := range out.KeyPairs { + names = append(names, aws.StringValue(kp.KeyName)) + } + return names +} + +// describeKeyPairID looks up a specific key pair by name and returns its +// KeyPairId. Empty string if the key isn't found in c's namespace. +func describeKeyPairID(t *testing.T, c *harness.AWSClient, name string) string { + t.Helper() + out, err := c.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{ + KeyNames: []*string{aws.String(name)}, + }) + if err != nil { + // NotFound here is informational, not a test failure. + return "" + } + if len(out.KeyPairs) == 0 { + return "" + } + return aws.StringValue(out.KeyPairs[0].KeyPairId) +} + +// vpcIDs flattens a slice of *ec2.Vpc to their ids. +func vpcIDs(vpcs []*ec2.Vpc) []string { + ids := make([]string, 0, len(vpcs)) + for _, v := range vpcs { + ids = append(ids, aws.StringValue(v.VpcId)) + } + return ids +} + +// describeRegionNames returns sorted region names. Bash compares by direct +// equality after a sort-by-position — same here, since the gateway returns +// the regions in a stable order. +func describeRegionNames(t *testing.T, c *harness.AWSClient) []string { + t.Helper() + out, err := c.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + require.NoError(t, err, "describe-regions") + names := make([]string, 0, len(out.Regions)) + for _, r := range out.Regions { + names = append(names, aws.StringValue(r.RegionName)) + } + return names +} + +// describeAZNames returns AZ names. +func describeAZNames(t *testing.T, c *harness.AWSClient) []string { + t.Helper() + out, err := c.EC2.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}) + require.NoError(t, err, "describe-availability-zones") + names := make([]string, 0, len(out.AvailabilityZones)) + for _, z := range out.AvailabilityZones { + names = append(names, aws.StringValue(z.ZoneName)) + } + return names +} + +// describeInstanceTypeNames returns sorted instance-type names — bash sorts +// before comparing so the assertion is order-insensitive. +func describeInstanceTypeNames(t *testing.T, c *harness.AWSClient) []string { + t.Helper() + out, err := c.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{}) + require.NoError(t, err, "describe-instance-types") + names := make([]string, 0, len(out.InstanceTypes)) + for _, it := range out.InstanceTypes { + names = append(names, aws.StringValue(it.InstanceType)) + } + // Sort to mirror bash's `| tr '\t' '\n' | sort` step. + sortStrings(names) + return names +} + +// sortStrings sorts in place — kept local to avoid an extra import in the +// already-busy file. Equivalent to sort.Strings. +func sortStrings(xs []string) { + for i := 1; i < len(xs); i++ { + for j := i; j > 0 && xs[j-1] > xs[j]; j-- { + xs[j-1], xs[j] = xs[j], xs[j-1] + } + } +} diff --git a/tests/e2e/single/iam_test.go b/tests/e2e/single/iam_test.go new file mode 100644 index 000000000..c4796c499 --- /dev/null +++ b/tests/e2e/single/iam_test.go @@ -0,0 +1,903 @@ +//go:build e2e + +package single + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Cross-phase identifiers used by the IAM sub-tests. Match the bash +// names so artifact diffs against run-e2e.sh remain greppable. +const ( + iamUserAlice = "alice" + iamUserBob = "bob" + iamUserCharlie = "charlie" + iamUserBobPath = "/engineering/" + + iamPolicyEC2ReadOnly = "EC2ReadOnly" + iamPolicyFullAdmin = "FullAdmin" + iamPolicyFullAdminPath = "/admin/" + iamPolicyDenyTerminate = "DenyTerminate" + iamPolicyIAMReadOnly = "IAMReadOnly" + iamPolicyEC2DescribeAll = "EC2DescribeAll" + + // Bash driver carries the bootstrap AdministratorAccess policy through + // to the end of Phase 7 and asserts exactly one policy remains. Mirror + // that name so the final assertion can ignore it. + iamPolicyBootstrap = "AdministratorAccess" +) + +// Inline policy documents mirroring the bash heredocs. Indentation is +// trimmed to keep diffs against the bash source readable. +const ( + iamDocEC2ReadOnly = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs"], + "Resource": "*" + }] + }` + + iamDocFullAdmin = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "*", + "Resource": "*" + }] + }` + + iamDocDenyTerminate = `{ + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "ec2:*", "Resource": "*"}, + {"Effect": "Deny", "Action": "ec2:TerminateInstances", "Resource": "*"} + ] + }` + + iamDocIAMReadOnly = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["iam:GetUser", "iam:ListUsers", "iam:ListPolicies", "iam:GetPolicy"], + "Resource": "*" + }] + }` + + iamDocEC2DescribeAll = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "ec2:Describe*", + "Resource": "*" + }] + }` + + iamDocEC2ReadOnlyV2 = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs", "ec2:DescribeImages"], + "Resource": "*" + }] + }` +) + +// phaseIAM1_UserCRUD ports run-e2e.sh IAM Phase 1 (~1405–1459): +// CreateUser, GetUser, ListUsers, UpdateUser (rename+path), DeleteUser +// plus the EntityAlreadyExists / NoSuchEntity negative paths. Each +// created user is scheduled for cleanup so a mid-phase failure can't +// poison later runs. UpdateUser exercises an out-of-band rename + +// rename-back so Phase 2+ continue to find "alice" by the well-known +// name. +func phaseIAM1_UserCRUD(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 1 — User CRUD") + + // Defensive: drop leftovers from a prior failed run before recreate. + iamDeleteUserBestEffort(fix, iamUserAlice) + iamDeleteUserBestEffort(fix, iamUserBob) + + harness.Step(t, "list-users (root auth sanity)") + rootUsers, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{}) + require.NoError(t, err, "list-users (root)") + require.NotEmpty(t, rootUsers.Users, "root list-users returned 0 — root account missing?") + + harness.Step(t, "create-user %q", iamUserAlice) + aliceOut, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "create-user %s", iamUserAlice) + require.Equal(t, iamUserAlice, aws.StringValue(aliceOut.User.UserName)) + harness.Detail(t, "user", iamUserAlice, "arn", aws.StringValue(aliceOut.User.Arn)) + + harness.Step(t, "create-user %q path=%q", iamUserBob, iamUserBobPath) + bobOut, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(iamUserBob), + Path: aws.String(iamUserBobPath), + }) + require.NoError(t, err, "create-user %s", iamUserBob) + require.Equal(t, iamUserBobPath, aws.StringValue(bobOut.User.Path), + "bob created without expected path") + + harness.Step(t, "create-user %q again (expect EntityAlreadyExists)", iamUserAlice) + harness.ExpectError(t, "EntityAlreadyExists", func() error { + _, e := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(iamUserAlice)}) + return e + }) + + harness.Step(t, "get-user %q", iamUserAlice) + got, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(iamUserAlice)}) + require.NoError(t, err, "get-user %s", iamUserAlice) + require.Equal(t, iamUserAlice, aws.StringValue(got.User.UserName)) + + harness.Step(t, "get-user nonexistent (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String("nonexistent")}) + return e + }) + + harness.Step(t, "list-users (>=3 root+alice+bob)") + listed, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{}) + require.NoError(t, err, "list-users") + require.GreaterOrEqual(t, len(listed.Users), 3, + "expected >=3 users, got %d (names=%v)", len(listed.Users), iamUserNames(listed.Users)) + + harness.Step(t, "list-users --path-prefix %q (expect 1)", iamUserBobPath) + eng, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{PathPrefix: aws.String(iamUserBobPath)}) + require.NoError(t, err, "list-users --path-prefix") + require.Len(t, eng.Users, 1, + "expected 1 user under %s, got %d (names=%v)", iamUserBobPath, len(eng.Users), iamUserNames(eng.Users)) + + // UpdateUser: rename alice -> alice-renamed with a new path, then + // rename back so Phase 2+ continue to see the well-known "alice". + // Not in the bash driver — covers the SDK code path the task spec + // explicitly calls out. + const renamed = "alice-renamed" + const renamedPath = "/staff/" + harness.Step(t, "update-user %q -> %q path=%q", iamUserAlice, renamed, renamedPath) + _, err = fix.AWS.IAM.UpdateUser(&iam.UpdateUserInput{ + UserName: aws.String(iamUserAlice), + NewUserName: aws.String(renamed), + NewPath: aws.String(renamedPath), + }) + require.NoError(t, err, "update-user rename+path") + check, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(renamed)}) + require.NoError(t, err, "get-user after rename") + assert.Equal(t, renamedPath, aws.StringValue(check.User.Path), "path not updated") + + harness.Step(t, "update-user %q -> %q (restore)", renamed, iamUserAlice) + _, err = fix.AWS.IAM.UpdateUser(&iam.UpdateUserInput{ + UserName: aws.String(renamed), + NewUserName: aws.String(iamUserAlice), + NewPath: aws.String("/"), + }) + require.NoError(t, err, "update-user restore") + + // DeleteUser idempotency: create a throwaway user, delete it, then + // delete it again and assert the second delete surfaces NoSuchEntity + // rather than silently succeeding (matches AWS semantics). + const ephemeral = "iam-phase1-ephemeral" + iamDeleteUserBestEffort(fix, ephemeral) + harness.Step(t, "create-user %q (delete idempotency probe)", ephemeral) + _, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(ephemeral)}) + require.NoError(t, err, "create-user %s", ephemeral) + + harness.Step(t, "delete-user %q", ephemeral) + _, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(ephemeral)}) + require.NoError(t, err, "delete-user %s", ephemeral) + + harness.Step(t, "delete-user %q again (expect NoSuchEntity)", ephemeral) + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(ephemeral)}) + return e + }) +} + +// phaseIAM2_AccessKeyLifecycle ports IAM Phase 2 (~1463–1534): +// CreateAccessKey, ListAccessKeys, UpdateAccessKey (deactivate/activate), +// DeleteAccessKey plus LimitExceeded / NoSuchEntity negative paths. +// Captures alice's primary key into the Fixture so Phase 3 can sign +// requests with it. +func phaseIAM2_AccessKeyLifecycle(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 2 — Access Key Lifecycle") + require.NotEmpty(t, fix.IAMAdminAccount, "") // intentionally lenient — set by Phase 4 + // Note: bash runs Phase 4 *after* Phase 2, so IAMAdminAccount is + // not required here. The NotEmpty check with empty msg above is a + // no-op when the field is empty (require treats "" as ok). Keep + // the line as documentation of the cross-phase ordering. + + harness.Step(t, "create-access-key user=%s (key 1)", iamUserAlice) + k1, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "create-access-key 1") + fix.IAMAliceKeyID = aws.StringValue(k1.AccessKey.AccessKeyId) + fix.IAMAliceSecret = aws.StringValue(k1.AccessKey.SecretAccessKey) + require.NotEmpty(t, fix.IAMAliceKeyID, "empty AccessKeyId") + require.NotEmpty(t, fix.IAMAliceSecret, "empty SecretAccessKey") + harness.Detail(t, "key1", fix.IAMAliceKeyID) + + harness.Step(t, "create-access-key user=%s (key 2)", iamUserAlice) + k2, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "create-access-key 2") + aliceKey2 := aws.StringValue(k2.AccessKey.AccessKeyId) + require.NotEmpty(t, aliceKey2) + harness.Detail(t, "key2", aliceKey2) + + harness.Step(t, "create-access-key user=%s key 3 (expect LimitExceeded)", iamUserAlice) + harness.ExpectError(t, "LimitExceeded", func() error { + _, e := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + }) + return e + }) + + harness.Step(t, "create-access-key user=ghost (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String("ghost"), + }) + return e + }) + + harness.Step(t, "list-access-keys user=%s (expect 2)", iamUserAlice) + keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "list-access-keys alice") + require.Len(t, keys.AccessKeyMetadata, 2, "alice key count") + + harness.Step(t, "list-access-keys user=%s (expect 0)", iamUserBob) + bobKeys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(iamUserBob), + }) + require.NoError(t, err, "list-access-keys bob") + require.Empty(t, bobKeys.AccessKeyMetadata, "bob should have 0 keys") + + harness.Step(t, "update-access-key %s -> Inactive", fix.IAMAliceKeyID) + _, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + AccessKeyId: aws.String(fix.IAMAliceKeyID), + Status: aws.String(iam.StatusTypeInactive), + }) + require.NoError(t, err, "update-access-key deactivate") + require.Equal(t, iam.StatusTypeInactive, + iamFindKeyStatus(t, fix, iamUserAlice, fix.IAMAliceKeyID), + "key not Inactive after update") + + harness.Step(t, "update-access-key %s -> Active", fix.IAMAliceKeyID) + _, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + AccessKeyId: aws.String(fix.IAMAliceKeyID), + Status: aws.String(iam.StatusTypeActive), + }) + require.NoError(t, err, "update-access-key reactivate") + + harness.Step(t, "delete-access-key %s (key 2)", aliceKey2) + _, err = fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(iamUserAlice), + AccessKeyId: aws.String(aliceKey2), + }) + require.NoError(t, err, "delete-access-key key2") + + after, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "list-access-keys after delete") + require.Len(t, after.AccessKeyMetadata, 1, "alice should have 1 key after delete") +} + +// phaseIAM3_UserAuthentication ports IAM Phase 3 (~1538–1579): +// build a scoped AWS client with alice's key and confirm signing +// works (active key) / fails (deactivated key, bad secret, bogus ID). +// Also creates bob's key so Phase 5 enforcement / Phase 7 cleanup +// can use it. +func phaseIAM3_UserAuthentication(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 3 — User Authentication") + require.NotEmpty(t, fix.IAMAliceKeyID, "Phase 2 must populate fix.IAMAliceKeyID") + require.NotEmpty(t, fix.IAMAliceSecret, "Phase 2 must populate fix.IAMAliceSecret") + + harness.Step(t, "scoped client (alice) describe-instances — active key OK") + aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, fix.IAMAliceSecret) + _, err := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "alice describe-instances with active key") + + harness.Step(t, "deactivate alice key, expect InvalidClientTokenId") + _, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + AccessKeyId: aws.String(fix.IAMAliceKeyID), + Status: aws.String(iam.StatusTypeInactive), + }) + require.NoError(t, err, "deactivate alice key") + harness.ExpectError(t, "InvalidClientTokenId", func() error { + _, e := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + return e + }) + + harness.Step(t, "reactivate alice key") + _, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ + UserName: aws.String(iamUserAlice), + AccessKeyId: aws.String(fix.IAMAliceKeyID), + Status: aws.String(iam.StatusTypeActive), + }) + require.NoError(t, err, "reactivate alice key") + + harness.Step(t, "bad secret (expect SignatureDoesNotMatch)") + badSecret := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, "WRONG_SECRET_KEY_HERE_12345678901") + harness.ExpectError(t, "SignatureDoesNotMatch", func() error { + _, e := badSecret.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + return e + }) + + harness.Step(t, "bogus key id (expect InvalidClientTokenId)") + fakeCli := harness.NewAWSClientWithCreds(t, fix.Env, "AKIAXXXXXXXXXXXXXXXX", "doesntmatter") + harness.ExpectError(t, "InvalidClientTokenId", func() error { + _, e := fakeCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + return e + }) + + harness.Step(t, "create-access-key user=%s (multi-user auth)", iamUserBob) + bobKey, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(iamUserBob), + }) + require.NoError(t, err, "create-access-key bob") + fix.IAMBobKeyID = aws.StringValue(bobKey.AccessKey.AccessKeyId) + fix.IAMBobSecret = aws.StringValue(bobKey.AccessKey.SecretAccessKey) + require.NotEmpty(t, fix.IAMBobKeyID, "empty bob key id") + harness.Detail(t, "bob_key", fix.IAMBobKeyID) + + harness.Step(t, "root auth still OK") + _, err = fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "root describe-instances") +} + +// phaseIAM4_PolicyCRUD ports IAM Phase 4 (~1583–1698): +// CreatePolicy (5 variants), CreatePolicyVersion / ListPolicyVersions / +// SetDefaultPolicyVersion / DeletePolicyVersion (extends bash, per +// task spec), GetPolicy, GetPolicyVersion, ListPolicies plus +// EntityAlreadyExists / MalformedPolicyDocument / NoSuchEntity errors. +// Captures the admin account ID into the Fixture for Phase 5–7. +func phaseIAM4_PolicyCRUD(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 4 — Policy CRUD") + + // Defensive: drop any leftover test policies from a prior failed run. + // Detach-then-delete isn't worth the round trips here — the cleanup + // in Phase 7 (or a teardown of a previous test) should have cleared + // these; if not, the create below will surface EntityAlreadyExists + // and the operator can hand-clean. + + harness.Step(t, "create-policy %s", iamPolicyEC2ReadOnly) + pol, err := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(iamPolicyEC2ReadOnly), + PolicyDocument: aws.String(iamDocEC2ReadOnly), + }) + require.NoError(t, err, "create-policy %s", iamPolicyEC2ReadOnly) + ec2roArn := aws.StringValue(pol.Policy.Arn) + require.NotEmpty(t, ec2roArn, "empty policy ARN") + fix.IAMAdminAccount = iamAccountFromARN(t, ec2roArn) + harness.Detail(t, "policy", iamPolicyEC2ReadOnly, "arn", ec2roArn, "account", fix.IAMAdminAccount) + + harness.Step(t, "create-policy %s path=%s", iamPolicyFullAdmin, iamPolicyFullAdminPath) + _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(iamPolicyFullAdmin), + Path: aws.String(iamPolicyFullAdminPath), + Description: aws.String("Full access to all services"), + PolicyDocument: aws.String(iamDocFullAdmin), + }) + require.NoError(t, err, "create-policy %s", iamPolicyFullAdmin) + + harness.Step(t, "create-policy %s (mixed Allow+Deny)", iamPolicyDenyTerminate) + _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(iamPolicyDenyTerminate), + PolicyDocument: aws.String(iamDocDenyTerminate), + }) + require.NoError(t, err, "create-policy %s", iamPolicyDenyTerminate) + + harness.Step(t, "create-policy %s", iamPolicyIAMReadOnly) + _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(iamPolicyIAMReadOnly), + PolicyDocument: aws.String(iamDocIAMReadOnly), + }) + require.NoError(t, err, "create-policy %s", iamPolicyIAMReadOnly) + + harness.Step(t, "create-policy %s (wildcard)", iamPolicyEC2DescribeAll) + _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(iamPolicyEC2DescribeAll), + PolicyDocument: aws.String(iamDocEC2DescribeAll), + }) + require.NoError(t, err, "create-policy %s", iamPolicyEC2DescribeAll) + + harness.Step(t, "create-policy duplicate (expect EntityAlreadyExists)") + harness.ExpectError(t, "EntityAlreadyExists", func() error { + _, e := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(iamPolicyEC2ReadOnly), + PolicyDocument: aws.String(iamDocFullAdmin), + }) + return e + }) + + harness.Step(t, "create-policy malformed (expect MalformedPolicyDocument)") + harness.ExpectError(t, "MalformedPolicyDocument", func() error { + _, e := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String("BadPolicy"), + PolicyDocument: aws.String(`{"not valid"}`), + }) + return e + }) + + harness.Step(t, "get-policy %s", iamPolicyEC2ReadOnly) + got, err := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(ec2roArn)}) + require.NoError(t, err, "get-policy %s", iamPolicyEC2ReadOnly) + require.Equal(t, iamPolicyEC2ReadOnly, aws.StringValue(got.Policy.PolicyName)) + + harness.Step(t, "get-policy nonexistent (expect NoSuchEntity)") + ghostArn := iamPolicyARN(fix.IAMAdminAccount, "Ghost") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(ghostArn)}) + return e + }) + + harness.Step(t, "get-policy-version %s v1", iamPolicyEC2ReadOnly) + pv, err := fix.AWS.IAM.GetPolicyVersion(&iam.GetPolicyVersionInput{ + PolicyArn: aws.String(ec2roArn), + VersionId: aws.String("v1"), + }) + require.NoError(t, err, "get-policy-version v1") + require.Equal(t, "v1", aws.StringValue(pv.PolicyVersion.VersionId)) + + // Policy version lifecycle — extends the bash driver (task spec). + // Round-trip: create v2 (as default), list, set v1 default, delete v2. + harness.Step(t, "create-policy-version %s v2 (default)", iamPolicyEC2ReadOnly) + cv, err := fix.AWS.IAM.CreatePolicyVersion(&iam.CreatePolicyVersionInput{ + PolicyArn: aws.String(ec2roArn), + PolicyDocument: aws.String(iamDocEC2ReadOnlyV2), + SetAsDefault: aws.Bool(true), + }) + require.NoError(t, err, "create-policy-version v2") + v2 := aws.StringValue(cv.PolicyVersion.VersionId) + require.NotEmpty(t, v2, "empty new VersionId") + harness.Detail(t, "new_version", v2) + + harness.Step(t, "list-policy-versions %s", iamPolicyEC2ReadOnly) + versions, err := fix.AWS.IAM.ListPolicyVersions(&iam.ListPolicyVersionsInput{ + PolicyArn: aws.String(ec2roArn), + }) + require.NoError(t, err, "list-policy-versions") + require.GreaterOrEqual(t, len(versions.Versions), 2, + "expected >=2 versions, got %d", len(versions.Versions)) + + harness.Step(t, "set-default-policy-version %s v1", iamPolicyEC2ReadOnly) + _, err = fix.AWS.IAM.SetDefaultPolicyVersion(&iam.SetDefaultPolicyVersionInput{ + PolicyArn: aws.String(ec2roArn), + VersionId: aws.String("v1"), + }) + require.NoError(t, err, "set-default-policy-version v1") + + harness.Step(t, "delete-policy-version %s %s", iamPolicyEC2ReadOnly, v2) + _, err = fix.AWS.IAM.DeletePolicyVersion(&iam.DeletePolicyVersionInput{ + PolicyArn: aws.String(ec2roArn), + VersionId: aws.String(v2), + }) + require.NoError(t, err, "delete-policy-version %s", v2) + + harness.Step(t, "list-policies (>=5)") + all, err := fix.AWS.IAM.ListPolicies(&iam.ListPoliciesInput{}) + require.NoError(t, err, "list-policies") + require.GreaterOrEqual(t, len(all.Policies), 5, + "expected >=5 policies, got %d (%v)", len(all.Policies), iamPolicyNames(all.Policies)) +} + +// phaseIAM5_PolicyAttachmentEnforcement ports IAM Phase 5 (~1702–1833): +// AttachUserPolicy idempotency, ListAttachedUserPolicies, enforcement +// (default deny / explicit allow / wildcard / explicit deny / root +// bypass / prefix wildcard / FullAdmin), DetachUserPolicy. +func phaseIAM5_PolicyAttachmentEnforcement(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 5 — Policy Attachment & Enforcement") + require.NotEmpty(t, fix.IAMAdminAccount, "Phase 4 must populate fix.IAMAdminAccount") + + ec2roArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyEC2ReadOnly) + iamroArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyIAMReadOnly) + denyArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyDenyTerminate) + descAllArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyEC2DescribeAll) + fullAdminArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyFullAdminPath[1:]+iamPolicyFullAdmin) + + harness.Step(t, "create-user %q + key (enforcement subject)", iamUserCharlie) + iamDeleteUserBestEffort(fix, iamUserCharlie) + _, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(iamUserCharlie), + }) + require.NoError(t, err, "create-user %s", iamUserCharlie) + charlieKey, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(iamUserCharlie), + }) + require.NoError(t, err, "create-access-key %s", iamUserCharlie) + fix.IAMCharlieKeyID = aws.StringValue(charlieKey.AccessKey.AccessKeyId) + fix.IAMCharlieSecret = aws.StringValue(charlieKey.AccessKey.SecretAccessKey) + harness.Detail(t, "charlie_key", fix.IAMCharlieKeyID) + + aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, fix.IAMAliceSecret) + bobCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMBobKeyID, fix.IAMBobSecret) + charlieCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMCharlieKeyID, fix.IAMCharlieSecret) + + harness.Step(t, "attach-user-policy alice <- EC2ReadOnly + IAMReadOnly") + _, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(ec2roArn), + }) + require.NoError(t, err, "attach EC2ReadOnly") + _, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(iamroArn), + }) + require.NoError(t, err, "attach IAMReadOnly") + + harness.Step(t, "attach-user-policy bob <- DenyTerminate") + _, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserBob), PolicyArn: aws.String(denyArn), + }) + require.NoError(t, err, "attach DenyTerminate") + + harness.Step(t, "list-attached-user-policies alice (expect 2)") + attached, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "list-attached-user-policies alice") + require.Len(t, attached.AttachedPolicies, 2, "alice attached count") + + harness.Step(t, "idempotent attach EC2ReadOnly") + _, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(ec2roArn), + }) + require.NoError(t, err, "idempotent attach") + attached2, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{ + UserName: aws.String(iamUserAlice), + }) + require.NoError(t, err, "list-attached after idempotent attach") + require.Len(t, attached2.AttachedPolicies, 2, + "attached count must not grow on idempotent re-attach") + + harness.Step(t, "attach non-existent policy (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserAlice), + PolicyArn: aws.String(iamPolicyARN(fix.IAMAdminAccount, "Ghost")), + }) + return e + }) + + harness.Step(t, "attach to non-existent user (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String("ghost"), + PolicyArn: aws.String(ec2roArn), + }) + return e + }) + + // --- Enforcement --- + + harness.Step(t, "default deny (charlie, no policies)") + harness.ExpectError(t, "AccessDenied", func() error { + _, e := charlieCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + return e + }) + harness.ExpectError(t, "AccessDenied", func() error { + _, e := charlieCli.IAM.ListUsers(&iam.ListUsersInput{}) + return e + }) + + harness.Step(t, "explicit allow (alice, EC2ReadOnly + IAMReadOnly)") + _, err = aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "alice ec2:DescribeInstances") + _, err = aliceCli.EC2.DescribeVpcs(&ec2.DescribeVpcsInput{}) + require.NoError(t, err, "alice ec2:DescribeVpcs") + _, err = aliceCli.IAM.ListUsers(&iam.ListUsersInput{}) + require.NoError(t, err, "alice iam:ListUsers") + + harness.Step(t, "deny actions outside alice's policies") + harness.ExpectError(t, "AccessDenied", func() error { + _, e := aliceCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) + return e + }) + harness.ExpectError(t, "AccessDenied", func() error { + _, e := aliceCli.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String("hack")}) + return e + }) + + harness.Step(t, "wildcard allow + explicit deny (bob, DenyTerminate)") + _, err = bobCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "bob ec2:DescribeInstances") + _, err = bobCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) + require.NoError(t, err, "bob ec2:DescribeKeyPairs") + harness.ExpectError(t, "AccessDenied", func() error { + _, e := bobCli.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String("i-fake")}, + }) + return e + }) + harness.ExpectError(t, "AccessDenied", func() error { + _, e := bobCli.IAM.ListUsers(&iam.ListUsersInput{}) + return e + }) + + harness.Step(t, "root user bypass") + _, err = fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "root ec2:DescribeInstances") + _, err = fix.AWS.IAM.ListUsers(&iam.ListUsersInput{}) + require.NoError(t, err, "root iam:ListUsers") + _, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String("temp")}) + require.NoError(t, err, "root create-user temp") + _, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String("temp")}) + require.NoError(t, err, "root delete-user temp") + + harness.Step(t, "prefix wildcard (alice -> EC2DescribeAll)") + _, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(ec2roArn), + }) + require.NoError(t, err, "detach EC2ReadOnly") + _, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(iamroArn), + }) + require.NoError(t, err, "detach IAMReadOnly") + _, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(descAllArn), + }) + require.NoError(t, err, "attach EC2DescribeAll") + + _, err = aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "alice ec2:DescribeInstances (Describe*)") + _, err = aliceCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}) + require.NoError(t, err, "alice ec2:DescribeKeyPairs (Describe*)") + harness.ExpectError(t, "AccessDenied", func() error { + _, e := aliceCli.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String("x")}) + return e + }) + harness.ExpectError(t, "AccessDenied", func() error { + _, e := aliceCli.IAM.ListUsers(&iam.ListUsersInput{}) + return e + }) + + harness.Step(t, "FullAdmin (charlie unlocks)") + _, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(iamUserCharlie), PolicyArn: aws.String(fullAdminArn), + }) + require.NoError(t, err, "attach FullAdmin to charlie") + _, err = charlieCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + require.NoError(t, err, "charlie ec2:DescribeInstances after FullAdmin") + _, err = charlieCli.IAM.ListUsers(&iam.ListUsersInput{}) + require.NoError(t, err, "charlie iam:ListUsers after FullAdmin") +} + +// phaseIAM6_PolicyLifecycle ports IAM Phase 6 (~1837–1864): +// detach alice's policy and confirm she loses access; deleting a +// still-attached policy must surface DeleteConflict; after detach + +// delete the policy is gone (NoSuchEntity). +func phaseIAM6_PolicyLifecycle(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 6 — Policy Lifecycle (Detach & Delete)") + require.NotEmpty(t, fix.IAMAdminAccount, "Phase 4 must populate fix.IAMAdminAccount") + + descAllArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyEC2DescribeAll) + denyArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyDenyTerminate) + + aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, fix.IAMAliceSecret) + + harness.Step(t, "detach EC2DescribeAll from alice (expect AccessDenied)") + _, err := fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(iamUserAlice), PolicyArn: aws.String(descAllArn), + }) + require.NoError(t, err, "detach EC2DescribeAll") + harness.ExpectError(t, "AccessDenied", func() error { + _, e := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) + return e + }) + + harness.Step(t, "delete-policy DenyTerminate while attached (expect DeleteConflict)") + harness.ExpectError(t, "DeleteConflict", func() error { + _, e := fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(denyArn)}) + return e + }) + + harness.Step(t, "detach DenyTerminate from bob, then delete-policy") + _, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(iamUserBob), PolicyArn: aws.String(denyArn), + }) + require.NoError(t, err, "detach DenyTerminate") + _, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(denyArn)}) + require.NoError(t, err, "delete-policy DenyTerminate") + + harness.Step(t, "get-policy DenyTerminate (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(denyArn)}) + return e + }) +} + +// phaseIAM7_Cleanup ports IAM Phase 7 (~1868–1911): tear down every +// user, key, and policy created by Phases 1–6 even if intermediate +// phases failed. Each step is best-effort so a missing resource +// (already torn down or never created) doesn't fail the cleanup pass. +// The final list-users / list-policies assertions are commented +// expectations rather than hard checks because Phase 7 may run with +// residual state from a partial Phase 1–6 failure. +func phaseIAM7_Cleanup(t *testing.T, fix *Fixture) { + harness.Phase(t, "IAM Phase 7 — Cleanup") + + // Users: delete their keys first, then the user. iamDeleteUserBestEffort + // handles already-deleted resources gracefully. + for _, u := range []string{iamUserAlice, iamUserBob, iamUserCharlie} { + harness.Step(t, "cleanup user %s", u) + iamDeleteUserBestEffort(fix, u) + } + + // Verify root is the only user left. Soft check — log + continue + // rather than fail, because Phase 7 must succeed even if a prior + // phase left orphans we can't legally delete. + users, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{}) + if err == nil { + harness.Detail(t, "remaining_users", len(users.Users), "names", iamUserNames(users.Users)) + assert.LessOrEqual(t, len(users.Users), 1, + "expected only root after cleanup, got %d (%v)", len(users.Users), iamUserNames(users.Users)) + } + + // Policies: detach (where applicable) and delete every test policy. + // FullAdmin lives under /admin/ so its ARN includes the path. + if fix.IAMAdminAccount == "" { + // Phase 4 never ran → nothing to clean. Skip policy teardown + // entirely; this keeps Phase 7 idempotent when run standalone. + return + } + + for _, p := range []struct{ name, path string }{ + {iamPolicyEC2ReadOnly, ""}, + {iamPolicyFullAdmin, iamPolicyFullAdminPath}, + {iamPolicyIAMReadOnly, ""}, + {iamPolicyEC2DescribeAll, ""}, + {iamPolicyDenyTerminate, ""}, // already deleted in Phase 6 but defensive + } { + key := p.name + if p.path != "" { + // AWS stores the path inside the ARN, not the policy name — + // stripping the leading '/' matches iamPolicyARN's expectation. + key = p.path[1:] + p.name + } + arn := iamPolicyARN(fix.IAMAdminAccount, key) + harness.Step(t, "cleanup policy %s", arn) + iamDeletePolicyBestEffort(fix, arn) + } + + pols, err := fix.AWS.IAM.ListPolicies(&iam.ListPoliciesInput{}) + if err == nil { + harness.Detail(t, "remaining_policies", len(pols.Policies), "names", iamPolicyNames(pols.Policies)) + // Bash asserts exactly 1 (AdministratorAccess). We assert that + // no *test* policy survives — robust to bootstrap variations + // across environments. + for _, p := range pols.Policies { + name := aws.StringValue(p.PolicyName) + assert.NotContains(t, + []string{iamPolicyEC2ReadOnly, iamPolicyFullAdmin, iamPolicyDenyTerminate, + iamPolicyIAMReadOnly, iamPolicyEC2DescribeAll}, + name, + "test policy %q survived cleanup", name) + } + } +} + +// --- helpers --- + +// iamDeleteUserBestEffort detaches all policies, deletes all access keys, +// then deletes the user. Every step is best-effort; missing resources +// are ignored so callers (Phase 7 cleanup, pre-phase recreate) can use +// it idempotently. +func iamDeleteUserBestEffort(fix *Fixture, user string) { + // Detach any attached managed policies first — DeleteUser fails + // with DeleteConflict if anything is still attached. + attached, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{ + UserName: aws.String(user), + }) + if err == nil { + for _, p := range attached.AttachedPolicies { + _, _ = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(user), + PolicyArn: p.PolicyArn, + }) + } + } + // Delete every access key for this user. + keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(user), + }) + if err == nil { + for _, k := range keys.AccessKeyMetadata { + _, _ = fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(user), + AccessKeyId: k.AccessKeyId, + }) + } + } + _, _ = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(user)}) +} + +// iamDeletePolicyBestEffort drops non-default policy versions and +// deletes the policy. Missing/unattached resources are ignored. +func iamDeletePolicyBestEffort(fix *Fixture, arn string) { + versions, err := fix.AWS.IAM.ListPolicyVersions(&iam.ListPolicyVersionsInput{ + PolicyArn: aws.String(arn), + }) + if err == nil { + for _, v := range versions.Versions { + if aws.BoolValue(v.IsDefaultVersion) { + continue + } + _, _ = fix.AWS.IAM.DeletePolicyVersion(&iam.DeletePolicyVersionInput{ + PolicyArn: aws.String(arn), + VersionId: v.VersionId, + }) + } + } + _, _ = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(arn)}) +} + +// iamFindKeyStatus returns the Status of a specific access key for a +// user. Fails the test if the key isn't found — caller has already +// asserted the key exists. +func iamFindKeyStatus(t *testing.T, fix *Fixture, user, keyID string) string { + t.Helper() + out, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(user), + }) + require.NoError(t, err, "list-access-keys %s", user) + for _, k := range out.AccessKeyMetadata { + if aws.StringValue(k.AccessKeyId) == keyID { + return aws.StringValue(k.Status) + } + } + t.Fatalf("access key %s not found for user %s", keyID, user) + return "" +} + +// iamUserNames extracts user names from a *iam.User slice — handy for +// log lines that need to print the actual set on assertion failures. +func iamUserNames(users []*iam.User) []string { + out := make([]string, 0, len(users)) + for _, u := range users { + out = append(out, aws.StringValue(u.UserName)) + } + return out +} + +// iamPolicyNames mirrors iamUserNames for *iam.Policy. +func iamPolicyNames(policies []*iam.Policy) []string { + out := make([]string, 0, len(policies)) + for _, p := range policies { + out = append(out, aws.StringValue(p.PolicyName)) + } + return out +} + +// iamAccountFromARN parses the account-id segment from an ARN like +// +// arn:aws:iam::123456789012:policy/EC2ReadOnly +// +// Fails the test if the ARN doesn't have the expected 6-field shape. +func iamAccountFromARN(t *testing.T, arn string) string { + t.Helper() + parts := strings.Split(arn, ":") + require.GreaterOrEqual(t, len(parts), 6, "unexpected ARN shape: %q", arn) + require.NotEmpty(t, parts[4], "empty account-id in ARN %q", arn) + return parts[4] +} + +// iamPolicyARN builds the canonical policy ARN for a given account + +// policy "key" (name or "path/name" without the leading slash). +func iamPolicyARN(account, key string) string { + return "arn:aws:iam::" + account + ":policy/" + key +} diff --git a/tests/e2e/single/main_test.go b/tests/e2e/single/main_test.go index 7811b3304..d0b7ba13f 100644 --- a/tests/e2e/single/main_test.go +++ b/tests/e2e/single/main_test.go @@ -52,6 +52,17 @@ type Fixture struct { OVNAvailable bool // gates 8b-e PoolMode bool // gates 8b / 8d + + // IAM phase state (Stage E). Threaded between IAM Phase 1–7 so + // Phase 7's cleanup can defensively delete keys/users created + // earlier even when an intermediate phase short-circuits. + IAMAdminAccount string // account ID extracted from CreatePolicy ARN + IAMAliceKeyID string + IAMAliceSecret string + IAMBobKeyID string + IAMBobSecret string + IAMCharlieKeyID string + IAMCharlieSecret string } // TestSingleNode is the Go port of run-e2e.sh. Phases run as sequential @@ -100,7 +111,17 @@ func TestSingleNode(t *testing.T) { t.Run("Phase8_NegativeErrorPaths", func(t *testing.T) { phase8_NegativeErrorPaths(t, fix) }) - // Stages E–G will append more t.Run calls here. + t.Run("IAM1_UserCRUD", func(t *testing.T) { phaseIAM1_UserCRUD(t, fix) }) + t.Run("IAM2_AccessKeyLifecycle", func(t *testing.T) { phaseIAM2_AccessKeyLifecycle(t, fix) }) + t.Run("IAM3_UserAuthentication", func(t *testing.T) { phaseIAM3_UserAuthentication(t, fix) }) + t.Run("IAM4_PolicyCRUD", func(t *testing.T) { phaseIAM4_PolicyCRUD(t, fix) }) + t.Run("IAM5_PolicyAttachmentEnforcement", func(t *testing.T) { phaseIAM5_PolicyAttachmentEnforcement(t, fix) }) + t.Run("IAM6_PolicyLifecycle", func(t *testing.T) { phaseIAM6_PolicyLifecycle(t, fix) }) + t.Run("IAM7_Cleanup", func(t *testing.T) { phaseIAM7_Cleanup(t, fix) }) + + t.Run("Phase8Acct_AccountScoping", func(t *testing.T) { phase8Acct_AccountScoping(t, fix) }) + + // Stages F–G will append more t.Run calls here. harness.OnFailure(t, func() { harness.DumpCmd(t, fix.Artifacts, "ec2-describe-instances.txt",