test(e2e-single): port run-e2e.sh IAM 1-7 + Phase 8 account scoping to Go

Stage E of the single-node Go port (mulga-siv-89). Adds two files
under tests/e2e/single/:

- iam_test.go (903 lines): IAM Phases 1-7 — User CRUD, Access Key
  Lifecycle, User Authentication (via harness.NewAWSClientWithCreds),
  Policy CRUD (+ CreatePolicyVersion / SetDefaultPolicyVersion /
  DeletePolicyVersion which bash lacks), Policy Attachment &
  Enforcement (allow / deny / wildcard / root bypass), Policy Lifecycle
  (DeleteConflict while attached), and idempotent Cleanup.

- account_test.go (1186 lines): Phase 8 EC2 Account Scoping — 11 Steps
  (Account Setup, Instance / Volume / KeyPair / Snapshot / VPC-Subnet /
  IGW-EIGW Scoping, Account Settings, Global Resources, Edge Cases,
  Cleanup). Uses harness.AccountCarousel for the multi-profile
  credential map and SpxAdminAccountCreate for admin account
  bootstrap.

Fixture extended with IAM cross-phase state (IAMAdminAccount,
IAM{Alice,Bob,Charlie}{KeyID,Secret}) so Phase 7 cleanup is defensive
even when an intermediate IAM phase short-circuits. All 11 Phase 8
Steps share local state in the outer function; t.Cleanup mirrors
Step 11 LIFO so a mid-Step failure still tears down accounts.

Wired into TestSingleNode. run-e2e.sh stays in tree until step-7
cleanup per parent plan.
This commit is contained in:
joshsiv-mulga
2026-05-18 21:30:45 +10:00
parent 3da760c692
commit 562d7d66fd
3 changed files with 2111 additions and 1 deletions
File diff suppressed because it is too large Load Diff
+903
View File
@@ -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
}
+22 -1
View File
@@ -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",