Files
spinifex/tests/e2e/iam/iam_test.go
T
2026-06-17 15:06:16 +10:00

863 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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 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]
}