test(e2e): remove IAM/STS authz tests migrated to the integration tier

All 13 signed-request authz tests plus TestIAMCleanup now have faithful,
mutation-proven equivalents in tests/integration/ — the gateway dispatches
IAM/STS actions directly with no daemon hop, so the in-process tier is a
faithful and far cheaper substitute. The iam/ e2e suite now runs only
TestIAMInstanceProfileAssociation and TestIMDS.

Drop the now fully-dead iam_helpers_test.go alongside the six deleted test
files, and remove harness.IAMGroupARN/IAMDeleteGroupBestEffort, which had no
remaining caller anywhere in the repo once iam_groups_test.go was gone.
This commit is contained in:
joshsiv-mulga
2026-07-20 11:29:09 +10:00
parent a36b626485
commit df93463234
10 changed files with 11 additions and 2709 deletions
-5
View File
@@ -22,11 +22,6 @@ func IAMPolicyARN(account, key string) string {
return "arn:aws:iam::" + account + ":policy/" + key
}
// IAMGroupARN constructs arn:aws:iam::<account>:group/<name> (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.
@@ -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")
}
-544
View File
@@ -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::<acct>:group/<name>")
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
})
}
-183
View File
@@ -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
}
-331
View File
@@ -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::<acct>:role/<name>")
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/<name>). 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
})
}
-874
View File
@@ -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]
}
-88
View File
@@ -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
})
}
-506
View File
@@ -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::<acct>:role/<name>")
// 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")
}
+11 -90
View File
@@ -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 <name>_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 <name>_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))
}
-1
View File
@@ -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