mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-01 00:42:13 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fd85d117b | |||
| 51d7ac91f5 |
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
+9
-11
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>'"+tc.resourceType+"' 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>'"+tc.resourceType+"' 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"))
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
+15
-13
@@ -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 ()
|
||||
+2
-2
@@ -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 ()
|
||||
+6
-13
@@ -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,
|
||||
+5
-16
@@ -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
|
||||
}
|
||||
+13
-11
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package awsgw_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
+13
-11
@@ -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
Reference in New Issue
Block a user