mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 12:16:52 +00:00
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:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user