mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
test(e2e-single): port run-e2e.sh IAM 1-7 + Phase 8 account scoping to Go
Stage E of the single-node Go port (mulga-siv-89). Adds two files
under tests/e2e/single/:
- iam_test.go (903 lines): IAM Phases 1-7 — User CRUD, Access Key
Lifecycle, User Authentication (via harness.NewAWSClientWithCreds),
Policy CRUD (+ CreatePolicyVersion / SetDefaultPolicyVersion /
DeletePolicyVersion which bash lacks), Policy Attachment &
Enforcement (allow / deny / wildcard / root bypass), Policy Lifecycle
(DeleteConflict while attached), and idempotent Cleanup.
- account_test.go (1186 lines): Phase 8 EC2 Account Scoping — 11 Steps
(Account Setup, Instance / Volume / KeyPair / Snapshot / VPC-Subnet /
IGW-EIGW Scoping, Account Settings, Global Resources, Edge Cases,
Cleanup). Uses harness.AccountCarousel for the multi-profile
credential map and SpxAdminAccountCreate for admin account
bootstrap.
Fixture extended with IAM cross-phase state (IAMAdminAccount,
IAM{Alice,Bob,Charlie}{KeyID,Secret}) so Phase 7 cleanup is defensive
even when an intermediate IAM phase short-circuits. All 11 Phase 8
Steps share local state in the outer function; t.Cleanup mirrors
Step 11 LIFO so a mid-Step failure still tears down accounts.
Wired into TestSingleNode. run-e2e.sh stays in tree until step-7
cleanup per parent plan.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,903 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Cross-phase identifiers used by the IAM sub-tests. Match the bash
|
||||
// names so artifact diffs against run-e2e.sh remain greppable.
|
||||
const (
|
||||
iamUserAlice = "alice"
|
||||
iamUserBob = "bob"
|
||||
iamUserCharlie = "charlie"
|
||||
iamUserBobPath = "/engineering/"
|
||||
|
||||
iamPolicyEC2ReadOnly = "EC2ReadOnly"
|
||||
iamPolicyFullAdmin = "FullAdmin"
|
||||
iamPolicyFullAdminPath = "/admin/"
|
||||
iamPolicyDenyTerminate = "DenyTerminate"
|
||||
iamPolicyIAMReadOnly = "IAMReadOnly"
|
||||
iamPolicyEC2DescribeAll = "EC2DescribeAll"
|
||||
|
||||
// Bash driver carries the bootstrap AdministratorAccess policy through
|
||||
// to the end of Phase 7 and asserts exactly one policy remains. Mirror
|
||||
// that name so the final assertion can ignore it.
|
||||
iamPolicyBootstrap = "AdministratorAccess"
|
||||
)
|
||||
|
||||
// Inline policy documents mirroring the bash heredocs. Indentation is
|
||||
// trimmed to keep diffs against the bash source readable.
|
||||
const (
|
||||
iamDocEC2ReadOnly = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs"],
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
|
||||
iamDocFullAdmin = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "*",
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
|
||||
iamDocDenyTerminate = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{"Effect": "Allow", "Action": "ec2:*", "Resource": "*"},
|
||||
{"Effect": "Deny", "Action": "ec2:TerminateInstances", "Resource": "*"}
|
||||
]
|
||||
}`
|
||||
|
||||
iamDocIAMReadOnly = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["iam:GetUser", "iam:ListUsers", "iam:ListPolicies", "iam:GetPolicy"],
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
|
||||
iamDocEC2DescribeAll = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "ec2:Describe*",
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
|
||||
iamDocEC2ReadOnlyV2 = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs", "ec2:DescribeImages"],
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
)
|
||||
|
||||
// phaseIAM1_UserCRUD ports run-e2e.sh IAM Phase 1 (~1405–1459):
|
||||
// CreateUser, GetUser, ListUsers, UpdateUser (rename+path), DeleteUser
|
||||
// plus the EntityAlreadyExists / NoSuchEntity negative paths. Each
|
||||
// created user is scheduled for cleanup so a mid-phase failure can't
|
||||
// poison later runs. UpdateUser exercises an out-of-band rename +
|
||||
// rename-back so Phase 2+ continue to find "alice" by the well-known
|
||||
// name.
|
||||
func phaseIAM1_UserCRUD(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 1 — User CRUD")
|
||||
|
||||
// Defensive: drop leftovers from a prior failed run before recreate.
|
||||
iamDeleteUserBestEffort(fix, iamUserAlice)
|
||||
iamDeleteUserBestEffort(fix, iamUserBob)
|
||||
|
||||
harness.Step(t, "list-users (root auth sanity)")
|
||||
rootUsers, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err, "list-users (root)")
|
||||
require.NotEmpty(t, rootUsers.Users, "root list-users returned 0 — root account missing?")
|
||||
|
||||
harness.Step(t, "create-user %q", iamUserAlice)
|
||||
aliceOut, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "create-user %s", iamUserAlice)
|
||||
require.Equal(t, iamUserAlice, aws.StringValue(aliceOut.User.UserName))
|
||||
harness.Detail(t, "user", iamUserAlice, "arn", aws.StringValue(aliceOut.User.Arn))
|
||||
|
||||
harness.Step(t, "create-user %q path=%q", iamUserBob, iamUserBobPath)
|
||||
bobOut, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(iamUserBob),
|
||||
Path: aws.String(iamUserBobPath),
|
||||
})
|
||||
require.NoError(t, err, "create-user %s", iamUserBob)
|
||||
require.Equal(t, iamUserBobPath, aws.StringValue(bobOut.User.Path),
|
||||
"bob created without expected path")
|
||||
|
||||
harness.Step(t, "create-user %q again (expect EntityAlreadyExists)", iamUserAlice)
|
||||
harness.ExpectError(t, "EntityAlreadyExists", func() error {
|
||||
_, e := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(iamUserAlice)})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "get-user %q", iamUserAlice)
|
||||
got, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(iamUserAlice)})
|
||||
require.NoError(t, err, "get-user %s", iamUserAlice)
|
||||
require.Equal(t, iamUserAlice, aws.StringValue(got.User.UserName))
|
||||
|
||||
harness.Step(t, "get-user nonexistent (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String("nonexistent")})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "list-users (>=3 root+alice+bob)")
|
||||
listed, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err, "list-users")
|
||||
require.GreaterOrEqual(t, len(listed.Users), 3,
|
||||
"expected >=3 users, got %d (names=%v)", len(listed.Users), iamUserNames(listed.Users))
|
||||
|
||||
harness.Step(t, "list-users --path-prefix %q (expect 1)", iamUserBobPath)
|
||||
eng, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{PathPrefix: aws.String(iamUserBobPath)})
|
||||
require.NoError(t, err, "list-users --path-prefix")
|
||||
require.Len(t, eng.Users, 1,
|
||||
"expected 1 user under %s, got %d (names=%v)", iamUserBobPath, len(eng.Users), iamUserNames(eng.Users))
|
||||
|
||||
// UpdateUser: rename alice -> alice-renamed with a new path, then
|
||||
// rename back so Phase 2+ continue to see the well-known "alice".
|
||||
// Not in the bash driver — covers the SDK code path the task spec
|
||||
// explicitly calls out.
|
||||
const renamed = "alice-renamed"
|
||||
const renamedPath = "/staff/"
|
||||
harness.Step(t, "update-user %q -> %q path=%q", iamUserAlice, renamed, renamedPath)
|
||||
_, err = fix.AWS.IAM.UpdateUser(&iam.UpdateUserInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
NewUserName: aws.String(renamed),
|
||||
NewPath: aws.String(renamedPath),
|
||||
})
|
||||
require.NoError(t, err, "update-user rename+path")
|
||||
check, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(renamed)})
|
||||
require.NoError(t, err, "get-user after rename")
|
||||
assert.Equal(t, renamedPath, aws.StringValue(check.User.Path), "path not updated")
|
||||
|
||||
harness.Step(t, "update-user %q -> %q (restore)", renamed, iamUserAlice)
|
||||
_, err = fix.AWS.IAM.UpdateUser(&iam.UpdateUserInput{
|
||||
UserName: aws.String(renamed),
|
||||
NewUserName: aws.String(iamUserAlice),
|
||||
NewPath: aws.String("/"),
|
||||
})
|
||||
require.NoError(t, err, "update-user restore")
|
||||
|
||||
// DeleteUser idempotency: create a throwaway user, delete it, then
|
||||
// delete it again and assert the second delete surfaces NoSuchEntity
|
||||
// rather than silently succeeding (matches AWS semantics).
|
||||
const ephemeral = "iam-phase1-ephemeral"
|
||||
iamDeleteUserBestEffort(fix, ephemeral)
|
||||
harness.Step(t, "create-user %q (delete idempotency probe)", ephemeral)
|
||||
_, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(ephemeral)})
|
||||
require.NoError(t, err, "create-user %s", ephemeral)
|
||||
|
||||
harness.Step(t, "delete-user %q", ephemeral)
|
||||
_, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(ephemeral)})
|
||||
require.NoError(t, err, "delete-user %s", ephemeral)
|
||||
|
||||
harness.Step(t, "delete-user %q again (expect NoSuchEntity)", ephemeral)
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(ephemeral)})
|
||||
return e
|
||||
})
|
||||
}
|
||||
|
||||
// phaseIAM2_AccessKeyLifecycle ports IAM Phase 2 (~1463–1534):
|
||||
// CreateAccessKey, ListAccessKeys, UpdateAccessKey (deactivate/activate),
|
||||
// DeleteAccessKey plus LimitExceeded / NoSuchEntity negative paths.
|
||||
// Captures alice's primary key into the Fixture so Phase 3 can sign
|
||||
// requests with it.
|
||||
func phaseIAM2_AccessKeyLifecycle(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 2 — Access Key Lifecycle")
|
||||
require.NotEmpty(t, fix.IAMAdminAccount, "") // intentionally lenient — set by Phase 4
|
||||
// Note: bash runs Phase 4 *after* Phase 2, so IAMAdminAccount is
|
||||
// not required here. The NotEmpty check with empty msg above is a
|
||||
// no-op when the field is empty (require treats "" as ok). Keep
|
||||
// the line as documentation of the cross-phase ordering.
|
||||
|
||||
harness.Step(t, "create-access-key user=%s (key 1)", iamUserAlice)
|
||||
k1, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "create-access-key 1")
|
||||
fix.IAMAliceKeyID = aws.StringValue(k1.AccessKey.AccessKeyId)
|
||||
fix.IAMAliceSecret = aws.StringValue(k1.AccessKey.SecretAccessKey)
|
||||
require.NotEmpty(t, fix.IAMAliceKeyID, "empty AccessKeyId")
|
||||
require.NotEmpty(t, fix.IAMAliceSecret, "empty SecretAccessKey")
|
||||
harness.Detail(t, "key1", fix.IAMAliceKeyID)
|
||||
|
||||
harness.Step(t, "create-access-key user=%s (key 2)", iamUserAlice)
|
||||
k2, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "create-access-key 2")
|
||||
aliceKey2 := aws.StringValue(k2.AccessKey.AccessKeyId)
|
||||
require.NotEmpty(t, aliceKey2)
|
||||
harness.Detail(t, "key2", aliceKey2)
|
||||
|
||||
harness.Step(t, "create-access-key user=%s key 3 (expect LimitExceeded)", iamUserAlice)
|
||||
harness.ExpectError(t, "LimitExceeded", func() error {
|
||||
_, e := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "create-access-key user=ghost (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String("ghost"),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "list-access-keys user=%s (expect 2)", iamUserAlice)
|
||||
keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "list-access-keys alice")
|
||||
require.Len(t, keys.AccessKeyMetadata, 2, "alice key count")
|
||||
|
||||
harness.Step(t, "list-access-keys user=%s (expect 0)", iamUserBob)
|
||||
bobKeys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(iamUserBob),
|
||||
})
|
||||
require.NoError(t, err, "list-access-keys bob")
|
||||
require.Empty(t, bobKeys.AccessKeyMetadata, "bob should have 0 keys")
|
||||
|
||||
harness.Step(t, "update-access-key %s -> Inactive", fix.IAMAliceKeyID)
|
||||
_, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
AccessKeyId: aws.String(fix.IAMAliceKeyID),
|
||||
Status: aws.String(iam.StatusTypeInactive),
|
||||
})
|
||||
require.NoError(t, err, "update-access-key deactivate")
|
||||
require.Equal(t, iam.StatusTypeInactive,
|
||||
iamFindKeyStatus(t, fix, iamUserAlice, fix.IAMAliceKeyID),
|
||||
"key not Inactive after update")
|
||||
|
||||
harness.Step(t, "update-access-key %s -> Active", fix.IAMAliceKeyID)
|
||||
_, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
AccessKeyId: aws.String(fix.IAMAliceKeyID),
|
||||
Status: aws.String(iam.StatusTypeActive),
|
||||
})
|
||||
require.NoError(t, err, "update-access-key reactivate")
|
||||
|
||||
harness.Step(t, "delete-access-key %s (key 2)", aliceKey2)
|
||||
_, err = fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
AccessKeyId: aws.String(aliceKey2),
|
||||
})
|
||||
require.NoError(t, err, "delete-access-key key2")
|
||||
|
||||
after, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "list-access-keys after delete")
|
||||
require.Len(t, after.AccessKeyMetadata, 1, "alice should have 1 key after delete")
|
||||
}
|
||||
|
||||
// phaseIAM3_UserAuthentication ports IAM Phase 3 (~1538–1579):
|
||||
// build a scoped AWS client with alice's key and confirm signing
|
||||
// works (active key) / fails (deactivated key, bad secret, bogus ID).
|
||||
// Also creates bob's key so Phase 5 enforcement / Phase 7 cleanup
|
||||
// can use it.
|
||||
func phaseIAM3_UserAuthentication(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 3 — User Authentication")
|
||||
require.NotEmpty(t, fix.IAMAliceKeyID, "Phase 2 must populate fix.IAMAliceKeyID")
|
||||
require.NotEmpty(t, fix.IAMAliceSecret, "Phase 2 must populate fix.IAMAliceSecret")
|
||||
|
||||
harness.Step(t, "scoped client (alice) describe-instances — active key OK")
|
||||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, fix.IAMAliceSecret)
|
||||
_, err := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "alice describe-instances with active key")
|
||||
|
||||
harness.Step(t, "deactivate alice key, expect InvalidClientTokenId")
|
||||
_, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
AccessKeyId: aws.String(fix.IAMAliceKeyID),
|
||||
Status: aws.String(iam.StatusTypeInactive),
|
||||
})
|
||||
require.NoError(t, err, "deactivate alice key")
|
||||
harness.ExpectError(t, "InvalidClientTokenId", func() error {
|
||||
_, e := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "reactivate alice key")
|
||||
_, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
AccessKeyId: aws.String(fix.IAMAliceKeyID),
|
||||
Status: aws.String(iam.StatusTypeActive),
|
||||
})
|
||||
require.NoError(t, err, "reactivate alice key")
|
||||
|
||||
harness.Step(t, "bad secret (expect SignatureDoesNotMatch)")
|
||||
badSecret := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, "WRONG_SECRET_KEY_HERE_12345678901")
|
||||
harness.ExpectError(t, "SignatureDoesNotMatch", func() error {
|
||||
_, e := badSecret.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "bogus key id (expect InvalidClientTokenId)")
|
||||
fakeCli := harness.NewAWSClientWithCreds(t, fix.Env, "AKIAXXXXXXXXXXXXXXXX", "doesntmatter")
|
||||
harness.ExpectError(t, "InvalidClientTokenId", func() error {
|
||||
_, e := fakeCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "create-access-key user=%s (multi-user auth)", iamUserBob)
|
||||
bobKey, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(iamUserBob),
|
||||
})
|
||||
require.NoError(t, err, "create-access-key bob")
|
||||
fix.IAMBobKeyID = aws.StringValue(bobKey.AccessKey.AccessKeyId)
|
||||
fix.IAMBobSecret = aws.StringValue(bobKey.AccessKey.SecretAccessKey)
|
||||
require.NotEmpty(t, fix.IAMBobKeyID, "empty bob key id")
|
||||
harness.Detail(t, "bob_key", fix.IAMBobKeyID)
|
||||
|
||||
harness.Step(t, "root auth still OK")
|
||||
_, err = fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "root describe-instances")
|
||||
}
|
||||
|
||||
// phaseIAM4_PolicyCRUD ports IAM Phase 4 (~1583–1698):
|
||||
// CreatePolicy (5 variants), CreatePolicyVersion / ListPolicyVersions /
|
||||
// SetDefaultPolicyVersion / DeletePolicyVersion (extends bash, per
|
||||
// task spec), GetPolicy, GetPolicyVersion, ListPolicies plus
|
||||
// EntityAlreadyExists / MalformedPolicyDocument / NoSuchEntity errors.
|
||||
// Captures the admin account ID into the Fixture for Phase 5–7.
|
||||
func phaseIAM4_PolicyCRUD(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 4 — Policy CRUD")
|
||||
|
||||
// Defensive: drop any leftover test policies from a prior failed run.
|
||||
// Detach-then-delete isn't worth the round trips here — the cleanup
|
||||
// in Phase 7 (or a teardown of a previous test) should have cleared
|
||||
// these; if not, the create below will surface EntityAlreadyExists
|
||||
// and the operator can hand-clean.
|
||||
|
||||
harness.Step(t, "create-policy %s", iamPolicyEC2ReadOnly)
|
||||
pol, err := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(iamPolicyEC2ReadOnly),
|
||||
PolicyDocument: aws.String(iamDocEC2ReadOnly),
|
||||
})
|
||||
require.NoError(t, err, "create-policy %s", iamPolicyEC2ReadOnly)
|
||||
ec2roArn := aws.StringValue(pol.Policy.Arn)
|
||||
require.NotEmpty(t, ec2roArn, "empty policy ARN")
|
||||
fix.IAMAdminAccount = iamAccountFromARN(t, ec2roArn)
|
||||
harness.Detail(t, "policy", iamPolicyEC2ReadOnly, "arn", ec2roArn, "account", fix.IAMAdminAccount)
|
||||
|
||||
harness.Step(t, "create-policy %s path=%s", iamPolicyFullAdmin, iamPolicyFullAdminPath)
|
||||
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(iamPolicyFullAdmin),
|
||||
Path: aws.String(iamPolicyFullAdminPath),
|
||||
Description: aws.String("Full access to all services"),
|
||||
PolicyDocument: aws.String(iamDocFullAdmin),
|
||||
})
|
||||
require.NoError(t, err, "create-policy %s", iamPolicyFullAdmin)
|
||||
|
||||
harness.Step(t, "create-policy %s (mixed Allow+Deny)", iamPolicyDenyTerminate)
|
||||
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(iamPolicyDenyTerminate),
|
||||
PolicyDocument: aws.String(iamDocDenyTerminate),
|
||||
})
|
||||
require.NoError(t, err, "create-policy %s", iamPolicyDenyTerminate)
|
||||
|
||||
harness.Step(t, "create-policy %s", iamPolicyIAMReadOnly)
|
||||
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(iamPolicyIAMReadOnly),
|
||||
PolicyDocument: aws.String(iamDocIAMReadOnly),
|
||||
})
|
||||
require.NoError(t, err, "create-policy %s", iamPolicyIAMReadOnly)
|
||||
|
||||
harness.Step(t, "create-policy %s (wildcard)", iamPolicyEC2DescribeAll)
|
||||
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(iamPolicyEC2DescribeAll),
|
||||
PolicyDocument: aws.String(iamDocEC2DescribeAll),
|
||||
})
|
||||
require.NoError(t, err, "create-policy %s", iamPolicyEC2DescribeAll)
|
||||
|
||||
harness.Step(t, "create-policy duplicate (expect EntityAlreadyExists)")
|
||||
harness.ExpectError(t, "EntityAlreadyExists", func() error {
|
||||
_, e := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(iamPolicyEC2ReadOnly),
|
||||
PolicyDocument: aws.String(iamDocFullAdmin),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "create-policy malformed (expect MalformedPolicyDocument)")
|
||||
harness.ExpectError(t, "MalformedPolicyDocument", func() error {
|
||||
_, e := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String("BadPolicy"),
|
||||
PolicyDocument: aws.String(`{"not valid"}`),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "get-policy %s", iamPolicyEC2ReadOnly)
|
||||
got, err := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(ec2roArn)})
|
||||
require.NoError(t, err, "get-policy %s", iamPolicyEC2ReadOnly)
|
||||
require.Equal(t, iamPolicyEC2ReadOnly, aws.StringValue(got.Policy.PolicyName))
|
||||
|
||||
harness.Step(t, "get-policy nonexistent (expect NoSuchEntity)")
|
||||
ghostArn := iamPolicyARN(fix.IAMAdminAccount, "Ghost")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(ghostArn)})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "get-policy-version %s v1", iamPolicyEC2ReadOnly)
|
||||
pv, err := fix.AWS.IAM.GetPolicyVersion(&iam.GetPolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
VersionId: aws.String("v1"),
|
||||
})
|
||||
require.NoError(t, err, "get-policy-version v1")
|
||||
require.Equal(t, "v1", aws.StringValue(pv.PolicyVersion.VersionId))
|
||||
|
||||
// Policy version lifecycle — extends the bash driver (task spec).
|
||||
// Round-trip: create v2 (as default), list, set v1 default, delete v2.
|
||||
harness.Step(t, "create-policy-version %s v2 (default)", iamPolicyEC2ReadOnly)
|
||||
cv, err := fix.AWS.IAM.CreatePolicyVersion(&iam.CreatePolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
PolicyDocument: aws.String(iamDocEC2ReadOnlyV2),
|
||||
SetAsDefault: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err, "create-policy-version v2")
|
||||
v2 := aws.StringValue(cv.PolicyVersion.VersionId)
|
||||
require.NotEmpty(t, v2, "empty new VersionId")
|
||||
harness.Detail(t, "new_version", v2)
|
||||
|
||||
harness.Step(t, "list-policy-versions %s", iamPolicyEC2ReadOnly)
|
||||
versions, err := fix.AWS.IAM.ListPolicyVersions(&iam.ListPolicyVersionsInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
})
|
||||
require.NoError(t, err, "list-policy-versions")
|
||||
require.GreaterOrEqual(t, len(versions.Versions), 2,
|
||||
"expected >=2 versions, got %d", len(versions.Versions))
|
||||
|
||||
harness.Step(t, "set-default-policy-version %s v1", iamPolicyEC2ReadOnly)
|
||||
_, err = fix.AWS.IAM.SetDefaultPolicyVersion(&iam.SetDefaultPolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
VersionId: aws.String("v1"),
|
||||
})
|
||||
require.NoError(t, err, "set-default-policy-version v1")
|
||||
|
||||
harness.Step(t, "delete-policy-version %s %s", iamPolicyEC2ReadOnly, v2)
|
||||
_, err = fix.AWS.IAM.DeletePolicyVersion(&iam.DeletePolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
VersionId: aws.String(v2),
|
||||
})
|
||||
require.NoError(t, err, "delete-policy-version %s", v2)
|
||||
|
||||
harness.Step(t, "list-policies (>=5)")
|
||||
all, err := fix.AWS.IAM.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err, "list-policies")
|
||||
require.GreaterOrEqual(t, len(all.Policies), 5,
|
||||
"expected >=5 policies, got %d (%v)", len(all.Policies), iamPolicyNames(all.Policies))
|
||||
}
|
||||
|
||||
// phaseIAM5_PolicyAttachmentEnforcement ports IAM Phase 5 (~1702–1833):
|
||||
// AttachUserPolicy idempotency, ListAttachedUserPolicies, enforcement
|
||||
// (default deny / explicit allow / wildcard / explicit deny / root
|
||||
// bypass / prefix wildcard / FullAdmin), DetachUserPolicy.
|
||||
func phaseIAM5_PolicyAttachmentEnforcement(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 5 — Policy Attachment & Enforcement")
|
||||
require.NotEmpty(t, fix.IAMAdminAccount, "Phase 4 must populate fix.IAMAdminAccount")
|
||||
|
||||
ec2roArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyEC2ReadOnly)
|
||||
iamroArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyIAMReadOnly)
|
||||
denyArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyDenyTerminate)
|
||||
descAllArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyEC2DescribeAll)
|
||||
fullAdminArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyFullAdminPath[1:]+iamPolicyFullAdmin)
|
||||
|
||||
harness.Step(t, "create-user %q + key (enforcement subject)", iamUserCharlie)
|
||||
iamDeleteUserBestEffort(fix, iamUserCharlie)
|
||||
_, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(iamUserCharlie),
|
||||
})
|
||||
require.NoError(t, err, "create-user %s", iamUserCharlie)
|
||||
charlieKey, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(iamUserCharlie),
|
||||
})
|
||||
require.NoError(t, err, "create-access-key %s", iamUserCharlie)
|
||||
fix.IAMCharlieKeyID = aws.StringValue(charlieKey.AccessKey.AccessKeyId)
|
||||
fix.IAMCharlieSecret = aws.StringValue(charlieKey.AccessKey.SecretAccessKey)
|
||||
harness.Detail(t, "charlie_key", fix.IAMCharlieKeyID)
|
||||
|
||||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, fix.IAMAliceSecret)
|
||||
bobCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMBobKeyID, fix.IAMBobSecret)
|
||||
charlieCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMCharlieKeyID, fix.IAMCharlieSecret)
|
||||
|
||||
harness.Step(t, "attach-user-policy alice <- EC2ReadOnly + IAMReadOnly")
|
||||
_, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(ec2roArn),
|
||||
})
|
||||
require.NoError(t, err, "attach EC2ReadOnly")
|
||||
_, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(iamroArn),
|
||||
})
|
||||
require.NoError(t, err, "attach IAMReadOnly")
|
||||
|
||||
harness.Step(t, "attach-user-policy bob <- DenyTerminate")
|
||||
_, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserBob), PolicyArn: aws.String(denyArn),
|
||||
})
|
||||
require.NoError(t, err, "attach DenyTerminate")
|
||||
|
||||
harness.Step(t, "list-attached-user-policies alice (expect 2)")
|
||||
attached, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "list-attached-user-policies alice")
|
||||
require.Len(t, attached.AttachedPolicies, 2, "alice attached count")
|
||||
|
||||
harness.Step(t, "idempotent attach EC2ReadOnly")
|
||||
_, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(ec2roArn),
|
||||
})
|
||||
require.NoError(t, err, "idempotent attach")
|
||||
attached2, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
})
|
||||
require.NoError(t, err, "list-attached after idempotent attach")
|
||||
require.Len(t, attached2.AttachedPolicies, 2,
|
||||
"attached count must not grow on idempotent re-attach")
|
||||
|
||||
harness.Step(t, "attach non-existent policy (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
PolicyArn: aws.String(iamPolicyARN(fix.IAMAdminAccount, "Ghost")),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "attach to non-existent user (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String("ghost"),
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
// --- Enforcement ---
|
||||
|
||||
harness.Step(t, "default deny (charlie, no policies)")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := charlieCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
return e
|
||||
})
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := charlieCli.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "explicit allow (alice, EC2ReadOnly + IAMReadOnly)")
|
||||
_, err = aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "alice ec2:DescribeInstances")
|
||||
_, err = aliceCli.EC2.DescribeVpcs(&ec2.DescribeVpcsInput{})
|
||||
require.NoError(t, err, "alice ec2:DescribeVpcs")
|
||||
_, err = aliceCli.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err, "alice iam:ListUsers")
|
||||
|
||||
harness.Step(t, "deny actions outside alice's policies")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := aliceCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{})
|
||||
return e
|
||||
})
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := aliceCli.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String("hack")})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "wildcard allow + explicit deny (bob, DenyTerminate)")
|
||||
_, err = bobCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "bob ec2:DescribeInstances")
|
||||
_, err = bobCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{})
|
||||
require.NoError(t, err, "bob ec2:DescribeKeyPairs")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := bobCli.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
||||
InstanceIds: []*string{aws.String("i-fake")},
|
||||
})
|
||||
return e
|
||||
})
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := bobCli.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "root user bypass")
|
||||
_, err = fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "root ec2:DescribeInstances")
|
||||
_, err = fix.AWS.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err, "root iam:ListUsers")
|
||||
_, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String("temp")})
|
||||
require.NoError(t, err, "root create-user temp")
|
||||
_, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String("temp")})
|
||||
require.NoError(t, err, "root delete-user temp")
|
||||
|
||||
harness.Step(t, "prefix wildcard (alice -> EC2DescribeAll)")
|
||||
_, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(ec2roArn),
|
||||
})
|
||||
require.NoError(t, err, "detach EC2ReadOnly")
|
||||
_, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(iamroArn),
|
||||
})
|
||||
require.NoError(t, err, "detach IAMReadOnly")
|
||||
_, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(descAllArn),
|
||||
})
|
||||
require.NoError(t, err, "attach EC2DescribeAll")
|
||||
|
||||
_, err = aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "alice ec2:DescribeInstances (Describe*)")
|
||||
_, err = aliceCli.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{})
|
||||
require.NoError(t, err, "alice ec2:DescribeKeyPairs (Describe*)")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := aliceCli.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String("x")})
|
||||
return e
|
||||
})
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := aliceCli.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "FullAdmin (charlie unlocks)")
|
||||
_, err = fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserCharlie), PolicyArn: aws.String(fullAdminArn),
|
||||
})
|
||||
require.NoError(t, err, "attach FullAdmin to charlie")
|
||||
_, err = charlieCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "charlie ec2:DescribeInstances after FullAdmin")
|
||||
_, err = charlieCli.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err, "charlie iam:ListUsers after FullAdmin")
|
||||
}
|
||||
|
||||
// phaseIAM6_PolicyLifecycle ports IAM Phase 6 (~1837–1864):
|
||||
// detach alice's policy and confirm she loses access; deleting a
|
||||
// still-attached policy must surface DeleteConflict; after detach +
|
||||
// delete the policy is gone (NoSuchEntity).
|
||||
func phaseIAM6_PolicyLifecycle(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 6 — Policy Lifecycle (Detach & Delete)")
|
||||
require.NotEmpty(t, fix.IAMAdminAccount, "Phase 4 must populate fix.IAMAdminAccount")
|
||||
|
||||
descAllArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyEC2DescribeAll)
|
||||
denyArn := iamPolicyARN(fix.IAMAdminAccount, iamPolicyDenyTerminate)
|
||||
|
||||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, fix.IAMAliceKeyID, fix.IAMAliceSecret)
|
||||
|
||||
harness.Step(t, "detach EC2DescribeAll from alice (expect AccessDenied)")
|
||||
_, err := fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice), PolicyArn: aws.String(descAllArn),
|
||||
})
|
||||
require.NoError(t, err, "detach EC2DescribeAll")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "delete-policy DenyTerminate while attached (expect DeleteConflict)")
|
||||
harness.ExpectError(t, "DeleteConflict", func() error {
|
||||
_, e := fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(denyArn)})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "detach DenyTerminate from bob, then delete-policy")
|
||||
_, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
||||
UserName: aws.String(iamUserBob), PolicyArn: aws.String(denyArn),
|
||||
})
|
||||
require.NoError(t, err, "detach DenyTerminate")
|
||||
_, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(denyArn)})
|
||||
require.NoError(t, err, "delete-policy DenyTerminate")
|
||||
|
||||
harness.Step(t, "get-policy DenyTerminate (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(denyArn)})
|
||||
return e
|
||||
})
|
||||
}
|
||||
|
||||
// phaseIAM7_Cleanup ports IAM Phase 7 (~1868–1911): tear down every
|
||||
// user, key, and policy created by Phases 1–6 even if intermediate
|
||||
// phases failed. Each step is best-effort so a missing resource
|
||||
// (already torn down or never created) doesn't fail the cleanup pass.
|
||||
// The final list-users / list-policies assertions are commented
|
||||
// expectations rather than hard checks because Phase 7 may run with
|
||||
// residual state from a partial Phase 1–6 failure.
|
||||
func phaseIAM7_Cleanup(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "IAM Phase 7 — Cleanup")
|
||||
|
||||
// Users: delete their keys first, then the user. iamDeleteUserBestEffort
|
||||
// handles already-deleted resources gracefully.
|
||||
for _, u := range []string{iamUserAlice, iamUserBob, iamUserCharlie} {
|
||||
harness.Step(t, "cleanup user %s", u)
|
||||
iamDeleteUserBestEffort(fix, u)
|
||||
}
|
||||
|
||||
// Verify root is the only user left. Soft check — log + continue
|
||||
// rather than fail, because Phase 7 must succeed even if a prior
|
||||
// phase left orphans we can't legally delete.
|
||||
users, err := fix.AWS.IAM.ListUsers(&iam.ListUsersInput{})
|
||||
if err == nil {
|
||||
harness.Detail(t, "remaining_users", len(users.Users), "names", iamUserNames(users.Users))
|
||||
assert.LessOrEqual(t, len(users.Users), 1,
|
||||
"expected only root after cleanup, got %d (%v)", len(users.Users), iamUserNames(users.Users))
|
||||
}
|
||||
|
||||
// Policies: detach (where applicable) and delete every test policy.
|
||||
// FullAdmin lives under /admin/ so its ARN includes the path.
|
||||
if fix.IAMAdminAccount == "" {
|
||||
// Phase 4 never ran → nothing to clean. Skip policy teardown
|
||||
// entirely; this keeps Phase 7 idempotent when run standalone.
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range []struct{ name, path string }{
|
||||
{iamPolicyEC2ReadOnly, ""},
|
||||
{iamPolicyFullAdmin, iamPolicyFullAdminPath},
|
||||
{iamPolicyIAMReadOnly, ""},
|
||||
{iamPolicyEC2DescribeAll, ""},
|
||||
{iamPolicyDenyTerminate, ""}, // already deleted in Phase 6 but defensive
|
||||
} {
|
||||
key := p.name
|
||||
if p.path != "" {
|
||||
// AWS stores the path inside the ARN, not the policy name —
|
||||
// stripping the leading '/' matches iamPolicyARN's expectation.
|
||||
key = p.path[1:] + p.name
|
||||
}
|
||||
arn := iamPolicyARN(fix.IAMAdminAccount, key)
|
||||
harness.Step(t, "cleanup policy %s", arn)
|
||||
iamDeletePolicyBestEffort(fix, arn)
|
||||
}
|
||||
|
||||
pols, err := fix.AWS.IAM.ListPolicies(&iam.ListPoliciesInput{})
|
||||
if err == nil {
|
||||
harness.Detail(t, "remaining_policies", len(pols.Policies), "names", iamPolicyNames(pols.Policies))
|
||||
// Bash asserts exactly 1 (AdministratorAccess). We assert that
|
||||
// no *test* policy survives — robust to bootstrap variations
|
||||
// across environments.
|
||||
for _, p := range pols.Policies {
|
||||
name := aws.StringValue(p.PolicyName)
|
||||
assert.NotContains(t,
|
||||
[]string{iamPolicyEC2ReadOnly, iamPolicyFullAdmin, iamPolicyDenyTerminate,
|
||||
iamPolicyIAMReadOnly, iamPolicyEC2DescribeAll},
|
||||
name,
|
||||
"test policy %q survived cleanup", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// iamDeleteUserBestEffort detaches all policies, deletes all access keys,
|
||||
// then deletes the user. Every step is best-effort; missing resources
|
||||
// are ignored so callers (Phase 7 cleanup, pre-phase recreate) can use
|
||||
// it idempotently.
|
||||
func iamDeleteUserBestEffort(fix *Fixture, user string) {
|
||||
// Detach any attached managed policies first — DeleteUser fails
|
||||
// with DeleteConflict if anything is still attached.
|
||||
attached, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
|
||||
UserName: aws.String(user),
|
||||
})
|
||||
if err == nil {
|
||||
for _, p := range attached.AttachedPolicies {
|
||||
_, _ = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
||||
UserName: aws.String(user),
|
||||
PolicyArn: p.PolicyArn,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Delete every access key for this user.
|
||||
keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(user),
|
||||
})
|
||||
if err == nil {
|
||||
for _, k := range keys.AccessKeyMetadata {
|
||||
_, _ = fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(user),
|
||||
AccessKeyId: k.AccessKeyId,
|
||||
})
|
||||
}
|
||||
}
|
||||
_, _ = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(user)})
|
||||
}
|
||||
|
||||
// iamDeletePolicyBestEffort drops non-default policy versions and
|
||||
// deletes the policy. Missing/unattached resources are ignored.
|
||||
func iamDeletePolicyBestEffort(fix *Fixture, arn string) {
|
||||
versions, err := fix.AWS.IAM.ListPolicyVersions(&iam.ListPolicyVersionsInput{
|
||||
PolicyArn: aws.String(arn),
|
||||
})
|
||||
if err == nil {
|
||||
for _, v := range versions.Versions {
|
||||
if aws.BoolValue(v.IsDefaultVersion) {
|
||||
continue
|
||||
}
|
||||
_, _ = fix.AWS.IAM.DeletePolicyVersion(&iam.DeletePolicyVersionInput{
|
||||
PolicyArn: aws.String(arn),
|
||||
VersionId: v.VersionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
_, _ = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(arn)})
|
||||
}
|
||||
|
||||
// iamFindKeyStatus returns the Status of a specific access key for a
|
||||
// user. Fails the test if the key isn't found — caller has already
|
||||
// asserted the key exists.
|
||||
func iamFindKeyStatus(t *testing.T, fix *Fixture, user, keyID string) string {
|
||||
t.Helper()
|
||||
out, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(user),
|
||||
})
|
||||
require.NoError(t, err, "list-access-keys %s", user)
|
||||
for _, k := range out.AccessKeyMetadata {
|
||||
if aws.StringValue(k.AccessKeyId) == keyID {
|
||||
return aws.StringValue(k.Status)
|
||||
}
|
||||
}
|
||||
t.Fatalf("access key %s not found for user %s", keyID, user)
|
||||
return ""
|
||||
}
|
||||
|
||||
// iamUserNames extracts user names from a *iam.User slice — handy for
|
||||
// log lines that need to print the actual set on assertion failures.
|
||||
func iamUserNames(users []*iam.User) []string {
|
||||
out := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, aws.StringValue(u.UserName))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// iamPolicyNames mirrors iamUserNames for *iam.Policy.
|
||||
func iamPolicyNames(policies []*iam.Policy) []string {
|
||||
out := make([]string, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
out = append(out, aws.StringValue(p.PolicyName))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// iamAccountFromARN parses the account-id segment from an ARN like
|
||||
//
|
||||
// arn:aws:iam::123456789012:policy/EC2ReadOnly
|
||||
//
|
||||
// Fails the test if the ARN doesn't have the expected 6-field shape.
|
||||
func iamAccountFromARN(t *testing.T, arn string) string {
|
||||
t.Helper()
|
||||
parts := strings.Split(arn, ":")
|
||||
require.GreaterOrEqual(t, len(parts), 6, "unexpected ARN shape: %q", arn)
|
||||
require.NotEmpty(t, parts[4], "empty account-id in ARN %q", arn)
|
||||
return parts[4]
|
||||
}
|
||||
|
||||
// iamPolicyARN builds the canonical policy ARN for a given account +
|
||||
// policy "key" (name or "path/name" without the leading slash).
|
||||
func iamPolicyARN(account, key string) string {
|
||||
return "arn:aws:iam::" + account + ":policy/" + key
|
||||
}
|
||||
@@ -52,6 +52,17 @@ type Fixture struct {
|
||||
|
||||
OVNAvailable bool // gates 8b-e
|
||||
PoolMode bool // gates 8b / 8d
|
||||
|
||||
// IAM phase state (Stage E). Threaded between IAM Phase 1–7 so
|
||||
// Phase 7's cleanup can defensively delete keys/users created
|
||||
// earlier even when an intermediate phase short-circuits.
|
||||
IAMAdminAccount string // account ID extracted from CreatePolicy ARN
|
||||
IAMAliceKeyID string
|
||||
IAMAliceSecret string
|
||||
IAMBobKeyID string
|
||||
IAMBobSecret string
|
||||
IAMCharlieKeyID string
|
||||
IAMCharlieSecret string
|
||||
}
|
||||
|
||||
// TestSingleNode is the Go port of run-e2e.sh. Phases run as sequential
|
||||
@@ -100,7 +111,17 @@ func TestSingleNode(t *testing.T) {
|
||||
|
||||
t.Run("Phase8_NegativeErrorPaths", func(t *testing.T) { phase8_NegativeErrorPaths(t, fix) })
|
||||
|
||||
// Stages E–G will append more t.Run calls here.
|
||||
t.Run("IAM1_UserCRUD", func(t *testing.T) { phaseIAM1_UserCRUD(t, fix) })
|
||||
t.Run("IAM2_AccessKeyLifecycle", func(t *testing.T) { phaseIAM2_AccessKeyLifecycle(t, fix) })
|
||||
t.Run("IAM3_UserAuthentication", func(t *testing.T) { phaseIAM3_UserAuthentication(t, fix) })
|
||||
t.Run("IAM4_PolicyCRUD", func(t *testing.T) { phaseIAM4_PolicyCRUD(t, fix) })
|
||||
t.Run("IAM5_PolicyAttachmentEnforcement", func(t *testing.T) { phaseIAM5_PolicyAttachmentEnforcement(t, fix) })
|
||||
t.Run("IAM6_PolicyLifecycle", func(t *testing.T) { phaseIAM6_PolicyLifecycle(t, fix) })
|
||||
t.Run("IAM7_Cleanup", func(t *testing.T) { phaseIAM7_Cleanup(t, fix) })
|
||||
|
||||
t.Run("Phase8Acct_AccountScoping", func(t *testing.T) { phase8Acct_AccountScoping(t, fix) })
|
||||
|
||||
// Stages F–G will append more t.Run calls here.
|
||||
|
||||
harness.OnFailure(t, func() {
|
||||
harness.DumpCmd(t, fix.Artifacts, "ec2-describe-instances.txt",
|
||||
|
||||
Reference in New Issue
Block a user