diff --git a/tests/e2e/harness/iam.go b/tests/e2e/harness/iam.go index 64aee86f9..0a9907df5 100644 --- a/tests/e2e/harness/iam.go +++ b/tests/e2e/harness/iam.go @@ -22,11 +22,6 @@ func IAMPolicyARN(account, key string) string { return "arn:aws:iam::" + account + ":policy/" + key } -// IAMGroupARN constructs arn:aws:iam:::group/ (default path /). -func IAMGroupARN(account, name string) string { - return "arn:aws:iam::" + account + ":group/" + name -} - // IAMAccountID returns the active account ID via STS GetCallerIdentity. Unlike // deriving it from a created IAM user's ARN, this is side-effect-free, so suites // sharing one account can call it concurrently without minting colliding users. diff --git a/tests/e2e/iam/assumed_role_enforcement_test.go b/tests/e2e/iam/assumed_role_enforcement_test.go deleted file mode 100644 index 25ff9a22f..000000000 --- a/tests/e2e/iam/assumed_role_enforcement_test.go +++ /dev/null @@ -1,87 +0,0 @@ -//go:build e2e - -package iam - -import ( - "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/aws/aws-sdk-go/service/sts" - "github.com/mulgadc/spinifex/tests/e2e/harness" - "github.com/stretchr/testify/require" -) - -const ( - areRoleName = "are-e2e-role" - arePolicyName = "are-e2e-ec2-describe-regions" - areSessionName = "are-e2e-session" - - // A single-action grant, so the positive case proves a specific managed - // policy was resolved and evaluated — not a blanket allow. - arePolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}` -) - -// runAssumedRoleControlPlaneEnforcement proves the gateway evaluates assumed-role -// sessions against managed policies: a zero-policy role is denied, and the same -// live session is allowed once a policy is attached (policies resolved per request). -func runAssumedRoleControlPlaneEnforcement(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — Assumed-role control-plane enforcement") - adminAccount := harness.IAMAccountID(t, fix.AWS) - roleARN := harness.IAMRoleARN(adminAccount, areRoleName) - policyARN := harness.IAMPolicyARN(adminAccount, arePolicyName) - - // Pre-sweep in case a previous run left fragments. - sweep := func() { - harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, areRoleName, nil, policyARN) - _, _ = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)}) - } - sweep() - fix.Harness.RegisterCleanup(sweep) - - harness.Step(t, "create-role %q (no permission policy)", areRoleName) - _, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String(areRoleName), - AssumeRolePolicyDocument: aws.String(stsTrustPolicyAllowAny), - Description: aws.String("E2E assumed-role control-plane enforcement"), - }) - require.NoError(t, err, "create-role") - - // Mint assumed-role (ASIA) credentials. - harness.Step(t, "assume-role %q session=%q", roleARN, areSessionName) - aOut, err := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String(areSessionName), - }) - require.NoError(t, err, "assume-role") - creds := aOut.Credentials - require.NotNil(t, creds, "AssumeRole returned nil Credentials") - sessionCli := harness.NewAWSClientWithSessionCreds(t, fix.Env, - aws.StringValue(creds.AccessKeyId), - aws.StringValue(creds.SecretAccessKey), - aws.StringValue(creds.SessionToken)) - - harness.Step(t, "describe-regions with zero-policy role (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) - - // Attach a managed policy granting ec2:DescribeRegions to the role. - harness.Step(t, "create+attach policy %q (grants ec2:DescribeRegions)", arePolicyName) - _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ - PolicyName: aws.String(arePolicyName), - PolicyDocument: aws.String(arePolicyDoc), - }) - require.NoError(t, err, "create-policy") - _, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ - RoleName: aws.String(areRoleName), - PolicyArn: aws.String(policyARN), - }) - require.NoError(t, err, "attach-role-policy") - - harness.Step(t, "describe-regions after grant (expect success)") - _, err = sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - require.NoError(t, err, "describe-regions must be allowed once the role grants it") -} diff --git a/tests/e2e/iam/iam_groups_test.go b/tests/e2e/iam/iam_groups_test.go deleted file mode 100644 index 913d702f0..000000000 --- a/tests/e2e/iam/iam_groups_test.go +++ /dev/null @@ -1,544 +0,0 @@ -//go:build e2e - -package iam - -import ( - "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/require" -) - -// Dedicated identifiers for the Groups suite. Distinct from the shared -// alice/bob/charlie graph so the group tests can pre-sweep and tear down their -// own state without colliding with the User/Policy phases. -const ( - grpLifecycleGroup = "grp-e2e-developers" - grpLifecycleUser = "grp-e2e-lc-user" - grpLifecyclePolicy = "grp-e2e-lc-describe-regions" - grpLifecycleInline = "grp-e2e-lc-inline-describe-regions" - - grpEnforceGroup = "grp-e2e-enforce" - grpEnforceUser = "grp-e2e-enf-user" - grpEnforcePolicy = "grp-e2e-enf-describe-regions" - - grpInlineEnforceGroup = "grp-e2e-inline-enforce" - grpInlineEnforceUser = "grp-e2e-inl-enf-user" - grpInlineEnforceName = "grp-e2e-inl-enf-describe-regions" - - // A single-action grant so the enforcement positive case proves a specific - // group-attached policy was resolved and evaluated — not a blanket allow. - grpPolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}` -) - -// runIAMGroupsLifecycle exercises the full group surface: CRUD, membership, -// group-policy attachment, inline group policies (put/get/list), the listing -// reverse-lookups, and every deletion guard (delete-group while attached, while -// inline-policied, while non-empty, delete-user while in a group), then a clean -// teardown. Mirrors runIAMRolesAndProfiles in shape. -func runIAMGroupsLifecycle(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Groups Lifecycle") - adminAccount := harness.IAMAccountID(t, fix.AWS) - groupARN := harness.IAMGroupARN(adminAccount, grpLifecycleGroup) - policyARN := harness.IAMPolicyARN(adminAccount, grpLifecyclePolicy) - - // Defensive sweep — a prior failed run may have left fragments. Order: remove - // member + detach policy (group delete refuses a non-empty/attached group), - // then the user, then the policy. - sweep := func() { - harness.IAMDeleteGroupBestEffort(fix.AWS, grpLifecycleGroup, []string{grpLifecycleUser}, policyARN) - iamDeleteUserBestEffort(fix, grpLifecycleUser) - iamDeletePolicyBestEffort(fix, policyARN) - } - sweep() - fix.Harness.RegisterCleanup(sweep) - - // CreateGroup — happy path; assert ARN + AWS group-id prefix. - harness.Step(t, "create-group %q", grpLifecycleGroup) - createOut, err := fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{ - GroupName: aws.String(grpLifecycleGroup), - }) - require.NoError(t, err, "create-group") - require.Equal(t, grpLifecycleGroup, aws.StringValue(createOut.Group.GroupName)) - require.Equal(t, groupARN, aws.StringValue(createOut.Group.Arn), - "group ARN must follow arn:aws:iam:::group/") - require.True(t, len(aws.StringValue(createOut.Group.GroupId)) > 4 && - aws.StringValue(createOut.Group.GroupId)[:4] == "AGPA", - "group id must carry the AWS AGPA prefix, got %q", aws.StringValue(createOut.Group.GroupId)) - harness.Detail(t, "group", grpLifecycleGroup, "arn", groupARN) - - // Duplicate. - harness.Step(t, "create-group duplicate (expect EntityAlreadyExists)") - harness.ExpectError(t, "EntityAlreadyExists", func() error { - _, e := fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - return e - }) - - // GetGroup — fresh group has an empty (non-nil) member list. - harness.Step(t, "get-group %q (no members yet)", grpLifecycleGroup) - got, err := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - require.NoError(t, err, "get-group") - require.Equal(t, grpLifecycleGroup, aws.StringValue(got.Group.GroupName)) - require.Empty(t, got.Users, "new group must have zero members") - - harness.Step(t, "get-group nonexistent (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String("ghost-group")}) - return e - }) - - // ListGroups + PathPrefix scan. - harness.Step(t, "list-groups (>= 1)") - listed, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{}) - require.NoError(t, err, "list-groups") - require.GreaterOrEqual(t, len(listed.Groups), 1, "expected >= 1 group, got %d", len(listed.Groups)) - - harness.Step(t, "list-groups --path-prefix / (surfaces groups at /)") - pp, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{PathPrefix: aws.String("/")}) - require.NoError(t, err, "list-groups --path-prefix /") - require.GreaterOrEqual(t, len(pp.Groups), 1, "path-prefix / must surface groups at /") - - harness.Step(t, "list-groups --path-prefix /nonexistent/ (expect 0)") - none, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{PathPrefix: aws.String("/nonexistent/")}) - require.NoError(t, err, "list-groups --path-prefix /nonexistent/") - require.Empty(t, none.Groups, "no group lives under /nonexistent/") - - // Member for the membership + guard sub-steps. - harness.Step(t, "create-user %q (group member)", grpLifecycleUser) - _, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpLifecycleUser)}) - require.NoError(t, err, "create-user") - - // remove-user-from-group on a non-member surfaces NoSuchEntity (must not no-op). - harness.Step(t, "remove-user-from-group non-member (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ - GroupName: aws.String(grpLifecycleGroup), - UserName: aws.String(grpLifecycleUser), - }) - return e - }) - - // Ghost-entity binds → NoSuchEntity. Group is validated before the user. - harness.Step(t, "add-user-to-group ghost group (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{ - GroupName: aws.String("ghost-group"), - UserName: aws.String(grpLifecycleUser), - }) - return e - }) - harness.Step(t, "add-user-to-group ghost user (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{ - GroupName: aws.String(grpLifecycleGroup), - UserName: aws.String("ghost-user"), - }) - return e - }) - - // AddUserToGroup — idempotent re-add must not duplicate the membership. - harness.Step(t, "add-user-to-group %s <- %s", grpLifecycleGroup, grpLifecycleUser) - _, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{ - GroupName: aws.String(grpLifecycleGroup), - UserName: aws.String(grpLifecycleUser), - }) - require.NoError(t, err, "add-user-to-group") - - _, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{ - GroupName: aws.String(grpLifecycleGroup), - UserName: aws.String(grpLifecycleUser), - }) - require.NoError(t, err, "idempotent re-add") - - // ListGroupsForUser — reverse lookup sees exactly the one group. - harness.Step(t, "list-groups-for-user %s (expect 1)", grpLifecycleUser) - forUser, err := fix.AWS.IAM.ListGroupsForUser(&iam.ListGroupsForUserInput{ - UserName: aws.String(grpLifecycleUser), - }) - require.NoError(t, err, "list-groups-for-user") - require.Len(t, forUser.Groups, 1, "user should belong to exactly one group") - require.Equal(t, grpLifecycleGroup, aws.StringValue(forUser.Groups[0].GroupName)) - - // GetGroup — member now visible from the group side. - harness.Step(t, "get-group %q (member visible)", grpLifecycleGroup) - got, err = fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - require.NoError(t, err, "get-group after add") - require.Len(t, got.Users, 1, "group should list exactly one member") - require.Equal(t, grpLifecycleUser, aws.StringValue(got.Users[0].UserName)) - - // AttachGroupPolicy — idempotent re-attach must not grow the count. - harness.Step(t, "create-policy %s (grants ec2:DescribeRegions)", grpLifecyclePolicy) - _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ - PolicyName: aws.String(grpLifecyclePolicy), - PolicyDocument: aws.String(grpPolicyDoc), - }) - require.NoError(t, err, "create-policy") - - harness.Step(t, "attach-group-policy unknown policy (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")), - }) - return e - }) - - harness.Step(t, "attach-group-policy %s <- %s", grpLifecycleGroup, grpLifecyclePolicy) - _, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyArn: aws.String(policyARN), - }) - require.NoError(t, err, "attach-group-policy") - - attached, err := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{ - GroupName: aws.String(grpLifecycleGroup), - }) - require.NoError(t, err, "list-attached-group-policies") - require.Len(t, attached.AttachedPolicies, 1) - require.Equal(t, policyARN, aws.StringValue(attached.AttachedPolicies[0].PolicyArn)) - - _, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyArn: aws.String(policyARN), - }) - require.NoError(t, err, "idempotent re-attach") - reAttached, err := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{ - GroupName: aws.String(grpLifecycleGroup), - }) - require.NoError(t, err) - require.Len(t, reAttached.AttachedPolicies, 1, "re-attach must be idempotent") - - harness.Step(t, "list-attached-group-policies ghost group (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{ - GroupName: aws.String("ghost-group"), - }) - return e - }) - - // --- Inline group policies (put / get / list) --- - - harness.Step(t, "put-group-policy ghost group (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.PutGroupPolicy(&iam.PutGroupPolicyInput{ - GroupName: aws.String("ghost-group"), - PolicyName: aws.String(grpLifecycleInline), - PolicyDocument: aws.String(grpPolicyDoc), - }) - return e - }) - - // PutGroupPolicy — upsert; re-put with the same name must overwrite, not add. - harness.Step(t, "put-group-policy %s/%s (grants ec2:DescribeRegions)", grpLifecycleGroup, grpLifecycleInline) - _, err = fix.AWS.IAM.PutGroupPolicy(&iam.PutGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyName: aws.String(grpLifecycleInline), - PolicyDocument: aws.String(grpPolicyDoc), - }) - require.NoError(t, err, "put-group-policy") - - _, err = fix.AWS.IAM.PutGroupPolicy(&iam.PutGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyName: aws.String(grpLifecycleInline), - PolicyDocument: aws.String(grpPolicyDoc), - }) - require.NoError(t, err, "idempotent re-put") - - // GetGroupPolicy — round-trips the stored document (raw, not URL-encoded). - harness.Step(t, "get-group-policy %s/%s (round-trip)", grpLifecycleGroup, grpLifecycleInline) - inlineGot, err := fix.AWS.IAM.GetGroupPolicy(&iam.GetGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyName: aws.String(grpLifecycleInline), - }) - require.NoError(t, err, "get-group-policy") - require.Equal(t, grpLifecycleGroup, aws.StringValue(inlineGot.GroupName)) - require.Equal(t, grpLifecycleInline, aws.StringValue(inlineGot.PolicyName)) - require.JSONEq(t, grpPolicyDoc, aws.StringValue(inlineGot.PolicyDocument), - "get-group-policy must round-trip the stored document") - - harness.Step(t, "get-group-policy unknown name (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.GetGroupPolicy(&iam.GetGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyName: aws.String("ghost-inline"), - }) - return e - }) - - // ListGroupPolicies — surfaces exactly the one inline name, never truncated. - harness.Step(t, "list-group-policies %s (expect 1)", grpLifecycleGroup) - inlineList, err := fix.AWS.IAM.ListGroupPolicies(&iam.ListGroupPoliciesInput{ - GroupName: aws.String(grpLifecycleGroup), - }) - require.NoError(t, err, "list-group-policies") - require.Len(t, inlineList.PolicyNames, 1, "exactly one inline policy expected") - require.Equal(t, grpLifecycleInline, aws.StringValue(inlineList.PolicyNames[0])) - require.False(t, aws.BoolValue(inlineList.IsTruncated), "list-group-policies is never truncated") - - harness.Step(t, "list-group-policies ghost group (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.ListGroupPolicies(&iam.ListGroupPoliciesInput{ - GroupName: aws.String("ghost-group"), - }) - return e - }) - - // --- Deletion guards --- - - // delete-user while still a group member → DeleteConflict. - harness.Step(t, "delete-user while in group (expect DeleteConflict)") - harness.ExpectError(t, "DeleteConflict", func() error { - _, e := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(grpLifecycleUser)}) - return e - }) - - // delete-group while non-empty AND attached → DeleteConflict. - harness.Step(t, "delete-group while member + policy present (expect DeleteConflict)") - harness.ExpectError(t, "DeleteConflict", func() error { - _, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - return e - }) - - // Remove the member; the attached-policy guard alone must still block delete. - harness.Step(t, "remove-user-from-group %s", grpLifecycleUser) - _, err = fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ - GroupName: aws.String(grpLifecycleGroup), - UserName: aws.String(grpLifecycleUser), - }) - require.NoError(t, err, "remove-user-from-group") - - harness.Step(t, "delete-group while only policy attached (expect DeleteConflict)") - harness.ExpectError(t, "DeleteConflict", func() error { - _, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - return e - }) - - // detach a policy that isn't attached → NoSuchEntity. - harness.Step(t, "detach-group-policy not-attached (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")), - }) - return e - }) - - // --- Teardown (asserted) --- - - harness.Step(t, "detach-group-policy %s", grpLifecyclePolicy) - _, err = fix.AWS.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyArn: aws.String(policyARN), - }) - require.NoError(t, err, "detach-group-policy") - - // With the managed policy gone, the inline policy alone must still block the - // delete — the new inline guard, symmetric with the attached-policy guard. - harness.Step(t, "delete-group while only inline policy present (expect DeleteConflict)") - harness.ExpectError(t, "DeleteConflict", func() error { - _, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - return e - }) - - harness.Step(t, "delete-group-policy unknown name (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyName: aws.String("ghost-inline"), - }) - return e - }) - - harness.Step(t, "delete-group-policy %s", grpLifecycleInline) - _, err = fix.AWS.IAM.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ - GroupName: aws.String(grpLifecycleGroup), - PolicyName: aws.String(grpLifecycleInline), - }) - require.NoError(t, err, "delete-group-policy") - - harness.Step(t, "list-group-policies after delete (expect 0)") - emptyInline, err := fix.AWS.IAM.ListGroupPolicies(&iam.ListGroupPoliciesInput{ - GroupName: aws.String(grpLifecycleGroup), - }) - require.NoError(t, err, "list-group-policies after delete") - require.Empty(t, emptyInline.PolicyNames, "inline policy must be gone after delete-group-policy") - - harness.Step(t, "delete-group %s", grpLifecycleGroup) - _, err = fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - require.NoError(t, err, "delete-group") - - harness.Step(t, "get-group after delete (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)}) - return e - }) - - // User is no longer a member, so DeleteUser now succeeds. - harness.Step(t, "delete-user %s (no longer in a group)", grpLifecycleUser) - _, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(grpLifecycleUser)}) - require.NoError(t, err, "delete-user") - - _, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)}) - require.NoError(t, err, "delete-policy") -} - -// runIAMGroupEnforcement proves the linchpin: a policy attached to a group -// actually grants its permission to every member. A user with its own access -// key is denied a guarded action; once a policy granting that action is attached -// to a group the user joins, the same live credentials are allowed (policies -// resolved per request); after leaving the group the action is denied again. -// Modelled on runAssumedRoleControlPlaneEnforcement. -func runIAMGroupEnforcement(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Group authorization enforcement") - adminAccount := harness.IAMAccountID(t, fix.AWS) - policyARN := harness.IAMPolicyARN(adminAccount, grpEnforcePolicy) - - sweep := func() { - harness.IAMDeleteGroupBestEffort(fix.AWS, grpEnforceGroup, []string{grpEnforceUser}, policyARN) - iamDeleteUserBestEffort(fix, grpEnforceUser) - iamDeletePolicyBestEffort(fix, policyARN) - } - sweep() - fix.Harness.RegisterCleanup(sweep) - - // Member with its own static credentials. - harness.Step(t, "create-user %q + access key", grpEnforceUser) - _, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpEnforceUser)}) - require.NoError(t, err, "create-user") - key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(grpEnforceUser)}) - require.NoError(t, err, "create-access-key") - memberCli := harness.NewAWSClientWithCreds(t, fix.Env, - aws.StringValue(key.AccessKey.AccessKeyId), - aws.StringValue(key.AccessKey.SecretAccessKey)) - - // No policy anywhere yet: the active key authenticates, then default-denies. - harness.Step(t, "describe-regions with no grant (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) - - // Group + policy granting ec2:DescribeRegions, then join the user. - harness.Step(t, "create-group %q", grpEnforceGroup) - _, err = fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpEnforceGroup)}) - require.NoError(t, err, "create-group") - - harness.Step(t, "create+attach policy %q to group (grants ec2:DescribeRegions)", grpEnforcePolicy) - _, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{ - PolicyName: aws.String(grpEnforcePolicy), - PolicyDocument: aws.String(grpPolicyDoc), - }) - require.NoError(t, err, "create-policy") - _, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ - GroupName: aws.String(grpEnforceGroup), - PolicyArn: aws.String(policyARN), - }) - require.NoError(t, err, "attach-group-policy") - - harness.Step(t, "add-user-to-group %s <- %s", grpEnforceGroup, grpEnforceUser) - _, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{ - GroupName: aws.String(grpEnforceGroup), - UserName: aws.String(grpEnforceUser), - }) - require.NoError(t, err, "add-user-to-group") - - // Same live credentials are now allowed — the grant flows through the group. - harness.Step(t, "describe-regions after group grant (expect success)") - _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - require.NoError(t, err, "describe-regions must be allowed once the group grants it") - - // Leave the group and the inherited grant must evaporate. - harness.Step(t, "remove-user-from-group %s", grpEnforceUser) - _, err = fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ - GroupName: aws.String(grpEnforceGroup), - UserName: aws.String(grpEnforceUser), - }) - require.NoError(t, err, "remove-user-from-group") - - harness.Step(t, "describe-regions after leaving group (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) -} - -// runIAMGroupInlineEnforcement proves the inline-policy linchpin: an inline -// policy embedded in a group grants its permission to every member, exactly as a -// managed attachment does. A user with its own access key is denied a guarded -// action; once an inline policy granting that action is put on a group the user -// joins, the same live credentials are allowed (policies resolved per request); -// after the inline policy is deleted — with the user still a member — the action -// is denied again, proving the inline document was the grant source. Mirrors -// runIAMGroupEnforcement with put-group-policy in place of attach-group-policy. -func runIAMGroupInlineEnforcement(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Group inline-policy authorization enforcement") - - // No managed policy here — the inline doc lives in the group record, so the - // best-effort sweep clears it via ListGroupPolicies/DeleteGroupPolicy. - sweep := func() { - harness.IAMDeleteGroupBestEffort(fix.AWS, grpInlineEnforceGroup, []string{grpInlineEnforceUser}) - iamDeleteUserBestEffort(fix, grpInlineEnforceUser) - } - sweep() - fix.Harness.RegisterCleanup(sweep) - - // Member with its own static credentials. - harness.Step(t, "create-user %q + access key", grpInlineEnforceUser) - _, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpInlineEnforceUser)}) - require.NoError(t, err, "create-user") - key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(grpInlineEnforceUser)}) - require.NoError(t, err, "create-access-key") - memberCli := harness.NewAWSClientWithCreds(t, fix.Env, - aws.StringValue(key.AccessKey.AccessKeyId), - aws.StringValue(key.AccessKey.SecretAccessKey)) - - // No grant anywhere yet: the active key authenticates, then default-denies. - harness.Step(t, "describe-regions with no grant (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) - - // Group with an inline policy granting ec2:DescribeRegions, then join the user. - harness.Step(t, "create-group %q", grpInlineEnforceGroup) - _, err = fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpInlineEnforceGroup)}) - require.NoError(t, err, "create-group") - - harness.Step(t, "put-group-policy %q (grants ec2:DescribeRegions)", grpInlineEnforceName) - _, err = fix.AWS.IAM.PutGroupPolicy(&iam.PutGroupPolicyInput{ - GroupName: aws.String(grpInlineEnforceGroup), - PolicyName: aws.String(grpInlineEnforceName), - PolicyDocument: aws.String(grpPolicyDoc), - }) - require.NoError(t, err, "put-group-policy") - - harness.Step(t, "add-user-to-group %s <- %s", grpInlineEnforceGroup, grpInlineEnforceUser) - _, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{ - GroupName: aws.String(grpInlineEnforceGroup), - UserName: aws.String(grpInlineEnforceUser), - }) - require.NoError(t, err, "add-user-to-group") - - // Same live credentials are now allowed — the inline grant flows through the group. - harness.Step(t, "describe-regions after inline group grant (expect success)") - _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - require.NoError(t, err, "describe-regions must be allowed once the group inline policy grants it") - - // Delete the inline policy while the user is still a member — the grant must - // evaporate, isolating the inline document as the sole grant source. - harness.Step(t, "delete-group-policy %s (user stays a member)", grpInlineEnforceName) - _, err = fix.AWS.IAM.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ - GroupName: aws.String(grpInlineEnforceGroup), - PolicyName: aws.String(grpInlineEnforceName), - }) - require.NoError(t, err, "delete-group-policy") - - harness.Step(t, "describe-regions after inline policy removed (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) -} diff --git a/tests/e2e/iam/iam_helpers_test.go b/tests/e2e/iam/iam_helpers_test.go deleted file mode 100644 index 478d99def..000000000 --- a/tests/e2e/iam/iam_helpers_test.go +++ /dev/null @@ -1,183 +0,0 @@ -//go:build e2e - -package iam - -import ( - "sync" - "testing" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/iam" -) - -// IAM state shared across the IAM phase tests, memoized at package scope via -// sync.Once. Cleanup is registered on fix.Harness to run at fixture teardown. -// All helpers tolerate "already exists" outcomes so concurrent phases converge. - -var ( - iamAliceOnce sync.Once - iamAliceErr error - iamBobOnce sync.Once - iamBobErr error - iamCharlieOnce sync.Once - iamCharlieErr error - - iamAliceKeyOnce sync.Once - iamAliceKeyID string - iamAliceKeySecret string - iamAliceKeyErr error - - iamBobKeyOnce sync.Once - iamBobKeyID string - iamBobKeySecret string - iamBobKeyErr error - - iamCharlieKeyOnce sync.Once - iamCharlieKeyID string - iamCharlieKeySecret string - iamCharlieKeyErr error -) - -// iamEnsureAlice ensures the canonical "alice" IAM user exists. The user is -// torn down at fixture teardown via iamDeleteUserBestEffort. -func iamEnsureAlice(t *testing.T, fix *Fixture) { - t.Helper() - iamAliceOnce.Do(func() { - iamAliceErr = iamCreateUserIdempotent(fix, iamUserAlice, "") - if iamAliceErr == nil { - fix.Harness.RegisterCleanup(func() { iamDeleteUserBestEffort(fix, iamUserAlice) }) - } - }) - if iamAliceErr != nil { - t.Fatalf("iamEnsureAlice: %v", iamAliceErr) - } -} - -// iamEnsureBob ensures the canonical "bob" IAM user (path /engineering/) -// exists. Torn down at fixture teardown. -func iamEnsureBob(t *testing.T, fix *Fixture) { - t.Helper() - iamBobOnce.Do(func() { - iamBobErr = iamCreateUserIdempotent(fix, iamUserBob, iamUserBobPath) - if iamBobErr == nil { - fix.Harness.RegisterCleanup(func() { iamDeleteUserBestEffort(fix, iamUserBob) }) - } - }) - if iamBobErr != nil { - t.Fatalf("iamEnsureBob: %v", iamBobErr) - } -} - -// iamEnsureCharlie ensures the canonical "charlie" IAM user exists. Torn down -// at fixture teardown. -func iamEnsureCharlie(t *testing.T, fix *Fixture) { - t.Helper() - iamCharlieOnce.Do(func() { - iamCharlieErr = iamCreateUserIdempotent(fix, iamUserCharlie, "") - if iamCharlieErr == nil { - fix.Harness.RegisterCleanup(func() { iamDeleteUserBestEffort(fix, iamUserCharlie) }) - } - }) - if iamCharlieErr != nil { - t.Fatalf("iamEnsureCharlie: %v", iamCharlieErr) - } -} - -// iamEnsureAliceKey returns alice's primary access key + secret, creating -// the key (and alice) on first call. -func iamEnsureAliceKey(t *testing.T, fix *Fixture) (keyID, secret string) { - t.Helper() - iamEnsureAlice(t, fix) - iamAliceKeyOnce.Do(func() { - iamAliceKeyID, iamAliceKeySecret, iamAliceKeyErr = iamCreateAccessKey(fix, iamUserAlice) - }) - if iamAliceKeyErr != nil { - t.Fatalf("iamEnsureAliceKey: %v", iamAliceKeyErr) - } - return iamAliceKeyID, iamAliceKeySecret -} - -// iamEnsureBobKey returns bob's primary access key + secret, creating the -// key (and bob) on first call. -func iamEnsureBobKey(t *testing.T, fix *Fixture) (keyID, secret string) { - t.Helper() - iamEnsureBob(t, fix) - iamBobKeyOnce.Do(func() { - iamBobKeyID, iamBobKeySecret, iamBobKeyErr = iamCreateAccessKey(fix, iamUserBob) - }) - if iamBobKeyErr != nil { - t.Fatalf("iamEnsureBobKey: %v", iamBobKeyErr) - } - return iamBobKeyID, iamBobKeySecret -} - -// iamEnsureCharlieKey returns charlie's primary access key + secret, -// creating the key (and charlie) on first call. -func iamEnsureCharlieKey(t *testing.T, fix *Fixture) (keyID, secret string) { - t.Helper() - iamEnsureCharlie(t, fix) - iamCharlieKeyOnce.Do(func() { - iamCharlieKeyID, iamCharlieKeySecret, iamCharlieKeyErr = iamCreateAccessKey(fix, iamUserCharlie) - }) - if iamCharlieKeyErr != nil { - t.Fatalf("iamEnsureCharlieKey: %v", iamCharlieKeyErr) - } - return iamCharlieKeyID, iamCharlieKeySecret -} - -// iamCreateUserIdempotent creates a user, treating EntityAlreadyExists as success. -func iamCreateUserIdempotent(fix *Fixture, name, path string) error { - input := &iam.CreateUserInput{UserName: aws.String(name)} - if path != "" { - input.Path = aws.String(path) - } - _, err := fix.AWS.IAM.CreateUser(input) - if err == nil { - return nil - } - if iamIsEntityAlreadyExists(err) { - return nil - } - return err -} - -// iamDeleteAllKeys drops every access key for user. Used as a "clean slate" -// before tests that assert against the AWS 2-key per-user cap. -func iamDeleteAllKeys(fix *Fixture, user string) { - keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ - UserName: aws.String(user), - }) - if err != nil { - return - } - for _, k := range keys.AccessKeyMetadata { - _, _ = fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{ - UserName: aws.String(user), - AccessKeyId: k.AccessKeyId, - }) - } -} - -// iamCreateAccessKey creates a fresh access key on user. Returns the -// (id, secret) pair plus any error surfaced by the API. -func iamCreateAccessKey(fix *Fixture, user string) (string, string, error) { - out, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{ - UserName: aws.String(user), - }) - if err != nil { - return "", "", err - } - return aws.StringValue(out.AccessKey.AccessKeyId), - aws.StringValue(out.AccessKey.SecretAccessKey), - nil -} - -// iamIsEntityAlreadyExists reports whether err is an AWS EntityAlreadyExists -// surface, used by idempotent create helpers. -func iamIsEntityAlreadyExists(err error) bool { - type coder interface{ Code() string } - if c, ok := err.(coder); ok { - return c.Code() == "EntityAlreadyExists" - } - return false -} diff --git a/tests/e2e/iam/iam_roles_test.go b/tests/e2e/iam/iam_roles_test.go deleted file mode 100644 index 6ee3e4c52..000000000 --- a/tests/e2e/iam/iam_roles_test.go +++ /dev/null @@ -1,331 +0,0 @@ -//go:build e2e - -package iam - -import ( - "testing" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/iam" - "github.com/mulgadc/spinifex/tests/e2e/harness" - "github.com/stretchr/testify/require" -) - -// Cross-phase identifiers for IAM Role / InstanceProfile tests. The names -// mirror the bash driver (run-e2e.sh IAM Phase 8 / 9) so a future -// side-by-side diff stays greppable. -const ( - iamRoleAppName = "app-role" - iamProfileAppName = "app-profile" - iamProfileOtherName = "other-profile" - iamProfileEngName = "eng-profile" - iamProfileEngPath = "/eng/" - iamPolicyAdministrator = "AdministratorAccess" - iamTrustPolicyEC2Standard = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}` - iamTrustPolicyEC2V2 = `{"Version":"2012-10-17","Statement":[{"Sid":"v2","Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}` -) - -// runIAMRolesAndProfiles ports run-e2e.sh IAM Phase 8: Role + InstanceProfile -// CRUD, attach-role-policy, add-role-to-instance-profile, the one-role and -// delete-while-referenced guards, and full teardown. Runs sequentially under -// TestIAMRolesAndProfiles so cleanup never collides with TestIAMInstanceProfileAssociation -// which recreates the same names. -func runIAMRolesAndProfiles(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Roles & Instance Profiles") - adminAccount := harness.IAMAccountID(t, fix.AWS) - roleARN := harness.IAMRoleARN(adminAccount, iamRoleAppName) - adminPolicyARN := harness.IAMPolicyARN(adminAccount, iamPolicyAdministrator) - - // Defensive sweep — previous failed run may have left fragments. Order - // matters: detach role from profile before delete-instance-profile, - // detach policy from role before delete-role. - harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, iamRoleAppName, - []string{iamProfileAppName, iamProfileOtherName, iamProfileEngName}, adminPolicyARN) - fix.Harness.RegisterCleanup(func() { - harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, iamRoleAppName, - []string{iamProfileAppName, iamProfileOtherName, iamProfileEngName}, adminPolicyARN) - }) - - // CreateRole — happy path with non-default description. - harness.Step(t, "create-role %q", iamRoleAppName) - createOut, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String(iamRoleAppName), - AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard), - Description: aws.String("E2E test role"), - }) - require.NoError(t, err, "create-role") - require.Equal(t, iamRoleAppName, aws.StringValue(createOut.Role.RoleName)) - require.Equal(t, roleARN, aws.StringValue(createOut.Role.Arn), - "role ARN must follow arn:aws:iam:::role/") - harness.Detail(t, "role", iamRoleAppName, "arn", roleARN) - - // Duplicate. - harness.Step(t, "create-role duplicate (expect EntityAlreadyExists)") - harness.ExpectError(t, "EntityAlreadyExists", func() error { - _, e := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String(iamRoleAppName), - AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard), - }) - return e - }) - - // Malformed trust policy → MalformedPolicyDocument. - harness.Step(t, "create-role malformed trust policy (expect MalformedPolicyDocument)") - harness.ExpectError(t, "MalformedPolicyDocument", func() error { - _, e := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String("bad-role"), - AssumeRolePolicyDocument: aws.String(`{not valid json`), - }) - return e - }) - - // GetRole + NoSuchEntity probe. - harness.Step(t, "get-role %q", iamRoleAppName) - got, err := fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String(iamRoleAppName)}) - require.NoError(t, err, "get-role") - require.Equal(t, iamRoleAppName, aws.StringValue(got.Role.RoleName)) - - harness.Step(t, "get-role nonexistent (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String("ghost-role")}) - return e - }) - - // ListRoles + PathPrefix scan. - harness.Step(t, "list-roles (>= 1)") - listed, err := fix.AWS.IAM.ListRoles(&iam.ListRolesInput{}) - require.NoError(t, err, "list-roles") - require.GreaterOrEqual(t, len(listed.Roles), 1, - "expected >= 1 role, got %d", len(listed.Roles)) - - harness.Step(t, "list-roles --path-prefix /") - pp, err := fix.AWS.IAM.ListRoles(&iam.ListRolesInput{PathPrefix: aws.String("/")}) - require.NoError(t, err, "list-roles --path-prefix /") - require.GreaterOrEqual(t, len(pp.Roles), 1, "path-prefix / must surface roles at /") - - // UpdateRole — description + MaxSessionDuration round-trip + range guard. - harness.Step(t, "update-role description + max-session-duration=7200") - _, err = fix.AWS.IAM.UpdateRole(&iam.UpdateRoleInput{ - RoleName: aws.String(iamRoleAppName), - Description: aws.String("updated"), - MaxSessionDuration: aws.Int64(7200), - }) - require.NoError(t, err, "update-role") - got, err = fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String(iamRoleAppName)}) - require.NoError(t, err, "get-role after update") - require.Equal(t, "updated", aws.StringValue(got.Role.Description)) - require.Equal(t, int64(7200), aws.Int64Value(got.Role.MaxSessionDuration)) - - // Server-side MaxSessionDuration range guard (900-43200) isn't reachable - // via the AWS SDK: UpdateRoleInput carries min:"3600" so SDK.Validate() - // blocks values < 3600 before dispatch, and botocore enforces the upper - // bound the same way. The gateway guard is covered by - // handlers/iam/roles_test.go TestCreateRole_MaxSessionDuration_TooSmall. - - // UpdateAssumeRolePolicy — swap document, no enforcement yet. - harness.Step(t, "update-assume-role-policy") - _, err = fix.AWS.IAM.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{ - RoleName: aws.String(iamRoleAppName), - PolicyDocument: aws.String(iamTrustPolicyEC2V2), - }) - require.NoError(t, err, "update-assume-role-policy") - - // AttachRolePolicy — idempotent re-attach must not grow the count. - harness.Step(t, "attach-role-policy %s <- %s", iamRoleAppName, iamPolicyAdministrator) - _, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ - RoleName: aws.String(iamRoleAppName), - PolicyArn: aws.String(adminPolicyARN), - }) - require.NoError(t, err, "attach-role-policy") - - attached, err := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ - RoleName: aws.String(iamRoleAppName), - }) - require.NoError(t, err, "list-attached-role-policies") - require.Len(t, attached.AttachedPolicies, 1) - - _, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ - RoleName: aws.String(iamRoleAppName), - PolicyArn: aws.String(adminPolicyARN), - }) - require.NoError(t, err, "idempotent re-attach") - reAttached, err := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ - RoleName: aws.String(iamRoleAppName), - }) - require.NoError(t, err) - require.Len(t, reAttached.AttachedPolicies, 1, "re-attach must be idempotent") - - // Attach unknown policy → NoSuchEntity. - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ - RoleName: aws.String(iamRoleAppName), - PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")), - }) - return e - }) - - // List attached policies for a role that doesn't exist → NoSuchEntity - // (must not be masked into an empty list). - harness.Step(t, "list-attached-role-policies ghost role (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ - RoleName: aws.String("ghost-role"), - }) - return e - }) - - // CreateInstanceProfile — primary + replace-target. - harness.Step(t, "create-instance-profile %q", iamProfileAppName) - prim, err := fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - }) - require.NoError(t, err, "create-instance-profile primary") - require.Equal(t, iamProfileAppName, aws.StringValue(prim.InstanceProfile.InstanceProfileName)) - - harness.Step(t, "create-instance-profile %q", iamProfileOtherName) - _, err = fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileOtherName), - }) - require.NoError(t, err, "create-instance-profile other") - - harness.Step(t, "create-instance-profile duplicate (expect EntityAlreadyExists)") - harness.ExpectError(t, "EntityAlreadyExists", func() error { - _, e := fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - }) - return e - }) - - // GetInstanceProfile / ListInstanceProfiles. - gotProf, err := fix.AWS.IAM.GetInstanceProfile(&iam.GetInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - }) - require.NoError(t, err, "get-instance-profile") - require.Equal(t, iamProfileAppName, aws.StringValue(gotProf.InstanceProfile.InstanceProfileName)) - - harness.Step(t, "get-instance-profile ghost (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.GetInstanceProfile(&iam.GetInstanceProfileInput{ - InstanceProfileName: aws.String("ghost-profile"), - }) - return e - }) - - listedProf, err := fix.AWS.IAM.ListInstanceProfiles(&iam.ListInstanceProfilesInput{}) - require.NoError(t, err, "list-instance-profiles") - require.GreaterOrEqual(t, len(listedProf.InstanceProfiles), 2) - - // Explicit Path → the ARN carries the path between instance-profile and the - // name (arn:...:instance-profile/eng/). Created, asserted, and torn - // down inline so it doesn't perturb the count assertions above. - harness.Step(t, "create-instance-profile %q path=%s (ARN carries the path)", iamProfileEngName, iamProfileEngPath) - engProf, err := fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileEngName), - Path: aws.String(iamProfileEngPath), - }) - require.NoError(t, err, "create-instance-profile with explicit path") - require.Equal(t, - "arn:aws:iam::"+adminAccount+":instance-profile"+iamProfileEngPath+iamProfileEngName, - aws.StringValue(engProf.InstanceProfile.Arn), - "explicit-Path instance-profile ARN must embed the path") - _, err = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileEngName), - }) - require.NoError(t, err, "delete-instance-profile (explicit path)") - - // AddRoleToInstanceProfile — primary; second add rejected by one-role limit. - harness.Step(t, "add-role-to-instance-profile %s <- %s", iamProfileAppName, iamRoleAppName) - _, err = fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - RoleName: aws.String(iamRoleAppName), - }) - require.NoError(t, err, "add-role-to-instance-profile primary") - - harness.Step(t, "add-role-to-instance-profile second role (expect LimitExceeded)") - harness.ExpectError(t, "LimitExceeded", func() error { - _, e := fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - RoleName: aws.String(iamRoleAppName), - }) - return e - }) - - // Ghost-entity binds → NoSuchEntity. Role is validated before the profile, so - // a real profile + ghost role and a real role + ghost profile both surface - // NoSuchEntity rather than silently no-op'ing. - harness.Step(t, "add-role-to-instance-profile ghost role (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileOtherName), - RoleName: aws.String("ghost-role"), - }) - return e - }) - harness.Step(t, "add-role-to-instance-profile ghost profile (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ - InstanceProfileName: aws.String("ghost-profile"), - RoleName: aws.String(iamRoleAppName), - }) - return e - }) - - // ListInstanceProfilesForRole — reverse lookup. - rev, err := fix.AWS.IAM.ListInstanceProfilesForRole(&iam.ListInstanceProfilesForRoleInput{ - RoleName: aws.String(iamRoleAppName), - }) - require.NoError(t, err, "list-instance-profiles-for-role") - require.Len(t, rev.InstanceProfiles, 1, - "reverse lookup should see only the profile we added") - - // DeleteRole guards: attached policy + still referenced by a profile. - harness.Step(t, "delete-role while attached (expect DeleteConflict)") - harness.ExpectError(t, "DeleteConflict", func() error { - _, e := fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(iamRoleAppName)}) - return e - }) - - // DeleteInstanceProfile guard: role still attached. - harness.Step(t, "delete-instance-profile while role attached (expect DeleteConflict)") - harness.ExpectError(t, "DeleteConflict", func() error { - _, e := fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - }) - return e - }) - - // Full teardown so TestIAMInstanceProfileAssociation gets a clean slate. - // remove-role then delete-profile, detach-policy then delete-role, in - // that order — same as iamDeleteRoleAndProfilesBestEffort but asserted. - harness.Step(t, "remove-role-from-instance-profile %s", iamProfileAppName) - _, err = fix.AWS.IAM.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - RoleName: aws.String(iamRoleAppName), - }) - require.NoError(t, err, "remove-role-from-instance-profile") - - _, err = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileAppName), - }) - require.NoError(t, err, "delete-instance-profile primary") - - _, err = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ - InstanceProfileName: aws.String(iamProfileOtherName), - }) - require.NoError(t, err, "delete-instance-profile other") - - _, err = fix.AWS.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{ - RoleName: aws.String(iamRoleAppName), - PolicyArn: aws.String(adminPolicyARN), - }) - require.NoError(t, err, "detach-role-policy") - - _, err = fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(iamRoleAppName)}) - require.NoError(t, err, "delete-role") - - harness.Step(t, "get-role after delete (expect NoSuchEntity)") - harness.ExpectError(t, "NoSuchEntity", func() error { - _, e := fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String(iamRoleAppName)}) - return e - }) -} diff --git a/tests/e2e/iam/iam_test.go b/tests/e2e/iam/iam_test.go deleted file mode 100644 index 35d33a18e..000000000 --- a/tests/e2e/iam/iam_test.go +++ /dev/null @@ -1,874 +0,0 @@ -//go:build e2e - -package iam - -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": "*" - }] - }` -) - -// runIAMUserCRUD ports run-e2e.sh IAM Phase 1 (~1405–1459): -// CreateUser, GetUser, ListUsers, DeleteUser plus the -// EntityAlreadyExists / NoSuchEntity negative paths. Each created user -// is scheduled for cleanup so a mid-phase failure can't poison later -// runs. -func runIAMUserCRUD(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM User CRUD") - - // Defensive: drop leftovers from a prior failed run before recreate. - iamDeleteUserBestEffort(fix, iamUserAlice) - iamDeleteUserBestEffort(fix, iamUserBob) - - // Register parent-scoped cleanup BEFORE create so a mid-phase panic - // still tears down. Parent scope (TestSingleNode) keeps alice/bob - // reachable through phases 2–7; LIFO ordering means policy cleanup - // (phase 4) runs before user cleanup here. Phase 7 still runs the - // same logic — these registrations are an idempotent safety net for - // partial / out-of-order runs. - fix.Harness.RegisterCleanup(func() { - iamDeleteUserBestEffort(fix, iamUserAlice) - iamDeleteUserBestEffort(fix, iamUserBob) - iamDeleteUserBestEffort(fix, iamUserCharlie) - }) - - 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)) - - // 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 - }) -} - -// runIAMAccessKeyLifecycle 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 runIAMAccessKeyLifecycle(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Access Key Lifecycle") - iamEnsureAlice(t, fix) - iamEnsureBob(t, fix) - - // Drop any leftover keys so the LimitExceeded sub-step still bites at - // the AWS 2-key cap when this Test* runs in isolation. - iamDeleteAllKeys(fix, iamUserAlice) - - 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") - aliceKeyID := aws.StringValue(k1.AccessKey.AccessKeyId) - aliceSecret := aws.StringValue(k1.AccessKey.SecretAccessKey) - require.NotEmpty(t, aliceKeyID, "empty AccessKeyId") - require.NotEmpty(t, aliceSecret, "empty SecretAccessKey") - harness.Detail(t, "key1", aliceKeyID) - - 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", aliceKeyID) - _, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ - UserName: aws.String(iamUserAlice), - AccessKeyId: aws.String(aliceKeyID), - Status: aws.String(iam.StatusTypeInactive), - }) - require.NoError(t, err, "update-access-key deactivate") - require.Equal(t, iam.StatusTypeInactive, - iamFindKeyStatus(t, fix, iamUserAlice, aliceKeyID), - "key not Inactive after update") - - harness.Step(t, "update-access-key %s -> Active", aliceKeyID) - _, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ - UserName: aws.String(iamUserAlice), - AccessKeyId: aws.String(aliceKeyID), - Status: aws.String(iam.StatusTypeActive), - }) - require.NoError(t, err, "update-access-key reactivate") - _ = aliceSecret // captured for parity with helpers; not consumed by IAM2 itself. - - 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") -} - -// runIAMUserAuthentication ports IAM Phase 3 (~1538–1579): build a scoped -// AWS client with alice's key and confirm signing is honoured (active key) -// / rejected (deactivated key, bad secret, bogus ID). Alice has no policy in -// this phase, so the authenticated call is authz-denied — AccessDenied (not -// an authn error) proves the active key signed and was accepted. Also creates -// bob's key so Phase 5 enforcement / Phase 7 cleanup can use it. -func runIAMUserAuthentication(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM User Authentication") - aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix) - - // Active, correctly-signed key with no policy: the request authenticates - // then default-denies. AccessDenied (vs the InvalidClientTokenId / - // SignatureDoesNotMatch below) is the signal that authn succeeded. - harness.Step(t, "scoped client (alice) describe-instances — active key authenticates (AccessDenied, no policy)") - aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret) - harness.ExpectError(t, "AccessDenied", func() error { - _, e := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) - return e - }) - - harness.Step(t, "deactivate alice key, expect InvalidClientTokenId") - _, err := fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{ - UserName: aws.String(iamUserAlice), - AccessKeyId: aws.String(aliceKeyID), - 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(aliceKeyID), - 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, aliceKeyID, "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 - }) - - bobKeyID, _ := iamEnsureBobKey(t, fix) - harness.Detail(t, "bob_key", bobKeyID) - - harness.Step(t, "root auth still OK") - _, err = fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) - require.NoError(t, err, "root describe-instances") -} - -// runIAMPolicyCRUD ports IAM Phase 4 (~1583–1698): -// CreatePolicy (5 variants), GetPolicy, GetPolicyVersion, ListPolicies -// plus EntityAlreadyExists / MalformedPolicyDocument / NoSuchEntity -// errors. Captures the admin account ID into the Fixture for Phase 5–7. -func runIAMPolicyCRUD(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM 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") - adminAccount := iamAccountFromARN(t, ec2roArn) - - // Parent-scoped cleanup of every policy this phase creates. Registered - // once we have the account ID so the ARN constructor works. LIFO ⇒ - // runs before phase 1's user cleanup (DetachUserPolicy from inside - // iamDeleteUserBestEffort still works either way; this just removes - // the policy faster on partial-run cleanup). - fix.Harness.RegisterCleanup(func() { - for _, p := range []struct{ name, path string }{ - {iamPolicyEC2ReadOnly, ""}, - {iamPolicyFullAdmin, iamPolicyFullAdminPath}, - {iamPolicyIAMReadOnly, ""}, - {iamPolicyEC2DescribeAll, ""}, - {iamPolicyDenyTerminate, ""}, - } { - key := p.name - if p.path != "" { - key = p.path[1:] + p.name - } - iamDeletePolicyBestEffort(fix, harness.IAMPolicyARN(adminAccount, key)) - } - }) - harness.Detail(t, "policy", iamPolicyEC2ReadOnly, "arn", ec2roArn, "account", adminAccount) - - // EC2ReadOnly is seeded sequentially above because it populates - // the admin-account ID (read by Phase 5). The remaining four policies - // have no cross-dependency, so fan them out in parallel under a - // wrapping t.Run that blocks until all four complete. - t.Run("create_policies_parallel", func(t *testing.T) { - t.Run(iamPolicyFullAdmin, func(t *testing.T) { - t.Parallel() - 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) - }) - t.Run(iamPolicyDenyTerminate, func(t *testing.T) { - t.Parallel() - 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) - }) - t.Run(iamPolicyIAMReadOnly, func(t *testing.T) { - t.Parallel() - 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) - }) - t.Run(iamPolicyEC2DescribeAll, func(t *testing.T) { - t.Parallel() - 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 := harness.IAMPolicyARN(adminAccount, "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)) - - 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)) -} - -// runIAMPolicyAttachmentEnforcement ports IAM Phase 5 (~1702–1833): -// AttachUserPolicy idempotency, ListAttachedUserPolicies, enforcement -// (default deny / explicit allow / wildcard / explicit deny / root -// bypass / prefix wildcard / FullAdmin), DetachUserPolicy. -func runIAMPolicyAttachmentEnforcement(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Policy Attachment & Enforcement") - adminAccount := harness.IAMAccountID(t, fix.AWS) - aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix) - bobKeyID, bobSecret := iamEnsureBobKey(t, fix) - charlieKeyID, charlieSecret := iamEnsureCharlieKey(t, fix) - - ec2roArn := harness.IAMPolicyARN(adminAccount, iamPolicyEC2ReadOnly) - iamroArn := harness.IAMPolicyARN(adminAccount, iamPolicyIAMReadOnly) - denyArn := harness.IAMPolicyARN(adminAccount, iamPolicyDenyTerminate) - descAllArn := harness.IAMPolicyARN(adminAccount, iamPolicyEC2DescribeAll) - fullAdminArn := harness.IAMPolicyARN(adminAccount, iamPolicyFullAdminPath[1:]+iamPolicyFullAdmin) - - harness.Detail(t, "charlie_key", charlieKeyID) - aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret) - bobCli := harness.NewAWSClientWithCreds(t, fix.Env, bobKeyID, bobSecret) - charlieCli := harness.NewAWSClientWithCreds(t, fix.Env, charlieKeyID, charlieSecret) - var err error - - 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(harness.IAMPolicyARN(adminAccount, "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") -} - -// runIAMPolicyLifecycle 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 runIAMPolicyLifecycle(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Policy Lifecycle (Detach & Delete)") - adminAccount := harness.IAMAccountID(t, fix.AWS) - aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix) - - descAllArn := harness.IAMPolicyARN(adminAccount, iamPolicyEC2DescribeAll) - denyArn := harness.IAMPolicyARN(adminAccount, iamPolicyDenyTerminate) - - aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret) - - 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 - }) -} - -// runIAMCleanup 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 runIAMCleanup(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM Cleanup") - - // Resolve account ID before deleting users — iamEnsureAdminAccountID - // probes via GetUser(alice), and the user is gone after the loop below. - adminAccount := harness.IAMAccountID(t, fix.AWS) - - // 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. - 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 := harness.IAMPolicyARN(adminAccount, 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 any inline policies so the inline-policy guard can't block the final - // DeleteUser after a partial run. - if inline, err := fix.AWS.IAM.ListUserPolicies(&iam.ListUserPoliciesInput{ - UserName: aws.String(user), - }); err == nil { - for _, name := range inline.PolicyNames { - _, _ = fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{ - UserName: aws.String(user), - PolicyName: name, - }) - } - } - // 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] -} diff --git a/tests/e2e/iam/iam_user_inline_test.go b/tests/e2e/iam/iam_user_inline_test.go deleted file mode 100644 index cf8003dd2..000000000 --- a/tests/e2e/iam/iam_user_inline_test.go +++ /dev/null @@ -1,88 +0,0 @@ -//go:build e2e - -package iam - -import ( - "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/require" -) - -const ( - usrInlineEnforceUser = "usr-e2e-inl-enf-user" - usrInlineEnforceName = "usr-e2e-inl-enf-describe-regions" - - // A single-action grant so the positive case proves the user's own inline - // document was resolved and evaluated — not a blanket allow. - usrInlinePolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}` -) - -// runIAMUserInlineEnforcement proves the user-inline linchpin: a policy embedded -// directly in a user record grants that user its permission with no group or -// managed attachment involved. A user with its own access key is denied a guarded -// action; once an inline policy granting that action is put on the user, the same -// live credentials are allowed (policies resolved per request); after the inline -// policy is deleted the action is denied again, isolating the user's own inline -// document as the sole grant source. Mirrors runIAMGroupInlineEnforcement without -// the group indirection. -func runIAMUserInlineEnforcement(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — IAM User inline-policy authorization enforcement") - - // No group or managed policy — the inline doc lives on the user record, so the - // best-effort sweep clears it via ListUserPolicies/DeleteUserPolicy. - sweep := func() { - iamDeleteUserBestEffort(fix, usrInlineEnforceUser) - } - sweep() - fix.Harness.RegisterCleanup(sweep) - - // User with its own static credentials. - harness.Step(t, "create-user %q + access key", usrInlineEnforceUser) - _, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(usrInlineEnforceUser)}) - require.NoError(t, err, "create-user") - key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(usrInlineEnforceUser)}) - require.NoError(t, err, "create-access-key") - userCli := harness.NewAWSClientWithCreds(t, fix.Env, - aws.StringValue(key.AccessKey.AccessKeyId), - aws.StringValue(key.AccessKey.SecretAccessKey)) - - // No grant anywhere yet: the active key authenticates, then default-denies. - harness.Step(t, "describe-regions with no grant (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) - - // Put an inline policy granting ec2:DescribeRegions directly on the user. - harness.Step(t, "put-user-policy %q (grants ec2:DescribeRegions)", usrInlineEnforceName) - _, err = fix.AWS.IAM.PutUserPolicy(&iam.PutUserPolicyInput{ - UserName: aws.String(usrInlineEnforceUser), - PolicyName: aws.String(usrInlineEnforceName), - PolicyDocument: aws.String(usrInlinePolicyDoc), - }) - require.NoError(t, err, "put-user-policy") - - // Same live credentials are now allowed — the user's own inline grant applies. - harness.Step(t, "describe-regions after inline user grant (expect success)") - _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - require.NoError(t, err, "describe-regions must be allowed once the user inline policy grants it") - - // Delete the inline policy — the grant must evaporate, isolating the inline - // document as the sole grant source. - harness.Step(t, "delete-user-policy %s", usrInlineEnforceName) - _, err = fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{ - UserName: aws.String(usrInlineEnforceUser), - PolicyName: aws.String(usrInlineEnforceName), - }) - require.NoError(t, err, "delete-user-policy") - - harness.Step(t, "describe-regions after inline policy removed (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) -} diff --git a/tests/e2e/iam/sts_test.go b/tests/e2e/iam/sts_test.go deleted file mode 100644 index 648216b4d..000000000 --- a/tests/e2e/iam/sts_test.go +++ /dev/null @@ -1,506 +0,0 @@ -//go:build e2e - -package iam - -import ( - "fmt" - "strings" - "testing" - "time" - - "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/aws/aws-sdk-go/service/sts" - "github.com/mulgadc/spinifex/tests/e2e/harness" - "github.com/stretchr/testify/require" -) - -// STS E2E identifiers. The role name carries an sts-e2e- prefix so it never -// collides with the IAM-roles suite (which owns "app-role" / "other-role"). -const ( - stsRoleName = "sts-e2e-role" - stsSessionName = "e2e-session-1" - - // Chained-assume target: a second role whose trust policy names the - // first role's IAM ARN. Exercises the role-ARN-clause vs. session-ARN- - // caller auto-expansion path in the trust-policy matcher. - stsRoleNameChain = "sts-e2e-role-chain" - stsSessionNameChain = "e2e-chain-session" - - // Role names used only by the write-time-rejection sub-tests. CreateRole - // is expected to fail before any persistence, so cleanup is best-effort. - stsRoleNameForbidCondition = "sts-e2e-forbid-condition" - stsRoleNameForbidNotPrincipal = "sts-e2e-forbid-notprincipal" - stsRoleNameForbidNotAction = "sts-e2e-forbid-notaction" - - // Trust policy with a wildcard AWS principal so the test isn't coupled to - // whatever user the bootstrap profile maps to (root vs. a named user). - stsTrustPolicyAllowAny = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` - - // Trust policy that names a principal which cannot exist in this cluster. - // Used to confirm a trust-policy denial surfaces AccessDenied without - // changing the role contract on the SDK side. - stsTrustPolicyDenyAny = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::999999999999:user/ghost"},"Action":"sts:AssumeRole"}]}` -) - -// runSTS covers the STS v1 surface: GetCallerIdentity smoke, the AssumeRole -// happy path (ASIA AKID + session token + ~1h expiry), use-the-session-token -// round-trip via GetCallerIdentity, the cross-service assumed-role denial at -// the gateway policy gate, chained assume (assumed-role session calls -// AssumeRole on a second role whose trust policy names the source role), -// trust-policy denial after UpdateAssumeRolePolicy, NoSuchEntity on a missing -// same-account role, and write-time rejection of forbidden trust-policy -// blocks (Condition / NotPrincipal / NotAction) via the SDK. -func runSTS(t *testing.T, fix *Fixture) { - harness.Phase(t, "Single — STS AssumeRole + GetCallerIdentity") - adminAccount := harness.IAMAccountID(t, fix.AWS) - - // Bootstrap-creds GetCallerIdentity — sanity check the live endpoint and - // capture the active ARN for the detail log so failures elsewhere are - // easier to attribute. - harness.Step(t, "get-caller-identity (bootstrap)") - who, err := fix.AWS.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{}) - require.NoError(t, err, "get-caller-identity (bootstrap)") - require.Equal(t, adminAccount, aws.StringValue(who.Account), - "bootstrap account mismatch") - require.NotEmpty(t, aws.StringValue(who.Arn), "empty caller ARN") - require.NotEmpty(t, aws.StringValue(who.UserId), "empty caller UserId") - harness.Detail(t, "arn", aws.StringValue(who.Arn), - "user_id", aws.StringValue(who.UserId)) - - // GetSessionToken: exchange the bootstrap user's long-lived (AKIA) creds for - // short-lived ASIA session creds bound to the SAME identity. Unlike - // AssumeRole, the session must resolve back to the original caller ARN, not - // an assumed-role ARN. This is the behaviour unit tests cannot fully prove — - // real HMAC token verification + wire round-trip + the user-principal branch - // in resolveSessionAKID. - harness.Step(t, "get-session-token (user creds → ASIA)") - beforeGST := time.Now().UTC() - gstOut, err := fix.AWS.STS.GetSessionToken(&sts.GetSessionTokenInput{}) - require.NoError(t, err, "get-session-token") - gstCreds := gstOut.Credentials - require.NotNil(t, gstCreds, "GetSessionToken returned nil Credentials") - gstAKID := aws.StringValue(gstCreds.AccessKeyId) - gstSecret := aws.StringValue(gstCreds.SecretAccessKey) - gstToken := aws.StringValue(gstCreds.SessionToken) - require.True(t, strings.HasPrefix(gstAKID, "ASIA"), - "session AKID must start with ASIA, got %q", gstAKID) - require.NotEmpty(t, gstSecret, "empty SecretAccessKey") - require.NotEmpty(t, gstToken, "empty SessionToken") - require.NotNil(t, gstCreds.Expiration, "nil Expiration") - - // Default DurationSeconds is 43200 (12h). Bound loosely so runner latency - // doesn't flake CI. - gstExpiresIn := gstCreds.Expiration.Sub(beforeGST) - require.Greater(t, gstExpiresIn, 11*time.Hour, - "get-session-token expiration too soon: %v (akid=%s)", gstExpiresIn, gstAKID) - require.LessOrEqual(t, gstExpiresIn, 12*time.Hour+5*time.Minute, - "get-session-token expiration too far: %v (akid=%s)", gstExpiresIn, gstAKID) - harness.Detail(t, "gst_akid", gstAKID, "gst_expires_in", gstExpiresIn.String()) - - // Drive the ASIA SigV4 path with the session creds: GetCallerIdentity must - // return the ORIGINAL caller identity (same Account/Arn/UserId as the - // bootstrap user), proving the user-principal branch resolves back to the - // user rather than synthesising an assumed-role ARN. - harness.Step(t, "get-caller-identity with get-session-token creds (expect same user)") - gstCli := harness.NewAWSClientWithSessionCreds(t, fix.Env, gstAKID, gstSecret, gstToken) - gstWho, err := gstCli.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{}) - require.NoError(t, err, "get-caller-identity (get-session-token)") - require.Equal(t, aws.StringValue(who.Account), aws.StringValue(gstWho.Account), - "get-session-token session account must match the calling user") - require.Equal(t, aws.StringValue(who.Arn), aws.StringValue(gstWho.Arn), - "get-session-token session must resolve to the user ARN, not an assumed-role ARN") - require.Equal(t, aws.StringValue(who.UserId), aws.StringValue(gstWho.UserId), - "get-session-token session UserId must match the calling user") - - // The user session must be authorised AS THE USER for non-STS actions: - // drive a real EC2 endpoint with the GetSessionToken creds and assert it is - // NOT denied. This is the only wire-level proof that gateway.checkPolicy - // evaluates policy against the user principal — the assumed-role path below - // hard-denies, so a unit test cannot exercise ctxPrincipalType=user reaching - // the EC2 dispatcher. - harness.Step(t, "ec2 describe-instances with get-session-token creds (expect authorised)") - _, err = gstCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{}) - require.NoError(t, err, "user session must be authorised for ec2:DescribeInstances as the user") - - // GetSessionToken is long-lived-user-only: a GetSessionToken session - // resolves back to principalType "user", so its ASIA access-key prefix is - // the only signal that it is a temporary credential. Replaying it into - // GetSessionToken must be denied — otherwise a captured session could roll - // its own lifetime forward forever. The wire path proves the handler's - // ASIA-prefix guard sees c.accessKey from the SigV4 context, which a unit - // test of the handler cannot exercise. - harness.Step(t, "get-session-token with get-session-token creds (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := gstCli.STS.GetSessionToken(&sts.GetSessionTokenInput{}) - return e - }) - - // Defensive sweep + parent-scoped cleanup so a mid-test panic still - // removes the role. iamDeleteRoleAndProfilesBestEffort tolerates a - // missing role. Cleans up every role the suite might create — the - // forbid-* roles are expected to fail at CreateRole but the sweep is - // cheap and protects against a future validator regression that lets - // one slip through. - stsRoles := []string{ - stsRoleName, - stsRoleNameChain, - stsRoleNameForbidCondition, - stsRoleNameForbidNotPrincipal, - stsRoleNameForbidNotAction, - } - for _, name := range stsRoles { - harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, name, nil) - } - fix.Harness.RegisterCleanup(func() { - for _, name := range stsRoles { - harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, name, nil) - } - }) - - harness.Step(t, "create-role %q (trust=AWS:*)", stsRoleName) - createOut, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String(stsRoleName), - AssumeRolePolicyDocument: aws.String(stsTrustPolicyAllowAny), - Description: aws.String("E2E STS AssumeRole + GetCallerIdentity"), - }) - require.NoError(t, err, "create-role") - roleARN := aws.StringValue(createOut.Role.Arn) - require.Equal(t, harness.IAMRoleARN(adminAccount, stsRoleName), roleARN, - "role ARN must follow arn:aws:iam:::role/") - - // Happy path. Verify the wire-format invariants: ASIA prefix, non-empty - // secret + token, expiration ≈ 1h, and the AssumedRoleUser shape. - harness.Step(t, "assume-role %q session=%q", roleARN, stsSessionName) - beforeAssume := time.Now().UTC() - aOut, err := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String(stsSessionName), - }) - require.NoError(t, err, "assume-role") - - creds := aOut.Credentials - require.NotNil(t, creds, "AssumeRole returned nil Credentials") - akid := aws.StringValue(creds.AccessKeyId) - secret := aws.StringValue(creds.SecretAccessKey) - token := aws.StringValue(creds.SessionToken) - require.True(t, strings.HasPrefix(akid, "ASIA"), - "session AKID must start with ASIA, got %q", akid) - require.NotEmpty(t, secret, "empty SecretAccessKey") - require.NotEmpty(t, token, "empty SessionToken") - require.NotNil(t, creds.Expiration, "nil Expiration") - - // Default DurationSeconds is 3600 (1h). Bound the assertion loosely so a - // few seconds of test-runner latency doesn't flake CI. - expiresIn := creds.Expiration.Sub(beforeAssume) - require.Greater(t, expiresIn, 30*time.Minute, - "expiration too soon: %v (akid=%s)", expiresIn, akid) - require.LessOrEqual(t, expiresIn, time.Hour+5*time.Minute, - "expiration too far: %v (akid=%s)", expiresIn, akid) - - expectedAssumedARN := "arn:aws:sts::" + adminAccount + ":assumed-role/" + - stsRoleName + "/" + stsSessionName - require.NotNil(t, aOut.AssumedRoleUser, "nil AssumedRoleUser") - require.Equal(t, expectedAssumedARN, aws.StringValue(aOut.AssumedRoleUser.Arn), - "assumed-role ARN shape mismatch") - assumedRoleID := aws.StringValue(aOut.AssumedRoleUser.AssumedRoleId) - require.NotEmpty(t, assumedRoleID, "empty AssumedRoleId") - require.True(t, strings.HasSuffix(assumedRoleID, ":"+stsSessionName), - "AssumedRoleId must end with :%s, got %q", stsSessionName, assumedRoleID) - harness.Detail(t, "akid", akid, "assumed_role_arn", expectedAssumedARN, - "assumed_role_id", assumedRoleID) - - // Drive the SigV4 ASIA path: use the freshly-minted creds against - // GetCallerIdentity and assert the identity round-trips. The endpoint's - // own auth middleware verifies X-Amz-Security-Token, so a wire-token - // mismatch would surface here as InvalidClientTokenId. - harness.Step(t, "get-caller-identity with assumed-role creds (ASIA path)") - sessionCli := harness.NewAWSClientWithSessionCreds(t, fix.Env, akid, secret, token) - sessWho, err := sessionCli.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{}) - require.NoError(t, err, "get-caller-identity (assumed)") - require.Equal(t, adminAccount, aws.StringValue(sessWho.Account), - "assumed account mismatch") - require.Equal(t, expectedAssumedARN, aws.StringValue(sessWho.Arn), - "assumed ARN must be the sts:assumed-role form") - require.Equal(t, assumedRoleID, aws.StringValue(sessWho.UserId), - "UserId for assumed-role must equal AssumedRoleId") - - // Cross-service ASIA SigV4: drive a non-STS endpoint with the assumed - // creds. The role here carries no permission policy, so gateway.checkPolicy - // resolves its (empty) managed policies and the action falls to an implicit - // deny — assumability does not imply permissions. The wire path also proves - // ctxPrincipalType + the underlying-role ARN propagate beyond the STS - // service into the EC2 dispatcher's policy gate; a unit test of checkPolicy - // cannot exercise that propagation. The granted-policy positive case lives - // in TestAssumedRoleControlPlaneEnforcement. - harness.Step(t, "ec2 describe-regions with assumed-role creds (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) - - // GetSessionToken is user-only: an assumed-role (ASIA) session must NOT be - // able to mint a user session. AWS forbids calling GetSessionToken with - // temporary credentials; the handler enforces it via the resolved principal - // type. The wire path proves ctxPrincipalType=assumed-role reaches the - // handler's guard — a unit test of the handler cannot exercise that - // propagation from the SigV4 ASIA verifier. - harness.Step(t, "get-session-token with assumed-role creds (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := sessionCli.STS.GetSessionToken(&sts.GetSessionTokenInput{}) - return e - }) - - // --- Edge cases: tampered token, rejected params, duration clamp --------- - // Run while sts-e2e-role still trusts AWS:* and carries no - // MaxSessionDuration, so the duration ceiling is the 3600s default. - - // Tampered session token → InvalidClientTokenId. Reuse the happy-path ASIA - // akid+secret but present a forged X-Amz-Security-Token. resolveSessionAKID - // verifies the token HMAC before the request signature, so the mismatch - // surfaces as InvalidClientTokenId regardless of the (valid) SigV4 sig. The - // probe is an EC2 call: the gateway serialises SigV4 auth failures in the EC2 - // XML envelope (writeSigV4Error), which the STS Query client can't unmarshal - // (it masks the code as SerializationError). The HMAC branch is - // service-agnostic, so EC2 drives the same gateway/auth.go path with a - // response the SDK decodes into the asserted code. - harness.Step(t, "ec2 describe-regions with tampered session token (expect InvalidClientTokenId)") - tamperedCli := harness.NewAWSClientWithSessionCreds(t, fix.Env, akid, secret, token+"-tampered") - harness.ExpectError(t, "InvalidClientTokenId", func() error { - _, e := tamperedCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) - return e - }) - - // Rejected-parameter wire rejections. The handler refuses inline session - // policies, session tags, and MFA up front; each sub-test sets only its own - // field so the field-specific code is unambiguous. Proves the SDK marshals - // these over the wire and the gateway returns the AWS code rather than - // silently dropping them. - harness.Step(t, "assume-role with inline Policy (expect PackedPolicyTooLarge)") - harness.ExpectError(t, "PackedPolicyTooLarge", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-reject-policy"), - Policy: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`), - }) - return e - }) - harness.Step(t, "assume-role with session Tags (expect InvalidParameterValue)") - harness.ExpectError(t, "InvalidParameterValue", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-reject-tags"), - Tags: []*sts.Tag{{Key: aws.String("team"), Value: aws.String("eng")}}, - }) - return e - }) - harness.Step(t, "assume-role with MFA SerialNumber+TokenCode (expect InvalidParameterValue)") - harness.ExpectError(t, "InvalidParameterValue", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-reject-mfa"), - SerialNumber: aws.String("arn:aws:iam::" + adminAccount + ":mfa/e2e"), - TokenCode: aws.String("123456"), - }) - return e - }) - - // DurationSeconds × role MaxSessionDuration clamp. With no MaxSessionDuration - // on the role the ceiling is the 3600s default: 900s mints a ~15m session and - // 7200s is rejected. After raising MaxSessionDuration to 7200, 7200s mints a - // ~2h session and 10800s is rejected. - harness.Step(t, "assume-role DurationSeconds=900 (expect ~15m expiry)") - beforeShort := time.Now().UTC() - shortOut, err := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-dur-900"), - DurationSeconds: aws.Int64(900), - }) - require.NoError(t, err, "assume-role DurationSeconds=900") - require.NotNil(t, shortOut.Credentials, "nil Credentials for DurationSeconds=900") - shortExpiresIn := shortOut.Credentials.Expiration.Sub(beforeShort) - require.Greater(t, shortExpiresIn, 14*time.Minute, - "DurationSeconds=900 expiry too soon: %v", shortExpiresIn) - require.LessOrEqual(t, shortExpiresIn, 16*time.Minute, - "DurationSeconds=900 expiry too far: %v", shortExpiresIn) - - harness.Step(t, "assume-role DurationSeconds=7200 over default 3600 ceiling (expect ValidationError)") - harness.ExpectError(t, "ValidationError", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-dur-7200-over"), - DurationSeconds: aws.Int64(7200), - }) - return e - }) - - harness.Step(t, "update-role %q MaxSessionDuration=7200", stsRoleName) - _, err = fix.AWS.IAM.UpdateRole(&iam.UpdateRoleInput{ - RoleName: aws.String(stsRoleName), - MaxSessionDuration: aws.Int64(7200), - }) - require.NoError(t, err, "update-role MaxSessionDuration=7200") - - harness.Step(t, "assume-role DurationSeconds=7200 under raised ceiling (expect ~2h expiry)") - beforeLong := time.Now().UTC() - longOut, err := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-dur-7200-ok"), - DurationSeconds: aws.Int64(7200), - }) - require.NoError(t, err, "assume-role DurationSeconds=7200 after raise") - require.NotNil(t, longOut.Credentials, "nil Credentials for DurationSeconds=7200") - longExpiresIn := longOut.Credentials.Expiration.Sub(beforeLong) - require.Greater(t, longExpiresIn, time.Hour+50*time.Minute, - "DurationSeconds=7200 expiry too soon: %v", longExpiresIn) - require.LessOrEqual(t, longExpiresIn, 2*time.Hour+5*time.Minute, - "DurationSeconds=7200 expiry too far: %v", longExpiresIn) - - harness.Step(t, "assume-role DurationSeconds=10800 over raised 7200 ceiling (expect ValidationError)") - harness.ExpectError(t, "ValidationError", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-dur-10800-over"), - DurationSeconds: aws.Int64(10800), - }) - return e - }) - - // Chained assume: create a second role whose trust policy names the - // first role's IAM ARN, then have the assumed-role session call - // AssumeRole on it. Exercises the role-ARN-clause vs. session-ARN- - // caller auto-expansion path end-to-end — eval logic is unit-tested, - // the wire flow (ASIA SigV4 → ctxAssumedRoleARN → handler caller ARN - // → trust-policy match → new ASIA mint) is not. - chainedTrustPolicy := fmt.Sprintf( - `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":%q},"Action":"sts:AssumeRole"}]}`, - harness.IAMRoleARN(adminAccount, stsRoleName), - ) - harness.Step(t, "create-role %q (trust=role/%s for chained assume)", - stsRoleNameChain, stsRoleName) - chainCreateOut, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String(stsRoleNameChain), - AssumeRolePolicyDocument: aws.String(chainedTrustPolicy), - Description: aws.String("E2E STS chained AssumeRole target"), - }) - require.NoError(t, err, "create-role (chain)") - chainedRoleARN := aws.StringValue(chainCreateOut.Role.Arn) - - harness.Step(t, "assume-role %q via assumed-role session (chained)", chainedRoleARN) - chainOut, err := sessionCli.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(chainedRoleARN), - RoleSessionName: aws.String(stsSessionNameChain), - }) - require.NoError(t, err, "assume-role (chain)") - require.NotNil(t, chainOut.Credentials, "chained AssumeRole returned nil Credentials") - chainAKID := aws.StringValue(chainOut.Credentials.AccessKeyId) - chainSecret := aws.StringValue(chainOut.Credentials.SecretAccessKey) - chainToken := aws.StringValue(chainOut.Credentials.SessionToken) - require.True(t, strings.HasPrefix(chainAKID, "ASIA"), - "chained session AKID must start with ASIA, got %q", chainAKID) - require.NotEmpty(t, chainSecret, "empty chained SecretAccessKey") - require.NotEmpty(t, chainToken, "empty chained SessionToken") - - expectedChainARN := "arn:aws:sts::" + adminAccount + ":assumed-role/" + - stsRoleNameChain + "/" + stsSessionNameChain - require.NotNil(t, chainOut.AssumedRoleUser, "nil chained AssumedRoleUser") - require.Equal(t, expectedChainARN, aws.StringValue(chainOut.AssumedRoleUser.Arn), - "chained assumed-role ARN shape mismatch") - harness.Detail(t, "chain_akid", chainAKID, "chain_arn", expectedChainARN) - - // Round-trip the chained creds to prove the new session token verifies - // on the wire and the gateway reports the chained-role ARN, not the - // source-role ARN. Catches a regression that reuses the source session - // token or fails to refresh ctxAssumedRoleARN on the new session. - harness.Step(t, "get-caller-identity with chained assumed-role creds") - chainCli := harness.NewAWSClientWithSessionCreds(t, fix.Env, chainAKID, chainSecret, chainToken) - chainWho, err := chainCli.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{}) - require.NoError(t, err, "get-caller-identity (chained)") - require.Equal(t, expectedChainARN, aws.StringValue(chainWho.Arn), - "chained GetCallerIdentity must report the chained-role ARN, not the source-role ARN") - - // Trust-policy denial: swap to a principal that cannot match the caller, - // then re-assume to confirm the trust-policy evaluator returns - // AccessDenied. Uses a different session name to make any stale-record - // confusion easier to spot in logs. - harness.Step(t, "update-assume-role-policy → unmatched principal") - _, err = fix.AWS.IAM.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{ - RoleName: aws.String(stsRoleName), - PolicyDocument: aws.String(stsTrustPolicyDenyAny), - }) - require.NoError(t, err, "update-assume-role-policy") - harness.Step(t, "assume-role after policy swap (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(roleARN), - RoleSessionName: aws.String("e2e-session-2"), - }) - return e - }) - - // All missing-role lookups are masked to AccessDenied by the handler, - // matching AWS and preventing role enumeration; same- vs cross-account - // masking is covered by handlers/sts unit tests. - harness.Step(t, "assume-role missing role (expect AccessDenied)") - harness.ExpectError(t, "AccessDenied", func() error { - _, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{ - RoleArn: aws.String(harness.IAMRoleARN(adminAccount, - "sts-e2e-no-such-role")), - RoleSessionName: aws.String("ghost"), - }) - return e - }) - - // Write-time trust-policy rejection via the SDK: Condition, NotPrincipal, - // and NotAction must be refused at CreateRole rather than silently - // accepted (silent-allow vector). Handler-level unit tests cover the - // validator; the e2e value here is proving the SDK marshals these fields - // over the wire intact and the gateway returns the AWS-conformant - // MalformedPolicyDocument code rather than a generic ValidationError. - for _, tc := range []struct { - label string - roleName string - doc string - }{ - { - label: "Condition", - roleName: stsRoleNameForbidCondition, - doc: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"abc"}}}]}`, - }, - { - label: "NotPrincipal", - roleName: stsRoleNameForbidNotPrincipal, - doc: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"arn:aws:iam::123456789012:user/Bob"},"Action":"sts:AssumeRole"}]}`, - }, - { - label: "NotAction", - roleName: stsRoleNameForbidNotAction, - doc: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"NotAction":["sts:AssumeRole"]}]}`, - }, - } { - harness.Step(t, "create-role with %s trust block (expect MalformedPolicyDocument)", tc.label) - harness.ExpectError(t, "MalformedPolicyDocument", func() error { - _, e := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ - RoleName: aws.String(tc.roleName), - AssumeRolePolicyDocument: aws.String(tc.doc), - }) - return e - }) - } - - // Assertive teardown — the cleanup hook above is the safety net; this is - // the happy-path delete so the test surfaces a DeleteConflict regression - // (e.g. stray attached policy from a future refactor) loudly. The chained - // role tears down first so its trust-policy dependency on stsRoleName - // doesn't matter, but DeleteRole is unaffected by trust-policy references - // in either direction — order is purely for readability. - _, err = fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{ - RoleName: aws.String(stsRoleNameChain), - }) - require.NoError(t, err, "delete-role (chain)") - _, err = fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{ - RoleName: aws.String(stsRoleName), - }) - require.NoError(t, err, "delete-role") -} diff --git a/tests/e2e/iam/tests_test.go b/tests/e2e/iam/tests_test.go index 5abb94d37..f79bf8fe9 100644 --- a/tests/e2e/iam/tests_test.go +++ b/tests/e2e/iam/tests_test.go @@ -4,102 +4,23 @@ package iam import "testing" -// Top-level Test* entry points for the IAM/STS + instance-identity suite. Each -// delegates to a runX function in the matching _test.go file. Kept as -// separate top-level Test* (not one inline Test) so `go test -run TestX` and the -// suite selector can address each individually. +// Top-level Test* entry points for the instance-identity suite. Each delegates +// to a runX function in the matching _test.go file. Kept as separate +// top-level Test* (not one inline Test) so `go test -run TestX` and the suite +// selector can address each individually. // -// Execution is sequential in source order: these tests share the package-scoped -// IAM state (users/policies/roles are account-flat), and TestIAMCleanup tears -// down the user/policy graph the earlier phases build. The parallelism win of -// the split is the suite-as-matrix-leg, not intra-suite concurrency — sub-tests -// inside each runIAM* still fan out with t.Parallel where it is safe. -// -// The final two entries (TestIAMInstanceProfileAssociation, TestIMDS) boot guest -// VMs. They self-bootstrap their own roles/profiles/VPCs under collision-safe -// namespaces and run last, after the control-plane phases. - -func TestIAMUserCRUD(t *testing.T) { - runIAMUserCRUD(t, requireIAMFixture(t)) -} - -func TestIAMAccessKeyLifecycle(t *testing.T) { - runIAMAccessKeyLifecycle(t, requireIAMFixture(t)) -} - -func TestIAMUserAuthentication(t *testing.T) { - runIAMUserAuthentication(t, requireIAMFixture(t)) -} - -func TestIAMPolicyCRUD(t *testing.T) { - runIAMPolicyCRUD(t, requireIAMFixture(t)) -} - -func TestIAMPolicyAttachmentEnforcement(t *testing.T) { - runIAMPolicyAttachmentEnforcement(t, requireIAMFixture(t)) -} - -func TestIAMPolicyLifecycle(t *testing.T) { - runIAMPolicyLifecycle(t, requireIAMFixture(t)) -} - -func TestIAMCleanup(t *testing.T) { - runIAMCleanup(t, requireIAMFixture(t)) -} - -func TestIAMRolesAndProfiles(t *testing.T) { - runIAMRolesAndProfiles(t, requireIAMFixture(t)) -} - -// TestSTSAssumeRoleAndGetCallerIdentity is sequential to avoid racing trust-policy -// mutations against a parallel AssumeRole. -func TestSTSAssumeRoleAndGetCallerIdentity(t *testing.T) { - runSTS(t, requireIAMFixture(t)) -} - -// TestAssumedRoleControlPlaneEnforcement verifies a zero-policy assumed-role is -// denied and permitted once a policy is attached. Sequential to avoid racing -// the mid-test grant. -func TestAssumedRoleControlPlaneEnforcement(t *testing.T) { - runAssumedRoleControlPlaneEnforcement(t, requireIAMFixture(t)) -} - -// TestIAMGroupsLifecycle exercises group CRUD, membership, group-policy -// attachment, the reverse-lookup listings, and every deletion guard. Sequential: -// it creates/destroys its own dedicated group/user/policy graph. -func TestIAMGroupsLifecycle(t *testing.T) { - runIAMGroupsLifecycle(t, requireIAMFixture(t)) -} - -// TestIAMGroupEnforcement proves a group-attached policy grants its permission -// to members: denied with no grant, allowed once in the group, denied again -// after leaving. Sequential to avoid racing the mid-test grant. -func TestIAMGroupEnforcement(t *testing.T) { - runIAMGroupEnforcement(t, requireIAMFixture(t)) -} - -// TestIAMGroupInlineEnforcement proves a group inline policy grants its -// permission to members: denied with no grant, allowed once the inline policy is -// put and the user joins, denied again after the inline policy is deleted (with -// the user still a member). Sequential to avoid racing the mid-test grant. -func TestIAMGroupInlineEnforcement(t *testing.T) { - runIAMGroupInlineEnforcement(t, requireIAMFixture(t)) -} - -// TestIAMUserInlineEnforcement proves a user's own inline policy grants its -// permission with no group or managed attachment: denied with no grant, allowed -// once the inline policy is put, denied again after it is deleted. Sequential to -// avoid racing the mid-test grant. -func TestIAMUserInlineEnforcement(t *testing.T) { - runIAMUserInlineEnforcement(t, requireIAMFixture(t)) -} +// The IAM/STS control-plane authz tests that used to live here have been +// ported to the in-process integration tier (tests/integration/) and removed +// — the gateway dispatches every IAM/STS action directly with no daemon hop, +// so the integration tier is a faithful, much cheaper substitute. What +// remains boots guest VMs and self-bootstraps its own roles/profiles/VPCs +// under collision-safe namespaces. // TestIAMInstanceProfileAssociation exercises the EC2 IAM instance-profile // association lifecycle (associate/replace/disassociate, RunInstances // --iam-instance-profile, auto-disassociate on terminate). It boots its own // instance and self-bootstraps a role + two profiles under the -// single-iamprofile- namespace, distinct from this suite's app-role/app-profile. -// Sequential: it boots VMs and mutates instance state. +// single-iamprofile- namespace. func TestIAMInstanceProfileAssociation(t *testing.T) { runIAMInstanceProfileAssociation(t, requireIAMFixture(t)) } diff --git a/tests/e2e/manifest-lint/baseline.txt b/tests/e2e/manifest-lint/baseline.txt index ad2105ae0..774e21536 100644 --- a/tests/e2e/manifest-lint/baseline.txt +++ b/tests/e2e/manifest-lint/baseline.txt @@ -5,7 +5,6 @@ create tests/e2e/eks/eks_test.go CreateCluster #1 create tests/e2e/gpu/gpu_test.go RunInstances #1 create tests/e2e/gpu/gpu_test.go RunInstances #2 create tests/e2e/gpu/helpers_test.go RunInstances #1 -create tests/e2e/iam/iam_test.go CreateKeyPair #1 create tests/e2e/iam/imds_test.go CreateInternetGateway #1 create tests/e2e/iam/imds_test.go CreateRouteTable #1 create tests/e2e/iam/imds_test.go CreateSubnet #1