mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
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:
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user