Files
spinifex/tests/e2e/single/iam_test.go
T
Josh Siviour 40f8c4da94 refactor(e2e/single): drop phase numbers + parallel bucket #1 (#247)
- 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).
2026-05-21 22:47:26 +10:00

957 lines
39 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 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
}