From 3da80d97430d64924481d9f52075e2e9b115e69d Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Mon, 20 Jul 2026 11:05:45 +1000 Subject: [PATCH] test(integration): add IAM/STS client accessors and port the zero-stub authz tests Adds IAMClient/STSClient plus ClientsWithCreds/ClientsWithSessionCreds so a test can act as a principal other than the seeded root user, and stubs the node-discovery subject in StartGateway so call paths that trigger discovery resolve immediately instead of riding out the fan-out timeout. Ports the four enforcement tests that guard on ec2:DescribeRegions, a gateway-local action, so they need no daemon-side stub at all. --- tests/integration/assumed_role_test.go | 85 ++++++++++++ tests/integration/harness.go | 84 +++++++++++- tests/integration/iam_groups_test.go | 177 +++++++++++++++++++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 tests/integration/assumed_role_test.go create mode 100644 tests/integration/iam_groups_test.go diff --git a/tests/integration/assumed_role_test.go b/tests/integration/assumed_role_test.go new file mode 100644 index 000000000..90048689c --- /dev/null +++ b/tests/integration/assumed_role_test.go @@ -0,0 +1,85 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // assumedRoleTrustPolicy lets any principal assume the role, so the test + // isolates policy attachment as the sole variable under test. + assumedRoleTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + + // A single-action grant, so the positive case proves a specific managed + // policy was resolved and evaluated - not a blanket allow. + assumedRoleDescribeRegionsPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}` +) + +// requireAWSErrorCode fails t unless err is an awserr.Error carrying code. +// Shared by every allow/deny authz assertion in this tier. +func requireAWSErrorCode(t *testing.T, err error, code string) { + t.Helper() + require.Error(t, err) + var awsErr awserr.Error + require.ErrorAs(t, err, &awsErr, "expected awserr.Error, got %T: %v", err, err) + assert.Equal(t, code, awsErr.Code()) +} + +// TestAssumedRoleControlPlaneEnforcement proves the gateway evaluates +// assumed-role sessions against managed policies: a zero-policy role is +// denied a guarded action, and the same live session is allowed once a +// policy is attached to the role (policies resolved per request). +// ec2:DescribeRegions is dispatched entirely inside the gateway +// (gateway/ec2.go ec2LocalActions) so this exercises only gateway-side authz, +// with no NATS stub involved. +func TestAssumedRoleControlPlaneEnforcement(t *testing.T) { + gw := StartGateway(t) + + roleOut, err := gw.IAMClient(t).CreateRole(&iam.CreateRoleInput{ + RoleName: aws.String("are-role"), + AssumeRolePolicyDocument: aws.String(assumedRoleTrustPolicy), + }) + require.NoError(t, err, "create-role") + + assumeOut, err := gw.STSClient(t).AssumeRole(&sts.AssumeRoleInput{ + RoleArn: roleOut.Role.Arn, + RoleSessionName: aws.String("are-session"), + }) + require.NoError(t, err, "assume-role") + creds := assumeOut.Credentials + require.NotNil(t, creds, "AssumeRole returned nil Credentials") + + sessionCli := gw.ClientsWithSessionCreds(t, + aws.StringValue(creds.AccessKeyId), + aws.StringValue(creds.SecretAccessKey), + aws.StringValue(creds.SessionToken)) + + // Zero-policy role: the session authenticates, then default-denies. + _, err = sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") + + policyOut, err := gw.IAMClient(t).CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String("are-ec2-describe-regions"), + PolicyDocument: aws.String(assumedRoleDescribeRegionsPolicy), + }) + require.NoError(t, err, "create-policy") + + _, err = gw.IAMClient(t).AttachRolePolicy(&iam.AttachRolePolicyInput{ + RoleName: roleOut.Role.RoleName, + PolicyArn: policyOut.Policy.Arn, + }) + require.NoError(t, err, "attach-role-policy") + + // Same live session credentials are now allowed - the role grants it. + _, err = sessionCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + require.NoError(t, err, "describe-regions must be allowed once the role grants it") +} diff --git a/tests/integration/harness.go b/tests/integration/harness.go index 776810d54..6e27a9117 100644 --- a/tests/integration/harness.go +++ b/tests/integration/harness.go @@ -19,6 +19,7 @@ package integration import ( + "encoding/json" "net/http/httptest" "testing" @@ -26,10 +27,13 @@ import ( awscreds "github.com/aws/aws-sdk-go/aws/credentials" awssession "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/sts" "github.com/mulgadc/spinifex/spinifex/gateway" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts" "github.com/mulgadc/spinifex/spinifex/testutil" + "github.com/mulgadc/spinifex/spinifex/types" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/stretchr/testify/require" @@ -48,6 +52,12 @@ const ( // spinifex/gateway/auth.go resolveLongLivedAKID). testAccessKeyID = "AKIAINTEGRATIONTESTR" testSecretAccessKey = "integration-test-root-secret" + + // nodeDiscoverSubject is the fan-out GatewayConfig.DiscoverActiveNodes + // publishes to (spinifex/gateway/gateway.go). StartGateway stubs it with + // a single-node reply so any call path that triggers node discovery + // resolves immediately instead of riding out the fan-out's 500ms timeout. + nodeDiscoverSubject = "spinifex.nodes.discover" ) // Gateway is a running in-process instance of the real AWS gateway router, @@ -129,12 +139,18 @@ func StartGateway(t *testing.T, opts ...Option) *Gateway { srv := httptest.NewServer(cfg.SetupRoutes()) t.Cleanup(srv.Close) - return &Gateway{ + gw := &Gateway{ Server: srv, NATSConn: nc, AccountID: utils.GlobalAccountID, Config: cfg, } + + nodeReply, err := json.Marshal(types.NodeDiscoverResponse{Node: "integration-test-node"}) + require.NoError(t, err) + gw.StubSubject(t, nodeDiscoverSubject, nodeReply) + + return gw } // EC2Client returns an aws-sdk-go v1 EC2 client pointed at the gateway's @@ -145,6 +161,72 @@ func (gw *Gateway) EC2Client(t *testing.T) *ec2.EC2 { return ec2.New(gw.session(t)) } +// IAMClient returns an aws-sdk-go v1 IAM client pointed at the gateway's +// httptest server, signed with the seeded root credentials. +func (gw *Gateway) IAMClient(t *testing.T) *iam.IAM { + t.Helper() + return iam.New(gw.session(t)) +} + +// STSClient returns an aws-sdk-go v1 STS client pointed at the gateway's +// httptest server, signed with the seeded root credentials. +func (gw *Gateway) STSClient(t *testing.T) *sts.STS { + t.Helper() + return sts.New(gw.session(t)) +} + +// PrincipalClients bundles the SDK service handles an authz test needs to act +// as a principal other than the harness's seeded root user: an IAM user +// created within the test, or a role assumed via STS. Populated by +// ClientsWithCreds / ClientsWithSessionCreds. +type PrincipalClients struct { + EC2 *ec2.EC2 + IAM *iam.IAM + STS *sts.STS +} + +// ClientsWithCreds returns PrincipalClients signed with a static access +// key/secret and no session token — the long-lived-access-key path (an AKIA +// key from IAM CreateAccessKey), as opposed to the seeded-root session +// EC2Client/IAMClient/STSClient use. +func (gw *Gateway) ClientsWithCreds(t *testing.T, accessKeyID, secretAccessKey string) *PrincipalClients { + t.Helper() + return gw.principalClients(t, accessKeyID, secretAccessKey, "") +} + +// ClientsWithSessionCreds returns PrincipalClients signed with STS temporary +// credentials — the assumed-role path (an ASIA key, secret, and session +// token from STS AssumeRole). +func (gw *Gateway) ClientsWithSessionCreds(t *testing.T, accessKeyID, secretAccessKey, sessionToken string) *PrincipalClients { + t.Helper() + return gw.principalClients(t, accessKeyID, secretAccessKey, sessionToken) +} + +// principalClients builds the session backing both ClientsWithCreds and +// ClientsWithSessionCreds. Passing sessionToken == "" here — the +// ClientsWithCreds path — must not add an X-Amz-Security-Token header at +// all: aws-sdk-go's v4 signer only sets the header when SessionToken is +// non-empty, so a single code path correctly serves both the long-lived-key +// and assumed-role principals without a conditional. +func (gw *Gateway) principalClients(t *testing.T, accessKeyID, secretAccessKey, sessionToken string) *PrincipalClients { + t.Helper() + sess, err := awssession.NewSession(&aws.Config{ + Region: aws.String(testRegion), + Endpoint: aws.String(gw.Server.URL), + Credentials: awscreds.NewStaticCredentials(accessKeyID, secretAccessKey, sessionToken), + DisableSSL: aws.Bool(true), + // Error-path tests assert on the first response; the default retry + // loop would mask a deterministic 4xx behind minutes of backoff. + MaxRetries: aws.Int(0), + }) + require.NoError(t, err) + return &PrincipalClients{ + EC2: ec2.New(sess), + IAM: iam.New(sess), + STS: sts.New(sess), + } +} + // session builds the shared aws-sdk-go v1 Session every per-service client // accessor (EC2Client, ...) derives from, pointed at this Gateway's httptest // server and signed with the seeded root credentials. diff --git a/tests/integration/iam_groups_test.go b/tests/integration/iam_groups_test.go new file mode 100644 index 000000000..3fdd5db8a --- /dev/null +++ b/tests/integration/iam_groups_test.go @@ -0,0 +1,177 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/stretchr/testify/require" +) + +// groupDescribeRegionsPolicy is a single-action grant, so the enforcement +// positive case proves a specific group-attached (or group-inline) policy was +// resolved and evaluated - not a blanket allow. +const groupDescribeRegionsPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}` + +// TestIAMGroupEnforcement proves a policy attached to a group actually grants +// its permission to every member: a user with its own access key is denied a +// guarded action, allowed once a policy granting that action is attached to a +// group the user joins (policies resolved per request), and denied again +// after leaving the group. ec2:DescribeRegions is dispatched entirely inside +// the gateway, so this needs no NATS stub. +func TestIAMGroupEnforcement(t *testing.T) { + gw := StartGateway(t) + + userOut, err := gw.IAMClient(t).CreateUser(&iam.CreateUserInput{UserName: aws.String("grp-enf-user")}) + require.NoError(t, err, "create-user") + key, err := gw.IAMClient(t).CreateAccessKey(&iam.CreateAccessKeyInput{UserName: userOut.User.UserName}) + require.NoError(t, err, "create-access-key") + memberCli := gw.ClientsWithCreds(t, + aws.StringValue(key.AccessKey.AccessKeyId), + aws.StringValue(key.AccessKey.SecretAccessKey)) + + // No policy anywhere yet: the active key authenticates, then default-denies. + _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") + + // Group + policy granting ec2:DescribeRegions, then join the user. + groupOut, err := gw.IAMClient(t).CreateGroup(&iam.CreateGroupInput{GroupName: aws.String("grp-enforce")}) + require.NoError(t, err, "create-group") + + policyOut, err := gw.IAMClient(t).CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String("grp-enf-describe-regions"), + PolicyDocument: aws.String(groupDescribeRegionsPolicy), + }) + require.NoError(t, err, "create-policy") + _, err = gw.IAMClient(t).AttachGroupPolicy(&iam.AttachGroupPolicyInput{ + GroupName: groupOut.Group.GroupName, + PolicyArn: policyOut.Policy.Arn, + }) + require.NoError(t, err, "attach-group-policy") + + _, err = gw.IAMClient(t).AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: groupOut.Group.GroupName, + UserName: userOut.User.UserName, + }) + require.NoError(t, err, "add-user-to-group") + + // Same live credentials are now allowed - the grant flows through the group. + _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + require.NoError(t, err, "describe-regions must be allowed once the group grants it") + + // Leave the group and the inherited grant must evaporate. + _, err = gw.IAMClient(t).RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: groupOut.Group.GroupName, + UserName: userOut.User.UserName, + }) + require.NoError(t, err, "remove-user-from-group") + + _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") +} + +// TestIAMGroupInlineEnforcement proves an inline policy embedded in a group +// grants its permission to every member exactly as a managed attachment +// does: denied with no grant, allowed once the inline policy is put and the +// user joins, denied again after the inline policy is deleted - with the +// user still a member, isolating the inline document as the sole grant +// source. ec2:DescribeRegions needs no NATS stub. +func TestIAMGroupInlineEnforcement(t *testing.T) { + gw := StartGateway(t) + + userOut, err := gw.IAMClient(t).CreateUser(&iam.CreateUserInput{UserName: aws.String("grp-inl-enf-user")}) + require.NoError(t, err, "create-user") + key, err := gw.IAMClient(t).CreateAccessKey(&iam.CreateAccessKeyInput{UserName: userOut.User.UserName}) + require.NoError(t, err, "create-access-key") + memberCli := gw.ClientsWithCreds(t, + aws.StringValue(key.AccessKey.AccessKeyId), + aws.StringValue(key.AccessKey.SecretAccessKey)) + + // No grant anywhere yet: the active key authenticates, then default-denies. + _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") + + // Group with an inline policy granting ec2:DescribeRegions, then join the user. + groupOut, err := gw.IAMClient(t).CreateGroup(&iam.CreateGroupInput{GroupName: aws.String("grp-inline-enforce")}) + require.NoError(t, err, "create-group") + + const inlinePolicyName = "grp-inl-enf-describe-regions" + _, err = gw.IAMClient(t).PutGroupPolicy(&iam.PutGroupPolicyInput{ + GroupName: groupOut.Group.GroupName, + PolicyName: aws.String(inlinePolicyName), + PolicyDocument: aws.String(groupDescribeRegionsPolicy), + }) + require.NoError(t, err, "put-group-policy") + + _, err = gw.IAMClient(t).AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: groupOut.Group.GroupName, + UserName: userOut.User.UserName, + }) + require.NoError(t, err, "add-user-to-group") + + // Same live credentials are now allowed - the inline grant flows through the group. + _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + require.NoError(t, err, "describe-regions must be allowed once the group inline policy grants it") + + // Delete the inline policy while the user is still a member - the grant + // must evaporate, isolating the inline document as the sole grant source. + _, err = gw.IAMClient(t).DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ + GroupName: groupOut.Group.GroupName, + PolicyName: aws.String(inlinePolicyName), + }) + require.NoError(t, err, "delete-group-policy") + + _, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") +} + +// TestIAMUserInlineEnforcement proves the user-inline linchpin: a policy +// embedded directly in a user record grants that user its permission with no +// group or managed attachment involved. A user with its own access key is +// denied a guarded action; once an inline policy granting that action is put +// on the user, the same live credentials are allowed (policies resolved per +// request); after the inline policy is deleted the action is denied again, +// isolating the user's own inline document as the sole grant source. +// ec2:DescribeRegions needs no NATS stub. +func TestIAMUserInlineEnforcement(t *testing.T) { + gw := StartGateway(t) + + userOut, err := gw.IAMClient(t).CreateUser(&iam.CreateUserInput{UserName: aws.String("usr-inl-enf-user")}) + require.NoError(t, err, "create-user") + key, err := gw.IAMClient(t).CreateAccessKey(&iam.CreateAccessKeyInput{UserName: userOut.User.UserName}) + require.NoError(t, err, "create-access-key") + userCli := gw.ClientsWithCreds(t, + aws.StringValue(key.AccessKey.AccessKeyId), + aws.StringValue(key.AccessKey.SecretAccessKey)) + + // No grant anywhere yet: the active key authenticates, then default-denies. + _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") + + // Put an inline policy granting ec2:DescribeRegions directly on the user. + const inlinePolicyName = "usr-inl-enf-describe-regions" + _, err = gw.IAMClient(t).PutUserPolicy(&iam.PutUserPolicyInput{ + UserName: userOut.User.UserName, + PolicyName: aws.String(inlinePolicyName), + PolicyDocument: aws.String(groupDescribeRegionsPolicy), + }) + require.NoError(t, err, "put-user-policy") + + // Same live credentials are now allowed - the user's own inline grant applies. + _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + require.NoError(t, err, "describe-regions must be allowed once the user inline policy grants it") + + // Delete the inline policy - the grant must evaporate, isolating the + // inline document as the sole grant source. + _, err = gw.IAMClient(t).DeleteUserPolicy(&iam.DeleteUserPolicyInput{ + UserName: userOut.User.UserName, + PolicyName: aws.String(inlinePolicyName), + }) + require.NoError(t, err, "delete-user-policy") + + _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + requireAWSErrorCode(t, err, "AccessDenied") +}