Compare commits

...

2 Commits

Author SHA1 Message Date
Josh Siviour 0fd85d117b test: move remaining service and verdict tests out of gateway (#1372)
* refactor: move gateway auth fixtures to runtime-free gwfix package

* refactor: move ACM gateway tests to awsgw

* refactor: keep gateway-internal ACM tests in gateway and drop test-only exports

* refactor: move RDS gateway tests to awsgw

* refactor: move ELBv2 gateway tests to awsgw

* refactor: move IAM gateway tests to awsgw

* refactor: move STS gateway tests to awsgw

* refactor: move tagging gateway tests to awsgw

* refactor: move operator and bedrock gateway tests to awsgw

* refactor: move ECR gateway tests to awsgw

* refactor: move ECS and EKS gateway tests to awsgw

* refactor: move EC2 gateway tests to awsgw

* refactor: move credential verdict tests to awsgw

* refactor: move policy-gate dispatch tests to their service packages

* refactor: move checkPolicy verdict tests to policyauth

* refactor: run gateway tests on a fake verifier, real auth only for listed verdict tests
2026-11-01 08:42:07 +10:00
Ben Duncan 51d7ac91f5 fix: bound the console's instance-type payload and stop polling it (#1371)
* fix: bound the console's instance-type payload and stop polling it

DescribeInstanceTypes with capacity=true returned one entry per free slot per
node: 3,528,765 bytes of XML for 50 distinct types on prod, refetched every
five seconds. The response was sized by free capacity, so the emptier the
cluster the larger it got, and at 44,346 slots it exceeded the NATS maximum
payload and the console reported no compute at all.

The listing now names each admissible type once. The free-slot figure crosses
as a number through a new GetInstanceTypeCapacity action, open to any account
because the launch form a tenant uses is what needs it. The catalogue query no
longer polls, and the EKS and ECS dialogs read it only when open.

The gateway negotiates gzip inside the SigV4 group, which excludes the OCI
Distribution registry structurally. A response is compressed only if the client
offered gzip, the handler declared neither a length nor an encoding, the
content type is neither streaming nor already compressed, the status carries a
body, and 1 KiB was written before any flush.

An unknown capacity figure no longer caps the count at 1. The launch decides,
and RunInstances answers InsufficientInstanceCapacity if it cannot.

* test: read the free slot count from its own action in the console e2e

Two e2e tests encoded capacity as repeated instance-type entries, so one
expected a caption the deduplicated listing can no longer produce. The fixture
answers GetInstanceTypeCapacity instead, which lets a test give the catalogue
and the figure different answers.

A third test covers the case the change introduces: a session whose policy does
not grant the action reads no figure, so the form offers the type with no
capacity caption and leaves the launch enabled.

The committed bundle is rebuilt, which rehashes every chunk reaching the two
query modules.
2026-10-31 14:03:19 -05:00
258 changed files with 11575 additions and 8700 deletions
+5 -1
View File
@@ -150,13 +150,17 @@ aws ec2 describe-instance-types \
--query "InstanceTypes[?InstanceType=='t3.small']"
```
Show capacity (how many of each type can still be launched):
List only the types that can still be launched:
```bash
aws ec2 describe-instance-types \
--filters Name=capacity,Values=true
```
Each type appears once, whatever its free capacity.
This filter is a Spinifex extension and AWS does not declare it, so it is ignored by a region that does not implement it.
AWS reports exhaustion at launch instead, with `InsufficientInstanceCapacity`, and so does Spinifex.
## SSH
To SSH into instances via their public IPs, see [Setting Up Your Cluster](/docs/setting-up-your-cluster).
+266
View File
@@ -0,0 +1,266 @@
// Package gwfix holds runtime-free fixtures for gateway-level tests: SigV4
// signing helpers, identity/session doubles and policy builders. It must not
// import runtime/ or operator/ so gateway tests stay free of them.
package gwfix
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
awsv1 "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/require"
)
const (
AccessKey = "AKIAIOSFODNN7EXAMPLE" //nolint:gosec // AWS documentation example key
SecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" //nolint:gosec // AWS documentation example secret
Region = "us-east-1"
Service = "ec2"
SessionAKID = "ASIATESTSESSIONAAAAA"
SessionToken = "wire-session-token-plain"
RoleARN = "arn:aws:iam::123456789012:role/test-role"
AssumedARN = "arn:aws:sts::123456789012:assumed-role/test-role/test-session"
PrincipalTypeUser = "user"
PrincipalTypeAssumedRole = "assumed-role"
PrincipalTypeRoot = "root"
)
// IAM implements iamdomain.IAMService for auth tests. It embeds the interface
// so it satisfies the full contract; only the methods the SigV4 path drives are
// wired, and any other method nil-panics to surface an unexpected call.
type IAM struct {
iamdomain.IAMService
AccessKeys map[string]*iamdomain.AccessKey
MasterKey []byte
// Accounts overrides the status the auth gate reads. An empty map means
// every account is ACTIVE.
Accounts map[string]*iamdomain.Account
// GetUserFn overrides the synthetic user record, for a test that cares what
// aws:userid resolves to or that it fails to resolve at all.
GetUserFn func(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
}
func (m *IAM) LookupAccessKey(accessKeyID string) (*iamdomain.AccessKey, error) {
ak, ok := m.AccessKeys[accessKeyID]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
return ak, nil
}
func (m *IAM) DecryptSecret(ciphertext string) (string, error) {
return iamdomain.DecryptSecret(ciphertext, m.MasterKey)
}
func (m *IAM) GetAccount(accountID string) (*iamdomain.Account, error) {
if account, ok := m.Accounts[accountID]; ok {
return account, nil
}
return &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}, nil
}
// GetUser answers with a unique ID derived from the name, so aws:userid resolves
// on the authorization path without every test defining a user record.
func (m *IAM) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
if m.GetUserFn != nil {
return m.GetUserFn(accountID, input)
}
name := awsv1.StringValue(input.UserName)
return &iam.GetUserOutput{User: &iam.User{
UserName: awsv1.String(name),
UserId: awsv1.String("AIDA" + strings.ToUpper(name)),
}}, nil
}
// PolicyIAM adds configurable user and role policy lookups to IAM.
type PolicyIAM struct {
IAM
GetUserPoliciesFn func(accountID, userName string) ([]iamdomain.PolicyDocument, error)
GetRolePoliciesFn func(accountID, roleName string) ([]iamdomain.PolicyDocument, error)
}
func (m *PolicyIAM) GetUserPolicies(accountID, userName string) ([]iamdomain.PolicyDocument, error) {
if m.GetUserPoliciesFn != nil {
return m.GetUserPoliciesFn(accountID, userName)
}
return nil, nil
}
func (m *PolicyIAM) GetRolePolicies(accountID, roleName string) ([]iamdomain.PolicyDocument, error) {
if m.GetRolePoliciesFn != nil {
return m.GetRolePoliciesFn(accountID, roleName)
}
return nil, nil
}
// ErrorLookupIAM returns a configurable error from LookupAccessKey.
type ErrorLookupIAM struct {
IAM
Err error
}
func (m *ErrorLookupIAM) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
return nil, m.Err
}
// MasterKey is a fixed 32-byte key for deterministic secret encryption in tests.
var MasterKey = bytes.Repeat([]byte{0x5a}, 32)
// STS implements the session-credential half of stsdomain.STSService.
type STS struct {
stsdomain.STSService
Sessions map[string]*stsdomain.SessionCredential
Tokens map[string]string // AKID → plaintext wire token for HMAC equivalence
LookupErr error
Lookups atomic.Int32 // counts LookupSessionCredential calls for negative-side-effect assertions
PrincipalErr error // verdict or fault the continuity check returns
PrincipalChecks atomic.Int32 // counts VerifySessionPrincipal calls, to pin its ordering
}
func (m *STS) LookupSessionCredential(accessKeyID string) (*stsdomain.SessionCredential, error) {
m.Lookups.Add(1)
if m.LookupErr != nil {
return nil, m.LookupErr
}
if !strings.HasPrefix(accessKeyID, "ASIA") {
return nil, nil
}
cred, ok := m.Sessions[accessKeyID]
if !ok {
return nil, nil
}
return cred, nil
}
// VerifySessionToken uses plaintext equality rather than HMAC to keep tests
// simple while preserving the contract shape.
func (m *STS) VerifySessionToken(cred *stsdomain.SessionCredential, wireToken string) bool {
if cred == nil {
return false
}
want, ok := m.Tokens[cred.AccessKeyID]
if !ok {
return false
}
return want == wireToken
}
// VerifySessionPrincipal defaults to "still the same principal"; PrincipalErr
// drives the rejection-verdict and dependency-fault branches.
func (m *STS) VerifySessionPrincipal(cred *stsdomain.SessionCredential) (*stsdomain.SessionPrincipal, error) {
m.PrincipalChecks.Add(1)
if m.PrincipalErr != nil {
return nil, m.PrincipalErr
}
return StubSessionPrincipal(cred)
}
// StubSessionPrincipal is the shared "principal unchanged" answer for the
// session doubles. It derives RoleName from the underlying role ARN rather than
// the session name, because that is what the real implementation returns: a
// stub echoing SessionName would let a door read the wrong field and stay green.
func StubSessionPrincipal(cred *stsdomain.SessionCredential) (*stsdomain.SessionPrincipal, error) {
if cred == nil {
return nil, errors.New("nil session credential")
}
roleName := cred.UnderlyingRoleARN
if idx := strings.LastIndex(roleName, "/"); idx >= 0 {
roleName = roleName[idx+1:]
}
return &stsdomain.SessionPrincipal{
UserID: cred.UserID,
RoleID: cred.RoleID,
RoleName: roleName,
RoleARN: cred.UnderlyingRoleARN,
}, nil
}
// SignRequest signs req in place with the aws-sdk-go-v2 v4 signer — the same
// path production callers use via gwsign — so the gateway verifies a real SDK
// signature. body must match what the middleware reads from r.Body so the payload
// hash agrees. An optional signingTime pins the clock for skew tests.
func SignRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret string, signingTime ...time.Time) {
t.Helper()
when := time.Now().UTC()
if len(signingTime) > 0 {
when = signingTime[0]
}
SignRequestAs(t, req, body, accessKey, secret, Service, when)
}
// SignRequestAs signs with an explicit credential-scope service, so a test
// can drive a scope the gateway does not serve.
func SignRequestAs(t *testing.T, req *http.Request, body []byte, accessKey, secret, service string, when time.Time) {
t.Helper()
sum := sha256.Sum256(body)
payloadHash := hex.EncodeToString(sum[:])
// gwsign sets this header so the SDK signs it; sigv4 reproduces it when
// reconstructing the canonical request.
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secret},
req, payloadHash, service, Region, when))
}
// SignSessionRequest sets X-Amz-Security-Token before signing so it's included
// in SignedHeaders, matching what the AWS CLI/SDK do with session credentials.
func SignSessionRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret, sessionToken string) {
t.Helper()
req.Header.Set("X-Amz-Security-Token", sessionToken)
SignRequest(t, req, body, accessKey, secret)
}
// AllowPolicy returns a single-statement Allow policy granting action on "*".
func AllowPolicy(action string) []iamdomain.PolicyDocument {
return AllowPolicyResource(action, "*")
}
// AllowPolicyResource returns a single-statement Allow policy for action on resource.
func AllowPolicyResource(action, resource string) []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{{
Effect: "Allow",
Action: iamdomain.StringOrArr{action},
Resource: iamdomain.StringOrArr{resource},
}},
}}
}
// AssumedRoleSessionCred builds an assumed-role SessionCredential.
// SecretEncrypted is filled in by the gateway builder.
func AssumedRoleSessionCred(sessionName, underlyingRoleARN, accountID string) *stsdomain.SessionCredential {
return &stsdomain.SessionCredential{
AccessKeyID: SessionAKID,
AccountID: accountID,
PrincipalType: PrincipalTypeAssumedRole,
AssumedRoleARN: "arn:aws:sts::" + accountID + ":assumed-role/role/" + sessionName,
UnderlyingRoleARN: underlyingRoleARN,
SessionName: sessionName,
ExpiresAt: time.Now().UTC().Add(time.Hour),
CreatedAt: time.Now().UTC().Add(-time.Minute),
}
}
+44
View File
@@ -0,0 +1,44 @@
package gwfix
import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
)
var _ credential.Verifier = (*Verifier)(nil)
// Verifier authenticates a long-lived access key as the IAM user the key names.
// It decides nothing else: no status, session or account check, so a test that
// asserts a credential verdict must use the real verifier instead.
type Verifier struct {
IAM iamdomain.IAMService
}
func (v *Verifier) Authenticate(req credential.Request, verifySig func(secret string) error) (credential.Authenticated, *credential.Failure) {
ak, err := v.IAM.LookupAccessKey(req.AccessKeyID)
if err != nil {
return credential.Authenticated{}, &credential.Failure{Code: awserrors.ErrorInvalidClientTokenId}
}
secret, err := v.IAM.DecryptSecret(ak.SecretAccessKey)
if err != nil {
return credential.Authenticated{}, &credential.Failure{Code: awserrors.ErrorInternalError}
}
if err := verifySig(secret); err != nil {
return credential.Authenticated{}, &credential.Failure{Code: awserrors.ErrorSignatureDoesNotMatch, SignatureMismatch: true}
}
return credential.Authenticated{AccountID: ak.AccountID, Handle: ak}, nil
}
func (v *Verifier) Resolve(_ credential.Request, auth credential.Authenticated) (credential.Principal, *credential.Failure) {
ak, ok := auth.Handle.(*iamdomain.AccessKey)
if !ok {
return credential.Principal{}, &credential.Failure{Code: awserrors.ErrorInternalError}
}
return credential.Principal{
Identity: ak.UserName,
AccountID: ak.AccountID,
PrincipalType: PrincipalTypeUser,
CallerARN: "arn:aws:iam::" + ak.AccountID + ":user/" + ak.UserName,
}, nil
}
@@ -0,0 +1,32 @@
package awsapi_test
import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func assertDryRun(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorDryRunOperation, err.Error())
}
// A caller the policy refuses is denied rather than told the request would
// have succeeded.
func TestEC2Request_DryRunDeniedCaller(t *testing.T) {
h := gwtest.Scoped(
gwtest.Statement("Allow", "ec2:*", "*"),
gwtest.Statement("Deny", "ec2:CreateVpc", "*"),
)
gwtest.AssertUnauthorized(t, dispatchEC2(t, h, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=true"))
assertDryRun(t, dispatchEC2(t, h, "Action=CreateSecurityGroup&GroupName=g&GroupDescription=d&DryRun=true"))
}
func TestEC2Request_DryRunFalseDispatches(t *testing.T) {
h := gwtest.AllowAll()
gwtest.AssertPermitted(t, dispatchEC2(t, h, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=false"))
}
@@ -1,30 +1,28 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (scopedPolicyGateway, dispatchEC2).
package gateway
package awsapi_test
import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
)
// The PowerUserAccess shape: an Allow on everything except the listed actions.
func TestEC2Request_AllowNotActionGrantsTheComplement(t *testing.T) {
gw := scopedPolicyGateway(iamdomain.Statement{
h := gwtest.Scoped(iamdomain.Statement{
Effect: "Allow",
NotAction: iamdomain.StringOrArr{"iam:*", "ec2:TerminateInstances"},
Resource: iamdomain.StringOrArr{"*"},
})
assertPermitted(t, dispatchEC2(t, gw, "Action=StopInstances&InstanceId.1=i-dev"))
assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev"))
gwtest.AssertPermitted(t, dispatchEC2(t, h, "Action=StopInstances&InstanceId.1=i-dev"))
gwtest.AssertUnauthorized(t, dispatchEC2(t, h, "Action=TerminateInstances&InstanceId.1=i-dev"))
}
// A Deny on NotResource fences every instance but the named one.
func TestEC2Request_DenyNotResourceFencesTheComplement(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
h := gwtest.Scoped(
gwtest.Statement("Allow", "ec2:*", "*"),
iamdomain.Statement{
Effect: "Deny",
Action: iamdomain.StringOrArr{"ec2:TerminateInstances"},
@@ -32,6 +30,6 @@ func TestEC2Request_DenyNotResourceFencesTheComplement(t *testing.T) {
},
)
assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev"))
assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod"))
gwtest.AssertPermitted(t, dispatchEC2(t, h, "Action=TerminateInstances&InstanceId.1=i-dev"))
gwtest.AssertUnauthorized(t, dispatchEC2(t, h, "Action=TerminateInstances&InstanceId.1=i-prod"))
}
@@ -93,17 +93,9 @@ func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypes
describeIncompleteRetryAfter,
)
}
finalInstanceTypes = expandAvailability(allInstanceTypes, availability)
finalInstanceTypes = dedupeByName(allInstanceTypes, availability)
} else {
seen := make(map[string]bool)
for _, it := range allInstanceTypes {
if it != nil && it.InstanceType != nil {
if !seen[*it.InstanceType] {
seen[*it.InstanceType] = true
finalInstanceTypes = append(finalInstanceTypes, it)
}
}
}
finalInstanceTypes = dedupeByName(allInstanceTypes, nil)
}
if len(requestedTypes) > 0 {
@@ -145,8 +137,8 @@ func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypes
output := &ec2.DescribeInstanceTypesOutput{InstanceTypes: finalInstanceTypes}
// Unpaged unless asked: the capacity listing the UI reads has one entry per
// free slot and does not follow NextToken.
// Unpaged unless asked. The listing is one entry per instance type, which is
// inside the page cap AWS declares, so a caller that wants pages asks.
if input.MaxResults != nil || aws.StringValue(input.NextToken) != "" {
page, nextToken, err := pageByOffset(finalInstanceTypes, input.NextToken, input.MaxResults)
if err != nil {
@@ -160,32 +152,26 @@ func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypes
return output, nil
}
// expandAvailability returns one entry per free cluster-wide slot for each
// type, deduplicating the per-node descriptions first.
// dedupeByName returns one entry per instance type name. Every node describes
// the types it hosts, so the fan-out carries a copy per node.
//
// The client SDK drops response fields it does not model, so a count cannot
// reach the console any other way. Nodes send counts, and only this last hop
// repeats the entry.
func expandAvailability(types []*ec2.InstanceTypeInfo, availability map[string]int64) []*ec2.InstanceTypeInfo {
byName := make(map[string]*ec2.InstanceTypeInfo, len(types))
order := make([]string, 0, len(types))
// A non-nil availability keeps only the types with a free slot, which is the
// capacity request. The count itself is not carried here: the reply is bounded
// by the catalogue, never by how much capacity is free.
func dedupeByName(types []*ec2.InstanceTypeInfo, availability map[string]int64) []*ec2.InstanceTypeInfo {
seen := make(map[string]bool, len(types))
deduped := make([]*ec2.InstanceTypeInfo, 0, len(types))
for _, it := range types {
if it == nil || it.InstanceType == nil {
if it == nil || it.InstanceType == nil || seen[*it.InstanceType] {
continue
}
if _, seen := byName[*it.InstanceType]; !seen {
byName[*it.InstanceType] = it
order = append(order, *it.InstanceType)
if availability != nil && availability[*it.InstanceType] <= 0 {
continue
}
seen[*it.InstanceType] = true
deduped = append(deduped, it)
}
expanded := make([]*ec2.InstanceTypeInfo, 0, len(order))
for _, name := range order {
for range availability[name] {
expanded = append(expanded, byName[name])
}
}
return expanded
return deduped
}
// missingInstanceTypes returns the requested types absent from found, in the
@@ -88,7 +88,9 @@ func TestDescribeInstanceTypes_DeduplicatesAcrossNodes(t *testing.T) {
assert.Equal(t, 1, seen["m5.large"])
}
func TestDescribeInstanceTypes_CapacityFilterSumsPerNodeCounts(t *testing.T) {
// Every node describes the types it hosts, so a four-node cluster sends four
// copies. The listing reports each type once.
func TestDescribeInstanceTypes_CapacityReportsEachTypeOncePerCluster(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
@@ -113,24 +115,48 @@ func TestDescribeInstanceTypes_CapacityFilterSumsPerNodeCounts(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, output)
// One entry per free slot across both nodes.
assert.Len(t, output.InstanceTypes, 2)
assert.Len(t, output.InstanceTypes, 1)
assert.Equal(t, "t3.micro", aws.StringValue(output.InstanceTypes[0].InstanceType))
}
// A node reports a count, not one entry per slot, so the reply stays the same
// size whether the host is idle or full.
// The reply is bounded by the catalogue, so its size is the same whether the
// cluster is idle or full. This is the property the per-slot encoding lost: at
// 44,346 free slots the same shape exceeded the NATS maximum payload and the
// console was told the cluster had no compute at all.
func TestDescribeInstanceTypes_CapacityIsBoundedByTypeCountNotFreeSlots(t *testing.T) {
t.Parallel()
for name, slots := range map[string]int64{"idle": 44346, "nearly full": 1} {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
payload := capacityPayload(t, map[string]int64{"t3.micro": slots})
require.Less(t, len(payload), 4096, "the node reply must not grow with free capacity")
subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1", payload)
output, err := DescribeInstanceTypes(context.Background(), capacityInput(), nc, 1, nil, "")
require.NoError(t, err)
assert.Len(t, output.InstanceTypes, 1)
})
}
}
// A type the node could not admit is absent from the listing, so the console
// does not offer a type the cluster would refuse.
func TestDescribeInstanceTypes_CapacityOmitsTypesWithNoFreeSlot(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
payload := capacityPayload(t, map[string]int64{"t3.micro": 4096})
require.Less(t, len(payload), 4096, "the node reply must not grow with free capacity")
subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1", payload)
subscribeAsNode(t, nc, "ec2.DescribeInstanceTypes", "n1",
capacityPayload(t, map[string]int64{"t3.micro": 4, "x2gd.metal": 0}))
output, err := DescribeInstanceTypes(context.Background(), capacityInput(), nc, 1, nil, "")
require.NoError(t, err)
assert.Len(t, output.InstanceTypes, 4096)
require.Len(t, output.InstanceTypes, 1)
assert.Equal(t, "t3.micro", aws.StringValue(output.InstanceTypes[0].InstanceType))
}
// Nothing is known about capacity when no node answered, so the request fails
@@ -801,16 +801,20 @@ func TestDescribeInstanceTypes_ReturnsSupportedByDefault(t *testing.T) {
assert.Equal(t, 0, prov.calls, "capacity-gated path must not be hit when capacity filter absent")
}
// The free slots cross as a number against each type, so the reply is sized by
// the catalogue however much capacity the node has.
func TestDescribeInstanceTypes_CapacityFilterHitsAvailable(t *testing.T) {
prov := &fakeResourceCapacityProvider{
types: []*ec2.InstanceTypeInfo{
{InstanceType: aws.String("t3.micro")},
{InstanceType: aws.String("t3.micro")},
},
supportedTypes: []*ec2.InstanceTypeInfo{
{InstanceType: aws.String("t3.micro")},
{InstanceType: aws.String("m5.large")},
},
availableFn: func(it *ec2.InstanceTypeInfo) int64 {
if aws.StringValue(it.InstanceType) == "t3.micro" {
return 874
}
return 3
},
}
svc := &InstanceServiceImpl{resourceMgr: prov}
@@ -821,7 +825,8 @@ func TestDescribeInstanceTypes_CapacityFilterHitsAvailable(t *testing.T) {
}
out, err := svc.DescribeInstanceTypes(context.Background(), input, "")
require.NoError(t, err)
require.Len(t, out.InstanceTypes, 2, "capacity=true must use the per-slot list")
require.Len(t, out.InstanceTypes, 2, "each admissible type is named once")
assert.Equal(t, map[string]int64{"t3.micro": 874, "m5.large": 3}, out.Availability)
assert.Equal(t, 1, prov.calls, "capacity-gated path must be hit when capacity=true")
assert.True(t, prov.gotShowCap, "capacity=true filter must reach the provider")
assert.Equal(t, 0, prov.supportedCalls, "supported-types path must not be hit when capacity=true")
@@ -22,6 +22,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
@@ -36,7 +37,8 @@ import (
type eksGateway struct {
*gwtest.Harness
nc *nats.Conn
nc *nats.Conn
roles passrole.Resolver
}
func scopedEKS(statements ...iamdomain.Statement) *eksGateway {
@@ -52,7 +54,7 @@ func (g *eksGateway) as(caller credential.Principal) *eksGateway {
}
func (g *eksGateway) serve(w http.ResponseWriter, r *http.Request) error {
return g.Dispatch(eksawsapi.NewRegistration(eksawsapi.Deps{NATS: g.nc}), eksawsapi.ServiceName, w, r)
return g.Dispatch(eksawsapi.NewRegistration(eksawsapi.Deps{NATS: g.nc, Roles: g.roles}), eksawsapi.ServiceName, w, r)
}
func assertNotDenied(t *testing.T, err error) {
@@ -0,0 +1,243 @@
package awsapi_test
import (
"errors"
"net/http"
"testing"
"github.com/mulgadc/bluebottle/pkg/auth"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
eksNodeRoleARN = "arn:aws:iam::" + gwtest.AccountID + ":role/eks/node-role"
foreignAccountID = "999999999999"
)
// roleStore resolves role names to the ARNs in roles, keyed by account then
// name, the way the IAM store does.
type roleStore struct {
roles map[string]map[string]string
canonicalErr error
}
func (s *roleStore) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
roleAccount, name, err := auth.ParseRoleARN(roleARN)
if err != nil {
return passrole.Role{}, passrole.ErrMalformed
}
if roleAccount != accountID {
return passrole.Role{}, passrole.ErrForeignAccount
}
canonical, err := s.canonical(roleAccount, name)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{}, err
}
if canonical != roleARN {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{ARN: canonical, Name: name}, nil
}
func (s *roleStore) canonical(account, name string) (string, error) {
if s.canonicalErr != nil {
return "", s.canonicalErr
}
if roleARN, ok := s.roles[account][name]; ok {
return roleARN, nil
}
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
}
// The foreign account holds a role of the same name, so only the account
// check stops its ARN from resolving.
func eksPassRole(statements ...iamdomain.Statement) *eksGateway {
return eksPassRoleFaulting(nil, statements...)
}
// eksPassRoleFaulting is eksPassRole with every role lookup failing with
// canonicalErr when it is non-nil.
func eksPassRoleFaulting(canonicalErr error, statements ...iamdomain.Statement) *eksGateway {
store := &roleStore{canonicalErr: canonicalErr, roles: map[string]map[string]string{
gwtest.AccountID: {"node-role": eksNodeRoleARN},
foreignAccountID: {"node-role": "arn:aws:iam::" + foreignAccountID + ":role/eks/node-role"},
}}
g := scopedEKS(statements...)
g.roles = store
return g
}
func createNodegroupBody(nodeRole string) string {
return `{"nodegroupName":"workers","nodeRole":"` + nodeRole + `"}`
}
func createAddonBody(roleARN string) string {
return `{"addonName":"aws-load-balancer-controller","serviceAccountRoleArn":"` + roleARN + `"}`
}
func updateAddonBody(roleARN string) string {
return `{"serviceAccountRoleArn":"` + roleARN + `"}`
}
// The AWS capture of 2026-10-30 refuses a foreign or non-role ARN on CreateCluster
// and CreateNodegroup with AccessDeniedException before PassRole is evaluated.
func assertCrossAccountPassRole(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorAccessDeniedException, code)
assert.Contains(t, err.Error(), "Cross-account pass role is not allowed.")
}
func assertInvalidParameter(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorEKSInvalidParameter, code)
}
func TestEKSCreateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/addons", createAddonBody("")))
}
func TestEKSCreateAddon_PassRole(t *testing.T) {
body := createAddonBody(eksNodeRoleARN)
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/addons", body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/addons", body))
}
func TestEKSCreateCluster_PassRole(t *testing.T) {
body := `{"name":"prod","roleArn":"` + eksNodeRoleARN + `"}`
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters", body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters", body))
}
func TestEKSCreateNodegroup_AllowedWithPassRoleOnRole(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
// eks:* alone must not let a caller launch nodes under a role it cannot pass.
func TestEKSCreateNodegroup_DeniedWithoutPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
func TestEKSCreateNodegroup_ForeignAccountRoleIsRefused(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+foreignAccountID+":role/eks/node-role"))
assertCrossAccountPassRole(t, err)
}
// A grant scoped to a path the role is not under must not be satisfied by
// spelling the role's name under that path.
func TestEKSCreateNodegroup_InventedPathIsRefused(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "arn:aws:iam::"+gwtest.AccountID+":role/sandbox/*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+gwtest.AccountID+":role/sandbox/node-role"))
assertInvalidParameter(t, err)
}
// A store fault is not evidence the role is absent, so it must not surface as
// a caller error or fall through to dispatch.
func TestEKSCreateNodegroup_LookupFaultPassesThrough(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
gw := eksPassRoleFaulting(storeErr,
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
func TestEKSCreateNodegroup_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", `{"nodegroupName":"workers"}`))
}
func TestEKSCreateNodegroup_NonRoleARNIsRefused(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+gwtest.AccountID+":user/node-role"))
assertCrossAccountPassRole(t, err)
}
// Current behaviour: a store fault on the role lookup is returned before the
// PassRole check, so a caller without iam:PassRole sees the fault, not a denial.
func TestEKSCreateNodegroup_OutageIsReportedToACallerWithoutPassRole(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
gw := eksPassRoleFaulting(storeErr, gwtest.Statement("Allow", "eks:*", "*"))
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
// An unknown role is reported only to a caller allowed to pass it, so the
// reply cannot be used to enumerate roles.
func TestEKSCreateNodegroup_UnknownRole(t *testing.T) {
body := createNodegroupBody("arn:aws:iam::" + gwtest.AccountID + ":role/ghost")
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/node-groups", body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
assertInvalidParameter(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/node-groups", body))
}
func TestEKSUpdateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost,
"/clusters/prod/addons/aws-load-balancer-controller/update", updateAddonBody("")))
}
func TestEKSUpdateAddon_PassRole(t *testing.T) {
body := updateAddonBody(eksNodeRoleARN)
const path = "/clusters/prod/addons/aws-load-balancer-controller/update"
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, path, body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, path, body))
}
+4 -267
View File
@@ -1,159 +1,20 @@
// Characterization tests for ACM control-plane dispatch as it exists in
// acm.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// acmPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS, with docs as every principal's policy. The region is
// testRegion because signTestRequestAs always signs for it.
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withACM(gw, acmawsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllACMDocs is the policy acmPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllACMDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signACMRequest builds a POST / signed with credential-scope service "acm",
// the shape every ACM JSON 1.1 client sends.
func signACMRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "acm", time.Now().UTC())
return req
}
// subscribeACMCounter answers subject with payload and counts invocations, so
// a denial can be proven to short-circuit before the handler ever runs.
func subscribeACMCounter(t *testing.T, nc *nats.Conn, subject string, payload []byte) *int {
t.Helper()
calls := 0
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
calls++
_ = msg.Respond(payload)
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
t.Cleanup(func() { _ = sub.Unsubscribe() })
return &calls
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestACMPipeline_MissingTarget_400MissingActionException pins that an ACM
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestACMPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
resp := doRequest(app, signACMRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestACMPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from acmActions fails with InvalidAction.
func TestACMPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
target := "CertificateManager.MadeUpAction"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestACMPipeline_ListCertificates_200JSONContentType pins that a successful
// ACM action answers 200 with the AWS JSON 1.1 content type through the full
// pipeline, once the daemon-side NATS responder answers.
func TestACMPipeline_ListCertificates_200JSONContentType(t *testing.T) {
gw, app := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
target := "CertificateManager.ListCertificates"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, acmawsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// TestACMActionFromTarget_FormsAndTelemetryAgree pins how acmActionFromTarget
// and the legacy resolveNonQueryAction each resolve the X-Amz-Target header
// today. A refactor that lets them diverge breaks this.
@@ -182,86 +43,6 @@ func TestACMActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
}
}
// --- Oversized body over HTTP ---------------------------------------------
// TestACMPipeline_OversizedBody_413OverHTTP pins that an ACM-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ACM dispatch ever runs, with the JSON error envelope.
func TestACMPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", "CertificateManager.ListCertificates")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "acm", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before the NATS-backed action ----------------------
// TestACMPipeline_AuthzGateBlocksAction pins that the policy gate in ACM
// dispatch runs, and denies, before the NATS-backed action handler is ever
// invoked, through the full pipeline.
func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"acm:DeleteCertificate"},
Resource: iamdomain.StringOrArr{"arn:aws:acm:" + testRegion + ":" + authzAccountID + ":certificate/other"},
},
},
}}
gw, app := acmPipelineApp(t, scopedDocs)
calls := subscribeACMCounter(t, gw.NATSConn, "acm.DeleteCertificate", []byte(`{}`))
target := "CertificateManager.DeleteCertificate"
body := []byte(`{"CertificateArn":"arn:aws:acm:` + testRegion + `:` + authzAccountID + `:certificate/prod-1111"}`)
resp := doRequest(app, signACMRequest(t, target, body))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
respBody := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", respBody.Type)
assert.Equal(t, 0, *calls, "the NATS-backed handler must not run once the gate denies")
}
// --- NATS unavailable for ACM ---------------------------------------------
// TestRequest_ClusterUnavailableNilConn_ACM pins that ACM fails at the
// request-level NATS gate before dispatch reads the action, so ACM's own
// nil-connection check is unreachable over HTTP.
func TestRequest_ClusterUnavailableNilConn_ACM(t *testing.T) {
for _, action := range []string{"ListCertificates", "DeleteCertificate"} {
t.Run(action, func(t *testing.T) {
gw := withACM(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), acmawsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", "CertificateManager."+action)
ctx := reqctx.WithService(req.Context(), "acm")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ACM request ---------------------------------
// TestACMPipeline_TraceActionEnricher_SpanNamedServiceAction pins that the
// server span is renamed to "acm.<Action>" and carries aws.action once a real
// ACM request runs through SigV4AuthMiddleware and traceActionEnricher.
@@ -296,47 +77,3 @@ func TestACMPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
assert.Equal(t, "ListCertificates", got["aws.action"])
assert.Equal(t, "acm", got["aws.service"])
}
// TestACMPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ACM action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestACMPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := "CertificateManager.ListCertificates"
doRequest(r, signACMRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListCertificates", auditField(got, "action"))
}
// --- Inventory pin ----------------------------------------------------------
// TestACMInventory_RegisteredArePinned pins the ACM inventory so a refactor
// that drops or adds an action, including by moving ACM onto the
// registration seam, fails here.
func TestACMInventory_RegisteredArePinned(t *testing.T) {
acm := AWSOperationInventory(acmRegistrationInventory())["acm"]
want := []string{
"ImportCertificate", "RequestCertificate", "DescribeCertificate", "GetCertificate",
"ListCertificates", "DeleteCertificate", "ListTagsForCertificate", "AddTagsToCertificate",
"RemoveTagsFromCertificate",
}
assert.ElementsMatch(t, want, acm.Registered)
assert.Empty(t, acm.Stubbed)
assert.Empty(t, acm.Unsupported)
}
@@ -8,10 +8,10 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net/http"
"strings"
"testing"
@@ -98,12 +98,12 @@ func actorProbeGateway(t *testing.T, e *cbEnv) (http.Handler, *[]actorObservatio
gw := &GatewayConfig{
DisableLogging: true,
NATSConn: e.nc,
Region: testRegion,
Region: gwfix.Region,
ExpectedNodes: 1,
IAMService: e.counter,
Credentials: credverify.New(e.counter, e.sts),
STSService: e.sts,
}
withRealAuth(gw)
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
require.NoError(t, b.Register(stsRegistration(gw)))
gw.Services = b.Build()
+12 -11
View File
@@ -7,6 +7,7 @@ package gateway
import (
"context"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -22,7 +23,7 @@ import (
)
// createAccountPolicy grants exactly the action the signup principal holds.
func createAccountPolicy() *policyMockIAMService {
func createAccountPolicy() *gwfix.PolicyIAM {
allow := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
@@ -33,9 +34,9 @@ func createAccountPolicy() *policyMockIAMService {
},
},
}}
return &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
return &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
GetRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
}
}
@@ -61,7 +62,7 @@ func decodeAdminError(t *testing.T, rec *httptest.ResponseRecorder) adminErrorBo
// An unknown method is rejected before authorization, so it never discloses
// whether the caller would have been permitted.
func TestAdminRequestUnknownMethod(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
rec := adminRequest(gw, "PurgeEverything", `{}`)
@@ -70,7 +71,7 @@ func TestAdminRequestUnknownMethod(t *testing.T) {
}
func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
rec := adminRequest(gw, "CreateAccount", `{}`)
@@ -82,7 +83,7 @@ func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
// The request ID ties a client failure to the gateway log line, so it must be
// present on the header and in the body even for a denial.
func TestAdminRequestAlwaysCarriesRequestID(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
rec := adminRequest(gw, "CreateAccount", `{}`)
@@ -105,7 +106,7 @@ func TestWriteAdminErrorFallsBackOnUnknownCode(t *testing.T) {
// The router serves every verb from one pattern, so the handler is what
// refuses a GET — a second route would shadow the POST one.
func TestAdminRequestRejectsNonPost(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
req := httptest.NewRequest(http.MethodGet, "/admin/CreateAccount", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("method", "CreateAccount")
@@ -135,7 +136,7 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
ctx = reqctx.WithService(ctx, "spinifex")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.DefaultAccountID())
ctx = withPrincipalField(ctx, fieldIdentity, "admin")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
rec := httptest.NewRecorder()
gw.Admin_Request(rec, req.WithContext(ctx))
@@ -145,7 +146,7 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
// An authorized caller must still get a clear answer when the cluster is not
// reachable, rather than a panic or a silent success.
func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
rec := authorizedAdminRequest(gw, `{}`)
@@ -155,7 +156,7 @@ func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
// Each admin method is a separate grant. The signup Worker holds
// spinifex:CreateAccount and must not reach deletion with it.
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
t.Run(method, func(t *testing.T) {
-1
View File
@@ -35,7 +35,6 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
if gw.RateLimiter == nil {
gw.RateLimiter = authlimit.NewAuthRateLimiter()
}
gw.fillDefaultCapabilities()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
src := authSource{limitKey: ingresshttp.ClientIP(r.RemoteAddr), logIP: ingresshttp.RequestClientIP(r)}
+12 -21
View File
@@ -5,6 +5,7 @@ package gateway
import (
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"net/http"
"net/http/httptest"
"testing"
@@ -18,38 +19,38 @@ import (
func statusGateRouter(t *testing.T, svc iamdomain.IAMService) (chi.Router, *http.Request) {
t.Helper()
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: svc})
gw := withEC2(withRealAuth(&GatewayConfig{DisableLogging: true, Region: gwfix.Region, IAMService: svc}))
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
signTestRequest(t, req, nil, testAccessKey, testSecretKey)
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
return r, req
}
// statusGateService returns a mock holding one usable access key for account,
// with that account pinned to status.
func statusGateService(t *testing.T, accountID, status string) *mockIAMService {
func statusGateService(t *testing.T, accountID, status string) *gwfix.IAM {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
if err != nil {
t.Fatalf("encrypt secret: %v", err)
}
return &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
return &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: accountID,
Status: iamdomain.AccessKeyStatusActive,
},
},
accounts: map[string]*iamdomain.Account{
Accounts: map[string]*iamdomain.Account{
accountID: {AccountID: accountID, Status: status},
},
}
@@ -63,16 +64,6 @@ func TestAuthAllowsAnActiveAccount(t *testing.T) {
}
}
// Until this gate existed, account status was enforced only in ECR's principal
// resolution, so suspending an account left EC2, S3 and STS wide open.
func TestAuthDeniesASuspendedAccount(t *testing.T) {
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusSuspended))
if resp := doRequest(r, req); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a SUSPENDED account, got %d", resp.StatusCode)
}
}
// The quiesce that makes teardown converge: a terminating account must not be
// able to create resources the cascade has already walked past.
func TestAuthDeniesATerminatingAccount(t *testing.T) {
@@ -85,7 +76,7 @@ func TestAuthDeniesATerminatingAccount(t *testing.T) {
// unreadableAccountService answers key lookups but never account lookups.
type unreadableAccountService struct {
*mockIAMService
*gwfix.IAM
}
func (s *unreadableAccountService) GetAccount(string) (*iamdomain.Account, error) {
+34 -484
View File
@@ -4,14 +4,9 @@ package gateway
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"net/http"
"net/http/httptest"
"net/url"
@@ -23,7 +18,6 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/golang-jwt/jwt/v5"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
@@ -35,7 +29,6 @@ import (
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
)
// The TestCurrentBehaviour_ tests in this file pin today's SigV4 authentication,
@@ -135,13 +128,12 @@ func (e *cbEnv) gateway() http.Handler {
gw := &GatewayConfig{
DisableLogging: true,
NATSConn: e.nc,
Region: testRegion,
Region: gwfix.Region,
ExpectedNodes: 1,
IAMService: e.counter,
Credentials: credverify.New(e.counter, e.sts),
STSService: e.sts,
}
withIAMAndSTS(gw)
withIAMAndSTS(withRealAuth(gw))
return gw.SetupRoutes()
}
@@ -182,7 +174,7 @@ func (e *cbEnv) request(t *testing.T, h http.Handler, c cbCreds, service string,
req.Header.Set("X-Amz-Security-Token", c.token)
}
if c.akid != "" {
signTestRequestAs(t, req, body, c.akid, c.secret, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, c.akid, c.secret, service, time.Now().UTC())
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
@@ -269,39 +261,6 @@ func (e *cbEnv) getSessionToken(t *testing.T, c cbCreds) cbCreds {
return cbSessionFrom(t, e.request(t, e.h, c, "sts", cbForm("GetSessionToken")))
}
// expireSession rewrites a stored session record's expiry into the past.
func (e *cbEnv) expireSession(t *testing.T, akid string) {
t.Helper()
kv, err := e.js.KeyValue(t.Context(), stsdomain.KVBucketSessionCredentials)
require.NoError(t, err)
entry, err := kv.Get(t.Context(), akid)
require.NoError(t, err)
var cred stsdomain.SessionCredential
require.NoError(t, json.Unmarshal(entry.Value(), &cred))
cred.ExpiresAt = time.Now().UTC().Add(-time.Minute)
data, err := json.Marshal(cred)
require.NoError(t, err)
_, err = kv.Put(t.Context(), akid, data)
require.NoError(t, err)
}
func (e *cbEnv) sessionStored(t *testing.T, akid string) bool {
t.Helper()
cred, err := e.sts.LookupSessionCredential(akid)
require.NoError(t, err)
return cred != nil
}
func tamper(s string) string {
b := []byte(s)
if b[0] == 'A' {
b[0] = 'B'
} else {
b[0] = 'A'
}
return string(b)
}
// Pins CURRENT behaviour: how a valid long-lived key resolves to a principal and account.
func TestCurrentBehaviour_Auth_LongLivedKeyResolvesPrincipal(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
@@ -339,278 +298,6 @@ func TestCurrentBehaviour_Auth_LongLivedKeyResolvesPrincipal(t *testing.T) {
})
}
// Pins CURRENT behaviour: wire outcomes for invalid long-lived credentials, and
// that key status changes apply to the very next request (no credential cache).
func TestCurrentBehaviour_Auth_LongLivedRejections(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
gci := cbForm("GetCallerIdentity")
t.Run("unknown_key", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), got)
})
t.Run("wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: e.alice.akid, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
t.Run("malformed_authorization_header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(gci.Encode()))
req.RemoteAddr = "10.200.0.1:40000"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=garbage")
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
assert.Equal(t, cbRegistry(awserrors.ErrorIncompleteSignature), cbOutcomeOf(rec))
})
t.Run("key_made_inactive_after_use", func(t *testing.T) {
creds := e.newKey(t, "alice")
require.Equal(t, http.StatusOK, e.callerIdentity(t, creds).Code)
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(creds.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, creds)))
})
t.Run("key_deleted_after_use", func(t *testing.T) {
u, _ := e.newUser(t, "dave")
require.Equal(t, http.StatusOK, e.callerIdentity(t, u).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("dave"), AccessKeyId: aws.String(u.akid),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, u)))
})
}
// Pins CURRENT behaviour: AssumeRole session credentials at the SigV4 door.
func TestCurrentBehaviour_Session_AssumeRoleCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, roleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
other := e.assumeRole(t, roleARN, "s2")
t.Run("valid_session", func(t *testing.T) {
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
body := rec.Body.String()
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/app/s1", cbXML(body, "Arn"))
assert.Equal(t, roleID+":s1", cbXML(body, "UserId"))
assert.Equal(t, e.accountID, cbXML(body, "Account"))
})
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
for name, c := range map[string]cbCreds{
"missing_token": {akid: s.akid, secret: s.secret},
"other_sessions_token": {akid: s.akid, secret: s.secret, token: other.token},
"tampered_token": {akid: s.akid, secret: s.secret, token: tamper(s.token)},
"unknown_session_akid": {akid: cbUnknownSes, secret: s.secret, token: s.token},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
// CURRENT: an AKIA key ignores X-Amz-Security-Token entirely; the header is
// only signed, never checked, so a long-lived key plus any token succeeds.
t.Run("long_lived_with_session_token", func(t *testing.T) {
rec := e.callerIdentity(t, cbCreds{akid: e.alice.akid, secret: e.alice.secret, token: s.token})
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
})
t.Run("valid_token_wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: cbBadSecret, token: s.token}))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
}
// Pins CURRENT behaviour: an expired session answers ExpiredToken (HTTP 400)
// before its session token is examined.
func TestCurrentBehaviour_Session_Expired(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, _ := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
e.expireSession(t, s.akid)
// CURRENT: ExpiredToken is 400 here; AWS STS/IAM answer it as 403 (unverified).
expired := cbRegistry(awserrors.ErrorExpiredToken)
require.Equal(t, http.StatusBadRequest, expired.status)
for name, c := range map[string]cbCreds{
"valid_token": s,
// CURRENT: expiry is checked ahead of the token, so a caller holding only
// the cleartext ASIA key ID learns the session has expired.
"no_token": {akid: s.akid, secret: s.secret},
"wrong_token": {akid: s.akid, secret: cbBadSecret, token: tamper(s.token)},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, expired, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
}
// Pins CURRENT behaviour: a GetSessionToken session resolves to the IAM user and
// outlives the long-lived key that minted it.
func TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
mintKey := e.newKey(t, "alice")
s := e.getSessionToken(t, mintKey)
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, e.aliceID, cbXML(rec.Body.String(), "UserId"))
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid),
})
require.NoError(t, err)
// CURRENT: the session record carries no reference to the minting key, so
// deactivating and deleting that key leaves the session authenticating.
assert.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
// A session cannot mint a further session.
assert.Equal(t, cbRegistry(awserrors.ErrorAccessDenied).code,
cbOutcomeOf(e.request(t, e.h, s, "sts", cbForm("GetSessionToken"))).code)
}
// Pins CURRENT behaviour: an AssumeRoleWithWebIdentity session authenticates at
// the SigV4 door exactly as an AssumeRole session does.
func TestCurrentBehaviour_Session_WebIdentityCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
issuer := testDiscIssuer
iamKV, err := kvutil.GetOrCreateBucket(t.Context(), e.js,
iamdomain.IAMAccountBucketName(e.accountID), iamdomain.KVBucketIAMAccountVersion)
require.NoError(t, err)
_, err = iamKV.Put(t.Context(), iamdomain.OIDCProviderKey(issuer), []byte(`{"registered":true}`))
require.NoError(t, err)
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
point, err := priv.PublicKey.Bytes()
require.NoError(t, err)
jwks, err := json.Marshal(stsdomain.JWKS{Keys: []stsdomain.JWK{{
Kty: "EC", Kid: "k1", Alg: "ES256", Use: "sig", Crv: "P-256",
X: base64.RawURLEncoding.EncodeToString(point[1:33]),
Y: base64.RawURLEncoding.EncodeToString(point[33:65]),
}}})
require.NoError(t, err)
eksKV, err := ekscluster.GetOrCreateAccountBucket(t.Context(), e.js, e.accountID)
require.NoError(t, err)
_, err = eksKV.Put(t.Context(), ekscluster.OIDCJWKSKey(testDiscCluster), jwks)
require.NoError(t, err)
federated := iamdomain.OIDCProviderARN(e.accountID, strings.TrimPrefix(issuer, "https://"))
role, err := e.iam.CreateRole(e.accountID, &iam.CreateRoleInput{
RoleName: aws.String("irsa"),
AssumeRolePolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"` +
federated + `"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`),
})
require.NoError(t, err)
now := time.Now().UTC()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": issuer, "sub": "system:serviceaccount:default:sa", "aud": []string{"sts.amazonaws.com"},
"exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(),
})
tok.Header["kid"] = "k1"
signed, err := tok.SignedString(priv)
require.NoError(t, err)
// Unsigned: the anonymous STS interceptor serves it ahead of SigV4.
s := cbSessionFrom(t, e.request(t, e.h, cbCreds{}, "sts", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", aws.StringValue(role.Role.Arn), "RoleSessionName", "pod", "WebIdentityToken", signed)))
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/irsa/pod", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId),
cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: s.secret, token: tamper(s.token)})))
}
// Pins CURRENT behaviour: deleting a user and recreating it under the same name
// strands its old key and sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_UserDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
oldKey, oldID := e.newUser(t, "bob")
session := e.getSessionToken(t, oldKey)
require.Equal(t, http.StatusOK, e.callerIdentity(t, session).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(oldKey.akid),
})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("bob")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, session.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "session after delete")
newKey, newID := e.newUser(t, "bob")
require.NotEqual(t, oldID, newID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, oldKey)), "old key after recreate")
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "old session after recreate")
rec := e.callerIdentity(t, newKey)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, newID, cbXML(rec.Body.String(), "UserId"))
})
}
}
// Pins CURRENT behaviour: deleting a role and recreating it under the same name
// strands its old sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_RoleDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
roleARN, oldRoleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
_, err := e.iam.DeleteRole(e.accountID, &iam.DeleteRoleInput{RoleName: aws.String("app")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, s.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "session after delete")
newARN, newRoleID := e.createAssumableRole(t, "app")
require.Equal(t, roleARN, newARN)
require.NotEqual(t, oldRoleID, newRoleID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "old session after recreate")
assert.Equal(t, http.StatusOK, e.callerIdentity(t, e.assumeRole(t, newARN, "s2")).Code)
})
}
}
// Pins CURRENT behaviour: a long-lived key is bound to a user by name only, so
// a key record orphaned from its user authenticates as a same-name replacement.
func TestCurrentBehaviour_Revocation_OrphanedKeyAuthenticatesAsRecreatedUser(t *testing.T) {
@@ -657,65 +344,41 @@ func (e *cbEnv) orphanKeys(t *testing.T, user string) {
require.NoError(t, err)
}
// Pins CURRENT behaviour: whose identity policy the policy check applies to an
// orphaned key once its user is deleted and recreated under the same name.
func TestCurrentBehaviour_Revocation_OrphanedKeyPermissionsAfterRecreation(t *testing.T) {
// Pins CURRENT behaviour: identity-policy allow, implicit deny and explicit deny
// on iam actions, evaluated after authentication and before the handler runs.
func TestCurrentBehaviour_Policy_IdentityPolicyOutcomes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
key, oldID := e.newUser(t, "frank")
listUsers := cbForm("ListUsers")
getUser := cbForm("GetUser", "UserName", "frank")
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.LookupErrorMessage("iam", awserrors.ErrorAccessDenied).Message}
// Policy A: ListUsers only.
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
require.Equal(t, http.StatusOK, e.request(t, e.h, key, "iam", listUsers).Code, "A allows ListUsers")
require.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", getUser)).code, "A denies GetUser")
e.orphanKeys(t, "frank")
_, err := e.iam.DeleteUserPolicy(e.accountID, &iam.DeleteUserPolicyInput{UserName: aws.String("frank"), PolicyName: aws.String("p")})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
// Policy B, on the replacement user: GetUser only.
u, err := e.iam.CreateUser(e.accountID, &iam.CreateUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
require.NotEqual(t, oldID, aws.StringValue(u.User.UserId))
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
// CURRENT: the orphaned key is evaluated against the replacement user's
// policy B, so it gains GetUser and loses the ListUsers that A granted.
rec := e.request(t, e.h, key, "iam", getUser)
assert.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", listUsers)).code)
}
// Pins CURRENT behaviour: unsigned requests are refused with
// MissingAuthenticationToken unless the action is anonymous STS.
func TestCurrentBehaviour_Auth_MissingCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
missing := cbRegistry(awserrors.ErrorMissingAuthenticationToken)
for _, f := range []url.Values{
cbForm("ListUsers"),
cbForm("GetCallerIdentity"),
cbForm("GetSessionToken"),
cbForm("AssumeRole", "RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s"),
} {
t.Run(f.Get("Action"), func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", f)))
})
}
// Session token alone, with no Authorization header.
t.Run("token_without_signature", func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{token: "anything"}, "", cbForm("GetCallerIdentity"))))
t.Run("implicit_deny", func(t *testing.T) {
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", listUsers)))
})
// The anonymous path reaches STS itself: its verdict, not the door's.
t.Run("AssumeRoleWithWebIdentity", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s1", "WebIdentityToken", "not-a-jwt")))
assert.Equal(t, http.StatusBadRequest, got.status)
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, got.code)
t.Run("allow", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
rec := e.request(t, e.h, e.alice, "iam", listUsers)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "<UserName>alice</UserName>")
})
t.Run("explicit_deny_blocks_before_dispatch", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Allow","Action":"iam:*","Resource":"*"},`+
`{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`)
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", cbForm("CreateUser", "UserName", "mallory"))))
_, err := e.iam.GetUser(e.accountID, &iam.GetUserInput{UserName: aws.String("mallory")})
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity, "denied CreateUser must not have run")
assert.Equal(t, http.StatusOK, e.request(t, e.h, e.alice, "iam", listUsers).Code)
})
// CURRENT: the root-name policy bypass is limited to the global account, so a
// tenant user named "root" with no policy is implicitly denied.
t.Run("tenant_user_named_root_implicit_deny", func(t *testing.T) {
creds, _ := e.newUser(t, "root")
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, creds, "iam", listUsers)))
})
}
@@ -771,116 +434,3 @@ func TestCurrentBehaviour_Auth_BackingStateUnavailable(t *testing.T) {
cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: e.alice.akid, secret: cbBadSecret})))
})
}
// Pins CURRENT behaviour: identity-policy allow, implicit deny and explicit deny
// on iam actions, evaluated after authentication and before the handler runs.
func TestCurrentBehaviour_Policy_IdentityPolicyOutcomes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
listUsers := cbForm("ListUsers")
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.LookupErrorMessage("iam", awserrors.ErrorAccessDenied).Message}
t.Run("implicit_deny", func(t *testing.T) {
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", listUsers)))
})
t.Run("allow", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
rec := e.request(t, e.h, e.alice, "iam", listUsers)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "<UserName>alice</UserName>")
})
t.Run("explicit_deny_blocks_before_dispatch", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Allow","Action":"iam:*","Resource":"*"},`+
`{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`)
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", cbForm("CreateUser", "UserName", "mallory"))))
_, err := e.iam.GetUser(e.accountID, &iam.GetUserInput{UserName: aws.String("mallory")})
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity, "denied CreateUser must not have run")
assert.Equal(t, http.StatusOK, e.request(t, e.h, e.alice, "iam", listUsers).Code)
})
// CURRENT: the root-name policy bypass is limited to the global account, so a
// tenant user named "root" with no policy is implicitly denied.
t.Run("tenant_user_named_root_implicit_deny", func(t *testing.T) {
creds, _ := e.newUser(t, "root")
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, creds, "iam", listUsers)))
})
}
// Pins CURRENT behaviour: which credential failures a caller can tell apart on
// the wire, and whether the door decrypted a secret before answering.
func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
bobKey, _ := e.newUser(t, "bob")
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(bobKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
carolKey, _ := e.newUser(t, "carol")
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{UserName: aws.String("carol"), AccessKeyId: aws.String(carolKey.akid)})
require.NoError(t, err)
live := e.getSessionToken(t, e.alice)
expired := e.getSessionToken(t, e.alice)
e.expireSession(t, expired.akid)
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
sigMismatch := cbRegistry(awserrors.ErrorSignatureDoesNotMatch)
for _, tc := range []struct {
name string
creds cbCreds
want cbOutcome
decrypts int32
}{
{"unknown_key", cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, invalid, 0},
{"unknown_prefix", cbCreds{akid: "AKIBCBUNKNOWNPREFIXA", secret: cbBadSecret}, invalid, 0},
{"deleted_key_right_secret", carolKey, invalid, 0},
{"inactive_key_right_secret", bobKey, invalid, 0},
// CURRENT: an inactive key answers InvalidClientTokenId without a
// signature check, while an active key with a bad signature answers
// SignatureDoesNotMatch, so a caller with no secret can tell the two apart.
{"inactive_key_bad_signature", cbCreds{akid: bobKey.akid, secret: cbBadSecret}, invalid, 0},
{"active_key_bad_signature", cbCreds{akid: e.alice.akid, secret: cbBadSecret}, sigMismatch, 1},
{"unknown_session", cbCreds{akid: cbUnknownSes, secret: cbBadSecret, token: live.token}, invalid, 0},
{"session_no_token", cbCreds{akid: live.akid, secret: cbBadSecret}, invalid, 0},
{"session_wrong_token", cbCreds{akid: live.akid, secret: cbBadSecret, token: tamper(live.token)}, invalid, 0},
{"session_expired_no_token", cbCreds{akid: expired.akid, secret: cbBadSecret}, cbRegistry(awserrors.ErrorExpiredToken), 0},
{"session_right_token_bad_signature", cbCreds{akid: live.akid, secret: cbBadSecret, token: live.token}, sigMismatch, 1},
} {
t.Run(tc.name, func(t *testing.T) {
e.counter.decrypts.Store(0)
assert.Equal(t, tc.want, cbOutcomeOf(e.callerIdentity(t, tc.creds)))
assert.Equal(t, tc.decrypts, e.counter.decrypts.Load(), "secret decrypts before the verdict")
})
}
}
// Pins CURRENT behaviour: the door caches only an account's ACTIVE status, per
// gateway, for accountadmission.ActiveTTL; keys and sessions are re-read on every request.
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
require.Equal(t, 5*time.Second, accountadmission.ActiveTTL)
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
t.Run(status, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
require.Equal(t, http.StatusOK, e.callerIdentity(t, e.alice).Code)
start := time.Now()
_, err := e.iam.SetAccountStatus(e.accountID, status)
require.NoError(t, err)
// CURRENT: an out-of-band status write is not seen by a gateway
// holding a warm ACTIVE entry until that entry expires.
warm := e.callerIdentity(t, e.alice)
if time.Since(start) >= accountadmission.ActiveTTL {
t.Skip("cache window elapsed before the warm request; outcome not attributable")
}
assert.Equal(t, http.StatusOK, warm.Code)
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.ErrorLookup[awserrors.ErrorAccessDenied].Message}
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.gateway(), e.alice, "sts", cbForm("GetCallerIdentity"))))
})
}
}
+9 -35
View File
@@ -2,9 +2,7 @@ package gateway
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
@@ -15,8 +13,6 @@ import (
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/stretchr/testify/require"
)
@@ -36,13 +32,13 @@ func captureLogs(t *testing.T) *bytes.Buffer {
// tell them apart after the fact, so the log line is the contract under test.
func TestSigV4Auth_SkewedRequestIsLogged(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
logs := captureLogs(t)
skewed := time.Now().UTC().Add(-30 * time.Minute)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
signTestRequest(t, req, nil, testAccessKey, testSecretKey, skewed)
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, skewed)
resp := doRequest(handler, req)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
@@ -55,32 +51,10 @@ func TestSigV4Auth_SkewedRequestIsLogged(t *testing.T) {
require.Contains(t, out, "serverTime=")
}
func TestSigV4Auth_UnsupportedServiceIsLogged(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
logs := captureLogs(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
sum := sha256.Sum256(nil)
payloadHash := hex.EncodeToString(sum[:])
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: testAccessKey, SecretAccessKey: testSecretKey},
req, payloadHash, "notaservice", testRegion, time.Now().UTC()))
resp := doRequest(handler, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
out := logs.String()
require.Contains(t, out, "unsupported service in credential scope")
require.Contains(t, out, "service=notaservice")
}
// The parse-error branch answers IncompleteSignature and was likewise silent,
// so a malformed envelope left no record of which validation stage rejected it.
func TestSigV4Auth_MalformedEnvelopeIsLogged(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
logs := captureLogs(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -127,10 +101,10 @@ func TestSigV4Auth_FailureLogsLoopbackGatedClientIP(t *testing.T) {
defer rl.Stop()
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
IAMService: &mockIAMService{masterKey: testMasterKey},
Credentials: credverify.New(&mockIAMService{masterKey: testMasterKey}, &mockSTSService{}),
STSService: &mockSTSService{},
Region: gwfix.Region,
IAMService: &gwfix.IAM{MasterKey: gwfix.MasterKey},
Credentials: credverify.New(&gwfix.IAM{MasterKey: gwfix.MasterKey}, &gwfix.STS{}),
STSService: &gwfix.STS{},
RateLimiter: rl,
}
withEC2(gw)
@@ -143,7 +117,7 @@ func TestSigV4Auth_FailureLogsLoopbackGatedClientIP(t *testing.T) {
req.Host = "localhost:9999"
req.RemoteAddr = tc.remoteAddr
req.Header.Set("X-Real-IP", "203.0.113.7")
signSessionRequest(t, req, nil, testSessionAKID, testSecretKey, testSessionToken)
gwfix.SignSessionRequest(t, req, nil, gwfix.SessionAKID, gwfix.SecretKey, gwfix.SessionToken)
resp := doRequest(handler, req)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
+1859 -2467
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
package gateway
import "github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
// defaultCapabilities builds the capabilities a GatewayConfig left unset from
// its IAM service and bus connection. Production composition sets all of them and
// leaves this nil.
var defaultCapabilities func(gw *GatewayConfig) (pipeline.PolicyEvaluator, pipeline.AccountAdmission, pipeline.Readiness)
// fillDefaultCapabilities sets whichever capability is nil from
// defaultCapabilities. With no defaults installed it changes nothing.
func (gw *GatewayConfig) fillDefaultCapabilities() {
if defaultCapabilities == nil || (gw.Policy != nil && gw.Admission != nil && gw.Readiness != nil) {
return
}
policy, admission, readiness := defaultCapabilities(gw)
if gw.Policy == nil {
gw.Policy = policy
}
if gw.Admission == nil {
gw.Admission = admission
}
if gw.Readiness == nil {
gw.Readiness = readiness
}
}
-10
View File
@@ -58,16 +58,7 @@ func TestCheckPolicy_ReturnsTheEvaluatorVerdict(t *testing.T) {
assert.ErrorIs(t, gw.checkPolicyResources(req, "ec2", "DescribeVpcs", []string{"*"}), verdict)
}
// withoutDefaultCapabilities removes the test defaults so a nil capability stays nil.
func withoutDefaultCapabilities(t *testing.T) {
t.Helper()
prev := defaultCapabilities
defaultCapabilities = nil
t.Cleanup(func() { defaultCapabilities = prev })
}
func TestCheckPolicy_NoEvaluatorFailsClosed(t *testing.T) {
withoutDefaultCapabilities(t)
gw := &GatewayConfig{}
req := withTestIdentity(httptest.NewRequest(http.MethodPost, "/", nil))
@@ -77,7 +68,6 @@ func TestCheckPolicy_NoEvaluatorFailsClosed(t *testing.T) {
}
func TestCheckAccountActive_MapsAdmissionVerdicts(t *testing.T) {
withoutDefaultCapabilities(t)
src := authSource{}
cases := []struct {
name string
@@ -1,23 +1,46 @@
package gateway
import (
"net/http"
"time"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/clusterready"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
)
// policyRetryBase shortens the backoff between policy-resolve attempts.
var policyRetryBase = 200 * time.Millisecond
func init() {
defaultCapabilities = func(gw *GatewayConfig) (pipeline.PolicyEvaluator, pipeline.AccountAdmission, pipeline.Readiness) {
var readiness pipeline.Readiness
if gw.NATSConn != nil {
readiness = clusterready.New(gw.NATSConn)
}
return policyauth.New(gw.IAMService, policyauth.WithRetryBase(policyRetryBase)), accountadmission.New(gw.IAMService), readiness
}
// allowPolicy permits every request; tests that assert an authorization
// verdict run against the real evaluator in the awsgw packages instead.
type allowPolicy struct{}
func (allowPolicy) RequestConditionKeys(*http.Request, credential.Principal) iampolicy.ConditionKeys {
return iampolicy.ConditionKeys{}
}
func (allowPolicy) Evaluate(credential.Principal, string, []string, iampolicy.ConditionKeys) error {
return nil
}
type allowAdmission struct{}
func (allowAdmission) Admit(string, string) error { return nil }
type natsReadiness struct{ gw *GatewayConfig }
func (n natsReadiness) Available() bool { return n.gw.NATSConn != nil && n.gw.NATSConn.IsConnected() }
// withFakeCapabilities fills whichever capability a test left unset.
func withFakeCapabilities(gw *GatewayConfig) *GatewayConfig {
if gw.Policy == nil {
gw.Policy = allowPolicy{}
}
if gw.Admission == nil {
gw.Admission = allowAdmission{}
}
if gw.Readiness == nil && gw.NATSConn != nil {
gw.Readiness = natsReadiness{gw}
}
return gw
}
@@ -7,6 +7,7 @@ package gateway
import (
"crypto/tls"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
@@ -110,18 +111,18 @@ func emittedKeys(t *testing.T) map[string]string {
"user": {
Identity: "alice",
AccountID: "000000000001",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
},
"assumed-role": {
Identity: "session",
AccountID: "000000000001",
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
AssumedRoleARN: "arn:aws:sts::000000000001:assumed-role/SharedOps/session",
AssumedRoleID: "AROASHAREDOPS:session",
},
}
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: &gwfix.IAM{}})
union := make(map[string]string)
for name, principal := range principals {
r := httptest.NewRequest(http.MethodPost, "/?prefix=home/&max-keys=100", nil)
+18 -17
View File
@@ -1,11 +1,12 @@
//test:in-package — requestConditionKeys, checkPolicyResources, credential.Principal
//and ctxAccountID are all unexported, and the file reuses the in-package
//policyMockIAMService and withTestIdentity doubles.
//gwfix.PolicyIAM and withTestIdentity doubles.
package gateway
import (
"crypto/tls"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"net/http"
@@ -27,7 +28,7 @@ func TestRequestConditionKeys_PopulatesAvailableKeys(t *testing.T) {
principal := credential.Principal{
Identity: "alice",
AccountID: "000000000001",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
UserID: "AIDAALICE",
}
@@ -62,9 +63,9 @@ func TestRequestConditionKeys_PrincipalType(t *testing.T) {
want string
omitted bool
}{
{principalType: principalTypeUser, want: iampolicy.PrincipalTypeUser},
{principalType: principalTypeAssumedRole, want: iampolicy.PrincipalTypeAssumedRole},
{principalType: principalTypeRoot, want: iampolicy.PrincipalTypeAccount},
{principalType: gwfix.PrincipalTypeUser, want: iampolicy.PrincipalTypeUser},
{principalType: gwfix.PrincipalTypeAssumedRole, want: iampolicy.PrincipalTypeAssumedRole},
{principalType: gwfix.PrincipalTypeRoot, want: iampolicy.PrincipalTypeAccount},
{principalType: "", omitted: true},
}
for _, tt := range tests {
@@ -87,7 +88,7 @@ func TestRequestConditionKeys_UserIDForAssumedRole(t *testing.T) {
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "session",
AccountID: "000000000001",
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
AssumedRoleID: "AROASHAREDOPS:session",
UserID: "AROASHAREDOPS:session",
})
@@ -102,7 +103,7 @@ func TestRequestConditionKeys_OmitsUnresolvableUserID(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", nil)
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "alice", AccountID: "000000000001", PrincipalType: principalTypeUser,
Identity: "alice", AccountID: "000000000001", PrincipalType: gwfix.PrincipalTypeUser,
})
assert.NotContains(t, keys, iampolicy.KeyUserID)
}
@@ -115,7 +116,7 @@ func TestRequestConditionKeys_OmitsUsernameForAssumedRole(t *testing.T) {
principal := credential.Principal{
Identity: "alice",
AccountID: "000000000001",
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
AssumedRoleARN: "arn:aws:sts::000000000001:assumed-role/SharedOps/alice",
}
@@ -131,7 +132,7 @@ func TestRequestConditionKeys_OmitsS3Prefix(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/?prefix=home/", nil)
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "alice",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
})
assert.NotContains(t, keys, iampolicy.KeyS3Prefix)
@@ -143,7 +144,7 @@ func TestRequestConditionKeys_OmitsS3Prefix(t *testing.T) {
func TestRequestConditionKeys_OmitsEmptyValues(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.RemoteAddr = ""
keys := policyauth.RequestConditionKeys(r, credential.Principal{PrincipalType: principalTypeUser})
keys := policyauth.RequestConditionKeys(r, credential.Principal{PrincipalType: gwfix.PrincipalTypeUser})
assert.NotContains(t, keys, iampolicy.KeySourceIP)
assert.NotContains(t, keys, iampolicy.KeyUsername)
@@ -159,7 +160,7 @@ func TestRequestConditionKeys_SourceIPWithoutAuthMiddleware(t *testing.T) {
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "alice",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
})
assert.Equal(t, "10.4.1.9", keys[iampolicy.KeySourceIP])
@@ -167,7 +168,7 @@ func TestRequestConditionKeys_SourceIPWithoutAuthMiddleware(t *testing.T) {
// conditionedPolicyIAMService serves one statement carrying cond, so a test can
// drive a real condition through checkPolicyResources.
func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdomain.ConditionValue) *policyMockIAMService {
func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdomain.ConditionValue) *gwfix.PolicyIAM {
docs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
@@ -183,9 +184,9 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
if effect == "Allow" {
docs[0].Statement = docs[0].Statement[1:]
}
return &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
return &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}
}
@@ -193,7 +194,7 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
// nil keys from checkPolicyResources would leave every other test green while
// silently killing every conditional Allow on this path.
func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
IAMService: conditionedPolicyIAMService("Allow", map[string]map[string]iamdomain.ConditionValue{
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"10.0.0.0/8"}},
@@ -232,7 +233,7 @@ func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
// the one direction where an absent key widens access. Pin it so the omission
// is a decision rather than an accident.
func TestCheckPolicyResources_ConditionalDenyHonoursSourceIP(t *testing.T) {
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
IAMService: conditionedPolicyIAMService("Deny", map[string]map[string]iamdomain.ConditionValue{
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"203.0.113.0/24"}},
+26 -25
View File
@@ -8,6 +8,7 @@ import (
"bytes"
"encoding/xml"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
@@ -138,7 +139,7 @@ func fakeNoResolverRegistration(c *dispatchCapture) dispatch.Registration {
// fake service's full pipeline mirrors ecrPipelineApp for a real one.
func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...dispatch.Registration) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
@@ -147,17 +148,17 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
require.NoError(t, b.Register(reg))
}
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
Region: gwfix.Region,
NATSConn: nc,
Services: b.Build(),
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
IAMService: &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: ecrTestAccount,
@@ -165,7 +166,7 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
@@ -184,7 +185,7 @@ func signDispatchRequest(t *testing.T, service string, body []byte) *http.Reques
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", "Whatever."+fakeAction)
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, service, time.Now().UTC())
return req
}
@@ -200,7 +201,7 @@ func TestDispatchSeam_RequestReachesDispatcherWithContext(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, capt.bodyRead, "dispatcher must have read the request body")
assert.Equal(t, ecrTestAccount, capt.accountID)
assert.Equal(t, testRegion, capt.region)
assert.Equal(t, gwfix.Region, capt.region)
assert.Equal(t, body, capt.body, "dispatcher must see the full body")
}
@@ -253,8 +254,8 @@ func TestDispatchSeam_ResolvedActionReachesThrottleTraceAndAudit(t *testing.T) {
// --- c. Authorize denies or allows ------------------------------------------
func TestDispatchSeam_AuthorizeDeniesOnMismatchAllowsOnMatch(t *testing.T) {
const allowedResource = "arn:aws:testsvc:" + testRegion + ":" + ecrTestAccount + ":widget/widget1"
const otherResource = "arn:aws:testsvc:" + testRegion + ":" + ecrTestAccount + ":widget/other"
const allowedResource = "arn:aws:testsvc:" + gwfix.Region + ":" + ecrTestAccount + ":widget/widget1"
const otherResource = "arn:aws:testsvc:" + gwfix.Region + ":" + ecrTestAccount + ":widget/other"
t.Run("scoped to a different resource is denied", func(t *testing.T) {
capt := &dispatchCapture{authorizeResource: allowedResource}
@@ -305,8 +306,8 @@ func TestDispatchSeam_ActorReturnsVerifiedCallerIgnoringBody(t *testing.T) {
require.NoError(t, capt.actorErr)
assert.Equal(t, ecrTestAccount, capt.actor.AccountID)
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/alice", capt.actor.CallerARN)
assert.Equal(t, principalTypeUser, capt.actor.PrincipalType)
assert.Equal(t, testAccessKey, capt.actor.AccessKeyID)
assert.Equal(t, gwfix.PrincipalTypeUser, capt.actor.PrincipalType)
assert.Equal(t, gwfix.AccessKey, capt.actor.AccessKeyID)
}
// --- e. dispatcher error renders in the registration's declared envelope ---
@@ -341,7 +342,7 @@ func TestDispatchSeam_NATSDownGetsSame503AsLegacy(t *testing.T) {
b := dispatch.NewBuilder()
require.NoError(t, b.Register(fakeJSONRegistration(&dispatchCapture{})))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService(), Services: b.Build()})
gw := withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService(), Services: b.Build()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := reqctx.WithService(req.Context(), fakeJSONService)
@@ -366,16 +367,16 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
b := dispatch.NewBuilder()
require.NoError(t, b.Register(fakeJSONRegistration(&dispatchCapture{})))
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
Region: gwfix.Region,
Services: b.Build(),
IAMService: &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {AccessKeyID: testAccessKey, SecretAccessKey: encryptedSecret, UserName: "root", Status: "Active"},
IAMService: &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {AccessKeyID: gwfix.AccessKey, SecretAccessKey: encryptedSecret, UserName: "root", Status: "Active"},
},
},
})
@@ -386,7 +387,7 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
signTestRequestAs(t, req, nil, testAccessKey, testSecretKey, "sqs", time.Now().UTC())
gwfix.SignRequestAs(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, "sqs", time.Now().UTC())
resp := doRequest(r, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
-104
View File
@@ -1,104 +0,0 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (setupEC2Request, policyMockIAMService) and auth context keys.
package gateway
import (
"net/http/httptest"
"testing"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
authzAccountID = "123456789012"
authzRegion = "ap-southeast-2"
)
// scopedPolicyGateway serves one policy document to every principal.
func scopedPolicyGateway(statements ...iamdomain.Statement) *GatewayConfig {
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return withVerifier(&GatewayConfig{
DisableLogging: true,
Region: authzRegion,
IAMService: &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
}
func statement(effect, action, resource string) iamdomain.Statement {
return iamdomain.Statement{
Effect: effect,
Action: iamdomain.StringOrArr{action},
Resource: iamdomain.StringOrArr{resource},
}
}
// dispatchEC2 drives the gateway with no NATS connection. A permitted request
// therefore reaches the handler and fails there, which is what proves the policy
// check ran ahead of the resource existing at all.
func dispatchEC2(t *testing.T, gw *GatewayConfig, body string) error {
t.Helper()
return gw.EC2_Request(httptest.NewRecorder(), setupEC2Request(body, authzAccountID))
}
// assertDenied matches on the resolved code, not the message: a policy denial
// names the principal, action and resource ahead of the code.
func assertDenied(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
}
// assertUnauthorized asserts EC2's policy denial, UnauthorizedOperation.
func assertUnauthorized(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorUnauthorizedOperation, err.Error())
}
// assertNotDenied asserts the policy gate passed where the handler failure that
// follows is not fixed. It resolves the code for the same reason assertDenied
// does: the denial message is no longer the bare code.
func assertNotDenied(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
code, _ := awserrors.ResolveErrorCode(err)
assert.NotEqual(t, awserrors.ErrorAccessDenied, code)
}
// assertPermitted asserts the policy gate passed. The request then fails on the
// absent NATS connection rather than on authorization, so a denial cannot hide
// behind a not-found.
func assertPermitted(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// profileIAMService resolves every instance profile to one holding role app, or
// fails resolution with resolveErr.
type profileIAMService struct {
policyMockIAMService
resolveErr error
}
func (s *profileIAMService) ResolveInstanceProfile(accountID, _ string) (*iamdomain.InstanceProfile, error) {
if s.resolveErr != nil {
return nil, s.resolveErr
}
return &iamdomain.InstanceProfile{AccountID: accountID, RoleName: "app"}, nil
}
func (s *profileIAMService) CanonicalResourceARN(accountID string, _ arn.IAMResourceType, name string) (string, error) {
return "arn:aws:iam::" + accountID + ":role/" + name, nil
}
@@ -1,23 +1,19 @@
//test:in-package — drives the public DeleteVolume action through EC2_Request
// with the gateway's unexported test helpers.
package gateway
import (
"context"
"github.com/mulgadc/spinifex/internal/testkit"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A configured node that never answers leaves the instance view incomplete, so
+2 -43
View File
@@ -1,32 +1,13 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"testing"
)
const dryRunMessage = "Request would have succeeded, but DryRun flag is set."
// serveEC2 runs a request through EC2_Request and, on error, ErrorHandler, so
// the assertion sees the status and body a client would.
func serveEC2(gw *GatewayConfig, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := setupEC2Request(body, authzAccountID)
if err := gw.EC2_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
return w
}
// The gateway has no NATS connection, so any request that reached its handler
// would fail ServerInternal; DryRunOperation proves nothing was dispatched.
func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) {
@@ -47,25 +28,3 @@ func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) {
})
}
}
func TestEC2Request_DryRunFalseDispatches(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
assertPermitted(t, dispatchEC2(t, gw, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=false"))
}
// A caller the policy refuses is denied rather than told the request would
// have succeeded.
func TestEC2Request_DryRunDeniedCaller(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:CreateVpc", "*"),
)
assertUnauthorized(t, dispatchEC2(t, gw, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=true"))
assertDryRun(t, dispatchEC2(t, gw, "Action=CreateSecurityGroup&GroupName=g&GroupDescription=d&DryRun=true"))
}
func assertDryRun(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorDryRunOperation, err.Error())
}
@@ -1,16 +1,6 @@
// Characterization tests for the labels an EC2 request carries through the
// signed pipeline today: the trace span, the audit action, the throttle key,
// and the service and action the policy gate evaluates. Also the order of the
// cluster-unavailable gate against the checks that follow it. They assert
// current behaviour, not AWS parity.
package gateway
import (
"net/http"
"slices"
"testing"
"github.com/mulgadc/bluebottle/pkg/ratelimit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
@@ -20,17 +10,39 @@ import (
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"slices"
"testing"
)
// ec2LabelCases span the query-protocol shapes: a daemon-backed read, the
// gateway-local actions, an idempotent create and a key import.
var ec2LabelCases = []struct{ action, body string }{
{"DescribeVpcs", "Action=DescribeVpcs"},
{"DescribeInstances", "Action=DescribeInstances"},
{"DescribeRegions", "Action=DescribeRegions"},
{"DescribeAvailabilityZones", "Action=DescribeAvailabilityZones"},
{"CreateEgressOnlyInternetGateway", "Action=CreateEgressOnlyInternetGateway&VpcId=vpc-1"},
{"ImportKeyPair", "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=c3NoLXJzYQ%3D%3D"},
// The cluster-unavailable gate runs ahead of everything after authentication:
// a caller the policy would refuse, an action that does not exist and a
// gateway-local action all get the same 503, so none of them reach dispatch.
func TestEC2Labels_ClusterUnavailableGateOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
}{
{"policy would deny", true, "Action=DescribeVpcs"},
{"unknown action", false, "Action=NoSuchAction"},
{"missing action", false, "Foo=bar"},
{"gateway-local DescribeRegions", false, "Action=DescribeRegions"},
{"dry run", false, "Action=DescribeVpcs&DryRun=true"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.NoNATS = true }, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, http.StatusServiceUnavailable, got.Status, got.Body)
assert.Equal(t, "ServiceUnavailable", got.Code)
assert.Contains(t, got.Message, "cluster unavailable", got.Message)
})
}
}
// The policy gate evaluates service "ec2" and the request's own Action: a
@@ -107,33 +119,3 @@ func TestEC2Labels_ThrottleKeyIsAccountAndAction(t *testing.T) {
other := probeEC2(t, app.Handler, "Action=DescribeInstances")
assert.Less(t, other.Status, 400, other.Body)
}
// The cluster-unavailable gate runs ahead of everything after authentication:
// a caller the policy would refuse, an action that does not exist and a
// gateway-local action all get the same 503, so none of them reach dispatch.
func TestEC2Labels_ClusterUnavailableGateOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
}{
{"policy would deny", true, "Action=DescribeVpcs"},
{"unknown action", false, "Action=NoSuchAction"},
{"missing action", false, "Foo=bar"},
{"gateway-local DescribeRegions", false, "Action=DescribeRegions"},
{"dry run", false, "Action=DescribeVpcs&DryRun=true"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.NoNATS = true }, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, http.StatusServiceUnavailable, got.Status, got.Body)
assert.Equal(t, "ServiceUnavailable", got.Code)
assert.Contains(t, got.Message, "cluster unavailable", got.Message)
})
}
}
@@ -1,212 +1,49 @@
// Characterization tests for EC2 through the full SigV4 pipeline as the legacy
// gateway switch and ec2.go implement them today. They assert current
// behaviour, not AWS parity.
package gateway
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"github.com/mulgadc/bluebottle/pkg/ratelimit"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/url"
"strings"
"testing"
)
// ec2Daemon stands in for the daemons behind the ec2.* NATS subjects. It
// records what the gateway forwarded and answers every subject with an empty
// output unless a case needs a real shape.
type ec2Daemon struct {
mu sync.Mutex
calls map[string]int
bodies map[string][]byte
eigw int
// replies overrides the canned answer for a subject.
replies map[string]string
}
func (d *ec2Daemon) reply(subject, body string) {
d.mu.Lock()
defer d.mu.Unlock()
d.replies[subject] = body
}
func newEC2Daemon(t *testing.T, nc *nats.Conn) *ec2Daemon {
t.Helper()
d := &ec2Daemon{calls: map[string]int{}, bodies: map[string][]byte{}, replies: map[string]string{}}
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) {
d.mu.Lock()
d.calls[m.Subject]++
d.bodies[m.Subject] = append([]byte(nil), m.Data...)
reply := `{}`
switch m.Subject {
case "ec2.CreateEgressOnlyInternetGateway":
d.eigw++
reply = fmt.Sprintf(`{"EgressOnlyInternetGateway":{"EgressOnlyInternetGatewayId":"eigw-%04d"}}`, d.eigw)
case "ec2.DescribeVpcs":
reply = `{"Vpcs":[]}`
}
if r, ok := d.replies[m.Subject]; ok {
reply = r
}
d.mu.Unlock()
_ = m.Respond([]byte(reply))
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
return d
}
func (d *ec2Daemon) count(subject string) int {
d.mu.Lock()
defer d.mu.Unlock()
return d.calls[subject]
}
func (d *ec2Daemon) body(subject string) []byte {
d.mu.Lock()
defer d.mu.Unlock()
return d.bodies[subject]
}
// ec2PipelineEnv is what a case may change before the app is built.
type ec2PipelineEnv struct {
NoNATS bool
ResolveErr error
// Outer runs ahead of SigV4, as the telemetry and audit middleware do.
Outer []func(http.Handler) http.Handler
// Throttler, when set, is installed after the action enricher as SetupRoutes does.
Throttler *ratelimit.Throttler
}
type ec2PipelineApp struct {
Handler http.Handler
Daemon *ec2Daemon
}
// ec2Pipeline wires SigV4AuthMiddleware -> Request as SetupRoutes does, with
// EC2 on the legacy switch over a real JetStream and a stub daemon. Policy
// statements are the caller's only IAM policy.
func ec2Pipeline(t *testing.T, mutate func(*ec2PipelineEnv), statements ...iamdomain.Statement) ec2PipelineApp {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
env := &ec2PipelineEnv{}
if mutate != nil {
mutate(env)
// TestEC2Pipeline_CheckOrder pins which refusal wins when a request earns
// several: policy, then tag types, then DryRun, then MaxResults.
func TestEC2Pipeline_CheckOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
code string
}{
{"policy denial beats every parameter error", true, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "UnauthorizedOperation"},
{"tag type beats dry run", false, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "InvalidParameterValue"},
{"dry run beats max results", false, "Action=DescribeInstances&DryRun=true&MaxResults=1", "DryRunOperation"},
{"max results alone", false, "Action=DescribeInstances&MaxResults=1", "InvalidParameterValue"},
}
svc := &profileIAMService{resolveErr: env.ResolveErr, policyMockIAMService: policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}}
_, nc, _ := testutil.StartTestJetStream(t)
daemon := newEC2Daemon(t, nc)
cfg := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: svc,
ExpectedNodes: 1,
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, nil, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, c.code, got.Code, got.Body)
})
}
if env.NoNATS {
cfg.NATSConn = nil
}
gw := withEC2(withVerifier(cfg), func(d *ec2awsapi.Deps) {
d.AZ = testRegion + "a"
d.Endpoint = "https://registry.example"
})
r := chi.NewRouter()
for _, mw := range env.Outer {
r.Use(mw)
}
r.Use(gw.SigV4AuthMiddleware())
r.Use(traceActionEnricher)
if env.Throttler != nil {
r.Use(env.Throttler.Middleware(gw.throttleKeyFuncs(), gw.writeThrottleError))
}
r.HandleFunc("/*", gw.Request)
return ec2PipelineApp{Handler: r, Daemon: daemon}
}
func signEC2Request(t *testing.T, body string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
signTestRequestAs(t, req, []byte(body), testAccessKey, testSecretKey, "ec2", time.Now().UTC())
return req
}
type ec2ErrorBody struct {
XMLName xml.Name `xml:"Response"`
Errors struct {
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
} `xml:"Errors"`
RequestID string `xml:"RequestID"`
}
type ec2Outcome struct {
Status int
ContentType string
Body string
Code string
Message string
RequestID string
}
func probeEC2(t *testing.T, app http.Handler, body string) ec2Outcome {
t.Helper()
resp := doRequest(app, signEC2Request(t, body))
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
out := ec2Outcome{Status: resp.StatusCode, ContentType: resp.Header.Get("Content-Type"), Body: string(raw)}
if resp.StatusCode >= 400 {
var env ec2ErrorBody
require.NoError(t, xml.Unmarshal(raw, &env), string(raw))
out.Code, out.Message, out.RequestID = env.Errors.Error.Code, env.Errors.Error.Message, env.RequestID
}
return out
}
func allowEC2() iamdomain.Statement { return statement("Allow", "ec2:*", "*") }
// TestEC2Pipeline_ErrorEnvelopes pins status, code and the EC2 error envelope
// for each way a request can be refused before or at dispatch.
func TestEC2Pipeline_ErrorEnvelopes(t *testing.T) {
@@ -244,70 +81,6 @@ func TestEC2Pipeline_ErrorEnvelopes(t *testing.T) {
}
}
// TestEC2Pipeline_CheckOrder pins which refusal wins when a request earns
// several: policy, then tag types, then DryRun, then MaxResults.
func TestEC2Pipeline_CheckOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
code string
}{
{"policy denial beats every parameter error", true, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "UnauthorizedOperation"},
{"tag type beats dry run", false, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "InvalidParameterValue"},
{"dry run beats max results", false, "Action=DescribeInstances&DryRun=true&MaxResults=1", "DryRunOperation"},
{"max results alone", false, "Action=DescribeInstances&MaxResults=1", "InvalidParameterValue"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, nil, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, c.code, got.Code, got.Body)
})
}
}
// TestEC2Pipeline_Success pins the success envelope for a daemon-backed list
// and for the gateway-local actions.
func TestEC2Pipeline_Success(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
t.Run("daemon backed list", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeKeyPairs")
require.Equal(t, 200, got.Status, got.Body)
assert.Equal(t, "text/xml", got.ContentType)
assert.Contains(t, got.Body, "<DescribeKeyPairsResponse")
assert.Contains(t, got.Body, "<requestId>")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeKeyPairs"))
})
t.Run("DescribeRegions needs no daemon", func(t *testing.T) {
before := app.Daemon.count("ec2.DescribeRegions")
got := probeEC2(t, app.Handler, "Action=DescribeRegions")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, testRegion)
assert.Contains(t, got.Body, "https://registry.example", "endpoint is built from the registry host and port")
assert.Equal(t, before, app.Daemon.count("ec2.DescribeRegions"))
})
t.Run("DescribeAvailabilityZones needs no daemon", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAvailabilityZones")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, testRegion+"a")
})
t.Run("DescribeAccountAttributes looks up the default VPC", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAccountAttributes")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, "<DescribeAccountAttributesResponse")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeVpcs"))
})
}
// TestEC2Pipeline_IdempotentCreate pins ClientToken handling on a creating
// action: a replay returns the first result, changed parameters are refused,
// and no token means no deduplication.
@@ -342,6 +115,32 @@ func TestEC2Pipeline_IdempotentCreate(t *testing.T) {
assert.Equal(t, 3, app.Daemon.count(subject), "without a token each call creates")
}
// TestEC2Pipeline_ImportKeyPairPadding pins the base64 padding workaround: a
// doubly encoded trailing "==" is decoded before the key reaches the daemon,
// and a single trailing "=" is not.
func TestEC2Pipeline_ImportKeyPairPadding(t *testing.T) {
forwarded := func(t *testing.T, material string) string {
t.Helper()
app := ec2Pipeline(t, nil, allowEC2())
body := "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=" + url.QueryEscape(material)
got := probeEC2(t, app.Handler, body)
require.Equal(t, 200, got.Status, got.Body)
var in ec2.ImportKeyPairInput
require.NoError(t, json.NewDecoder(bytes.NewReader(app.Daemon.body("ec2.ImportKeyPair"))).Decode(&in))
return string(in.PublicKeyMaterial)
}
t.Run("double padding", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA%3D%3D"))
})
t.Run("single padding is left encoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA%3D", forwarded(t, "ssh-ed25519 AAAA%3D"))
})
t.Run("already decoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA=="))
})
}
// TestEC2Pipeline_PassRole pins the gateway iam:PassRole gate on an action
// that hands an instance profile over: a denied role is UnauthorizedOperation,
// a permitted one reaches the daemon.
@@ -380,34 +179,34 @@ func TestEC2Pipeline_PassRole(t *testing.T) {
})
}
// TestEC2Pipeline_ImportKeyPairPadding pins the base64 padding workaround: a
// doubly encoded trailing "==" is decoded before the key reaches the daemon,
// and a single trailing "=" is not.
func TestEC2Pipeline_ImportKeyPairPadding(t *testing.T) {
forwarded := func(t *testing.T, material string) string {
t.Helper()
app := ec2Pipeline(t, nil, allowEC2())
body := "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=" + url.QueryEscape(material)
got := probeEC2(t, app.Handler, body)
require.Equal(t, 200, got.Status, got.Body)
var in ec2.ImportKeyPairInput
require.NoError(t, json.NewDecoder(bytes.NewReader(app.Daemon.body("ec2.ImportKeyPair"))).Decode(&in))
return string(in.PublicKeyMaterial)
}
// TestEC2Pipeline_RunInstancesInstanceProfile pins the current order of the
// profile checks on RunInstances: the profile is resolved first, so an unknown
// one is refused before iam:PassRole is consulted, and a resolved one is then
// subject to PassRole on its role. This is the baseline for any parity change.
func TestEC2Pipeline_RunInstancesInstanceProfile(t *testing.T) {
const body = runInstancesBase + "&ImageId=ami-1&IamInstanceProfile.Name=web"
t.Run("double padding", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA%3D%3D"))
t.Run("unknown profile is refused before PassRole", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorIAMNoSuchEntity) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "InvalidIamInstanceProfile.NotFound", got.Code, got.Body)
assert.Equal(t, 404, got.Status)
})
t.Run("single padding is left encoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA%3D", forwarded(t, "ssh-ed25519 AAAA%3D"))
t.Run("resolved profile without PassRole", func(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, 403, got.Status, got.Body)
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
})
t.Run("already decoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA=="))
t.Run("other resolution failure is returned as is", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorServerInternal) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "ServerInternal", got.Code, got.Body)
})
}
const runInstancesBase = "Action=RunInstances&InstanceType=t3.micro&MinCount=1&MaxCount=1"
// TestEC2Pipeline_RunInstancesLaunchTemplateAuthorizedAfterExpansion pins that
// a launch template is expanded before the policy gate, so a resource the
// template supplies is authorized and a Deny on it fires.
@@ -444,30 +243,39 @@ func TestEC2Pipeline_RunInstancesLaunchTemplateAuthorizedAfterExpansion(t *testi
})
}
// TestEC2Pipeline_RunInstancesInstanceProfile pins the current order of the
// profile checks on RunInstances: the profile is resolved first, so an unknown
// one is refused before iam:PassRole is consulted, and a resolved one is then
// subject to PassRole on its role. This is the baseline for any parity change.
func TestEC2Pipeline_RunInstancesInstanceProfile(t *testing.T) {
const body = runInstancesBase + "&ImageId=ami-1&IamInstanceProfile.Name=web"
// TestEC2Pipeline_Success pins the success envelope for a daemon-backed list
// and for the gateway-local actions.
func TestEC2Pipeline_Success(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
t.Run("unknown profile is refused before PassRole", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorIAMNoSuchEntity) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "InvalidIamInstanceProfile.NotFound", got.Code, got.Body)
assert.Equal(t, 404, got.Status)
t.Run("daemon backed list", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeKeyPairs")
require.Equal(t, 200, got.Status, got.Body)
assert.Equal(t, "text/xml", got.ContentType)
assert.Contains(t, got.Body, "<DescribeKeyPairsResponse")
assert.Contains(t, got.Body, "<requestId>")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeKeyPairs"))
})
t.Run("resolved profile without PassRole", func(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, 403, got.Status, got.Body)
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
t.Run("DescribeRegions needs no daemon", func(t *testing.T) {
before := app.Daemon.count("ec2.DescribeRegions")
got := probeEC2(t, app.Handler, "Action=DescribeRegions")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, gwfix.Region)
assert.Contains(t, got.Body, "https://registry.example", "endpoint is built from the registry host and port")
assert.Equal(t, before, app.Daemon.count("ec2.DescribeRegions"))
})
t.Run("other resolution failure is returned as is", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorServerInternal) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "ServerInternal", got.Code, got.Body)
t.Run("DescribeAvailabilityZones needs no daemon", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAvailabilityZones")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, gwfix.Region+"a")
})
t.Run("DescribeAccountAttributes looks up the default VPC", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAccountAttributes")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, "<DescribeAccountAttributesResponse")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeVpcs"))
})
}
-52
View File
@@ -1,52 +0,0 @@
package gateway
import (
"net/http"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/ec2quota"
)
// ec2Registration composes the EC2 registration from the NATS connection, IAM
// service and node count gw holds, as the awsgw role does. mutate adjusts the
// rest.
func ec2Registration(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) dispatch.Registration {
deps := ec2awsapi.Deps{
NATS: gw.NATSConn,
IAM: gw.IAMService,
Quota: ec2quota.Adapter{Service: quotaFor(gw)},
ActiveNodes: gw.DiscoverActiveNodes,
ExpectedNodes: gw.ExpectedNodes,
}
for _, m := range mutate {
m(&deps)
}
return ec2awsapi.NewRegistration(deps)
}
// withEC2 registers EC2 alone on gw.
func withEC2(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) *GatewayConfig {
return withServices(gw, ec2Registration(gw, mutate...))
}
// EC2_Request dispatches r to the registered EC2 service exactly as Request
// does past its NATS gate, registering it first from the fields gw holds when
// the test has not.
func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error {
if _, ok := gw.registered(ec2awsapi.ServiceName); !ok {
withEC2(gw)
}
entry, _ := gw.registered(ec2awsapi.ServiceName)
return gw.dispatchRegistered(entry, w, r)
}
// withEC2Verifier is withVerifier for a test that signs as EC2: the service
// must be registered for the auth gate to serve its credential scope.
func withEC2Verifier(gw *GatewayConfig) *GatewayConfig {
return withEC2(withVerifier(gw))
}
func ec2RegistrationInventory() map[string]dispatch.Inventory {
return withEC2(&GatewayConfig{}).Services.Inventory()
}
+60 -80
View File
@@ -1,65 +1,49 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package gateway
import (
"encoding/xml"
"github.com/stretchr/testify/assert"
"net/http"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func tagSpecBody(action, resourceType string) string {
return "Action=" + action + "&TagSpecification.1.ResourceType=" + resourceType +
"&TagSpecification.1.Tag.1.Key=k&TagSpecification.1.Tag.1.Value=v"
}
func TestEC2Request_TagSpecificationResourceType(t *testing.T) {
// Captured from AWS: the combination is refused even when MaxResults is also
// out of range.
func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
cases := []struct {
name, body, code, resourceType string
}{
{"out of enum", tagSpecBody("CreateKeyPair", "bogus-type") + "&KeyName=k", "InvalidParameterValue", "bogus-type"},
{"another resource's type", tagSpecBody("CreateInternetGateway", "instance"), "InvalidParameterValue", "instance"},
{"InvalidParameter action", tagSpecBody("CreateVpc", "instance") + "&CidrBlock=10.0.0.0/16", "InvalidParameter", "instance"},
{"checked before DryRun", tagSpecBody("CreateVpc", "subnet") + "&CidrBlock=10.0.0.0/16&DryRun=true", "InvalidParameter", "subnet"},
{"second spec checked", tagSpecBody("RunInstances", "volume") +
"&TagSpecification.2.ResourceType=image&ImageId=ami-1&MinCount=1&MaxCount=1", "InvalidParameterValue", "image"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := serveEC2(gw, tc.body)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+tc.code+"</Code>")
assert.Contains(t, w.Body.String(),
"<Message>&#39;"+tc.resourceType+"&#39; is not a valid taggable resource type for this operation.</Message>")
})
for _, n := range []string{"2", "5"} {
w := serveEC2(gw, "Action=DescribeInstances&InstanceId.1=i-0123456789abcdef0&MaxResults="+n)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>InvalidParameterCombination</Code>")
assert.Contains(t, w.Body.String(), "<Message>The parameter instancesSet cannot be used with the parameter maxResults</Message>")
}
}
// Each of an action's own types reaches the handler, which fails only on the
// absent NATS connection.
func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) {
// AWS answers DryRun ahead of a MaxResults out of range.
func TestEC2Request_MaxResultsAfterDryRun(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
assertDryRun(t, dispatchEC2(t, gw, "Action=DescribeLaunchTemplates&MaxResults=0&DryRun=true"))
}
func TestEC2Request_MaxResultsInRange(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
for _, rt := range []string{"instance", "volume", "network-interface", "spot-instances-request"} {
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("RunInstances", rt)+"&ImageId=ami-1&MinCount=1&MaxCount=1"))
for _, body := range []string{
"Action=DescribeCapacityReservations&MaxResults=1",
"Action=DescribeEgressOnlyInternetGateways&MaxResults=255",
"Action=DescribeInstances&MaxResults=5",
"Action=DescribeInstances&MaxResults=5000",
"Action=DescribeInstanceTypes&MaxResults=100",
"Action=DescribeInstanceTypeOfferings&MaxResults=5",
"Action=DescribeLaunchTemplates&MaxResults=200",
"Action=DescribeNatGateways&MaxResults=1000",
// AWS accepted 4 and 1001 here although the model bounds it 5-1000.
"Action=DescribeIamInstanceProfileAssociations&MaxResults=4",
"Action=DescribeIamInstanceProfileAssociations&MaxResults=1001",
} {
assertPermitted(t, dispatchEC2(t, gw, body))
}
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("CreateVpc", "vpc")+"&CidrBlock=10.0.0.0/16"))
}
// xmlText escapes s as the error body renders it.
func xmlText(t *testing.T, s string) string {
t.Helper()
var b strings.Builder
require.NoError(t, xml.EscapeText(&b, []byte(s)))
return b.String()
}
// The refusals are AWS's own, captured by scripts/aws-compliance/aws-response-diff/scen_e.py.
@@ -98,41 +82,37 @@ func TestEC2Request_MaxResultsOutOfRange(t *testing.T) {
}
}
// Captured from AWS: the combination is refused even when MaxResults is also
// out of range.
func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) {
// Each of an action's own types reaches the handler, which fails only on the
// absent NATS connection.
func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
for _, n := range []string{"2", "5"} {
w := serveEC2(gw, "Action=DescribeInstances&InstanceId.1=i-0123456789abcdef0&MaxResults="+n)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>InvalidParameterCombination</Code>")
assert.Contains(t, w.Body.String(), "<Message>The parameter instancesSet cannot be used with the parameter maxResults</Message>")
for _, rt := range []string{"instance", "volume", "network-interface", "spot-instances-request"} {
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("RunInstances", rt)+"&ImageId=ami-1&MinCount=1&MaxCount=1"))
}
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("CreateVpc", "vpc")+"&CidrBlock=10.0.0.0/16"))
}
func TestEC2Request_TagSpecificationResourceType(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
cases := []struct {
name, body, code, resourceType string
}{
{"out of enum", tagSpecBody("CreateKeyPair", "bogus-type") + "&KeyName=k", "InvalidParameterValue", "bogus-type"},
{"another resource's type", tagSpecBody("CreateInternetGateway", "instance"), "InvalidParameterValue", "instance"},
{"InvalidParameter action", tagSpecBody("CreateVpc", "instance") + "&CidrBlock=10.0.0.0/16", "InvalidParameter", "instance"},
{"checked before DryRun", tagSpecBody("CreateVpc", "subnet") + "&CidrBlock=10.0.0.0/16&DryRun=true", "InvalidParameter", "subnet"},
{"second spec checked", tagSpecBody("RunInstances", "volume") +
"&TagSpecification.2.ResourceType=image&ImageId=ami-1&MinCount=1&MaxCount=1", "InvalidParameterValue", "image"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := serveEC2(gw, tc.body)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+tc.code+"</Code>")
assert.Contains(t, w.Body.String(),
"<Message>&#39;"+tc.resourceType+"&#39; is not a valid taggable resource type for this operation.</Message>")
})
}
}
func TestEC2Request_MaxResultsInRange(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
for _, body := range []string{
"Action=DescribeCapacityReservations&MaxResults=1",
"Action=DescribeEgressOnlyInternetGateways&MaxResults=255",
"Action=DescribeInstances&MaxResults=5",
"Action=DescribeInstances&MaxResults=5000",
"Action=DescribeInstanceTypes&MaxResults=100",
"Action=DescribeInstanceTypeOfferings&MaxResults=5",
"Action=DescribeLaunchTemplates&MaxResults=200",
"Action=DescribeNatGateways&MaxResults=1000",
// AWS accepted 4 and 1001 here although the model bounds it 5-1000.
"Action=DescribeIamInstanceProfileAssociations&MaxResults=4",
"Action=DescribeIamInstanceProfileAssociations&MaxResults=1001",
} {
assertPermitted(t, dispatchEC2(t, gw, body))
}
}
// AWS answers DryRun ahead of a MaxResults out of range.
func TestEC2Request_MaxResultsAfterDryRun(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
assertDryRun(t, dispatchEC2(t, gw, "Action=DescribeLaunchTemplates&MaxResults=0&DryRun=true"))
}
+69 -430
View File
@@ -1,191 +1,23 @@
// Characterization tests for ECR control-plane dispatch as it exists in
// ecrapi.go/ecr.go today, pinned ahead of moving dispatch out of gateway into
// a registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// decodeAuthToken decodes an ECR authorizationToken ("AWS:<jwt>") to the jwt.
func decodeAuthToken(token string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return "", err
}
_, jwtStr, _ := strings.Cut(string(decoded), ":")
return jwtStr, nil
}
// ecrPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS so Request dispatches. docs is every principal's policy; the
// region is testRegion because signTestRequestAs always signs for it.
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
InternalSuffix: ecrTestSuffix,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: ecrTestAccount,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withECR(gw, awsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllECRDocs is the policy ecrPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllECRDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signECRRequest builds a POST / signed with credential-scope service "ecr",
// the shape every ECR JSON 1.1 client sends.
func signECRRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "ecr", time.Now().UTC())
return req
}
// jsonErrorEnvelope is the AWS JSON 1.1 error body shape.
type jsonErrorEnvelope struct {
Type string `json:"__type"`
Message string `json:"message"`
}
func decodeJSONError(t *testing.T, resp *http.Response) jsonErrorEnvelope {
t.Helper()
var out jsonErrorEnvelope
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
return out
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestECRPipeline_MissingTarget_400MissingActionException pins that an ECR
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestECRPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
resp := doRequest(app, signECRRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestECRPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from awsapi.Actions fails with InvalidAction.
func TestECRPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".MadeUpAction"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// TestECRPipeline_StubbedAction_501NotImplementedException pins that a
// registered-but-stubbed action (ListRepositories) still answers 501 through
// the full pipeline.
func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".ListRepositories"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "NotImplementedException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "NotImplementedException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestECRPipeline_GetAuthorizationToken_200JSONContentType pins that a
// successful ECR action answers 200 with the AWS JSON 1.1 content type
// through the full pipeline.
func TestECRPipeline_GetAuthorizationToken_200JSONContentType(t *testing.T) {
iss, _ := newECRAuth(t)
gw, app := ecrPipelineApp(t, allowAllECRDocs())
withECR(gw, awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".GetAuthorizationToken"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, awsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// TestECRActionFromTarget_FormsAndTelemetryAgree pins how the ECR
// registration's resolver (dispatch and telemetry/throttle) and the legacy
// resolveNonQueryAction each resolve the X-Amz-Target header today. A refactor
@@ -215,54 +47,6 @@ func TestECRActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
}
}
// TestECRRequest_UnrelatedPrefixDispatchesByBareAction pins that a target with
// any "<prefix>.<Action>" shape (not just the ECR prefix) dispatches on the
// bare action name today.
func TestECRRequest_UnrelatedPrefixDispatchesByBareAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(), setupECRRequest("a.b.ListRepositories", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
// TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction pins that a target
// carrying another service's prefix but naming an action outside awsapi.Actions
// fails as an unrecognised ECR action, not as a cross-service error.
func TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest("AmazonEC2ContainerServiceV20141113.ListClusters", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// TestECRRequest_TrailingDotIsMissingAction pins that a target ending in "."
// resolves to an empty action, the same as an absent target.
func TestECRRequest_TrailingDotIsMissingAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest(awsapi.TargetPrefix+".", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
// --- Exact stub inventory --------------------------------------------------
// TestECRInventory_StubbedSetIsPinned pins the exact set of actions still
// bound to awsapi.NotImplemented, independent of whether a composed capability
// intercepts them before dispatch reaches that stub.
func TestECRInventory_StubbedSetIsPinned(t *testing.T) {
want := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "BatchGetImage", "BatchCheckLayerAvailability", "BatchDeleteImage",
"PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer", "InitiateLayerUpload",
"UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy", "PutRegistryPolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview",
"PutReplicationConfiguration", "ReplicateImage",
}
assert.ElementsMatch(t, want, awsapi.StubbedActionNames())
}
// TestECRInventory_ReachableStubsAnswer501 pins the subset of the raw stub set
// that nothing intercepts ahead of the handler table, i.e. what the HTTP
// pipeline actually answers 501 for today.
@@ -303,158 +87,6 @@ func TestECRInventory_ScanningActionsReturnOperationNotSupported(t *testing.T) {
}
}
// --- Oversized body over HTTP ---------------------------------------------
// TestECRPipeline_OversizedBody_413OverHTTP pins that an ECR-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ECR dispatch ever runs, with the JSON error envelope.
func TestECRPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".ListRepositories")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "ecr", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before a composed action --------------------------
// recordingRepoStore is a RepositoryStore fake that records whether it was
// ever invoked, so a denial can be proven to short-circuit before the composed
// capability runs.
type recordingRepoStore struct {
calls int
}
func (s *recordingRepoStore) GetRepo(context.Context, string, string) (ecr.RepoMeta, error) {
s.calls++
return ecr.RepoMeta{}, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListRepos(context.Context, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) PutRepo(context.Context, string, ecr.RepoMeta) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListManifests(context.Context, string, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) DeleteRepo(context.Context, string, string) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
// TestECRPipeline_AuthzGateBlocksComposedAction pins that the policy gate in
// ECR dispatch runs, and denies, before a composed capability (here
// RepositoryActionService backing CreateRepository) is ever invoked.
func TestECRPipeline_AuthzGateBlocksComposedAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"ecr:CreateRepository"},
Resource: iamdomain.StringOrArr{"arn:aws:ecr:" + testRegion + ":" + ecrTestAccount + ":repository/other"},
},
},
}}
gw, app := ecrPipelineApp(t, scopedDocs)
store := &recordingRepoStore{}
withECR(gw, awsapi.Deps{Repository: awsapi.NewRepositoryActionService(store, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".CreateRepository"
resp := doRequest(app, signECRRequest(t, target, []byte(`{"repositoryName":"team/app"}`)))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", body.Type)
assert.Equal(t, 0, store.calls, "composed capability must not run once the gate denies")
}
// --- GetAuthorizationToken ignores request-supplied identity -------------
// A registryIds field naming a foreign account must not reach the token: its
// account and subject come from the verified caller, never the body.
func TestGetAuthorizationToken_IgnoresRequestSuppliedIdentity(t *testing.T) {
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"registryIds":["999999999999"]}`))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
w := httptest.NewRecorder()
require.NoError(t, gw.serveECR(w, req.WithContext(ctx)))
require.Equal(t, http.StatusOK, w.Code)
var out struct {
AuthorizationData []struct {
AuthorizationToken string `json:"authorizationToken"`
} `json:"authorizationData"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Len(t, out.AuthorizationData, 1)
decoded, err := decodeAuthToken(out.AuthorizationData[0].AuthorizationToken)
require.NoError(t, err)
claims, err := verify.Verify(decoded)
require.NoError(t, err)
assert.Equal(t, ecrTestAccount, claims.AccountID)
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/dev", claims.Subject)
}
// --- NATS unavailable for ECR ---------------------------------------------
// Stub and composed ECR actions alike fail at the NATS gate, before dispatch
// reads the action.
func TestRequest_ClusterUnavailableNilConn_ECR(t *testing.T) {
for _, action := range []string{"ListRepositories", "CreateRepository"} {
t.Run(action, func(t *testing.T) {
gw := withECR(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), awsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+"."+action)
ctx := reqctx.WithService(req.Context(), "ecr")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ECR request ---------------------------------
// TestECRPipeline_TraceActionEnricher_SpanNamedServiceAction pins that the
// server span is renamed to "ecr.<Action>" and carries aws.action once a real
// ECR request runs through SigV4AuthMiddleware and traceActionEnricher.
@@ -486,63 +118,70 @@ func TestECRPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
assert.Equal(t, "ecr", got["aws.service"])
}
// TestECRPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ECR action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestECRPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := ecrPipelineApp(t, allowAllECRDocs())
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := awsapi.TargetPrefix + ".ListRepositories"
doRequest(r, signECRRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListRepositories", auditField(got, "action"))
// TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction pins that a target
// carrying another service's prefix but naming an action outside awsapi.Actions
// fails as an unrecognised ECR action, not as a cross-service error.
func TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest("AmazonEC2ContainerServiceV20141113.ListClusters", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// --- Inventory pin --------------------------------------------------------
// Pins the ECR inventory so a refactor that drops or adds an action, including
// by changing which capability intercepts it, fails here.
func TestECRInventory_RegisteredStubbedUnsupportedArePinned(t *testing.T) {
ecr := AWSOperationInventory(ecrRegistrationInventory())["ecr"]
wantRegistered := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "PutImageTagMutability", "BatchGetImage", "BatchCheckLayerAvailability",
"BatchDeleteImage", "PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "SetRepositoryPolicy",
"GetRepositoryPolicy", "DeleteRepositoryPolicy", "GetRegistryPolicy", "PutRegistryPolicy",
"DescribeRegistry", "PutImageScanningConfiguration", "GetImageScanningConfiguration",
"StartImageScan", "DescribeImageScanFindings", "GetRegistryScanningConfiguration",
"PutRegistryScanningConfiguration", "BatchGetRepositoryScanningConfiguration",
"PutLifecyclePolicy", "GetLifecyclePolicy", "DeleteLifecyclePolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview", "PutReplicationConfiguration",
"ReplicateImage", "TagResource", "UntagResource", "ListTagsForResource",
}
wantStubbed := []string{
"ListRepositories", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy",
"PutRegistryPolicy", "PutReplicationConfiguration", "ReplicateImage",
}
wantUnsupported := []string{
"GetImageScanningConfiguration", "StartImageScan", "DescribeImageScanFindings",
"GetRegistryScanningConfiguration", "PutRegistryScanningConfiguration",
"BatchGetRepositoryScanningConfiguration",
}
assert.ElementsMatch(t, wantRegistered, ecr.Registered)
assert.ElementsMatch(t, wantStubbed, ecr.Stubbed)
assert.ElementsMatch(t, wantUnsupported, ecr.Unsupported)
// TestECRRequest_TrailingDotIsMissingAction pins that a target ending in "."
// resolves to an empty action, the same as an absent target.
func TestECRRequest_TrailingDotIsMissingAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest(awsapi.TargetPrefix+".", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
// TestECRRequest_UnrelatedPrefixDispatchesByBareAction pins that a target with
// any "<prefix>.<Action>" shape (not just the ECR prefix) dispatches on the
// bare action name today.
func TestECRRequest_UnrelatedPrefixDispatchesByBareAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(), setupECRRequest("a.b.ListRepositories", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
// A registryIds field naming a foreign account must not reach the token: its
// account and subject come from the verified caller, never the body.
func TestGetAuthorizationToken_IgnoresRequestSuppliedIdentity(t *testing.T) {
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"registryIds":["999999999999"]}`))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
w := httptest.NewRecorder()
require.NoError(t, gw.serveECR(w, req.WithContext(ctx)))
require.Equal(t, http.StatusOK, w.Code)
var out struct {
AuthorizationData []struct {
AuthorizationToken string `json:"authorizationToken"`
} `json:"authorizationData"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Len(t, out.AuthorizationData, 1)
decoded, err := decodeAuthToken(out.AuthorizationData[0].AuthorizationToken)
require.NoError(t, err)
claims, err := verify.Verify(decoded)
require.NoError(t, err)
assert.Equal(t, ecrTestAccount, claims.AccountID)
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/dev", claims.Subject)
}
@@ -3,18 +3,33 @@ package gateway
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
iss, _ := newECRAuth(t)
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
w := httptest.NewRecorder()
req := setupECRRequest("AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken", "{}")
require.NoError(t, gw.serveECR(w, req))
assert.Equal(t, http.StatusOK, w.Code)
}
func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
@@ -26,7 +41,7 @@ func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
@@ -62,13 +77,33 @@ func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/dev", claims.Subject)
}
func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
err := gw.serveECR(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, "ServerInternal", err.Error())
}
func TestGetAuthorizationToken_NoIssuerNotImplemented(t *testing.T) {
gw := withECR(withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(nil, awsapi.RepositoryEndpoint{})})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
err := gw.serveECR(httptest.NewRecorder(), req.WithContext(ctx))
require.Error(t, err)
assert.Equal(t, "NotImplemented", err.Error())
}
// The mint and verify halves agree on a pathed user: the token is minted for the
// stored ARN the middleware resolved, and verifies against the same record.
func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.users[ecrPrincipalTestAccount+"|dev"].Arn = aws.String(ecrPathedUserARN)
allow, _ := allowAllIAMService().getUserPoliciesFn(ecrPrincipalTestAccount, "dev")
allow, _ := allowAllIAMService().GetUserPoliciesFn(ecrPrincipalTestAccount, "dev")
iamSvc.userPolicies[ecrPrincipalTestAccount+"|dev"] = allow
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
@@ -80,7 +115,7 @@ func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrPrincipalTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldUserARN, ecrPathedUserARN)
ctx = withPrincipalField(ctx, fieldAccessKey, ecrPrincipalTestAKID)
@@ -119,7 +154,7 @@ func TestGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
@@ -135,38 +170,3 @@ func TestGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
require.Len(t, out.AuthorizationData, 1)
assert.Equal(t, "https://"+ecrTestAccount+".dkr.ecr."+ecrTestRegion+"."+ecrTestSuffix+":9999", out.AuthorizationData[0].ProxyEndpoint)
}
func TestGetAuthorizationToken_NoIssuerNotImplemented(t *testing.T) {
gw := withECR(withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(nil, awsapi.RepositoryEndpoint{})})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
err := gw.serveECR(httptest.NewRecorder(), req.WithContext(ctx))
require.Error(t, err)
assert.Equal(t, "NotImplemented", err.Error())
}
func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
err := gw.serveECR(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, "ServerInternal", err.Error())
}
func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
iss, _ := newECRAuth(t)
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
w := httptest.NewRecorder()
req := setupECRRequest("AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken", "{}")
require.NoError(t, gw.serveECR(w, req))
assert.Equal(t, http.StatusOK, w.Code)
}
+2 -1
View File
@@ -2,6 +2,7 @@ package gateway
import (
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -26,7 +27,7 @@ func setupECRRequest(target, body string) *http.Request {
ctx := reqctx.WithService(req.Context(), "ecr")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAECRTESTCONTROLPL1")
return req.WithContext(ctx)
}
@@ -1,79 +0,0 @@
package gateway
import (
"net/http"
"github.com/mulgadc/bluebottle/pkg/auth"
"github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// mockRoleResolver resolves roles through the gateway's mock IAM service, the
// way the IAM-owned resolver does against the real store.
type mockRoleResolver struct{ iam IAMServiceLike }
// IAMServiceLike is the slice of the IAM service the mock resolver reads.
type IAMServiceLike interface {
CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error)
}
func (m mockRoleResolver) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
roleAccount, name, err := auth.ParseRoleARN(roleARN)
if err != nil {
return passrole.Role{}, passrole.ErrMalformed
}
if roleAccount != accountID {
return passrole.Role{}, passrole.ErrForeignAccount
}
canonical, err := m.iam.CanonicalResourceARN(roleAccount, arn.IAMRole, name)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{}, err
}
if canonical != roleARN {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{ARN: canonical, Name: name}, nil
}
var _ passrole.Resolver = mockRoleResolver{}
// withContainers registers ECS and EKS on gw with the NATS connection and
// mock IAM service it holds, as the awsgw role composes them.
func withContainers(gw *GatewayConfig) *GatewayConfig {
b := dispatch.NewBuilder()
if err := b.Register(awsapi.NewRegistration(awsapi.Deps{NATS: gw.NATSConn})); err != nil {
panic(err)
}
roles, _ := gw.IAMService.(IAMServiceLike)
if err := b.Register(eksawsapi.NewRegistration(eksawsapi.Deps{NATS: gw.NATSConn, Roles: mockRoleResolver{iam: roles}})); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
func (gw *GatewayConfig) serveRegistered(service string, w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withContainers(gw)
}
entry, ok := gw.registered(service)
if !ok {
panic(service + " is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
func (gw *GatewayConfig) serveECS(w http.ResponseWriter, r *http.Request) error {
return gw.serveRegistered(awsapi.ServiceName, w, r)
}
func (gw *GatewayConfig) serveEKS(w http.ResponseWriter, r *http.Request) error {
return gw.serveRegistered(eksawsapi.ServiceName, w, r)
}
+8 -36
View File
@@ -2,52 +2,24 @@ package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecs"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
)
// TestECSRegisterTaskDefinition_PassRole exercises the full gw.ECS_Request
// dispatch (X-Amz-Target parsing, action-level checkPolicy, then the
// iam:PassRole resource check gateway/ecs.go builds and threads through
// ecsawsapi.Handler) with a real authenticated user identity, so the IAM
// policy is actually evaluated rather than taking the pre-IAM bypass.
const testECSPassRoleTargetARN = "arn:aws:iam::123456789012:role/ecs-task-role"
// setupECSUserRequest is setupECSRequest plus an authenticated user identity.
func setupECSUserRequest(target, body string) *http.Request {
req := setupECSRequest(target, body)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
return req.WithContext(ctx)
}
func registerTaskDefinitionBody(t *testing.T, taskRoleARN string) string {
t.Helper()
input := &ecs.RegisterTaskDefinitionInput{Family: aws.String("web")}
if taskRoleARN != "" {
input.TaskRoleArn = aws.String(taskRoleARN)
}
body, err := json.Marshal(input)
require.NoError(t, err)
return string(body)
}
// TestECSRegisterTaskDefinition_DenialRendersAccessDenied403 proves shared
// policy denials stay opaque while retaining the service's 403 envelope.
func TestECSRegisterTaskDefinition_DenialRendersAccessDenied403(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return allowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
mock := &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return gwfix.AllowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
},
}
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
gw := withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := setupECSUserRequest("AmazonEC2ContainerServiceV20141113.RegisterTaskDefinition",
registerTaskDefinitionBody(t, testECSPassRoleTargetARN))
+18 -61
View File
@@ -1,74 +1,16 @@
package gateway
import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"strings"
"testing"
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupECSRequest(target, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
ctx := reqctx.WithService(req.Context(), "ecs")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
return withTestIdentity(req.WithContext(ctx))
}
// The Actions map is the v1 API contract (ecs-v1.md §1): every action across the
// Cluster / TaskDefinition / Task / Service / ContainerInstance / Account / Tags
// surfaces must be registered, whether wired to the daemon or a 501 stub.
func TestECSActionsMap_NamespaceRegistered(t *testing.T) {
namespace := []string{
// Cluster
"CreateCluster", "DescribeClusters", "ListClusters", "DeleteCluster",
"UpdateCluster", "PutClusterCapacityProviders",
// Task definition
"RegisterTaskDefinition", "DeregisterTaskDefinition", "DescribeTaskDefinition",
"ListTaskDefinitions", "ListTaskDefinitionFamilies",
// Task
"RunTask", "StartTask", "StopTask", "DescribeTasks", "ListTasks",
// Service
"CreateService", "UpdateService", "DeleteService", "DescribeServices",
"ListServices", "ListServicesByNamespace",
// Container instance
"RegisterContainerInstance", "DeregisterContainerInstance",
"DescribeContainerInstances", "ListContainerInstances",
"UpdateContainerInstancesState",
// Account
"PutAccountSetting", "ListAccountSettings",
// Tags
"TagResource", "UntagResource", "ListTagsForResource",
}
for _, action := range namespace {
_, ok := ecsawsapi.Actions[action]
assert.True(t, ok, "action %q should be registered", action)
}
}
func TestECSRequest_MissingTarget(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(), setupECSRequest("", ""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
func TestECSRequest_UnknownAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(),
setupECSRequest("AmazonEC2ContainerServiceV20141113.MadeUpAction", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// An action whose real handler has not yet landed still resolves to the 501 stub.
func TestECSRequest_KnownActionNotImplemented(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
@@ -92,3 +34,18 @@ func TestECSRequest_MissingAccountID(t *testing.T) {
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
func TestECSRequest_MissingTarget(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(), setupECSRequest("", ""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
func TestECSRequest_UnknownAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(),
setupECSRequest("AmazonEC2ContainerServiceV20141113.MadeUpAction", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
-243
View File
@@ -1,243 +0,0 @@
package gateway
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// dispatchEKS drives the gateway with no NATS connection, so a permitted request
// reaches the NATS guard and fails there.
func dispatchEKS(t *testing.T, gw *GatewayConfig, method, path, body string) error {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
ctx := reqctx.WithService(req.Context(), "eks")
ctx = withPrincipalField(ctx, fieldAccountID, authzAccountID)
req = withTestIdentity(req.WithContext(ctx))
return gw.serveEKS(httptest.NewRecorder(), req)
}
const eksNodeRoleARN = "arn:aws:iam::" + authzAccountID + ":role/eks/node-role"
const foreignAccountID = "999999999999"
// roleStoreIAMService evaluates policy from docs and resolves role names to the
// ARNs in roles, keyed by account then name, the way the IAM store does.
type roleStoreIAMService struct {
policyMockIAMService
roles map[string]map[string]string
canonicalErr error
}
func (s *roleStoreIAMService) CanonicalResourceARN(account string, _ arn.IAMResourceType, name string) (string, error) {
if s.canonicalErr != nil {
return "", s.canonicalErr
}
if roleARN, ok := s.roles[account][name]; ok {
return roleARN, nil
}
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
}
// The foreign account holds a role of the same name, so only the gateway's
// account check stops its ARN from resolving.
func eksPassRoleService(statements ...iamdomain.Statement) *roleStoreIAMService {
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return &roleStoreIAMService{
policyMockIAMService: policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
roles: map[string]map[string]string{
authzAccountID: {"node-role": eksNodeRoleARN},
foreignAccountID: {"node-role": "arn:aws:iam::" + foreignAccountID + ":role/eks/node-role"},
},
}
}
func eksPassRoleGateway(statements ...iamdomain.Statement) *GatewayConfig {
return eksGatewayWithIAM(eksPassRoleService(statements...))
}
func eksGatewayWithIAM(iamService *roleStoreIAMService) *GatewayConfig {
return withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: iamService})
}
func createNodegroupBody(nodeRole string) string {
return `{"nodegroupName":"workers","nodeRole":"` + nodeRole + `"}`
}
// The AWS capture of 2026-10-30 refuses a foreign or non-role ARN on CreateCluster
// and CreateNodegroup with AccessDeniedException before PassRole is evaluated.
func assertCrossAccountPassRole(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorAccessDeniedException, code)
assert.Contains(t, err.Error(), "Cross-account pass role is not allowed.")
}
func assertInvalidParameter(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorEKSInvalidParameter, code)
}
// eks:* alone must not let a caller launch nodes under a role it cannot pass.
func TestEKSCreateNodegroup_DeniedWithoutPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
func TestEKSCreateNodegroup_AllowedWithPassRoleOnRole(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
// A grant scoped to a path the role is not under must not be satisfied by
// spelling the role's name under that path.
func TestEKSCreateNodegroup_InventedPathIsRefused(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "arn:aws:iam::"+authzAccountID+":role/sandbox/*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+authzAccountID+":role/sandbox/node-role"))
assertInvalidParameter(t, err)
}
func TestEKSCreateNodegroup_ForeignAccountRoleIsRefused(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+foreignAccountID+":role/eks/node-role"))
assertCrossAccountPassRole(t, err)
}
func TestEKSCreateNodegroup_NonRoleARNIsRefused(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+authzAccountID+":user/node-role"))
assertCrossAccountPassRole(t, err)
}
// A store fault is not evidence the role is absent, so it must not surface as
// a caller error or fall through to dispatch.
func TestEKSCreateNodegroup_LookupFaultPassesThrough(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
iamService := eksPassRoleService(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
iamService.canonicalErr = storeErr
err := dispatchEKS(t, eksGatewayWithIAM(iamService), http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
// An unknown role is reported only to a caller allowed to pass it, so the
// reply cannot be used to enumerate roles.
func TestEKSCreateNodegroup_UnknownRole(t *testing.T) {
body := createNodegroupBody("arn:aws:iam::" + authzAccountID + ":role/ghost")
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/node-groups", body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
assertInvalidParameter(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/node-groups", body))
}
func TestEKSCreateNodegroup_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", `{"nodegroupName":"workers"}`))
}
func TestEKSCreateCluster_PassRole(t *testing.T) {
body := `{"name":"prod","roleArn":"` + eksNodeRoleARN + `"}`
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters", body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters", body))
}
func createAddonBody(roleARN string) string {
return `{"addonName":"aws-load-balancer-controller","serviceAccountRoleArn":"` + roleARN + `"}`
}
func updateAddonBody(roleARN string) string {
return `{"serviceAccountRoleArn":"` + roleARN + `"}`
}
func TestEKSCreateAddon_PassRole(t *testing.T) {
body := createAddonBody(eksNodeRoleARN)
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/addons", body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/addons", body))
}
func TestEKSCreateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/addons", createAddonBody("")))
}
func TestEKSUpdateAddon_PassRole(t *testing.T) {
body := updateAddonBody(eksNodeRoleARN)
const path = "/clusters/prod/addons/aws-load-balancer-controller/update"
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, path, body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, path, body))
}
func TestEKSUpdateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost,
"/clusters/prod/addons/aws-load-balancer-controller/update", updateAddonBody("")))
}
// Current behaviour: a store fault on the role lookup is returned before the
// PassRole check, so a caller without iam:PassRole sees the fault, not a denial.
func TestEKSCreateNodegroup_OutageIsReportedToACallerWithoutPassRole(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
iamService := eksPassRoleService(statement("Allow", "eks:*", "*"))
iamService.canonicalErr = storeErr
err := dispatchEKS(t, eksGatewayWithIAM(iamService), http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
+36 -99
View File
@@ -1,94 +1,45 @@
//test:in-package — drives the ELBv2 service through the gateway test helpers
// and the quota fixtures, none of which are reachable from outside the package.
package gateway
import (
"encoding/xml"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
// accountLimitsResponse is the wire shape a caller parses, so the test asserts
// on what leaves the gateway rather than on the struct behind it.
type accountLimitsResponse struct {
XMLName xml.Name `xml:"DescribeAccountLimitsResponse"`
Limits []struct {
Name string `xml:"Name"`
Max string `xml:"Max"`
} `xml:"DescribeAccountLimitsResult>Limits>member"`
}
// The service must accept the paging parameters the model defines and still
// answer with the whole list.
func TestELBv2DescribeAccountLimitsAcceptsPaging(t *testing.T) {
gw, account := quotaTestGateway(t)
// elbv2Request builds an authenticated Query request for body, as the SigV4
// middleware would hand it to the service.
func elbv2Request(accountID, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
ctx := reqctx.WithService(req.Context(), "elasticloadbalancing")
ctx = withPrincipalField(ctx, fieldAccountID, accountID)
return withTestIdentity(req.WithContext(ctx))
}
// serveAccountLimits runs DescribeAccountLimits through the service with live
// NATS, so the request is dispatched rather than stopped at the NATS guard.
func serveAccountLimits(t *testing.T, gw *GatewayConfig, accountID, body string) ([]byte, error) {
t.Helper()
_, nc := testutil.StartTestNATS(t)
gw.NATSConn = nc
gw.IAMService = allowAllIAMService()
withELBv2(gw)
w := httptest.NewRecorder()
if err := gw.serveELBv2(w, elbv2Request(accountID, body)); err != nil {
return nil, err
}
return w.Body.Bytes(), nil
}
// describeAccountLimits decodes the XML DescribeAccountLimits writes, keyed by
// limit name.
func describeAccountLimits(t *testing.T, gw *GatewayConfig, accountID string) map[string]string {
t.Helper()
body, err := serveAccountLimits(t, gw, accountID, "Action=DescribeAccountLimits")
body, err := serveAccountLimits(t, gw, account, "Action=DescribeAccountLimits&Marker=2&PageSize=1")
require.NoError(t, err)
var decoded accountLimitsResponse
require.NoError(t, xml.Unmarshal(body, &decoded))
require.NotEmpty(t, decoded.Limits)
byName := make(map[string]string, len(decoded.Limits))
for _, limit := range decoded.Limits {
byName[limit.Name] = limit.Max
}
return byName
assert.Len(t, decoded.Limits, len(elbv2awsapi.DescribeAccountLimits(&elbv2.DescribeAccountLimitsInput{}, 2).Limits))
assert.NotContains(t, string(body), "NextMarker")
}
// The reported cap must be the one CreateLoadBalancer enforces, including an
// override an operator has stored since the gateway started.
func TestELBv2DescribeAccountLimitsReportsStoredQuota(t *testing.T) {
gw, account := quotaTestGateway(t)
// A disabled quota means "not enforced", not "nothing permitted", so the entries
// report the AWS default rather than the unconsulted configured value.
func TestELBv2DescribeAccountLimitsDefaultsWhenQuotaDisabled(t *testing.T) {
disabled := quota.New(quota.Limits{LoadBalancers: 0}, nil)
byName := describeAccountLimits(t, gw, account)
assert.Equal(t, "2", byName["application-load-balancers"])
assert.Equal(t, "2", byName["network-load-balancers"])
require.NoError(t, quotaFor(gw).PutAccountQuota(t.Context(), account,
quota.Overrides{LoadBalancers: quotaPtr(9)}, "operator"))
byName = describeAccountLimits(t, gw, account)
assert.Equal(t, "9", byName["application-load-balancers"])
assert.Equal(t, "9", byName["network-load-balancers"])
for name, gw := range map[string]*GatewayConfig{
"disabled": withFakeCapabilities(withQuota(&GatewayConfig{DisableLogging: true}, disabled)),
"unwired": withFakeCapabilities(&GatewayConfig{DisableLogging: true}),
} {
t.Run(name, func(t *testing.T) {
byName := describeAccountLimits(t, gw, "000000000042")
assert.Equal(t, "50", byName["application-load-balancers"])
assert.Equal(t, "50", byName["network-load-balancers"])
})
}
}
// The limit must be resolved per request, so one account's override cannot be
@@ -106,21 +57,21 @@ func TestELBv2DescribeAccountLimitsIsPerAccount(t *testing.T) {
assert.Equal(t, "4", describeAccountLimits(t, gw, other)["application-load-balancers"])
}
// A disabled quota means "not enforced", not "nothing permitted", so the entries
// report the AWS default rather than the unconsulted configured value.
func TestELBv2DescribeAccountLimitsDefaultsWhenQuotaDisabled(t *testing.T) {
disabled := quota.New(quota.Limits{LoadBalancers: 0}, nil)
// The reported cap must be the one CreateLoadBalancer enforces, including an
// override an operator has stored since the gateway started.
func TestELBv2DescribeAccountLimitsReportsStoredQuota(t *testing.T) {
gw, account := quotaTestGateway(t)
for name, gw := range map[string]*GatewayConfig{
"disabled": withQuota(&GatewayConfig{DisableLogging: true}, disabled),
"unwired": {DisableLogging: true},
} {
t.Run(name, func(t *testing.T) {
byName := describeAccountLimits(t, gw, "000000000042")
assert.Equal(t, "50", byName["application-load-balancers"])
assert.Equal(t, "50", byName["network-load-balancers"])
})
}
byName := describeAccountLimits(t, gw, account)
assert.Equal(t, "2", byName["application-load-balancers"])
assert.Equal(t, "2", byName["network-load-balancers"])
require.NoError(t, quotaFor(gw).PutAccountQuota(t.Context(), account,
quota.Overrides{LoadBalancers: quotaPtr(9)}, "operator"))
byName = describeAccountLimits(t, gw, account)
assert.Equal(t, "9", byName["application-load-balancers"])
assert.Equal(t, "9", byName["network-load-balancers"])
}
// The limits Spinifex does not police are still advertised, so a caller does not
@@ -149,17 +100,3 @@ func TestELBv2DescribeAccountLimitsBlockedOnSealedRecord(t *testing.T) {
require.Equal(t, awserrors.ErrorBlocked, err.Error())
assert.Empty(t, body)
}
// The service must accept the paging parameters the model defines and still
// answer with the whole list.
func TestELBv2DescribeAccountLimitsAcceptsPaging(t *testing.T) {
gw, account := quotaTestGateway(t)
body, err := serveAccountLimits(t, gw, account, "Action=DescribeAccountLimits&Marker=2&PageSize=1")
require.NoError(t, err)
var decoded accountLimitsResponse
require.NoError(t, xml.Unmarshal(body, &decoded))
assert.Len(t, decoded.Limits, len(elbv2awsapi.DescribeAccountLimits(&elbv2.DescribeAccountLimitsInput{}, 2).Limits))
assert.NotContains(t, string(body), "NextMarker")
}
+2 -342
View File
@@ -1,325 +1,17 @@
// Characterization tests for ELBv2 Query-protocol dispatch as it exists in
// elbv2.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"encoding/xml"
"net/http"
"net/http/httptest"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"slices"
"testing"
)
var requestIDPattern = regexp.MustCompile(`<RequestId>[^<]*</RequestId>`)
// elbv2PipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS, with docs as every principal's policy.
func elbv2PipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, quota *admissionquota.Service) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withELBv2(withQuota(gw, quota))
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// signELBv2Request builds a signed Query-protocol POST for the
// elasticloadbalancing credential scope.
func signELBv2Request(t *testing.T, body string) *http.Request {
t.Helper()
b := []byte(body)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequestAs(t, req, b, testAccessKey, testSecretKey, "elasticloadbalancing", time.Now().UTC())
return req
}
// respondELBv2 answers subject with payload and counts invocations, so a
// denial can be proven to short-circuit before the handler ever runs.
func respondELBv2(t *testing.T, nc *nats.Conn, subject, payload string) *int {
t.Helper()
calls := 0
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
calls++
_ = msg.Respond([]byte(payload))
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
t.Cleanup(func() { _ = sub.Unsubscribe() })
return &calls
}
func normalizeRequestID(s string) string {
return requestIDPattern.ReplaceAllString(s, "<RequestId>ID</RequestId>")
}
// --- Full-pipeline error envelopes ---------------------------------------
func TestELBv2Pipeline_ErrorEnvelopes_GoldenXML(t *testing.T) {
cases := []struct {
name, body, code, message string
status int
}{
{"missing action", "", "MissingAction", "The request is missing an action or a required parameter.", http.StatusBadRequest},
{"unknown action", "Action=Bogus", "InvalidAction", "The action or operation requested is not valid. Verify that the action is typed correctly.", http.StatusBadRequest},
{"malformed query string", "Action=%zz", "MalformedQueryString", "The query string contains a syntax error.", http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signELBv2Request(t, tc.body))
require.Equal(t, tc.status, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Equal(t, `<?xml version="1.0" encoding="UTF-8"?>
<ErrorResponse>
<Error>
<Type>Sender</Type>
<Code>`+tc.code+`</Code>
<Message>`+tc.message+`</Message>
</Error>
<RequestId>ID</RequestId>
</ErrorResponse>`, normalizeRequestID(readBody(t, resp)))
})
}
}
// A handler failure with no AWS code is a 500 InternalError, here because no
// daemon answers the NATS subject.
func TestELBv2Pipeline_NoResponder_500InternalError(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeSSLPolicies"))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Contains(t, readBody(t, resp), "<Code>InternalError</Code>")
}
// --- Golden success responses ----------------------------------------------
// An action served without NATS answers 200 text/xml in the Query envelope.
func TestELBv2Pipeline_DescribeListenerAttributes_GoldenXML(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
listener := pipelineListener("web", "1", "2")
resp := doRequest(app, signELBv2Request(t, "Action=DescribeListenerAttributes&ListenerArn="+listener))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
assert.Equal(t, `<DescribeListenerAttributesResponse><DescribeListenerAttributesResult><Attributes></Attributes></DescribeListenerAttributesResult></DescribeListenerAttributesResponse>`,
readBody(t, resp))
}
func TestELBv2Pipeline_DescribeLoadBalancers_GoldenXML(t *testing.T) {
gw, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
calls := respondELBv2(t, gw.NATSConn, "elbv2.DescribeLoadBalancers",
`{"LoadBalancers":[{"LoadBalancerName":"web","LoadBalancerArn":"`+pipelineLB("web", "abc")+`","Type":"application"}]}`)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeLoadBalancers"))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
assert.Equal(t, 1, *calls)
var out struct {
LBs []struct {
ARN string `xml:"LoadBalancerArn"`
Name string `xml:"LoadBalancerName"`
Type string `xml:"Type"`
} `xml:"DescribeLoadBalancersResult>LoadBalancers>member"`
}
// Member field order varies between runs, so the result is pinned decoded.
require.NoError(t, xml.Unmarshal([]byte(readBody(t, resp)), &out))
require.Len(t, out.LBs, 1)
assert.Equal(t, pipelineLB("web", "abc"), out.LBs[0].ARN)
assert.Equal(t, "web", out.LBs[0].Name)
assert.Equal(t, "application", out.LBs[0].Type)
}
func TestELBv2Pipeline_DescribeAccountLimits_GoldenXML(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeAccountLimits"))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
var out struct {
Limits []struct {
Name string `xml:"Name"`
Max int `xml:"Max"`
} `xml:"DescribeAccountLimitsResult>Limits>member"`
}
require.NoError(t, xml.Unmarshal([]byte(readBody(t, resp)), &out))
got := map[string]int{}
for _, l := range out.Limits {
got[l.Name] = l.Max
}
// Member field order varies between runs, so the limits are pinned decoded.
assert.Equal(t, map[string]int{
"application-load-balancers": 50, "network-load-balancers": 50, "target-groups": 3000,
"target-groups-per-application-load-balancer": 100, "target-groups-per-action-on-application-load-balancer": 5,
"target-groups-per-action-on-network-load-balancer": 1, "listeners-per-application-load-balancer": 50,
"listeners-per-network-load-balancer": 50, "rules-per-application-load-balancer": 100,
"condition-values-per-alb-rule": 5, "condition-wildcards-per-alb-rule": 5,
"targets-per-application-load-balancer": 1000, "targets-per-network-load-balancer": 3000,
"targets-per-availability-zone-per-network-load-balancer": 500,
"certificates-per-application-load-balancer": 25, "certificates-per-network-load-balancer": 25,
}, got)
}
// --- Account quota on CreateLoadBalancer -------------------------------------
func TestELBv2Pipeline_CreateLoadBalancer_QuotaExceededStopsBeforeCreate(t *testing.T) {
quota := admissionquota.New(admissionquota.Limits{Enabled: true, LoadBalancers: 1}, nil)
gw, app := elbv2PipelineApp(t, allowAllTaggingDocs(), quota)
respondELBv2(t, gw.NATSConn, "elbv2.DescribeLoadBalancers",
`{"LoadBalancers":[{"LoadBalancerName":"web","LoadBalancerArn":"`+pipelineLB("web", "abc")+`"}]}`)
creates := respondELBv2(t, gw.NATSConn, "elbv2.CreateLoadBalancer", `{}`)
resp := doRequest(app, signELBv2Request(t, "Action=CreateLoadBalancer&Name=second"))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, readBody(t, resp), "<Code>ResourceLimitExceeded</Code>")
assert.Zero(t, *creates, "the create must not run once the quota refuses")
}
func TestELBv2Pipeline_CreateLoadBalancer_QuotaDisabledReachesCreate(t *testing.T) {
gw, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
respondELBv2(t, gw.NATSConn, "elbv2.DescribeLoadBalancers", `{"LoadBalancers":[]}`)
creates := respondELBv2(t, gw.NATSConn, "elbv2.CreateLoadBalancer", `{}`)
resp := doRequest(app, signELBv2Request(t, "Action=CreateLoadBalancer&Name=second"))
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, *creates)
}
// --- Authorization -----------------------------------------------------------
// A denied caller is answered AccessDenied before any NATS handler runs.
func TestELBv2Pipeline_Authz_DeniedStopsBeforeHandler(t *testing.T) {
docs := scopedTaggingDocs("Allow", "elasticloadbalancing:DeleteLoadBalancer", pipelineLB("other", "zzz"))
gw, app := elbv2PipelineApp(t, docs, nil)
calls := respondELBv2(t, gw.NATSConn, "elbv2.DeleteLoadBalancer", `{}`)
resp := doRequest(app, signELBv2Request(t, "Action=DeleteLoadBalancer&LoadBalancerArn="+pipelineLB("prod", "abc")))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body := readBody(t, resp)
assert.Contains(t, body, "<Code>AccessDenied</Code>")
assert.Zero(t, *calls)
}
// The Query input is parsed before authorization, so a request whose list is
// past the parser's cap is MalformedQueryString even for a caller who would be
// denied.
func TestELBv2Pipeline_Authz_ParseFailurePrecedesGate(t *testing.T) {
docs := scopedTaggingDocs("Allow", "ec2:DescribeTags", "*")
_, app := elbv2PipelineApp(t, docs, nil)
var b strings.Builder
b.WriteString("Action=AddTags")
for i := 1; i <= 100000; i++ {
b.WriteString("&ResourceArns.member." + strconv.Itoa(i) + "=x")
}
req := signELBv2Request(t, b.String())
resp := doRequest(app, req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, readBody(t, resp), "<Code>MalformedQueryString</Code>")
}
// An unrelated grant denies, and an unknown action is rejected before the gate.
func TestELBv2Pipeline_Authz_UnrelatedGrantDeniedUnknownActionNot(t *testing.T) {
_, app := elbv2PipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), nil)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeLoadBalancers"))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
resp = doRequest(app, signELBv2Request(t, "Action=Bogus"))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, readBody(t, resp), "<Code>InvalidAction</Code>")
}
// --- Unavailable dependencies -------------------------------------------------
// A nil NATS connection fails at the request-level gate, before dispatch.
func TestELBv2Pipeline_NilNATS_503ServiceUnavailable(t *testing.T) {
gw := withELBv2(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
req := elbv2Request(authzAccountID, "Action=DescribeLoadBalancers")
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Contains(t, normalizeRequestID(readBody(t, resp)), "<Code>ServiceUnavailable</Code>")
}
// --- Oversized body over HTTP -------------------------------------------------
func TestELBv2Pipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "elasticloadbalancing", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Contains(t, readBody(t, resp), "<Code>RequestEntityTooLarge</Code>")
}
// --- Pipeline metadata ---------------------------------------------------------
func TestELBv2Pipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
sr := tracetest.NewSpanRecorder()
prev := otel.GetTracerProvider()
@@ -348,35 +40,3 @@ func TestELBv2Pipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T)
assert.Equal(t, "DescribeListenerAttributes", got["aws.action"])
assert.Equal(t, "elasticloadbalancing", got["aws.service"])
}
func TestELBv2Pipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
listener := pipelineListener("web", "1", "2")
doRequest(r, signELBv2Request(t, "Action=DescribeListenerAttributes&ListenerArn="+listener))
require.NotNil(t, got)
assert.Equal(t, "DescribeListenerAttributes", auditField(got, "action"))
}
// The pipeline signs in testRegion, so ARNs must carry it to pass the
// endpoint-region check.
func pipelineLB(name, id string) string {
return arn.FormatELBv2LoadBalancer(testRegion, authzAccountID, name, id, "application")
}
func pipelineListener(lbName, lbID, id string) string {
return arn.FormatELBv2Listener(testRegion, authzAccountID, lbName, lbID, id, "application")
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"maps"
"net/http"
@@ -41,8 +42,8 @@ func registerTestCodes(t *testing.T, codes map[string]awserrors.ErrorMessage) {
func sdkConfig(url string) aws.Config {
return aws.Config{
Region: testRegion,
Credentials: credentials.NewStaticCredentialsProvider(testAccessKey, testSecretKey, ""),
Region: gwfix.Region,
Credentials: credentials.NewStaticCredentialsProvider(gwfix.AccessKey, gwfix.SecretKey, ""),
RetryMaxAttempts: 1,
BaseEndpoint: aws.String(url),
}
+2 -1
View File
@@ -6,6 +6,7 @@ package gateway
import (
"bytes"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
@@ -264,7 +265,7 @@ func TestErrorEnvelope_OversizeBody(t *testing.T) {
send := func(t *testing.T, app http.Handler, service string) *http.Response {
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, service, time.Now().UTC())
return doRequest(app, req)
}
c := &dispatchCapture{}
+6 -11
View File
@@ -29,6 +29,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/compress"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
@@ -38,15 +39,6 @@ import (
"go.opentelemetry.io/otel/trace"
)
// Values of callerPrincipal.principalType. Downstream handlers that interpret
// callerPrincipal.identity as an IAM user name MUST gate on principalTypeUser; otherwise a
// session whose SessionName collides with a user name inherits that user's policies.
const (
principalTypeUser = "user"
principalTypeAssumedRole = "assumed-role"
principalTypeRoot = "root"
)
// GatewayConfig is the AWS API gateway's shared state: its NATS connection, region and cluster
// facts, auth and throttling collaborators, and lazily built caches. Its methods are the
// per-service request handlers.
@@ -186,6 +178,11 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
auth.Use(gw.SigV4AuthMiddleware())
auth.Use(traceActionEnricher)
// Mounted inside this group, so the OCI Distribution registry above is
// excluded: it declares its own Content-Length and serves layers that
// are already compressed.
auth.Use(compress.Middleware)
// Post-auth, per-account+action token bucket throttle.
if gw.Throttler != nil {
auth.Use(gw.Throttler.Middleware(
@@ -321,7 +318,6 @@ func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
// Fail fast when NATS is down; every NATS-bound handler would otherwise hang
// until per-call timeout.
gw.fillDefaultCapabilities()
if gw.Readiness == nil || !gw.Readiness.Available() {
gw.writeClusterUnavailable(w, r, svc)
return
@@ -411,7 +407,6 @@ func (gw *GatewayConfig) checkPolicyResourcesWithKeys(r *http.Request, service,
// point with its resolved action, so telemetry enrichment lives here.
recordResolvedAction(r.Context(), service, action)
gw.fillDefaultCapabilities()
caller, authenticated := reqctx.Principal(r.Context())
return pipeline.Authorize(gw.Policy, r, caller, authenticated, service, action, resources, actionKeys)
}
+22 -381
View File
@@ -6,6 +6,7 @@ import (
"encoding/xml"
"errors"
"fmt"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
@@ -25,7 +26,6 @@ import (
"github.com/mulgadc/bluebottle/pkg/ratelimit"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
ochreawsapi "github.com/mulgadc/spinifex/spinifex/domains/ochre/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -655,81 +655,6 @@ func TestParseAWSQueryArgs_CapturedClientBodies(t *testing.T) {
}
}
func TestGetService(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
withServices(gw, taggingRegistration(nil), iamRegistration(gw), ec2Registration(gw))
tests := []struct {
name string
ctxVal string // service to record, empty means none
path string // request path, empty means "/"
wantSvc string
wantError string
}{
{
name: "no service in context",
wantError: awserrors.ErrorAuthFailure,
},
{
name: "unsupported service",
ctxVal: "s3",
wantError: awserrors.ErrorUnsupportedOperation,
},
{
name: "ec2 service",
ctxVal: "ec2",
wantSvc: "ec2",
},
{
name: "iam service",
ctxVal: "iam",
wantSvc: "iam",
},
{
name: "tagging service",
ctxVal: "tagging",
wantSvc: "tagging",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path := tc.path
if path == "" {
path = "/"
}
req := httptest.NewRequest(http.MethodPost, path, nil)
if tc.ctxVal != "" {
ctx := reqctx.WithService(req.Context(), tc.ctxVal)
req = req.WithContext(ctx)
}
svc, err := gw.GetService(req)
if tc.wantError != "" {
require.Error(t, err)
assert.Equal(t, tc.wantError, err.Error())
assert.Empty(t, svc)
} else {
require.NoError(t, err)
assert.Equal(t, tc.wantSvc, svc)
}
})
}
}
func TestRequest_NoServiceContext(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Contains(t, string(body), "AuthFailure")
}
func TestRequest_UnsupportedService(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
@@ -790,7 +715,7 @@ func TestRequest_EC2MissingAction(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
ctx := reqctx.WithService(req.Context(), "ec2")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -807,7 +732,7 @@ func TestRequest_IAMNilService(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=test"))
ctx := reqctx.WithService(req.Context(), "iam")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -967,219 +892,6 @@ func TestEC2Request_DescribeAvailabilityZones(t *testing.T) {
assert.Contains(t, string(body), "DescribeAvailabilityZonesResponse")
}
// TestCheckPolicy_FailsClosed covers the three states that once returned allow
// before any policy was consulted. A missing service is a server fault; a
// request with no resolved principal is denied.
func TestCheckPolicy_FailsClosed(t *testing.T) {
authed := func(identity string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, identity)
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
return req.WithContext(ctx)
}
cases := []struct {
name string
gw *GatewayConfig
req *http.Request
wantErr string
}{
{
name: "nil IAM service",
gw: withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: nil}),
req: authed("alice"),
wantErr: awserrors.ErrorInternalError,
},
{
name: "no auth context",
gw: withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}}),
req: httptest.NewRequest(http.MethodPost, "/", nil),
wantErr: awserrors.ErrorAccessDenied,
},
{
name: "empty identity",
gw: withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}}),
req: authed(""),
wantErr: awserrors.ErrorAccessDenied,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.gw.checkPolicy(tc.req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
})
}
}
// Shared authorization errors must not expose a canonical resource that was
// resolved from storage. STS opts into detailed wording at its dispatcher.
func TestCheckPolicyResources_DenialIsOpaque(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
resource := "arn:aws:iam::123456789012:role/private/target"
err := gw.checkPolicyResources(req, "iam", "GetRole", []string{resource})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
code, message, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
assert.Empty(t, message)
assert.NotContains(t, err.Error(), resource)
}
// TestCheckPolicy_PassRoleFailsClosed proves the change reaches a real gated
// call site: an unauthenticated RunInstances carrying an instance profile is
// denied rather than passing the iam:PassRole check.
func TestCheckPolicy_PassRoleFailsClosed(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
err := gw.checkPolicyResources(req, "iam", "PassRole",
[]string{"arn:aws:iam::123456789012:role/app"})
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_RootUserGlobalAccount(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000") // GlobalAccountID
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
}
// TestCheckPolicy_AssumedRoleSessionNamedRoot ensures the principal-type gate
// fires BEFORE the identity-string root short-circuit. A session whose
// SessionName is "root" must not inherit root privileges.
func TestCheckPolicy_AssumedRoleSessionNamedRoot(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeAssumedRole)
ctx = withPrincipalField(ctx, fieldAssumedRoleARN, "arn:aws:sts::000000000000:assumed-role/r/root")
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_NonRootAllowPolicy(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return []iamdomain.PolicyDocument{
{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
}
func TestCheckPolicy_NonRootDenyPolicy(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return []iamdomain.PolicyDocument{
{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Deny", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_NonRootNoPolicies(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_GetUserPoliciesError(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, errors.New("db connection failed")
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
func TestCheckPolicy_MissingAccountID(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
// No account ID
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
// shortenPolicyResolveRetry keeps the transient-NATS retries without paying
// the production backoff. Tests that call it must not run in parallel.
func shortenPolicyResolveRetry(t *testing.T) {
@@ -1189,77 +901,6 @@ func shortenPolicyResolveRetry(t *testing.T) {
t.Cleanup(func() { policyRetryBase = prev })
}
func TestCheckPolicy_NATSTransientRetriesAllAttempts(t *testing.T) {
shortenPolicyResolveRetry(t)
calls := 0
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
calls++
return nil, fmt.Errorf("get user: %w", nats.ErrNoResponders)
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
assert.Equal(t, 3, calls, "should have retried all 3 attempts")
}
func TestCheckPolicy_NATSTransientRetriesThenSucceeds(t *testing.T) {
shortenPolicyResolveRetry(t)
calls := 0
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
calls++
if calls < 3 {
return nil, fmt.Errorf("get user: %w", nats.ErrNoResponders)
}
return []iamdomain.PolicyDocument{
{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
assert.Equal(t, 3, calls, "should have retried until success")
}
func TestCheckPolicy_NonTransientErrorStillFails(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, errors.New("database corruption")
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
// --- Throttle middleware integration tests ---
func TestThrottleKeyFuncs_ExtractsAccountAndAction(t *testing.T) {
@@ -1378,25 +1019,6 @@ func TestWriteThrottleError_Bedrock(t *testing.T) {
assert.Equal(t, "RequestLimitExceededException", resp.Header.Get("X-Amzn-Errortype"))
}
func TestWriteSigV4Error_BedrockEmitsJSON(t *testing.T) {
gw := withServices(&GatewayConfig{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
req := httptest.NewRequest(http.MethodPost, "/model/meta.llama3-2-1b-instruct-v1%3A0/converse", nil)
req = req.WithContext(reqctx.WithService(req.Context(), "bedrock"))
w := httptest.NewRecorder()
gw.writeSigV4Error(w, req, awserrors.ErrorSignatureDoesNotMatch, "")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "a bedrock auth failure must be SDK-parseable JSON")
assert.Contains(t, string(body), "SignatureDoesNotMatch")
assert.NotContains(t, string(body), "<?xml")
// SDKs resolve the modelled exception type from this header, not the body.
assert.Equal(t, "SignatureDoesNotMatchException", resp.Header.Get("X-Amzn-Errortype"))
}
func TestThrottleMiddleware_Integration(t *testing.T) {
cfg := ratelimit.Config{
Enabled: true,
@@ -1557,3 +1179,22 @@ func TestRequest_ClusterUnavailableClosedConn(t *testing.T) {
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestWriteSigV4Error_BedrockEmitsJSON(t *testing.T) {
gw := withServices(&GatewayConfig{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
req := httptest.NewRequest(http.MethodPost, "/model/meta.llama3-2-1b-instruct-v1%3A0/converse", nil)
req = req.WithContext(reqctx.WithService(req.Context(), "bedrock"))
w := httptest.NewRecorder()
gw.writeSigV4Error(w, req, awserrors.ErrorSignatureDoesNotMatch, "")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "a bedrock auth failure must be SDK-parseable JSON")
assert.Contains(t, string(body), "SignatureDoesNotMatch")
assert.NotContains(t, string(body), "<?xml")
// SDKs resolve the modelled exception type from this header, not the body.
assert.Equal(t, "SignatureDoesNotMatchException", resp.Header.Get("X-Amzn-Errortype"))
}
File diff suppressed because it is too large Load Diff
+4 -263
View File
@@ -1,34 +1,20 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"reflect"
"slices"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http/httptest"
"reflect"
"slices"
"testing"
)
type iamExamplesFile struct {
Examples map[string][]iamExample `json:"examples"`
}
type iamExample struct {
Input json.RawMessage `json:"input"`
Output json.RawMessage `json:"output"`
Title string `json:"title"`
}
func TestIAMExamplesContract(t *testing.T) {
examples := loadIAMExamples(t)
covered := 0
@@ -91,248 +77,3 @@ func TestIAMExamplesContract(t *testing.T) {
require.NotZero(t, covered, "no IAM examples were covered — check testdata path")
}
func loadIAMExamples(t *testing.T) iamExamplesFile {
t.Helper()
data, err := os.ReadFile("testdata/aws/examples-1.json")
require.NoError(t, err)
var examples iamExamplesFile
require.NoError(t, json.Unmarshal(data, &examples))
require.NotEmpty(t, examples.Examples)
return examples
}
func unmarshalIAMExample(t *testing.T, action, field string, data json.RawMessage, target any) {
t.Helper()
if len(data) == 0 || string(data) == "null" {
return
}
require.NoError(t, json.Unmarshal(data, target), "%s example %s", action, field)
}
func normalizeEmptySlices(v reflect.Value) {
if !v.IsValid() {
return
}
if v.Kind() == reflect.Interface && !v.IsNil() {
v = v.Elem()
}
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
normalizeEmptySlices(v.Elem())
return
}
if v.Kind() != reflect.Struct {
return
}
for _, field := range v.Fields() {
switch field.Kind() {
case reflect.Slice:
if field.Len() == 0 && field.CanSet() {
field.Set(reflect.Zero(field.Type()))
}
for j := 0; j < field.Len(); j++ {
normalizeEmptySlices(field.Index(j))
}
case reflect.Pointer, reflect.Struct, reflect.Interface:
normalizeEmptySlices(field)
}
}
}
type echoIAMService struct {
flexMockIAMService
wantOutput any
gotAccountID string
gotInput any
}
func echoIAMCall[In any, Out any](svc *echoIAMService, accountID string, input *In) (*Out, error) {
svc.gotAccountID = accountID
svc.gotInput = input
if svc.wantOutput == nil {
return new(Out), nil
}
output, ok := svc.wantOutput.(*Out)
if !ok {
return nil, fmt.Errorf("echo IAM output type %T is not %T", svc.wantOutput, new(Out))
}
return output, nil
}
func (svc *echoIAMService) AddRoleToInstanceProfile(accountID string, input *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) {
return echoIAMCall[iam.AddRoleToInstanceProfileInput, iam.AddRoleToInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) AddUserToGroup(accountID string, input *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return echoIAMCall[iam.AddUserToGroupInput, iam.AddUserToGroupOutput](svc, accountID, input)
}
func (svc *echoIAMService) AttachGroupPolicy(accountID string, input *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return echoIAMCall[iam.AttachGroupPolicyInput, iam.AttachGroupPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) AttachRolePolicy(accountID string, input *iam.AttachRolePolicyInput) (*iam.AttachRolePolicyOutput, error) {
return echoIAMCall[iam.AttachRolePolicyInput, iam.AttachRolePolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) AttachUserPolicy(accountID string, input *iam.AttachUserPolicyInput) (*iam.AttachUserPolicyOutput, error) {
return echoIAMCall[iam.AttachUserPolicyInput, iam.AttachUserPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) {
return echoIAMCall[iam.CreateAccessKeyInput, iam.CreateAccessKeyOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateGroup(accountID string, input *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return echoIAMCall[iam.CreateGroupInput, iam.CreateGroupOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateInstanceProfile(accountID string, input *iam.CreateInstanceProfileInput) (*iam.CreateInstanceProfileOutput, error) {
return echoIAMCall[iam.CreateInstanceProfileInput, iam.CreateInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
return echoIAMCall[iam.CreateOpenIDConnectProviderInput, iam.CreateOpenIDConnectProviderOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateRole(accountID string, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) {
return echoIAMCall[iam.CreateRoleInput, iam.CreateRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateUser(accountID string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
return echoIAMCall[iam.CreateUserInput, iam.CreateUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
return echoIAMCall[iam.DeleteAccessKeyInput, iam.DeleteAccessKeyOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteGroupPolicy(accountID string, input *iam.DeleteGroupPolicyInput) (*iam.DeleteGroupPolicyOutput, error) {
return echoIAMCall[iam.DeleteGroupPolicyInput, iam.DeleteGroupPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteInstanceProfile(accountID string, input *iam.DeleteInstanceProfileInput) (*iam.DeleteInstanceProfileOutput, error) {
return echoIAMCall[iam.DeleteInstanceProfileInput, iam.DeleteInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteRole(accountID string, input *iam.DeleteRoleInput) (*iam.DeleteRoleOutput, error) {
return echoIAMCall[iam.DeleteRoleInput, iam.DeleteRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteRolePolicy(accountID string, input *iam.DeleteRolePolicyInput) (*iam.DeleteRolePolicyOutput, error) {
return echoIAMCall[iam.DeleteRolePolicyInput, iam.DeleteRolePolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
return echoIAMCall[iam.DeleteUserInput, iam.DeleteUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return echoIAMCall[iam.DeleteUserPolicyInput, iam.DeleteUserPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error) {
return echoIAMCall[iam.GetAccountSummaryInput, iam.GetAccountSummaryOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
return echoIAMCall[iam.ListAccountAliasesInput, iam.ListAccountAliasesOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) {
if _, ok := svc.wantOutput.(*iam.GetInstanceProfileOutput); !ok {
return &iam.GetInstanceProfileOutput{InstanceProfile: &iam.InstanceProfile{}}, nil
}
return echoIAMCall[iam.GetInstanceProfileInput, iam.GetInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return echoIAMCall[iam.GetRoleInput, iam.GetRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
return echoIAMCall[iam.GetUserInput, iam.GetUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListAccessKeys(accountID string, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
return echoIAMCall[iam.ListAccessKeysInput, iam.ListAccessKeysOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListGroupPolicies(accountID string, input *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return echoIAMCall[iam.ListGroupPoliciesInput, iam.ListGroupPoliciesOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListGroups(accountID string, input *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return echoIAMCall[iam.ListGroupsInput, iam.ListGroupsOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListGroupsForUser(accountID string, input *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return echoIAMCall[iam.ListGroupsForUserInput, iam.ListGroupsForUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListRoleTags(accountID string, input *iam.ListRoleTagsInput) (*iam.ListRoleTagsOutput, error) {
return echoIAMCall[iam.ListRoleTagsInput, iam.ListRoleTagsOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListUserTags(accountID string, input *iam.ListUserTagsInput) (*iam.ListUserTagsOutput, error) {
return echoIAMCall[iam.ListUserTagsInput, iam.ListUserTagsOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
return echoIAMCall[iam.ListUsersInput, iam.ListUsersOutput](svc, accountID, input)
}
func (svc *echoIAMService) PutGroupPolicy(accountID string, input *iam.PutGroupPolicyInput) (*iam.PutGroupPolicyOutput, error) {
return echoIAMCall[iam.PutGroupPolicyInput, iam.PutGroupPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) PutRolePolicy(accountID string, input *iam.PutRolePolicyInput) (*iam.PutRolePolicyOutput, error) {
return echoIAMCall[iam.PutRolePolicyInput, iam.PutRolePolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) PutUserPolicy(accountID string, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return echoIAMCall[iam.PutUserPolicyInput, iam.PutUserPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) RemoveRoleFromInstanceProfile(accountID string, input *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) {
return echoIAMCall[iam.RemoveRoleFromInstanceProfileInput, iam.RemoveRoleFromInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) RemoveUserFromGroup(accountID string, input *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return echoIAMCall[iam.RemoveUserFromGroupInput, iam.RemoveUserFromGroupOutput](svc, accountID, input)
}
func (svc *echoIAMService) TagRole(accountID string, input *iam.TagRoleInput) (*iam.TagRoleOutput, error) {
return echoIAMCall[iam.TagRoleInput, iam.TagRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) TagUser(accountID string, input *iam.TagUserInput) (*iam.TagUserOutput, error) {
return echoIAMCall[iam.TagUserInput, iam.TagUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) UntagRole(accountID string, input *iam.UntagRoleInput) (*iam.UntagRoleOutput, error) {
return echoIAMCall[iam.UntagRoleInput, iam.UntagRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) UntagUser(accountID string, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) {
return echoIAMCall[iam.UntagUserInput, iam.UntagUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) {
return echoIAMCall[iam.UpdateAccessKeyInput, iam.UpdateAccessKeyOutput](svc, accountID, input)
}
func (svc *echoIAMService) UpdateAssumeRolePolicy(accountID string, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) {
return echoIAMCall[iam.UpdateAssumeRolePolicyInput, iam.UpdateAssumeRolePolicyOutput](svc, accountID, input)
}
var _ iamdomain.IAMService = (*echoIAMService)(nil)
-51
View File
@@ -1,51 +0,0 @@
package gateway
import (
"context"
"net/http"
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// iamRegistration is the IAM registration over the IAMService, NATS connection
// and node count gw holds, as the awsgw role composes it. Set them on gw first.
func iamRegistration(gw *GatewayConfig) dispatch.Registration {
return iamawsapi.NewRegistration(iamawsapi.Deps{
IAM: gw.IAMService,
CountInstanceProfileAssociations: func(ctx context.Context, accountID, profileARN string) (int, error) {
return ec2instanceapi.CountInstanceProfileAssociations(ctx, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID, profileARN)
},
})
}
// withServices makes regs the gateway's registered services.
func withServices(gw *GatewayConfig, regs ...dispatch.Registration) *GatewayConfig {
b := dispatch.NewBuilder()
for _, reg := range regs {
if err := b.Register(reg); err != nil {
panic(err)
}
}
gw.Services = b.Build()
return gw
}
// withIAM registers IAM alone on gw.
func withIAM(gw *GatewayConfig) *GatewayConfig {
return withServices(gw, iamRegistration(gw))
}
// IAM_Request dispatches r to the registered IAM service exactly as Request
// does, registering it first from the fields gw holds when the test has not.
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withIAM(gw)
}
entry, ok := gw.registered(iamawsapi.ServiceName)
if !ok {
panic("IAM is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
+70 -268
View File
@@ -2,211 +2,23 @@ package gateway
import (
"errors"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flexMockIAMService is a configurable mock with per-method overrides. It
// embeds the interface so it satisfies the full contract; only the fn-field
// methods below are wired, and any other method nil-panics if a test reaches
// an unmocked path.
type flexMockIAMService struct {
iamdomain.IAMService
createUserFn func(string, *iam.CreateUserInput) (*iam.CreateUserOutput, error)
getUserFn func(string, *iam.GetUserInput) (*iam.GetUserOutput, error)
listUsersFn func(string, *iam.ListUsersInput) (*iam.ListUsersOutput, error)
deleteUserFn func(string, *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
createAccessKeyFn func(string, *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
listAccessKeysFn func(string, *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error)
deleteAccessKeyFn func(string, *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error)
updateAccessKeyFn func(string, *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error)
canonicalResourceARNFn func(string, arn.IAMResourceType, string) (string, error)
lookupAccessKeyFn func(string) (*iamdomain.AccessKey, error)
getAccountSummaryFn func(string, *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error)
getRoleFn func(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error)
}
// GetRole answers NoSuchEntity by default: the STS identity gate resolves the
// target role, and a mock that invented one would let a test pass against a
// role the account does not hold.
func (m *flexMockIAMService) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
if m.getRoleFn != nil {
return m.getRoleFn(accountID, input)
}
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
func (m *flexMockIAMService) CreateUser(accountID string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
if m.createUserFn != nil {
return m.createUserFn(accountID, input)
}
return &iam.CreateUserOutput{}, nil
}
func (m *flexMockIAMService) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
if m.getUserFn != nil {
return m.getUserFn(accountID, input)
}
return &iam.GetUserOutput{}, nil
}
func (m *flexMockIAMService) ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
if m.listUsersFn != nil {
return m.listUsersFn(accountID, input)
}
return &iam.ListUsersOutput{}, nil
}
func (m *flexMockIAMService) DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
if m.deleteUserFn != nil {
return m.deleteUserFn(accountID, input)
}
return &iam.DeleteUserOutput{}, nil
}
func (m *flexMockIAMService) CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) {
if m.createAccessKeyFn != nil {
return m.createAccessKeyFn(accountID, input)
}
return &iam.CreateAccessKeyOutput{}, nil
}
func (m *flexMockIAMService) ListAccessKeys(accountID string, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
if m.listAccessKeysFn != nil {
return m.listAccessKeysFn(accountID, input)
}
return &iam.ListAccessKeysOutput{}, nil
}
func (m *flexMockIAMService) DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
if m.deleteAccessKeyFn != nil {
return m.deleteAccessKeyFn(accountID, input)
}
return &iam.DeleteAccessKeyOutput{}, nil
}
func (m *flexMockIAMService) UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) {
if m.updateAccessKeyFn != nil {
return m.updateAccessKeyFn(accountID, input)
}
return &iam.UpdateAccessKeyOutput{}, nil
}
func (m *flexMockIAMService) CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error) {
if m.canonicalResourceARNFn != nil {
return m.canonicalResourceARNFn(accountID, kind, name)
}
return arn.FormatIAMPath(kind, accountID, "/", name), nil
}
func (m *flexMockIAMService) LookupAccessKey(accessKeyID string) (*iamdomain.AccessKey, error) {
if m.lookupAccessKeyFn != nil {
return m.lookupAccessKeyFn(accessKeyID)
}
return &iamdomain.AccessKey{AccessKeyID: accessKeyID, AccountID: "000000000000", UserName: "user"}, nil
}
func (m *flexMockIAMService) GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error) {
if m.getAccountSummaryFn != nil {
return m.getAccountSummaryFn(accountID, input)
}
return &iam.GetAccountSummaryOutput{}, nil
}
func (m *flexMockIAMService) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
return &iam.ListAccountAliasesOutput{}, nil
}
func (m *flexMockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
func (m *flexMockIAMService) GetGroup(_ string, _ *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
return &iam.GetGroupOutput{}, nil
}
func (m *flexMockIAMService) ListGroups(_ string, _ *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return &iam.ListGroupsOutput{}, nil
}
func (m *flexMockIAMService) DeleteGroup(_ string, _ *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
return &iam.DeleteGroupOutput{}, nil
}
func (m *flexMockIAMService) AddUserToGroup(_ string, _ *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return &iam.AddUserToGroupOutput{}, nil
}
func (m *flexMockIAMService) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (m *flexMockIAMService) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return &iam.ListGroupsForUserOutput{}, nil
}
func (m *flexMockIAMService) AttachGroupPolicy(_ string, _ *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return &iam.AttachGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
return &iam.DetachGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
return &iam.ListAttachedGroupPoliciesOutput{}, nil
}
func (m *flexMockIAMService) PutGroupPolicy(_ string, _ *iam.PutGroupPolicyInput) (*iam.PutGroupPolicyOutput, error) {
return &iam.PutGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) GetGroupPolicy(_ string, _ *iam.GetGroupPolicyInput) (*iam.GetGroupPolicyOutput, error) {
return &iam.GetGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInput) (*iam.DeleteGroupPolicyOutput, error) {
return &iam.DeleteGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return &iam.ListGroupPoliciesOutput{}, nil
}
func (m *flexMockIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return &iam.PutUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
return &iam.GetUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return &iam.DeleteUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
return &iam.ListUserPoliciesOutput{}, nil
}
// setupIAMRequestHandler wires an http.Handler for IAM_Request with injected SigV4 context values.
func setupIAMRequestHandler(svc iamdomain.IAMService) http.Handler {
gw := &GatewayConfig{
DisableLogging: true,
IAMService: svc,
Credentials: credverify.New(svc, nil),
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = reqctx.WithService(ctx, "iam")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000")
ctx = withPrincipalField(ctx, fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
r = r.WithContext(ctx)
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
}
func TestIAMRequest_CreateUser_Success(t *testing.T) {
svc := &flexMockIAMService{
createUserFn: func(_ string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
@@ -234,31 +46,17 @@ func TestIAMRequest_CreateUser_Success(t *testing.T) {
assert.Contains(t, xmlStr, "alice")
}
func TestIAMRequest_ListUsers_Success(t *testing.T) {
svc := &flexMockIAMService{
listUsersFn: func(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
return &iam.ListUsersOutput{
Users: []*iam.User{
{UserName: aws.String("alice"), UserId: aws.String("AID1")},
{UserName: aws.String("bob"), UserId: aws.String("AID2")},
},
IsTruncated: aws.Bool(false),
}, nil
},
}
handler := setupIAMRequestHandler(svc)
func TestIAMRequest_EmptyAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ListUsers"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("UserName=alice"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
xmlStr := string(body)
assert.Contains(t, xmlStr, "ListUsersResult")
assert.Contains(t, xmlStr, "alice")
assert.Contains(t, xmlStr, "bob")
assert.Contains(t, string(body), "MissingAction")
}
// Locks in the xmlutil map behaviour: SummaryMap (map[string]*int64) must
@@ -290,14 +88,28 @@ func TestIAMRequest_GetAccountSummary_Success(t *testing.T) {
assert.Contains(t, xmlStr, "<value>2</value>")
}
// realAliasesIAMService answers ListAccountAliases from the real service, which
// needs no storage, so the test sees the response the gateway actually sends.
type realAliasesIAMService struct {
flexMockIAMService
}
// AWS answers GetRole for a never-used role with an empty <RoleLastUsed/>
// element; a nil-field struct must still marshal to that element, not vanish.
func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) {
svc := &flexMockIAMService{
getRoleFn: func(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
AssumeRolePolicyDocument: aws.String("{}"),
RoleLastUsed: &iam.RoleLastUsed{},
}}, nil
},
}
handler := setupIAMRequestHandler(svc)
func (realAliasesIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
return (&iamdomain.IAMServiceImpl{}).ListAccountAliases(accountID, input)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetRole&RoleName=app"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "<RoleLastUsed></RoleLastUsed>")
}
// AWS answers an account with no alias with an empty AccountAliases list and
@@ -324,54 +136,55 @@ func TestIAMRequest_ListAccountAliases_Empty(t *testing.T) {
assert.Contains(t, err.Error(), awserrors.ErrorValidationError)
}
// AWS answers GetRole for a never-used role with an empty <RoleLastUsed/>
// element; a nil-field struct must still marshal to that element, not vanish.
func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) {
func TestIAMRequest_ListUsers_Success(t *testing.T) {
svc := &flexMockIAMService{
getRoleFn: func(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
AssumeRolePolicyDocument: aws.String("{}"),
RoleLastUsed: &iam.RoleLastUsed{},
}}, nil
listUsersFn: func(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
return &iam.ListUsersOutput{
Users: []*iam.User{
{UserName: aws.String("alice"), UserId: aws.String("AID1")},
{UserName: aws.String("bob"), UserId: aws.String("AID2")},
},
IsTruncated: aws.Bool(false),
}, nil
},
}
handler := setupIAMRequestHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetRole&RoleName=app"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ListUsers"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "<RoleLastUsed></RoleLastUsed>")
xmlStr := string(body)
assert.Contains(t, xmlStr, "ListUsersResult")
assert.Contains(t, xmlStr, "alice")
assert.Contains(t, xmlStr, "bob")
}
func TestIAMRequest_UnknownAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
func TestIAMRequest_MissingAccountID(t *testing.T) {
gw := &GatewayConfig{
DisableLogging: true,
IAMService: &flexMockIAMService{},
Credentials: credverify.New(&flexMockIAMService{}, nil),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "iam")
r = withTestIdentity(r.WithContext(ctx))
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=DoesNotExist"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=alice"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
assert.Equal(t, 500, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "InvalidAction")
}
func TestIAMRequest_EmptyAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("UserName=alice"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "MissingAction")
assert.Contains(t, string(body), "InternalError")
}
func TestIAMRequest_NilService(t *testing.T) {
@@ -385,7 +198,7 @@ func TestIAMRequest_NilService(t *testing.T) {
ctx = reqctx.WithService(ctx, "iam")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000")
ctx = withPrincipalField(ctx, fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
r = r.WithContext(ctx)
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
@@ -422,28 +235,17 @@ func TestIAMRequest_ServiceError(t *testing.T) {
assert.Contains(t, xmlStr, "<ErrorResponse>")
}
func TestIAMRequest_MissingAccountID(t *testing.T) {
gw := &GatewayConfig{
DisableLogging: true,
IAMService: &flexMockIAMService{},
Credentials: credverify.New(&flexMockIAMService{}, nil),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "iam")
r = withTestIdentity(r.WithContext(ctx))
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
func TestIAMRequest_UnknownAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=alice"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=DoesNotExist"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
assert.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "InternalError")
assert.Contains(t, string(body), "InvalidAction")
}
func TestIAMRequest_ValidationError(t *testing.T) {
+192 -347
View File
@@ -1,337 +1,18 @@
// Characterization tests for the spinifex Query actions and the /admin REST-JSON
// surface as they dispatch today, pinned ahead of moving both behind one
// registration. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/internal/testkit"
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/stretchr/testify/require"
"net/http"
"net/url"
"strings"
"testing"
)
// operatorIAM adds the account listing /admin/ListAccounts reads to the policy
// mock, which leaves it to a nil embedded interface.
type operatorIAM struct {
*policyMockIAMService
}
func (operatorIAM) ListAccounts() ([]*iamdomain.Account, error) {
return []*iamdomain.Account{
{AccountID: "000000000001", AccountName: "spinifex", Status: "ACTIVE", CreatedAt: "2026-01-02T03:04:05Z"},
{AccountID: "000000000002", AccountName: "tenant", Status: "ACTIVE", CreatedAt: "2026-02-03T04:05:06Z"},
}, nil
}
// operatorPipelineApp wires SigV4AuthMiddleware -> the two operator routes as
// SetupRoutes does, on live test NATS, with docs as every principal's policy.
// accountID owns the signing key; the region is testRegion because
// signTestRequestAs always signs for it.
func operatorPipelineApp(t *testing.T, accountID string, docs []iamdomain.PolicyDocument, mutate func(*GatewayConfig)) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
Version: "v9.9.9-test",
Commit: "abc1234",
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "admin",
AccountID: accountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
gw.IAMService = operatorIAM{gw.IAMService.(*policyMockIAMService)}
if mutate != nil {
mutate(gw)
}
withOperator(gw)
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.Use(traceActionEnricher)
r.HandleFunc("/admin/{method}", gw.Admin_Request)
r.HandleFunc("/*", gw.Request)
return gw, r
}
func allowAllOperatorDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signOperatorRequest builds a signed request for the spinifex scope (or any
// scope named), the shape the CLI and the operator tooling send.
func signOperatorRequest(t *testing.T, method, path, service string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Host = "localhost:9999"
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, service, time.Now().UTC())
return req
}
func spinifexQuery(t *testing.T, action string, args url.Values) *http.Request {
t.Helper()
if args == nil {
args = url.Values{}
}
if action != "" {
args.Set("Action", action)
}
body := []byte(args.Encode())
req := signOperatorRequest(t, http.MethodPost, "/", "spinifex", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
// ec2ErrorCode reads the code out of the EC2 <Response><Errors> envelope the
// spinifex Query service renders its failures in.
func ec2ErrorCode(t *testing.T, resp *http.Response) string {
t.Helper()
var body struct {
Code string `xml:"Errors>Error>Code"`
RequestID string `xml:"RequestID"`
}
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, xml.Unmarshal(raw, &body), string(raw))
require.NotEmpty(t, body.RequestID, "the EC2 envelope carries its request ID in the body")
return body.Code
}
type operatorAdminError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
RequestID string `json:"requestId"`
}
// adminError decodes the /admin JSON envelope and checks the request ID is on
// both the header and the body, which is how a client ties a failure to a log line.
func adminError(t *testing.T, resp *http.Response) operatorAdminError {
t.Helper()
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var body operatorAdminError
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.NotEmpty(t, body.RequestID)
require.Equal(t, body.RequestID, resp.Header.Get("X-Amzn-Requestid"))
return body
}
func opBody(t *testing.T, resp *http.Response) string {
t.Helper()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(raw)
}
// --- Query surface ---------------------------------------------------------
// Successes are JSON with application/json even though the service is a
// Query-protocol one, and no request ID header is set on this surface.
func TestOperatorQuery_GetVersion_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.Empty(t, resp.Header.Get("X-Amzn-Requestid"))
require.JSONEq(t,
`{"version":"v9.9.9-test","commit":"abc1234","os":"linux","arch":"amd64","license":"open-source"}`,
opBody(t, resp))
}
// With no node answering, discovery falls back to the expected-node count and
// the listings are empty rather than an error.
func TestOperatorQuery_NodeAndVMListingsWithNoNodes(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetNodes", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"nodes":[],"cluster_mode":"single-node"}`, opBody(t, resp))
resp = doRequest(app, spinifexQuery(t, "GetVMs", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"vms":[]}`, opBody(t, resp))
}
// A storage-config request nobody answers surfaces as the unmapped-error
// InternalError, not a distinct unavailable code.
func TestOperatorQuery_GetStorageStatusWithNoResponder(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetStorageStatus", nil))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
require.Equal(t, "InternalError", ec2ErrorCode(t, resp))
}
func TestOperatorQuery_ListOchreCatalog_ReturnsEntries(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "ListOchreCatalog", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var out struct {
Entries []map[string]any `json:"entries"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.NotEmpty(t, out.Entries)
require.Contains(t, out.Entries[0], "modelId")
}
// Every action named in spinifexAdminActions refuses a caller outside the
// super-admin account, and refuses it with the same AccessDenied.
func TestOperatorQuery_EveryAdminActionDeniesNonAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
for action := range spinifexAdminActions {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
// The policy gate runs first: a super-admin caller with no spinifex:<Action>
// grant is denied, whatever the action.
func TestOperatorQuery_PolicyDenialBeatsEverythingForTheAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), nil, nil)
for _, action := range []string{"GetVersion", "PromoteImage", "NotAnAction"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
// An action outside the admin set is not account-gated, so a non-admin caller
// holding the policy gets the unknown-action answer, not a denial.
func TestOperatorQuery_UnknownActionAndMissingAction(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "NotAnAction", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", ec2ErrorCode(t, resp))
resp = doRequest(app, spinifexQuery(t, "", url.Values{"Version": {"1"}}))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingAction", ec2ErrorCode(t, resp))
}
func TestOperatorQuery_MalformedQueryString(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, signOperatorRequest(t, http.MethodPost, "/", "spinifex", []byte("Action=%zz")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MalformedQueryString", ec2ErrorCode(t, resp))
}
func TestOperatorQuery_RequiredParameters(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "PromoteImage", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingParameter", ec2ErrorCode(t, resp))
}
// Without a grant store the model-access actions answer ServerInternal before
// they look at their parameters.
func TestOperatorQuery_ModelAccessWithoutStoreIsServerInternal(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
for _, action := range []string{"GrantModelAccess", "RevokeModelAccess", "ListModelAccess"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, url.Values{"AccountId": {"000000000002"}, "ModelId": {"m"}}))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "ServerInternal", ec2ErrorCode(t, resp))
})
}
}
// The cluster-unavailable check is in the shared Request path, ahead of
// authorization, so even a caller with no grant sees 503 when NATS is down.
func TestOperatorQuery_NATSDownAnswers503BeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, func(gw *GatewayConfig) { gw.NATSConn = nil })
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
require.Equal(t, "ServiceUnavailable", ec2ErrorCode(t, resp))
}
// --- /admin surface --------------------------------------------------------
func adminCall(t *testing.T, app http.Handler, method, name, service string, body string) *http.Response {
t.Helper()
return doRequest(app, signOperatorRequest(t, method, "/admin/"+name, service, []byte(body)))
}
func TestOperatorAdmin_ListAccounts_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", `{}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.NotEmpty(t, resp.Header.Get("X-Amzn-Requestid"), "success carries the request ID header too")
require.JSONEq(t, `{"accounts":[`+
`{"accountId":"000000000001","accountName":"spinifex","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},`+
`{"accountId":"000000000002","accountName":"tenant","status":"ACTIVE","createdAt":"2026-02-03T04:05:06Z"}],`+
`"count":2}`, opBody(t, resp))
}
func TestOperatorAdmin_GetAccountQuota_DefaultsComeFromConfig(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *GatewayConfig) {
withQuota(gw, admissionquota.New(admissionquota.Limits{Enabled: true}, nil))
})
resp := adminCall(t, app, http.MethodPost, "GetAccountQuota", "spinifex", `{"accountId":"000000000002"}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
var out struct {
AccountID string `json:"accountId"`
Source map[string]string `json:"source"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.Equal(t, "000000000002", out.AccountID)
require.Equal(t, "config", out.Source["vcpus"])
}
// An empty or non-JSON body is InvalidRequest on every body-reading method
// that parses one, and MissingParameter where a required field is absent.
func TestOperatorAdmin_BodyErrors(t *testing.T) {
@@ -360,30 +41,6 @@ func TestOperatorAdmin_BodyErrors(t *testing.T) {
}
}
func TestOperatorAdmin_OversizedBodyIsInvalidRequest(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", strings.Repeat("a", 9000))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
e := adminError(t, resp)
require.Equal(t, "InvalidRequest", e.Error.Code)
require.Equal(t, "request body exceeds 8 KiB", e.Error.Message)
}
// The method is checked before authorization, so an unknown name and a
// non-POST verb answer the same way for a caller with no grant at all.
func TestOperatorAdmin_UnknownMethodAndVerbBeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, nil)
resp := adminCall(t, app, http.MethodPost, "PurgeEverything", "spinifex", `{}`)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", adminError(t, resp).Error.Code)
resp = adminCall(t, app, http.MethodGet, "ListAccounts", "spinifex", "")
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
require.Equal(t, "MethodNotAllowed", adminError(t, resp).Error.Code)
}
// Each gate denies with the same AccessDenied: wrong credential scope, wrong
// account, and a super-admin user holding no spinifex:<Method> grant.
func TestOperatorAdmin_EveryGateDeniesWithTheSameAnswer(t *testing.T) {
@@ -412,6 +69,35 @@ func TestOperatorAdmin_EveryGateDeniesWithTheSameAnswer(t *testing.T) {
}
}
func TestOperatorAdmin_GetAccountQuota_DefaultsComeFromConfig(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *GatewayConfig) {
withQuota(gw, admissionquota.New(admissionquota.Limits{Enabled: true}, nil))
})
resp := adminCall(t, app, http.MethodPost, "GetAccountQuota", "spinifex", `{"accountId":"000000000002"}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
var out struct {
AccountID string `json:"accountId"`
Source map[string]string `json:"source"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.Equal(t, "000000000002", out.AccountID)
require.Equal(t, "config", out.Source["vcpus"])
}
func TestOperatorAdmin_ListAccounts_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", `{}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.NotEmpty(t, resp.Header.Get("X-Amzn-Requestid"), "success carries the request ID header too")
require.JSONEq(t, `{"accounts":[`+
`{"accountId":"000000000001","accountName":"spinifex","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},`+
`{"accountId":"000000000002","accountName":"tenant","status":"ACTIVE","createdAt":"2026-02-03T04:05:06Z"}],`+
`"count":2}`, opBody(t, resp))
}
// Unlike the Query surface, /admin authorizes first and only then checks the
// cluster, so NATS down is visible only to an authorized caller.
func TestOperatorAdmin_NATSDownAnswers503OnlyAfterAuthorization(t *testing.T) {
@@ -429,6 +115,16 @@ func TestOperatorAdmin_NATSDownAnswers503OnlyAfterAuthorization(t *testing.T) {
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestOperatorAdmin_OversizedBodyIsInvalidRequest(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", strings.Repeat("a", 9000))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
e := adminError(t, resp)
require.Equal(t, "InvalidRequest", e.Error.Code)
require.Equal(t, "request body exceeds 8 KiB", e.Error.Message)
}
// Every request, success or failure, gets its own request ID.
func TestOperatorAdmin_RequestIDIsUniquePerRequest(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
@@ -438,3 +134,152 @@ func TestOperatorAdmin_RequestIDIsUniquePerRequest(t *testing.T) {
require.NotEmpty(t, first)
require.NotEqual(t, first, second)
}
// The method is checked before authorization, so an unknown name and a
// non-POST verb answer the same way for a caller with no grant at all.
func TestOperatorAdmin_UnknownMethodAndVerbBeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, nil)
resp := adminCall(t, app, http.MethodPost, "PurgeEverything", "spinifex", `{}`)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", adminError(t, resp).Error.Code)
resp = adminCall(t, app, http.MethodGet, "ListAccounts", "spinifex", "")
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
require.Equal(t, "MethodNotAllowed", adminError(t, resp).Error.Code)
}
// Every action named in spinifexAdminActions refuses a caller outside the
// super-admin account, and refuses it with the same AccessDenied.
func TestOperatorQuery_EveryAdminActionDeniesNonAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
for action := range spinifexAdminActions {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
// A storage-config request nobody answers surfaces as the unmapped-error
// InternalError, not a distinct unavailable code.
func TestOperatorQuery_GetStorageStatusWithNoResponder(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetStorageStatus", nil))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
require.Equal(t, "InternalError", ec2ErrorCode(t, resp))
}
// Successes are JSON with application/json even though the service is a
// Query-protocol one, and no request ID header is set on this surface.
func TestOperatorQuery_GetVersion_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.Empty(t, resp.Header.Get("X-Amzn-Requestid"))
require.JSONEq(t,
`{"version":"v9.9.9-test","commit":"abc1234","os":"linux","arch":"amd64","license":"open-source"}`,
opBody(t, resp))
}
func TestOperatorQuery_ListOchreCatalog_ReturnsEntries(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "ListOchreCatalog", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var out struct {
Entries []map[string]any `json:"entries"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.NotEmpty(t, out.Entries)
require.Contains(t, out.Entries[0], "modelId")
}
func TestOperatorQuery_MalformedQueryString(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, signOperatorRequest(t, http.MethodPost, "/", "spinifex", []byte("Action=%zz")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MalformedQueryString", ec2ErrorCode(t, resp))
}
// Without a grant store the model-access actions answer ServerInternal before
// they look at their parameters.
func TestOperatorQuery_ModelAccessWithoutStoreIsServerInternal(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
for _, action := range []string{"GrantModelAccess", "RevokeModelAccess", "ListModelAccess"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, url.Values{"AccountId": {"000000000002"}, "ModelId": {"m"}}))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "ServerInternal", ec2ErrorCode(t, resp))
})
}
}
// The cluster-unavailable check is in the shared Request path, ahead of
// authorization, so even a caller with no grant sees 503 when NATS is down.
func TestOperatorQuery_NATSDownAnswers503BeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, func(gw *GatewayConfig) { gw.NATSConn = nil })
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
require.Equal(t, "ServiceUnavailable", ec2ErrorCode(t, resp))
}
// With no node answering, discovery falls back to the expected-node count and
// the listings are empty rather than an error.
func TestOperatorQuery_NodeAndVMListingsWithNoNodes(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetNodes", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"nodes":[],"cluster_mode":"single-node"}`, opBody(t, resp))
resp = doRequest(app, spinifexQuery(t, "GetVMs", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"vms":[]}`, opBody(t, resp))
}
// The policy gate runs first: a super-admin caller with no spinifex:<Action>
// grant is denied, whatever the action.
func TestOperatorQuery_PolicyDenialBeatsEverythingForTheAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), nil, nil)
for _, action := range []string{"GetVersion", "PromoteImage", "NotAnAction"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
func TestOperatorQuery_RequiredParameters(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "PromoteImage", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingParameter", ec2ErrorCode(t, resp))
}
// An action outside the admin set is not account-gated, so a non-admin caller
// holding the policy gets the unknown-action answer, not a denial.
func TestOperatorQuery_UnknownActionAndMissingAction(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "NotAnAction", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", ec2ErrorCode(t, resp))
resp = doRequest(app, spinifexQuery(t, "", url.Values{"Version": {"1"}}))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingAction", ec2ErrorCode(t, resp))
}
@@ -2,72 +2,35 @@ package gateway
import (
"bufio"
"bytes"
"encoding/json"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
streamAccount = "000000000002"
streamInstance = "i-0123456789abcdef0"
)
func TestOperatorQuery_GetInstanceMetrics_PolicyOnAnotherInstanceIsDenied(t *testing.T) {
otherARN := "arn:aws:ec2:" + gwfix.Region + ":" + streamAccount + ":instance/i-0fffffffffffffff0"
gw, app := operatorPipelineApp(t, streamAccount, instanceMetricsDocs(otherARN), nil)
answerWatches(t, gw.NATSConn)
srv := httptest.NewServer(app)
t.Cleanup(srv.Close)
func instanceMetricsDocs(resource string) []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"spinifex:GetInstanceMetrics"}, Resource: iamdomain.StringOrArr{resource}},
},
}}
}
// answerWatches replies to the instance's watch with one sample, as the
// owning node's collector would.
func answerWatches(t *testing.T, nc *nats.Conn) {
t.Helper()
ring, err := json.Marshal(telemetryv1.LiveRing{Samples: []telemetryv1.LiveSample{{TimeMillis: 1000, CPUPercent: 7}}})
require.NoError(t, err)
sub, err := nc.Subscribe(telemetryv1.LiveWatchSubject(streamAccount, streamInstance), func(msg *nats.Msg) {
if msg.Reply != "" {
_ = msg.Respond(ring)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
// postSignedMetrics sends a signed GetInstanceMetrics over real HTTP, so the
// response can be read while the stream is still open.
func postSignedMetrics(t *testing.T, srv *httptest.Server) *http.Response {
t.Helper()
body := []byte(url.Values{"Action": {"GetInstanceMetrics"}, "InstanceId": {streamInstance}}.Encode())
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL+"/", bytes.NewReader(body))
require.NoError(t, err)
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "spinifex", time.Now().UTC())
resp, err := srv.Client().Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
return resp
resp := postSignedMetrics(t, srv)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, awserrors.ErrorAccessDenied, ec2ErrorCode(t, resp))
}
// Through the signed pipeline, a tenant's policy naming the instance's ARN is
// enough, and the first sample arrives while the stream is still open.
func TestOperatorQuery_GetInstanceMetrics_StreamsThroughThePipeline(t *testing.T) {
instanceARN := "arn:aws:ec2:" + testRegion + ":" + streamAccount + ":instance/" + streamInstance
instanceARN := "arn:aws:ec2:" + gwfix.Region + ":" + streamAccount + ":instance/" + streamInstance
gw, app := operatorPipelineApp(t, streamAccount, instanceMetricsDocs(instanceARN), nil)
answerWatches(t, gw.NATSConn)
srv := httptest.NewServer(app)
@@ -96,15 +59,3 @@ func TestOperatorQuery_GetInstanceMetrics_StreamsThroughThePipeline(t *testing.T
t.Fatal("no sample was flushed through the pipeline")
}
}
func TestOperatorQuery_GetInstanceMetrics_PolicyOnAnotherInstanceIsDenied(t *testing.T) {
otherARN := "arn:aws:ec2:" + testRegion + ":" + streamAccount + ":instance/i-0fffffffffffffff0"
gw, app := operatorPipelineApp(t, streamAccount, instanceMetricsDocs(otherARN), nil)
answerWatches(t, gw.NATSConn)
srv := httptest.NewServer(app)
t.Cleanup(srv.Close)
resp := postSignedMetrics(t, srv)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, awserrors.ErrorAccessDenied, ec2ErrorCode(t, resp))
}
@@ -1,42 +0,0 @@
package gateway
import (
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
operatoradmin "github.com/mulgadc/spinifex/spinifex/operator/admin"
)
// spinifexAdminActions is the set of super-admin Query actions the spinifex
// registration serves, which the characterization tests walk.
var spinifexAdminActions = func() map[string]bool {
m := map[string]bool{}
for _, name := range operatoradmin.AdminActionNames() {
m[name] = true
}
return m
}()
// withOperator registers the spinifex Query service on gw from the fields gw
// holds, as awsgw composes it.
func withOperator(gw *GatewayConfig) *GatewayConfig {
b := dispatch.NewBuilder()
deps := operatoradmin.Deps{
Version: gw.Version,
Commit: gw.Commit,
NATS: gw.NATSConn,
RootCAs: gw.RootCAs,
ActiveNodes: gw.DiscoverActiveNodes,
IAM: gw.IAMService,
Quota: quotaFor(gw),
ExpectedNodes: gw.ExpectedNodes,
}
reg := operatoradmin.NewRegistration(deps)
if err := b.Register(reg); err != nil {
panic(err)
}
if err := b.Register(ec2Registration(gw)); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
-143
View File
@@ -1,143 +0,0 @@
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// passRoleTo is a PassRole statement on every role, conditioned on the
// consuming service the way AWS's documented examples scope it.
func passRoleTo(effect, operator, service string) iamdomain.Statement {
s := statement(effect, "iam:PassRole", "*")
s.Condition = map[string]map[string]iamdomain.ConditionValue{
operator: {iampolicy.KeyPassedToService: {service}},
}
return s
}
func passRoleToGateway(t *testing.T, statements ...iamdomain.Statement) *GatewayConfig {
t.Helper()
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return withVerifier(&GatewayConfig{
DisableLogging: true,
Region: authzRegion,
NATSConn: startTestNATS(t),
IAMService: &profileIAMService{
policyMockIAMService: policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
},
})
}
func associateProfile(t *testing.T, gw *GatewayConfig) error {
t.Helper()
return dispatchEC2(t, gw, "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web")
}
func registerTaskDefinition(t *testing.T, gw *GatewayConfig) error {
t.Helper()
req := setupECSUserRequest("AmazonEC2ContainerServiceV20141113.RegisterTaskDefinition",
registerTaskDefinitionBody(t, testECSPassRoleTargetARN))
return gw.serveECS(httptest.NewRecorder(), req)
}
func createNodegroup(t *testing.T, gw *GatewayConfig) error {
t.Helper()
return dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
}
func assertNotUnauthorized(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotEqual(t, awserrors.ErrorUnauthorizedOperation, err.Error())
}
// Each EC2 action that hands over an instance profile builds its own PassRole
// check, so every one is pinned to ec2.amazonaws.com.
func TestPassedToService_EC2(t *testing.T) {
tests := []struct {
action string
body string
}{
{"RunInstances", "Action=RunInstances&ImageId=ami-1&InstanceType=t3.micro&MinCount=1&MaxCount=1&IamInstanceProfile.Name=web"},
{"AssociateIamInstanceProfile", "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web"},
{"ReplaceIamInstanceProfileAssociation", "Action=ReplaceIamInstanceProfileAssociation" +
"&AssociationId=iip-assoc-dev&IamInstanceProfile.Name=web"},
{"RequestSpotInstances", "Action=RequestSpotInstances&LaunchSpecification.ImageId=ami-1" +
"&LaunchSpecification.InstanceType=t3.micro&LaunchSpecification.IamInstanceProfile.Name=web"},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
assertNotUnauthorized(t, dispatchEC2(t, passRoleToGateway(t,
statement("Allow", "ec2:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"),
), tt.body))
assertUnauthorized(t, dispatchEC2(t, passRoleToGateway(t,
statement("Allow", "ec2:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ecs-tasks.amazonaws.com"),
), tt.body))
})
}
}
func TestPassedToService_ECS(t *testing.T) {
assertNotDenied(t, registerTaskDefinition(t, passRoleToGateway(t,
statement("Allow", "ecs:*", "*"),
passRoleTo("Allow", iampolicy.OpStringLike, "ecs-tasks.*"),
)))
assertDenied(t, registerTaskDefinition(t, passRoleToGateway(t,
statement("Allow", "ecs:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"),
)))
}
// AWS reports a nodegroup's node role as passed to EKS, not to the EC2
// instances that end up running under it.
func TestPassedToService_EKS(t *testing.T) {
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "eks.amazonaws.com"),
)
assertPermitted(t, createNodegroup(t, allowed))
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters",
`{"name":"prod","roleArn":"`+eksNodeRoleARN+`"}`))
denied := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"),
)
assertDenied(t, createNodegroup(t, denied))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters",
`{"name":"prod","roleArn":"`+eksNodeRoleARN+`"}`))
}
// A Deny scoped to one service fences that service and leaves the others to
// the unconditional grant.
func TestPassedToService_DenyFencesOneService(t *testing.T) {
statements := []iamdomain.Statement{
statement("Allow", "ec2:*", "*"),
statement("Allow", "ecs:*", "*"),
statement("Allow", "iam:PassRole", "*"),
passRoleTo("Deny", iampolicy.OpStringEquals, "ecs-tasks.amazonaws.com"),
}
assertDenied(t, registerTaskDefinition(t, passRoleToGateway(t, statements...)))
assertNotUnauthorized(t, associateProfile(t, passRoleToGateway(t, statements...)))
}
// The key exists only on a PassRole check, so a condition on it attached to
// any other action does not hold.
func TestPassedToService_AbsentOutsidePassRole(t *testing.T) {
grant := statement("Allow", "ec2:*", "*")
grant.Condition = map[string]map[string]iamdomain.ConditionValue{
iampolicy.OpStringEquals: {iampolicy.KeyPassedToService: {"ec2.amazonaws.com"}},
}
assertUnauthorized(t, dispatchEC2(t, scopedPolicyGateway(grant), "Action=DeleteVolume"))
}
+6 -5
View File
@@ -1,6 +1,7 @@
package gateway
import (
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
@@ -10,16 +11,16 @@ import (
// allowAllIAMService resolves an allow-everything policy for every principal.
// Tests that exercise handler behaviour rather than authorization use it so the
// policy gate passes on a real evaluation instead of a bypass.
func allowAllIAMService() *policyMockIAMService {
func allowAllIAMService() *gwfix.PolicyIAM {
allow := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
return &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
return &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
GetRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
}
}
@@ -33,7 +34,7 @@ func withTestIdentity(req *http.Request) *http.Request {
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
}
if have.PrincipalType == "" {
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
}
return req.WithContext(ctx)
}
+2 -1
View File
@@ -3,6 +3,7 @@ package gateway
import (
"context"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"sync"
"testing"
@@ -33,7 +34,7 @@ func quotaFor(gw *GatewayConfig) *admissionquota.Service {
// quotaIAMService resolves exactly one account.
type quotaIAMService struct {
policyMockIAMService
gwfix.PolicyIAM
known string
}
+9 -8
View File
@@ -1,6 +1,7 @@
package gateway
import (
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"io"
@@ -26,14 +27,14 @@ import (
func setupTestAppWithRateLimiter(t *testing.T, accessKey, secretKey string, rl *authlimit.AuthRateLimiter) http.Handler {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(secretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(secretKey, gwfix.MasterKey)
if err != nil {
panic("failed to encrypt test secret: " + err.Error())
}
mockSvc := &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
mockSvc := &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
accessKey: {
AccessKeyID: accessKey,
SecretAccessKey: encryptedSecret,
@@ -50,7 +51,7 @@ func setupTestAppWithRateLimiter(t *testing.T, accessKey, secretKey string, rl *
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
Region: gwfix.Region,
IAMService: mockSvc,
Credentials: credverify.New(mockSvc, nil),
RateLimiter: rl,
@@ -71,7 +72,7 @@ func TestRateLimitIntegration_LockedIPGets503(t *testing.T) {
rl := authlimit.NewAuthRateLimiter()
defer rl.Stop()
handler := setupTestAppWithRateLimiter(t, testAccessKey, testSecretKey, rl)
handler := setupTestAppWithRateLimiter(t, gwfix.AccessKey, gwfix.SecretKey, rl)
// Send maxFailures requests with invalid signatures to trigger lockout.
for range authlimit.MaxFailures {
@@ -105,7 +106,7 @@ func TestRateLimitIntegration_SuccessResetsLockout(t *testing.T) {
rl := authlimit.NewAuthRateLimiter()
defer rl.Stop()
handler := setupTestAppWithRateLimiter(t, testAccessKey, testSecretKey, rl)
handler := setupTestAppWithRateLimiter(t, gwfix.AccessKey, gwfix.SecretKey, rl)
// Accumulate failures below threshold.
for range authlimit.MaxFailures - 1 {
@@ -121,7 +122,7 @@ func TestRateLimitIntegration_SuccessResetsLockout(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.RemoteAddr = "10.99.0.2:54321"
signTestRequest(t, req, nil, testAccessKey, testSecretKey)
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusOK {
-42
View File
@@ -1,42 +0,0 @@
package gateway
import (
"net/http"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// withRDS registers RDS on gw with the NATS connection and node count it holds,
// as the awsgw role composes it. Set both on gw before calling.
func withRDS(gw *GatewayConfig) *GatewayConfig {
deps := rdsawsapi.Deps{NATS: gw.NATSConn, ExpectedNodes: gw.ExpectedNodes}
if quotaFor(gw) != nil {
deps.Quota = quotaFor(gw)
}
b := dispatch.NewBuilder()
if err := b.Register(rdsawsapi.NewRegistration(deps)); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
// serveRDS dispatches r to the registered RDS service exactly as Request does,
// registering it first from the fields gw holds when the test has not.
func (gw *GatewayConfig) serveRDS(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withRDS(gw)
}
entry, ok := gw.registered(rdsawsapi.ServiceName)
if !ok {
panic("RDS is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
// rdsRegistrationInventory is the inventory the RDS registration declares to a
// registry, keyed as AWSOperationInventory expects.
func rdsRegistrationInventory() map[string]dispatch.Inventory {
return withRDS(&GatewayConfig{}).Services.Inventory()
}
+24 -107
View File
@@ -2,123 +2,19 @@ package gateway
import (
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
rdsinstance "github.com/mulgadc/spinifex/spinifex/domains/rds/instance"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
)
const rdsTestAccountID = "123456789012"
func setupRDSRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
ctx := reqctx.WithService(req.Context(), "rds")
ctx = withPrincipalField(ctx, fieldAccountID, rdsTestAccountID)
return withTestIdentity(req.WithContext(ctx))
}
// A signed customer request: an IAM user with a resolvable identity, which is
// what turns the policy check from the pre-IAM bypass into a real evaluation.
func setupRDSUserRequest(body, accountID string) *http.Request {
req := setupRDSRequest(body)
ctx := withPrincipalField(req.Context(), fieldAccountID, accountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
return req.WithContext(ctx)
}
// Counts policy resolutions, so a test can tell an authorized request from one
// that failed some other guard before the evaluator was ever reached.
type rdsPolicyProbe struct{ consulted int }
// A gateway whose IAM service answers with policies, so the RDS dispatch path
// runs a real evaluation for either principal branch.
func newRDSPolicyGateway(policies []iamdomain.PolicyDocument) (*GatewayConfig, *rdsPolicyProbe) {
return newRDSGatewayWithResolver(func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return policies, nil
})
}
func newRDSGatewayWithResolver(
resolve func(accountID, name string) ([]iamdomain.PolicyDocument, error),
) (*GatewayConfig, *rdsPolicyProbe) {
probe := &rdsPolicyProbe{}
counted := func(accountID, name string) ([]iamdomain.PolicyDocument, error) {
probe.consulted++
return resolve(accountID, name)
}
// Both branches are wired: an assumed-role principal resolves by role, and a
// test that only stubbed users would miss it entirely.
return withVerifier(&GatewayConfig{
DisableLogging: true,
Region: "ap-southeast-2",
IAMService: &policyMockIAMService{
getUserPoliciesFn: counted,
getRolePoliciesFn: counted,
},
}), probe
}
func TestRDSRequest_MalformedScalarReturnsHTTP400(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
gw := withVerifier(&GatewayConfig{DisableLogging: true, NATSConn: nc, IAMService: allowAllIAMService()})
for _, body := range []string{
"Action=CreateDBInstance&AllocatedStorage=abc",
"Action=CreateDBInstance&MultiAZ=yes",
} {
t.Run(body, func(t *testing.T) {
req := setupRDSRequest(body)
err := gw.serveRDS(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
w := httptest.NewRecorder()
gw.ErrorHandler(w, req, err)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+awserrors.ErrorInvalidParameterValue+"</Code>")
})
}
}
// RDS errors must use the IAM-style <ErrorResponse> envelope: the aws-sdk-go
// query unmarshaler rejects the EC2 <Response><Errors> shape for this service.
func TestRDSErrorHandler_UsesIAMEnvelope(t *testing.T) {
gw := withVerifier(withRDS(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
w := httptest.NewRecorder()
gw.ErrorHandler(w, setupRDSRequest("Action=CreateDBInstance"), errNotImplementedForTest)
body := w.Body.String()
assert.Contains(t, body, "<ErrorResponse>")
assert.Contains(t, body, "<Code>"+awserrors.ErrorNotImplemented+"</Code>")
}
func TestRDSErrorHandler_UsesRDSUnsupportedActionWording(t *testing.T) {
gw := withVerifier(withRDS(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
w := httptest.NewRecorder()
gw.ErrorHandler(w, setupRDSRequest("Action=CreateDBCluster"),
errors.New(awserrors.ErrorOperationNotSupported))
body := w.Body.String()
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, body, "RDS")
assert.Contains(t, body, "v1")
assert.NotContains(t, body, "registry")
}
var errNotImplementedForTest = errors.New(awserrors.ErrorNotImplemented)
// TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400 runs the real public path:
// a CreateDBInstance query request through RDS_Request, over NATS, into the production
// rdsinstance.Service, and the resulting error through ErrorHandler's HTTP/XML rendering. AWS
@@ -146,3 +42,24 @@ func TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400(t *testin
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+awserrors.ErrorInvalidParameterValue+"</Code>")
}
func TestRDSRequest_MalformedScalarReturnsHTTP400(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
gw := withVerifier(&GatewayConfig{DisableLogging: true, NATSConn: nc, IAMService: allowAllIAMService()})
for _, body := range []string{
"Action=CreateDBInstance&AllocatedStorage=abc",
"Action=CreateDBInstance&MultiAZ=yes",
} {
t.Run(body, func(t *testing.T) {
req := setupRDSRequest(body)
err := gw.serveRDS(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
w := httptest.NewRecorder()
gw.ErrorHandler(w, req, err)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+awserrors.ErrorInvalidParameterValue+"</Code>")
})
}
}
@@ -0,0 +1,35 @@
package gateway
import (
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/clusterready"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
)
// withRealAuth wires the production verifier, policy evaluator and account
// admission. Only tests that assert a verdict which depends on gateway
// internals (the middleware stash, the admin method gate, the account gate)
// use it; every other gateway test runs on the fake verifier.
func withRealAuth(gw *GatewayConfig) *GatewayConfig {
gw.Credentials = credverify.New(gw.IAMService, gw.STSService)
gw.Policy = policyauth.New(gw.IAMService, policyauth.WithRetryBase(policyRetryBase))
gw.Admission = accountadmission.New(gw.IAMService)
if gw.NATSConn != nil {
gw.Readiness = realReadiness{gw}
}
return gw
}
// withEC2RealAuth is withEC2Verifier on the production capabilities.
func withEC2RealAuth(gw *GatewayConfig) *GatewayConfig {
return withEC2(withRealAuth(gw))
}
// realReadiness reads the bus connection when asked, so a test that drops it
// after wiring sees the cluster as unavailable.
type realReadiness struct{ gw *GatewayConfig }
func (r realReadiness) Available() bool {
return r.gw.NATSConn != nil && clusterready.New(r.gw.NATSConn).Available()
}
+1 -34
View File
@@ -1,36 +1,3 @@
package gateway
import (
"net/http"
"testing"
"time"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/registryadapt"
"github.com/stretchr/testify/require"
)
// newTestRegistryHandler composes the /v2 handler the way the awsgw role does, over
// gw's IAM and STS services, with registry serving authorized requests.
func newTestRegistryHandler(t *testing.T, gw *GatewayConfig, iss *ecrauth.Issuer, verify *ecrauth.Verifier, registry ...http.Handler) http.Handler {
t.Helper()
cfg := ecrregistry.BridgeConfig{
Verifier: verify,
Issuer: iss,
Admission: accountadmission.New(gw.IAMService),
Region: gw.Region,
}
cfg = registryadapt.Config(cfg, credverify.New(gw.IAMService, gw.STSService),
policyauth.New(gw.IAMService, policyauth.WithRetryBase(time.Millisecond)))
if len(registry) > 0 {
cfg.Registry = registry[0]
}
b, err := ecrregistry.NewBridge(cfg)
require.NoError(t, err)
return ingresshttp.NewHostMatcher(gw.InternalSuffix, "dkr", "ecr").Middleware(b.Handler())
}
import ()
@@ -12,6 +12,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"io"
"net/http"
"net/http/httptest"
@@ -66,15 +67,15 @@ type reqctxGatewayEnv struct {
// optional route53 probe over a caller who holds only the given statements.
func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.Statement) *GatewayConfig {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
base := policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
base := gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
@@ -82,11 +83,11 @@ func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.S
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}
svc := &iamAuthzPolicyService{
policyMockIAMService: base,
arns: map[string]string{"alice": "arn:aws:iam::" + authzAccountID + ":user/alice"},
PolicyIAM: base,
arns: map[string]string{"alice": "arn:aws:iam::" + authzAccountID + ":user/alice"},
}
var nc *nats.Conn
@@ -96,7 +97,7 @@ func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.S
nc.Close()
}
}
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: reqctxRegion,
NATSConn: nc,
@@ -139,7 +140,7 @@ func signReqctx(t *testing.T, req *http.Request, body []byte, service, region st
payloadHash := sha256Hex(body)
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(t.Context(),
aws.Credentials{AccessKeyID: testAccessKey, SecretAccessKey: testSecretKey},
aws.Credentials{AccessKeyID: gwfix.AccessKey, SecretAccessKey: gwfix.SecretKey},
req, payloadHash, service, region, time.Now().UTC()))
return req
}
@@ -436,7 +437,7 @@ func TestReqctxPipeline_CredentialScopeRegionVersusInvocationRegion(t *testing.T
// iamAuthzPolicyService resolves a role or user name to a fixed ARN.
type iamAuthzPolicyService struct {
policyMockIAMService
gwfix.PolicyIAM
arns map[string]string
}
+35 -34
View File
@@ -2,6 +2,7 @@ package gateway
import (
"fmt"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net"
@@ -30,9 +31,9 @@ func auditRouter(t *testing.T, keys map[string]*iamdomain.AccessKey) (http.Handl
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
IAMService: &mockIAMService{masterKey: testMasterKey, accessKeys: keys},
Credentials: credverify.New(&mockIAMService{masterKey: testMasterKey, accessKeys: keys}, nil),
Region: gwfix.Region,
IAMService: &gwfix.IAM{MasterKey: gwfix.MasterKey, AccessKeys: keys},
Credentials: credverify.New(&gwfix.IAM{MasterKey: gwfix.MasterKey, AccessKeys: keys}, nil),
RateLimiter: rl,
}
withEC2(gw)
@@ -54,22 +55,6 @@ func auditRouter(t *testing.T, keys map[string]*iamdomain.AccessKey) (http.Handl
return r, func() *audit.Record { return got }
}
// A request that never presented credentials is still attributable to where it
// came from, and carries the verdict it was answered with.
func TestRequestAuditRecordsUnauthenticatedRejection(t *testing.T) {
handler, record := auditRouter(t, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.15.8.11:54321"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.NotNil(t, record())
assert.Equal(t, "10.15.8.11", auditField(record(), "sourceIP"))
assert.Equal(t, awserrors.ErrorMissingAuthenticationToken, auditField(record(), "authError"))
assert.Empty(t, auditField(record(), "accessKeyID"))
}
// signedRequest builds a request whose signature is wrong but whose envelope
// parses: the credential scope date must match X-Amz-Date or the request is
// rejected as malformed before the access key is ever looked at.
@@ -84,27 +69,13 @@ func signedRequestForKey(remoteAddr, accessKeyID string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKeyID+"/"+
now.Format("20060102")+"/"+testRegion+"/ec2/aws4_request, "+
now.Format("20060102")+"/"+gwfix.Region+"/ec2/aws4_request, "+
"SignedHeaders=host;x-amz-date, Signature="+
"0000000000000000000000000000000000000000000000000000000000000000")
req.Header.Set("X-Amz-Date", now.Format("20060102T150405Z"))
return req
}
// A rejected caller is identifiable: the key it presented is recorded even
// though authentication failed on it.
func TestRequestAuditRecordsRejectedAccessKey(t *testing.T) {
handler, record := auditRouter(t, map[string]*iamdomain.AccessKey{})
w := httptest.NewRecorder()
handler.ServeHTTP(w, signedRequest("10.15.8.12:54321"))
require.NotNil(t, record())
assert.Equal(t, "10.15.8.12", auditField(record(), "sourceIP"))
assert.Equal(t, "AKIAINVALIDKEY000000", auditField(record(), "accessKeyID"))
assert.Equal(t, awserrors.ErrorInvalidClientTokenId, auditField(record(), "authError"))
}
// The lockout that answers 503 is the case that motivated this: without the
// record, the response carries no client address and no reason at all.
func TestRequestAuditRecordsRateLimitLockout(t *testing.T) {
@@ -198,3 +169,33 @@ func auditField(rec *audit.Record, key string) string {
}
return ""
}
// A rejected caller is identifiable: the key it presented is recorded even
// though authentication failed on it.
func TestRequestAuditRecordsRejectedAccessKey(t *testing.T) {
handler, record := auditRouter(t, map[string]*iamdomain.AccessKey{})
w := httptest.NewRecorder()
handler.ServeHTTP(w, signedRequest("10.15.8.12:54321"))
require.NotNil(t, record())
assert.Equal(t, "10.15.8.12", auditField(record(), "sourceIP"))
assert.Equal(t, "AKIAINVALIDKEY000000", auditField(record(), "accessKeyID"))
assert.Equal(t, awserrors.ErrorInvalidClientTokenId, auditField(record(), "authError"))
}
// A request that never presented credentials is still attributable to where it
// came from, and carries the verdict it was answered with.
func TestRequestAuditRecordsUnauthenticatedRejection(t *testing.T) {
handler, record := auditRouter(t, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.15.8.11:54321"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.NotNil(t, record())
assert.Equal(t, "10.15.8.11", auditField(record(), "sourceIP"))
assert.Equal(t, awserrors.ErrorMissingAuthenticationToken, auditField(record(), "authError"))
assert.Empty(t, auditField(record(), "accessKeyID"))
}
@@ -2,6 +2,7 @@ package gateway
import (
"context"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -68,7 +69,7 @@ func sqsWorkerRequest(service, body, session string) *http.Request {
ctx := reqctx.WithService(req.Context(), service)
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, session)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeAssumedRole)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeAssumedRole)
ctx = withPrincipalField(ctx, fieldUnderlyingRoleARN, roleARN)
ctx = withPrincipalField(ctx, fieldAssumedRoleARN,
"arn:aws:sts::"+awsidentifiers.GlobalAccountID+":assumed-role/"+sqsworkerapi.WorkerRoleName+"/"+session)
@@ -92,7 +93,7 @@ func newSQSInternalGateway(policies []iamdomain.PolicyDocument) (*GatewayConfig,
// The worker's session, signed for spinifex-sqs, reaches the internal surface
// and is evaluated against its role policy under the spinifex-sqs prefix.
func TestSQSInternal_RoutedBySigningServiceAndRolePolicy(t *testing.T) {
gw, probe, ids := newSQSInternalGateway(allowPolicy("spinifex-sqs:ClaimFirstStore"))
gw, probe, ids := newSQSInternalGateway(gwfix.AllowPolicy("spinifex-sqs:ClaimFirstStore"))
rec := httptest.NewRecorder()
require.NoError(t, serveSigned(gw, rec, sqsWorkerRequest(sqsworkerapi.ServiceName, "Action=ClaimFirstStore", sqsWorkerInstance)))
assert.Equal(t, 1, probe.consulted)
@@ -103,7 +104,7 @@ func TestSQSInternal_RoutedBySigningServiceAndRolePolicy(t *testing.T) {
// The role policy is real: a worker session whose policy grants another
// service's action is denied before any store is consulted.
func TestSQSInternal_PolicyForAnotherServiceDenies(t *testing.T) {
gw, probe, ids := newSQSInternalGateway(allowPolicy("sqs:ClaimFirstStore"))
gw, probe, ids := newSQSInternalGateway(gwfix.AllowPolicy("sqs:ClaimFirstStore"))
err := serveSigned(gw, httptest.NewRecorder(), sqsWorkerRequest(sqsworkerapi.ServiceName, "Action=ClaimFirstStore", sqsWorkerInstance))
assertDenied(t, err)
assert.Equal(t, 1, probe.consulted)
@@ -112,7 +113,7 @@ func TestSQSInternal_PolicyForAnotherServiceDenies(t *testing.T) {
// A customer administrator holding every action is refused by the class gate.
func TestSQSInternal_CustomerAdminDenied(t *testing.T) {
gw, probe, ids := newSQSInternalGateway(allowPolicy("*"))
gw, probe, ids := newSQSInternalGateway(gwfix.AllowPolicy("*"))
req := setupRDSUserRequest("Action=ClaimFirstStore", rdsTestAccountID)
req = req.WithContext(reqctx.WithService(req.Context(), sqsworkerapi.ServiceName))
assertDenied(t, serveSigned(gw, httptest.NewRecorder(), req))
@@ -123,7 +124,7 @@ func TestSQSInternal_CustomerAdminDenied(t *testing.T) {
// Public SQS is untouched: nothing serves the sqs signing service, and the
// internal actions do not answer under RDS's name either.
func TestSQSInternal_PublicSQSUnchanged(t *testing.T) {
gw, _, ids := newSQSInternalGateway(allowPolicy("*"))
gw, _, ids := newSQSInternalGateway(gwfix.AllowPolicy("*"))
_, ok := gw.registered("sqs")
assert.False(t, ok, "the sqs signing service must stay unserved")
inv := gw.Services.Inventory()
+20 -61
View File
@@ -1,55 +1,36 @@
package gateway
import (
"github.com/aws/aws-sdk-go/service/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAnonymousSTS_WebIdentityNoAuthReachesHandler proves an unsigned
// AssumeRoleWithWebIdentity is routed to the STS dispatcher before SigV4, not rejected.
func TestAnonymousSTS_WebIdentityNoAuthReachesHandler(t *testing.T) {
var seenToken string
gw := withSTS(withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: &flexMockIAMService{},
STSService: &flexMockSTSService{
assumeWebIdentityFn: func(in *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
seenToken = aws.StringValue(in.WebIdentityToken)
return &sts.AssumeRoleWithWebIdentityOutput{
Credentials: &sts.Credentials{
AccessKeyId: aws.String("ASIAIRSA"),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("token"),
},
}, nil
},
},
}))
handler := gw.SetupRoutes()
// TestAnonymousSTSArgs_Classification covers the body-peek helper for anonymous action detection.
func TestAnonymousSTSArgs_Classification(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
body := "Action=AssumeRoleWithWebIdentity&Version=2011-06-15" +
"&RoleArn=arn:aws:iam::000000000001:role/demo&RoleSessionName=s1" +
"&WebIdentityToken=header.payload.sig"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// No Authorization header — unsigned bootstrap path.
t.Run("anonymous action recognised, body restored", func(t *testing.T) {
const body = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
args, ok := gw.anonymousSTSArgs(req)
require.True(t, ok)
assert.Equal(t, "AssumeRoleWithWebIdentity", args["Action"])
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
restored, _ := io.ReadAll(req.Body)
assert.Equal(t, body, string(restored), "body must be restored for downstream read")
})
require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
b, _ := io.ReadAll(rec.Body)
assert.Contains(t, string(b), "AssumeRoleWithWebIdentityResult")
assert.Contains(t, string(b), "ASIAIRSA")
assert.Equal(t, "header.payload.sig", seenToken)
t.Run("non-anonymous action rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
_, ok := gw.anonymousSTSArgs(req)
assert.False(t, ok)
})
}
// TestAnonymousSTS_SignedRequestFallsThrough confirms a request with an Authorization
@@ -76,25 +57,3 @@ func TestAnonymousSTS_SignedRequestFallsThrough(t *testing.T) {
gw.anonymousSTSInterceptor(next).ServeHTTP(rec, req)
assert.True(t, nextCalled, "signed request must pass through to next handler")
}
// TestAnonymousSTSArgs_Classification covers the body-peek helper for anonymous action detection.
func TestAnonymousSTSArgs_Classification(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
t.Run("anonymous action recognised, body restored", func(t *testing.T) {
const body = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
args, ok := gw.anonymousSTSArgs(req)
require.True(t, ok)
assert.Equal(t, "AssumeRoleWithWebIdentity", args["Action"])
restored, _ := io.ReadAll(req.Body)
assert.Equal(t, body, string(restored), "body must be restored for downstream read")
})
t.Run("non-anonymous action rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
_, ok := gw.anonymousSTSArgs(req)
assert.False(t, ok)
})
}
+86 -210
View File
@@ -1,129 +1,79 @@
// (stsRequestParams, principalTypeUser, the context keys) and shares the
// policy mocks with the other in-package authz suites.
//
//test:in-package — the gate is exercised through unexported request wiring
package gateway
import (
"errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"testing"
)
const (
stsTestRoleName = "app"
stsTestRoleARN = "arn:aws:iam::000000000000:role/app"
)
// stsPolicyIAMService serves the identity policies the STS gate evaluates, the
// GetUser lookup GetCallerIdentity performs, and the GetRole the gate resolves
// the target role with.
type stsPolicyIAMService struct {
policyMockIAMService
roles map[string]string
}
func (m *stsPolicyIAMService) GetUser(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{UserId: aws.String("AIDAALICE000")}}, nil
}
func (m *stsPolicyIAMService) GetRole(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
arn, ok := m.roles[aws.StringValue(input.RoleName)]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
// TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated is the case that would
// have caught the ECS agent regression: a role session whose underlying role
// grants a non-sts action and nothing else must not be able to assume anything.
func TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
params := func(iamSvc iamdomain.IAMService, stsSvc *flexMockSTSService) stsRequestParams {
return stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "s1",
principalType: gwfix.PrincipalTypeAssumedRole,
accessKey: "ASIAEXAMPLE",
assumedRoleARN: "arn:aws:sts::000000000000:assumed-role/ecsInstanceRole/s1",
assumedRoleID: "AROAECS:s1",
underlyingRoleARN: "arn:aws:iam::000000000000:role/ecsInstanceRole",
stsSvc: stsSvc,
iamSvc: iamSvc,
}
}
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
Arn: aws.String(arn),
}}, nil
t.Run("ecs-only role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "ecs:*", "*"))
resp := stsPolicyRequestAs(t, params(svc, deniedSTSService(t)), body)
assertSTSAccessDenied(t, resp)
})
t.Run("role granted sts:AssumeRole reaches handler", func(t *testing.T) {
svc := stsIdentityPolicy(
stsStatement("Allow", "ecs:*", "*"),
stsStatement("Allow", "sts:AssumeRole", "arn:aws:iam::000000000000:role/*"),
)
resp := stsPolicyRequestAs(t, params(svc, &flexMockSTSService{}), body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
// stsIdentityPolicy builds an IAM service returning the given statements as the
// caller's only identity policy, for a user and for an assumed role alike. The
// one stored role is the pathless `app`; withRoles replaces the set.
func stsIdentityPolicy(statements ...iamdomain.Statement) *stsPolicyIAMService {
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
svc := &stsPolicyIAMService{roles: map[string]string{stsTestRoleName: stsTestRoleARN}}
svc.getUserPoliciesFn = func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return docs, nil
}
svc.getRolePoliciesFn = func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return docs, nil
}
return svc
}
// TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence keeps the
// detailed denial message safe to return: it must read identically whether or
// not the role exists, which a lookup added ahead of the gate would break.
func TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
// withRoles replaces the roles GetRole answers for, keyed by name and mapped to
// the ARN IAM stored — which is what a resource-scoped grant is matched against.
func (m *stsPolicyIAMService) withRoles(roles map[string]string) *stsPolicyIAMService {
m.roles = roles
return m
}
existing := assertSTSAccessDenied(t, stsPolicyRequest(t, stsIdentityPolicy(), nil, body))
missing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, body))
// stsStatement is shorthand for a single-action, single-resource statement.
func stsStatement(effect, action, resource string) iamdomain.Statement {
return iamdomain.Statement{
Effect: effect,
Action: iamdomain.StringOrArr{action},
Resource: iamdomain.StringOrArr{resource},
}
}
assert.Equal(t, stripRequestID(existing), stripRequestID(missing))
assert.Contains(t, existing, "is not authorized to perform: sts:AssumeRole on resource: "+stsTestRoleARN)
// deniedSTSService fails the test if any gated handler is reached, so a passing
// denial can only have come from the gate.
func deniedSTSService(t *testing.T) *flexMockSTSService {
t.Helper()
return &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("handler reached: the identity-policy gate should have denied this request")
return nil, nil
},
}
}
// A pathed role is where the two can diverge: a resolved role names its
// stored ARN, so an unresolved one must name what the caller supplied
// rather than any form derived from it.
const pathed = "arn:aws:iam::000000000000:role/team/svc"
pathedBody := "Action=AssumeRole&RoleArn=" + pathed + "&RoleSessionName=s1"
// stsPolicyRequest dispatches an STS action as alice with the given identity
// policies.
func stsPolicyRequest(t *testing.T, iamSvc iamdomain.IAMService, stsSvc *flexMockSTSService, body string) *http.Response {
t.Helper()
if stsSvc == nil {
stsSvc = deniedSTSService(t)
}
return stsPolicyRequestAs(t, stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: stsSvc,
iamSvc: iamSvc,
}, body)
}
pathedExisting := assertSTSAccessDenied(t, stsPolicyRequest(t,
stsIdentityPolicy().withRoles(map[string]string{"svc": pathed}), nil, pathedBody))
pathedMissing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, pathedBody))
func stsPolicyRequestAs(t *testing.T, p stsRequestParams, body string) *http.Response {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return doRequest(setupSTSRequestHandler(p), req)
}
func assertSTSAccessDenied(t *testing.T, resp *http.Response) string {
t.Helper()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "AccessDenied")
return string(b)
assert.Equal(t, stripRequestID(pathedExisting), stripRequestID(pathedMissing))
assert.Contains(t, pathedExisting, "on resource: "+pathed)
}
// TestSTSRequest_AssumeRole_IdentityPolicyGate covers the identity-side gate.
@@ -161,64 +111,6 @@ func TestSTSRequest_AssumeRole_IdentityPolicyGate(t *testing.T) {
})
}
// TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated is the case that would
// have caught the ECS agent regression: a role session whose underlying role
// grants a non-sts action and nothing else must not be able to assume anything.
func TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
params := func(iamSvc iamdomain.IAMService, stsSvc *flexMockSTSService) stsRequestParams {
return stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "s1",
principalType: principalTypeAssumedRole,
accessKey: "ASIAEXAMPLE",
assumedRoleARN: "arn:aws:sts::000000000000:assumed-role/ecsInstanceRole/s1",
assumedRoleID: "AROAECS:s1",
underlyingRoleARN: "arn:aws:iam::000000000000:role/ecsInstanceRole",
stsSvc: stsSvc,
iamSvc: iamSvc,
}
}
t.Run("ecs-only role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "ecs:*", "*"))
resp := stsPolicyRequestAs(t, params(svc, deniedSTSService(t)), body)
assertSTSAccessDenied(t, resp)
})
t.Run("role granted sts:AssumeRole reaches handler", func(t *testing.T) {
svc := stsIdentityPolicy(
stsStatement("Allow", "ecs:*", "*"),
stsStatement("Allow", "sts:AssumeRole", "arn:aws:iam::000000000000:role/*"),
)
resp := stsPolicyRequestAs(t, params(svc, &flexMockSTSService{}), body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
// TestSTSRequest_AssumeRole_PolicyScopedToRole proves the gate evaluates the
// target role ARN, not "*", so a grant on one role does not open another.
func TestSTSRequest_AssumeRole_PolicyScopedToRole(t *testing.T) {
roles := map[string]string{
stsTestRoleName: stsTestRoleARN,
"other": "arn:aws:iam::000000000000:role/other",
}
t.Run("granted role allowed", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, &flexMockSTSService{},
"Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1")
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("other role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, nil,
"Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/other&RoleSessionName=s1")
assertSTSAccessDenied(t, resp)
})
}
// TestSTSRequest_AssumeRole_PathIsResolvedNotTrusted pins the gate to the ARN
// IAM stored, so a role genuinely stored under a path is matched by its full
// ARN. An ARN that is not the stored one is refused by role resolution itself
@@ -251,43 +143,38 @@ func TestSTSRequest_AssumeRole_PathIsResolvedNotTrusted(t *testing.T) {
})
}
// TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence keeps the
// detailed denial message safe to return: it must read identically whether or
// not the role exists, which a lookup added ahead of the gate would break.
func TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
// TestSTSRequest_AssumeRole_PolicyScopedToRole proves the gate evaluates the
// target role ARN, not "*", so a grant on one role does not open another.
func TestSTSRequest_AssumeRole_PolicyScopedToRole(t *testing.T) {
roles := map[string]string{
stsTestRoleName: stsTestRoleARN,
"other": "arn:aws:iam::000000000000:role/other",
}
existing := assertSTSAccessDenied(t, stsPolicyRequest(t, stsIdentityPolicy(), nil, body))
missing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, body))
t.Run("granted role allowed", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, &flexMockSTSService{},
"Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1")
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
assert.Equal(t, stripRequestID(existing), stripRequestID(missing))
assert.Contains(t, existing, "is not authorized to perform: sts:AssumeRole on resource: "+stsTestRoleARN)
// A pathed role is where the two can diverge: a resolved role names its
// stored ARN, so an unresolved one must name what the caller supplied
// rather than any form derived from it.
const pathed = "arn:aws:iam::000000000000:role/team/svc"
pathedBody := "Action=AssumeRole&RoleArn=" + pathed + "&RoleSessionName=s1"
pathedExisting := assertSTSAccessDenied(t, stsPolicyRequest(t,
stsIdentityPolicy().withRoles(map[string]string{"svc": pathed}), nil, pathedBody))
pathedMissing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, pathedBody))
assert.Equal(t, stripRequestID(pathedExisting), stripRequestID(pathedMissing))
assert.Contains(t, pathedExisting, "on resource: "+pathed)
t.Run("other role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, nil,
"Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/other&RoleSessionName=s1")
assertSTSAccessDenied(t, resp)
})
}
// stripRequestID removes the per-request UUID so two error bodies can be
// compared for everything else.
func stripRequestID(body string) string {
start := strings.Index(body, "<RequestId>")
end := strings.Index(body, "</RequestId>")
if start < 0 || end < start {
return body
}
return body[:start] + body[end:]
// TestSTSRequest_GetCallerIdentity_NotGated pins the AWS contract: the action
// requires no permission and cannot be denied by policy.
func TestSTSRequest_GetCallerIdentity_NotGated(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Deny", "sts:*", "*"))
resp := stsPolicyRequest(t, svc, &flexMockSTSService{}, "Action=GetCallerIdentity")
require.Equal(t, http.StatusOK, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "GetCallerIdentityResult")
}
// TestSTSRequest_GetSessionToken_NotGated pins the AWS contract: it is an
@@ -310,14 +197,3 @@ func TestSTSRequest_GetSessionToken_NotGated(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, called)
}
// TestSTSRequest_GetCallerIdentity_NotGated pins the AWS contract: the action
// requires no permission and cannot be denied by policy.
func TestSTSRequest_GetCallerIdentity_NotGated(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Deny", "sts:*", "*"))
resp := stsPolicyRequest(t, svc, &flexMockSTSService{}, "Action=GetCallerIdentity")
require.Equal(t, http.StatusOK, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "GetCallerIdentityResult")
}
-41
View File
@@ -1,41 +0,0 @@
package gateway
import (
"net/http"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// stsRegistration is the STS registration over the IAMService and STSService
// gw holds, as the awsgw role composes it. Set them on gw first.
func stsRegistration(gw *GatewayConfig) dispatch.Registration {
return stsawsapi.NewRegistration(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
}
// withSTS registers STS alone on gw and composes its unsigned web-identity
// capability, as awsgw does.
func withSTS(gw *GatewayConfig) *GatewayConfig {
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
return withServices(gw, stsRegistration(gw))
}
// withIAMAndSTS registers IAM and STS together on gw, with the unsigned
// web-identity capability, as awsgw composes them.
func withIAMAndSTS(gw *GatewayConfig) *GatewayConfig {
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
return withServices(gw, iamRegistration(gw), stsRegistration(gw))
}
// STS_Request dispatches r to the registered STS service exactly as Request
// does, registering it first from the fields gw holds when the test has not.
func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withSTS(gw)
}
entry, ok := gw.registered(stsawsapi.ServiceName)
if !ok {
panic("STS is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
+195 -299
View File
@@ -2,126 +2,110 @@ package gateway
import (
"errors"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flexMockSTSService is a configurable STSService mock with per-method
// overrides. It embeds the interface so it satisfies the full contract; only
// the fn-field methods below are wired, and any other method nil-panics if a
// test reaches an unmocked path.
type flexMockSTSService struct {
stsdomain.STSService
assumeRoleFn func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error)
getCallerIdentityFn func(string, string, string, *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)
getSessionTokenFn func(string, string, string, string, *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error)
lookupSessionFn func(string) (*stsdomain.SessionCredential, error)
assumeWebIdentityFn func(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error)
}
var _ stsdomain.STSService = (*flexMockSTSService)(nil)
func (m *flexMockSTSService) AssumeRole(callerAccountID, callerARN, callerIdentity string, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
if m.assumeRoleFn != nil {
return m.assumeRoleFn(callerAccountID, callerARN, callerIdentity, input)
func TestSTSRequest_AssumeRole_MissingRoleArn(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("AssumeRole should not be called when required params missing")
return nil, nil
},
}
return &sts.AssumeRoleOutput{}, nil
}
func (m *flexMockSTSService) GetCallerIdentity(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) {
if m.getCallerIdentityFn != nil {
return m.getCallerIdentityFn(callerAccountID, callerARN, callerUserID, input)
}
return &sts.GetCallerIdentityOutput{
Account: aws.String(callerAccountID),
Arn: aws.String(callerARN),
UserId: aws.String(callerUserID),
}, nil
}
func (m *flexMockSTSService) GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
if m.getSessionTokenFn != nil {
return m.getSessionTokenFn(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID, input)
}
return &sts.GetSessionTokenOutput{}, nil
}
func (m *flexMockSTSService) LookupSessionCredential(akid string) (*stsdomain.SessionCredential, error) {
if m.lookupSessionFn != nil {
return m.lookupSessionFn(akid)
}
return nil, nil
}
func (m *flexMockSTSService) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
if m.assumeWebIdentityFn != nil {
return m.assumeWebIdentityFn(input)
}
return nil, errors.New(awserrors.ErrorNotImplemented)
}
// stsRequestParams holds identity values the STS dispatcher reads from SigV4 context.
type stsRequestParams struct {
accountID string
identity string
principalType string
accessKey string
assumedRoleARN string
assumedRoleID string
// underlyingRoleARN is what the policy gate resolves an assumed-role
// caller's policies by; SessionName never participates.
underlyingRoleARN string
stsSvc stsdomain.STSService
iamSvc iamdomain.IAMService
}
func setupSTSRequestHandler(p stsRequestParams) http.Handler {
if p.iamSvc == nil {
p.iamSvc = &flexMockIAMService{}
}
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
STSService: p.stsSvc,
IAMService: p.iamSvc,
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
// An empty policy: a request missing RoleArn is malformed whatever the
// caller holds, so the gate must not answer it with a denial.
iamSvc: stsIdentityPolicy(),
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = reqctx.WithService(ctx, "sts")
ctx = withPrincipalField(ctx, fieldAccountID, p.accountID)
ctx = withPrincipalField(ctx, fieldIdentity, p.identity)
ctx = withPrincipalField(ctx, fieldPrincipalType, p.principalType)
ctx = withPrincipalField(ctx, fieldAccessKey, p.accessKey)
if p.assumedRoleARN != "" {
ctx = withPrincipalField(ctx, fieldAssumedRoleARN, p.assumedRoleARN)
}
if p.assumedRoleID != "" {
ctx = withPrincipalField(ctx, fieldAssumedRoleID, p.assumedRoleID)
// The door seals aws:userid as the assumed-role ID for a role session.
ctx = withPrincipalField(ctx, fieldUserID, p.assumedRoleID)
}
if p.underlyingRoleARN != "" {
ctx = withPrincipalField(ctx, fieldUnderlyingRoleARN, p.underlyingRoleARN)
}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=AssumeRole&RoleSessionName=s1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "MissingParameter")
}
// TestSTSRequest_AssumeRole_NilIAMService_InternalError pins the gate to the
// same fail-closed answer checkPolicyResources gives: the target-role lookup
// runs ahead of it, so an absent IAM service must not reach a nil interface.
func TestSTSRequest_AssumeRole_NilIAMService_InternalError(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("handler reached: an unavailable IAM service must fail closed")
return nil, nil
},
}
gw := withRealAuth(&GatewayConfig{DisableLogging: true, STSService: svc, IAMService: nil})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader("Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
func TestSTSRequest_AssumeRole_ServiceError_PropagatesAccessDenied(t *testing.T) {
// AccessDenied from the handler (trust-policy denial) must reach the wire as IAM ErrorResponse + 403.
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
return nil, errors.New(awserrors.ErrorAccessDenied)
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
iamSvc: stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", "*")),
})
body := "Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/app&RoleSessionName=s1"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 403, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "AccessDenied")
assert.Contains(t, string(b), "<ErrorResponse>")
}
func TestSTSRequest_AssumeRole_Success(t *testing.T) {
@@ -145,7 +129,7 @@ func TestSTSRequest_AssumeRole_Success(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
iamSvc: stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", "*")),
@@ -176,7 +160,7 @@ func TestSTSRequest_GetCallerIdentity_AssumedRole(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "s1",
principalType: principalTypeAssumedRole,
principalType: gwfix.PrincipalTypeAssumedRole,
accessKey: "ASIAEXAMPLE",
assumedRoleARN: "arn:aws:sts::000000000000:assumed-role/app/s1",
assumedRoleID: "AROAEXAMPLE:s1",
@@ -196,12 +180,54 @@ func TestSTSRequest_GetCallerIdentity_AssumedRole(t *testing.T) {
assert.Contains(t, xmlStr, "AROAEXAMPLE:s1")
}
// A user at a non-root path is reported with its stored ARN, as AWS does,
// resolved by the SigV4 middleware rather than reformatted at "/".
func TestSTSRequest_GetCallerIdentity_PathedUserThroughMiddleware(t *testing.T) {
const pathed = "arn:aws:iam::123456789012:user/eng/alice"
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
STSService: &flexMockSTSService{},
IAMService: &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey, SecretAccessKey: encryptedSecret,
UserName: "alice", AccountID: "123456789012", Status: "Active",
}},
GetUserFn: func(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{
UserName: aws.String("alice"), UserId: aws.String("AIDAALICE"), Arn: aws.String(pathed),
}}, nil
},
},
})
withServices(gw, stsRegistration(gw), ec2Registration(gw))
handler := gw.SigV4AuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
}))
body := []byte("Action=GetCallerIdentity")
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
gwfix.SignRequest(t, req, body, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(handler, req)
b, _ := io.ReadAll(resp.Body)
require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", string(b))
assert.Contains(t, string(b), "<Arn>"+pathed+"</Arn>")
}
func TestSTSRequest_GetCallerIdentity_RootShortcircuitsIAMLookup(t *testing.T) {
// Root short-circuits IAM lookup — flexMockIAMService.GetUser would cause InternalError otherwise.
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
@@ -218,6 +244,34 @@ func TestSTSRequest_GetCallerIdentity_RootShortcircuitsIAMLookup(t *testing.T) {
assert.Contains(t, xmlStr, "<UserId>000000000000</UserId>")
}
func TestSTSRequest_GetCallerIdentity_User_LookupIAM(t *testing.T) {
// User principal triggers IAMService.GetUser to resolve UserId.
iamSvc := &flexMockIAMService{
getUserFn: func(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{UserId: aws.String("AIDAALICE000")}}, nil
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: &flexMockSTSService{},
iamSvc: iamSvc,
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
require.Equal(t, 200, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
xmlStr := string(b)
assert.Contains(t, xmlStr, "<Arn>arn:aws:iam::000000000000:user/alice</Arn>")
assert.Contains(t, xmlStr, "<UserId>AIDAALICE000</UserId>")
}
func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
var got struct {
accountID string
@@ -245,7 +299,7 @@ func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
})
@@ -265,34 +319,16 @@ func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
// Verify forwarded identity fields and that checkPolicy did not block (STS_Request runs no checkPolicy pass).
assert.Equal(t, awsidentifiers.GlobalAccountID, got.accountID)
assert.Equal(t, "alice", got.userName)
assert.Equal(t, principalTypeUser, got.principalType)
assert.Equal(t, gwfix.PrincipalTypeUser, got.principalType)
assert.Equal(t, "AKIAEXAMPLE", got.accessKeyID)
assert.Equal(t, int64(3600), got.duration)
}
func TestSTSRequest_UnknownAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: principalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ThisDoesNotExist"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InvalidAction")
}
func TestSTSRequest_MissingAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
@@ -306,183 +342,43 @@ func TestSTSRequest_MissingAction(t *testing.T) {
assert.Contains(t, string(b), "MissingAction")
}
func TestSTSRequest_AssumeRole_MissingRoleArn(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("AssumeRole should not be called when required params missing")
return nil, nil
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
// An empty policy: a request missing RoleArn is malformed whatever the
// caller holds, so the gate must not answer it with a denial.
iamSvc: stsIdentityPolicy(),
func TestSTSRequest_NilService_InternalError(t *testing.T) {
gw := withRealAuth(&GatewayConfig{DisableLogging: true, STSService: nil, IAMService: &flexMockIAMService{}})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=AssumeRole&RoleSessionName=s1"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
func TestSTSRequest_UnknownAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ThisDoesNotExist"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "MissingParameter")
}
func TestSTSRequest_NilService_InternalError(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, STSService: nil, IAMService: &flexMockIAMService{}})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
// TestSTSRequest_AssumeRole_NilIAMService_InternalError pins the gate to the
// same fail-closed answer checkPolicyResources gives: the target-role lookup
// runs ahead of it, so an absent IAM service must not reach a nil interface.
func TestSTSRequest_AssumeRole_NilIAMService_InternalError(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("handler reached: an unavailable IAM service must fail closed")
return nil, nil
},
}
gw := withVerifier(&GatewayConfig{DisableLogging: true, STSService: svc, IAMService: nil})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader("Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
func TestSTSRequest_AssumeRole_ServiceError_PropagatesAccessDenied(t *testing.T) {
// AccessDenied from the handler (trust-policy denial) must reach the wire as IAM ErrorResponse + 403.
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
return nil, errors.New(awserrors.ErrorAccessDenied)
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
iamSvc: stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", "*")),
})
body := "Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/app&RoleSessionName=s1"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 403, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "AccessDenied")
assert.Contains(t, string(b), "<ErrorResponse>")
}
func TestSTSRequest_GetCallerIdentity_User_LookupIAM(t *testing.T) {
// User principal triggers IAMService.GetUser to resolve UserId.
iamSvc := &flexMockIAMService{
getUserFn: func(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{UserId: aws.String("AIDAALICE000")}}, nil
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: &flexMockSTSService{},
iamSvc: iamSvc,
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
require.Equal(t, 200, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
xmlStr := string(b)
assert.Contains(t, xmlStr, "<Arn>arn:aws:iam::000000000000:user/alice</Arn>")
assert.Contains(t, xmlStr, "<UserId>AIDAALICE000</UserId>")
}
// A user at a non-root path is reported with its stored ARN, as AWS does,
// resolved by the SigV4 middleware rather than reformatted at "/".
func TestSTSRequest_GetCallerIdentity_PathedUserThroughMiddleware(t *testing.T) {
const pathed = "arn:aws:iam::123456789012:user/eng/alice"
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
STSService: &flexMockSTSService{},
IAMService: &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{testAccessKey: {
AccessKeyID: testAccessKey, SecretAccessKey: encryptedSecret,
UserName: "alice", AccountID: "123456789012", Status: "Active",
}},
getUserFn: func(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{
UserName: aws.String("alice"), UserId: aws.String("AIDAALICE"), Arn: aws.String(pathed),
}}, nil
},
},
})
withServices(gw, stsRegistration(gw), ec2Registration(gw))
handler := gw.SigV4AuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
}))
body := []byte("Action=GetCallerIdentity")
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequest(t, req, body, testAccessKey, testSecretKey)
resp := doRequest(handler, req)
b, _ := io.ReadAll(resp.Body)
require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", string(b))
assert.Contains(t, string(b), "<Arn>"+pathed+"</Arn>")
assert.Contains(t, string(b), "InvalidAction")
}
@@ -1,226 +1,20 @@
// Characterization tests for Resource Groups Tagging API dispatch as it exists
// in tagging.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/domains/tagging"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
const taggingTargetPrefix = "ResourceGroupsTaggingAPI_20170126."
// fakeTaggedLister serves fixed resources and counts calls, so a denial can be
// proven to short-circuit before any lister runs.
type fakeTaggedLister struct {
resources []tagging.TaggedResource
err error
calls int
}
func (f *fakeTaggedLister) ListTagged(_ context.Context, _ string, _ map[string]bool) ([]tagging.TaggedResource, error) {
f.calls++
return f.resources, f.err
}
// taggingPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does,
// on live test NATS, with docs as every principal's policy.
func taggingPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, agg *tagging.Aggregator) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withTagging(gw, agg)
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
func allowAllTaggingDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
func scopedTaggingDocs(effect, action, resource string) []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: effect, Action: iamdomain.StringOrArr{action}, Resource: iamdomain.StringOrArr{resource}},
},
}}
}
// signTaggingRequest builds a POST / signed with credential-scope service
// "tagging", the shape every Resource Groups Tagging API client sends.
func signTaggingRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "tagging", time.Now().UTC())
return req
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(b)
}
func taggingFixture() (*fakeTaggedLister, *fakeTaggedLister) {
elb := &fakeTaggedLister{resources: []tagging.TaggedResource{
{ARN: "arn:aws:elasticloadbalancing:" + testRegion + ":" + authzAccountID + ":loadbalancer/app/web/1", Tags: map[string]string{"env": "prod", "team": "a"}},
}}
ec2 := &fakeTaggedLister{resources: []tagging.TaggedResource{
{ARN: "arn:aws:ec2:" + testRegion + ":" + authzAccountID + ":instance/i-1", Tags: map[string]string{"env": "dev"}},
{ARN: "arn:aws:ec2:" + testRegion + ":" + authzAccountID + ":volume/vol-1"},
}}
return elb, ec2
}
// --- Full-pipeline error envelopes ---------------------------------------
func TestTaggingPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
assert.Equal(t, "MissingActionException", decodeJSONError(t, resp).Type)
}
func TestTaggingPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"TagResources", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
assert.Equal(t, "InvalidActionException", decodeJSONError(t, resp).Type)
}
// A malformed GetResources body reaches the aggregator and fails there.
func TestTaggingPipeline_MalformedBody_ErrorEnvelope(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{not json")))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, resp.Header.Get("X-Amzn-Errortype"), body.Type)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, "InvalidParameterValueException", body.Type)
}
// --- Golden GetResources responses ----------------------------------------
func TestTaggingPipeline_GetResources_GoldenAllResources(t *testing.T) {
elb, ec2 := taggingFixture()
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(elb, ec2))
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, tagging.JSONContentType, resp.Header.Get("Content-Type"))
// Results come back sorted by ARN, not in lister order.
assert.JSONEq(t, `{"PaginationToken":"","ResourceTagMappingList":[`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:instance/i-1","Tags":[{"Key":"env","Value":"dev"}]},`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:volume/vol-1","Tags":[]},`+
`{"ResourceARN":"arn:aws:elasticloadbalancing:`+testRegion+`:`+authzAccountID+`:loadbalancer/app/web/1","Tags":[{"Key":"env","Value":"prod"},{"Key":"team","Value":"a"}]}]}`,
readBody(t, resp))
}
func TestTaggingPipeline_GetResources_GoldenTagFilter(t *testing.T) {
elb, ec2 := taggingFixture()
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(elb, ec2))
body := []byte(`{"TagFilters":[{"Key":"env","Values":["dev"]}]}`)
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", body))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.JSONEq(t, `{"PaginationToken":"","ResourceTagMappingList":[`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:instance/i-1","Tags":[{"Key":"env","Value":"dev"}]}]}`,
readBody(t, resp))
}
func TestTaggingPipeline_GetResources_GoldenPagination(t *testing.T) {
elb, ec2 := taggingFixture()
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(elb, ec2))
body := []byte(`{"ResourcesPerPage":1}`)
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", body))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.JSONEq(t, `{"PaginationToken":"1","ResourceTagMappingList":[`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:instance/i-1","Tags":[{"Key":"env","Value":"dev"}]}]}`,
readBody(t, resp))
}
// A lister error surfaces with its own awserrors code through the envelope.
func TestTaggingPipeline_GetResources_ListerErrorPropagates(t *testing.T) {
failing := &fakeTaggedLister{err: errors.New(awserrors.ErrorServerInternal)}
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(failing))
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "ServerInternalException", decodeJSONError(t, resp).Type)
}
// --- Target parsing and telemetry ------------------------------------------
func TestTaggingActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
cases := []struct {
name, target, wantAction string
@@ -266,145 +60,3 @@ func TestTaggingPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T
assert.Equal(t, "GetResources", got["aws.action"])
assert.Equal(t, "tagging", got["aws.service"])
}
func TestTaggingPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
doRequest(r, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "GetResources", auditField(got, "action"))
}
// --- Oversized body over HTTP ----------------------------------------------
func TestTaggingPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", taggingTargetPrefix+"GetResources")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "tagging", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "RequestEntityTooLargeException", decodeJSONError(t, resp).Type)
}
// --- Unavailable dependencies -----------------------------------------------
// A nil NATS connection fails at the request-level gate, before dispatch.
func TestRequest_ClusterUnavailableNilConn_Tagging(t *testing.T) {
gw := withTagging(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), tagging.NewAggregator())
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", taggingTargetPrefix+"GetResources")
req = req.WithContext(reqctx.WithService(req.Context(), "tagging"))
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "ServiceUnavailableException", decodeJSONError(t, resp).Type)
}
// A connected gateway with no aggregator wired answers ServerInternal.
func TestTaggingPipeline_NoAggregator_500ServerInternal(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "ServerInternalException", decodeJSONError(t, resp).Type)
}
// --- Authorization ----------------------------------------------------------
// Every tagging action is account-level: a grant scoped to a resource ARN does
// not reach it, and a wildcard grant does.
func TestTaggingPipeline_Authz_ScopedAllowDenied_WildcardPermitted(t *testing.T) {
scoped := scopedTaggingDocs("Allow", "tagging:GetResources", "arn:aws:ec2:"+testRegion+":"+authzAccountID+":instance/i-dev")
lister := &fakeTaggedLister{}
_, app := taggingPipelineApp(t, scoped, tagging.NewAggregator(lister))
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, "AccessDeniedException", decodeJSONError(t, resp).Type)
assert.Zero(t, lister.calls, "the aggregator must not run once the gate denies")
_, app = taggingPipelineApp(t, scopedTaggingDocs("Allow", "tagging:GetResources", "*"), tagging.NewAggregator())
resp = doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// A Deny scoped to a resource ARN must not fire: every tagging action is
// evaluated against "*".
func TestTaggingPipeline_Authz_ScopedDenyDoesNotFire(t *testing.T) {
docs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"tagging:*"}, Resource: iamdomain.StringOrArr{"*"}},
{Effect: "Deny", Action: iamdomain.StringOrArr{"tagging:GetResources"}, Resource: iamdomain.StringOrArr{"arn:aws:ec2:" + testRegion + ":" + authzAccountID + ":instance/i-prod"}},
},
}}
_, app := taggingPipelineApp(t, docs, tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// An unrelated action grant does not carry to GetResources, and an explicit
// Deny on the action wins over a wildcard Allow.
func TestTaggingPipeline_Authz_UnrelatedGrantAndExplicitDeny(t *testing.T) {
_, app := taggingPipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
docs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
{Effect: "Deny", Action: iamdomain.StringOrArr{"tagging:GetResources"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
_, app = taggingPipelineApp(t, docs, tagging.NewAggregator())
resp = doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
// An unknown action is rejected before authorization: with no grant at all it
// still answers InvalidAction rather than AccessDenied.
func TestTaggingPipeline_Authz_UnknownActionRejectedBeforeGate(t *testing.T) {
_, app := taggingPipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"TagResources", []byte("{}")))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, "InvalidActionException", decodeJSONError(t, resp).Type)
}
// A denied request is answered AccessDenied even when its body is also bad:
// the gate runs before the body is read.
func TestTaggingPipeline_Authz_DenyPrecedesBodyHandling(t *testing.T) {
_, app := taggingPipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte(strings.Repeat("{", 8))))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, "AccessDeniedException", decodeJSONError(t, resp).Type)
}
@@ -1,89 +1,14 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helper setupEC2Request.
package gateway
import (
"context"
"fmt"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
)
// The TestCurrentBehaviour_Tagging_ tests here pin today's EC2 tag-write
// outcomes at the AWSGW door. They drive the real router over the real EC2 tag
// service on an embedded JetStream, and describe current behaviour, not the
// target design. The daemon's record-mirror fan-out is not wired here, so only
// the central tag index is exercised.
// newTaggingCBGateway serves ec2.CreateTags from a real TagsServiceImpl
// over a fresh JetStream bucket, with no legacy object store.
func newTaggingCBGateway(t *testing.T) (*GatewayConfig, *ec2tags.TagsServiceImpl) {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
kv, err := ec2tags.GetOrCreateTagsBucket(t.Context(), js)
require.NoError(t, err)
svc := ec2tags.NewTagsServiceImplWithStore(&config.Config{}, nil, kv)
serve := func(subject string, fn func(*nats.Msg)) {
sub, err := nc.Subscribe(subject, fn)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
serve("ec2.CreateTags", func(msg *nats.Msg) {
natsmsg.ServeNATSRequestCtx(msg, func(ctx context.Context, in *ec2.CreateTagsInput) (*ec2.CreateTagsOutput, error) {
return svc.CreateTags(ctx, in, natsmsg.AccountIDFromMsg(msg))
})
})
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService(), NATSConn: nc})
return gw, svc
}
// ec2TagQuery builds a CreateTags or DeleteTags query body.
func ec2TagQuery(action string, resources []string, tags map[string]string) string {
q := url.Values{"Action": {action}}
for i, r := range resources {
q.Set(fmt.Sprintf("ResourceId.%d", i+1), r)
}
i := 0
for k, v := range tags {
i++
q.Set(fmt.Sprintf("Tag.%d.Key", i), k)
q.Set(fmt.Sprintf("Tag.%d.Value", i), v)
}
return q.Encode()
}
func requireEC2OK(t *testing.T, gw *GatewayConfig, body string) {
t.Helper()
w := serveEC2(gw, body)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
func centralTagCount(t *testing.T, svc *ec2tags.TagsServiceImpl, resourceID string) int {
t.Helper()
out, err := svc.DescribeTags(t.Context(), &ec2.DescribeTagsInput{
Filters: []*ec2.Filter{{Name: aws.String("resource-id"), Values: []*string{aws.String(resourceID)}}},
}, authzAccountID)
require.NoError(t, err)
return len(out.Tags)
}
// CreateTags refuses an aws: key but applies no tag-count, key-length or
// value-length limit: 51 tags, a 129-character key and a 257-character value
// are all stored.
+5 -5
View File
@@ -1,10 +1,10 @@
package gateway
import "github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
import "github.com/mulgadc/spinifex/internal/testkit/gwfix"
// withVerifier authenticates through the same IAM and STS instances the config
// holds, so a test that changes one of them changes authentication too.
// withVerifier authenticates through the IAM service the config holds, so a
// test that changes it changes authentication too.
func withVerifier(gw *GatewayConfig) *GatewayConfig {
gw.Credentials = credverify.New(gw.IAMService, gw.STSService)
return gw
gw.Credentials = &gwfix.Verifier{IAM: gw.IAMService}
return withFakeCapabilities(gw)
}
+250
View File
@@ -0,0 +1,250 @@
// Package compress negotiates response content encoding for the AWS-compatible
// gateway.
//
// Responses here are XML and JSON describing repeated records, which compress
// by an order of magnitude or more. A browser always offers gzip and cannot be
// configured not to, so the console benefits on every call.
//
// Compression is a transport property. It is not a substitute for a response
// whose size is bounded by what it describes rather than by how many exist: a
// high ratio is evidence of redundancy, not of efficiency.
package compress
import (
"compress/gzip"
"net/http"
"strings"
"sync"
)
// minCompressBytes is the smallest response worth compressing. Below about a
// kilobyte the gzip header and the CPU cost outweigh the saving, and a short
// AWS error envelope is the commonest reply on the surface.
const minCompressBytes = 1024
// gzipLevel trades ratio for CPU. The default sits close to the best ratio on
// the repetitive XML this surface returns.
const gzipLevel = gzip.DefaultCompression
// passThroughTypes are content types that must reach the client byte for byte.
//
// The first two stream: the handler writes an event and flushes, so buffering
// to reach the size threshold would hold a sample until the next one arrived.
// The rest are already compressed, so a second pass costs CPU and grows them.
var passThroughTypes = []string{
"text/event-stream",
"application/vnd.amazon.eventstream",
"application/vnd.oci.",
"application/vnd.docker.",
"application/gzip",
"application/zip",
"application/x-gzip",
"image/",
"video/",
"audio/",
}
var writerPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(nil, gzipLevel)
return w
},
}
// Middleware compresses a response when the client offered gzip, the body is
// large enough to be worth it, and the handler has not ruled it out.
//
// Mount it inside the authenticated group. The OCI Distribution registry sets
// its own Content-Length and serves already-compressed layers, so it is
// excluded by where this is mounted as well as by the checks below.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !acceptsGzip(r.Header.Get("Accept-Encoding")) {
next.ServeHTTP(w, r)
return
}
cw := &compressWriter{ResponseWriter: w}
defer cw.close()
next.ServeHTTP(cw, r)
})
}
// acceptsGzip reports whether the header offers gzip without refusing it.
//
// A q-value of 0 is a refusal, which is how a client opts out. Anything else,
// including an absent q-value, is an offer.
func acceptsGzip(header string) bool {
for part := range strings.SplitSeq(header, ",") {
fields := strings.Split(strings.TrimSpace(part), ";")
coding := strings.ToLower(strings.TrimSpace(fields[0]))
if coding != "gzip" && coding != "*" {
continue
}
for _, param := range fields[1:] {
param = strings.ToLower(strings.ReplaceAll(param, " ", ""))
if param == "q=0" || strings.HasPrefix(param, "q=0.0") {
return false
}
}
return true
}
return false
}
// shouldCompress reports whether a response with these headers may be gzipped.
func shouldCompress(h http.Header) bool {
// Already encoded, so re-encoding would corrupt it.
if h.Get("Content-Encoding") != "" {
return false
}
// A handler that declared its own length has told the client exactly how
// many bytes to expect. Compressing would make that number wrong, and the
// OCI Distribution clients enforce it.
if h.Get("Content-Length") != "" {
return false
}
contentType := strings.ToLower(h.Get("Content-Type"))
for _, t := range passThroughTypes {
if strings.HasPrefix(contentType, t) {
return false
}
}
return true
}
// compressWriter buffers the first minCompressBytes to decide whether the body
// is worth compressing, then either gzips the rest or passes everything
// through. A Flush before that decision means the handler is streaming, so
// compression is abandoned rather than holding the data back.
type compressWriter struct {
http.ResponseWriter
status int
wroteHeader bool
// decided is set once the choice between gzip and pass-through is made.
decided bool
compressed bool
buf []byte
gz *gzip.Writer
}
// Unwrap exposes the underlying writer, so a handler probing the chain for
// http.Flusher or http.Hijacker still finds it.
func (cw *compressWriter) Unwrap() http.ResponseWriter {
return cw.ResponseWriter
}
func (cw *compressWriter) WriteHeader(status int) {
if cw.wroteHeader {
return
}
cw.wroteHeader = true
cw.status = status
// A header-only reply, an error envelope or a redirect carries nothing worth
// compressing, and 204 and 304 must carry no body at all.
if !shouldCompress(cw.Header()) || status == http.StatusNoContent || status == http.StatusNotModified {
cw.decide(false)
return
}
// The header is held back until the size is known: Content-Encoding cannot
// be added after the status line has gone out.
}
func (cw *compressWriter) Write(p []byte) (int, error) {
if !cw.wroteHeader {
cw.WriteHeader(http.StatusOK)
}
if cw.decided {
if cw.compressed {
return cw.gz.Write(p)
}
return cw.ResponseWriter.Write(p)
}
cw.buf = append(cw.buf, p...)
if len(cw.buf) >= minCompressBytes {
cw.decide(true)
}
return len(p), nil
}
// Flush is required by the SSE and event-stream handlers, which probe the
// writer chain for it before writing a header and fail the request if it is
// absent.
//
// A flush before the size decision means the handler is streaming, so the
// buffered bytes go out uncompressed and nothing is held back.
func (cw *compressWriter) Flush() {
if !cw.decided {
cw.decide(false)
}
if cw.compressed {
// Without this the compressor keeps the data, and a stream the client is
// waiting on stalls rather than arriving late.
_ = cw.gz.Flush()
}
if f, ok := cw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// decide commits to gzip or pass-through, writes the held-back header and
// drains the buffer.
func (cw *compressWriter) decide(compressed bool) {
if cw.decided {
return
}
cw.decided = true
cw.compressed = compressed
if !cw.wroteHeader {
cw.wroteHeader = true
cw.status = http.StatusOK
}
if compressed {
cw.Header().Set("Content-Encoding", "gzip")
cw.Header().Add("Vary", "Accept-Encoding")
// The length of the compressed body is not known yet, and the value the
// handler may have set describes the uncompressed one.
cw.Header().Del("Content-Length")
cw.ResponseWriter.WriteHeader(cw.status)
gz, _ := writerPool.Get().(*gzip.Writer)
gz.Reset(cw.ResponseWriter)
cw.gz = gz
if len(cw.buf) > 0 {
_, _ = gz.Write(cw.buf)
}
cw.buf = nil
return
}
if cw.status != 0 {
cw.ResponseWriter.WriteHeader(cw.status)
}
if len(cw.buf) > 0 {
_, _ = cw.ResponseWriter.Write(cw.buf)
cw.buf = nil
}
}
// close finishes the gzip stream, or emits a body that never reached the size
// threshold.
func (cw *compressWriter) close() {
if !cw.decided {
cw.decide(false)
return
}
if cw.compressed && cw.gz != nil {
_ = cw.gz.Close()
writerPool.Put(cw.gz)
cw.gz = nil
}
}
@@ -0,0 +1,323 @@
package compress
import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// largeXML is above the size threshold and is the shape this surface returns:
// repeated records, which is why compression pays here.
func largeXML() string {
var b strings.Builder
b.WriteString("<DescribeInstanceTypesResponse>")
for range 200 {
b.WriteString("<item><instanceType>t3.micro</instanceType></item>")
}
b.WriteString("</DescribeInstanceTypesResponse>")
return b.String()
}
// serve runs one request through the middleware and returns the recorder.
func serve(t *testing.T, acceptEncoding string, h http.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if acceptEncoding != "" {
req.Header.Set("Accept-Encoding", acceptEncoding)
}
rec := httptest.NewRecorder()
Middleware(h).ServeHTTP(rec, req)
return rec
}
// gunzip fails the test rather than returning an error, so a body that is not
// valid gzip is reported where it happened.
func gunzip(t *testing.T, body io.Reader) string {
t.Helper()
zr, err := gzip.NewReader(body)
require.NoError(t, err)
out, err := io.ReadAll(zr)
require.NoError(t, err)
return string(out)
}
func TestLargeXMLIsCompressedAndRoundTrips(t *testing.T) {
t.Parallel()
want := largeXML()
rec := serve(t, "gzip, deflate, br", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(want))
})
assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding")
assert.Equal(t, want, gunzip(t, rec.Body))
assert.Less(t, rec.Body.Len(), len(want)/4, "repeated XML must compress well")
}
// The saving is the reason for the middleware, so it is asserted rather than
// assumed.
func TestCompressedBodyIsSmallerThanTheOriginal(t *testing.T) {
t.Parallel()
want := largeXML()
plain := serve(t, "", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(want))
})
gzipped := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(want))
})
assert.Empty(t, plain.Header().Get("Content-Encoding"))
assert.Equal(t, len(want), plain.Body.Len())
assert.Less(t, gzipped.Body.Len(), plain.Body.Len())
}
func TestClientThatDoesNotOfferGzipGetsPlainBytes(t *testing.T) {
t.Parallel()
want := largeXML()
for name, accept := range map[string]string{
"absent": "",
"deflate": "deflate",
"refusedQ": "gzip;q=0",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
rec := serve(t, accept, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(want))
})
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Equal(t, want, rec.Body.String())
})
}
}
// A short error envelope is the commonest reply on this surface, and gzipping
// it would add bytes.
func TestShortBodyIsNotCompressed(t *testing.T) {
t.Parallel()
const want = `<Response><Errors><Error><Code>InvalidAction</Code></Error></Errors></Response>`
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(want))
})
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, want, rec.Body.String())
}
// A handler that declared its own length has told the client how many bytes to
// expect, and the OCI Distribution clients enforce it.
func TestHandlerDeclaredContentLengthIsNotCompressed(t *testing.T) {
t.Parallel()
want := largeXML()
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(want)))
_, _ = w.Write([]byte(want))
})
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Equal(t, strconv.Itoa(len(want)), rec.Header().Get("Content-Length"))
assert.Equal(t, want, rec.Body.String())
}
func TestAlreadyEncodedBodyIsNotReEncoded(t *testing.T) {
t.Parallel()
want := largeXML()
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Encoding", "br")
_, _ = w.Write([]byte(want))
})
assert.Equal(t, "br", rec.Header().Get("Content-Encoding"))
assert.Equal(t, want, rec.Body.String())
}
// Each of these reaches a client byte for byte: the first two stream, and the
// rest are already compressed.
func TestPassThroughContentTypesAreNotCompressed(t *testing.T) {
t.Parallel()
want := largeXML()
for _, contentType := range []string{
"text/event-stream",
"application/vnd.amazon.eventstream",
"application/vnd.oci.image.layer.v1.tar+gzip",
"application/vnd.docker.image.rootfs.diff.tar.gzip",
"application/gzip",
"image/png",
} {
t.Run(contentType, func(t *testing.T) {
t.Parallel()
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentType)
_, _ = w.Write([]byte(want))
})
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Equal(t, want, rec.Body.String())
})
}
}
// GetInstanceMetrics and the Bedrock event stream walk the writer chain for
// http.Flusher before writing a header and fail the request if they do not find
// one, so the wrapper must present it and must expose the writer beneath.
func TestWriterPresentsFlusherAndUnwrapsToTheRealWriter(t *testing.T) {
t.Parallel()
var sawFlusher, sawUnwrap bool
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
_, sawFlusher = w.(http.Flusher)
u, ok := w.(interface{ Unwrap() http.ResponseWriter })
if ok {
_, sawUnwrap = u.Unwrap().(http.Flusher)
}
w.WriteHeader(http.StatusOK)
})
assert.True(t, sawFlusher, "handler must find http.Flusher on the wrapper")
assert.True(t, sawUnwrap, "Unwrap must reach the writer beneath")
assert.Equal(t, http.StatusOK, rec.Code)
}
// A handler that flushes before the size threshold is streaming, so the bytes
// go out as written rather than waiting for a buffer to fill.
func TestFlushBeforeTheThresholdSendsBytesUncompressed(t *testing.T) {
t.Parallel()
const event = "data: {\"cpu\":1}\n\n"
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(event))
w.(http.Flusher).Flush()
})
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Equal(t, event, rec.Body.String())
assert.True(t, rec.Flushed)
}
// A flush after compression has started must push the compressor's buffer, or
// a client waiting on the stream sees nothing until the handler returns.
//
// The bytes are read at the moment of the flush, before the stream is closed,
// so a wrapper that forwards Flush without flushing the compressor fails here.
func TestFlushAfterCompressionStartedReachesTheClient(t *testing.T) {
t.Parallel()
want := largeXML()
var atFlush []byte
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Encoding", "gzip")
Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(want))
w.(http.Flusher).Flush()
atFlush = append(atFlush, rec.Body.Bytes()...)
})).ServeHTTP(rec, req)
require.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
require.NotEmpty(t, atFlush, "nothing reached the client at the flush")
// A flushed deflate stream has no trailer yet, so the reader reports an
// unexpected EOF after yielding everything written so far.
zr, err := gzip.NewReader(strings.NewReader(string(atFlush)))
require.NoError(t, err)
got, err := io.ReadAll(zr)
if err != nil {
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
}
assert.Equal(t, want, string(got), "the flush must carry the whole body written so far")
}
// 204 and 304 must carry no body, so nothing may be added to them.
func TestBodylessStatusesAreUntouched(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusNoContent, http.StatusNotModified} {
t.Run(http.StatusText(status), func(t *testing.T) {
t.Parallel()
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
})
assert.Equal(t, status, rec.Code)
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Empty(t, rec.Body.String())
})
}
}
// A handler that writes nothing must still produce a valid response.
func TestEmptyBodyProducesNoEncoding(t *testing.T) {
t.Parallel()
rec := serve(t, "gzip", func(http.ResponseWriter, *http.Request) {})
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Empty(t, rec.Body.String())
}
// The status a handler set must survive the header being held back while the
// body's size is measured.
func TestHandlerStatusSurvivesTheDeferredHeader(t *testing.T) {
t.Parallel()
want := largeXML()
rec := serve(t, "gzip", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(want))
})
assert.Equal(t, http.StatusAccepted, rec.Code)
assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
assert.Equal(t, want, gunzip(t, rec.Body))
}
func TestAcceptsGzip(t *testing.T) {
t.Parallel()
for header, want := range map[string]bool{
"": false,
"gzip": true,
"gzip, deflate, br": true,
"br, gzip;q=0.5": true,
"*": true,
"deflate": false,
"gzip;q=0": false,
"gzip;q=0.0": false,
"identity": false,
"GZIP": true,
"deflate, gzip ; q=0.9": true,
} {
t.Run(header, func(t *testing.T) {
t.Parallel()
assert.Equal(t, want, acceptsGzip(header))
})
}
}
@@ -0,0 +1,72 @@
// Cluster-wide free capacity per instance type, for a client that needs the
// count rather than the catalogue.
package admin
import (
"context"
"encoding/json"
"time"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
)
// GetInstanceTypeCapacityAction is open to any account. It reports how many
// instances of each type the cluster can still admit and names no resource, so
// a policy grants or denies it as a whole.
const GetInstanceTypeCapacityAction = "GetInstanceTypeCapacity"
// capacityCensusTimeout bounds the node-status fan-out. A short deadline is
// right here: the figure is advisory, and RunInstances is what decides.
const capacityCensusTimeout = 3 * time.Second
// GetInstanceTypeCapacityOutput is the response for GetInstanceTypeCapacity.
//
// Available is keyed by instance type name and holds the cluster-wide free slot
// count. A type the cluster supports but cannot currently admit is absent, so
// an absent key and a zero are the same answer.
type GetInstanceTypeCapacityOutput struct {
Available map[string]int64 `json:"available"`
Nodes int `json:"nodes"`
}
// GetInstanceTypeCapacity sums each node's per-type availability.
//
// The reply is bounded by the number of instance types, never by how much
// capacity is free. The same census is what the scheduler places against.
func GetInstanceTypeCapacity(ctx context.Context, nc *nats.Conn, expectedNodes int, accountID string) (*GetInstanceTypeCapacityOutput, error) {
if expectedNodes < 1 {
expectedNodes = 1
}
frames, _, err := natsmsg.Gather(ctx, nc, clusterv1.NodeStatusSubject, []byte("{}"),
natsmsg.GatherOpts{Timeout: capacityCensusTimeout, ExpectedNodes: expectedNodes, AccountID: accountID})
if err != nil {
return nil, err
}
available := make(map[string]int64)
seen := make(map[string]bool, len(frames))
for _, frame := range frames {
var status clusterv1.NodeStatusResponse
if err := json.Unmarshal(frame.Data, &status); err != nil {
continue
}
// A node answering twice would double its capacity.
if status.Node == "" || seen[status.Node] {
continue
}
seen[status.Node] = true
for _, c := range status.InstanceTypes {
if c.Name == "" || c.Available <= 0 {
continue
}
available[c.Name] += int64(c.Available)
}
}
return &GetInstanceTypeCapacityOutput{Available: available, Nodes: len(seen)}, nil
}
@@ -0,0 +1,152 @@
package admin
import (
"context"
"encoding/json"
"net/http"
"testing"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/internal/testkit"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// answerNodeStatus replies to the node-status fan-out as the given nodes would.
func answerNodeStatus(t *testing.T, nc *nats.Conn, nodes ...clusterv1.NodeStatusResponse) {
t.Helper()
for _, node := range nodes {
data, err := json.Marshal(node)
require.NoError(t, err)
sub, err := nc.Subscribe(clusterv1.NodeStatusSubject, func(m *nats.Msg) {
_ = m.Respond(data)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
require.NoError(t, nc.Flush())
}
func node(name string, types ...clusterv1.InstanceTypeCap) clusterv1.NodeStatusResponse {
return clusterv1.NodeStatusResponse{Node: name, InstanceTypes: types}
}
func TestGetInstanceTypeCapacitySumsAcrossNodes(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
answerNodeStatus(t, nc,
node("node1", clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 60}, clusterv1.InstanceTypeCap{Name: "c6a.large", Available: 4}),
node("node2", clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 62}),
)
out, err := GetInstanceTypeCapacity(context.Background(), nc, 2, "000000000002")
require.NoError(t, err)
assert.Equal(t, int64(122), out.Available["t3.micro"])
assert.Equal(t, int64(4), out.Available["c6a.large"])
assert.Equal(t, 2, out.Nodes)
}
// The reply carries a count per type, so its size follows the catalogue and not
// how much capacity is free. This is the property the EC2 listing lost.
func TestGetInstanceTypeCapacityReplyIsBoundedByTypeCount(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
answerNodeStatus(t, nc, node("node1",
clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 40000},
clusterv1.InstanceTypeCap{Name: "t3.small", Available: 20000},
))
out, err := GetInstanceTypeCapacity(context.Background(), nc, 1, "000000000002")
require.NoError(t, err)
data, err := json.Marshal(out)
require.NoError(t, err)
assert.Len(t, out.Available, 2)
assert.Less(t, len(data), 256, "60,000 free slots must not grow the reply: %s", data)
}
// A type the cluster supports but cannot currently admit is absent rather than
// reported as zero, so a client reads one answer and not two.
func TestGetInstanceTypeCapacityOmitsTypesWithNoFreeSlot(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
answerNodeStatus(t, nc, node("node1",
clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 3},
clusterv1.InstanceTypeCap{Name: "x2gd.metal", Available: 0},
))
out, err := GetInstanceTypeCapacity(context.Background(), nc, 1, "000000000002")
require.NoError(t, err)
assert.Equal(t, map[string]int64{"t3.micro": 3}, out.Available)
}
// A node answering twice would double the cluster's apparent capacity.
func TestGetInstanceTypeCapacityCountsEachNodeOnce(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
answerNodeStatus(t, nc,
node("node1", clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 5}),
node("node1", clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 5}),
)
out, err := GetInstanceTypeCapacity(context.Background(), nc, 2, "000000000002")
require.NoError(t, err)
assert.Equal(t, int64(5), out.Available["t3.micro"])
assert.Equal(t, 1, out.Nodes)
}
// Capacity is not operator-only: the launch form a tenant uses is what needs
// the figure, so this action must not be account-gated the way GetNodes is.
func TestRegistration_InstanceTypeCapacityIsNotAdminOnly(t *testing.T) {
assert.False(t, adminActions[GetInstanceTypeCapacityAction])
assert.NotContains(t, AdminActionNames(), GetInstanceTypeCapacityAction)
_, nc := testutil.StartTestNATS(t)
answerNodeStatus(t, nc, node("node1", clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 7}))
w, err := serve(t, Deps{NATS: nc, NodeIDs: []string{"node1"}}, "000000000002",
action(GetInstanceTypeCapacityAction), nil)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), `"t3.micro":7`)
}
// The action is reachable for the super-admin account too, so the operator
// console and a tenant console read the same figure.
func TestRegistration_InstanceTypeCapacityServesTheAdminAccount(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
answerNodeStatus(t, nc, node("node1", clusterv1.InstanceTypeCap{Name: "t3.micro", Available: 7}))
w, err := serve(t, Deps{NATS: nc, NodeIDs: []string{"node1"}}, awsidentifiers.DefaultAccountID(),
action(GetInstanceTypeCapacityAction), nil)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
}
// The inventory feeds the coverage surface and is not cross-checked against the
// dispatcher, so an action added to one and not the other fails nothing.
func TestRegistration_InventoryNamesEveryDispatchedAction(t *testing.T) {
assert.Contains(t, ActionNames(), GetInstanceTypeCapacityAction)
assert.Contains(t, ActionNames(), GetInstanceMetricsAction)
for _, name := range AdminActionNames() {
assert.Contains(t, ActionNames(), name)
}
// A dispatched action absent from the inventory would still answer, so the
// check runs the other way too: every registered name must dispatch.
for _, name := range ActionNames() {
t.Run(name, func(t *testing.T) {
_, err := serve(t, Deps{}, awsidentifiers.DefaultAccountID(), action(name), nil)
if err == nil {
return
}
assert.NotContains(t, err.Error(), "InvalidAction",
"%s is in the inventory but the dispatcher does not know it", name)
})
}
}
+6 -1
View File
@@ -81,7 +81,7 @@ func AdminActionNames() []string {
// ActionNames lists every action the registration dispatches, sorted.
func ActionNames() []string {
names := append(AdminActionNames(), GetInstanceMetricsAction)
names := append(AdminActionNames(), GetInstanceMetricsAction, GetInstanceTypeCapacityAction)
slices.Sort(names)
return names
}
@@ -193,6 +193,11 @@ func (d Deps) run(ctx context.Context, action string, args map[string]string, ac
return nil, errors.New(awserrors.ErrorServerInternal)
}
return GetNodes(ctx, d.NATS, d.activeNodes(ctx))
case GetInstanceTypeCapacityAction:
if d.NATS == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
return GetInstanceTypeCapacity(ctx, d.NATS, d.activeNodes(ctx), accountID)
case "GetVMs":
if d.NATS == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
@@ -248,6 +248,7 @@ func TestNewRegistration_DeclaresTheAdminActionsAsItsInventory(t *testing.T) {
"EnsureSQSStore",
"EnsureSQSWorker",
"GetInstanceMetrics",
"GetInstanceTypeCapacity",
}, reg.Inventory.Registered)
assert.NoError(t, dispatch.NewBuilder().Register(reg))
}
+23 -10
View File
@@ -608,8 +608,8 @@ func TestHandleEC2DescribeInstanceTypes(t *testing.T) {
}
})
// Test 4: Verify "capacity" filter returns duplicates
t.Run("VerifyCapacityFilter_Duplicates", func(t *testing.T) {
// Test 4: Verify "capacity" filter names each type once, with a count
t.Run("VerifyCapacityFilter_CountPerType", func(t *testing.T) {
// Force schedulable capacity to a predictable state by zeroing the
// reserve and pinning host figures directly.
daemon.resourceMgr.mu.Lock()
@@ -650,8 +650,8 @@ func TestHandleEC2DescribeInstanceTypes(t *testing.T) {
require.NoError(t, err)
// With 2 vCPUs and 16GB, every type whose full charge (guest -m + nbdkit,
// RG-6) fits 2 vCPUs / 16GB should have 1 slot. Calculate expected directly.
expectedSlots := 0
// RG-6) fits is listed once. Calculate the expected names directly.
expectedTypes := 0
for name, it := range daemon.resourceMgr.instanceTypes {
if instancetypes.IsSystemType(name) {
continue
@@ -659,13 +659,13 @@ func TestHandleEC2DescribeInstanceTypes(t *testing.T) {
vcpus := *it.VCpuInfo.DefaultVCpus
chargeGB := float64(daemon.resourceMgr.instanceMemChargeMiB(it)) / 1024.0
if vcpus <= 2 && chargeGB <= 16.0 {
expectedSlots++
expectedTypes++
}
}
assert.Len(t, output.InstanceTypes, expectedSlots,
"Should have %d slots for types fitting 2 vCPU / 16GB", expectedSlots)
assert.Len(t, output.InstanceTypes, expectedTypes,
"Should name %d types fitting 2 vCPU / 16GB", expectedTypes)
// Now increase capacity to test duplicate slots
// Now raise capacity: the listing keeps its length and the count rises
daemon.resourceMgr.mu.Lock()
daemon.resourceMgr.hostVCPU = 4
daemon.resourceMgr.hostMemGB = 15.0
@@ -676,8 +676,21 @@ func TestHandleEC2DescribeInstanceTypes(t *testing.T) {
err = json.Unmarshal(reply.Data, &output)
require.NoError(t, err)
// Verify the count rises with capacity — find a nano type and confirm 2 slots
// Find any nano type in the generated types
// The extra capacity shows up in the count and nowhere else: the listing
// names each admissible type once, so its size does not follow free slots.
widened := 0
for name, it := range daemon.resourceMgr.instanceTypes {
if instancetypes.IsSystemType(name) {
continue
}
chargeGB := float64(daemon.resourceMgr.instanceMemChargeMiB(it)) / 1024.0
if *it.VCpuInfo.DefaultVCpus <= 4 && chargeGB <= 15.0 {
widened++
}
}
assert.Len(t, output.InstanceTypes, widened,
"the listing is one entry per admissible type, not per free slot")
var nanoType string
for name := range daemon.resourceMgr.instanceTypes {
if strings.HasSuffix(name, ".nano") {
@@ -0,0 +1,276 @@
// Characterization tests for ACM control-plane dispatch as it exists in
// acm.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package awsgw_test
import (
"bytes"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// acmPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS, with docs as every principal's policy. The region is
// gwfix.Region because gwfix.SignRequestAs always signs for it.
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*gateway.GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
NATSConn: nc,
IAMService: &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withACM(gw, acmawsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllACMDocs is the policy acmPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllACMDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signACMRequest builds a POST / signed with credential-scope service "acm",
// the shape every ACM JSON 1.1 client sends.
func signACMRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, "acm", time.Now().UTC())
return req
}
// subscribeACMCounter answers subject with payload and counts invocations, so
// a denial can be proven to short-circuit before the handler ever runs.
func subscribeACMCounter(t *testing.T, nc *nats.Conn, subject string, payload []byte) *int {
t.Helper()
calls := 0
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
calls++
_ = msg.Respond(payload)
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
t.Cleanup(func() { _ = sub.Unsubscribe() })
return &calls
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestACMPipeline_MissingTarget_400MissingActionException pins that an ACM
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestACMPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
resp := doRequest(app, signACMRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestACMPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from acmActions fails with InvalidAction.
func TestACMPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
target := "CertificateManager.MadeUpAction"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestACMPipeline_ListCertificates_200JSONContentType pins that a successful
// ACM action answers 200 with the AWS JSON 1.1 content type through the full
// pipeline, once the daemon-side NATS responder answers.
func TestACMPipeline_ListCertificates_200JSONContentType(t *testing.T) {
gw, app := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
target := "CertificateManager.ListCertificates"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, acmawsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// --- Oversized body over HTTP ---------------------------------------------
// TestACMPipeline_OversizedBody_413OverHTTP pins that an ACM-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ACM dispatch ever runs, with the JSON error envelope.
func TestACMPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", "CertificateManager.ListCertificates")
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, "acm", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before the NATS-backed action ----------------------
// TestACMPipeline_AuthzGateBlocksAction pins that the policy gate in ACM
// dispatch runs, and denies, before the NATS-backed action handler is ever
// invoked, through the full pipeline.
func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"acm:DeleteCertificate"},
Resource: iamdomain.StringOrArr{"arn:aws:acm:" + gwfix.Region + ":" + authzAccountID + ":certificate/other"},
},
},
}}
gw, app := acmPipelineApp(t, scopedDocs)
calls := subscribeACMCounter(t, gw.NATSConn, "acm.DeleteCertificate", []byte(`{}`))
target := "CertificateManager.DeleteCertificate"
body := []byte(`{"CertificateArn":"arn:aws:acm:` + gwfix.Region + `:` + authzAccountID + `:certificate/prod-1111"}`)
resp := doRequest(app, signACMRequest(t, target, body))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
respBody := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", respBody.Type)
assert.Equal(t, 0, *calls, "the NATS-backed handler must not run once the gate denies")
}
// --- NATS unavailable for ACM ---------------------------------------------
// TestRequest_ClusterUnavailableNilConn_ACM pins that ACM fails at the
// request-level NATS gate before dispatch reads the action, so ACM's own
// nil-connection check is unreachable over HTTP.
func TestRequest_ClusterUnavailableNilConn_ACM(t *testing.T) {
for _, action := range []string{"ListCertificates", "DeleteCertificate"} {
t.Run(action, func(t *testing.T) {
gw := withACM(withVerifier(&gateway.GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), acmawsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", "CertificateManager."+action)
ctx := reqctx.WithService(req.Context(), "acm")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ACM request ---------------------------------
// TestACMPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ACM action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestACMPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := "CertificateManager.ListCertificates"
doRequest(r, signACMRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListCertificates", auditField(got, "action"))
}
// --- Inventory pin ----------------------------------------------------------
// TestACMInventory_RegisteredArePinned pins the ACM inventory so a refactor
// that drops or adds an action, including by moving ACM onto the
// registration seam, fails here.
func TestACMInventory_RegisteredArePinned(t *testing.T) {
acm := gateway.AWSOperationInventory(acmRegistrationInventory())["acm"]
want := []string{
"ImportCertificate", "RequestCertificate", "DescribeCertificate", "GetCertificate",
"ListCertificates", "DeleteCertificate", "ListTagsForCertificate", "AddTagsToCertificate",
"RemoveTagsFromCertificate",
}
assert.ElementsMatch(t, want, acm.Registered)
assert.Empty(t, acm.Stubbed)
assert.Empty(t, acm.Unsupported)
}
@@ -1,13 +1,14 @@
package gateway
package awsgw_test
import (
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// withACM registers the ACM control plane on gw backed by deps, as the awsgw
// role composes it. An unset deps.NATS takes gw.NATSConn.
func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
func withACM(gw *gateway.GatewayConfig, deps acmawsapi.Deps) *gateway.GatewayConfig {
if deps.NATS == nil {
deps.NATS = gw.NATSConn
}
@@ -22,5 +23,5 @@ func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
// acmRegistrationInventory is the inventory the ACM registration declares to
// a registry, keyed as AWSOperationInventory expects.
func acmRegistrationInventory() map[string]dispatch.Inventory {
return withACM(&GatewayConfig{}, acmawsapi.Deps{}).Services.Inventory()
return withACM(&gateway.GatewayConfig{}, acmawsapi.Deps{}).Services.Inventory()
}
@@ -0,0 +1,21 @@
package awsgw_test
//test:in-package — the status gate is an unexported step of the auth
// middleware, and its cache is deliberately not part of the package's API.
import (
"net/http"
"testing"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
)
// Until this gate existed, account status was enforced only in ECR's principal
// resolution, so suspending an account left EC2, S3 and STS wide open.
func TestAuthDeniesASuspendedAccount(t *testing.T) {
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusSuspended))
if resp := doRequest(r, req); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a SUSPENDED account, got %d", resp.StatusCode)
}
}
@@ -0,0 +1,439 @@
package awsgw_test
//test:in-package — this characterization drives unexported gateway wiring.
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
)
// Pins CURRENT behaviour: wire outcomes for invalid long-lived credentials, and
// that key status changes apply to the very next request (no credential cache).
func TestCurrentBehaviour_Auth_LongLivedRejections(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
gci := cbForm("GetCallerIdentity")
t.Run("unknown_key", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), got)
})
t.Run("wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: e.alice.akid, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
t.Run("malformed_authorization_header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(gci.Encode()))
req.RemoteAddr = "10.200.0.1:40000"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=garbage")
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
assert.Equal(t, cbRegistry(awserrors.ErrorIncompleteSignature), cbOutcomeOf(rec))
})
t.Run("key_made_inactive_after_use", func(t *testing.T) {
creds := e.newKey(t, "alice")
require.Equal(t, http.StatusOK, e.callerIdentity(t, creds).Code)
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(creds.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, creds)))
})
t.Run("key_deleted_after_use", func(t *testing.T) {
u, _ := e.newUser(t, "dave")
require.Equal(t, http.StatusOK, e.callerIdentity(t, u).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("dave"), AccessKeyId: aws.String(u.akid),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, u)))
})
}
// Pins CURRENT behaviour: AssumeRole session credentials at the SigV4 door.
func TestCurrentBehaviour_Session_AssumeRoleCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, roleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
other := e.assumeRole(t, roleARN, "s2")
t.Run("valid_session", func(t *testing.T) {
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
body := rec.Body.String()
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/app/s1", cbXML(body, "Arn"))
assert.Equal(t, roleID+":s1", cbXML(body, "UserId"))
assert.Equal(t, e.accountID, cbXML(body, "Account"))
})
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
for name, c := range map[string]cbCreds{
"missing_token": {akid: s.akid, secret: s.secret},
"other_sessions_token": {akid: s.akid, secret: s.secret, token: other.token},
"tampered_token": {akid: s.akid, secret: s.secret, token: tamper(s.token)},
"unknown_session_akid": {akid: cbUnknownSes, secret: s.secret, token: s.token},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
// CURRENT: an AKIA key ignores X-Amz-Security-Token entirely; the header is
// only signed, never checked, so a long-lived key plus any token succeeds.
t.Run("long_lived_with_session_token", func(t *testing.T) {
rec := e.callerIdentity(t, cbCreds{akid: e.alice.akid, secret: e.alice.secret, token: s.token})
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
})
t.Run("valid_token_wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: cbBadSecret, token: s.token}))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
}
// Pins CURRENT behaviour: an expired session answers ExpiredToken (HTTP 400)
// before its session token is examined.
func TestCurrentBehaviour_Session_Expired(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, _ := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
e.expireSession(t, s.akid)
// CURRENT: ExpiredToken is 400 here; AWS STS/IAM answer it as 403 (unverified).
expired := cbRegistry(awserrors.ErrorExpiredToken)
require.Equal(t, http.StatusBadRequest, expired.status)
for name, c := range map[string]cbCreds{
"valid_token": s,
// CURRENT: expiry is checked ahead of the token, so a caller holding only
// the cleartext ASIA key ID learns the session has expired.
"no_token": {akid: s.akid, secret: s.secret},
"wrong_token": {akid: s.akid, secret: cbBadSecret, token: tamper(s.token)},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, expired, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
}
// Pins CURRENT behaviour: a GetSessionToken session resolves to the IAM user and
// outlives the long-lived key that minted it.
func TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
mintKey := e.newKey(t, "alice")
s := e.getSessionToken(t, mintKey)
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, e.aliceID, cbXML(rec.Body.String(), "UserId"))
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid),
})
require.NoError(t, err)
// CURRENT: the session record carries no reference to the minting key, so
// deactivating and deleting that key leaves the session authenticating.
assert.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
// A session cannot mint a further session.
assert.Equal(t, cbRegistry(awserrors.ErrorAccessDenied).code,
cbOutcomeOf(e.request(t, e.h, s, "sts", cbForm("GetSessionToken"))).code)
}
// Pins CURRENT behaviour: an AssumeRoleWithWebIdentity session authenticates at
// the SigV4 door exactly as an AssumeRole session does.
func TestCurrentBehaviour_Session_WebIdentityCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
issuer := testDiscIssuer
iamKV, err := kvutil.GetOrCreateBucket(t.Context(), e.js,
iamdomain.IAMAccountBucketName(e.accountID), iamdomain.KVBucketIAMAccountVersion)
require.NoError(t, err)
_, err = iamKV.Put(t.Context(), iamdomain.OIDCProviderKey(issuer), []byte(`{"registered":true}`))
require.NoError(t, err)
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
point, err := priv.PublicKey.Bytes()
require.NoError(t, err)
jwks, err := json.Marshal(stsdomain.JWKS{Keys: []stsdomain.JWK{{
Kty: "EC", Kid: "k1", Alg: "ES256", Use: "sig", Crv: "P-256",
X: base64.RawURLEncoding.EncodeToString(point[1:33]),
Y: base64.RawURLEncoding.EncodeToString(point[33:65]),
}}})
require.NoError(t, err)
eksKV, err := ekscluster.GetOrCreateAccountBucket(t.Context(), e.js, e.accountID)
require.NoError(t, err)
_, err = eksKV.Put(t.Context(), ekscluster.OIDCJWKSKey(testDiscCluster), jwks)
require.NoError(t, err)
federated := iamdomain.OIDCProviderARN(e.accountID, strings.TrimPrefix(issuer, "https://"))
role, err := e.iam.CreateRole(e.accountID, &iam.CreateRoleInput{
RoleName: aws.String("irsa"),
AssumeRolePolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"` +
federated + `"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`),
})
require.NoError(t, err)
now := time.Now().UTC()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": issuer, "sub": "system:serviceaccount:default:sa", "aud": []string{"sts.amazonaws.com"},
"exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(),
})
tok.Header["kid"] = "k1"
signed, err := tok.SignedString(priv)
require.NoError(t, err)
// Unsigned: the anonymous STS interceptor serves it ahead of SigV4.
s := cbSessionFrom(t, e.request(t, e.h, cbCreds{}, "sts", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", aws.StringValue(role.Role.Arn), "RoleSessionName", "pod", "WebIdentityToken", signed)))
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/irsa/pod", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId),
cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: s.secret, token: tamper(s.token)})))
}
// Pins CURRENT behaviour: deleting a user and recreating it under the same name
// strands its old key and sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_UserDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
oldKey, oldID := e.newUser(t, "bob")
session := e.getSessionToken(t, oldKey)
require.Equal(t, http.StatusOK, e.callerIdentity(t, session).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(oldKey.akid),
})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("bob")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, session.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "session after delete")
newKey, newID := e.newUser(t, "bob")
require.NotEqual(t, oldID, newID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, oldKey)), "old key after recreate")
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "old session after recreate")
rec := e.callerIdentity(t, newKey)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, newID, cbXML(rec.Body.String(), "UserId"))
})
}
}
// Pins CURRENT behaviour: deleting a role and recreating it under the same name
// strands its old sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_RoleDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
roleARN, oldRoleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
_, err := e.iam.DeleteRole(e.accountID, &iam.DeleteRoleInput{RoleName: aws.String("app")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, s.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "session after delete")
newARN, newRoleID := e.createAssumableRole(t, "app")
require.Equal(t, roleARN, newARN)
require.NotEqual(t, oldRoleID, newRoleID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "old session after recreate")
assert.Equal(t, http.StatusOK, e.callerIdentity(t, e.assumeRole(t, newARN, "s2")).Code)
})
}
}
// Pins CURRENT behaviour: whose identity policy the policy check applies to an
// orphaned key once its user is deleted and recreated under the same name.
func TestCurrentBehaviour_Revocation_OrphanedKeyPermissionsAfterRecreation(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
key, oldID := e.newUser(t, "frank")
listUsers := cbForm("ListUsers")
getUser := cbForm("GetUser", "UserName", "frank")
// Policy A: ListUsers only.
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
require.Equal(t, http.StatusOK, e.request(t, e.h, key, "iam", listUsers).Code, "A allows ListUsers")
require.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", getUser)).code, "A denies GetUser")
e.orphanKeys(t, "frank")
_, err := e.iam.DeleteUserPolicy(e.accountID, &iam.DeleteUserPolicyInput{UserName: aws.String("frank"), PolicyName: aws.String("p")})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
// Policy B, on the replacement user: GetUser only.
u, err := e.iam.CreateUser(e.accountID, &iam.CreateUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
require.NotEqual(t, oldID, aws.StringValue(u.User.UserId))
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
// CURRENT: the orphaned key is evaluated against the replacement user's
// policy B, so it gains GetUser and loses the ListUsers that A granted.
rec := e.request(t, e.h, key, "iam", getUser)
assert.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", listUsers)).code)
}
// Pins CURRENT behaviour: unsigned requests are refused with
// MissingAuthenticationToken unless the action is anonymous STS.
func TestCurrentBehaviour_Auth_MissingCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
missing := cbRegistry(awserrors.ErrorMissingAuthenticationToken)
for _, f := range []url.Values{
cbForm("ListUsers"),
cbForm("GetCallerIdentity"),
cbForm("GetSessionToken"),
cbForm("AssumeRole", "RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s"),
} {
t.Run(f.Get("Action"), func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", f)))
})
}
// Session token alone, with no Authorization header.
t.Run("token_without_signature", func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{token: "anything"}, "", cbForm("GetCallerIdentity"))))
})
// The anonymous path reaches STS itself: its verdict, not the door's.
t.Run("AssumeRoleWithWebIdentity", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s1", "WebIdentityToken", "not-a-jwt")))
assert.Equal(t, http.StatusBadRequest, got.status)
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, got.code)
})
}
// Pins CURRENT behaviour: which credential failures a caller can tell apart on
// the wire, and whether the door decrypted a secret before answering.
func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
bobKey, _ := e.newUser(t, "bob")
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(bobKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
carolKey, _ := e.newUser(t, "carol")
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{UserName: aws.String("carol"), AccessKeyId: aws.String(carolKey.akid)})
require.NoError(t, err)
live := e.getSessionToken(t, e.alice)
expired := e.getSessionToken(t, e.alice)
e.expireSession(t, expired.akid)
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
sigMismatch := cbRegistry(awserrors.ErrorSignatureDoesNotMatch)
for _, tc := range []struct {
name string
creds cbCreds
want cbOutcome
decrypts int32
}{
{"unknown_key", cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, invalid, 0},
{"unknown_prefix", cbCreds{akid: "AKIBCBUNKNOWNPREFIXA", secret: cbBadSecret}, invalid, 0},
{"deleted_key_right_secret", carolKey, invalid, 0},
{"inactive_key_right_secret", bobKey, invalid, 0},
// CURRENT: an inactive key answers InvalidClientTokenId without a
// signature check, while an active key with a bad signature answers
// SignatureDoesNotMatch, so a caller with no secret can tell the two apart.
{"inactive_key_bad_signature", cbCreds{akid: bobKey.akid, secret: cbBadSecret}, invalid, 0},
{"active_key_bad_signature", cbCreds{akid: e.alice.akid, secret: cbBadSecret}, sigMismatch, 1},
{"unknown_session", cbCreds{akid: cbUnknownSes, secret: cbBadSecret, token: live.token}, invalid, 0},
{"session_no_token", cbCreds{akid: live.akid, secret: cbBadSecret}, invalid, 0},
{"session_wrong_token", cbCreds{akid: live.akid, secret: cbBadSecret, token: tamper(live.token)}, invalid, 0},
{"session_expired_no_token", cbCreds{akid: expired.akid, secret: cbBadSecret}, cbRegistry(awserrors.ErrorExpiredToken), 0},
{"session_right_token_bad_signature", cbCreds{akid: live.akid, secret: cbBadSecret, token: live.token}, sigMismatch, 1},
} {
t.Run(tc.name, func(t *testing.T) {
e.counter.decrypts.Store(0)
assert.Equal(t, tc.want, cbOutcomeOf(e.callerIdentity(t, tc.creds)))
assert.Equal(t, tc.decrypts, e.counter.decrypts.Load(), "secret decrypts before the verdict")
})
}
}
// Pins CURRENT behaviour: the door caches only an account's ACTIVE status, per
// gateway, for accountadmission.ActiveTTL; keys and sessions are re-read on every request.
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
require.Equal(t, 5*time.Second, accountadmission.ActiveTTL)
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
t.Run(status, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
require.Equal(t, http.StatusOK, e.callerIdentity(t, e.alice).Code)
start := time.Now()
_, err := e.iam.SetAccountStatus(e.accountID, status)
require.NoError(t, err)
// CURRENT: an out-of-band status write is not seen by a gateway
// holding a warm ACTIVE entry until that entry expires.
warm := e.callerIdentity(t, e.alice)
if time.Since(start) >= accountadmission.ActiveTTL {
t.Skip("cache window elapsed before the warm request; outcome not attributable")
}
assert.Equal(t, http.StatusOK, warm.Code)
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.ErrorLookup[awserrors.ErrorAccessDenied].Message}
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.gateway(), e.alice, "sts", cbForm("GetCallerIdentity"))))
})
}
}
@@ -0,0 +1,38 @@
package awsgw_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/stretchr/testify/require"
)
func TestSigV4Auth_UnsupportedServiceIsLogged(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
logs := captureLogs(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
sum := sha256.Sum256(nil)
payloadHash := hex.EncodeToString(sum[:])
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: gwfix.AccessKey, SecretAccessKey: gwfix.SecretKey},
req, payloadHash, "notaservice", gwfix.Region, time.Now().UTC()))
resp := doRequest(handler, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
out := logs.String()
require.Contains(t, out, "unsupported service in credential scope")
require.Contains(t, out, "service=notaservice")
}
@@ -0,0 +1,367 @@
package awsgw_test
import (
"encoding/xml"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSigV4Auth_NoAuthorizationHeader(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "MissingAuthenticationToken") {
t.Errorf("Expected MissingAuthenticationToken error, got: %s", string(body))
}
}
func TestSigV4Auth_MalformedHeader(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
testCases := []struct {
name string
authHeader string
}{
{"empty prefix", "InvalidPrefix Credential=test"},
{"missing parts", "AWS4-HMAC-SHA256 Credential=test"},
{"invalid credential format", "AWS4-HMAC-SHA256 Credential=a/b/c, SignedHeaders=host, Signature=sig"},
{"missing SignedHeaders prefix", "AWS4-HMAC-SHA256 Credential=a/b/c/d/aws4_request, Headers=host, Signature=sig"},
{"missing Signature prefix", "AWS4-HMAC-SHA256 Credential=a/b/c/d/aws4_request, SignedHeaders=host, Sig=abc"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", tc.authHeader)
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
})
}
}
func TestSigV4Auth_InvalidAccessKey(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, "INVALID_ACCESS_KEY", gwfix.SecretKey)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "InvalidClientTokenId") {
t.Errorf("Expected InvalidClientTokenId error, got: %s", string(body))
}
}
func TestSigV4Auth_InvalidSignature(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, "WRONG_SECRET_KEY")
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_InactiveAccessKey(t *testing.T) {
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
if err != nil {
t.Fatalf("Failed to encrypt secret: %v", err)
}
mockSvc := &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "root",
Status: "Inactive",
},
},
}
gw := withEC2Verifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
IAMService: mockSvc,
})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(r, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403 for inactive key, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "InvalidClientTokenId") {
t.Errorf("Expected InvalidClientTokenId error, got: %s", string(body))
}
}
func TestSigV4Auth_DecryptFailure(t *testing.T) {
// Secret encrypted under a rotated master key — unverifiable credential must
// return 403 InvalidClientTokenId, not 500, so the client re-authenticates.
otherKey, err := iamdomain.GenerateMasterKey()
if err != nil {
t.Fatalf("GenerateMasterKey() error: %v", err)
}
encryptedWithOther, err := iamdomain.EncryptSecret(gwfix.SecretKey, otherKey)
if err != nil {
t.Fatalf("EncryptSecret() error: %v", err)
}
mockSvc := &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedWithOther, // encrypted with wrong key
UserName: "root",
Status: "Active",
},
},
}
gw := withEC2Verifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
IAMService: mockSvc,
})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(r, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403 for decrypt failure, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), awserrors.ErrorInvalidClientTokenId) {
t.Errorf("Expected InvalidClientTokenId, got: %s", string(body))
}
}
func TestSigV4Auth_ExpiredTimestamp(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
past := time.Now().UTC().Add(-16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, past)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_FutureTimestamp(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
future := time.Now().UTC().Add(16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, future)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_MissingXAmzDate(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
req.Header.Del("X-Amz-Date")
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
}
func TestSigV4Auth_MalformedTimestamp(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
req.Header.Set("X-Amz-Date", "not-a-valid-date")
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
}
// TestSigV4Auth_RequireSignedHeaders verifies that omitting "host" or
// "x-amz-date" from SignedHeaders is rejected with IncompleteSignature before
// signature comparison. Omitting either enables Authorization header replay
// across vhosts or outside the clock-skew window.
func TestSigV4Auth_RequireSignedHeaders(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
rewriteSignedHeaders := func(authHeader, list string) string {
parts := strings.Split(authHeader, ", ")
if len(parts) != 3 {
t.Fatalf("expected 3-part auth header, got %d", len(parts))
}
parts[1] = "SignedHeaders=" + list
return strings.Join(parts, ", ")
}
cases := []struct {
name string
signedList string
}{
{"missing host", "x-amz-date"},
{"missing x-amz-date", "host"},
{"neither present", "content-type"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
req.Header.Set("Authorization",
rewriteSignedHeaders(req.Header.Get("Authorization"), tc.signedList))
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
body, _ := io.ReadAll(resp.Body)
t.Errorf("Expected status 400, got %d, body: %s", resp.StatusCode, string(body))
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
})
}
}
// The most important test in this cluster: a genuine signature mismatch on a
// SERVED scope must still return SignatureDoesNotMatch. Nothing about the
// unserved-scope InvalidAction change may make a real signature failure
// unrecognisable.
func TestSigV4Auth_ServedScopeSignatureMismatchStillSignatureDoesNotMatch(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
// Signed with the wrong secret: the credential scope (ec2) is served, so
// this must fail verification rather than the unserved-service gate.
gwfix.SignRequestAs(t, req, nil, gwfix.AccessKey, "wrong-secret-entirely", gwfix.Service, time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// ec2 speaks the query-protocol <Response><Errors><Error> shape, not the
// generic <ErrorResponse><Error> envelope other services use.
var parsed struct {
Errors struct {
Error struct {
Code string `xml:"Code"`
} `xml:"Error"`
} `xml:"Errors"`
}
require.NoError(t, xml.Unmarshal(body, &parsed), "body: %s", body)
assert.Equal(t, awserrors.ErrorSignatureDoesNotMatch, parsed.Errors.Error.Code)
}
@@ -2,12 +2,14 @@
// pipeline as bedrock*.go implement them today. They assert current
// behaviour, not AWS parity.
package gateway
package awsgw_test
import (
"bytes"
"context"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"io"
"net/http"
"net/http/httptest"
@@ -60,15 +62,15 @@ type bedrockPipelineEnv struct {
// unless mutate clears it. Policy statements are the caller's only IAM policy.
func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statements ...iamdomain.Statement) http.Handler {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
svc := &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
svc := &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
@@ -76,7 +78,7 @@ func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statemen
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}
_, nc, js := testutil.StartTestJetStream(t)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -91,8 +93,8 @@ func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statemen
Endpoints: ochre.NewStaticEndpointResolver(map[string]string{bedrockPipelineLlamaModel: stub.URL}),
Access: grantAllModels{},
Logging: ochre.NewLoggingConfigStore(js),
Provisioned: ochre.NewProvisionedStore(js, testRegion, nil),
Guardrails: ochre.NewGuardrailStore(js, testRegion),
Provisioned: ochre.NewProvisionedStore(js, gwfix.Region, nil),
Guardrails: ochre.NewGuardrailStore(js, gwfix.Region),
KB: ochrevector.NewKBStore(js),
DataSources: ochrevector.NewDataSourceStore(js),
Vector: unusedVector{},
@@ -100,7 +102,7 @@ func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statemen
if mutate != nil {
mutate(env)
}
cfg := &GatewayConfig{DisableLogging: true, Region: testRegion, NATSConn: nc, IAMService: svc}
cfg := &gateway.GatewayConfig{DisableLogging: true, Region: gwfix.Region, NATSConn: nc, IAMService: svc}
if env.NoNATS {
cfg.NATSConn = nil
}
@@ -121,7 +123,7 @@ func signBedrockRequest(t *testing.T, scope, method, path string, body []byte) *
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Host = "localhost:9999"
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, scope, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, scope, time.Now().UTC())
return req
}
@@ -0,0 +1,6 @@
package awsgw_test
//test:in-package — the gate steps under test are unexported and take their
// capabilities through the pipeline.PolicyEvaluator and pipeline.AccountAdmission interfaces.
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (setupEC2Request, gwfix.PolicyIAM) and auth context keys.
package awsgw_test
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives the public DeleteVolume action through EC2_Request
// with the gateway's unexported test helpers.
package awsgw_test
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package awsgw_test
import ()
@@ -0,0 +1,9 @@
// Characterization tests for the labels an EC2 request carries through the
// signed pipeline today: the trace span, the audit action, the throttle key,
// and the service and action the policy gate evaluates. Also the order of the
// cluster-unavailable gate against the checks that follow it. They assert
// current behaviour, not AWS parity.
package awsgw_test
import ()
@@ -0,0 +1,7 @@
// Characterization tests for EC2 through the full SigV4 pipeline as the legacy
// gateway switch and ec2.go implement them today. They assert current
// behaviour, not AWS parity.
package awsgw_test
import ()
@@ -0,0 +1,3 @@
package awsgw_test
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package awsgw_test
import ()
@@ -1,4 +1,4 @@
package gateway
package awsgw_test
import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
@@ -6,7 +6,7 @@ import (
// ecrAllowPolicy and ecrDenyPolicy build single-statement policy documents for
// the ECR operation-authorization tests below. They are distinct from
// auth_test.go's allowPolicy/allowPolicyResource helpers because those are
// auth_test.go's gwfix.AllowPolicy/gwfix.AllowPolicyResource helpers because those are
// fixed to a single action string; ECR route classification can require
// multiple actions per operation (e.g. cross-repo mount).
func ecrAllowPolicy(actions, resources []string) iamdomain.PolicyDocument {
@@ -0,0 +1,351 @@
// Characterization tests for ECR control-plane dispatch as it exists in
// ecrapi.go/ecr.go today, pinned ahead of moving dispatch out of gateway into
// a registration seam. These assert current behaviour, not AWS parity.
package awsgw_test
import (
"bytes"
"context"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ecrPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS so Request dispatches. docs is every principal's policy; the
// region is gwfix.Region because gwfix.SignRequestAs always signs for it.
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*gateway.GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
InternalSuffix: ecrTestSuffix,
NATSConn: nc,
IAMService: &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: ecrTestAccount,
Status: "Active",
},
},
},
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withECR(gw, awsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllECRDocs is the policy ecrPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllECRDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signECRRequest builds a POST / signed with credential-scope service "ecr",
// the shape every ECR JSON 1.1 client sends.
func signECRRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, "ecr", time.Now().UTC())
return req
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestECRPipeline_MissingTarget_400MissingActionException pins that an ECR
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestECRPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
resp := doRequest(app, signECRRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestECRPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from awsapi.Actions fails with InvalidAction.
func TestECRPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".MadeUpAction"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// TestECRPipeline_StubbedAction_501NotImplementedException pins that a
// registered-but-stubbed action (ListRepositories) still answers 501 through
// the full pipeline.
func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".ListRepositories"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "NotImplementedException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "NotImplementedException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestECRPipeline_GetAuthorizationToken_200JSONContentType pins that a
// successful ECR action answers 200 with the AWS JSON 1.1 content type
// through the full pipeline.
func TestECRPipeline_GetAuthorizationToken_200JSONContentType(t *testing.T) {
iss, _ := newECRAuth(t)
gw, app := ecrPipelineApp(t, allowAllECRDocs())
withECR(gw, awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".GetAuthorizationToken"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, awsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// --- Exact stub inventory --------------------------------------------------
// TestECRInventory_StubbedSetIsPinned pins the exact set of actions still
// bound to awsapi.NotImplemented, independent of whether a composed capability
// intercepts them before dispatch reaches that stub.
func TestECRInventory_StubbedSetIsPinned(t *testing.T) {
want := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "BatchGetImage", "BatchCheckLayerAvailability", "BatchDeleteImage",
"PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer", "InitiateLayerUpload",
"UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy", "PutRegistryPolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview",
"PutReplicationConfiguration", "ReplicateImage",
}
assert.ElementsMatch(t, want, awsapi.StubbedActionNames())
}
// --- Oversized body over HTTP ---------------------------------------------
// TestECRPipeline_OversizedBody_413OverHTTP pins that an ECR-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ECR dispatch ever runs, with the JSON error envelope.
func TestECRPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".ListRepositories")
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, "ecr", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before a composed action --------------------------
// recordingRepoStore is a RepositoryStore fake that records whether it was
// ever invoked, so a denial can be proven to short-circuit before the composed
// capability runs.
type recordingRepoStore struct {
calls int
}
func (s *recordingRepoStore) GetRepo(context.Context, string, string) (ecr.RepoMeta, error) {
s.calls++
return ecr.RepoMeta{}, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListRepos(context.Context, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) PutRepo(context.Context, string, ecr.RepoMeta) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListManifests(context.Context, string, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) DeleteRepo(context.Context, string, string) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
// TestECRPipeline_AuthzGateBlocksComposedAction pins that the policy gate in
// ECR dispatch runs, and denies, before a composed capability (here
// RepositoryActionService backing CreateRepository) is ever invoked.
func TestECRPipeline_AuthzGateBlocksComposedAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"ecr:CreateRepository"},
Resource: iamdomain.StringOrArr{"arn:aws:ecr:" + gwfix.Region + ":" + ecrTestAccount + ":repository/other"},
},
},
}}
gw, app := ecrPipelineApp(t, scopedDocs)
store := &recordingRepoStore{}
withECR(gw, awsapi.Deps{Repository: awsapi.NewRepositoryActionService(store, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".CreateRepository"
resp := doRequest(app, signECRRequest(t, target, []byte(`{"repositoryName":"team/app"}`)))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", body.Type)
assert.Equal(t, 0, store.calls, "composed capability must not run once the gate denies")
}
// --- GetAuthorizationToken ignores request-supplied identity -------------
// --- NATS unavailable for ECR ---------------------------------------------
// Stub and composed ECR actions alike fail at the NATS gate, before dispatch
// reads the action.
func TestRequest_ClusterUnavailableNilConn_ECR(t *testing.T) {
for _, action := range []string{"ListRepositories", "CreateRepository"} {
t.Run(action, func(t *testing.T) {
gw := withECR(withVerifier(&gateway.GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), awsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+"."+action)
ctx := reqctx.WithService(req.Context(), "ecr")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ECR request ---------------------------------
// TestECRPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ECR action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestECRPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := ecrPipelineApp(t, allowAllECRDocs())
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := awsapi.TargetPrefix + ".ListRepositories"
doRequest(r, signECRRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListRepositories", auditField(got, "action"))
}
// --- Inventory pin --------------------------------------------------------
// Pins the ECR inventory so a refactor that drops or adds an action, including
// by changing which capability intercepts it, fails here.
func TestECRInventory_RegisteredStubbedUnsupportedArePinned(t *testing.T) {
ecr := gateway.AWSOperationInventory(ecrRegistrationInventory())["ecr"]
wantRegistered := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "PutImageTagMutability", "BatchGetImage", "BatchCheckLayerAvailability",
"BatchDeleteImage", "PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "SetRepositoryPolicy",
"GetRepositoryPolicy", "DeleteRepositoryPolicy", "GetRegistryPolicy", "PutRegistryPolicy",
"DescribeRegistry", "PutImageScanningConfiguration", "GetImageScanningConfiguration",
"StartImageScan", "DescribeImageScanFindings", "GetRegistryScanningConfiguration",
"PutRegistryScanningConfiguration", "BatchGetRepositoryScanningConfiguration",
"PutLifecyclePolicy", "GetLifecyclePolicy", "DeleteLifecyclePolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview", "PutReplicationConfiguration",
"ReplicateImage", "TagResource", "UntagResource", "ListTagsForResource",
}
wantStubbed := []string{
"ListRepositories", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy",
"PutRegistryPolicy", "PutReplicationConfiguration", "ReplicateImage",
}
wantUnsupported := []string{
"GetImageScanningConfiguration", "StartImageScan", "DescribeImageScanFindings",
"GetRegistryScanningConfiguration", "PutRegistryScanningConfiguration",
"BatchGetRepositoryScanningConfiguration",
}
assert.ElementsMatch(t, wantRegistered, ecr.Registered)
assert.ElementsMatch(t, wantStubbed, ecr.Stubbed)
assert.ElementsMatch(t, wantUnsupported, ecr.Unsupported)
}
@@ -0,0 +1,3 @@
package awsgw_test
import ()
@@ -1,7 +1,8 @@
package gateway
package awsgw_test
import (
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"strings"
"time"
@@ -138,19 +139,13 @@ func (m *ecrMockSTSService) VerifySessionPrincipal(cred *stsdomain.SessionCreden
if m.principalErr != nil {
return nil, m.principalErr
}
live, err := stubSessionPrincipal(cred)
if err == nil && cred.PrincipalType == principalTypeUser {
live, err := gwfix.StubSessionPrincipal(cred)
if err == nil && cred.PrincipalType == gwfix.PrincipalTypeUser {
live.UserARN = m.liveUserARN
}
return live, err
}
const (
ecrPrincipalTestAccount = "000000000042"
ecrPrincipalTestAKID = "AKIATESTLONGLIVED0001"
ecrPrincipalTestASID = "ASIATESTSESSION000001"
)
func seedECRTestUser(iamSvc *ecrMockIAMService, accountID, userName, akid string) {
iamSvc.accessKeys[akid] = &iamdomain.AccessKey{
AccessKeyID: akid,
@@ -166,8 +161,6 @@ func seedECRTestUser(iamSvc *ecrMockIAMService, accountID, userName, akid string
iamSvc.accounts[accountID] = &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}
}
const ecrPathedUserARN = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/eng/dev"
func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, accountID, userName, asid string, expiresAt time.Time) {
iamSvc.users[accountID+"|"+userName] = &iam.User{
UserName: aws.String(userName),
@@ -178,7 +171,7 @@ func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
stsSvc.sessions[asid] = &stsdomain.SessionCredential{
AccessKeyID: asid,
AccountID: accountID,
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
SessionName: userName,
UserID: "AIDA" + strings.ToUpper(userName),
ExpiresAt: expiresAt,
@@ -200,7 +193,7 @@ func seedECRAssumedRole(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
stsSvc.sessions[asid] = &stsdomain.SessionCredential{
AccessKeyID: asid,
AccountID: accountID,
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
SessionName: sessionName,
AssumedRoleARN: assumedRoleARN,
UnderlyingRoleARN: roleARN,
@@ -1,8 +1,8 @@
package gateway
package awsgw_test
import (
"github.com/mulgadc/spinifex/spinifex/gateway"
"maps"
"net/http"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -10,7 +10,7 @@ import (
// withECR registers the ECR control plane on gw backed by deps, as the awsgw
// role composes it. An unset deps.NATS takes gw.NATSConn.
func withECR(gw *GatewayConfig, deps awsapi.Deps) *GatewayConfig {
func withECR(gw *gateway.GatewayConfig, deps awsapi.Deps) *gateway.GatewayConfig {
if deps.NATS == nil {
deps.NATS = gw.NATSConn
}
@@ -22,21 +22,10 @@ func withECR(gw *GatewayConfig, deps awsapi.Deps) *GatewayConfig {
return gw
}
// serveECR dispatches r to gw's registered ECR service exactly as Request does
// past its NATS gate, first registering one with no capabilities if needed.
func (gw *GatewayConfig) serveECR(w http.ResponseWriter, r *http.Request) error {
entry, ok := gw.registered(awsapi.ServiceName)
if !ok {
withECR(gw, awsapi.Deps{})
entry, _ = gw.registered(awsapi.ServiceName)
}
return gw.dispatchRegistered(entry, w, r)
}
// ecrRegistrationInventory is the inventory the ECR registration declares to
// a registry, keyed as AWSOperationInventory expects.
func ecrRegistrationInventory() map[string]dispatch.Inventory {
inventory := withECR(&GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
maps.Copy(inventory, withContainers(&GatewayConfig{}).Services.Inventory())
inventory := withECR(&gateway.GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
maps.Copy(inventory, withContainers(&gateway.GatewayConfig{}).Services.Inventory())
return inventory
}
@@ -1,9 +1,11 @@
package gateway
package awsgw_test
import (
"encoding/base64"
"encoding/json"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"net/http"
"net/http/httptest"
"testing"
@@ -54,7 +56,7 @@ func newV2Env(t *testing.T, opts v2Options) *v2Env {
}
iamSvc.userPolicies[v2Account+"|dev"] = policies
gw := withVerifier(&GatewayConfig{
gw := withVerifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: ecrTestRegion,
InternalSuffix: ecrTestSuffix,
@@ -78,14 +80,14 @@ func (e *v2Env) mintRole(t *testing.T, roleID string) string {
t.Helper()
tok, _, err := e.issuer.Mint(ecrauth.Principal{
AccountID: v2Account, ARN: "arn:aws:sts::" + v2Account + ":assumed-role/deploy/s1",
Type: principalTypeAssumedRole, AccessKeyID: v2ASID, RoleID: roleID,
Type: gwfix.PrincipalTypeAssumedRole, AccessKeyID: v2ASID, RoleID: roleID,
})
require.NoError(t, err)
return tok
}
func (e *v2Env) userToken(t *testing.T) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeUser, v2AKID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeUser, v2AKID)
}
func basic(token string) string {
@@ -154,7 +156,7 @@ func TestV2Pipeline_VersionCheckAndStub(t *testing.T) {
func TestV2Pipeline_TokenRejections(t *testing.T) {
e := newV2Env(t, v2Options{})
good := e.userToken(t)
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", principalTypeUser, v2AKID)
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", gwfix.PrincipalTypeUser, v2AKID)
tests := []struct {
name string
@@ -200,10 +202,10 @@ func TestV2Pipeline_NonRegistryHostPassesThrough(t *testing.T) {
func TestV2Pipeline_PrincipalRehydration(t *testing.T) {
user := func(e *v2Env) string { return e.userToken(t) }
session := func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeUser, v2ASID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeUser, v2ASID)
}
role := func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:sts::"+v2Account+":assumed-role/deploy/s1", principalTypeAssumedRole, v2ASID)
return e.mint(t, v2Account, "arn:aws:sts::"+v2Account+":assumed-role/deploy/s1", gwfix.PrincipalTypeAssumedRole, v2ASID)
}
seedSession := func(e *v2Env) {
seedECRSessionUser(e.iam, e.sts, v2Account, "dev", v2ASID, time.Now().Add(time.Hour))
@@ -245,13 +247,13 @@ func TestV2Pipeline_PrincipalRehydration(t *testing.T) {
e.sts.principalErr = stsdomain.ErrSessionPrincipalReplaced
}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"unrecognized key prefix", func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeUser, "AROAUNKNOWN0000000001")
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeUser, "AROAUNKNOWN0000000001")
}, func(*v2Env) {}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"claim subject does not match the principal", func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/someone-else", principalTypeUser, v2AKID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/someone-else", gwfix.PrincipalTypeUser, v2AKID)
}, func(*v2Env) {}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"claim principal type does not match", func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeAssumedRole, v2AKID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeAssumedRole, v2AKID)
}, func(*v2Env) {}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"IAM outage", user, func(e *v2Env) { e.iam.genericErr = errors.New("nats: timeout") }, http.StatusServiceUnavailable, "UNKNOWN"},
{"STS outage", session, func(e *v2Env) {
@@ -449,7 +451,7 @@ func TestV2Pipeline_TokenEndpoint(t *testing.T) {
t.Run("token for another account than the host", func(t *testing.T) {
e := newV2Env(t, v2Options{})
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", principalTypeUser, v2AKID)
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", gwfix.PrincipalTypeUser, v2AKID)
w := e.do(http.MethodGet, "/v2/token", v2Host, "Bearer "+other)
@@ -1,4 +1,4 @@
package gateway
package awsgw_test
import (
"crypto/rand"
@@ -2,10 +2,12 @@
// pipeline as ecs.go and eks.go implement it today. They assert current
// behaviour, not AWS parity.
package gateway
package awsgw_test
import (
"bytes"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"net/http"
"net/http/httptest"
"testing"
@@ -24,16 +26,16 @@ const ecsPipelineTarget = "AmazonEC2ContainerServiceV20141113."
// validation therefore stops at the handler with ServerInternal.
func containerPipelineApp(t *testing.T, statements ...iamdomain.Statement) http.Handler {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
svc := &roleStoreIAMService{
policyMockIAMService: policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
PolicyIAM: gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
@@ -41,7 +43,7 @@ func containerPipelineApp(t *testing.T, statements ...iamdomain.Statement) http.
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
roles: map[string]map[string]string{
authzAccountID: {"node-role": eksNodeRoleARN},
@@ -49,7 +51,7 @@ func containerPipelineApp(t *testing.T, statements ...iamdomain.Statement) http.
},
}
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(withContainers(&GatewayConfig{DisableLogging: true, Region: testRegion, NATSConn: nc, IAMService: svc}))
gw := withVerifier(withContainers(&gateway.GatewayConfig{DisableLogging: true, Region: gwfix.Region, NATSConn: nc, IAMService: svc}))
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
@@ -63,7 +65,7 @@ func signContainerRequest(t *testing.T, service, method, path, target string, bo
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, service, time.Now().UTC())
return req
}
@@ -0,0 +1,3 @@
package awsgw_test
import ()

Some files were not shown because too many files have changed in this diff Show More