mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
40f8c4da94
- Rename 36 phaseN_X / phaseIAM_N funcs to runX. - Drop numeric suffix from 7 TestIAMN_X wrappers (TestIAM1_UserCRUD -> TestIAMUserCRUD). - Rewrite harness.Phase log strings "Phase N - X" -> "Single - X" to mirror multinode convention. - Add t.Parallel() to bucket #1: 10 read-only / own-everything top-level Tests (Environment, ClusterStatsCLI, Discovery, SerialConsoleAccess, KeyPairs, Image, TagManagement, AccountScoping, VPCSubnetE2E, RouteTableValidation). Sequential bucket retains source-order singleton lifecycle (InstanceLaunch through FinalClusterStats).
957 lines
39 KiB
Go
957 lines
39 KiB
Go
//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": "*"
|
||
}]
|
||
}`
|
||
)
|
||
|
||
// runIAMUserCRUD 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 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))
|
||
|
||
// UpdateUser rename+path round-trip: daemon does not implement the
|
||
// UpdateUser action yet (returns InvalidAction). Skip-gate the block
|
||
// so the rest of Phase 1 still runs; flip to a real assertion once
|
||
// the daemon handler lands.
|
||
t.Run("UpdateUser", func(t *testing.T) {
|
||
t.Skip("daemon UpdateUser handler not implemented — InvalidAction")
|
||
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
|
||
})
|
||
}
|
||
|
||
// 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
|
||
// 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 runIAMUserAuthentication(t *testing.T, fix *Fixture) {
|
||
harness.Phase(t, "Single — IAM User Authentication")
|
||
// Daemon does not yet honour active-key signatures created via the IAM
|
||
// API — every scoped DescribeInstances returns 403. Skip-gate until
|
||
// the handler lands; mulga-siv-100 tracks the daemon-side work.
|
||
t.Skip("daemon IAM signature/principal lookup not implemented — mulga-siv-100")
|
||
aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix)
|
||
|
||
harness.Step(t, "scoped client (alice) describe-instances — active key OK")
|
||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret)
|
||
_, 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(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), 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 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, 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 := 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))
|
||
|
||
// Policy version lifecycle — extends the bash driver (task spec).
|
||
// Round-trip: create v2 (as default), list, set v1 default, delete v2.
|
||
// Daemon does not implement CreatePolicyVersion / ListPolicyVersions /
|
||
// SetDefaultPolicyVersion / DeletePolicyVersion yet (InvalidAction).
|
||
// Skip-gate until the handlers land.
|
||
t.Run("PolicyVersionLifecycle", func(t *testing.T) {
|
||
t.Skip("daemon CreatePolicyVersion handler not implemented — InvalidAction")
|
||
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))
|
||
}
|
||
|
||
// 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 IAM Phase 5 — Policy Attachment & Enforcement Enforcement")
|
||
// Enforcement depends on scoped-credential signing (Phase 3) which the
|
||
// daemon doesn't honour yet — mulga-siv-100. Skip-gate the whole phase
|
||
// until the upstream gap closes.
|
||
t.Skip("daemon IAM scoped-credential enforcement not implemented — mulga-siv-100")
|
||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||
aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix)
|
||
bobKeyID, bobSecret := iamEnsureBobKey(t, fix)
|
||
charlieKeyID, charlieSecret := iamEnsureCharlieKey(t, fix)
|
||
|
||
ec2roArn := iamPolicyARN(adminAccount, iamPolicyEC2ReadOnly)
|
||
iamroArn := iamPolicyARN(adminAccount, iamPolicyIAMReadOnly)
|
||
denyArn := iamPolicyARN(adminAccount, iamPolicyDenyTerminate)
|
||
descAllArn := iamPolicyARN(adminAccount, iamPolicyEC2DescribeAll)
|
||
fullAdminArn := 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(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 IAM Phase 6 — Policy Lifecycle (Detach & Delete) Delete)")
|
||
// DetachUserPolicy returns 404 NoSuchEntity even for attachments
|
||
// confirmed by Phase 5 — daemon's attachment ledger isn't persisted
|
||
// across the API boundary. Skip-gate; mulga-siv-100 tracks the fix.
|
||
t.Skip("daemon DetachUserPolicy not implemented — mulga-siv-100")
|
||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||
aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix)
|
||
|
||
descAllArn := iamPolicyARN(adminAccount, iamPolicyEC2DescribeAll)
|
||
denyArn := 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 := iamEnsureAdminAccountID(t, fix)
|
||
|
||
// 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 := 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 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
|
||
}
|