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.
This commit is contained in:
joshsiv-mulga
2026-07-20 11:05:45 +10:00
parent 4c3da1e346
commit 3da80d9743
3 changed files with 345 additions and 1 deletions
+85
View File
@@ -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")
}
+83 -1
View File
@@ -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.
+177
View File
@@ -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")
}