Compare commits

...

1 Commits

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

* refactor: move ACM gateway tests to awsgw

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

* refactor: move RDS gateway tests to awsgw

* refactor: move ELBv2 gateway tests to awsgw

* refactor: move IAM gateway tests to awsgw

* refactor: move STS gateway tests to awsgw

* refactor: move tagging gateway tests to awsgw

* refactor: move operator and bedrock gateway tests to awsgw

* refactor: move ECR gateway tests to awsgw

* refactor: move ECS and EKS gateway tests to awsgw

* refactor: move EC2 gateway tests to awsgw

* refactor: move credential verdict tests to awsgw

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

* refactor: move checkPolicy verdict tests to policyauth

* refactor: run gateway tests on a fake verifier, real auth only for listed verdict tests
2026-11-01 08:42:07 +10:00
121 changed files with 10227 additions and 8342 deletions
+266
View File
@@ -0,0 +1,266 @@
// Package gwfix holds runtime-free fixtures for gateway-level tests: SigV4
// signing helpers, identity/session doubles and policy builders. It must not
// import runtime/ or operator/ so gateway tests stay free of them.
package gwfix
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
awsv1 "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/require"
)
const (
AccessKey = "AKIAIOSFODNN7EXAMPLE" //nolint:gosec // AWS documentation example key
SecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" //nolint:gosec // AWS documentation example secret
Region = "us-east-1"
Service = "ec2"
SessionAKID = "ASIATESTSESSIONAAAAA"
SessionToken = "wire-session-token-plain"
RoleARN = "arn:aws:iam::123456789012:role/test-role"
AssumedARN = "arn:aws:sts::123456789012:assumed-role/test-role/test-session"
PrincipalTypeUser = "user"
PrincipalTypeAssumedRole = "assumed-role"
PrincipalTypeRoot = "root"
)
// IAM implements iamdomain.IAMService for auth tests. It embeds the interface
// so it satisfies the full contract; only the methods the SigV4 path drives are
// wired, and any other method nil-panics to surface an unexpected call.
type IAM struct {
iamdomain.IAMService
AccessKeys map[string]*iamdomain.AccessKey
MasterKey []byte
// Accounts overrides the status the auth gate reads. An empty map means
// every account is ACTIVE.
Accounts map[string]*iamdomain.Account
// GetUserFn overrides the synthetic user record, for a test that cares what
// aws:userid resolves to or that it fails to resolve at all.
GetUserFn func(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
}
func (m *IAM) LookupAccessKey(accessKeyID string) (*iamdomain.AccessKey, error) {
ak, ok := m.AccessKeys[accessKeyID]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
return ak, nil
}
func (m *IAM) DecryptSecret(ciphertext string) (string, error) {
return iamdomain.DecryptSecret(ciphertext, m.MasterKey)
}
func (m *IAM) GetAccount(accountID string) (*iamdomain.Account, error) {
if account, ok := m.Accounts[accountID]; ok {
return account, nil
}
return &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}, nil
}
// GetUser answers with a unique ID derived from the name, so aws:userid resolves
// on the authorization path without every test defining a user record.
func (m *IAM) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
if m.GetUserFn != nil {
return m.GetUserFn(accountID, input)
}
name := awsv1.StringValue(input.UserName)
return &iam.GetUserOutput{User: &iam.User{
UserName: awsv1.String(name),
UserId: awsv1.String("AIDA" + strings.ToUpper(name)),
}}, nil
}
// PolicyIAM adds configurable user and role policy lookups to IAM.
type PolicyIAM struct {
IAM
GetUserPoliciesFn func(accountID, userName string) ([]iamdomain.PolicyDocument, error)
GetRolePoliciesFn func(accountID, roleName string) ([]iamdomain.PolicyDocument, error)
}
func (m *PolicyIAM) GetUserPolicies(accountID, userName string) ([]iamdomain.PolicyDocument, error) {
if m.GetUserPoliciesFn != nil {
return m.GetUserPoliciesFn(accountID, userName)
}
return nil, nil
}
func (m *PolicyIAM) GetRolePolicies(accountID, roleName string) ([]iamdomain.PolicyDocument, error) {
if m.GetRolePoliciesFn != nil {
return m.GetRolePoliciesFn(accountID, roleName)
}
return nil, nil
}
// ErrorLookupIAM returns a configurable error from LookupAccessKey.
type ErrorLookupIAM struct {
IAM
Err error
}
func (m *ErrorLookupIAM) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
return nil, m.Err
}
// MasterKey is a fixed 32-byte key for deterministic secret encryption in tests.
var MasterKey = bytes.Repeat([]byte{0x5a}, 32)
// STS implements the session-credential half of stsdomain.STSService.
type STS struct {
stsdomain.STSService
Sessions map[string]*stsdomain.SessionCredential
Tokens map[string]string // AKID → plaintext wire token for HMAC equivalence
LookupErr error
Lookups atomic.Int32 // counts LookupSessionCredential calls for negative-side-effect assertions
PrincipalErr error // verdict or fault the continuity check returns
PrincipalChecks atomic.Int32 // counts VerifySessionPrincipal calls, to pin its ordering
}
func (m *STS) LookupSessionCredential(accessKeyID string) (*stsdomain.SessionCredential, error) {
m.Lookups.Add(1)
if m.LookupErr != nil {
return nil, m.LookupErr
}
if !strings.HasPrefix(accessKeyID, "ASIA") {
return nil, nil
}
cred, ok := m.Sessions[accessKeyID]
if !ok {
return nil, nil
}
return cred, nil
}
// VerifySessionToken uses plaintext equality rather than HMAC to keep tests
// simple while preserving the contract shape.
func (m *STS) VerifySessionToken(cred *stsdomain.SessionCredential, wireToken string) bool {
if cred == nil {
return false
}
want, ok := m.Tokens[cred.AccessKeyID]
if !ok {
return false
}
return want == wireToken
}
// VerifySessionPrincipal defaults to "still the same principal"; PrincipalErr
// drives the rejection-verdict and dependency-fault branches.
func (m *STS) VerifySessionPrincipal(cred *stsdomain.SessionCredential) (*stsdomain.SessionPrincipal, error) {
m.PrincipalChecks.Add(1)
if m.PrincipalErr != nil {
return nil, m.PrincipalErr
}
return StubSessionPrincipal(cred)
}
// StubSessionPrincipal is the shared "principal unchanged" answer for the
// session doubles. It derives RoleName from the underlying role ARN rather than
// the session name, because that is what the real implementation returns: a
// stub echoing SessionName would let a door read the wrong field and stay green.
func StubSessionPrincipal(cred *stsdomain.SessionCredential) (*stsdomain.SessionPrincipal, error) {
if cred == nil {
return nil, errors.New("nil session credential")
}
roleName := cred.UnderlyingRoleARN
if idx := strings.LastIndex(roleName, "/"); idx >= 0 {
roleName = roleName[idx+1:]
}
return &stsdomain.SessionPrincipal{
UserID: cred.UserID,
RoleID: cred.RoleID,
RoleName: roleName,
RoleARN: cred.UnderlyingRoleARN,
}, nil
}
// SignRequest signs req in place with the aws-sdk-go-v2 v4 signer — the same
// path production callers use via gwsign — so the gateway verifies a real SDK
// signature. body must match what the middleware reads from r.Body so the payload
// hash agrees. An optional signingTime pins the clock for skew tests.
func SignRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret string, signingTime ...time.Time) {
t.Helper()
when := time.Now().UTC()
if len(signingTime) > 0 {
when = signingTime[0]
}
SignRequestAs(t, req, body, accessKey, secret, Service, when)
}
// SignRequestAs signs with an explicit credential-scope service, so a test
// can drive a scope the gateway does not serve.
func SignRequestAs(t *testing.T, req *http.Request, body []byte, accessKey, secret, service string, when time.Time) {
t.Helper()
sum := sha256.Sum256(body)
payloadHash := hex.EncodeToString(sum[:])
// gwsign sets this header so the SDK signs it; sigv4 reproduces it when
// reconstructing the canonical request.
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secret},
req, payloadHash, service, Region, when))
}
// SignSessionRequest sets X-Amz-Security-Token before signing so it's included
// in SignedHeaders, matching what the AWS CLI/SDK do with session credentials.
func SignSessionRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret, sessionToken string) {
t.Helper()
req.Header.Set("X-Amz-Security-Token", sessionToken)
SignRequest(t, req, body, accessKey, secret)
}
// AllowPolicy returns a single-statement Allow policy granting action on "*".
func AllowPolicy(action string) []iamdomain.PolicyDocument {
return AllowPolicyResource(action, "*")
}
// AllowPolicyResource returns a single-statement Allow policy for action on resource.
func AllowPolicyResource(action, resource string) []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{{
Effect: "Allow",
Action: iamdomain.StringOrArr{action},
Resource: iamdomain.StringOrArr{resource},
}},
}}
}
// AssumedRoleSessionCred builds an assumed-role SessionCredential.
// SecretEncrypted is filled in by the gateway builder.
func AssumedRoleSessionCred(sessionName, underlyingRoleARN, accountID string) *stsdomain.SessionCredential {
return &stsdomain.SessionCredential{
AccessKeyID: SessionAKID,
AccountID: accountID,
PrincipalType: PrincipalTypeAssumedRole,
AssumedRoleARN: "arn:aws:sts::" + accountID + ":assumed-role/role/" + sessionName,
UnderlyingRoleARN: underlyingRoleARN,
SessionName: sessionName,
ExpiresAt: time.Now().UTC().Add(time.Hour),
CreatedAt: time.Now().UTC().Add(-time.Minute),
}
}
+44
View File
@@ -0,0 +1,44 @@
package gwfix
import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
)
var _ credential.Verifier = (*Verifier)(nil)
// Verifier authenticates a long-lived access key as the IAM user the key names.
// It decides nothing else: no status, session or account check, so a test that
// asserts a credential verdict must use the real verifier instead.
type Verifier struct {
IAM iamdomain.IAMService
}
func (v *Verifier) Authenticate(req credential.Request, verifySig func(secret string) error) (credential.Authenticated, *credential.Failure) {
ak, err := v.IAM.LookupAccessKey(req.AccessKeyID)
if err != nil {
return credential.Authenticated{}, &credential.Failure{Code: awserrors.ErrorInvalidClientTokenId}
}
secret, err := v.IAM.DecryptSecret(ak.SecretAccessKey)
if err != nil {
return credential.Authenticated{}, &credential.Failure{Code: awserrors.ErrorInternalError}
}
if err := verifySig(secret); err != nil {
return credential.Authenticated{}, &credential.Failure{Code: awserrors.ErrorSignatureDoesNotMatch, SignatureMismatch: true}
}
return credential.Authenticated{AccountID: ak.AccountID, Handle: ak}, nil
}
func (v *Verifier) Resolve(_ credential.Request, auth credential.Authenticated) (credential.Principal, *credential.Failure) {
ak, ok := auth.Handle.(*iamdomain.AccessKey)
if !ok {
return credential.Principal{}, &credential.Failure{Code: awserrors.ErrorInternalError}
}
return credential.Principal{
Identity: ak.UserName,
AccountID: ak.AccountID,
PrincipalType: PrincipalTypeUser,
CallerARN: "arn:aws:iam::" + ak.AccountID + ":user/" + ak.UserName,
}, nil
}
@@ -0,0 +1,32 @@
package awsapi_test
import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func assertDryRun(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorDryRunOperation, err.Error())
}
// A caller the policy refuses is denied rather than told the request would
// have succeeded.
func TestEC2Request_DryRunDeniedCaller(t *testing.T) {
h := gwtest.Scoped(
gwtest.Statement("Allow", "ec2:*", "*"),
gwtest.Statement("Deny", "ec2:CreateVpc", "*"),
)
gwtest.AssertUnauthorized(t, dispatchEC2(t, h, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=true"))
assertDryRun(t, dispatchEC2(t, h, "Action=CreateSecurityGroup&GroupName=g&GroupDescription=d&DryRun=true"))
}
func TestEC2Request_DryRunFalseDispatches(t *testing.T) {
h := gwtest.AllowAll()
gwtest.AssertPermitted(t, dispatchEC2(t, h, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=false"))
}
@@ -1,30 +1,28 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (scopedPolicyGateway, dispatchEC2).
package gateway
package awsapi_test
import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
)
// The PowerUserAccess shape: an Allow on everything except the listed actions.
func TestEC2Request_AllowNotActionGrantsTheComplement(t *testing.T) {
gw := scopedPolicyGateway(iamdomain.Statement{
h := gwtest.Scoped(iamdomain.Statement{
Effect: "Allow",
NotAction: iamdomain.StringOrArr{"iam:*", "ec2:TerminateInstances"},
Resource: iamdomain.StringOrArr{"*"},
})
assertPermitted(t, dispatchEC2(t, gw, "Action=StopInstances&InstanceId.1=i-dev"))
assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev"))
gwtest.AssertPermitted(t, dispatchEC2(t, h, "Action=StopInstances&InstanceId.1=i-dev"))
gwtest.AssertUnauthorized(t, dispatchEC2(t, h, "Action=TerminateInstances&InstanceId.1=i-dev"))
}
// A Deny on NotResource fences every instance but the named one.
func TestEC2Request_DenyNotResourceFencesTheComplement(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
h := gwtest.Scoped(
gwtest.Statement("Allow", "ec2:*", "*"),
iamdomain.Statement{
Effect: "Deny",
Action: iamdomain.StringOrArr{"ec2:TerminateInstances"},
@@ -32,6 +30,6 @@ func TestEC2Request_DenyNotResourceFencesTheComplement(t *testing.T) {
},
)
assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev"))
assertUnauthorized(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod"))
gwtest.AssertPermitted(t, dispatchEC2(t, h, "Action=TerminateInstances&InstanceId.1=i-dev"))
gwtest.AssertUnauthorized(t, dispatchEC2(t, h, "Action=TerminateInstances&InstanceId.1=i-prod"))
}
@@ -22,6 +22,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
@@ -36,7 +37,8 @@ import (
type eksGateway struct {
*gwtest.Harness
nc *nats.Conn
nc *nats.Conn
roles passrole.Resolver
}
func scopedEKS(statements ...iamdomain.Statement) *eksGateway {
@@ -52,7 +54,7 @@ func (g *eksGateway) as(caller credential.Principal) *eksGateway {
}
func (g *eksGateway) serve(w http.ResponseWriter, r *http.Request) error {
return g.Dispatch(eksawsapi.NewRegistration(eksawsapi.Deps{NATS: g.nc}), eksawsapi.ServiceName, w, r)
return g.Dispatch(eksawsapi.NewRegistration(eksawsapi.Deps{NATS: g.nc, Roles: g.roles}), eksawsapi.ServiceName, w, r)
}
func assertNotDenied(t *testing.T, err error) {
@@ -0,0 +1,243 @@
package awsapi_test
import (
"errors"
"net/http"
"testing"
"github.com/mulgadc/bluebottle/pkg/auth"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
eksNodeRoleARN = "arn:aws:iam::" + gwtest.AccountID + ":role/eks/node-role"
foreignAccountID = "999999999999"
)
// roleStore resolves role names to the ARNs in roles, keyed by account then
// name, the way the IAM store does.
type roleStore struct {
roles map[string]map[string]string
canonicalErr error
}
func (s *roleStore) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
roleAccount, name, err := auth.ParseRoleARN(roleARN)
if err != nil {
return passrole.Role{}, passrole.ErrMalformed
}
if roleAccount != accountID {
return passrole.Role{}, passrole.ErrForeignAccount
}
canonical, err := s.canonical(roleAccount, name)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{}, err
}
if canonical != roleARN {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{ARN: canonical, Name: name}, nil
}
func (s *roleStore) canonical(account, name string) (string, error) {
if s.canonicalErr != nil {
return "", s.canonicalErr
}
if roleARN, ok := s.roles[account][name]; ok {
return roleARN, nil
}
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
}
// The foreign account holds a role of the same name, so only the account
// check stops its ARN from resolving.
func eksPassRole(statements ...iamdomain.Statement) *eksGateway {
return eksPassRoleFaulting(nil, statements...)
}
// eksPassRoleFaulting is eksPassRole with every role lookup failing with
// canonicalErr when it is non-nil.
func eksPassRoleFaulting(canonicalErr error, statements ...iamdomain.Statement) *eksGateway {
store := &roleStore{canonicalErr: canonicalErr, roles: map[string]map[string]string{
gwtest.AccountID: {"node-role": eksNodeRoleARN},
foreignAccountID: {"node-role": "arn:aws:iam::" + foreignAccountID + ":role/eks/node-role"},
}}
g := scopedEKS(statements...)
g.roles = store
return g
}
func createNodegroupBody(nodeRole string) string {
return `{"nodegroupName":"workers","nodeRole":"` + nodeRole + `"}`
}
func createAddonBody(roleARN string) string {
return `{"addonName":"aws-load-balancer-controller","serviceAccountRoleArn":"` + roleARN + `"}`
}
func updateAddonBody(roleARN string) string {
return `{"serviceAccountRoleArn":"` + roleARN + `"}`
}
// The AWS capture of 2026-10-30 refuses a foreign or non-role ARN on CreateCluster
// and CreateNodegroup with AccessDeniedException before PassRole is evaluated.
func assertCrossAccountPassRole(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorAccessDeniedException, code)
assert.Contains(t, err.Error(), "Cross-account pass role is not allowed.")
}
func assertInvalidParameter(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorEKSInvalidParameter, code)
}
func TestEKSCreateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/addons", createAddonBody("")))
}
func TestEKSCreateAddon_PassRole(t *testing.T) {
body := createAddonBody(eksNodeRoleARN)
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/addons", body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/addons", body))
}
func TestEKSCreateCluster_PassRole(t *testing.T) {
body := `{"name":"prod","roleArn":"` + eksNodeRoleARN + `"}`
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters", body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters", body))
}
func TestEKSCreateNodegroup_AllowedWithPassRoleOnRole(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
// eks:* alone must not let a caller launch nodes under a role it cannot pass.
func TestEKSCreateNodegroup_DeniedWithoutPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
func TestEKSCreateNodegroup_ForeignAccountRoleIsRefused(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+foreignAccountID+":role/eks/node-role"))
assertCrossAccountPassRole(t, err)
}
// A grant scoped to a path the role is not under must not be satisfied by
// spelling the role's name under that path.
func TestEKSCreateNodegroup_InventedPathIsRefused(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "arn:aws:iam::"+gwtest.AccountID+":role/sandbox/*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+gwtest.AccountID+":role/sandbox/node-role"))
assertInvalidParameter(t, err)
}
// A store fault is not evidence the role is absent, so it must not surface as
// a caller error or fall through to dispatch.
func TestEKSCreateNodegroup_LookupFaultPassesThrough(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
gw := eksPassRoleFaulting(storeErr,
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
func TestEKSCreateNodegroup_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", `{"nodegroupName":"workers"}`))
}
func TestEKSCreateNodegroup_NonRoleARNIsRefused(t *testing.T) {
gw := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+gwtest.AccountID+":user/node-role"))
assertCrossAccountPassRole(t, err)
}
// Current behaviour: a store fault on the role lookup is returned before the
// PassRole check, so a caller without iam:PassRole sees the fault, not a denial.
func TestEKSCreateNodegroup_OutageIsReportedToACallerWithoutPassRole(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
gw := eksPassRoleFaulting(storeErr, gwtest.Statement("Allow", "eks:*", "*"))
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
// An unknown role is reported only to a caller allowed to pass it, so the
// reply cannot be used to enumerate roles.
func TestEKSCreateNodegroup_UnknownRole(t *testing.T) {
body := createNodegroupBody("arn:aws:iam::" + gwtest.AccountID + ":role/ghost")
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/node-groups", body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
)
assertInvalidParameter(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/node-groups", body))
}
func TestEKSUpdateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertPermitted(t, dispatchEKS(t, gw, http.MethodPost,
"/clusters/prod/addons/aws-load-balancer-controller/update", updateAddonBody("")))
}
func TestEKSUpdateAddon_PassRole(t *testing.T) {
body := updateAddonBody(eksNodeRoleARN)
const path = "/clusters/prod/addons/aws-load-balancer-controller/update"
denied := eksPassRole(gwtest.Statement("Allow", "eks:*", "*"))
gwtest.AssertDenied(t, dispatchEKS(t, denied, http.MethodPost, path, body))
allowed := eksPassRole(
gwtest.Statement("Allow", "eks:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
gwtest.AssertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, path, body))
}
+4 -267
View File
@@ -1,159 +1,20 @@
// Characterization tests for ACM control-plane dispatch as it exists in
// acm.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// acmPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS, with docs as every principal's policy. The region is
// testRegion because signTestRequestAs always signs for it.
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withACM(gw, acmawsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllACMDocs is the policy acmPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllACMDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signACMRequest builds a POST / signed with credential-scope service "acm",
// the shape every ACM JSON 1.1 client sends.
func signACMRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "acm", time.Now().UTC())
return req
}
// subscribeACMCounter answers subject with payload and counts invocations, so
// a denial can be proven to short-circuit before the handler ever runs.
func subscribeACMCounter(t *testing.T, nc *nats.Conn, subject string, payload []byte) *int {
t.Helper()
calls := 0
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
calls++
_ = msg.Respond(payload)
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
t.Cleanup(func() { _ = sub.Unsubscribe() })
return &calls
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestACMPipeline_MissingTarget_400MissingActionException pins that an ACM
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestACMPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
resp := doRequest(app, signACMRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestACMPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from acmActions fails with InvalidAction.
func TestACMPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
target := "CertificateManager.MadeUpAction"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestACMPipeline_ListCertificates_200JSONContentType pins that a successful
// ACM action answers 200 with the AWS JSON 1.1 content type through the full
// pipeline, once the daemon-side NATS responder answers.
func TestACMPipeline_ListCertificates_200JSONContentType(t *testing.T) {
gw, app := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
target := "CertificateManager.ListCertificates"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, acmawsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// TestACMActionFromTarget_FormsAndTelemetryAgree pins how acmActionFromTarget
// and the legacy resolveNonQueryAction each resolve the X-Amz-Target header
// today. A refactor that lets them diverge breaks this.
@@ -182,86 +43,6 @@ func TestACMActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
}
}
// --- Oversized body over HTTP ---------------------------------------------
// TestACMPipeline_OversizedBody_413OverHTTP pins that an ACM-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ACM dispatch ever runs, with the JSON error envelope.
func TestACMPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", "CertificateManager.ListCertificates")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "acm", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before the NATS-backed action ----------------------
// TestACMPipeline_AuthzGateBlocksAction pins that the policy gate in ACM
// dispatch runs, and denies, before the NATS-backed action handler is ever
// invoked, through the full pipeline.
func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"acm:DeleteCertificate"},
Resource: iamdomain.StringOrArr{"arn:aws:acm:" + testRegion + ":" + authzAccountID + ":certificate/other"},
},
},
}}
gw, app := acmPipelineApp(t, scopedDocs)
calls := subscribeACMCounter(t, gw.NATSConn, "acm.DeleteCertificate", []byte(`{}`))
target := "CertificateManager.DeleteCertificate"
body := []byte(`{"CertificateArn":"arn:aws:acm:` + testRegion + `:` + authzAccountID + `:certificate/prod-1111"}`)
resp := doRequest(app, signACMRequest(t, target, body))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
respBody := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", respBody.Type)
assert.Equal(t, 0, *calls, "the NATS-backed handler must not run once the gate denies")
}
// --- NATS unavailable for ACM ---------------------------------------------
// TestRequest_ClusterUnavailableNilConn_ACM pins that ACM fails at the
// request-level NATS gate before dispatch reads the action, so ACM's own
// nil-connection check is unreachable over HTTP.
func TestRequest_ClusterUnavailableNilConn_ACM(t *testing.T) {
for _, action := range []string{"ListCertificates", "DeleteCertificate"} {
t.Run(action, func(t *testing.T) {
gw := withACM(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), acmawsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", "CertificateManager."+action)
ctx := reqctx.WithService(req.Context(), "acm")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ACM request ---------------------------------
// TestACMPipeline_TraceActionEnricher_SpanNamedServiceAction pins that the
// server span is renamed to "acm.<Action>" and carries aws.action once a real
// ACM request runs through SigV4AuthMiddleware and traceActionEnricher.
@@ -296,47 +77,3 @@ func TestACMPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
assert.Equal(t, "ListCertificates", got["aws.action"])
assert.Equal(t, "acm", got["aws.service"])
}
// TestACMPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ACM action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestACMPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := "CertificateManager.ListCertificates"
doRequest(r, signACMRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListCertificates", auditField(got, "action"))
}
// --- Inventory pin ----------------------------------------------------------
// TestACMInventory_RegisteredArePinned pins the ACM inventory so a refactor
// that drops or adds an action, including by moving ACM onto the
// registration seam, fails here.
func TestACMInventory_RegisteredArePinned(t *testing.T) {
acm := AWSOperationInventory(acmRegistrationInventory())["acm"]
want := []string{
"ImportCertificate", "RequestCertificate", "DescribeCertificate", "GetCertificate",
"ListCertificates", "DeleteCertificate", "ListTagsForCertificate", "AddTagsToCertificate",
"RemoveTagsFromCertificate",
}
assert.ElementsMatch(t, want, acm.Registered)
assert.Empty(t, acm.Stubbed)
assert.Empty(t, acm.Unsupported)
}
@@ -8,10 +8,10 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net/http"
"strings"
"testing"
@@ -98,12 +98,12 @@ func actorProbeGateway(t *testing.T, e *cbEnv) (http.Handler, *[]actorObservatio
gw := &GatewayConfig{
DisableLogging: true,
NATSConn: e.nc,
Region: testRegion,
Region: gwfix.Region,
ExpectedNodes: 1,
IAMService: e.counter,
Credentials: credverify.New(e.counter, e.sts),
STSService: e.sts,
}
withRealAuth(gw)
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
require.NoError(t, b.Register(stsRegistration(gw)))
gw.Services = b.Build()
+12 -11
View File
@@ -7,6 +7,7 @@ package gateway
import (
"context"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -22,7 +23,7 @@ import (
)
// createAccountPolicy grants exactly the action the signup principal holds.
func createAccountPolicy() *policyMockIAMService {
func createAccountPolicy() *gwfix.PolicyIAM {
allow := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
@@ -33,9 +34,9 @@ func createAccountPolicy() *policyMockIAMService {
},
},
}}
return &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
return &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
GetRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
}
}
@@ -61,7 +62,7 @@ func decodeAdminError(t *testing.T, rec *httptest.ResponseRecorder) adminErrorBo
// An unknown method is rejected before authorization, so it never discloses
// whether the caller would have been permitted.
func TestAdminRequestUnknownMethod(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
rec := adminRequest(gw, "PurgeEverything", `{}`)
@@ -70,7 +71,7 @@ func TestAdminRequestUnknownMethod(t *testing.T) {
}
func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
rec := adminRequest(gw, "CreateAccount", `{}`)
@@ -82,7 +83,7 @@ func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
// The request ID ties a client failure to the gateway log line, so it must be
// present on the header and in the body even for a denial.
func TestAdminRequestAlwaysCarriesRequestID(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
rec := adminRequest(gw, "CreateAccount", `{}`)
@@ -105,7 +106,7 @@ func TestWriteAdminErrorFallsBackOnUnknownCode(t *testing.T) {
// The router serves every verb from one pattern, so the handler is what
// refuses a GET — a second route would shadow the POST one.
func TestAdminRequestRejectsNonPost(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
req := httptest.NewRequest(http.MethodGet, "/admin/CreateAccount", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("method", "CreateAccount")
@@ -135,7 +136,7 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
ctx = reqctx.WithService(ctx, "spinifex")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.DefaultAccountID())
ctx = withPrincipalField(ctx, fieldIdentity, "admin")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
rec := httptest.NewRecorder()
gw.Admin_Request(rec, req.WithContext(ctx))
@@ -145,7 +146,7 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
// An authorized caller must still get a clear answer when the cluster is not
// reachable, rather than a panic or a silent success.
func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
rec := authorizedAdminRequest(gw, `{}`)
@@ -155,7 +156,7 @@ func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
// Each admin method is a separate grant. The signup Worker holds
// spinifex:CreateAccount and must not reach deletion with it.
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
gw := withOperator(withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
t.Run(method, func(t *testing.T) {
-1
View File
@@ -35,7 +35,6 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
if gw.RateLimiter == nil {
gw.RateLimiter = authlimit.NewAuthRateLimiter()
}
gw.fillDefaultCapabilities()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
src := authSource{limitKey: ingresshttp.ClientIP(r.RemoteAddr), logIP: ingresshttp.RequestClientIP(r)}
+12 -21
View File
@@ -5,6 +5,7 @@ package gateway
import (
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"net/http"
"net/http/httptest"
"testing"
@@ -18,38 +19,38 @@ import (
func statusGateRouter(t *testing.T, svc iamdomain.IAMService) (chi.Router, *http.Request) {
t.Helper()
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: svc})
gw := withEC2(withRealAuth(&GatewayConfig{DisableLogging: true, Region: gwfix.Region, IAMService: svc}))
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
signTestRequest(t, req, nil, testAccessKey, testSecretKey)
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
return r, req
}
// statusGateService returns a mock holding one usable access key for account,
// with that account pinned to status.
func statusGateService(t *testing.T, accountID, status string) *mockIAMService {
func statusGateService(t *testing.T, accountID, status string) *gwfix.IAM {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
if err != nil {
t.Fatalf("encrypt secret: %v", err)
}
return &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
return &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: accountID,
Status: iamdomain.AccessKeyStatusActive,
},
},
accounts: map[string]*iamdomain.Account{
Accounts: map[string]*iamdomain.Account{
accountID: {AccountID: accountID, Status: status},
},
}
@@ -63,16 +64,6 @@ func TestAuthAllowsAnActiveAccount(t *testing.T) {
}
}
// Until this gate existed, account status was enforced only in ECR's principal
// resolution, so suspending an account left EC2, S3 and STS wide open.
func TestAuthDeniesASuspendedAccount(t *testing.T) {
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusSuspended))
if resp := doRequest(r, req); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a SUSPENDED account, got %d", resp.StatusCode)
}
}
// The quiesce that makes teardown converge: a terminating account must not be
// able to create resources the cascade has already walked past.
func TestAuthDeniesATerminatingAccount(t *testing.T) {
@@ -85,7 +76,7 @@ func TestAuthDeniesATerminatingAccount(t *testing.T) {
// unreadableAccountService answers key lookups but never account lookups.
type unreadableAccountService struct {
*mockIAMService
*gwfix.IAM
}
func (s *unreadableAccountService) GetAccount(string) (*iamdomain.Account, error) {
+34 -484
View File
@@ -4,14 +4,9 @@ package gateway
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"net/http"
"net/http/httptest"
"net/url"
@@ -23,7 +18,6 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/golang-jwt/jwt/v5"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
@@ -35,7 +29,6 @@ import (
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
)
// The TestCurrentBehaviour_ tests in this file pin today's SigV4 authentication,
@@ -135,13 +128,12 @@ func (e *cbEnv) gateway() http.Handler {
gw := &GatewayConfig{
DisableLogging: true,
NATSConn: e.nc,
Region: testRegion,
Region: gwfix.Region,
ExpectedNodes: 1,
IAMService: e.counter,
Credentials: credverify.New(e.counter, e.sts),
STSService: e.sts,
}
withIAMAndSTS(gw)
withIAMAndSTS(withRealAuth(gw))
return gw.SetupRoutes()
}
@@ -182,7 +174,7 @@ func (e *cbEnv) request(t *testing.T, h http.Handler, c cbCreds, service string,
req.Header.Set("X-Amz-Security-Token", c.token)
}
if c.akid != "" {
signTestRequestAs(t, req, body, c.akid, c.secret, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, c.akid, c.secret, service, time.Now().UTC())
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
@@ -269,39 +261,6 @@ func (e *cbEnv) getSessionToken(t *testing.T, c cbCreds) cbCreds {
return cbSessionFrom(t, e.request(t, e.h, c, "sts", cbForm("GetSessionToken")))
}
// expireSession rewrites a stored session record's expiry into the past.
func (e *cbEnv) expireSession(t *testing.T, akid string) {
t.Helper()
kv, err := e.js.KeyValue(t.Context(), stsdomain.KVBucketSessionCredentials)
require.NoError(t, err)
entry, err := kv.Get(t.Context(), akid)
require.NoError(t, err)
var cred stsdomain.SessionCredential
require.NoError(t, json.Unmarshal(entry.Value(), &cred))
cred.ExpiresAt = time.Now().UTC().Add(-time.Minute)
data, err := json.Marshal(cred)
require.NoError(t, err)
_, err = kv.Put(t.Context(), akid, data)
require.NoError(t, err)
}
func (e *cbEnv) sessionStored(t *testing.T, akid string) bool {
t.Helper()
cred, err := e.sts.LookupSessionCredential(akid)
require.NoError(t, err)
return cred != nil
}
func tamper(s string) string {
b := []byte(s)
if b[0] == 'A' {
b[0] = 'B'
} else {
b[0] = 'A'
}
return string(b)
}
// Pins CURRENT behaviour: how a valid long-lived key resolves to a principal and account.
func TestCurrentBehaviour_Auth_LongLivedKeyResolvesPrincipal(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
@@ -339,278 +298,6 @@ func TestCurrentBehaviour_Auth_LongLivedKeyResolvesPrincipal(t *testing.T) {
})
}
// Pins CURRENT behaviour: wire outcomes for invalid long-lived credentials, and
// that key status changes apply to the very next request (no credential cache).
func TestCurrentBehaviour_Auth_LongLivedRejections(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
gci := cbForm("GetCallerIdentity")
t.Run("unknown_key", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), got)
})
t.Run("wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: e.alice.akid, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
t.Run("malformed_authorization_header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(gci.Encode()))
req.RemoteAddr = "10.200.0.1:40000"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=garbage")
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
assert.Equal(t, cbRegistry(awserrors.ErrorIncompleteSignature), cbOutcomeOf(rec))
})
t.Run("key_made_inactive_after_use", func(t *testing.T) {
creds := e.newKey(t, "alice")
require.Equal(t, http.StatusOK, e.callerIdentity(t, creds).Code)
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(creds.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, creds)))
})
t.Run("key_deleted_after_use", func(t *testing.T) {
u, _ := e.newUser(t, "dave")
require.Equal(t, http.StatusOK, e.callerIdentity(t, u).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("dave"), AccessKeyId: aws.String(u.akid),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, u)))
})
}
// Pins CURRENT behaviour: AssumeRole session credentials at the SigV4 door.
func TestCurrentBehaviour_Session_AssumeRoleCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, roleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
other := e.assumeRole(t, roleARN, "s2")
t.Run("valid_session", func(t *testing.T) {
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
body := rec.Body.String()
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/app/s1", cbXML(body, "Arn"))
assert.Equal(t, roleID+":s1", cbXML(body, "UserId"))
assert.Equal(t, e.accountID, cbXML(body, "Account"))
})
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
for name, c := range map[string]cbCreds{
"missing_token": {akid: s.akid, secret: s.secret},
"other_sessions_token": {akid: s.akid, secret: s.secret, token: other.token},
"tampered_token": {akid: s.akid, secret: s.secret, token: tamper(s.token)},
"unknown_session_akid": {akid: cbUnknownSes, secret: s.secret, token: s.token},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
// CURRENT: an AKIA key ignores X-Amz-Security-Token entirely; the header is
// only signed, never checked, so a long-lived key plus any token succeeds.
t.Run("long_lived_with_session_token", func(t *testing.T) {
rec := e.callerIdentity(t, cbCreds{akid: e.alice.akid, secret: e.alice.secret, token: s.token})
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
})
t.Run("valid_token_wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: cbBadSecret, token: s.token}))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
}
// Pins CURRENT behaviour: an expired session answers ExpiredToken (HTTP 400)
// before its session token is examined.
func TestCurrentBehaviour_Session_Expired(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, _ := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
e.expireSession(t, s.akid)
// CURRENT: ExpiredToken is 400 here; AWS STS/IAM answer it as 403 (unverified).
expired := cbRegistry(awserrors.ErrorExpiredToken)
require.Equal(t, http.StatusBadRequest, expired.status)
for name, c := range map[string]cbCreds{
"valid_token": s,
// CURRENT: expiry is checked ahead of the token, so a caller holding only
// the cleartext ASIA key ID learns the session has expired.
"no_token": {akid: s.akid, secret: s.secret},
"wrong_token": {akid: s.akid, secret: cbBadSecret, token: tamper(s.token)},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, expired, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
}
// Pins CURRENT behaviour: a GetSessionToken session resolves to the IAM user and
// outlives the long-lived key that minted it.
func TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
mintKey := e.newKey(t, "alice")
s := e.getSessionToken(t, mintKey)
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, e.aliceID, cbXML(rec.Body.String(), "UserId"))
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid),
})
require.NoError(t, err)
// CURRENT: the session record carries no reference to the minting key, so
// deactivating and deleting that key leaves the session authenticating.
assert.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
// A session cannot mint a further session.
assert.Equal(t, cbRegistry(awserrors.ErrorAccessDenied).code,
cbOutcomeOf(e.request(t, e.h, s, "sts", cbForm("GetSessionToken"))).code)
}
// Pins CURRENT behaviour: an AssumeRoleWithWebIdentity session authenticates at
// the SigV4 door exactly as an AssumeRole session does.
func TestCurrentBehaviour_Session_WebIdentityCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
issuer := testDiscIssuer
iamKV, err := kvutil.GetOrCreateBucket(t.Context(), e.js,
iamdomain.IAMAccountBucketName(e.accountID), iamdomain.KVBucketIAMAccountVersion)
require.NoError(t, err)
_, err = iamKV.Put(t.Context(), iamdomain.OIDCProviderKey(issuer), []byte(`{"registered":true}`))
require.NoError(t, err)
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
point, err := priv.PublicKey.Bytes()
require.NoError(t, err)
jwks, err := json.Marshal(stsdomain.JWKS{Keys: []stsdomain.JWK{{
Kty: "EC", Kid: "k1", Alg: "ES256", Use: "sig", Crv: "P-256",
X: base64.RawURLEncoding.EncodeToString(point[1:33]),
Y: base64.RawURLEncoding.EncodeToString(point[33:65]),
}}})
require.NoError(t, err)
eksKV, err := ekscluster.GetOrCreateAccountBucket(t.Context(), e.js, e.accountID)
require.NoError(t, err)
_, err = eksKV.Put(t.Context(), ekscluster.OIDCJWKSKey(testDiscCluster), jwks)
require.NoError(t, err)
federated := iamdomain.OIDCProviderARN(e.accountID, strings.TrimPrefix(issuer, "https://"))
role, err := e.iam.CreateRole(e.accountID, &iam.CreateRoleInput{
RoleName: aws.String("irsa"),
AssumeRolePolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"` +
federated + `"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`),
})
require.NoError(t, err)
now := time.Now().UTC()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": issuer, "sub": "system:serviceaccount:default:sa", "aud": []string{"sts.amazonaws.com"},
"exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(),
})
tok.Header["kid"] = "k1"
signed, err := tok.SignedString(priv)
require.NoError(t, err)
// Unsigned: the anonymous STS interceptor serves it ahead of SigV4.
s := cbSessionFrom(t, e.request(t, e.h, cbCreds{}, "sts", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", aws.StringValue(role.Role.Arn), "RoleSessionName", "pod", "WebIdentityToken", signed)))
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/irsa/pod", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId),
cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: s.secret, token: tamper(s.token)})))
}
// Pins CURRENT behaviour: deleting a user and recreating it under the same name
// strands its old key and sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_UserDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
oldKey, oldID := e.newUser(t, "bob")
session := e.getSessionToken(t, oldKey)
require.Equal(t, http.StatusOK, e.callerIdentity(t, session).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(oldKey.akid),
})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("bob")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, session.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "session after delete")
newKey, newID := e.newUser(t, "bob")
require.NotEqual(t, oldID, newID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, oldKey)), "old key after recreate")
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "old session after recreate")
rec := e.callerIdentity(t, newKey)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, newID, cbXML(rec.Body.String(), "UserId"))
})
}
}
// Pins CURRENT behaviour: deleting a role and recreating it under the same name
// strands its old sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_RoleDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
roleARN, oldRoleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
_, err := e.iam.DeleteRole(e.accountID, &iam.DeleteRoleInput{RoleName: aws.String("app")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, s.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "session after delete")
newARN, newRoleID := e.createAssumableRole(t, "app")
require.Equal(t, roleARN, newARN)
require.NotEqual(t, oldRoleID, newRoleID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "old session after recreate")
assert.Equal(t, http.StatusOK, e.callerIdentity(t, e.assumeRole(t, newARN, "s2")).Code)
})
}
}
// Pins CURRENT behaviour: a long-lived key is bound to a user by name only, so
// a key record orphaned from its user authenticates as a same-name replacement.
func TestCurrentBehaviour_Revocation_OrphanedKeyAuthenticatesAsRecreatedUser(t *testing.T) {
@@ -657,65 +344,41 @@ func (e *cbEnv) orphanKeys(t *testing.T, user string) {
require.NoError(t, err)
}
// Pins CURRENT behaviour: whose identity policy the policy check applies to an
// orphaned key once its user is deleted and recreated under the same name.
func TestCurrentBehaviour_Revocation_OrphanedKeyPermissionsAfterRecreation(t *testing.T) {
// Pins CURRENT behaviour: identity-policy allow, implicit deny and explicit deny
// on iam actions, evaluated after authentication and before the handler runs.
func TestCurrentBehaviour_Policy_IdentityPolicyOutcomes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
key, oldID := e.newUser(t, "frank")
listUsers := cbForm("ListUsers")
getUser := cbForm("GetUser", "UserName", "frank")
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.LookupErrorMessage("iam", awserrors.ErrorAccessDenied).Message}
// Policy A: ListUsers only.
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
require.Equal(t, http.StatusOK, e.request(t, e.h, key, "iam", listUsers).Code, "A allows ListUsers")
require.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", getUser)).code, "A denies GetUser")
e.orphanKeys(t, "frank")
_, err := e.iam.DeleteUserPolicy(e.accountID, &iam.DeleteUserPolicyInput{UserName: aws.String("frank"), PolicyName: aws.String("p")})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
// Policy B, on the replacement user: GetUser only.
u, err := e.iam.CreateUser(e.accountID, &iam.CreateUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
require.NotEqual(t, oldID, aws.StringValue(u.User.UserId))
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
// CURRENT: the orphaned key is evaluated against the replacement user's
// policy B, so it gains GetUser and loses the ListUsers that A granted.
rec := e.request(t, e.h, key, "iam", getUser)
assert.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", listUsers)).code)
}
// Pins CURRENT behaviour: unsigned requests are refused with
// MissingAuthenticationToken unless the action is anonymous STS.
func TestCurrentBehaviour_Auth_MissingCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
missing := cbRegistry(awserrors.ErrorMissingAuthenticationToken)
for _, f := range []url.Values{
cbForm("ListUsers"),
cbForm("GetCallerIdentity"),
cbForm("GetSessionToken"),
cbForm("AssumeRole", "RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s"),
} {
t.Run(f.Get("Action"), func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", f)))
})
}
// Session token alone, with no Authorization header.
t.Run("token_without_signature", func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{token: "anything"}, "", cbForm("GetCallerIdentity"))))
t.Run("implicit_deny", func(t *testing.T) {
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", listUsers)))
})
// The anonymous path reaches STS itself: its verdict, not the door's.
t.Run("AssumeRoleWithWebIdentity", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s1", "WebIdentityToken", "not-a-jwt")))
assert.Equal(t, http.StatusBadRequest, got.status)
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, got.code)
t.Run("allow", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
rec := e.request(t, e.h, e.alice, "iam", listUsers)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "<UserName>alice</UserName>")
})
t.Run("explicit_deny_blocks_before_dispatch", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Allow","Action":"iam:*","Resource":"*"},`+
`{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`)
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", cbForm("CreateUser", "UserName", "mallory"))))
_, err := e.iam.GetUser(e.accountID, &iam.GetUserInput{UserName: aws.String("mallory")})
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity, "denied CreateUser must not have run")
assert.Equal(t, http.StatusOK, e.request(t, e.h, e.alice, "iam", listUsers).Code)
})
// CURRENT: the root-name policy bypass is limited to the global account, so a
// tenant user named "root" with no policy is implicitly denied.
t.Run("tenant_user_named_root_implicit_deny", func(t *testing.T) {
creds, _ := e.newUser(t, "root")
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, creds, "iam", listUsers)))
})
}
@@ -771,116 +434,3 @@ func TestCurrentBehaviour_Auth_BackingStateUnavailable(t *testing.T) {
cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: e.alice.akid, secret: cbBadSecret})))
})
}
// Pins CURRENT behaviour: identity-policy allow, implicit deny and explicit deny
// on iam actions, evaluated after authentication and before the handler runs.
func TestCurrentBehaviour_Policy_IdentityPolicyOutcomes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
listUsers := cbForm("ListUsers")
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.LookupErrorMessage("iam", awserrors.ErrorAccessDenied).Message}
t.Run("implicit_deny", func(t *testing.T) {
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", listUsers)))
})
t.Run("allow", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
rec := e.request(t, e.h, e.alice, "iam", listUsers)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "<UserName>alice</UserName>")
})
t.Run("explicit_deny_blocks_before_dispatch", func(t *testing.T) {
e.putUserPolicy(t, "alice", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Allow","Action":"iam:*","Resource":"*"},`+
`{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`)
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, e.alice, "iam", cbForm("CreateUser", "UserName", "mallory"))))
_, err := e.iam.GetUser(e.accountID, &iam.GetUserInput{UserName: aws.String("mallory")})
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity, "denied CreateUser must not have run")
assert.Equal(t, http.StatusOK, e.request(t, e.h, e.alice, "iam", listUsers).Code)
})
// CURRENT: the root-name policy bypass is limited to the global account, so a
// tenant user named "root" with no policy is implicitly denied.
t.Run("tenant_user_named_root_implicit_deny", func(t *testing.T) {
creds, _ := e.newUser(t, "root")
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.h, creds, "iam", listUsers)))
})
}
// Pins CURRENT behaviour: which credential failures a caller can tell apart on
// the wire, and whether the door decrypted a secret before answering.
func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
bobKey, _ := e.newUser(t, "bob")
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(bobKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
carolKey, _ := e.newUser(t, "carol")
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{UserName: aws.String("carol"), AccessKeyId: aws.String(carolKey.akid)})
require.NoError(t, err)
live := e.getSessionToken(t, e.alice)
expired := e.getSessionToken(t, e.alice)
e.expireSession(t, expired.akid)
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
sigMismatch := cbRegistry(awserrors.ErrorSignatureDoesNotMatch)
for _, tc := range []struct {
name string
creds cbCreds
want cbOutcome
decrypts int32
}{
{"unknown_key", cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, invalid, 0},
{"unknown_prefix", cbCreds{akid: "AKIBCBUNKNOWNPREFIXA", secret: cbBadSecret}, invalid, 0},
{"deleted_key_right_secret", carolKey, invalid, 0},
{"inactive_key_right_secret", bobKey, invalid, 0},
// CURRENT: an inactive key answers InvalidClientTokenId without a
// signature check, while an active key with a bad signature answers
// SignatureDoesNotMatch, so a caller with no secret can tell the two apart.
{"inactive_key_bad_signature", cbCreds{akid: bobKey.akid, secret: cbBadSecret}, invalid, 0},
{"active_key_bad_signature", cbCreds{akid: e.alice.akid, secret: cbBadSecret}, sigMismatch, 1},
{"unknown_session", cbCreds{akid: cbUnknownSes, secret: cbBadSecret, token: live.token}, invalid, 0},
{"session_no_token", cbCreds{akid: live.akid, secret: cbBadSecret}, invalid, 0},
{"session_wrong_token", cbCreds{akid: live.akid, secret: cbBadSecret, token: tamper(live.token)}, invalid, 0},
{"session_expired_no_token", cbCreds{akid: expired.akid, secret: cbBadSecret}, cbRegistry(awserrors.ErrorExpiredToken), 0},
{"session_right_token_bad_signature", cbCreds{akid: live.akid, secret: cbBadSecret, token: live.token}, sigMismatch, 1},
} {
t.Run(tc.name, func(t *testing.T) {
e.counter.decrypts.Store(0)
assert.Equal(t, tc.want, cbOutcomeOf(e.callerIdentity(t, tc.creds)))
assert.Equal(t, tc.decrypts, e.counter.decrypts.Load(), "secret decrypts before the verdict")
})
}
}
// Pins CURRENT behaviour: the door caches only an account's ACTIVE status, per
// gateway, for accountadmission.ActiveTTL; keys and sessions are re-read on every request.
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
require.Equal(t, 5*time.Second, accountadmission.ActiveTTL)
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
t.Run(status, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
require.Equal(t, http.StatusOK, e.callerIdentity(t, e.alice).Code)
start := time.Now()
_, err := e.iam.SetAccountStatus(e.accountID, status)
require.NoError(t, err)
// CURRENT: an out-of-band status write is not seen by a gateway
// holding a warm ACTIVE entry until that entry expires.
warm := e.callerIdentity(t, e.alice)
if time.Since(start) >= accountadmission.ActiveTTL {
t.Skip("cache window elapsed before the warm request; outcome not attributable")
}
assert.Equal(t, http.StatusOK, warm.Code)
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.ErrorLookup[awserrors.ErrorAccessDenied].Message}
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.gateway(), e.alice, "sts", cbForm("GetCallerIdentity"))))
})
}
}
+9 -35
View File
@@ -2,9 +2,7 @@ package gateway
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
@@ -15,8 +13,6 @@ import (
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/stretchr/testify/require"
)
@@ -36,13 +32,13 @@ func captureLogs(t *testing.T) *bytes.Buffer {
// tell them apart after the fact, so the log line is the contract under test.
func TestSigV4Auth_SkewedRequestIsLogged(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
logs := captureLogs(t)
skewed := time.Now().UTC().Add(-30 * time.Minute)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
signTestRequest(t, req, nil, testAccessKey, testSecretKey, skewed)
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, skewed)
resp := doRequest(handler, req)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
@@ -55,32 +51,10 @@ func TestSigV4Auth_SkewedRequestIsLogged(t *testing.T) {
require.Contains(t, out, "serverTime=")
}
func TestSigV4Auth_UnsupportedServiceIsLogged(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
logs := captureLogs(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
sum := sha256.Sum256(nil)
payloadHash := hex.EncodeToString(sum[:])
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: testAccessKey, SecretAccessKey: testSecretKey},
req, payloadHash, "notaservice", testRegion, time.Now().UTC()))
resp := doRequest(handler, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
out := logs.String()
require.Contains(t, out, "unsupported service in credential scope")
require.Contains(t, out, "service=notaservice")
}
// The parse-error branch answers IncompleteSignature and was likewise silent,
// so a malformed envelope left no record of which validation stage rejected it.
func TestSigV4Auth_MalformedEnvelopeIsLogged(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
logs := captureLogs(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -127,10 +101,10 @@ func TestSigV4Auth_FailureLogsLoopbackGatedClientIP(t *testing.T) {
defer rl.Stop()
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
IAMService: &mockIAMService{masterKey: testMasterKey},
Credentials: credverify.New(&mockIAMService{masterKey: testMasterKey}, &mockSTSService{}),
STSService: &mockSTSService{},
Region: gwfix.Region,
IAMService: &gwfix.IAM{MasterKey: gwfix.MasterKey},
Credentials: credverify.New(&gwfix.IAM{MasterKey: gwfix.MasterKey}, &gwfix.STS{}),
STSService: &gwfix.STS{},
RateLimiter: rl,
}
withEC2(gw)
@@ -143,7 +117,7 @@ func TestSigV4Auth_FailureLogsLoopbackGatedClientIP(t *testing.T) {
req.Host = "localhost:9999"
req.RemoteAddr = tc.remoteAddr
req.Header.Set("X-Real-IP", "203.0.113.7")
signSessionRequest(t, req, nil, testSessionAKID, testSecretKey, testSessionToken)
gwfix.SignSessionRequest(t, req, nil, gwfix.SessionAKID, gwfix.SecretKey, gwfix.SessionToken)
resp := doRequest(handler, req)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
+1859 -2467
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
package gateway
import "github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
// defaultCapabilities builds the capabilities a GatewayConfig left unset from
// its IAM service and bus connection. Production composition sets all of them and
// leaves this nil.
var defaultCapabilities func(gw *GatewayConfig) (pipeline.PolicyEvaluator, pipeline.AccountAdmission, pipeline.Readiness)
// fillDefaultCapabilities sets whichever capability is nil from
// defaultCapabilities. With no defaults installed it changes nothing.
func (gw *GatewayConfig) fillDefaultCapabilities() {
if defaultCapabilities == nil || (gw.Policy != nil && gw.Admission != nil && gw.Readiness != nil) {
return
}
policy, admission, readiness := defaultCapabilities(gw)
if gw.Policy == nil {
gw.Policy = policy
}
if gw.Admission == nil {
gw.Admission = admission
}
if gw.Readiness == nil {
gw.Readiness = readiness
}
}
-10
View File
@@ -58,16 +58,7 @@ func TestCheckPolicy_ReturnsTheEvaluatorVerdict(t *testing.T) {
assert.ErrorIs(t, gw.checkPolicyResources(req, "ec2", "DescribeVpcs", []string{"*"}), verdict)
}
// withoutDefaultCapabilities removes the test defaults so a nil capability stays nil.
func withoutDefaultCapabilities(t *testing.T) {
t.Helper()
prev := defaultCapabilities
defaultCapabilities = nil
t.Cleanup(func() { defaultCapabilities = prev })
}
func TestCheckPolicy_NoEvaluatorFailsClosed(t *testing.T) {
withoutDefaultCapabilities(t)
gw := &GatewayConfig{}
req := withTestIdentity(httptest.NewRequest(http.MethodPost, "/", nil))
@@ -77,7 +68,6 @@ func TestCheckPolicy_NoEvaluatorFailsClosed(t *testing.T) {
}
func TestCheckAccountActive_MapsAdmissionVerdicts(t *testing.T) {
withoutDefaultCapabilities(t)
src := authSource{}
cases := []struct {
name string
@@ -1,23 +1,46 @@
package gateway
import (
"net/http"
"time"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/clusterready"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
)
// policyRetryBase shortens the backoff between policy-resolve attempts.
var policyRetryBase = 200 * time.Millisecond
func init() {
defaultCapabilities = func(gw *GatewayConfig) (pipeline.PolicyEvaluator, pipeline.AccountAdmission, pipeline.Readiness) {
var readiness pipeline.Readiness
if gw.NATSConn != nil {
readiness = clusterready.New(gw.NATSConn)
}
return policyauth.New(gw.IAMService, policyauth.WithRetryBase(policyRetryBase)), accountadmission.New(gw.IAMService), readiness
}
// allowPolicy permits every request; tests that assert an authorization
// verdict run against the real evaluator in the awsgw packages instead.
type allowPolicy struct{}
func (allowPolicy) RequestConditionKeys(*http.Request, credential.Principal) iampolicy.ConditionKeys {
return iampolicy.ConditionKeys{}
}
func (allowPolicy) Evaluate(credential.Principal, string, []string, iampolicy.ConditionKeys) error {
return nil
}
type allowAdmission struct{}
func (allowAdmission) Admit(string, string) error { return nil }
type natsReadiness struct{ gw *GatewayConfig }
func (n natsReadiness) Available() bool { return n.gw.NATSConn != nil && n.gw.NATSConn.IsConnected() }
// withFakeCapabilities fills whichever capability a test left unset.
func withFakeCapabilities(gw *GatewayConfig) *GatewayConfig {
if gw.Policy == nil {
gw.Policy = allowPolicy{}
}
if gw.Admission == nil {
gw.Admission = allowAdmission{}
}
if gw.Readiness == nil && gw.NATSConn != nil {
gw.Readiness = natsReadiness{gw}
}
return gw
}
@@ -7,6 +7,7 @@ package gateway
import (
"crypto/tls"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
@@ -110,18 +111,18 @@ func emittedKeys(t *testing.T) map[string]string {
"user": {
Identity: "alice",
AccountID: "000000000001",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
},
"assumed-role": {
Identity: "session",
AccountID: "000000000001",
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
AssumedRoleARN: "arn:aws:sts::000000000001:assumed-role/SharedOps/session",
AssumedRoleID: "AROASHAREDOPS:session",
},
}
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: &gwfix.IAM{}})
union := make(map[string]string)
for name, principal := range principals {
r := httptest.NewRequest(http.MethodPost, "/?prefix=home/&max-keys=100", nil)
+18 -17
View File
@@ -1,11 +1,12 @@
//test:in-package — requestConditionKeys, checkPolicyResources, credential.Principal
//and ctxAccountID are all unexported, and the file reuses the in-package
//policyMockIAMService and withTestIdentity doubles.
//gwfix.PolicyIAM and withTestIdentity doubles.
package gateway
import (
"crypto/tls"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"net/http"
@@ -27,7 +28,7 @@ func TestRequestConditionKeys_PopulatesAvailableKeys(t *testing.T) {
principal := credential.Principal{
Identity: "alice",
AccountID: "000000000001",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
UserID: "AIDAALICE",
}
@@ -62,9 +63,9 @@ func TestRequestConditionKeys_PrincipalType(t *testing.T) {
want string
omitted bool
}{
{principalType: principalTypeUser, want: iampolicy.PrincipalTypeUser},
{principalType: principalTypeAssumedRole, want: iampolicy.PrincipalTypeAssumedRole},
{principalType: principalTypeRoot, want: iampolicy.PrincipalTypeAccount},
{principalType: gwfix.PrincipalTypeUser, want: iampolicy.PrincipalTypeUser},
{principalType: gwfix.PrincipalTypeAssumedRole, want: iampolicy.PrincipalTypeAssumedRole},
{principalType: gwfix.PrincipalTypeRoot, want: iampolicy.PrincipalTypeAccount},
{principalType: "", omitted: true},
}
for _, tt := range tests {
@@ -87,7 +88,7 @@ func TestRequestConditionKeys_UserIDForAssumedRole(t *testing.T) {
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "session",
AccountID: "000000000001",
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
AssumedRoleID: "AROASHAREDOPS:session",
UserID: "AROASHAREDOPS:session",
})
@@ -102,7 +103,7 @@ func TestRequestConditionKeys_OmitsUnresolvableUserID(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", nil)
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "alice", AccountID: "000000000001", PrincipalType: principalTypeUser,
Identity: "alice", AccountID: "000000000001", PrincipalType: gwfix.PrincipalTypeUser,
})
assert.NotContains(t, keys, iampolicy.KeyUserID)
}
@@ -115,7 +116,7 @@ func TestRequestConditionKeys_OmitsUsernameForAssumedRole(t *testing.T) {
principal := credential.Principal{
Identity: "alice",
AccountID: "000000000001",
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
AssumedRoleARN: "arn:aws:sts::000000000001:assumed-role/SharedOps/alice",
}
@@ -131,7 +132,7 @@ func TestRequestConditionKeys_OmitsS3Prefix(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/?prefix=home/", nil)
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "alice",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
})
assert.NotContains(t, keys, iampolicy.KeyS3Prefix)
@@ -143,7 +144,7 @@ func TestRequestConditionKeys_OmitsS3Prefix(t *testing.T) {
func TestRequestConditionKeys_OmitsEmptyValues(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.RemoteAddr = ""
keys := policyauth.RequestConditionKeys(r, credential.Principal{PrincipalType: principalTypeUser})
keys := policyauth.RequestConditionKeys(r, credential.Principal{PrincipalType: gwfix.PrincipalTypeUser})
assert.NotContains(t, keys, iampolicy.KeySourceIP)
assert.NotContains(t, keys, iampolicy.KeyUsername)
@@ -159,7 +160,7 @@ func TestRequestConditionKeys_SourceIPWithoutAuthMiddleware(t *testing.T) {
keys := policyauth.RequestConditionKeys(r, credential.Principal{
Identity: "alice",
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
})
assert.Equal(t, "10.4.1.9", keys[iampolicy.KeySourceIP])
@@ -167,7 +168,7 @@ func TestRequestConditionKeys_SourceIPWithoutAuthMiddleware(t *testing.T) {
// conditionedPolicyIAMService serves one statement carrying cond, so a test can
// drive a real condition through checkPolicyResources.
func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdomain.ConditionValue) *policyMockIAMService {
func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdomain.ConditionValue) *gwfix.PolicyIAM {
docs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
@@ -183,9 +184,9 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
if effect == "Allow" {
docs[0].Statement = docs[0].Statement[1:]
}
return &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
return &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}
}
@@ -193,7 +194,7 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
// nil keys from checkPolicyResources would leave every other test green while
// silently killing every conditional Allow on this path.
func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
IAMService: conditionedPolicyIAMService("Allow", map[string]map[string]iamdomain.ConditionValue{
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"10.0.0.0/8"}},
@@ -232,7 +233,7 @@ func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
// the one direction where an absent key widens access. Pin it so the omission
// is a decision rather than an accident.
func TestCheckPolicyResources_ConditionalDenyHonoursSourceIP(t *testing.T) {
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
IAMService: conditionedPolicyIAMService("Deny", map[string]map[string]iamdomain.ConditionValue{
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"203.0.113.0/24"}},
+26 -25
View File
@@ -8,6 +8,7 @@ import (
"bytes"
"encoding/xml"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
@@ -138,7 +139,7 @@ func fakeNoResolverRegistration(c *dispatchCapture) dispatch.Registration {
// fake service's full pipeline mirrors ecrPipelineApp for a real one.
func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...dispatch.Registration) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
@@ -147,17 +148,17 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
require.NoError(t, b.Register(reg))
}
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
Region: gwfix.Region,
NATSConn: nc,
Services: b.Build(),
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
IAMService: &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: ecrTestAccount,
@@ -165,7 +166,7 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
@@ -184,7 +185,7 @@ func signDispatchRequest(t *testing.T, service string, body []byte) *http.Reques
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", "Whatever."+fakeAction)
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, service, time.Now().UTC())
return req
}
@@ -200,7 +201,7 @@ func TestDispatchSeam_RequestReachesDispatcherWithContext(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, capt.bodyRead, "dispatcher must have read the request body")
assert.Equal(t, ecrTestAccount, capt.accountID)
assert.Equal(t, testRegion, capt.region)
assert.Equal(t, gwfix.Region, capt.region)
assert.Equal(t, body, capt.body, "dispatcher must see the full body")
}
@@ -253,8 +254,8 @@ func TestDispatchSeam_ResolvedActionReachesThrottleTraceAndAudit(t *testing.T) {
// --- c. Authorize denies or allows ------------------------------------------
func TestDispatchSeam_AuthorizeDeniesOnMismatchAllowsOnMatch(t *testing.T) {
const allowedResource = "arn:aws:testsvc:" + testRegion + ":" + ecrTestAccount + ":widget/widget1"
const otherResource = "arn:aws:testsvc:" + testRegion + ":" + ecrTestAccount + ":widget/other"
const allowedResource = "arn:aws:testsvc:" + gwfix.Region + ":" + ecrTestAccount + ":widget/widget1"
const otherResource = "arn:aws:testsvc:" + gwfix.Region + ":" + ecrTestAccount + ":widget/other"
t.Run("scoped to a different resource is denied", func(t *testing.T) {
capt := &dispatchCapture{authorizeResource: allowedResource}
@@ -305,8 +306,8 @@ func TestDispatchSeam_ActorReturnsVerifiedCallerIgnoringBody(t *testing.T) {
require.NoError(t, capt.actorErr)
assert.Equal(t, ecrTestAccount, capt.actor.AccountID)
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/alice", capt.actor.CallerARN)
assert.Equal(t, principalTypeUser, capt.actor.PrincipalType)
assert.Equal(t, testAccessKey, capt.actor.AccessKeyID)
assert.Equal(t, gwfix.PrincipalTypeUser, capt.actor.PrincipalType)
assert.Equal(t, gwfix.AccessKey, capt.actor.AccessKeyID)
}
// --- e. dispatcher error renders in the registration's declared envelope ---
@@ -341,7 +342,7 @@ func TestDispatchSeam_NATSDownGetsSame503AsLegacy(t *testing.T) {
b := dispatch.NewBuilder()
require.NoError(t, b.Register(fakeJSONRegistration(&dispatchCapture{})))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService(), Services: b.Build()})
gw := withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService(), Services: b.Build()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := reqctx.WithService(req.Context(), fakeJSONService)
@@ -366,16 +367,16 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
b := dispatch.NewBuilder()
require.NoError(t, b.Register(fakeJSONRegistration(&dispatchCapture{})))
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
Region: gwfix.Region,
Services: b.Build(),
IAMService: &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {AccessKeyID: testAccessKey, SecretAccessKey: encryptedSecret, UserName: "root", Status: "Active"},
IAMService: &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {AccessKeyID: gwfix.AccessKey, SecretAccessKey: encryptedSecret, UserName: "root", Status: "Active"},
},
},
})
@@ -386,7 +387,7 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
signTestRequestAs(t, req, nil, testAccessKey, testSecretKey, "sqs", time.Now().UTC())
gwfix.SignRequestAs(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, "sqs", time.Now().UTC())
resp := doRequest(r, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
-104
View File
@@ -1,104 +0,0 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (setupEC2Request, policyMockIAMService) and auth context keys.
package gateway
import (
"net/http/httptest"
"testing"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
authzAccountID = "123456789012"
authzRegion = "ap-southeast-2"
)
// scopedPolicyGateway serves one policy document to every principal.
func scopedPolicyGateway(statements ...iamdomain.Statement) *GatewayConfig {
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return withVerifier(&GatewayConfig{
DisableLogging: true,
Region: authzRegion,
IAMService: &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
}
func statement(effect, action, resource string) iamdomain.Statement {
return iamdomain.Statement{
Effect: effect,
Action: iamdomain.StringOrArr{action},
Resource: iamdomain.StringOrArr{resource},
}
}
// dispatchEC2 drives the gateway with no NATS connection. A permitted request
// therefore reaches the handler and fails there, which is what proves the policy
// check ran ahead of the resource existing at all.
func dispatchEC2(t *testing.T, gw *GatewayConfig, body string) error {
t.Helper()
return gw.EC2_Request(httptest.NewRecorder(), setupEC2Request(body, authzAccountID))
}
// assertDenied matches on the resolved code, not the message: a policy denial
// names the principal, action and resource ahead of the code.
func assertDenied(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
}
// assertUnauthorized asserts EC2's policy denial, UnauthorizedOperation.
func assertUnauthorized(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorUnauthorizedOperation, err.Error())
}
// assertNotDenied asserts the policy gate passed where the handler failure that
// follows is not fixed. It resolves the code for the same reason assertDenied
// does: the denial message is no longer the bare code.
func assertNotDenied(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
code, _ := awserrors.ResolveErrorCode(err)
assert.NotEqual(t, awserrors.ErrorAccessDenied, code)
}
// assertPermitted asserts the policy gate passed. The request then fails on the
// absent NATS connection rather than on authorization, so a denial cannot hide
// behind a not-found.
func assertPermitted(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// profileIAMService resolves every instance profile to one holding role app, or
// fails resolution with resolveErr.
type profileIAMService struct {
policyMockIAMService
resolveErr error
}
func (s *profileIAMService) ResolveInstanceProfile(accountID, _ string) (*iamdomain.InstanceProfile, error) {
if s.resolveErr != nil {
return nil, s.resolveErr
}
return &iamdomain.InstanceProfile{AccountID: accountID, RoleName: "app"}, nil
}
func (s *profileIAMService) CanonicalResourceARN(accountID string, _ arn.IAMResourceType, name string) (string, error) {
return "arn:aws:iam::" + accountID + ":role/" + name, nil
}
@@ -1,23 +1,19 @@
//test:in-package — drives the public DeleteVolume action through EC2_Request
// with the gateway's unexported test helpers.
package gateway
import (
"context"
"github.com/mulgadc/spinifex/internal/testkit"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A configured node that never answers leaves the instance view incomplete, so
+2 -43
View File
@@ -1,32 +1,13 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"testing"
)
const dryRunMessage = "Request would have succeeded, but DryRun flag is set."
// serveEC2 runs a request through EC2_Request and, on error, ErrorHandler, so
// the assertion sees the status and body a client would.
func serveEC2(gw *GatewayConfig, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := setupEC2Request(body, authzAccountID)
if err := gw.EC2_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
return w
}
// The gateway has no NATS connection, so any request that reached its handler
// would fail ServerInternal; DryRunOperation proves nothing was dispatched.
func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) {
@@ -47,25 +28,3 @@ func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) {
})
}
}
func TestEC2Request_DryRunFalseDispatches(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
assertPermitted(t, dispatchEC2(t, gw, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=false"))
}
// A caller the policy refuses is denied rather than told the request would
// have succeeded.
func TestEC2Request_DryRunDeniedCaller(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:CreateVpc", "*"),
)
assertUnauthorized(t, dispatchEC2(t, gw, "Action=CreateVpc&CidrBlock=10.0.0.0/16&DryRun=true"))
assertDryRun(t, dispatchEC2(t, gw, "Action=CreateSecurityGroup&GroupName=g&GroupDescription=d&DryRun=true"))
}
func assertDryRun(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorDryRunOperation, err.Error())
}
@@ -1,16 +1,6 @@
// Characterization tests for the labels an EC2 request carries through the
// signed pipeline today: the trace span, the audit action, the throttle key,
// and the service and action the policy gate evaluates. Also the order of the
// cluster-unavailable gate against the checks that follow it. They assert
// current behaviour, not AWS parity.
package gateway
import (
"net/http"
"slices"
"testing"
"github.com/mulgadc/bluebottle/pkg/ratelimit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
@@ -20,17 +10,39 @@ import (
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"slices"
"testing"
)
// ec2LabelCases span the query-protocol shapes: a daemon-backed read, the
// gateway-local actions, an idempotent create and a key import.
var ec2LabelCases = []struct{ action, body string }{
{"DescribeVpcs", "Action=DescribeVpcs"},
{"DescribeInstances", "Action=DescribeInstances"},
{"DescribeRegions", "Action=DescribeRegions"},
{"DescribeAvailabilityZones", "Action=DescribeAvailabilityZones"},
{"CreateEgressOnlyInternetGateway", "Action=CreateEgressOnlyInternetGateway&VpcId=vpc-1"},
{"ImportKeyPair", "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=c3NoLXJzYQ%3D%3D"},
// The cluster-unavailable gate runs ahead of everything after authentication:
// a caller the policy would refuse, an action that does not exist and a
// gateway-local action all get the same 503, so none of them reach dispatch.
func TestEC2Labels_ClusterUnavailableGateOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
}{
{"policy would deny", true, "Action=DescribeVpcs"},
{"unknown action", false, "Action=NoSuchAction"},
{"missing action", false, "Foo=bar"},
{"gateway-local DescribeRegions", false, "Action=DescribeRegions"},
{"dry run", false, "Action=DescribeVpcs&DryRun=true"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.NoNATS = true }, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, http.StatusServiceUnavailable, got.Status, got.Body)
assert.Equal(t, "ServiceUnavailable", got.Code)
assert.Contains(t, got.Message, "cluster unavailable", got.Message)
})
}
}
// The policy gate evaluates service "ec2" and the request's own Action: a
@@ -107,33 +119,3 @@ func TestEC2Labels_ThrottleKeyIsAccountAndAction(t *testing.T) {
other := probeEC2(t, app.Handler, "Action=DescribeInstances")
assert.Less(t, other.Status, 400, other.Body)
}
// The cluster-unavailable gate runs ahead of everything after authentication:
// a caller the policy would refuse, an action that does not exist and a
// gateway-local action all get the same 503, so none of them reach dispatch.
func TestEC2Labels_ClusterUnavailableGateOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
}{
{"policy would deny", true, "Action=DescribeVpcs"},
{"unknown action", false, "Action=NoSuchAction"},
{"missing action", false, "Foo=bar"},
{"gateway-local DescribeRegions", false, "Action=DescribeRegions"},
{"dry run", false, "Action=DescribeVpcs&DryRun=true"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.NoNATS = true }, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, http.StatusServiceUnavailable, got.Status, got.Body)
assert.Equal(t, "ServiceUnavailable", got.Code)
assert.Contains(t, got.Message, "cluster unavailable", got.Message)
})
}
}
@@ -1,212 +1,49 @@
// Characterization tests for EC2 through the full SigV4 pipeline as the legacy
// gateway switch and ec2.go implement them today. They assert current
// behaviour, not AWS parity.
package gateway
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"github.com/mulgadc/bluebottle/pkg/ratelimit"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/url"
"strings"
"testing"
)
// ec2Daemon stands in for the daemons behind the ec2.* NATS subjects. It
// records what the gateway forwarded and answers every subject with an empty
// output unless a case needs a real shape.
type ec2Daemon struct {
mu sync.Mutex
calls map[string]int
bodies map[string][]byte
eigw int
// replies overrides the canned answer for a subject.
replies map[string]string
}
func (d *ec2Daemon) reply(subject, body string) {
d.mu.Lock()
defer d.mu.Unlock()
d.replies[subject] = body
}
func newEC2Daemon(t *testing.T, nc *nats.Conn) *ec2Daemon {
t.Helper()
d := &ec2Daemon{calls: map[string]int{}, bodies: map[string][]byte{}, replies: map[string]string{}}
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) {
d.mu.Lock()
d.calls[m.Subject]++
d.bodies[m.Subject] = append([]byte(nil), m.Data...)
reply := `{}`
switch m.Subject {
case "ec2.CreateEgressOnlyInternetGateway":
d.eigw++
reply = fmt.Sprintf(`{"EgressOnlyInternetGateway":{"EgressOnlyInternetGatewayId":"eigw-%04d"}}`, d.eigw)
case "ec2.DescribeVpcs":
reply = `{"Vpcs":[]}`
}
if r, ok := d.replies[m.Subject]; ok {
reply = r
}
d.mu.Unlock()
_ = m.Respond([]byte(reply))
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
return d
}
func (d *ec2Daemon) count(subject string) int {
d.mu.Lock()
defer d.mu.Unlock()
return d.calls[subject]
}
func (d *ec2Daemon) body(subject string) []byte {
d.mu.Lock()
defer d.mu.Unlock()
return d.bodies[subject]
}
// ec2PipelineEnv is what a case may change before the app is built.
type ec2PipelineEnv struct {
NoNATS bool
ResolveErr error
// Outer runs ahead of SigV4, as the telemetry and audit middleware do.
Outer []func(http.Handler) http.Handler
// Throttler, when set, is installed after the action enricher as SetupRoutes does.
Throttler *ratelimit.Throttler
}
type ec2PipelineApp struct {
Handler http.Handler
Daemon *ec2Daemon
}
// ec2Pipeline wires SigV4AuthMiddleware -> Request as SetupRoutes does, with
// EC2 on the legacy switch over a real JetStream and a stub daemon. Policy
// statements are the caller's only IAM policy.
func ec2Pipeline(t *testing.T, mutate func(*ec2PipelineEnv), statements ...iamdomain.Statement) ec2PipelineApp {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
env := &ec2PipelineEnv{}
if mutate != nil {
mutate(env)
// TestEC2Pipeline_CheckOrder pins which refusal wins when a request earns
// several: policy, then tag types, then DryRun, then MaxResults.
func TestEC2Pipeline_CheckOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
code string
}{
{"policy denial beats every parameter error", true, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "UnauthorizedOperation"},
{"tag type beats dry run", false, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "InvalidParameterValue"},
{"dry run beats max results", false, "Action=DescribeInstances&DryRun=true&MaxResults=1", "DryRunOperation"},
{"max results alone", false, "Action=DescribeInstances&MaxResults=1", "InvalidParameterValue"},
}
svc := &profileIAMService{resolveErr: env.ResolveErr, policyMockIAMService: policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}}
_, nc, _ := testutil.StartTestJetStream(t)
daemon := newEC2Daemon(t, nc)
cfg := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: svc,
ExpectedNodes: 1,
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, nil, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, c.code, got.Code, got.Body)
})
}
if env.NoNATS {
cfg.NATSConn = nil
}
gw := withEC2(withVerifier(cfg), func(d *ec2awsapi.Deps) {
d.AZ = testRegion + "a"
d.Endpoint = "https://registry.example"
})
r := chi.NewRouter()
for _, mw := range env.Outer {
r.Use(mw)
}
r.Use(gw.SigV4AuthMiddleware())
r.Use(traceActionEnricher)
if env.Throttler != nil {
r.Use(env.Throttler.Middleware(gw.throttleKeyFuncs(), gw.writeThrottleError))
}
r.HandleFunc("/*", gw.Request)
return ec2PipelineApp{Handler: r, Daemon: daemon}
}
func signEC2Request(t *testing.T, body string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
signTestRequestAs(t, req, []byte(body), testAccessKey, testSecretKey, "ec2", time.Now().UTC())
return req
}
type ec2ErrorBody struct {
XMLName xml.Name `xml:"Response"`
Errors struct {
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
} `xml:"Errors"`
RequestID string `xml:"RequestID"`
}
type ec2Outcome struct {
Status int
ContentType string
Body string
Code string
Message string
RequestID string
}
func probeEC2(t *testing.T, app http.Handler, body string) ec2Outcome {
t.Helper()
resp := doRequest(app, signEC2Request(t, body))
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
out := ec2Outcome{Status: resp.StatusCode, ContentType: resp.Header.Get("Content-Type"), Body: string(raw)}
if resp.StatusCode >= 400 {
var env ec2ErrorBody
require.NoError(t, xml.Unmarshal(raw, &env), string(raw))
out.Code, out.Message, out.RequestID = env.Errors.Error.Code, env.Errors.Error.Message, env.RequestID
}
return out
}
func allowEC2() iamdomain.Statement { return statement("Allow", "ec2:*", "*") }
// TestEC2Pipeline_ErrorEnvelopes pins status, code and the EC2 error envelope
// for each way a request can be refused before or at dispatch.
func TestEC2Pipeline_ErrorEnvelopes(t *testing.T) {
@@ -244,70 +81,6 @@ func TestEC2Pipeline_ErrorEnvelopes(t *testing.T) {
}
}
// TestEC2Pipeline_CheckOrder pins which refusal wins when a request earns
// several: policy, then tag types, then DryRun, then MaxResults.
func TestEC2Pipeline_CheckOrder(t *testing.T) {
cases := []struct {
name string
deny bool
body string
code string
}{
{"policy denial beats every parameter error", true, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "UnauthorizedOperation"},
{"tag type beats dry run", false, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "InvalidParameterValue"},
{"dry run beats max results", false, "Action=DescribeInstances&DryRun=true&MaxResults=1", "DryRunOperation"},
{"max results alone", false, "Action=DescribeInstances&MaxResults=1", "InvalidParameterValue"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stmts []iamdomain.Statement
if !c.deny {
stmts = append(stmts, allowEC2())
}
app := ec2Pipeline(t, nil, stmts...)
got := probeEC2(t, app.Handler, c.body)
assert.Equal(t, c.code, got.Code, got.Body)
})
}
}
// TestEC2Pipeline_Success pins the success envelope for a daemon-backed list
// and for the gateway-local actions.
func TestEC2Pipeline_Success(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
t.Run("daemon backed list", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeKeyPairs")
require.Equal(t, 200, got.Status, got.Body)
assert.Equal(t, "text/xml", got.ContentType)
assert.Contains(t, got.Body, "<DescribeKeyPairsResponse")
assert.Contains(t, got.Body, "<requestId>")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeKeyPairs"))
})
t.Run("DescribeRegions needs no daemon", func(t *testing.T) {
before := app.Daemon.count("ec2.DescribeRegions")
got := probeEC2(t, app.Handler, "Action=DescribeRegions")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, testRegion)
assert.Contains(t, got.Body, "https://registry.example", "endpoint is built from the registry host and port")
assert.Equal(t, before, app.Daemon.count("ec2.DescribeRegions"))
})
t.Run("DescribeAvailabilityZones needs no daemon", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAvailabilityZones")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, testRegion+"a")
})
t.Run("DescribeAccountAttributes looks up the default VPC", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAccountAttributes")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, "<DescribeAccountAttributesResponse")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeVpcs"))
})
}
// TestEC2Pipeline_IdempotentCreate pins ClientToken handling on a creating
// action: a replay returns the first result, changed parameters are refused,
// and no token means no deduplication.
@@ -342,6 +115,32 @@ func TestEC2Pipeline_IdempotentCreate(t *testing.T) {
assert.Equal(t, 3, app.Daemon.count(subject), "without a token each call creates")
}
// TestEC2Pipeline_ImportKeyPairPadding pins the base64 padding workaround: a
// doubly encoded trailing "==" is decoded before the key reaches the daemon,
// and a single trailing "=" is not.
func TestEC2Pipeline_ImportKeyPairPadding(t *testing.T) {
forwarded := func(t *testing.T, material string) string {
t.Helper()
app := ec2Pipeline(t, nil, allowEC2())
body := "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=" + url.QueryEscape(material)
got := probeEC2(t, app.Handler, body)
require.Equal(t, 200, got.Status, got.Body)
var in ec2.ImportKeyPairInput
require.NoError(t, json.NewDecoder(bytes.NewReader(app.Daemon.body("ec2.ImportKeyPair"))).Decode(&in))
return string(in.PublicKeyMaterial)
}
t.Run("double padding", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA%3D%3D"))
})
t.Run("single padding is left encoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA%3D", forwarded(t, "ssh-ed25519 AAAA%3D"))
})
t.Run("already decoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA=="))
})
}
// TestEC2Pipeline_PassRole pins the gateway iam:PassRole gate on an action
// that hands an instance profile over: a denied role is UnauthorizedOperation,
// a permitted one reaches the daemon.
@@ -380,34 +179,34 @@ func TestEC2Pipeline_PassRole(t *testing.T) {
})
}
// TestEC2Pipeline_ImportKeyPairPadding pins the base64 padding workaround: a
// doubly encoded trailing "==" is decoded before the key reaches the daemon,
// and a single trailing "=" is not.
func TestEC2Pipeline_ImportKeyPairPadding(t *testing.T) {
forwarded := func(t *testing.T, material string) string {
t.Helper()
app := ec2Pipeline(t, nil, allowEC2())
body := "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=" + url.QueryEscape(material)
got := probeEC2(t, app.Handler, body)
require.Equal(t, 200, got.Status, got.Body)
var in ec2.ImportKeyPairInput
require.NoError(t, json.NewDecoder(bytes.NewReader(app.Daemon.body("ec2.ImportKeyPair"))).Decode(&in))
return string(in.PublicKeyMaterial)
}
// TestEC2Pipeline_RunInstancesInstanceProfile pins the current order of the
// profile checks on RunInstances: the profile is resolved first, so an unknown
// one is refused before iam:PassRole is consulted, and a resolved one is then
// subject to PassRole on its role. This is the baseline for any parity change.
func TestEC2Pipeline_RunInstancesInstanceProfile(t *testing.T) {
const body = runInstancesBase + "&ImageId=ami-1&IamInstanceProfile.Name=web"
t.Run("double padding", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA%3D%3D"))
t.Run("unknown profile is refused before PassRole", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorIAMNoSuchEntity) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "InvalidIamInstanceProfile.NotFound", got.Code, got.Body)
assert.Equal(t, 404, got.Status)
})
t.Run("single padding is left encoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA%3D", forwarded(t, "ssh-ed25519 AAAA%3D"))
t.Run("resolved profile without PassRole", func(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, 403, got.Status, got.Body)
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
})
t.Run("already decoded", func(t *testing.T) {
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA=="))
t.Run("other resolution failure is returned as is", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorServerInternal) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "ServerInternal", got.Code, got.Body)
})
}
const runInstancesBase = "Action=RunInstances&InstanceType=t3.micro&MinCount=1&MaxCount=1"
// TestEC2Pipeline_RunInstancesLaunchTemplateAuthorizedAfterExpansion pins that
// a launch template is expanded before the policy gate, so a resource the
// template supplies is authorized and a Deny on it fires.
@@ -444,30 +243,39 @@ func TestEC2Pipeline_RunInstancesLaunchTemplateAuthorizedAfterExpansion(t *testi
})
}
// TestEC2Pipeline_RunInstancesInstanceProfile pins the current order of the
// profile checks on RunInstances: the profile is resolved first, so an unknown
// one is refused before iam:PassRole is consulted, and a resolved one is then
// subject to PassRole on its role. This is the baseline for any parity change.
func TestEC2Pipeline_RunInstancesInstanceProfile(t *testing.T) {
const body = runInstancesBase + "&ImageId=ami-1&IamInstanceProfile.Name=web"
// TestEC2Pipeline_Success pins the success envelope for a daemon-backed list
// and for the gateway-local actions.
func TestEC2Pipeline_Success(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
t.Run("unknown profile is refused before PassRole", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorIAMNoSuchEntity) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "InvalidIamInstanceProfile.NotFound", got.Code, got.Body)
assert.Equal(t, 404, got.Status)
t.Run("daemon backed list", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeKeyPairs")
require.Equal(t, 200, got.Status, got.Body)
assert.Equal(t, "text/xml", got.ContentType)
assert.Contains(t, got.Body, "<DescribeKeyPairsResponse")
assert.Contains(t, got.Body, "<requestId>")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeKeyPairs"))
})
t.Run("resolved profile without PassRole", func(t *testing.T) {
app := ec2Pipeline(t, nil, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, 403, got.Status, got.Body)
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
t.Run("DescribeRegions needs no daemon", func(t *testing.T) {
before := app.Daemon.count("ec2.DescribeRegions")
got := probeEC2(t, app.Handler, "Action=DescribeRegions")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, gwfix.Region)
assert.Contains(t, got.Body, "https://registry.example", "endpoint is built from the registry host and port")
assert.Equal(t, before, app.Daemon.count("ec2.DescribeRegions"))
})
t.Run("other resolution failure is returned as is", func(t *testing.T) {
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorServerInternal) }, allowEC2())
got := probeEC2(t, app.Handler, body)
assert.Equal(t, "ServerInternal", got.Code, got.Body)
t.Run("DescribeAvailabilityZones needs no daemon", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAvailabilityZones")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, gwfix.Region+"a")
})
t.Run("DescribeAccountAttributes looks up the default VPC", func(t *testing.T) {
got := probeEC2(t, app.Handler, "Action=DescribeAccountAttributes")
require.Equal(t, 200, got.Status, got.Body)
assert.Contains(t, got.Body, "<DescribeAccountAttributesResponse")
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeVpcs"))
})
}
-52
View File
@@ -1,52 +0,0 @@
package gateway
import (
"net/http"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/ec2quota"
)
// ec2Registration composes the EC2 registration from the NATS connection, IAM
// service and node count gw holds, as the awsgw role does. mutate adjusts the
// rest.
func ec2Registration(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) dispatch.Registration {
deps := ec2awsapi.Deps{
NATS: gw.NATSConn,
IAM: gw.IAMService,
Quota: ec2quota.Adapter{Service: quotaFor(gw)},
ActiveNodes: gw.DiscoverActiveNodes,
ExpectedNodes: gw.ExpectedNodes,
}
for _, m := range mutate {
m(&deps)
}
return ec2awsapi.NewRegistration(deps)
}
// withEC2 registers EC2 alone on gw.
func withEC2(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) *GatewayConfig {
return withServices(gw, ec2Registration(gw, mutate...))
}
// EC2_Request dispatches r to the registered EC2 service exactly as Request
// does past its NATS gate, registering it first from the fields gw holds when
// the test has not.
func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error {
if _, ok := gw.registered(ec2awsapi.ServiceName); !ok {
withEC2(gw)
}
entry, _ := gw.registered(ec2awsapi.ServiceName)
return gw.dispatchRegistered(entry, w, r)
}
// withEC2Verifier is withVerifier for a test that signs as EC2: the service
// must be registered for the auth gate to serve its credential scope.
func withEC2Verifier(gw *GatewayConfig) *GatewayConfig {
return withEC2(withVerifier(gw))
}
func ec2RegistrationInventory() map[string]dispatch.Inventory {
return withEC2(&GatewayConfig{}).Services.Inventory()
}
+60 -80
View File
@@ -1,65 +1,49 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package gateway
import (
"encoding/xml"
"github.com/stretchr/testify/assert"
"net/http"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func tagSpecBody(action, resourceType string) string {
return "Action=" + action + "&TagSpecification.1.ResourceType=" + resourceType +
"&TagSpecification.1.Tag.1.Key=k&TagSpecification.1.Tag.1.Value=v"
}
func TestEC2Request_TagSpecificationResourceType(t *testing.T) {
// Captured from AWS: the combination is refused even when MaxResults is also
// out of range.
func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
cases := []struct {
name, body, code, resourceType string
}{
{"out of enum", tagSpecBody("CreateKeyPair", "bogus-type") + "&KeyName=k", "InvalidParameterValue", "bogus-type"},
{"another resource's type", tagSpecBody("CreateInternetGateway", "instance"), "InvalidParameterValue", "instance"},
{"InvalidParameter action", tagSpecBody("CreateVpc", "instance") + "&CidrBlock=10.0.0.0/16", "InvalidParameter", "instance"},
{"checked before DryRun", tagSpecBody("CreateVpc", "subnet") + "&CidrBlock=10.0.0.0/16&DryRun=true", "InvalidParameter", "subnet"},
{"second spec checked", tagSpecBody("RunInstances", "volume") +
"&TagSpecification.2.ResourceType=image&ImageId=ami-1&MinCount=1&MaxCount=1", "InvalidParameterValue", "image"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := serveEC2(gw, tc.body)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+tc.code+"</Code>")
assert.Contains(t, w.Body.String(),
"<Message>&#39;"+tc.resourceType+"&#39; is not a valid taggable resource type for this operation.</Message>")
})
for _, n := range []string{"2", "5"} {
w := serveEC2(gw, "Action=DescribeInstances&InstanceId.1=i-0123456789abcdef0&MaxResults="+n)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>InvalidParameterCombination</Code>")
assert.Contains(t, w.Body.String(), "<Message>The parameter instancesSet cannot be used with the parameter maxResults</Message>")
}
}
// Each of an action's own types reaches the handler, which fails only on the
// absent NATS connection.
func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) {
// AWS answers DryRun ahead of a MaxResults out of range.
func TestEC2Request_MaxResultsAfterDryRun(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
assertDryRun(t, dispatchEC2(t, gw, "Action=DescribeLaunchTemplates&MaxResults=0&DryRun=true"))
}
func TestEC2Request_MaxResultsInRange(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
for _, rt := range []string{"instance", "volume", "network-interface", "spot-instances-request"} {
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("RunInstances", rt)+"&ImageId=ami-1&MinCount=1&MaxCount=1"))
for _, body := range []string{
"Action=DescribeCapacityReservations&MaxResults=1",
"Action=DescribeEgressOnlyInternetGateways&MaxResults=255",
"Action=DescribeInstances&MaxResults=5",
"Action=DescribeInstances&MaxResults=5000",
"Action=DescribeInstanceTypes&MaxResults=100",
"Action=DescribeInstanceTypeOfferings&MaxResults=5",
"Action=DescribeLaunchTemplates&MaxResults=200",
"Action=DescribeNatGateways&MaxResults=1000",
// AWS accepted 4 and 1001 here although the model bounds it 5-1000.
"Action=DescribeIamInstanceProfileAssociations&MaxResults=4",
"Action=DescribeIamInstanceProfileAssociations&MaxResults=1001",
} {
assertPermitted(t, dispatchEC2(t, gw, body))
}
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("CreateVpc", "vpc")+"&CidrBlock=10.0.0.0/16"))
}
// xmlText escapes s as the error body renders it.
func xmlText(t *testing.T, s string) string {
t.Helper()
var b strings.Builder
require.NoError(t, xml.EscapeText(&b, []byte(s)))
return b.String()
}
// The refusals are AWS's own, captured by scripts/aws-compliance/aws-response-diff/scen_e.py.
@@ -98,41 +82,37 @@ func TestEC2Request_MaxResultsOutOfRange(t *testing.T) {
}
}
// Captured from AWS: the combination is refused even when MaxResults is also
// out of range.
func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) {
// Each of an action's own types reaches the handler, which fails only on the
// absent NATS connection.
func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
for _, n := range []string{"2", "5"} {
w := serveEC2(gw, "Action=DescribeInstances&InstanceId.1=i-0123456789abcdef0&MaxResults="+n)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>InvalidParameterCombination</Code>")
assert.Contains(t, w.Body.String(), "<Message>The parameter instancesSet cannot be used with the parameter maxResults</Message>")
for _, rt := range []string{"instance", "volume", "network-interface", "spot-instances-request"} {
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("RunInstances", rt)+"&ImageId=ami-1&MinCount=1&MaxCount=1"))
}
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("CreateVpc", "vpc")+"&CidrBlock=10.0.0.0/16"))
}
func TestEC2Request_TagSpecificationResourceType(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
cases := []struct {
name, body, code, resourceType string
}{
{"out of enum", tagSpecBody("CreateKeyPair", "bogus-type") + "&KeyName=k", "InvalidParameterValue", "bogus-type"},
{"another resource's type", tagSpecBody("CreateInternetGateway", "instance"), "InvalidParameterValue", "instance"},
{"InvalidParameter action", tagSpecBody("CreateVpc", "instance") + "&CidrBlock=10.0.0.0/16", "InvalidParameter", "instance"},
{"checked before DryRun", tagSpecBody("CreateVpc", "subnet") + "&CidrBlock=10.0.0.0/16&DryRun=true", "InvalidParameter", "subnet"},
{"second spec checked", tagSpecBody("RunInstances", "volume") +
"&TagSpecification.2.ResourceType=image&ImageId=ami-1&MinCount=1&MaxCount=1", "InvalidParameterValue", "image"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := serveEC2(gw, tc.body)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+tc.code+"</Code>")
assert.Contains(t, w.Body.String(),
"<Message>&#39;"+tc.resourceType+"&#39; is not a valid taggable resource type for this operation.</Message>")
})
}
}
func TestEC2Request_MaxResultsInRange(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
for _, body := range []string{
"Action=DescribeCapacityReservations&MaxResults=1",
"Action=DescribeEgressOnlyInternetGateways&MaxResults=255",
"Action=DescribeInstances&MaxResults=5",
"Action=DescribeInstances&MaxResults=5000",
"Action=DescribeInstanceTypes&MaxResults=100",
"Action=DescribeInstanceTypeOfferings&MaxResults=5",
"Action=DescribeLaunchTemplates&MaxResults=200",
"Action=DescribeNatGateways&MaxResults=1000",
// AWS accepted 4 and 1001 here although the model bounds it 5-1000.
"Action=DescribeIamInstanceProfileAssociations&MaxResults=4",
"Action=DescribeIamInstanceProfileAssociations&MaxResults=1001",
} {
assertPermitted(t, dispatchEC2(t, gw, body))
}
}
// AWS answers DryRun ahead of a MaxResults out of range.
func TestEC2Request_MaxResultsAfterDryRun(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
assertDryRun(t, dispatchEC2(t, gw, "Action=DescribeLaunchTemplates&MaxResults=0&DryRun=true"))
}
+69 -430
View File
@@ -1,191 +1,23 @@
// Characterization tests for ECR control-plane dispatch as it exists in
// ecrapi.go/ecr.go today, pinned ahead of moving dispatch out of gateway into
// a registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// decodeAuthToken decodes an ECR authorizationToken ("AWS:<jwt>") to the jwt.
func decodeAuthToken(token string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return "", err
}
_, jwtStr, _ := strings.Cut(string(decoded), ":")
return jwtStr, nil
}
// ecrPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS so Request dispatches. docs is every principal's policy; the
// region is testRegion because signTestRequestAs always signs for it.
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
InternalSuffix: ecrTestSuffix,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: ecrTestAccount,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withECR(gw, awsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllECRDocs is the policy ecrPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllECRDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signECRRequest builds a POST / signed with credential-scope service "ecr",
// the shape every ECR JSON 1.1 client sends.
func signECRRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "ecr", time.Now().UTC())
return req
}
// jsonErrorEnvelope is the AWS JSON 1.1 error body shape.
type jsonErrorEnvelope struct {
Type string `json:"__type"`
Message string `json:"message"`
}
func decodeJSONError(t *testing.T, resp *http.Response) jsonErrorEnvelope {
t.Helper()
var out jsonErrorEnvelope
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
return out
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestECRPipeline_MissingTarget_400MissingActionException pins that an ECR
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestECRPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
resp := doRequest(app, signECRRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestECRPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from awsapi.Actions fails with InvalidAction.
func TestECRPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".MadeUpAction"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// TestECRPipeline_StubbedAction_501NotImplementedException pins that a
// registered-but-stubbed action (ListRepositories) still answers 501 through
// the full pipeline.
func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".ListRepositories"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "NotImplementedException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "NotImplementedException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestECRPipeline_GetAuthorizationToken_200JSONContentType pins that a
// successful ECR action answers 200 with the AWS JSON 1.1 content type
// through the full pipeline.
func TestECRPipeline_GetAuthorizationToken_200JSONContentType(t *testing.T) {
iss, _ := newECRAuth(t)
gw, app := ecrPipelineApp(t, allowAllECRDocs())
withECR(gw, awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".GetAuthorizationToken"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, awsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// TestECRActionFromTarget_FormsAndTelemetryAgree pins how the ECR
// registration's resolver (dispatch and telemetry/throttle) and the legacy
// resolveNonQueryAction each resolve the X-Amz-Target header today. A refactor
@@ -215,54 +47,6 @@ func TestECRActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
}
}
// TestECRRequest_UnrelatedPrefixDispatchesByBareAction pins that a target with
// any "<prefix>.<Action>" shape (not just the ECR prefix) dispatches on the
// bare action name today.
func TestECRRequest_UnrelatedPrefixDispatchesByBareAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(), setupECRRequest("a.b.ListRepositories", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
// TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction pins that a target
// carrying another service's prefix but naming an action outside awsapi.Actions
// fails as an unrecognised ECR action, not as a cross-service error.
func TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest("AmazonEC2ContainerServiceV20141113.ListClusters", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// TestECRRequest_TrailingDotIsMissingAction pins that a target ending in "."
// resolves to an empty action, the same as an absent target.
func TestECRRequest_TrailingDotIsMissingAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest(awsapi.TargetPrefix+".", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
// --- Exact stub inventory --------------------------------------------------
// TestECRInventory_StubbedSetIsPinned pins the exact set of actions still
// bound to awsapi.NotImplemented, independent of whether a composed capability
// intercepts them before dispatch reaches that stub.
func TestECRInventory_StubbedSetIsPinned(t *testing.T) {
want := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "BatchGetImage", "BatchCheckLayerAvailability", "BatchDeleteImage",
"PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer", "InitiateLayerUpload",
"UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy", "PutRegistryPolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview",
"PutReplicationConfiguration", "ReplicateImage",
}
assert.ElementsMatch(t, want, awsapi.StubbedActionNames())
}
// TestECRInventory_ReachableStubsAnswer501 pins the subset of the raw stub set
// that nothing intercepts ahead of the handler table, i.e. what the HTTP
// pipeline actually answers 501 for today.
@@ -303,158 +87,6 @@ func TestECRInventory_ScanningActionsReturnOperationNotSupported(t *testing.T) {
}
}
// --- Oversized body over HTTP ---------------------------------------------
// TestECRPipeline_OversizedBody_413OverHTTP pins that an ECR-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ECR dispatch ever runs, with the JSON error envelope.
func TestECRPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".ListRepositories")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "ecr", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before a composed action --------------------------
// recordingRepoStore is a RepositoryStore fake that records whether it was
// ever invoked, so a denial can be proven to short-circuit before the composed
// capability runs.
type recordingRepoStore struct {
calls int
}
func (s *recordingRepoStore) GetRepo(context.Context, string, string) (ecr.RepoMeta, error) {
s.calls++
return ecr.RepoMeta{}, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListRepos(context.Context, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) PutRepo(context.Context, string, ecr.RepoMeta) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListManifests(context.Context, string, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) DeleteRepo(context.Context, string, string) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
// TestECRPipeline_AuthzGateBlocksComposedAction pins that the policy gate in
// ECR dispatch runs, and denies, before a composed capability (here
// RepositoryActionService backing CreateRepository) is ever invoked.
func TestECRPipeline_AuthzGateBlocksComposedAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"ecr:CreateRepository"},
Resource: iamdomain.StringOrArr{"arn:aws:ecr:" + testRegion + ":" + ecrTestAccount + ":repository/other"},
},
},
}}
gw, app := ecrPipelineApp(t, scopedDocs)
store := &recordingRepoStore{}
withECR(gw, awsapi.Deps{Repository: awsapi.NewRepositoryActionService(store, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".CreateRepository"
resp := doRequest(app, signECRRequest(t, target, []byte(`{"repositoryName":"team/app"}`)))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", body.Type)
assert.Equal(t, 0, store.calls, "composed capability must not run once the gate denies")
}
// --- GetAuthorizationToken ignores request-supplied identity -------------
// A registryIds field naming a foreign account must not reach the token: its
// account and subject come from the verified caller, never the body.
func TestGetAuthorizationToken_IgnoresRequestSuppliedIdentity(t *testing.T) {
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"registryIds":["999999999999"]}`))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
w := httptest.NewRecorder()
require.NoError(t, gw.serveECR(w, req.WithContext(ctx)))
require.Equal(t, http.StatusOK, w.Code)
var out struct {
AuthorizationData []struct {
AuthorizationToken string `json:"authorizationToken"`
} `json:"authorizationData"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Len(t, out.AuthorizationData, 1)
decoded, err := decodeAuthToken(out.AuthorizationData[0].AuthorizationToken)
require.NoError(t, err)
claims, err := verify.Verify(decoded)
require.NoError(t, err)
assert.Equal(t, ecrTestAccount, claims.AccountID)
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/dev", claims.Subject)
}
// --- NATS unavailable for ECR ---------------------------------------------
// Stub and composed ECR actions alike fail at the NATS gate, before dispatch
// reads the action.
func TestRequest_ClusterUnavailableNilConn_ECR(t *testing.T) {
for _, action := range []string{"ListRepositories", "CreateRepository"} {
t.Run(action, func(t *testing.T) {
gw := withECR(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), awsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+"."+action)
ctx := reqctx.WithService(req.Context(), "ecr")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ECR request ---------------------------------
// TestECRPipeline_TraceActionEnricher_SpanNamedServiceAction pins that the
// server span is renamed to "ecr.<Action>" and carries aws.action once a real
// ECR request runs through SigV4AuthMiddleware and traceActionEnricher.
@@ -486,63 +118,70 @@ func TestECRPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
assert.Equal(t, "ecr", got["aws.service"])
}
// TestECRPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ECR action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestECRPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := ecrPipelineApp(t, allowAllECRDocs())
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := awsapi.TargetPrefix + ".ListRepositories"
doRequest(r, signECRRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListRepositories", auditField(got, "action"))
// TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction pins that a target
// carrying another service's prefix but naming an action outside awsapi.Actions
// fails as an unrecognised ECR action, not as a cross-service error.
func TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest("AmazonEC2ContainerServiceV20141113.ListClusters", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// --- Inventory pin --------------------------------------------------------
// Pins the ECR inventory so a refactor that drops or adds an action, including
// by changing which capability intercepts it, fails here.
func TestECRInventory_RegisteredStubbedUnsupportedArePinned(t *testing.T) {
ecr := AWSOperationInventory(ecrRegistrationInventory())["ecr"]
wantRegistered := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "PutImageTagMutability", "BatchGetImage", "BatchCheckLayerAvailability",
"BatchDeleteImage", "PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "SetRepositoryPolicy",
"GetRepositoryPolicy", "DeleteRepositoryPolicy", "GetRegistryPolicy", "PutRegistryPolicy",
"DescribeRegistry", "PutImageScanningConfiguration", "GetImageScanningConfiguration",
"StartImageScan", "DescribeImageScanFindings", "GetRegistryScanningConfiguration",
"PutRegistryScanningConfiguration", "BatchGetRepositoryScanningConfiguration",
"PutLifecyclePolicy", "GetLifecyclePolicy", "DeleteLifecyclePolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview", "PutReplicationConfiguration",
"ReplicateImage", "TagResource", "UntagResource", "ListTagsForResource",
}
wantStubbed := []string{
"ListRepositories", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy",
"PutRegistryPolicy", "PutReplicationConfiguration", "ReplicateImage",
}
wantUnsupported := []string{
"GetImageScanningConfiguration", "StartImageScan", "DescribeImageScanFindings",
"GetRegistryScanningConfiguration", "PutRegistryScanningConfiguration",
"BatchGetRepositoryScanningConfiguration",
}
assert.ElementsMatch(t, wantRegistered, ecr.Registered)
assert.ElementsMatch(t, wantStubbed, ecr.Stubbed)
assert.ElementsMatch(t, wantUnsupported, ecr.Unsupported)
// TestECRRequest_TrailingDotIsMissingAction pins that a target ending in "."
// resolves to an empty action, the same as an absent target.
func TestECRRequest_TrailingDotIsMissingAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(),
setupECRRequest(awsapi.TargetPrefix+".", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
// TestECRRequest_UnrelatedPrefixDispatchesByBareAction pins that a target with
// any "<prefix>.<Action>" shape (not just the ECR prefix) dispatches on the
// bare action name today.
func TestECRRequest_UnrelatedPrefixDispatchesByBareAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECR(httptest.NewRecorder(), setupECRRequest("a.b.ListRepositories", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
// A registryIds field naming a foreign account must not reach the token: its
// account and subject come from the verified caller, never the body.
func TestGetAuthorizationToken_IgnoresRequestSuppliedIdentity(t *testing.T) {
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"registryIds":["999999999999"]}`))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
w := httptest.NewRecorder()
require.NoError(t, gw.serveECR(w, req.WithContext(ctx)))
require.Equal(t, http.StatusOK, w.Code)
var out struct {
AuthorizationData []struct {
AuthorizationToken string `json:"authorizationToken"`
} `json:"authorizationData"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Len(t, out.AuthorizationData, 1)
decoded, err := decodeAuthToken(out.AuthorizationData[0].AuthorizationToken)
require.NoError(t, err)
claims, err := verify.Verify(decoded)
require.NoError(t, err)
assert.Equal(t, ecrTestAccount, claims.AccountID)
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/dev", claims.Subject)
}
@@ -3,18 +3,33 @@ package gateway
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
iss, _ := newECRAuth(t)
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
w := httptest.NewRecorder()
req := setupECRRequest("AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken", "{}")
require.NoError(t, gw.serveECR(w, req))
assert.Equal(t, http.StatusOK, w.Code)
}
func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
@@ -26,7 +41,7 @@ func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
@@ -62,13 +77,33 @@ func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
assert.Equal(t, "arn:aws:iam::"+ecrTestAccount+":user/dev", claims.Subject)
}
func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
err := gw.serveECR(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, "ServerInternal", err.Error())
}
func TestGetAuthorizationToken_NoIssuerNotImplemented(t *testing.T) {
gw := withECR(withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(nil, awsapi.RepositoryEndpoint{})})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
err := gw.serveECR(httptest.NewRecorder(), req.WithContext(ctx))
require.Error(t, err)
assert.Equal(t, "NotImplemented", err.Error())
}
// The mint and verify halves agree on a pathed user: the token is minted for the
// stored ARN the middleware resolved, and verifies against the same record.
func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.users[ecrPrincipalTestAccount+"|dev"].Arn = aws.String(ecrPathedUserARN)
allow, _ := allowAllIAMService().getUserPoliciesFn(ecrPrincipalTestAccount, "dev")
allow, _ := allowAllIAMService().GetUserPoliciesFn(ecrPrincipalTestAccount, "dev")
iamSvc.userPolicies[ecrPrincipalTestAccount+"|dev"] = allow
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
@@ -80,7 +115,7 @@ func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrPrincipalTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldUserARN, ecrPathedUserARN)
ctx = withPrincipalField(ctx, fieldAccessKey, ecrPrincipalTestAKID)
@@ -119,7 +154,7 @@ func TestGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAGETAUTHTOKENTEST1")
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".GetAuthorizationToken")
@@ -135,38 +170,3 @@ func TestGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
require.Len(t, out.AuthorizationData, 1)
assert.Equal(t, "https://"+ecrTestAccount+".dkr.ecr."+ecrTestRegion+"."+ecrTestSuffix+":9999", out.AuthorizationData[0].ProxyEndpoint)
}
func TestGetAuthorizationToken_NoIssuerNotImplemented(t *testing.T) {
gw := withECR(withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(nil, awsapi.RepositoryEndpoint{})})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
ctx := withPrincipalField(req.Context(), fieldAccountID, ecrTestAccount)
err := gw.serveECR(httptest.NewRecorder(), req.WithContext(ctx))
require.Error(t, err)
assert.Equal(t, "NotImplemented", err.Error())
}
func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
err := gw.serveECR(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, "ServerInternal", err.Error())
}
func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
iss, _ := newECRAuth(t)
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
w := httptest.NewRecorder()
req := setupECRRequest("AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken", "{}")
require.NoError(t, gw.serveECR(w, req))
assert.Equal(t, http.StatusOK, w.Code)
}
+2 -1
View File
@@ -2,6 +2,7 @@ package gateway
import (
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -26,7 +27,7 @@ func setupECRRequest(target, body string) *http.Request {
ctx := reqctx.WithService(req.Context(), "ecr")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldIdentity, "dev")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAECRTESTCONTROLPL1")
return req.WithContext(ctx)
}
@@ -1,79 +0,0 @@
package gateway
import (
"net/http"
"github.com/mulgadc/bluebottle/pkg/auth"
"github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// mockRoleResolver resolves roles through the gateway's mock IAM service, the
// way the IAM-owned resolver does against the real store.
type mockRoleResolver struct{ iam IAMServiceLike }
// IAMServiceLike is the slice of the IAM service the mock resolver reads.
type IAMServiceLike interface {
CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error)
}
func (m mockRoleResolver) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
roleAccount, name, err := auth.ParseRoleARN(roleARN)
if err != nil {
return passrole.Role{}, passrole.ErrMalformed
}
if roleAccount != accountID {
return passrole.Role{}, passrole.ErrForeignAccount
}
canonical, err := m.iam.CanonicalResourceARN(roleAccount, arn.IAMRole, name)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{}, err
}
if canonical != roleARN {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{ARN: canonical, Name: name}, nil
}
var _ passrole.Resolver = mockRoleResolver{}
// withContainers registers ECS and EKS on gw with the NATS connection and
// mock IAM service it holds, as the awsgw role composes them.
func withContainers(gw *GatewayConfig) *GatewayConfig {
b := dispatch.NewBuilder()
if err := b.Register(awsapi.NewRegistration(awsapi.Deps{NATS: gw.NATSConn})); err != nil {
panic(err)
}
roles, _ := gw.IAMService.(IAMServiceLike)
if err := b.Register(eksawsapi.NewRegistration(eksawsapi.Deps{NATS: gw.NATSConn, Roles: mockRoleResolver{iam: roles}})); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
func (gw *GatewayConfig) serveRegistered(service string, w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withContainers(gw)
}
entry, ok := gw.registered(service)
if !ok {
panic(service + " is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
func (gw *GatewayConfig) serveECS(w http.ResponseWriter, r *http.Request) error {
return gw.serveRegistered(awsapi.ServiceName, w, r)
}
func (gw *GatewayConfig) serveEKS(w http.ResponseWriter, r *http.Request) error {
return gw.serveRegistered(eksawsapi.ServiceName, w, r)
}
+8 -36
View File
@@ -2,52 +2,24 @@ package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecs"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
)
// TestECSRegisterTaskDefinition_PassRole exercises the full gw.ECS_Request
// dispatch (X-Amz-Target parsing, action-level checkPolicy, then the
// iam:PassRole resource check gateway/ecs.go builds and threads through
// ecsawsapi.Handler) with a real authenticated user identity, so the IAM
// policy is actually evaluated rather than taking the pre-IAM bypass.
const testECSPassRoleTargetARN = "arn:aws:iam::123456789012:role/ecs-task-role"
// setupECSUserRequest is setupECSRequest plus an authenticated user identity.
func setupECSUserRequest(target, body string) *http.Request {
req := setupECSRequest(target, body)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
return req.WithContext(ctx)
}
func registerTaskDefinitionBody(t *testing.T, taskRoleARN string) string {
t.Helper()
input := &ecs.RegisterTaskDefinitionInput{Family: aws.String("web")}
if taskRoleARN != "" {
input.TaskRoleArn = aws.String(taskRoleARN)
}
body, err := json.Marshal(input)
require.NoError(t, err)
return string(body)
}
// TestECSRegisterTaskDefinition_DenialRendersAccessDenied403 proves shared
// policy denials stay opaque while retaining the service's 403 envelope.
func TestECSRegisterTaskDefinition_DenialRendersAccessDenied403(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return allowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
mock := &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return gwfix.AllowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
},
}
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
gw := withRealAuth(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := setupECSUserRequest("AmazonEC2ContainerServiceV20141113.RegisterTaskDefinition",
registerTaskDefinitionBody(t, testECSPassRoleTargetARN))
+18 -61
View File
@@ -1,74 +1,16 @@
package gateway
import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"strings"
"testing"
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupECSRequest(target, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
ctx := reqctx.WithService(req.Context(), "ecs")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
return withTestIdentity(req.WithContext(ctx))
}
// The Actions map is the v1 API contract (ecs-v1.md §1): every action across the
// Cluster / TaskDefinition / Task / Service / ContainerInstance / Account / Tags
// surfaces must be registered, whether wired to the daemon or a 501 stub.
func TestECSActionsMap_NamespaceRegistered(t *testing.T) {
namespace := []string{
// Cluster
"CreateCluster", "DescribeClusters", "ListClusters", "DeleteCluster",
"UpdateCluster", "PutClusterCapacityProviders",
// Task definition
"RegisterTaskDefinition", "DeregisterTaskDefinition", "DescribeTaskDefinition",
"ListTaskDefinitions", "ListTaskDefinitionFamilies",
// Task
"RunTask", "StartTask", "StopTask", "DescribeTasks", "ListTasks",
// Service
"CreateService", "UpdateService", "DeleteService", "DescribeServices",
"ListServices", "ListServicesByNamespace",
// Container instance
"RegisterContainerInstance", "DeregisterContainerInstance",
"DescribeContainerInstances", "ListContainerInstances",
"UpdateContainerInstancesState",
// Account
"PutAccountSetting", "ListAccountSettings",
// Tags
"TagResource", "UntagResource", "ListTagsForResource",
}
for _, action := range namespace {
_, ok := ecsawsapi.Actions[action]
assert.True(t, ok, "action %q should be registered", action)
}
}
func TestECSRequest_MissingTarget(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(), setupECSRequest("", ""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
func TestECSRequest_UnknownAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(),
setupECSRequest("AmazonEC2ContainerServiceV20141113.MadeUpAction", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// An action whose real handler has not yet landed still resolves to the 501 stub.
func TestECSRequest_KnownActionNotImplemented(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
@@ -92,3 +34,18 @@ func TestECSRequest_MissingAccountID(t *testing.T) {
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
func TestECSRequest_MissingTarget(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(), setupECSRequest("", ""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
func TestECSRequest_UnknownAction(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
err := gw.serveECS(httptest.NewRecorder(),
setupECSRequest("AmazonEC2ContainerServiceV20141113.MadeUpAction", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
-243
View File
@@ -1,243 +0,0 @@
package gateway
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// dispatchEKS drives the gateway with no NATS connection, so a permitted request
// reaches the NATS guard and fails there.
func dispatchEKS(t *testing.T, gw *GatewayConfig, method, path, body string) error {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
ctx := reqctx.WithService(req.Context(), "eks")
ctx = withPrincipalField(ctx, fieldAccountID, authzAccountID)
req = withTestIdentity(req.WithContext(ctx))
return gw.serveEKS(httptest.NewRecorder(), req)
}
const eksNodeRoleARN = "arn:aws:iam::" + authzAccountID + ":role/eks/node-role"
const foreignAccountID = "999999999999"
// roleStoreIAMService evaluates policy from docs and resolves role names to the
// ARNs in roles, keyed by account then name, the way the IAM store does.
type roleStoreIAMService struct {
policyMockIAMService
roles map[string]map[string]string
canonicalErr error
}
func (s *roleStoreIAMService) CanonicalResourceARN(account string, _ arn.IAMResourceType, name string) (string, error) {
if s.canonicalErr != nil {
return "", s.canonicalErr
}
if roleARN, ok := s.roles[account][name]; ok {
return roleARN, nil
}
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
}
// The foreign account holds a role of the same name, so only the gateway's
// account check stops its ARN from resolving.
func eksPassRoleService(statements ...iamdomain.Statement) *roleStoreIAMService {
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return &roleStoreIAMService{
policyMockIAMService: policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
roles: map[string]map[string]string{
authzAccountID: {"node-role": eksNodeRoleARN},
foreignAccountID: {"node-role": "arn:aws:iam::" + foreignAccountID + ":role/eks/node-role"},
},
}
}
func eksPassRoleGateway(statements ...iamdomain.Statement) *GatewayConfig {
return eksGatewayWithIAM(eksPassRoleService(statements...))
}
func eksGatewayWithIAM(iamService *roleStoreIAMService) *GatewayConfig {
return withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: iamService})
}
func createNodegroupBody(nodeRole string) string {
return `{"nodegroupName":"workers","nodeRole":"` + nodeRole + `"}`
}
// The AWS capture of 2026-10-30 refuses a foreign or non-role ARN on CreateCluster
// and CreateNodegroup with AccessDeniedException before PassRole is evaluated.
func assertCrossAccountPassRole(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorAccessDeniedException, code)
assert.Contains(t, err.Error(), "Cross-account pass role is not allowed.")
}
func assertInvalidParameter(t *testing.T, err error) {
t.Helper()
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "unresolvable error code: %v", err)
assert.Equal(t, awserrors.ErrorEKSInvalidParameter, code)
}
// eks:* alone must not let a caller launch nodes under a role it cannot pass.
func TestEKSCreateNodegroup_DeniedWithoutPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
func TestEKSCreateNodegroup_AllowedWithPassRoleOnRole(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN)))
}
// A grant scoped to a path the role is not under must not be satisfied by
// spelling the role's name under that path.
func TestEKSCreateNodegroup_InventedPathIsRefused(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "arn:aws:iam::"+authzAccountID+":role/sandbox/*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+authzAccountID+":role/sandbox/node-role"))
assertInvalidParameter(t, err)
}
func TestEKSCreateNodegroup_ForeignAccountRoleIsRefused(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+foreignAccountID+":role/eks/node-role"))
assertCrossAccountPassRole(t, err)
}
func TestEKSCreateNodegroup_NonRoleARNIsRefused(t *testing.T) {
gw := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
err := dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups",
createNodegroupBody("arn:aws:iam::"+authzAccountID+":user/node-role"))
assertCrossAccountPassRole(t, err)
}
// A store fault is not evidence the role is absent, so it must not surface as
// a caller error or fall through to dispatch.
func TestEKSCreateNodegroup_LookupFaultPassesThrough(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
iamService := eksPassRoleService(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
iamService.canonicalErr = storeErr
err := dispatchEKS(t, eksGatewayWithIAM(iamService), http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
// An unknown role is reported only to a caller allowed to pass it, so the
// reply cannot be used to enumerate roles.
func TestEKSCreateNodegroup_UnknownRole(t *testing.T) {
body := createNodegroupBody("arn:aws:iam::" + authzAccountID + ":role/ghost")
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/node-groups", body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", "*"),
)
assertInvalidParameter(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/node-groups", body))
}
func TestEKSCreateNodegroup_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", `{"nodegroupName":"workers"}`))
}
func TestEKSCreateCluster_PassRole(t *testing.T) {
body := `{"name":"prod","roleArn":"` + eksNodeRoleARN + `"}`
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters", body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters", body))
}
func createAddonBody(roleARN string) string {
return `{"addonName":"aws-load-balancer-controller","serviceAccountRoleArn":"` + roleARN + `"}`
}
func updateAddonBody(roleARN string) string {
return `{"serviceAccountRoleArn":"` + roleARN + `"}`
}
func TestEKSCreateAddon_PassRole(t *testing.T) {
body := createAddonBody(eksNodeRoleARN)
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters/prod/addons", body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters/prod/addons", body))
}
func TestEKSCreateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/addons", createAddonBody("")))
}
func TestEKSUpdateAddon_PassRole(t *testing.T) {
body := updateAddonBody(eksNodeRoleARN)
const path = "/clusters/prod/addons/aws-load-balancer-controller/update"
denied := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, path, body))
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
statement("Allow", "iam:PassRole", eksNodeRoleARN),
)
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, path, body))
}
func TestEKSUpdateAddon_NoRoleSkipsPassRole(t *testing.T) {
gw := eksPassRoleGateway(statement("Allow", "eks:*", "*"))
assertPermitted(t, dispatchEKS(t, gw, http.MethodPost,
"/clusters/prod/addons/aws-load-balancer-controller/update", updateAddonBody("")))
}
// Current behaviour: a store fault on the role lookup is returned before the
// PassRole check, so a caller without iam:PassRole sees the fault, not a denial.
func TestEKSCreateNodegroup_OutageIsReportedToACallerWithoutPassRole(t *testing.T) {
storeErr := errors.New("get role: nats: timeout")
iamService := eksPassRoleService(statement("Allow", "eks:*", "*"))
iamService.canonicalErr = storeErr
err := dispatchEKS(t, eksGatewayWithIAM(iamService), http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
require.ErrorIs(t, err, storeErr)
}
+36 -99
View File
@@ -1,94 +1,45 @@
//test:in-package — drives the ELBv2 service through the gateway test helpers
// and the quota fixtures, none of which are reachable from outside the package.
package gateway
import (
"encoding/xml"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)
// accountLimitsResponse is the wire shape a caller parses, so the test asserts
// on what leaves the gateway rather than on the struct behind it.
type accountLimitsResponse struct {
XMLName xml.Name `xml:"DescribeAccountLimitsResponse"`
Limits []struct {
Name string `xml:"Name"`
Max string `xml:"Max"`
} `xml:"DescribeAccountLimitsResult>Limits>member"`
}
// The service must accept the paging parameters the model defines and still
// answer with the whole list.
func TestELBv2DescribeAccountLimitsAcceptsPaging(t *testing.T) {
gw, account := quotaTestGateway(t)
// elbv2Request builds an authenticated Query request for body, as the SigV4
// middleware would hand it to the service.
func elbv2Request(accountID, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
ctx := reqctx.WithService(req.Context(), "elasticloadbalancing")
ctx = withPrincipalField(ctx, fieldAccountID, accountID)
return withTestIdentity(req.WithContext(ctx))
}
// serveAccountLimits runs DescribeAccountLimits through the service with live
// NATS, so the request is dispatched rather than stopped at the NATS guard.
func serveAccountLimits(t *testing.T, gw *GatewayConfig, accountID, body string) ([]byte, error) {
t.Helper()
_, nc := testutil.StartTestNATS(t)
gw.NATSConn = nc
gw.IAMService = allowAllIAMService()
withELBv2(gw)
w := httptest.NewRecorder()
if err := gw.serveELBv2(w, elbv2Request(accountID, body)); err != nil {
return nil, err
}
return w.Body.Bytes(), nil
}
// describeAccountLimits decodes the XML DescribeAccountLimits writes, keyed by
// limit name.
func describeAccountLimits(t *testing.T, gw *GatewayConfig, accountID string) map[string]string {
t.Helper()
body, err := serveAccountLimits(t, gw, accountID, "Action=DescribeAccountLimits")
body, err := serveAccountLimits(t, gw, account, "Action=DescribeAccountLimits&Marker=2&PageSize=1")
require.NoError(t, err)
var decoded accountLimitsResponse
require.NoError(t, xml.Unmarshal(body, &decoded))
require.NotEmpty(t, decoded.Limits)
byName := make(map[string]string, len(decoded.Limits))
for _, limit := range decoded.Limits {
byName[limit.Name] = limit.Max
}
return byName
assert.Len(t, decoded.Limits, len(elbv2awsapi.DescribeAccountLimits(&elbv2.DescribeAccountLimitsInput{}, 2).Limits))
assert.NotContains(t, string(body), "NextMarker")
}
// The reported cap must be the one CreateLoadBalancer enforces, including an
// override an operator has stored since the gateway started.
func TestELBv2DescribeAccountLimitsReportsStoredQuota(t *testing.T) {
gw, account := quotaTestGateway(t)
// A disabled quota means "not enforced", not "nothing permitted", so the entries
// report the AWS default rather than the unconsulted configured value.
func TestELBv2DescribeAccountLimitsDefaultsWhenQuotaDisabled(t *testing.T) {
disabled := quota.New(quota.Limits{LoadBalancers: 0}, nil)
byName := describeAccountLimits(t, gw, account)
assert.Equal(t, "2", byName["application-load-balancers"])
assert.Equal(t, "2", byName["network-load-balancers"])
require.NoError(t, quotaFor(gw).PutAccountQuota(t.Context(), account,
quota.Overrides{LoadBalancers: quotaPtr(9)}, "operator"))
byName = describeAccountLimits(t, gw, account)
assert.Equal(t, "9", byName["application-load-balancers"])
assert.Equal(t, "9", byName["network-load-balancers"])
for name, gw := range map[string]*GatewayConfig{
"disabled": withFakeCapabilities(withQuota(&GatewayConfig{DisableLogging: true}, disabled)),
"unwired": withFakeCapabilities(&GatewayConfig{DisableLogging: true}),
} {
t.Run(name, func(t *testing.T) {
byName := describeAccountLimits(t, gw, "000000000042")
assert.Equal(t, "50", byName["application-load-balancers"])
assert.Equal(t, "50", byName["network-load-balancers"])
})
}
}
// The limit must be resolved per request, so one account's override cannot be
@@ -106,21 +57,21 @@ func TestELBv2DescribeAccountLimitsIsPerAccount(t *testing.T) {
assert.Equal(t, "4", describeAccountLimits(t, gw, other)["application-load-balancers"])
}
// A disabled quota means "not enforced", not "nothing permitted", so the entries
// report the AWS default rather than the unconsulted configured value.
func TestELBv2DescribeAccountLimitsDefaultsWhenQuotaDisabled(t *testing.T) {
disabled := quota.New(quota.Limits{LoadBalancers: 0}, nil)
// The reported cap must be the one CreateLoadBalancer enforces, including an
// override an operator has stored since the gateway started.
func TestELBv2DescribeAccountLimitsReportsStoredQuota(t *testing.T) {
gw, account := quotaTestGateway(t)
for name, gw := range map[string]*GatewayConfig{
"disabled": withQuota(&GatewayConfig{DisableLogging: true}, disabled),
"unwired": {DisableLogging: true},
} {
t.Run(name, func(t *testing.T) {
byName := describeAccountLimits(t, gw, "000000000042")
assert.Equal(t, "50", byName["application-load-balancers"])
assert.Equal(t, "50", byName["network-load-balancers"])
})
}
byName := describeAccountLimits(t, gw, account)
assert.Equal(t, "2", byName["application-load-balancers"])
assert.Equal(t, "2", byName["network-load-balancers"])
require.NoError(t, quotaFor(gw).PutAccountQuota(t.Context(), account,
quota.Overrides{LoadBalancers: quotaPtr(9)}, "operator"))
byName = describeAccountLimits(t, gw, account)
assert.Equal(t, "9", byName["application-load-balancers"])
assert.Equal(t, "9", byName["network-load-balancers"])
}
// The limits Spinifex does not police are still advertised, so a caller does not
@@ -149,17 +100,3 @@ func TestELBv2DescribeAccountLimitsBlockedOnSealedRecord(t *testing.T) {
require.Equal(t, awserrors.ErrorBlocked, err.Error())
assert.Empty(t, body)
}
// The service must accept the paging parameters the model defines and still
// answer with the whole list.
func TestELBv2DescribeAccountLimitsAcceptsPaging(t *testing.T) {
gw, account := quotaTestGateway(t)
body, err := serveAccountLimits(t, gw, account, "Action=DescribeAccountLimits&Marker=2&PageSize=1")
require.NoError(t, err)
var decoded accountLimitsResponse
require.NoError(t, xml.Unmarshal(body, &decoded))
assert.Len(t, decoded.Limits, len(elbv2awsapi.DescribeAccountLimits(&elbv2.DescribeAccountLimitsInput{}, 2).Limits))
assert.NotContains(t, string(body), "NextMarker")
}
+2 -342
View File
@@ -1,325 +1,17 @@
// Characterization tests for ELBv2 Query-protocol dispatch as it exists in
// elbv2.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"encoding/xml"
"net/http"
"net/http/httptest"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"slices"
"testing"
)
var requestIDPattern = regexp.MustCompile(`<RequestId>[^<]*</RequestId>`)
// elbv2PipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS, with docs as every principal's policy.
func elbv2PipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, quota *admissionquota.Service) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withELBv2(withQuota(gw, quota))
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// signELBv2Request builds a signed Query-protocol POST for the
// elasticloadbalancing credential scope.
func signELBv2Request(t *testing.T, body string) *http.Request {
t.Helper()
b := []byte(body)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequestAs(t, req, b, testAccessKey, testSecretKey, "elasticloadbalancing", time.Now().UTC())
return req
}
// respondELBv2 answers subject with payload and counts invocations, so a
// denial can be proven to short-circuit before the handler ever runs.
func respondELBv2(t *testing.T, nc *nats.Conn, subject, payload string) *int {
t.Helper()
calls := 0
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
calls++
_ = msg.Respond([]byte(payload))
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
t.Cleanup(func() { _ = sub.Unsubscribe() })
return &calls
}
func normalizeRequestID(s string) string {
return requestIDPattern.ReplaceAllString(s, "<RequestId>ID</RequestId>")
}
// --- Full-pipeline error envelopes ---------------------------------------
func TestELBv2Pipeline_ErrorEnvelopes_GoldenXML(t *testing.T) {
cases := []struct {
name, body, code, message string
status int
}{
{"missing action", "", "MissingAction", "The request is missing an action or a required parameter.", http.StatusBadRequest},
{"unknown action", "Action=Bogus", "InvalidAction", "The action or operation requested is not valid. Verify that the action is typed correctly.", http.StatusBadRequest},
{"malformed query string", "Action=%zz", "MalformedQueryString", "The query string contains a syntax error.", http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signELBv2Request(t, tc.body))
require.Equal(t, tc.status, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Equal(t, `<?xml version="1.0" encoding="UTF-8"?>
<ErrorResponse>
<Error>
<Type>Sender</Type>
<Code>`+tc.code+`</Code>
<Message>`+tc.message+`</Message>
</Error>
<RequestId>ID</RequestId>
</ErrorResponse>`, normalizeRequestID(readBody(t, resp)))
})
}
}
// A handler failure with no AWS code is a 500 InternalError, here because no
// daemon answers the NATS subject.
func TestELBv2Pipeline_NoResponder_500InternalError(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeSSLPolicies"))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Contains(t, readBody(t, resp), "<Code>InternalError</Code>")
}
// --- Golden success responses ----------------------------------------------
// An action served without NATS answers 200 text/xml in the Query envelope.
func TestELBv2Pipeline_DescribeListenerAttributes_GoldenXML(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
listener := pipelineListener("web", "1", "2")
resp := doRequest(app, signELBv2Request(t, "Action=DescribeListenerAttributes&ListenerArn="+listener))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
assert.Equal(t, `<DescribeListenerAttributesResponse><DescribeListenerAttributesResult><Attributes></Attributes></DescribeListenerAttributesResult></DescribeListenerAttributesResponse>`,
readBody(t, resp))
}
func TestELBv2Pipeline_DescribeLoadBalancers_GoldenXML(t *testing.T) {
gw, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
calls := respondELBv2(t, gw.NATSConn, "elbv2.DescribeLoadBalancers",
`{"LoadBalancers":[{"LoadBalancerName":"web","LoadBalancerArn":"`+pipelineLB("web", "abc")+`","Type":"application"}]}`)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeLoadBalancers"))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
assert.Equal(t, 1, *calls)
var out struct {
LBs []struct {
ARN string `xml:"LoadBalancerArn"`
Name string `xml:"LoadBalancerName"`
Type string `xml:"Type"`
} `xml:"DescribeLoadBalancersResult>LoadBalancers>member"`
}
// Member field order varies between runs, so the result is pinned decoded.
require.NoError(t, xml.Unmarshal([]byte(readBody(t, resp)), &out))
require.Len(t, out.LBs, 1)
assert.Equal(t, pipelineLB("web", "abc"), out.LBs[0].ARN)
assert.Equal(t, "web", out.LBs[0].Name)
assert.Equal(t, "application", out.LBs[0].Type)
}
func TestELBv2Pipeline_DescribeAccountLimits_GoldenXML(t *testing.T) {
_, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeAccountLimits"))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "text/xml", resp.Header.Get("Content-Type"))
var out struct {
Limits []struct {
Name string `xml:"Name"`
Max int `xml:"Max"`
} `xml:"DescribeAccountLimitsResult>Limits>member"`
}
require.NoError(t, xml.Unmarshal([]byte(readBody(t, resp)), &out))
got := map[string]int{}
for _, l := range out.Limits {
got[l.Name] = l.Max
}
// Member field order varies between runs, so the limits are pinned decoded.
assert.Equal(t, map[string]int{
"application-load-balancers": 50, "network-load-balancers": 50, "target-groups": 3000,
"target-groups-per-application-load-balancer": 100, "target-groups-per-action-on-application-load-balancer": 5,
"target-groups-per-action-on-network-load-balancer": 1, "listeners-per-application-load-balancer": 50,
"listeners-per-network-load-balancer": 50, "rules-per-application-load-balancer": 100,
"condition-values-per-alb-rule": 5, "condition-wildcards-per-alb-rule": 5,
"targets-per-application-load-balancer": 1000, "targets-per-network-load-balancer": 3000,
"targets-per-availability-zone-per-network-load-balancer": 500,
"certificates-per-application-load-balancer": 25, "certificates-per-network-load-balancer": 25,
}, got)
}
// --- Account quota on CreateLoadBalancer -------------------------------------
func TestELBv2Pipeline_CreateLoadBalancer_QuotaExceededStopsBeforeCreate(t *testing.T) {
quota := admissionquota.New(admissionquota.Limits{Enabled: true, LoadBalancers: 1}, nil)
gw, app := elbv2PipelineApp(t, allowAllTaggingDocs(), quota)
respondELBv2(t, gw.NATSConn, "elbv2.DescribeLoadBalancers",
`{"LoadBalancers":[{"LoadBalancerName":"web","LoadBalancerArn":"`+pipelineLB("web", "abc")+`"}]}`)
creates := respondELBv2(t, gw.NATSConn, "elbv2.CreateLoadBalancer", `{}`)
resp := doRequest(app, signELBv2Request(t, "Action=CreateLoadBalancer&Name=second"))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, readBody(t, resp), "<Code>ResourceLimitExceeded</Code>")
assert.Zero(t, *creates, "the create must not run once the quota refuses")
}
func TestELBv2Pipeline_CreateLoadBalancer_QuotaDisabledReachesCreate(t *testing.T) {
gw, app := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
respondELBv2(t, gw.NATSConn, "elbv2.DescribeLoadBalancers", `{"LoadBalancers":[]}`)
creates := respondELBv2(t, gw.NATSConn, "elbv2.CreateLoadBalancer", `{}`)
resp := doRequest(app, signELBv2Request(t, "Action=CreateLoadBalancer&Name=second"))
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, *creates)
}
// --- Authorization -----------------------------------------------------------
// A denied caller is answered AccessDenied before any NATS handler runs.
func TestELBv2Pipeline_Authz_DeniedStopsBeforeHandler(t *testing.T) {
docs := scopedTaggingDocs("Allow", "elasticloadbalancing:DeleteLoadBalancer", pipelineLB("other", "zzz"))
gw, app := elbv2PipelineApp(t, docs, nil)
calls := respondELBv2(t, gw.NATSConn, "elbv2.DeleteLoadBalancer", `{}`)
resp := doRequest(app, signELBv2Request(t, "Action=DeleteLoadBalancer&LoadBalancerArn="+pipelineLB("prod", "abc")))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body := readBody(t, resp)
assert.Contains(t, body, "<Code>AccessDenied</Code>")
assert.Zero(t, *calls)
}
// The Query input is parsed before authorization, so a request whose list is
// past the parser's cap is MalformedQueryString even for a caller who would be
// denied.
func TestELBv2Pipeline_Authz_ParseFailurePrecedesGate(t *testing.T) {
docs := scopedTaggingDocs("Allow", "ec2:DescribeTags", "*")
_, app := elbv2PipelineApp(t, docs, nil)
var b strings.Builder
b.WriteString("Action=AddTags")
for i := 1; i <= 100000; i++ {
b.WriteString("&ResourceArns.member." + strconv.Itoa(i) + "=x")
}
req := signELBv2Request(t, b.String())
resp := doRequest(app, req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, readBody(t, resp), "<Code>MalformedQueryString</Code>")
}
// An unrelated grant denies, and an unknown action is rejected before the gate.
func TestELBv2Pipeline_Authz_UnrelatedGrantDeniedUnknownActionNot(t *testing.T) {
_, app := elbv2PipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), nil)
resp := doRequest(app, signELBv2Request(t, "Action=DescribeLoadBalancers"))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
resp = doRequest(app, signELBv2Request(t, "Action=Bogus"))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Contains(t, readBody(t, resp), "<Code>InvalidAction</Code>")
}
// --- Unavailable dependencies -------------------------------------------------
// A nil NATS connection fails at the request-level gate, before dispatch.
func TestELBv2Pipeline_NilNATS_503ServiceUnavailable(t *testing.T) {
gw := withELBv2(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
req := elbv2Request(authzAccountID, "Action=DescribeLoadBalancers")
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Contains(t, normalizeRequestID(readBody(t, resp)), "<Code>ServiceUnavailable</Code>")
}
// --- Oversized body over HTTP -------------------------------------------------
func TestELBv2Pipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "elasticloadbalancing", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
assert.Contains(t, readBody(t, resp), "<Code>RequestEntityTooLarge</Code>")
}
// --- Pipeline metadata ---------------------------------------------------------
func TestELBv2Pipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
sr := tracetest.NewSpanRecorder()
prev := otel.GetTracerProvider()
@@ -348,35 +40,3 @@ func TestELBv2Pipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T)
assert.Equal(t, "DescribeListenerAttributes", got["aws.action"])
assert.Equal(t, "elasticloadbalancing", got["aws.service"])
}
func TestELBv2Pipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := elbv2PipelineApp(t, allowAllTaggingDocs(), nil)
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
listener := pipelineListener("web", "1", "2")
doRequest(r, signELBv2Request(t, "Action=DescribeListenerAttributes&ListenerArn="+listener))
require.NotNil(t, got)
assert.Equal(t, "DescribeListenerAttributes", auditField(got, "action"))
}
// The pipeline signs in testRegion, so ARNs must carry it to pass the
// endpoint-region check.
func pipelineLB(name, id string) string {
return arn.FormatELBv2LoadBalancer(testRegion, authzAccountID, name, id, "application")
}
func pipelineListener(lbName, lbID, id string) string {
return arn.FormatELBv2Listener(testRegion, authzAccountID, lbName, lbID, id, "application")
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"maps"
"net/http"
@@ -41,8 +42,8 @@ func registerTestCodes(t *testing.T, codes map[string]awserrors.ErrorMessage) {
func sdkConfig(url string) aws.Config {
return aws.Config{
Region: testRegion,
Credentials: credentials.NewStaticCredentialsProvider(testAccessKey, testSecretKey, ""),
Region: gwfix.Region,
Credentials: credentials.NewStaticCredentialsProvider(gwfix.AccessKey, gwfix.SecretKey, ""),
RetryMaxAttempts: 1,
BaseEndpoint: aws.String(url),
}
+2 -1
View File
@@ -6,6 +6,7 @@ package gateway
import (
"bytes"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
@@ -264,7 +265,7 @@ func TestErrorEnvelope_OversizeBody(t *testing.T) {
send := func(t *testing.T, app http.Handler, service string) *http.Response {
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, service, time.Now().UTC())
return doRequest(app, req)
}
c := &dispatchCapture{}
-11
View File
@@ -39,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.
@@ -327,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
@@ -417,7 +407,6 @@ func (gw *GatewayConfig) checkPolicyResourcesWithKeys(r *http.Request, service,
// point with its resolved action, so telemetry enrichment lives here.
recordResolvedAction(r.Context(), service, action)
gw.fillDefaultCapabilities()
caller, authenticated := reqctx.Principal(r.Context())
return pipeline.Authorize(gw.Policy, r, caller, authenticated, service, action, resources, actionKeys)
}
+22 -381
View File
@@ -6,6 +6,7 @@ import (
"encoding/xml"
"errors"
"fmt"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
@@ -25,7 +26,6 @@ import (
"github.com/mulgadc/bluebottle/pkg/ratelimit"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
ochreawsapi "github.com/mulgadc/spinifex/spinifex/domains/ochre/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -655,81 +655,6 @@ func TestParseAWSQueryArgs_CapturedClientBodies(t *testing.T) {
}
}
func TestGetService(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
withServices(gw, taggingRegistration(nil), iamRegistration(gw), ec2Registration(gw))
tests := []struct {
name string
ctxVal string // service to record, empty means none
path string // request path, empty means "/"
wantSvc string
wantError string
}{
{
name: "no service in context",
wantError: awserrors.ErrorAuthFailure,
},
{
name: "unsupported service",
ctxVal: "s3",
wantError: awserrors.ErrorUnsupportedOperation,
},
{
name: "ec2 service",
ctxVal: "ec2",
wantSvc: "ec2",
},
{
name: "iam service",
ctxVal: "iam",
wantSvc: "iam",
},
{
name: "tagging service",
ctxVal: "tagging",
wantSvc: "tagging",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path := tc.path
if path == "" {
path = "/"
}
req := httptest.NewRequest(http.MethodPost, path, nil)
if tc.ctxVal != "" {
ctx := reqctx.WithService(req.Context(), tc.ctxVal)
req = req.WithContext(ctx)
}
svc, err := gw.GetService(req)
if tc.wantError != "" {
require.Error(t, err)
assert.Equal(t, tc.wantError, err.Error())
assert.Empty(t, svc)
} else {
require.NoError(t, err)
assert.Equal(t, tc.wantSvc, svc)
}
})
}
}
func TestRequest_NoServiceContext(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Contains(t, string(body), "AuthFailure")
}
func TestRequest_UnsupportedService(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
@@ -790,7 +715,7 @@ func TestRequest_EC2MissingAction(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
ctx := reqctx.WithService(req.Context(), "ec2")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -807,7 +732,7 @@ func TestRequest_IAMNilService(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=test"))
ctx := reqctx.WithService(req.Context(), "iam")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -967,219 +892,6 @@ func TestEC2Request_DescribeAvailabilityZones(t *testing.T) {
assert.Contains(t, string(body), "DescribeAvailabilityZonesResponse")
}
// TestCheckPolicy_FailsClosed covers the three states that once returned allow
// before any policy was consulted. A missing service is a server fault; a
// request with no resolved principal is denied.
func TestCheckPolicy_FailsClosed(t *testing.T) {
authed := func(identity string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, identity)
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
return req.WithContext(ctx)
}
cases := []struct {
name string
gw *GatewayConfig
req *http.Request
wantErr string
}{
{
name: "nil IAM service",
gw: withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: nil}),
req: authed("alice"),
wantErr: awserrors.ErrorInternalError,
},
{
name: "no auth context",
gw: withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}}),
req: httptest.NewRequest(http.MethodPost, "/", nil),
wantErr: awserrors.ErrorAccessDenied,
},
{
name: "empty identity",
gw: withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}}),
req: authed(""),
wantErr: awserrors.ErrorAccessDenied,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.gw.checkPolicy(tc.req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
})
}
}
// Shared authorization errors must not expose a canonical resource that was
// resolved from storage. STS opts into detailed wording at its dispatcher.
func TestCheckPolicyResources_DenialIsOpaque(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
resource := "arn:aws:iam::123456789012:role/private/target"
err := gw.checkPolicyResources(req, "iam", "GetRole", []string{resource})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
code, message, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
assert.Empty(t, message)
assert.NotContains(t, err.Error(), resource)
}
// TestCheckPolicy_PassRoleFailsClosed proves the change reaches a real gated
// call site: an unauthenticated RunInstances carrying an instance profile is
// denied rather than passing the iam:PassRole check.
func TestCheckPolicy_PassRoleFailsClosed(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/", nil)
err := gw.checkPolicyResources(req, "iam", "PassRole",
[]string{"arn:aws:iam::123456789012:role/app"})
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_RootUserGlobalAccount(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000") // GlobalAccountID
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
}
// TestCheckPolicy_AssumedRoleSessionNamedRoot ensures the principal-type gate
// fires BEFORE the identity-string root short-circuit. A session whose
// SessionName is "root" must not inherit root privileges.
func TestCheckPolicy_AssumedRoleSessionNamedRoot(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeAssumedRole)
ctx = withPrincipalField(ctx, fieldAssumedRoleARN, "arn:aws:sts::000000000000:assumed-role/r/root")
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_NonRootAllowPolicy(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return []iamdomain.PolicyDocument{
{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
}
func TestCheckPolicy_NonRootDenyPolicy(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return []iamdomain.PolicyDocument{
{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Deny", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_NonRootNoPolicies(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assertDenied(t, err)
}
func TestCheckPolicy_GetUserPoliciesError(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, errors.New("db connection failed")
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
func TestCheckPolicy_MissingAccountID(t *testing.T) {
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
// No account ID
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
// shortenPolicyResolveRetry keeps the transient-NATS retries without paying
// the production backoff. Tests that call it must not run in parallel.
func shortenPolicyResolveRetry(t *testing.T) {
@@ -1189,77 +901,6 @@ func shortenPolicyResolveRetry(t *testing.T) {
t.Cleanup(func() { policyRetryBase = prev })
}
func TestCheckPolicy_NATSTransientRetriesAllAttempts(t *testing.T) {
shortenPolicyResolveRetry(t)
calls := 0
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
calls++
return nil, fmt.Errorf("get user: %w", nats.ErrNoResponders)
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
assert.Equal(t, 3, calls, "should have retried all 3 attempts")
}
func TestCheckPolicy_NATSTransientRetriesThenSucceeds(t *testing.T) {
shortenPolicyResolveRetry(t)
calls := 0
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
calls++
if calls < 3 {
return nil, fmt.Errorf("get user: %w", nats.ErrNoResponders)
}
return []iamdomain.PolicyDocument{
{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
assert.Equal(t, 3, calls, "should have retried until success")
}
func TestCheckPolicy_NonTransientErrorStillFails(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return nil, errors.New("database corruption")
},
}
gw := withEC2Verifier(&GatewayConfig{DisableLogging: true, IAMService: mock})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := withPrincipalField(req.Context(), fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
// --- Throttle middleware integration tests ---
func TestThrottleKeyFuncs_ExtractsAccountAndAction(t *testing.T) {
@@ -1378,25 +1019,6 @@ func TestWriteThrottleError_Bedrock(t *testing.T) {
assert.Equal(t, "RequestLimitExceededException", resp.Header.Get("X-Amzn-Errortype"))
}
func TestWriteSigV4Error_BedrockEmitsJSON(t *testing.T) {
gw := withServices(&GatewayConfig{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
req := httptest.NewRequest(http.MethodPost, "/model/meta.llama3-2-1b-instruct-v1%3A0/converse", nil)
req = req.WithContext(reqctx.WithService(req.Context(), "bedrock"))
w := httptest.NewRecorder()
gw.writeSigV4Error(w, req, awserrors.ErrorSignatureDoesNotMatch, "")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "a bedrock auth failure must be SDK-parseable JSON")
assert.Contains(t, string(body), "SignatureDoesNotMatch")
assert.NotContains(t, string(body), "<?xml")
// SDKs resolve the modelled exception type from this header, not the body.
assert.Equal(t, "SignatureDoesNotMatchException", resp.Header.Get("X-Amzn-Errortype"))
}
func TestThrottleMiddleware_Integration(t *testing.T) {
cfg := ratelimit.Config{
Enabled: true,
@@ -1557,3 +1179,22 @@ func TestRequest_ClusterUnavailableClosedConn(t *testing.T) {
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestWriteSigV4Error_BedrockEmitsJSON(t *testing.T) {
gw := withServices(&GatewayConfig{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
req := httptest.NewRequest(http.MethodPost, "/model/meta.llama3-2-1b-instruct-v1%3A0/converse", nil)
req = req.WithContext(reqctx.WithService(req.Context(), "bedrock"))
w := httptest.NewRecorder()
gw.writeSigV4Error(w, req, awserrors.ErrorSignatureDoesNotMatch, "")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "a bedrock auth failure must be SDK-parseable JSON")
assert.Contains(t, string(body), "SignatureDoesNotMatch")
assert.NotContains(t, string(body), "<?xml")
// SDKs resolve the modelled exception type from this header, not the body.
assert.Equal(t, "SignatureDoesNotMatchException", resp.Header.Get("X-Amzn-Errortype"))
}
File diff suppressed because it is too large Load Diff
+4 -263
View File
@@ -1,34 +1,20 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"reflect"
"slices"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http/httptest"
"reflect"
"slices"
"testing"
)
type iamExamplesFile struct {
Examples map[string][]iamExample `json:"examples"`
}
type iamExample struct {
Input json.RawMessage `json:"input"`
Output json.RawMessage `json:"output"`
Title string `json:"title"`
}
func TestIAMExamplesContract(t *testing.T) {
examples := loadIAMExamples(t)
covered := 0
@@ -91,248 +77,3 @@ func TestIAMExamplesContract(t *testing.T) {
require.NotZero(t, covered, "no IAM examples were covered — check testdata path")
}
func loadIAMExamples(t *testing.T) iamExamplesFile {
t.Helper()
data, err := os.ReadFile("testdata/aws/examples-1.json")
require.NoError(t, err)
var examples iamExamplesFile
require.NoError(t, json.Unmarshal(data, &examples))
require.NotEmpty(t, examples.Examples)
return examples
}
func unmarshalIAMExample(t *testing.T, action, field string, data json.RawMessage, target any) {
t.Helper()
if len(data) == 0 || string(data) == "null" {
return
}
require.NoError(t, json.Unmarshal(data, target), "%s example %s", action, field)
}
func normalizeEmptySlices(v reflect.Value) {
if !v.IsValid() {
return
}
if v.Kind() == reflect.Interface && !v.IsNil() {
v = v.Elem()
}
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
normalizeEmptySlices(v.Elem())
return
}
if v.Kind() != reflect.Struct {
return
}
for _, field := range v.Fields() {
switch field.Kind() {
case reflect.Slice:
if field.Len() == 0 && field.CanSet() {
field.Set(reflect.Zero(field.Type()))
}
for j := 0; j < field.Len(); j++ {
normalizeEmptySlices(field.Index(j))
}
case reflect.Pointer, reflect.Struct, reflect.Interface:
normalizeEmptySlices(field)
}
}
}
type echoIAMService struct {
flexMockIAMService
wantOutput any
gotAccountID string
gotInput any
}
func echoIAMCall[In any, Out any](svc *echoIAMService, accountID string, input *In) (*Out, error) {
svc.gotAccountID = accountID
svc.gotInput = input
if svc.wantOutput == nil {
return new(Out), nil
}
output, ok := svc.wantOutput.(*Out)
if !ok {
return nil, fmt.Errorf("echo IAM output type %T is not %T", svc.wantOutput, new(Out))
}
return output, nil
}
func (svc *echoIAMService) AddRoleToInstanceProfile(accountID string, input *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) {
return echoIAMCall[iam.AddRoleToInstanceProfileInput, iam.AddRoleToInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) AddUserToGroup(accountID string, input *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return echoIAMCall[iam.AddUserToGroupInput, iam.AddUserToGroupOutput](svc, accountID, input)
}
func (svc *echoIAMService) AttachGroupPolicy(accountID string, input *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return echoIAMCall[iam.AttachGroupPolicyInput, iam.AttachGroupPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) AttachRolePolicy(accountID string, input *iam.AttachRolePolicyInput) (*iam.AttachRolePolicyOutput, error) {
return echoIAMCall[iam.AttachRolePolicyInput, iam.AttachRolePolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) AttachUserPolicy(accountID string, input *iam.AttachUserPolicyInput) (*iam.AttachUserPolicyOutput, error) {
return echoIAMCall[iam.AttachUserPolicyInput, iam.AttachUserPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) {
return echoIAMCall[iam.CreateAccessKeyInput, iam.CreateAccessKeyOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateGroup(accountID string, input *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return echoIAMCall[iam.CreateGroupInput, iam.CreateGroupOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateInstanceProfile(accountID string, input *iam.CreateInstanceProfileInput) (*iam.CreateInstanceProfileOutput, error) {
return echoIAMCall[iam.CreateInstanceProfileInput, iam.CreateInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
return echoIAMCall[iam.CreateOpenIDConnectProviderInput, iam.CreateOpenIDConnectProviderOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateRole(accountID string, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) {
return echoIAMCall[iam.CreateRoleInput, iam.CreateRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) CreateUser(accountID string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
return echoIAMCall[iam.CreateUserInput, iam.CreateUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
return echoIAMCall[iam.DeleteAccessKeyInput, iam.DeleteAccessKeyOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteGroupPolicy(accountID string, input *iam.DeleteGroupPolicyInput) (*iam.DeleteGroupPolicyOutput, error) {
return echoIAMCall[iam.DeleteGroupPolicyInput, iam.DeleteGroupPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteInstanceProfile(accountID string, input *iam.DeleteInstanceProfileInput) (*iam.DeleteInstanceProfileOutput, error) {
return echoIAMCall[iam.DeleteInstanceProfileInput, iam.DeleteInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteRole(accountID string, input *iam.DeleteRoleInput) (*iam.DeleteRoleOutput, error) {
return echoIAMCall[iam.DeleteRoleInput, iam.DeleteRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteRolePolicy(accountID string, input *iam.DeleteRolePolicyInput) (*iam.DeleteRolePolicyOutput, error) {
return echoIAMCall[iam.DeleteRolePolicyInput, iam.DeleteRolePolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
return echoIAMCall[iam.DeleteUserInput, iam.DeleteUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return echoIAMCall[iam.DeleteUserPolicyInput, iam.DeleteUserPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error) {
return echoIAMCall[iam.GetAccountSummaryInput, iam.GetAccountSummaryOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
return echoIAMCall[iam.ListAccountAliasesInput, iam.ListAccountAliasesOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) {
if _, ok := svc.wantOutput.(*iam.GetInstanceProfileOutput); !ok {
return &iam.GetInstanceProfileOutput{InstanceProfile: &iam.InstanceProfile{}}, nil
}
return echoIAMCall[iam.GetInstanceProfileInput, iam.GetInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return echoIAMCall[iam.GetRoleInput, iam.GetRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
return echoIAMCall[iam.GetUserInput, iam.GetUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListAccessKeys(accountID string, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
return echoIAMCall[iam.ListAccessKeysInput, iam.ListAccessKeysOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListGroupPolicies(accountID string, input *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return echoIAMCall[iam.ListGroupPoliciesInput, iam.ListGroupPoliciesOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListGroups(accountID string, input *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return echoIAMCall[iam.ListGroupsInput, iam.ListGroupsOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListGroupsForUser(accountID string, input *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return echoIAMCall[iam.ListGroupsForUserInput, iam.ListGroupsForUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListRoleTags(accountID string, input *iam.ListRoleTagsInput) (*iam.ListRoleTagsOutput, error) {
return echoIAMCall[iam.ListRoleTagsInput, iam.ListRoleTagsOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListUserTags(accountID string, input *iam.ListUserTagsInput) (*iam.ListUserTagsOutput, error) {
return echoIAMCall[iam.ListUserTagsInput, iam.ListUserTagsOutput](svc, accountID, input)
}
func (svc *echoIAMService) ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
return echoIAMCall[iam.ListUsersInput, iam.ListUsersOutput](svc, accountID, input)
}
func (svc *echoIAMService) PutGroupPolicy(accountID string, input *iam.PutGroupPolicyInput) (*iam.PutGroupPolicyOutput, error) {
return echoIAMCall[iam.PutGroupPolicyInput, iam.PutGroupPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) PutRolePolicy(accountID string, input *iam.PutRolePolicyInput) (*iam.PutRolePolicyOutput, error) {
return echoIAMCall[iam.PutRolePolicyInput, iam.PutRolePolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) PutUserPolicy(accountID string, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return echoIAMCall[iam.PutUserPolicyInput, iam.PutUserPolicyOutput](svc, accountID, input)
}
func (svc *echoIAMService) RemoveRoleFromInstanceProfile(accountID string, input *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) {
return echoIAMCall[iam.RemoveRoleFromInstanceProfileInput, iam.RemoveRoleFromInstanceProfileOutput](svc, accountID, input)
}
func (svc *echoIAMService) RemoveUserFromGroup(accountID string, input *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return echoIAMCall[iam.RemoveUserFromGroupInput, iam.RemoveUserFromGroupOutput](svc, accountID, input)
}
func (svc *echoIAMService) TagRole(accountID string, input *iam.TagRoleInput) (*iam.TagRoleOutput, error) {
return echoIAMCall[iam.TagRoleInput, iam.TagRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) TagUser(accountID string, input *iam.TagUserInput) (*iam.TagUserOutput, error) {
return echoIAMCall[iam.TagUserInput, iam.TagUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) UntagRole(accountID string, input *iam.UntagRoleInput) (*iam.UntagRoleOutput, error) {
return echoIAMCall[iam.UntagRoleInput, iam.UntagRoleOutput](svc, accountID, input)
}
func (svc *echoIAMService) UntagUser(accountID string, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) {
return echoIAMCall[iam.UntagUserInput, iam.UntagUserOutput](svc, accountID, input)
}
func (svc *echoIAMService) UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) {
return echoIAMCall[iam.UpdateAccessKeyInput, iam.UpdateAccessKeyOutput](svc, accountID, input)
}
func (svc *echoIAMService) UpdateAssumeRolePolicy(accountID string, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) {
return echoIAMCall[iam.UpdateAssumeRolePolicyInput, iam.UpdateAssumeRolePolicyOutput](svc, accountID, input)
}
var _ iamdomain.IAMService = (*echoIAMService)(nil)
-51
View File
@@ -1,51 +0,0 @@
package gateway
import (
"context"
"net/http"
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// iamRegistration is the IAM registration over the IAMService, NATS connection
// and node count gw holds, as the awsgw role composes it. Set them on gw first.
func iamRegistration(gw *GatewayConfig) dispatch.Registration {
return iamawsapi.NewRegistration(iamawsapi.Deps{
IAM: gw.IAMService,
CountInstanceProfileAssociations: func(ctx context.Context, accountID, profileARN string) (int, error) {
return ec2instanceapi.CountInstanceProfileAssociations(ctx, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID, profileARN)
},
})
}
// withServices makes regs the gateway's registered services.
func withServices(gw *GatewayConfig, regs ...dispatch.Registration) *GatewayConfig {
b := dispatch.NewBuilder()
for _, reg := range regs {
if err := b.Register(reg); err != nil {
panic(err)
}
}
gw.Services = b.Build()
return gw
}
// withIAM registers IAM alone on gw.
func withIAM(gw *GatewayConfig) *GatewayConfig {
return withServices(gw, iamRegistration(gw))
}
// IAM_Request dispatches r to the registered IAM service exactly as Request
// does, registering it first from the fields gw holds when the test has not.
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withIAM(gw)
}
entry, ok := gw.registered(iamawsapi.ServiceName)
if !ok {
panic("IAM is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
+70 -268
View File
@@ -2,211 +2,23 @@ package gateway
import (
"errors"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flexMockIAMService is a configurable mock with per-method overrides. It
// embeds the interface so it satisfies the full contract; only the fn-field
// methods below are wired, and any other method nil-panics if a test reaches
// an unmocked path.
type flexMockIAMService struct {
iamdomain.IAMService
createUserFn func(string, *iam.CreateUserInput) (*iam.CreateUserOutput, error)
getUserFn func(string, *iam.GetUserInput) (*iam.GetUserOutput, error)
listUsersFn func(string, *iam.ListUsersInput) (*iam.ListUsersOutput, error)
deleteUserFn func(string, *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
createAccessKeyFn func(string, *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
listAccessKeysFn func(string, *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error)
deleteAccessKeyFn func(string, *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error)
updateAccessKeyFn func(string, *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error)
canonicalResourceARNFn func(string, arn.IAMResourceType, string) (string, error)
lookupAccessKeyFn func(string) (*iamdomain.AccessKey, error)
getAccountSummaryFn func(string, *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error)
getRoleFn func(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error)
}
// GetRole answers NoSuchEntity by default: the STS identity gate resolves the
// target role, and a mock that invented one would let a test pass against a
// role the account does not hold.
func (m *flexMockIAMService) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
if m.getRoleFn != nil {
return m.getRoleFn(accountID, input)
}
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
func (m *flexMockIAMService) CreateUser(accountID string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
if m.createUserFn != nil {
return m.createUserFn(accountID, input)
}
return &iam.CreateUserOutput{}, nil
}
func (m *flexMockIAMService) GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
if m.getUserFn != nil {
return m.getUserFn(accountID, input)
}
return &iam.GetUserOutput{}, nil
}
func (m *flexMockIAMService) ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
if m.listUsersFn != nil {
return m.listUsersFn(accountID, input)
}
return &iam.ListUsersOutput{}, nil
}
func (m *flexMockIAMService) DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) {
if m.deleteUserFn != nil {
return m.deleteUserFn(accountID, input)
}
return &iam.DeleteUserOutput{}, nil
}
func (m *flexMockIAMService) CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) {
if m.createAccessKeyFn != nil {
return m.createAccessKeyFn(accountID, input)
}
return &iam.CreateAccessKeyOutput{}, nil
}
func (m *flexMockIAMService) ListAccessKeys(accountID string, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) {
if m.listAccessKeysFn != nil {
return m.listAccessKeysFn(accountID, input)
}
return &iam.ListAccessKeysOutput{}, nil
}
func (m *flexMockIAMService) DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
if m.deleteAccessKeyFn != nil {
return m.deleteAccessKeyFn(accountID, input)
}
return &iam.DeleteAccessKeyOutput{}, nil
}
func (m *flexMockIAMService) UpdateAccessKey(accountID string, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) {
if m.updateAccessKeyFn != nil {
return m.updateAccessKeyFn(accountID, input)
}
return &iam.UpdateAccessKeyOutput{}, nil
}
func (m *flexMockIAMService) CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error) {
if m.canonicalResourceARNFn != nil {
return m.canonicalResourceARNFn(accountID, kind, name)
}
return arn.FormatIAMPath(kind, accountID, "/", name), nil
}
func (m *flexMockIAMService) LookupAccessKey(accessKeyID string) (*iamdomain.AccessKey, error) {
if m.lookupAccessKeyFn != nil {
return m.lookupAccessKeyFn(accessKeyID)
}
return &iamdomain.AccessKey{AccessKeyID: accessKeyID, AccountID: "000000000000", UserName: "user"}, nil
}
func (m *flexMockIAMService) GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error) {
if m.getAccountSummaryFn != nil {
return m.getAccountSummaryFn(accountID, input)
}
return &iam.GetAccountSummaryOutput{}, nil
}
func (m *flexMockIAMService) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
return &iam.ListAccountAliasesOutput{}, nil
}
func (m *flexMockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
func (m *flexMockIAMService) GetGroup(_ string, _ *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
return &iam.GetGroupOutput{}, nil
}
func (m *flexMockIAMService) ListGroups(_ string, _ *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return &iam.ListGroupsOutput{}, nil
}
func (m *flexMockIAMService) DeleteGroup(_ string, _ *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
return &iam.DeleteGroupOutput{}, nil
}
func (m *flexMockIAMService) AddUserToGroup(_ string, _ *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return &iam.AddUserToGroupOutput{}, nil
}
func (m *flexMockIAMService) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (m *flexMockIAMService) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return &iam.ListGroupsForUserOutput{}, nil
}
func (m *flexMockIAMService) AttachGroupPolicy(_ string, _ *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return &iam.AttachGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
return &iam.DetachGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
return &iam.ListAttachedGroupPoliciesOutput{}, nil
}
func (m *flexMockIAMService) PutGroupPolicy(_ string, _ *iam.PutGroupPolicyInput) (*iam.PutGroupPolicyOutput, error) {
return &iam.PutGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) GetGroupPolicy(_ string, _ *iam.GetGroupPolicyInput) (*iam.GetGroupPolicyOutput, error) {
return &iam.GetGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInput) (*iam.DeleteGroupPolicyOutput, error) {
return &iam.DeleteGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return &iam.ListGroupPoliciesOutput{}, nil
}
func (m *flexMockIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return &iam.PutUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
return &iam.GetUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return &iam.DeleteUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
return &iam.ListUserPoliciesOutput{}, nil
}
// setupIAMRequestHandler wires an http.Handler for IAM_Request with injected SigV4 context values.
func setupIAMRequestHandler(svc iamdomain.IAMService) http.Handler {
gw := &GatewayConfig{
DisableLogging: true,
IAMService: svc,
Credentials: credverify.New(svc, nil),
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = reqctx.WithService(ctx, "iam")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000")
ctx = withPrincipalField(ctx, fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
r = r.WithContext(ctx)
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
}
func TestIAMRequest_CreateUser_Success(t *testing.T) {
svc := &flexMockIAMService{
createUserFn: func(_ string, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
@@ -234,31 +46,17 @@ func TestIAMRequest_CreateUser_Success(t *testing.T) {
assert.Contains(t, xmlStr, "alice")
}
func TestIAMRequest_ListUsers_Success(t *testing.T) {
svc := &flexMockIAMService{
listUsersFn: func(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
return &iam.ListUsersOutput{
Users: []*iam.User{
{UserName: aws.String("alice"), UserId: aws.String("AID1")},
{UserName: aws.String("bob"), UserId: aws.String("AID2")},
},
IsTruncated: aws.Bool(false),
}, nil
},
}
handler := setupIAMRequestHandler(svc)
func TestIAMRequest_EmptyAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ListUsers"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("UserName=alice"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
xmlStr := string(body)
assert.Contains(t, xmlStr, "ListUsersResult")
assert.Contains(t, xmlStr, "alice")
assert.Contains(t, xmlStr, "bob")
assert.Contains(t, string(body), "MissingAction")
}
// Locks in the xmlutil map behaviour: SummaryMap (map[string]*int64) must
@@ -290,14 +88,28 @@ func TestIAMRequest_GetAccountSummary_Success(t *testing.T) {
assert.Contains(t, xmlStr, "<value>2</value>")
}
// realAliasesIAMService answers ListAccountAliases from the real service, which
// needs no storage, so the test sees the response the gateway actually sends.
type realAliasesIAMService struct {
flexMockIAMService
}
// AWS answers GetRole for a never-used role with an empty <RoleLastUsed/>
// element; a nil-field struct must still marshal to that element, not vanish.
func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) {
svc := &flexMockIAMService{
getRoleFn: func(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
AssumeRolePolicyDocument: aws.String("{}"),
RoleLastUsed: &iam.RoleLastUsed{},
}}, nil
},
}
handler := setupIAMRequestHandler(svc)
func (realAliasesIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
return (&iamdomain.IAMServiceImpl{}).ListAccountAliases(accountID, input)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetRole&RoleName=app"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "<RoleLastUsed></RoleLastUsed>")
}
// AWS answers an account with no alias with an empty AccountAliases list and
@@ -324,54 +136,55 @@ func TestIAMRequest_ListAccountAliases_Empty(t *testing.T) {
assert.Contains(t, err.Error(), awserrors.ErrorValidationError)
}
// AWS answers GetRole for a never-used role with an empty <RoleLastUsed/>
// element; a nil-field struct must still marshal to that element, not vanish.
func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) {
func TestIAMRequest_ListUsers_Success(t *testing.T) {
svc := &flexMockIAMService{
getRoleFn: func(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
AssumeRolePolicyDocument: aws.String("{}"),
RoleLastUsed: &iam.RoleLastUsed{},
}}, nil
listUsersFn: func(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
return &iam.ListUsersOutput{
Users: []*iam.User{
{UserName: aws.String("alice"), UserId: aws.String("AID1")},
{UserName: aws.String("bob"), UserId: aws.String("AID2")},
},
IsTruncated: aws.Bool(false),
}, nil
},
}
handler := setupIAMRequestHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetRole&RoleName=app"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ListUsers"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "<RoleLastUsed></RoleLastUsed>")
xmlStr := string(body)
assert.Contains(t, xmlStr, "ListUsersResult")
assert.Contains(t, xmlStr, "alice")
assert.Contains(t, xmlStr, "bob")
}
func TestIAMRequest_UnknownAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
func TestIAMRequest_MissingAccountID(t *testing.T) {
gw := &GatewayConfig{
DisableLogging: true,
IAMService: &flexMockIAMService{},
Credentials: credverify.New(&flexMockIAMService{}, nil),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "iam")
r = withTestIdentity(r.WithContext(ctx))
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=DoesNotExist"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=alice"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
assert.Equal(t, 500, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "InvalidAction")
}
func TestIAMRequest_EmptyAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("UserName=alice"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "MissingAction")
assert.Contains(t, string(body), "InternalError")
}
func TestIAMRequest_NilService(t *testing.T) {
@@ -385,7 +198,7 @@ func TestIAMRequest_NilService(t *testing.T) {
ctx = reqctx.WithService(ctx, "iam")
ctx = withPrincipalField(ctx, fieldAccountID, "000000000000")
ctx = withPrincipalField(ctx, fieldIdentity, "root")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
r = r.WithContext(ctx)
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
@@ -422,28 +235,17 @@ func TestIAMRequest_ServiceError(t *testing.T) {
assert.Contains(t, xmlStr, "<ErrorResponse>")
}
func TestIAMRequest_MissingAccountID(t *testing.T) {
gw := &GatewayConfig{
DisableLogging: true,
IAMService: &flexMockIAMService{},
Credentials: credverify.New(&flexMockIAMService{}, nil),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "iam")
r = withTestIdentity(r.WithContext(ctx))
if err := gw.IAM_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
func TestIAMRequest_UnknownAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=alice"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=DoesNotExist"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
assert.Equal(t, 400, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "InternalError")
assert.Contains(t, string(body), "InvalidAction")
}
func TestIAMRequest_ValidationError(t *testing.T) {
+192 -347
View File
@@ -1,337 +1,18 @@
// Characterization tests for the spinifex Query actions and the /admin REST-JSON
// surface as they dispatch today, pinned ahead of moving both behind one
// registration. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/internal/testkit"
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/stretchr/testify/require"
"net/http"
"net/url"
"strings"
"testing"
)
// operatorIAM adds the account listing /admin/ListAccounts reads to the policy
// mock, which leaves it to a nil embedded interface.
type operatorIAM struct {
*policyMockIAMService
}
func (operatorIAM) ListAccounts() ([]*iamdomain.Account, error) {
return []*iamdomain.Account{
{AccountID: "000000000001", AccountName: "spinifex", Status: "ACTIVE", CreatedAt: "2026-01-02T03:04:05Z"},
{AccountID: "000000000002", AccountName: "tenant", Status: "ACTIVE", CreatedAt: "2026-02-03T04:05:06Z"},
}, nil
}
// operatorPipelineApp wires SigV4AuthMiddleware -> the two operator routes as
// SetupRoutes does, on live test NATS, with docs as every principal's policy.
// accountID owns the signing key; the region is testRegion because
// signTestRequestAs always signs for it.
func operatorPipelineApp(t *testing.T, accountID string, docs []iamdomain.PolicyDocument, mutate func(*GatewayConfig)) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
Version: "v9.9.9-test",
Commit: "abc1234",
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "admin",
AccountID: accountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
gw.IAMService = operatorIAM{gw.IAMService.(*policyMockIAMService)}
if mutate != nil {
mutate(gw)
}
withOperator(gw)
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.Use(traceActionEnricher)
r.HandleFunc("/admin/{method}", gw.Admin_Request)
r.HandleFunc("/*", gw.Request)
return gw, r
}
func allowAllOperatorDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signOperatorRequest builds a signed request for the spinifex scope (or any
// scope named), the shape the CLI and the operator tooling send.
func signOperatorRequest(t *testing.T, method, path, service string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Host = "localhost:9999"
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, service, time.Now().UTC())
return req
}
func spinifexQuery(t *testing.T, action string, args url.Values) *http.Request {
t.Helper()
if args == nil {
args = url.Values{}
}
if action != "" {
args.Set("Action", action)
}
body := []byte(args.Encode())
req := signOperatorRequest(t, http.MethodPost, "/", "spinifex", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
// ec2ErrorCode reads the code out of the EC2 <Response><Errors> envelope the
// spinifex Query service renders its failures in.
func ec2ErrorCode(t *testing.T, resp *http.Response) string {
t.Helper()
var body struct {
Code string `xml:"Errors>Error>Code"`
RequestID string `xml:"RequestID"`
}
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, xml.Unmarshal(raw, &body), string(raw))
require.NotEmpty(t, body.RequestID, "the EC2 envelope carries its request ID in the body")
return body.Code
}
type operatorAdminError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
RequestID string `json:"requestId"`
}
// adminError decodes the /admin JSON envelope and checks the request ID is on
// both the header and the body, which is how a client ties a failure to a log line.
func adminError(t *testing.T, resp *http.Response) operatorAdminError {
t.Helper()
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var body operatorAdminError
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.NotEmpty(t, body.RequestID)
require.Equal(t, body.RequestID, resp.Header.Get("X-Amzn-Requestid"))
return body
}
func opBody(t *testing.T, resp *http.Response) string {
t.Helper()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(raw)
}
// --- Query surface ---------------------------------------------------------
// Successes are JSON with application/json even though the service is a
// Query-protocol one, and no request ID header is set on this surface.
func TestOperatorQuery_GetVersion_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.Empty(t, resp.Header.Get("X-Amzn-Requestid"))
require.JSONEq(t,
`{"version":"v9.9.9-test","commit":"abc1234","os":"linux","arch":"amd64","license":"open-source"}`,
opBody(t, resp))
}
// With no node answering, discovery falls back to the expected-node count and
// the listings are empty rather than an error.
func TestOperatorQuery_NodeAndVMListingsWithNoNodes(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetNodes", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"nodes":[],"cluster_mode":"single-node"}`, opBody(t, resp))
resp = doRequest(app, spinifexQuery(t, "GetVMs", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"vms":[]}`, opBody(t, resp))
}
// A storage-config request nobody answers surfaces as the unmapped-error
// InternalError, not a distinct unavailable code.
func TestOperatorQuery_GetStorageStatusWithNoResponder(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetStorageStatus", nil))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
require.Equal(t, "InternalError", ec2ErrorCode(t, resp))
}
func TestOperatorQuery_ListOchreCatalog_ReturnsEntries(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "ListOchreCatalog", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var out struct {
Entries []map[string]any `json:"entries"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.NotEmpty(t, out.Entries)
require.Contains(t, out.Entries[0], "modelId")
}
// Every action named in spinifexAdminActions refuses a caller outside the
// super-admin account, and refuses it with the same AccessDenied.
func TestOperatorQuery_EveryAdminActionDeniesNonAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
for action := range spinifexAdminActions {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
// The policy gate runs first: a super-admin caller with no spinifex:<Action>
// grant is denied, whatever the action.
func TestOperatorQuery_PolicyDenialBeatsEverythingForTheAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), nil, nil)
for _, action := range []string{"GetVersion", "PromoteImage", "NotAnAction"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
// An action outside the admin set is not account-gated, so a non-admin caller
// holding the policy gets the unknown-action answer, not a denial.
func TestOperatorQuery_UnknownActionAndMissingAction(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "NotAnAction", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", ec2ErrorCode(t, resp))
resp = doRequest(app, spinifexQuery(t, "", url.Values{"Version": {"1"}}))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingAction", ec2ErrorCode(t, resp))
}
func TestOperatorQuery_MalformedQueryString(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, signOperatorRequest(t, http.MethodPost, "/", "spinifex", []byte("Action=%zz")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MalformedQueryString", ec2ErrorCode(t, resp))
}
func TestOperatorQuery_RequiredParameters(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "PromoteImage", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingParameter", ec2ErrorCode(t, resp))
}
// Without a grant store the model-access actions answer ServerInternal before
// they look at their parameters.
func TestOperatorQuery_ModelAccessWithoutStoreIsServerInternal(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
for _, action := range []string{"GrantModelAccess", "RevokeModelAccess", "ListModelAccess"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, url.Values{"AccountId": {"000000000002"}, "ModelId": {"m"}}))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "ServerInternal", ec2ErrorCode(t, resp))
})
}
}
// The cluster-unavailable check is in the shared Request path, ahead of
// authorization, so even a caller with no grant sees 503 when NATS is down.
func TestOperatorQuery_NATSDownAnswers503BeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, func(gw *GatewayConfig) { gw.NATSConn = nil })
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
require.Equal(t, "ServiceUnavailable", ec2ErrorCode(t, resp))
}
// --- /admin surface --------------------------------------------------------
func adminCall(t *testing.T, app http.Handler, method, name, service string, body string) *http.Response {
t.Helper()
return doRequest(app, signOperatorRequest(t, method, "/admin/"+name, service, []byte(body)))
}
func TestOperatorAdmin_ListAccounts_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", `{}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.NotEmpty(t, resp.Header.Get("X-Amzn-Requestid"), "success carries the request ID header too")
require.JSONEq(t, `{"accounts":[`+
`{"accountId":"000000000001","accountName":"spinifex","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},`+
`{"accountId":"000000000002","accountName":"tenant","status":"ACTIVE","createdAt":"2026-02-03T04:05:06Z"}],`+
`"count":2}`, opBody(t, resp))
}
func TestOperatorAdmin_GetAccountQuota_DefaultsComeFromConfig(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *GatewayConfig) {
withQuota(gw, admissionquota.New(admissionquota.Limits{Enabled: true}, nil))
})
resp := adminCall(t, app, http.MethodPost, "GetAccountQuota", "spinifex", `{"accountId":"000000000002"}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
var out struct {
AccountID string `json:"accountId"`
Source map[string]string `json:"source"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.Equal(t, "000000000002", out.AccountID)
require.Equal(t, "config", out.Source["vcpus"])
}
// An empty or non-JSON body is InvalidRequest on every body-reading method
// that parses one, and MissingParameter where a required field is absent.
func TestOperatorAdmin_BodyErrors(t *testing.T) {
@@ -360,30 +41,6 @@ func TestOperatorAdmin_BodyErrors(t *testing.T) {
}
}
func TestOperatorAdmin_OversizedBodyIsInvalidRequest(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", strings.Repeat("a", 9000))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
e := adminError(t, resp)
require.Equal(t, "InvalidRequest", e.Error.Code)
require.Equal(t, "request body exceeds 8 KiB", e.Error.Message)
}
// The method is checked before authorization, so an unknown name and a
// non-POST verb answer the same way for a caller with no grant at all.
func TestOperatorAdmin_UnknownMethodAndVerbBeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, nil)
resp := adminCall(t, app, http.MethodPost, "PurgeEverything", "spinifex", `{}`)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", adminError(t, resp).Error.Code)
resp = adminCall(t, app, http.MethodGet, "ListAccounts", "spinifex", "")
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
require.Equal(t, "MethodNotAllowed", adminError(t, resp).Error.Code)
}
// Each gate denies with the same AccessDenied: wrong credential scope, wrong
// account, and a super-admin user holding no spinifex:<Method> grant.
func TestOperatorAdmin_EveryGateDeniesWithTheSameAnswer(t *testing.T) {
@@ -412,6 +69,35 @@ func TestOperatorAdmin_EveryGateDeniesWithTheSameAnswer(t *testing.T) {
}
}
func TestOperatorAdmin_GetAccountQuota_DefaultsComeFromConfig(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *GatewayConfig) {
withQuota(gw, admissionquota.New(admissionquota.Limits{Enabled: true}, nil))
})
resp := adminCall(t, app, http.MethodPost, "GetAccountQuota", "spinifex", `{"accountId":"000000000002"}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
var out struct {
AccountID string `json:"accountId"`
Source map[string]string `json:"source"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.Equal(t, "000000000002", out.AccountID)
require.Equal(t, "config", out.Source["vcpus"])
}
func TestOperatorAdmin_ListAccounts_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", `{}`)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.NotEmpty(t, resp.Header.Get("X-Amzn-Requestid"), "success carries the request ID header too")
require.JSONEq(t, `{"accounts":[`+
`{"accountId":"000000000001","accountName":"spinifex","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},`+
`{"accountId":"000000000002","accountName":"tenant","status":"ACTIVE","createdAt":"2026-02-03T04:05:06Z"}],`+
`"count":2}`, opBody(t, resp))
}
// Unlike the Query surface, /admin authorizes first and only then checks the
// cluster, so NATS down is visible only to an authorized caller.
func TestOperatorAdmin_NATSDownAnswers503OnlyAfterAuthorization(t *testing.T) {
@@ -429,6 +115,16 @@ func TestOperatorAdmin_NATSDownAnswers503OnlyAfterAuthorization(t *testing.T) {
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestOperatorAdmin_OversizedBodyIsInvalidRequest(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", strings.Repeat("a", 9000))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
e := adminError(t, resp)
require.Equal(t, "InvalidRequest", e.Error.Code)
require.Equal(t, "request body exceeds 8 KiB", e.Error.Message)
}
// Every request, success or failure, gets its own request ID.
func TestOperatorAdmin_RequestIDIsUniquePerRequest(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
@@ -438,3 +134,152 @@ func TestOperatorAdmin_RequestIDIsUniquePerRequest(t *testing.T) {
require.NotEmpty(t, first)
require.NotEqual(t, first, second)
}
// The method is checked before authorization, so an unknown name and a
// non-POST verb answer the same way for a caller with no grant at all.
func TestOperatorAdmin_UnknownMethodAndVerbBeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, nil)
resp := adminCall(t, app, http.MethodPost, "PurgeEverything", "spinifex", `{}`)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", adminError(t, resp).Error.Code)
resp = adminCall(t, app, http.MethodGet, "ListAccounts", "spinifex", "")
require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
require.Equal(t, "MethodNotAllowed", adminError(t, resp).Error.Code)
}
// Every action named in spinifexAdminActions refuses a caller outside the
// super-admin account, and refuses it with the same AccessDenied.
func TestOperatorQuery_EveryAdminActionDeniesNonAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
for action := range spinifexAdminActions {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
// A storage-config request nobody answers surfaces as the unmapped-error
// InternalError, not a distinct unavailable code.
func TestOperatorQuery_GetStorageStatusWithNoResponder(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetStorageStatus", nil))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "application/xml", resp.Header.Get("Content-Type"))
require.Equal(t, "InternalError", ec2ErrorCode(t, resp))
}
// Successes are JSON with application/json even though the service is a
// Query-protocol one, and no request ID header is set on this surface.
func TestOperatorQuery_GetVersion_JSONSuccess(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
require.Empty(t, resp.Header.Get("X-Amzn-Requestid"))
require.JSONEq(t,
`{"version":"v9.9.9-test","commit":"abc1234","os":"linux","arch":"amd64","license":"open-source"}`,
opBody(t, resp))
}
func TestOperatorQuery_ListOchreCatalog_ReturnsEntries(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "ListOchreCatalog", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var out struct {
Entries []map[string]any `json:"entries"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.NotEmpty(t, out.Entries)
require.Contains(t, out.Entries[0], "modelId")
}
func TestOperatorQuery_MalformedQueryString(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, signOperatorRequest(t, http.MethodPost, "/", "spinifex", []byte("Action=%zz")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MalformedQueryString", ec2ErrorCode(t, resp))
}
// Without a grant store the model-access actions answer ServerInternal before
// they look at their parameters.
func TestOperatorQuery_ModelAccessWithoutStoreIsServerInternal(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
for _, action := range []string{"GrantModelAccess", "RevokeModelAccess", "ListModelAccess"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, url.Values{"AccountId": {"000000000002"}, "ModelId": {"m"}}))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
require.Equal(t, "ServerInternal", ec2ErrorCode(t, resp))
})
}
}
// The cluster-unavailable check is in the shared Request path, ahead of
// authorization, so even a caller with no grant sees 503 when NATS is down.
func TestOperatorQuery_NATSDownAnswers503BeforeAuthorization(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, nil, func(gw *GatewayConfig) { gw.NATSConn = nil })
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
require.Equal(t, "ServiceUnavailable", ec2ErrorCode(t, resp))
}
// With no node answering, discovery falls back to the expected-node count and
// the listings are empty rather than an error.
func TestOperatorQuery_NodeAndVMListingsWithNoNodes(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "GetNodes", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"nodes":[],"cluster_mode":"single-node"}`, opBody(t, resp))
resp = doRequest(app, spinifexQuery(t, "GetVMs", nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.JSONEq(t, `{"vms":[]}`, opBody(t, resp))
}
// The policy gate runs first: a super-admin caller with no spinifex:<Action>
// grant is denied, whatever the action.
func TestOperatorQuery_PolicyDenialBeatsEverythingForTheAdminAccount(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), nil, nil)
for _, action := range []string{"GetVersion", "PromoteImage", "NotAnAction"} {
t.Run(action, func(t *testing.T) {
resp := doRequest(app, spinifexQuery(t, action, nil))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "AccessDenied", ec2ErrorCode(t, resp))
})
}
}
func TestOperatorQuery_RequiredParameters(t *testing.T) {
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "PromoteImage", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingParameter", ec2ErrorCode(t, resp))
}
// An action outside the admin set is not account-gated, so a non-admin caller
// holding the policy gets the unknown-action answer, not a denial.
func TestOperatorQuery_UnknownActionAndMissingAction(t *testing.T) {
_, app := operatorPipelineApp(t, authzAccountID, allowAllOperatorDocs(), nil)
resp := doRequest(app, spinifexQuery(t, "NotAnAction", nil))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "InvalidAction", ec2ErrorCode(t, resp))
resp = doRequest(app, spinifexQuery(t, "", url.Values{"Version": {"1"}}))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Equal(t, "MissingAction", ec2ErrorCode(t, resp))
}
@@ -2,72 +2,35 @@ package gateway
import (
"bufio"
"bytes"
"encoding/json"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
streamAccount = "000000000002"
streamInstance = "i-0123456789abcdef0"
)
func TestOperatorQuery_GetInstanceMetrics_PolicyOnAnotherInstanceIsDenied(t *testing.T) {
otherARN := "arn:aws:ec2:" + gwfix.Region + ":" + streamAccount + ":instance/i-0fffffffffffffff0"
gw, app := operatorPipelineApp(t, streamAccount, instanceMetricsDocs(otherARN), nil)
answerWatches(t, gw.NATSConn)
srv := httptest.NewServer(app)
t.Cleanup(srv.Close)
func instanceMetricsDocs(resource string) []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"spinifex:GetInstanceMetrics"}, Resource: iamdomain.StringOrArr{resource}},
},
}}
}
// answerWatches replies to the instance's watch with one sample, as the
// owning node's collector would.
func answerWatches(t *testing.T, nc *nats.Conn) {
t.Helper()
ring, err := json.Marshal(telemetryv1.LiveRing{Samples: []telemetryv1.LiveSample{{TimeMillis: 1000, CPUPercent: 7}}})
require.NoError(t, err)
sub, err := nc.Subscribe(telemetryv1.LiveWatchSubject(streamAccount, streamInstance), func(msg *nats.Msg) {
if msg.Reply != "" {
_ = msg.Respond(ring)
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
// postSignedMetrics sends a signed GetInstanceMetrics over real HTTP, so the
// response can be read while the stream is still open.
func postSignedMetrics(t *testing.T, srv *httptest.Server) *http.Response {
t.Helper()
body := []byte(url.Values{"Action": {"GetInstanceMetrics"}, "InstanceId": {streamInstance}}.Encode())
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL+"/", bytes.NewReader(body))
require.NoError(t, err)
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "spinifex", time.Now().UTC())
resp, err := srv.Client().Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
return resp
resp := postSignedMetrics(t, srv)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, awserrors.ErrorAccessDenied, ec2ErrorCode(t, resp))
}
// Through the signed pipeline, a tenant's policy naming the instance's ARN is
// enough, and the first sample arrives while the stream is still open.
func TestOperatorQuery_GetInstanceMetrics_StreamsThroughThePipeline(t *testing.T) {
instanceARN := "arn:aws:ec2:" + testRegion + ":" + streamAccount + ":instance/" + streamInstance
instanceARN := "arn:aws:ec2:" + gwfix.Region + ":" + streamAccount + ":instance/" + streamInstance
gw, app := operatorPipelineApp(t, streamAccount, instanceMetricsDocs(instanceARN), nil)
answerWatches(t, gw.NATSConn)
srv := httptest.NewServer(app)
@@ -96,15 +59,3 @@ func TestOperatorQuery_GetInstanceMetrics_StreamsThroughThePipeline(t *testing.T
t.Fatal("no sample was flushed through the pipeline")
}
}
func TestOperatorQuery_GetInstanceMetrics_PolicyOnAnotherInstanceIsDenied(t *testing.T) {
otherARN := "arn:aws:ec2:" + testRegion + ":" + streamAccount + ":instance/i-0fffffffffffffff0"
gw, app := operatorPipelineApp(t, streamAccount, instanceMetricsDocs(otherARN), nil)
answerWatches(t, gw.NATSConn)
srv := httptest.NewServer(app)
t.Cleanup(srv.Close)
resp := postSignedMetrics(t, srv)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, awserrors.ErrorAccessDenied, ec2ErrorCode(t, resp))
}
@@ -1,42 +0,0 @@
package gateway
import (
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
operatoradmin "github.com/mulgadc/spinifex/spinifex/operator/admin"
)
// spinifexAdminActions is the set of super-admin Query actions the spinifex
// registration serves, which the characterization tests walk.
var spinifexAdminActions = func() map[string]bool {
m := map[string]bool{}
for _, name := range operatoradmin.AdminActionNames() {
m[name] = true
}
return m
}()
// withOperator registers the spinifex Query service on gw from the fields gw
// holds, as awsgw composes it.
func withOperator(gw *GatewayConfig) *GatewayConfig {
b := dispatch.NewBuilder()
deps := operatoradmin.Deps{
Version: gw.Version,
Commit: gw.Commit,
NATS: gw.NATSConn,
RootCAs: gw.RootCAs,
ActiveNodes: gw.DiscoverActiveNodes,
IAM: gw.IAMService,
Quota: quotaFor(gw),
ExpectedNodes: gw.ExpectedNodes,
}
reg := operatoradmin.NewRegistration(deps)
if err := b.Register(reg); err != nil {
panic(err)
}
if err := b.Register(ec2Registration(gw)); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
-143
View File
@@ -1,143 +0,0 @@
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// passRoleTo is a PassRole statement on every role, conditioned on the
// consuming service the way AWS's documented examples scope it.
func passRoleTo(effect, operator, service string) iamdomain.Statement {
s := statement(effect, "iam:PassRole", "*")
s.Condition = map[string]map[string]iamdomain.ConditionValue{
operator: {iampolicy.KeyPassedToService: {service}},
}
return s
}
func passRoleToGateway(t *testing.T, statements ...iamdomain.Statement) *GatewayConfig {
t.Helper()
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return withVerifier(&GatewayConfig{
DisableLogging: true,
Region: authzRegion,
NATSConn: startTestNATS(t),
IAMService: &profileIAMService{
policyMockIAMService: policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
},
})
}
func associateProfile(t *testing.T, gw *GatewayConfig) error {
t.Helper()
return dispatchEC2(t, gw, "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web")
}
func registerTaskDefinition(t *testing.T, gw *GatewayConfig) error {
t.Helper()
req := setupECSUserRequest("AmazonEC2ContainerServiceV20141113.RegisterTaskDefinition",
registerTaskDefinitionBody(t, testECSPassRoleTargetARN))
return gw.serveECS(httptest.NewRecorder(), req)
}
func createNodegroup(t *testing.T, gw *GatewayConfig) error {
t.Helper()
return dispatchEKS(t, gw, http.MethodPost, "/clusters/prod/node-groups", createNodegroupBody(eksNodeRoleARN))
}
func assertNotUnauthorized(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotEqual(t, awserrors.ErrorUnauthorizedOperation, err.Error())
}
// Each EC2 action that hands over an instance profile builds its own PassRole
// check, so every one is pinned to ec2.amazonaws.com.
func TestPassedToService_EC2(t *testing.T) {
tests := []struct {
action string
body string
}{
{"RunInstances", "Action=RunInstances&ImageId=ami-1&InstanceType=t3.micro&MinCount=1&MaxCount=1&IamInstanceProfile.Name=web"},
{"AssociateIamInstanceProfile", "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web"},
{"ReplaceIamInstanceProfileAssociation", "Action=ReplaceIamInstanceProfileAssociation" +
"&AssociationId=iip-assoc-dev&IamInstanceProfile.Name=web"},
{"RequestSpotInstances", "Action=RequestSpotInstances&LaunchSpecification.ImageId=ami-1" +
"&LaunchSpecification.InstanceType=t3.micro&LaunchSpecification.IamInstanceProfile.Name=web"},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
assertNotUnauthorized(t, dispatchEC2(t, passRoleToGateway(t,
statement("Allow", "ec2:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"),
), tt.body))
assertUnauthorized(t, dispatchEC2(t, passRoleToGateway(t,
statement("Allow", "ec2:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ecs-tasks.amazonaws.com"),
), tt.body))
})
}
}
func TestPassedToService_ECS(t *testing.T) {
assertNotDenied(t, registerTaskDefinition(t, passRoleToGateway(t,
statement("Allow", "ecs:*", "*"),
passRoleTo("Allow", iampolicy.OpStringLike, "ecs-tasks.*"),
)))
assertDenied(t, registerTaskDefinition(t, passRoleToGateway(t,
statement("Allow", "ecs:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"),
)))
}
// AWS reports a nodegroup's node role as passed to EKS, not to the EC2
// instances that end up running under it.
func TestPassedToService_EKS(t *testing.T) {
allowed := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "eks.amazonaws.com"),
)
assertPermitted(t, createNodegroup(t, allowed))
assertPermitted(t, dispatchEKS(t, allowed, http.MethodPost, "/clusters",
`{"name":"prod","roleArn":"`+eksNodeRoleARN+`"}`))
denied := eksPassRoleGateway(
statement("Allow", "eks:*", "*"),
passRoleTo("Allow", iampolicy.OpStringEquals, "ec2.amazonaws.com"),
)
assertDenied(t, createNodegroup(t, denied))
assertDenied(t, dispatchEKS(t, denied, http.MethodPost, "/clusters",
`{"name":"prod","roleArn":"`+eksNodeRoleARN+`"}`))
}
// A Deny scoped to one service fences that service and leaves the others to
// the unconditional grant.
func TestPassedToService_DenyFencesOneService(t *testing.T) {
statements := []iamdomain.Statement{
statement("Allow", "ec2:*", "*"),
statement("Allow", "ecs:*", "*"),
statement("Allow", "iam:PassRole", "*"),
passRoleTo("Deny", iampolicy.OpStringEquals, "ecs-tasks.amazonaws.com"),
}
assertDenied(t, registerTaskDefinition(t, passRoleToGateway(t, statements...)))
assertNotUnauthorized(t, associateProfile(t, passRoleToGateway(t, statements...)))
}
// The key exists only on a PassRole check, so a condition on it attached to
// any other action does not hold.
func TestPassedToService_AbsentOutsidePassRole(t *testing.T) {
grant := statement("Allow", "ec2:*", "*")
grant.Condition = map[string]map[string]iamdomain.ConditionValue{
iampolicy.OpStringEquals: {iampolicy.KeyPassedToService: {"ec2.amazonaws.com"}},
}
assertUnauthorized(t, dispatchEC2(t, scopedPolicyGateway(grant), "Action=DeleteVolume"))
}
+6 -5
View File
@@ -1,6 +1,7 @@
package gateway
import (
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
@@ -10,16 +11,16 @@ import (
// allowAllIAMService resolves an allow-everything policy for every principal.
// Tests that exercise handler behaviour rather than authorization use it so the
// policy gate passes on a real evaluation instead of a bypass.
func allowAllIAMService() *policyMockIAMService {
func allowAllIAMService() *gwfix.PolicyIAM {
allow := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
return &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
return &gwfix.PolicyIAM{
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
GetRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
}
}
@@ -33,7 +34,7 @@ func withTestIdentity(req *http.Request) *http.Request {
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
}
if have.PrincipalType == "" {
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
}
return req.WithContext(ctx)
}
+2 -1
View File
@@ -3,6 +3,7 @@ package gateway
import (
"context"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"sync"
"testing"
@@ -33,7 +34,7 @@ func quotaFor(gw *GatewayConfig) *admissionquota.Service {
// quotaIAMService resolves exactly one account.
type quotaIAMService struct {
policyMockIAMService
gwfix.PolicyIAM
known string
}
+9 -8
View File
@@ -1,6 +1,7 @@
package gateway
import (
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"io"
@@ -26,14 +27,14 @@ import (
func setupTestAppWithRateLimiter(t *testing.T, accessKey, secretKey string, rl *authlimit.AuthRateLimiter) http.Handler {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(secretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(secretKey, gwfix.MasterKey)
if err != nil {
panic("failed to encrypt test secret: " + err.Error())
}
mockSvc := &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
mockSvc := &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
accessKey: {
AccessKeyID: accessKey,
SecretAccessKey: encryptedSecret,
@@ -50,7 +51,7 @@ func setupTestAppWithRateLimiter(t *testing.T, accessKey, secretKey string, rl *
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
Region: gwfix.Region,
IAMService: mockSvc,
Credentials: credverify.New(mockSvc, nil),
RateLimiter: rl,
@@ -71,7 +72,7 @@ func TestRateLimitIntegration_LockedIPGets503(t *testing.T) {
rl := authlimit.NewAuthRateLimiter()
defer rl.Stop()
handler := setupTestAppWithRateLimiter(t, testAccessKey, testSecretKey, rl)
handler := setupTestAppWithRateLimiter(t, gwfix.AccessKey, gwfix.SecretKey, rl)
// Send maxFailures requests with invalid signatures to trigger lockout.
for range authlimit.MaxFailures {
@@ -105,7 +106,7 @@ func TestRateLimitIntegration_SuccessResetsLockout(t *testing.T) {
rl := authlimit.NewAuthRateLimiter()
defer rl.Stop()
handler := setupTestAppWithRateLimiter(t, testAccessKey, testSecretKey, rl)
handler := setupTestAppWithRateLimiter(t, gwfix.AccessKey, gwfix.SecretKey, rl)
// Accumulate failures below threshold.
for range authlimit.MaxFailures - 1 {
@@ -121,7 +122,7 @@ func TestRateLimitIntegration_SuccessResetsLockout(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.RemoteAddr = "10.99.0.2:54321"
signTestRequest(t, req, nil, testAccessKey, testSecretKey)
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusOK {
-42
View File
@@ -1,42 +0,0 @@
package gateway
import (
"net/http"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// withRDS registers RDS on gw with the NATS connection and node count it holds,
// as the awsgw role composes it. Set both on gw before calling.
func withRDS(gw *GatewayConfig) *GatewayConfig {
deps := rdsawsapi.Deps{NATS: gw.NATSConn, ExpectedNodes: gw.ExpectedNodes}
if quotaFor(gw) != nil {
deps.Quota = quotaFor(gw)
}
b := dispatch.NewBuilder()
if err := b.Register(rdsawsapi.NewRegistration(deps)); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
// serveRDS dispatches r to the registered RDS service exactly as Request does,
// registering it first from the fields gw holds when the test has not.
func (gw *GatewayConfig) serveRDS(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withRDS(gw)
}
entry, ok := gw.registered(rdsawsapi.ServiceName)
if !ok {
panic("RDS is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
// rdsRegistrationInventory is the inventory the RDS registration declares to a
// registry, keyed as AWSOperationInventory expects.
func rdsRegistrationInventory() map[string]dispatch.Inventory {
return withRDS(&GatewayConfig{}).Services.Inventory()
}
+24 -107
View File
@@ -2,123 +2,19 @@ package gateway
import (
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
rdsinstance "github.com/mulgadc/spinifex/spinifex/domains/rds/instance"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
)
const rdsTestAccountID = "123456789012"
func setupRDSRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
ctx := reqctx.WithService(req.Context(), "rds")
ctx = withPrincipalField(ctx, fieldAccountID, rdsTestAccountID)
return withTestIdentity(req.WithContext(ctx))
}
// A signed customer request: an IAM user with a resolvable identity, which is
// what turns the policy check from the pre-IAM bypass into a real evaluation.
func setupRDSUserRequest(body, accountID string) *http.Request {
req := setupRDSRequest(body)
ctx := withPrincipalField(req.Context(), fieldAccountID, accountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
return req.WithContext(ctx)
}
// Counts policy resolutions, so a test can tell an authorized request from one
// that failed some other guard before the evaluator was ever reached.
type rdsPolicyProbe struct{ consulted int }
// A gateway whose IAM service answers with policies, so the RDS dispatch path
// runs a real evaluation for either principal branch.
func newRDSPolicyGateway(policies []iamdomain.PolicyDocument) (*GatewayConfig, *rdsPolicyProbe) {
return newRDSGatewayWithResolver(func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return policies, nil
})
}
func newRDSGatewayWithResolver(
resolve func(accountID, name string) ([]iamdomain.PolicyDocument, error),
) (*GatewayConfig, *rdsPolicyProbe) {
probe := &rdsPolicyProbe{}
counted := func(accountID, name string) ([]iamdomain.PolicyDocument, error) {
probe.consulted++
return resolve(accountID, name)
}
// Both branches are wired: an assumed-role principal resolves by role, and a
// test that only stubbed users would miss it entirely.
return withVerifier(&GatewayConfig{
DisableLogging: true,
Region: "ap-southeast-2",
IAMService: &policyMockIAMService{
getUserPoliciesFn: counted,
getRolePoliciesFn: counted,
},
}), probe
}
func TestRDSRequest_MalformedScalarReturnsHTTP400(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
gw := withVerifier(&GatewayConfig{DisableLogging: true, NATSConn: nc, IAMService: allowAllIAMService()})
for _, body := range []string{
"Action=CreateDBInstance&AllocatedStorage=abc",
"Action=CreateDBInstance&MultiAZ=yes",
} {
t.Run(body, func(t *testing.T) {
req := setupRDSRequest(body)
err := gw.serveRDS(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
w := httptest.NewRecorder()
gw.ErrorHandler(w, req, err)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+awserrors.ErrorInvalidParameterValue+"</Code>")
})
}
}
// RDS errors must use the IAM-style <ErrorResponse> envelope: the aws-sdk-go
// query unmarshaler rejects the EC2 <Response><Errors> shape for this service.
func TestRDSErrorHandler_UsesIAMEnvelope(t *testing.T) {
gw := withVerifier(withRDS(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
w := httptest.NewRecorder()
gw.ErrorHandler(w, setupRDSRequest("Action=CreateDBInstance"), errNotImplementedForTest)
body := w.Body.String()
assert.Contains(t, body, "<ErrorResponse>")
assert.Contains(t, body, "<Code>"+awserrors.ErrorNotImplemented+"</Code>")
}
func TestRDSErrorHandler_UsesRDSUnsupportedActionWording(t *testing.T) {
gw := withVerifier(withRDS(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
w := httptest.NewRecorder()
gw.ErrorHandler(w, setupRDSRequest("Action=CreateDBCluster"),
errors.New(awserrors.ErrorOperationNotSupported))
body := w.Body.String()
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, body, "RDS")
assert.Contains(t, body, "v1")
assert.NotContains(t, body, "registry")
}
var errNotImplementedForTest = errors.New(awserrors.ErrorNotImplemented)
// TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400 runs the real public path:
// a CreateDBInstance query request through RDS_Request, over NATS, into the production
// rdsinstance.Service, and the resulting error through ErrorHandler's HTTP/XML rendering. AWS
@@ -146,3 +42,24 @@ func TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400(t *testin
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+awserrors.ErrorInvalidParameterValue+"</Code>")
}
func TestRDSRequest_MalformedScalarReturnsHTTP400(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
gw := withVerifier(&GatewayConfig{DisableLogging: true, NATSConn: nc, IAMService: allowAllIAMService()})
for _, body := range []string{
"Action=CreateDBInstance&AllocatedStorage=abc",
"Action=CreateDBInstance&MultiAZ=yes",
} {
t.Run(body, func(t *testing.T) {
req := setupRDSRequest(body)
err := gw.serveRDS(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
w := httptest.NewRecorder()
gw.ErrorHandler(w, req, err)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "<Code>"+awserrors.ErrorInvalidParameterValue+"</Code>")
})
}
}
@@ -0,0 +1,35 @@
package gateway
import (
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/clusterready"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
)
// withRealAuth wires the production verifier, policy evaluator and account
// admission. Only tests that assert a verdict which depends on gateway
// internals (the middleware stash, the admin method gate, the account gate)
// use it; every other gateway test runs on the fake verifier.
func withRealAuth(gw *GatewayConfig) *GatewayConfig {
gw.Credentials = credverify.New(gw.IAMService, gw.STSService)
gw.Policy = policyauth.New(gw.IAMService, policyauth.WithRetryBase(policyRetryBase))
gw.Admission = accountadmission.New(gw.IAMService)
if gw.NATSConn != nil {
gw.Readiness = realReadiness{gw}
}
return gw
}
// withEC2RealAuth is withEC2Verifier on the production capabilities.
func withEC2RealAuth(gw *GatewayConfig) *GatewayConfig {
return withEC2(withRealAuth(gw))
}
// realReadiness reads the bus connection when asked, so a test that drops it
// after wiring sees the cluster as unavailable.
type realReadiness struct{ gw *GatewayConfig }
func (r realReadiness) Available() bool {
return r.gw.NATSConn != nil && clusterready.New(r.gw.NATSConn).Available()
}
+1 -34
View File
@@ -1,36 +1,3 @@
package gateway
import (
"net/http"
"testing"
"time"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/registryadapt"
"github.com/stretchr/testify/require"
)
// newTestRegistryHandler composes the /v2 handler the way the awsgw role does, over
// gw's IAM and STS services, with registry serving authorized requests.
func newTestRegistryHandler(t *testing.T, gw *GatewayConfig, iss *ecrauth.Issuer, verify *ecrauth.Verifier, registry ...http.Handler) http.Handler {
t.Helper()
cfg := ecrregistry.BridgeConfig{
Verifier: verify,
Issuer: iss,
Admission: accountadmission.New(gw.IAMService),
Region: gw.Region,
}
cfg = registryadapt.Config(cfg, credverify.New(gw.IAMService, gw.STSService),
policyauth.New(gw.IAMService, policyauth.WithRetryBase(time.Millisecond)))
if len(registry) > 0 {
cfg.Registry = registry[0]
}
b, err := ecrregistry.NewBridge(cfg)
require.NoError(t, err)
return ingresshttp.NewHostMatcher(gw.InternalSuffix, "dkr", "ecr").Middleware(b.Handler())
}
import ()
@@ -12,6 +12,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"io"
"net/http"
"net/http/httptest"
@@ -66,15 +67,15 @@ type reqctxGatewayEnv struct {
// optional route53 probe over a caller who holds only the given statements.
func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.Statement) *GatewayConfig {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
base := policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
base := gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
@@ -82,11 +83,11 @@ func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.S
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}
svc := &iamAuthzPolicyService{
policyMockIAMService: base,
arns: map[string]string{"alice": "arn:aws:iam::" + authzAccountID + ":user/alice"},
PolicyIAM: base,
arns: map[string]string{"alice": "arn:aws:iam::" + authzAccountID + ":user/alice"},
}
var nc *nats.Conn
@@ -96,7 +97,7 @@ func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.S
nc.Close()
}
}
gw := withVerifier(&GatewayConfig{
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: reqctxRegion,
NATSConn: nc,
@@ -139,7 +140,7 @@ func signReqctx(t *testing.T, req *http.Request, body []byte, service, region st
payloadHash := sha256Hex(body)
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(t.Context(),
aws.Credentials{AccessKeyID: testAccessKey, SecretAccessKey: testSecretKey},
aws.Credentials{AccessKeyID: gwfix.AccessKey, SecretAccessKey: gwfix.SecretKey},
req, payloadHash, service, region, time.Now().UTC()))
return req
}
@@ -436,7 +437,7 @@ func TestReqctxPipeline_CredentialScopeRegionVersusInvocationRegion(t *testing.T
// iamAuthzPolicyService resolves a role or user name to a fixed ARN.
type iamAuthzPolicyService struct {
policyMockIAMService
gwfix.PolicyIAM
arns map[string]string
}
+35 -34
View File
@@ -2,6 +2,7 @@ package gateway
import (
"fmt"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net"
@@ -30,9 +31,9 @@ func auditRouter(t *testing.T, keys map[string]*iamdomain.AccessKey) (http.Handl
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
IAMService: &mockIAMService{masterKey: testMasterKey, accessKeys: keys},
Credentials: credverify.New(&mockIAMService{masterKey: testMasterKey, accessKeys: keys}, nil),
Region: gwfix.Region,
IAMService: &gwfix.IAM{MasterKey: gwfix.MasterKey, AccessKeys: keys},
Credentials: credverify.New(&gwfix.IAM{MasterKey: gwfix.MasterKey, AccessKeys: keys}, nil),
RateLimiter: rl,
}
withEC2(gw)
@@ -54,22 +55,6 @@ func auditRouter(t *testing.T, keys map[string]*iamdomain.AccessKey) (http.Handl
return r, func() *audit.Record { return got }
}
// A request that never presented credentials is still attributable to where it
// came from, and carries the verdict it was answered with.
func TestRequestAuditRecordsUnauthenticatedRejection(t *testing.T) {
handler, record := auditRouter(t, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.15.8.11:54321"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.NotNil(t, record())
assert.Equal(t, "10.15.8.11", auditField(record(), "sourceIP"))
assert.Equal(t, awserrors.ErrorMissingAuthenticationToken, auditField(record(), "authError"))
assert.Empty(t, auditField(record(), "accessKeyID"))
}
// signedRequest builds a request whose signature is wrong but whose envelope
// parses: the credential scope date must match X-Amz-Date or the request is
// rejected as malformed before the access key is ever looked at.
@@ -84,27 +69,13 @@ func signedRequestForKey(remoteAddr, accessKeyID string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKeyID+"/"+
now.Format("20060102")+"/"+testRegion+"/ec2/aws4_request, "+
now.Format("20060102")+"/"+gwfix.Region+"/ec2/aws4_request, "+
"SignedHeaders=host;x-amz-date, Signature="+
"0000000000000000000000000000000000000000000000000000000000000000")
req.Header.Set("X-Amz-Date", now.Format("20060102T150405Z"))
return req
}
// A rejected caller is identifiable: the key it presented is recorded even
// though authentication failed on it.
func TestRequestAuditRecordsRejectedAccessKey(t *testing.T) {
handler, record := auditRouter(t, map[string]*iamdomain.AccessKey{})
w := httptest.NewRecorder()
handler.ServeHTTP(w, signedRequest("10.15.8.12:54321"))
require.NotNil(t, record())
assert.Equal(t, "10.15.8.12", auditField(record(), "sourceIP"))
assert.Equal(t, "AKIAINVALIDKEY000000", auditField(record(), "accessKeyID"))
assert.Equal(t, awserrors.ErrorInvalidClientTokenId, auditField(record(), "authError"))
}
// The lockout that answers 503 is the case that motivated this: without the
// record, the response carries no client address and no reason at all.
func TestRequestAuditRecordsRateLimitLockout(t *testing.T) {
@@ -198,3 +169,33 @@ func auditField(rec *audit.Record, key string) string {
}
return ""
}
// A rejected caller is identifiable: the key it presented is recorded even
// though authentication failed on it.
func TestRequestAuditRecordsRejectedAccessKey(t *testing.T) {
handler, record := auditRouter(t, map[string]*iamdomain.AccessKey{})
w := httptest.NewRecorder()
handler.ServeHTTP(w, signedRequest("10.15.8.12:54321"))
require.NotNil(t, record())
assert.Equal(t, "10.15.8.12", auditField(record(), "sourceIP"))
assert.Equal(t, "AKIAINVALIDKEY000000", auditField(record(), "accessKeyID"))
assert.Equal(t, awserrors.ErrorInvalidClientTokenId, auditField(record(), "authError"))
}
// A request that never presented credentials is still attributable to where it
// came from, and carries the verdict it was answered with.
func TestRequestAuditRecordsUnauthenticatedRejection(t *testing.T) {
handler, record := auditRouter(t, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.15.8.11:54321"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.NotNil(t, record())
assert.Equal(t, "10.15.8.11", auditField(record(), "sourceIP"))
assert.Equal(t, awserrors.ErrorMissingAuthenticationToken, auditField(record(), "authError"))
assert.Empty(t, auditField(record(), "accessKeyID"))
}
@@ -2,6 +2,7 @@ package gateway
import (
"context"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -68,7 +69,7 @@ func sqsWorkerRequest(service, body, session string) *http.Request {
ctx := reqctx.WithService(req.Context(), service)
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, session)
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeAssumedRole)
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeAssumedRole)
ctx = withPrincipalField(ctx, fieldUnderlyingRoleARN, roleARN)
ctx = withPrincipalField(ctx, fieldAssumedRoleARN,
"arn:aws:sts::"+awsidentifiers.GlobalAccountID+":assumed-role/"+sqsworkerapi.WorkerRoleName+"/"+session)
@@ -92,7 +93,7 @@ func newSQSInternalGateway(policies []iamdomain.PolicyDocument) (*GatewayConfig,
// The worker's session, signed for spinifex-sqs, reaches the internal surface
// and is evaluated against its role policy under the spinifex-sqs prefix.
func TestSQSInternal_RoutedBySigningServiceAndRolePolicy(t *testing.T) {
gw, probe, ids := newSQSInternalGateway(allowPolicy("spinifex-sqs:ClaimFirstStore"))
gw, probe, ids := newSQSInternalGateway(gwfix.AllowPolicy("spinifex-sqs:ClaimFirstStore"))
rec := httptest.NewRecorder()
require.NoError(t, serveSigned(gw, rec, sqsWorkerRequest(sqsworkerapi.ServiceName, "Action=ClaimFirstStore", sqsWorkerInstance)))
assert.Equal(t, 1, probe.consulted)
@@ -103,7 +104,7 @@ func TestSQSInternal_RoutedBySigningServiceAndRolePolicy(t *testing.T) {
// The role policy is real: a worker session whose policy grants another
// service's action is denied before any store is consulted.
func TestSQSInternal_PolicyForAnotherServiceDenies(t *testing.T) {
gw, probe, ids := newSQSInternalGateway(allowPolicy("sqs:ClaimFirstStore"))
gw, probe, ids := newSQSInternalGateway(gwfix.AllowPolicy("sqs:ClaimFirstStore"))
err := serveSigned(gw, httptest.NewRecorder(), sqsWorkerRequest(sqsworkerapi.ServiceName, "Action=ClaimFirstStore", sqsWorkerInstance))
assertDenied(t, err)
assert.Equal(t, 1, probe.consulted)
@@ -112,7 +113,7 @@ func TestSQSInternal_PolicyForAnotherServiceDenies(t *testing.T) {
// A customer administrator holding every action is refused by the class gate.
func TestSQSInternal_CustomerAdminDenied(t *testing.T) {
gw, probe, ids := newSQSInternalGateway(allowPolicy("*"))
gw, probe, ids := newSQSInternalGateway(gwfix.AllowPolicy("*"))
req := setupRDSUserRequest("Action=ClaimFirstStore", rdsTestAccountID)
req = req.WithContext(reqctx.WithService(req.Context(), sqsworkerapi.ServiceName))
assertDenied(t, serveSigned(gw, httptest.NewRecorder(), req))
@@ -123,7 +124,7 @@ func TestSQSInternal_CustomerAdminDenied(t *testing.T) {
// Public SQS is untouched: nothing serves the sqs signing service, and the
// internal actions do not answer under RDS's name either.
func TestSQSInternal_PublicSQSUnchanged(t *testing.T) {
gw, _, ids := newSQSInternalGateway(allowPolicy("*"))
gw, _, ids := newSQSInternalGateway(gwfix.AllowPolicy("*"))
_, ok := gw.registered("sqs")
assert.False(t, ok, "the sqs signing service must stay unserved")
inv := gw.Services.Inventory()
+20 -61
View File
@@ -1,55 +1,36 @@
package gateway
import (
"github.com/aws/aws-sdk-go/service/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAnonymousSTS_WebIdentityNoAuthReachesHandler proves an unsigned
// AssumeRoleWithWebIdentity is routed to the STS dispatcher before SigV4, not rejected.
func TestAnonymousSTS_WebIdentityNoAuthReachesHandler(t *testing.T) {
var seenToken string
gw := withSTS(withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: &flexMockIAMService{},
STSService: &flexMockSTSService{
assumeWebIdentityFn: func(in *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
seenToken = aws.StringValue(in.WebIdentityToken)
return &sts.AssumeRoleWithWebIdentityOutput{
Credentials: &sts.Credentials{
AccessKeyId: aws.String("ASIAIRSA"),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("token"),
},
}, nil
},
},
}))
handler := gw.SetupRoutes()
// TestAnonymousSTSArgs_Classification covers the body-peek helper for anonymous action detection.
func TestAnonymousSTSArgs_Classification(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
body := "Action=AssumeRoleWithWebIdentity&Version=2011-06-15" +
"&RoleArn=arn:aws:iam::000000000001:role/demo&RoleSessionName=s1" +
"&WebIdentityToken=header.payload.sig"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// No Authorization header — unsigned bootstrap path.
t.Run("anonymous action recognised, body restored", func(t *testing.T) {
const body = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
args, ok := gw.anonymousSTSArgs(req)
require.True(t, ok)
assert.Equal(t, "AssumeRoleWithWebIdentity", args["Action"])
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
restored, _ := io.ReadAll(req.Body)
assert.Equal(t, body, string(restored), "body must be restored for downstream read")
})
require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
b, _ := io.ReadAll(rec.Body)
assert.Contains(t, string(b), "AssumeRoleWithWebIdentityResult")
assert.Contains(t, string(b), "ASIAIRSA")
assert.Equal(t, "header.payload.sig", seenToken)
t.Run("non-anonymous action rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
_, ok := gw.anonymousSTSArgs(req)
assert.False(t, ok)
})
}
// TestAnonymousSTS_SignedRequestFallsThrough confirms a request with an Authorization
@@ -76,25 +57,3 @@ func TestAnonymousSTS_SignedRequestFallsThrough(t *testing.T) {
gw.anonymousSTSInterceptor(next).ServeHTTP(rec, req)
assert.True(t, nextCalled, "signed request must pass through to next handler")
}
// TestAnonymousSTSArgs_Classification covers the body-peek helper for anonymous action detection.
func TestAnonymousSTSArgs_Classification(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
t.Run("anonymous action recognised, body restored", func(t *testing.T) {
const body = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
args, ok := gw.anonymousSTSArgs(req)
require.True(t, ok)
assert.Equal(t, "AssumeRoleWithWebIdentity", args["Action"])
restored, _ := io.ReadAll(req.Body)
assert.Equal(t, body, string(restored), "body must be restored for downstream read")
})
t.Run("non-anonymous action rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
_, ok := gw.anonymousSTSArgs(req)
assert.False(t, ok)
})
}
+86 -210
View File
@@ -1,129 +1,79 @@
// (stsRequestParams, principalTypeUser, the context keys) and shares the
// policy mocks with the other in-package authz suites.
//
//test:in-package — the gate is exercised through unexported request wiring
package gateway
import (
"errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"testing"
)
const (
stsTestRoleName = "app"
stsTestRoleARN = "arn:aws:iam::000000000000:role/app"
)
// stsPolicyIAMService serves the identity policies the STS gate evaluates, the
// GetUser lookup GetCallerIdentity performs, and the GetRole the gate resolves
// the target role with.
type stsPolicyIAMService struct {
policyMockIAMService
roles map[string]string
}
func (m *stsPolicyIAMService) GetUser(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{UserId: aws.String("AIDAALICE000")}}, nil
}
func (m *stsPolicyIAMService) GetRole(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
arn, ok := m.roles[aws.StringValue(input.RoleName)]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
// TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated is the case that would
// have caught the ECS agent regression: a role session whose underlying role
// grants a non-sts action and nothing else must not be able to assume anything.
func TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
params := func(iamSvc iamdomain.IAMService, stsSvc *flexMockSTSService) stsRequestParams {
return stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "s1",
principalType: gwfix.PrincipalTypeAssumedRole,
accessKey: "ASIAEXAMPLE",
assumedRoleARN: "arn:aws:sts::000000000000:assumed-role/ecsInstanceRole/s1",
assumedRoleID: "AROAECS:s1",
underlyingRoleARN: "arn:aws:iam::000000000000:role/ecsInstanceRole",
stsSvc: stsSvc,
iamSvc: iamSvc,
}
}
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
Arn: aws.String(arn),
}}, nil
t.Run("ecs-only role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "ecs:*", "*"))
resp := stsPolicyRequestAs(t, params(svc, deniedSTSService(t)), body)
assertSTSAccessDenied(t, resp)
})
t.Run("role granted sts:AssumeRole reaches handler", func(t *testing.T) {
svc := stsIdentityPolicy(
stsStatement("Allow", "ecs:*", "*"),
stsStatement("Allow", "sts:AssumeRole", "arn:aws:iam::000000000000:role/*"),
)
resp := stsPolicyRequestAs(t, params(svc, &flexMockSTSService{}), body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
// stsIdentityPolicy builds an IAM service returning the given statements as the
// caller's only identity policy, for a user and for an assumed role alike. The
// one stored role is the pathless `app`; withRoles replaces the set.
func stsIdentityPolicy(statements ...iamdomain.Statement) *stsPolicyIAMService {
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
svc := &stsPolicyIAMService{roles: map[string]string{stsTestRoleName: stsTestRoleARN}}
svc.getUserPoliciesFn = func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return docs, nil
}
svc.getRolePoliciesFn = func(_, _ string) ([]iamdomain.PolicyDocument, error) {
return docs, nil
}
return svc
}
// TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence keeps the
// detailed denial message safe to return: it must read identically whether or
// not the role exists, which a lookup added ahead of the gate would break.
func TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
// withRoles replaces the roles GetRole answers for, keyed by name and mapped to
// the ARN IAM stored — which is what a resource-scoped grant is matched against.
func (m *stsPolicyIAMService) withRoles(roles map[string]string) *stsPolicyIAMService {
m.roles = roles
return m
}
existing := assertSTSAccessDenied(t, stsPolicyRequest(t, stsIdentityPolicy(), nil, body))
missing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, body))
// stsStatement is shorthand for a single-action, single-resource statement.
func stsStatement(effect, action, resource string) iamdomain.Statement {
return iamdomain.Statement{
Effect: effect,
Action: iamdomain.StringOrArr{action},
Resource: iamdomain.StringOrArr{resource},
}
}
assert.Equal(t, stripRequestID(existing), stripRequestID(missing))
assert.Contains(t, existing, "is not authorized to perform: sts:AssumeRole on resource: "+stsTestRoleARN)
// deniedSTSService fails the test if any gated handler is reached, so a passing
// denial can only have come from the gate.
func deniedSTSService(t *testing.T) *flexMockSTSService {
t.Helper()
return &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("handler reached: the identity-policy gate should have denied this request")
return nil, nil
},
}
}
// A pathed role is where the two can diverge: a resolved role names its
// stored ARN, so an unresolved one must name what the caller supplied
// rather than any form derived from it.
const pathed = "arn:aws:iam::000000000000:role/team/svc"
pathedBody := "Action=AssumeRole&RoleArn=" + pathed + "&RoleSessionName=s1"
// stsPolicyRequest dispatches an STS action as alice with the given identity
// policies.
func stsPolicyRequest(t *testing.T, iamSvc iamdomain.IAMService, stsSvc *flexMockSTSService, body string) *http.Response {
t.Helper()
if stsSvc == nil {
stsSvc = deniedSTSService(t)
}
return stsPolicyRequestAs(t, stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: stsSvc,
iamSvc: iamSvc,
}, body)
}
pathedExisting := assertSTSAccessDenied(t, stsPolicyRequest(t,
stsIdentityPolicy().withRoles(map[string]string{"svc": pathed}), nil, pathedBody))
pathedMissing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, pathedBody))
func stsPolicyRequestAs(t *testing.T, p stsRequestParams, body string) *http.Response {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return doRequest(setupSTSRequestHandler(p), req)
}
func assertSTSAccessDenied(t *testing.T, resp *http.Response) string {
t.Helper()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "AccessDenied")
return string(b)
assert.Equal(t, stripRequestID(pathedExisting), stripRequestID(pathedMissing))
assert.Contains(t, pathedExisting, "on resource: "+pathed)
}
// TestSTSRequest_AssumeRole_IdentityPolicyGate covers the identity-side gate.
@@ -161,64 +111,6 @@ func TestSTSRequest_AssumeRole_IdentityPolicyGate(t *testing.T) {
})
}
// TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated is the case that would
// have caught the ECS agent regression: a role session whose underlying role
// grants a non-sts action and nothing else must not be able to assume anything.
func TestSTSRequest_AssumeRole_AssumedRoleCallerIsGated(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
params := func(iamSvc iamdomain.IAMService, stsSvc *flexMockSTSService) stsRequestParams {
return stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "s1",
principalType: principalTypeAssumedRole,
accessKey: "ASIAEXAMPLE",
assumedRoleARN: "arn:aws:sts::000000000000:assumed-role/ecsInstanceRole/s1",
assumedRoleID: "AROAECS:s1",
underlyingRoleARN: "arn:aws:iam::000000000000:role/ecsInstanceRole",
stsSvc: stsSvc,
iamSvc: iamSvc,
}
}
t.Run("ecs-only role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "ecs:*", "*"))
resp := stsPolicyRequestAs(t, params(svc, deniedSTSService(t)), body)
assertSTSAccessDenied(t, resp)
})
t.Run("role granted sts:AssumeRole reaches handler", func(t *testing.T) {
svc := stsIdentityPolicy(
stsStatement("Allow", "ecs:*", "*"),
stsStatement("Allow", "sts:AssumeRole", "arn:aws:iam::000000000000:role/*"),
)
resp := stsPolicyRequestAs(t, params(svc, &flexMockSTSService{}), body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
// TestSTSRequest_AssumeRole_PolicyScopedToRole proves the gate evaluates the
// target role ARN, not "*", so a grant on one role does not open another.
func TestSTSRequest_AssumeRole_PolicyScopedToRole(t *testing.T) {
roles := map[string]string{
stsTestRoleName: stsTestRoleARN,
"other": "arn:aws:iam::000000000000:role/other",
}
t.Run("granted role allowed", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, &flexMockSTSService{},
"Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1")
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("other role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, nil,
"Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/other&RoleSessionName=s1")
assertSTSAccessDenied(t, resp)
})
}
// TestSTSRequest_AssumeRole_PathIsResolvedNotTrusted pins the gate to the ARN
// IAM stored, so a role genuinely stored under a path is matched by its full
// ARN. An ARN that is not the stored one is refused by role resolution itself
@@ -251,43 +143,38 @@ func TestSTSRequest_AssumeRole_PathIsResolvedNotTrusted(t *testing.T) {
})
}
// TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence keeps the
// detailed denial message safe to return: it must read identically whether or
// not the role exists, which a lookup added ahead of the gate would break.
func TestSTSRequest_AssumeRole_DenialDoesNotDiscloseRoleExistence(t *testing.T) {
body := "Action=AssumeRole&RoleArn=" + stsTestRoleARN + "&RoleSessionName=s1"
// TestSTSRequest_AssumeRole_PolicyScopedToRole proves the gate evaluates the
// target role ARN, not "*", so a grant on one role does not open another.
func TestSTSRequest_AssumeRole_PolicyScopedToRole(t *testing.T) {
roles := map[string]string{
stsTestRoleName: stsTestRoleARN,
"other": "arn:aws:iam::000000000000:role/other",
}
existing := assertSTSAccessDenied(t, stsPolicyRequest(t, stsIdentityPolicy(), nil, body))
missing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, body))
t.Run("granted role allowed", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, &flexMockSTSService{},
"Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1")
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
assert.Equal(t, stripRequestID(existing), stripRequestID(missing))
assert.Contains(t, existing, "is not authorized to perform: sts:AssumeRole on resource: "+stsTestRoleARN)
// A pathed role is where the two can diverge: a resolved role names its
// stored ARN, so an unresolved one must name what the caller supplied
// rather than any form derived from it.
const pathed = "arn:aws:iam::000000000000:role/team/svc"
pathedBody := "Action=AssumeRole&RoleArn=" + pathed + "&RoleSessionName=s1"
pathedExisting := assertSTSAccessDenied(t, stsPolicyRequest(t,
stsIdentityPolicy().withRoles(map[string]string{"svc": pathed}), nil, pathedBody))
pathedMissing := assertSTSAccessDenied(t,
stsPolicyRequest(t, stsIdentityPolicy().withRoles(nil), nil, pathedBody))
assert.Equal(t, stripRequestID(pathedExisting), stripRequestID(pathedMissing))
assert.Contains(t, pathedExisting, "on resource: "+pathed)
t.Run("other role denied", func(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", stsTestRoleARN)).withRoles(roles)
resp := stsPolicyRequest(t, svc, nil,
"Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/other&RoleSessionName=s1")
assertSTSAccessDenied(t, resp)
})
}
// stripRequestID removes the per-request UUID so two error bodies can be
// compared for everything else.
func stripRequestID(body string) string {
start := strings.Index(body, "<RequestId>")
end := strings.Index(body, "</RequestId>")
if start < 0 || end < start {
return body
}
return body[:start] + body[end:]
// TestSTSRequest_GetCallerIdentity_NotGated pins the AWS contract: the action
// requires no permission and cannot be denied by policy.
func TestSTSRequest_GetCallerIdentity_NotGated(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Deny", "sts:*", "*"))
resp := stsPolicyRequest(t, svc, &flexMockSTSService{}, "Action=GetCallerIdentity")
require.Equal(t, http.StatusOK, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "GetCallerIdentityResult")
}
// TestSTSRequest_GetSessionToken_NotGated pins the AWS contract: it is an
@@ -310,14 +197,3 @@ func TestSTSRequest_GetSessionToken_NotGated(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.True(t, called)
}
// TestSTSRequest_GetCallerIdentity_NotGated pins the AWS contract: the action
// requires no permission and cannot be denied by policy.
func TestSTSRequest_GetCallerIdentity_NotGated(t *testing.T) {
svc := stsIdentityPolicy(stsStatement("Deny", "sts:*", "*"))
resp := stsPolicyRequest(t, svc, &flexMockSTSService{}, "Action=GetCallerIdentity")
require.Equal(t, http.StatusOK, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "GetCallerIdentityResult")
}
-41
View File
@@ -1,41 +0,0 @@
package gateway
import (
"net/http"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// stsRegistration is the STS registration over the IAMService and STSService
// gw holds, as the awsgw role composes it. Set them on gw first.
func stsRegistration(gw *GatewayConfig) dispatch.Registration {
return stsawsapi.NewRegistration(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
}
// withSTS registers STS alone on gw and composes its unsigned web-identity
// capability, as awsgw does.
func withSTS(gw *GatewayConfig) *GatewayConfig {
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
return withServices(gw, stsRegistration(gw))
}
// withIAMAndSTS registers IAM and STS together on gw, with the unsigned
// web-identity capability, as awsgw composes them.
func withIAMAndSTS(gw *GatewayConfig) *GatewayConfig {
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
return withServices(gw, iamRegistration(gw), stsRegistration(gw))
}
// STS_Request dispatches r to the registered STS service exactly as Request
// does, registering it first from the fields gw holds when the test has not.
func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withSTS(gw)
}
entry, ok := gw.registered(stsawsapi.ServiceName)
if !ok {
panic("STS is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
+195 -299
View File
@@ -2,126 +2,110 @@ package gateway
import (
"errors"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// flexMockSTSService is a configurable STSService mock with per-method
// overrides. It embeds the interface so it satisfies the full contract; only
// the fn-field methods below are wired, and any other method nil-panics if a
// test reaches an unmocked path.
type flexMockSTSService struct {
stsdomain.STSService
assumeRoleFn func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error)
getCallerIdentityFn func(string, string, string, *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)
getSessionTokenFn func(string, string, string, string, *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error)
lookupSessionFn func(string) (*stsdomain.SessionCredential, error)
assumeWebIdentityFn func(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error)
}
var _ stsdomain.STSService = (*flexMockSTSService)(nil)
func (m *flexMockSTSService) AssumeRole(callerAccountID, callerARN, callerIdentity string, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
if m.assumeRoleFn != nil {
return m.assumeRoleFn(callerAccountID, callerARN, callerIdentity, input)
func TestSTSRequest_AssumeRole_MissingRoleArn(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("AssumeRole should not be called when required params missing")
return nil, nil
},
}
return &sts.AssumeRoleOutput{}, nil
}
func (m *flexMockSTSService) GetCallerIdentity(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) {
if m.getCallerIdentityFn != nil {
return m.getCallerIdentityFn(callerAccountID, callerARN, callerUserID, input)
}
return &sts.GetCallerIdentityOutput{
Account: aws.String(callerAccountID),
Arn: aws.String(callerARN),
UserId: aws.String(callerUserID),
}, nil
}
func (m *flexMockSTSService) GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
if m.getSessionTokenFn != nil {
return m.getSessionTokenFn(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID, input)
}
return &sts.GetSessionTokenOutput{}, nil
}
func (m *flexMockSTSService) LookupSessionCredential(akid string) (*stsdomain.SessionCredential, error) {
if m.lookupSessionFn != nil {
return m.lookupSessionFn(akid)
}
return nil, nil
}
func (m *flexMockSTSService) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
if m.assumeWebIdentityFn != nil {
return m.assumeWebIdentityFn(input)
}
return nil, errors.New(awserrors.ErrorNotImplemented)
}
// stsRequestParams holds identity values the STS dispatcher reads from SigV4 context.
type stsRequestParams struct {
accountID string
identity string
principalType string
accessKey string
assumedRoleARN string
assumedRoleID string
// underlyingRoleARN is what the policy gate resolves an assumed-role
// caller's policies by; SessionName never participates.
underlyingRoleARN string
stsSvc stsdomain.STSService
iamSvc iamdomain.IAMService
}
func setupSTSRequestHandler(p stsRequestParams) http.Handler {
if p.iamSvc == nil {
p.iamSvc = &flexMockIAMService{}
}
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
STSService: p.stsSvc,
IAMService: p.iamSvc,
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
// An empty policy: a request missing RoleArn is malformed whatever the
// caller holds, so the gate must not answer it with a denial.
iamSvc: stsIdentityPolicy(),
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = reqctx.WithService(ctx, "sts")
ctx = withPrincipalField(ctx, fieldAccountID, p.accountID)
ctx = withPrincipalField(ctx, fieldIdentity, p.identity)
ctx = withPrincipalField(ctx, fieldPrincipalType, p.principalType)
ctx = withPrincipalField(ctx, fieldAccessKey, p.accessKey)
if p.assumedRoleARN != "" {
ctx = withPrincipalField(ctx, fieldAssumedRoleARN, p.assumedRoleARN)
}
if p.assumedRoleID != "" {
ctx = withPrincipalField(ctx, fieldAssumedRoleID, p.assumedRoleID)
// The door seals aws:userid as the assumed-role ID for a role session.
ctx = withPrincipalField(ctx, fieldUserID, p.assumedRoleID)
}
if p.underlyingRoleARN != "" {
ctx = withPrincipalField(ctx, fieldUnderlyingRoleARN, p.underlyingRoleARN)
}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=AssumeRole&RoleSessionName=s1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "MissingParameter")
}
// TestSTSRequest_AssumeRole_NilIAMService_InternalError pins the gate to the
// same fail-closed answer checkPolicyResources gives: the target-role lookup
// runs ahead of it, so an absent IAM service must not reach a nil interface.
func TestSTSRequest_AssumeRole_NilIAMService_InternalError(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("handler reached: an unavailable IAM service must fail closed")
return nil, nil
},
}
gw := withRealAuth(&GatewayConfig{DisableLogging: true, STSService: svc, IAMService: nil})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader("Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
func TestSTSRequest_AssumeRole_ServiceError_PropagatesAccessDenied(t *testing.T) {
// AccessDenied from the handler (trust-policy denial) must reach the wire as IAM ErrorResponse + 403.
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
return nil, errors.New(awserrors.ErrorAccessDenied)
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
iamSvc: stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", "*")),
})
body := "Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/app&RoleSessionName=s1"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 403, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "AccessDenied")
assert.Contains(t, string(b), "<ErrorResponse>")
}
func TestSTSRequest_AssumeRole_Success(t *testing.T) {
@@ -145,7 +129,7 @@ func TestSTSRequest_AssumeRole_Success(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
iamSvc: stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", "*")),
@@ -176,7 +160,7 @@ func TestSTSRequest_GetCallerIdentity_AssumedRole(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "s1",
principalType: principalTypeAssumedRole,
principalType: gwfix.PrincipalTypeAssumedRole,
accessKey: "ASIAEXAMPLE",
assumedRoleARN: "arn:aws:sts::000000000000:assumed-role/app/s1",
assumedRoleID: "AROAEXAMPLE:s1",
@@ -196,12 +180,54 @@ func TestSTSRequest_GetCallerIdentity_AssumedRole(t *testing.T) {
assert.Contains(t, xmlStr, "AROAEXAMPLE:s1")
}
// A user at a non-root path is reported with its stored ARN, as AWS does,
// resolved by the SigV4 middleware rather than reformatted at "/".
func TestSTSRequest_GetCallerIdentity_PathedUserThroughMiddleware(t *testing.T) {
const pathed = "arn:aws:iam::123456789012:user/eng/alice"
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
gw := withRealAuth(&GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
STSService: &flexMockSTSService{},
IAMService: &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey, SecretAccessKey: encryptedSecret,
UserName: "alice", AccountID: "123456789012", Status: "Active",
}},
GetUserFn: func(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{
UserName: aws.String("alice"), UserId: aws.String("AIDAALICE"), Arn: aws.String(pathed),
}}, nil
},
},
})
withServices(gw, stsRegistration(gw), ec2Registration(gw))
handler := gw.SigV4AuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
}))
body := []byte("Action=GetCallerIdentity")
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
gwfix.SignRequest(t, req, body, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(handler, req)
b, _ := io.ReadAll(resp.Body)
require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", string(b))
assert.Contains(t, string(b), "<Arn>"+pathed+"</Arn>")
}
func TestSTSRequest_GetCallerIdentity_RootShortcircuitsIAMLookup(t *testing.T) {
// Root short-circuits IAM lookup — flexMockIAMService.GetUser would cause InternalError otherwise.
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
@@ -218,6 +244,34 @@ func TestSTSRequest_GetCallerIdentity_RootShortcircuitsIAMLookup(t *testing.T) {
assert.Contains(t, xmlStr, "<UserId>000000000000</UserId>")
}
func TestSTSRequest_GetCallerIdentity_User_LookupIAM(t *testing.T) {
// User principal triggers IAMService.GetUser to resolve UserId.
iamSvc := &flexMockIAMService{
getUserFn: func(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{UserId: aws.String("AIDAALICE000")}}, nil
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: &flexMockSTSService{},
iamSvc: iamSvc,
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
require.Equal(t, 200, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
xmlStr := string(b)
assert.Contains(t, xmlStr, "<Arn>arn:aws:iam::000000000000:user/alice</Arn>")
assert.Contains(t, xmlStr, "<UserId>AIDAALICE000</UserId>")
}
func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
var got struct {
accountID string
@@ -245,7 +299,7 @@ func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
})
@@ -265,34 +319,16 @@ func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
// Verify forwarded identity fields and that checkPolicy did not block (STS_Request runs no checkPolicy pass).
assert.Equal(t, awsidentifiers.GlobalAccountID, got.accountID)
assert.Equal(t, "alice", got.userName)
assert.Equal(t, principalTypeUser, got.principalType)
assert.Equal(t, gwfix.PrincipalTypeUser, got.principalType)
assert.Equal(t, "AKIAEXAMPLE", got.accessKeyID)
assert.Equal(t, int64(3600), got.duration)
}
func TestSTSRequest_UnknownAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: principalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ThisDoesNotExist"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InvalidAction")
}
func TestSTSRequest_MissingAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: principalTypeUser,
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
@@ -306,183 +342,43 @@ func TestSTSRequest_MissingAction(t *testing.T) {
assert.Contains(t, string(b), "MissingAction")
}
func TestSTSRequest_AssumeRole_MissingRoleArn(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("AssumeRole should not be called when required params missing")
return nil, nil
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
// An empty policy: a request missing RoleArn is malformed whatever the
// caller holds, so the gate must not answer it with a denial.
iamSvc: stsIdentityPolicy(),
func TestSTSRequest_NilService_InternalError(t *testing.T) {
gw := withRealAuth(&GatewayConfig{DisableLogging: true, STSService: nil, IAMService: &flexMockIAMService{}})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, gwfix.PrincipalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=AssumeRole&RoleSessionName=s1"))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
func TestSTSRequest_UnknownAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "root",
principalType: gwfix.PrincipalTypeUser,
accessKey: "AKIAROOT",
stsSvc: &flexMockSTSService{},
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=ThisDoesNotExist"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 400, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "MissingParameter")
}
func TestSTSRequest_NilService_InternalError(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, STSService: nil, IAMService: &flexMockIAMService{}})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
// TestSTSRequest_AssumeRole_NilIAMService_InternalError pins the gate to the
// same fail-closed answer checkPolicyResources gives: the target-role lookup
// runs ahead of it, so an absent IAM service must not reach a nil interface.
func TestSTSRequest_AssumeRole_NilIAMService_InternalError(t *testing.T) {
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
t.Fatal("handler reached: an unavailable IAM service must fail closed")
return nil, nil
},
}
gw := withVerifier(&GatewayConfig{DisableLogging: true, STSService: svc, IAMService: nil})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := reqctx.WithService(r.Context(), "sts")
ctx = withPrincipalField(ctx, fieldAccountID, awsidentifiers.GlobalAccountID)
ctx = withPrincipalField(ctx, fieldIdentity, "alice")
ctx = withPrincipalField(ctx, fieldPrincipalType, principalTypeUser)
ctx = withPrincipalField(ctx, fieldAccessKey, "AKIAEXAMPLE")
r = r.WithContext(ctx)
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
})
req := httptest.NewRequest(http.MethodPost, "/",
strings.NewReader("Action=AssumeRole&RoleArn="+stsTestRoleARN+"&RoleSessionName=s1"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 500, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "InternalError")
}
func TestSTSRequest_AssumeRole_ServiceError_PropagatesAccessDenied(t *testing.T) {
// AccessDenied from the handler (trust-policy denial) must reach the wire as IAM ErrorResponse + 403.
svc := &flexMockSTSService{
assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
return nil, errors.New(awserrors.ErrorAccessDenied)
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: svc,
iamSvc: stsIdentityPolicy(stsStatement("Allow", "sts:AssumeRole", "*")),
})
body := "Action=AssumeRole&RoleArn=arn:aws:iam::000000000000:role/app&RoleSessionName=s1"
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 403, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(b), "AccessDenied")
assert.Contains(t, string(b), "<ErrorResponse>")
}
func TestSTSRequest_GetCallerIdentity_User_LookupIAM(t *testing.T) {
// User principal triggers IAMService.GetUser to resolve UserId.
iamSvc := &flexMockIAMService{
getUserFn: func(_ string, _ *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{UserId: aws.String("AIDAALICE000")}}, nil
},
}
handler := setupSTSRequestHandler(stsRequestParams{
accountID: awsidentifiers.GlobalAccountID,
identity: "alice",
principalType: principalTypeUser,
accessKey: "AKIAEXAMPLE",
stsSvc: &flexMockSTSService{},
iamSvc: iamSvc,
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
require.Equal(t, 200, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
xmlStr := string(b)
assert.Contains(t, xmlStr, "<Arn>arn:aws:iam::000000000000:user/alice</Arn>")
assert.Contains(t, xmlStr, "<UserId>AIDAALICE000</UserId>")
}
// A user at a non-root path is reported with its stored ARN, as AWS does,
// resolved by the SigV4 middleware rather than reformatted at "/".
func TestSTSRequest_GetCallerIdentity_PathedUserThroughMiddleware(t *testing.T) {
const pathed = "arn:aws:iam::123456789012:user/eng/alice"
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
STSService: &flexMockSTSService{},
IAMService: &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{testAccessKey: {
AccessKeyID: testAccessKey, SecretAccessKey: encryptedSecret,
UserName: "alice", AccountID: "123456789012", Status: "Active",
}},
getUserFn: func(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
return &iam.GetUserOutput{User: &iam.User{
UserName: aws.String("alice"), UserId: aws.String("AIDAALICE"), Arn: aws.String(pathed),
}}, nil
},
},
})
withServices(gw, stsRegistration(gw), ec2Registration(gw))
handler := gw.SigV4AuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := gw.STS_Request(w, r); err != nil {
gw.ErrorHandler(w, r, err)
}
}))
body := []byte("Action=GetCallerIdentity")
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.Host = "localhost:9999"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
signTestRequest(t, req, body, testAccessKey, testSecretKey)
resp := doRequest(handler, req)
b, _ := io.ReadAll(resp.Body)
require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", string(b))
assert.Contains(t, string(b), "<Arn>"+pathed+"</Arn>")
assert.Contains(t, string(b), "InvalidAction")
}
@@ -1,226 +1,20 @@
// Characterization tests for Resource Groups Tagging API dispatch as it exists
// in tagging.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package gateway
import (
"bytes"
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/domains/tagging"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
const taggingTargetPrefix = "ResourceGroupsTaggingAPI_20170126."
// fakeTaggedLister serves fixed resources and counts calls, so a denial can be
// proven to short-circuit before any lister runs.
type fakeTaggedLister struct {
resources []tagging.TaggedResource
err error
calls int
}
func (f *fakeTaggedLister) ListTagged(_ context.Context, _ string, _ map[string]bool) ([]tagging.TaggedResource, error) {
f.calls++
return f.resources, f.err
}
// taggingPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does,
// on live test NATS, with docs as every principal's policy.
func taggingPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, agg *tagging.Aggregator) (*GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&GatewayConfig{
DisableLogging: true,
Region: testRegion,
NATSConn: nc,
IAMService: &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withTagging(gw, agg)
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
func allowAllTaggingDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
func scopedTaggingDocs(effect, action, resource string) []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: effect, Action: iamdomain.StringOrArr{action}, Resource: iamdomain.StringOrArr{resource}},
},
}}
}
// signTaggingRequest builds a POST / signed with credential-scope service
// "tagging", the shape every Resource Groups Tagging API client sends.
func signTaggingRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, "tagging", time.Now().UTC())
return req
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(b)
}
func taggingFixture() (*fakeTaggedLister, *fakeTaggedLister) {
elb := &fakeTaggedLister{resources: []tagging.TaggedResource{
{ARN: "arn:aws:elasticloadbalancing:" + testRegion + ":" + authzAccountID + ":loadbalancer/app/web/1", Tags: map[string]string{"env": "prod", "team": "a"}},
}}
ec2 := &fakeTaggedLister{resources: []tagging.TaggedResource{
{ARN: "arn:aws:ec2:" + testRegion + ":" + authzAccountID + ":instance/i-1", Tags: map[string]string{"env": "dev"}},
{ARN: "arn:aws:ec2:" + testRegion + ":" + authzAccountID + ":volume/vol-1"},
}}
return elb, ec2
}
// --- Full-pipeline error envelopes ---------------------------------------
func TestTaggingPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
assert.Equal(t, "MissingActionException", decodeJSONError(t, resp).Type)
}
func TestTaggingPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"TagResources", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
assert.Equal(t, "InvalidActionException", decodeJSONError(t, resp).Type)
}
// A malformed GetResources body reaches the aggregator and fails there.
func TestTaggingPipeline_MalformedBody_ErrorEnvelope(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{not json")))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, resp.Header.Get("X-Amzn-Errortype"), body.Type)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, "InvalidParameterValueException", body.Type)
}
// --- Golden GetResources responses ----------------------------------------
func TestTaggingPipeline_GetResources_GoldenAllResources(t *testing.T) {
elb, ec2 := taggingFixture()
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(elb, ec2))
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, tagging.JSONContentType, resp.Header.Get("Content-Type"))
// Results come back sorted by ARN, not in lister order.
assert.JSONEq(t, `{"PaginationToken":"","ResourceTagMappingList":[`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:instance/i-1","Tags":[{"Key":"env","Value":"dev"}]},`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:volume/vol-1","Tags":[]},`+
`{"ResourceARN":"arn:aws:elasticloadbalancing:`+testRegion+`:`+authzAccountID+`:loadbalancer/app/web/1","Tags":[{"Key":"env","Value":"prod"},{"Key":"team","Value":"a"}]}]}`,
readBody(t, resp))
}
func TestTaggingPipeline_GetResources_GoldenTagFilter(t *testing.T) {
elb, ec2 := taggingFixture()
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(elb, ec2))
body := []byte(`{"TagFilters":[{"Key":"env","Values":["dev"]}]}`)
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", body))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.JSONEq(t, `{"PaginationToken":"","ResourceTagMappingList":[`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:instance/i-1","Tags":[{"Key":"env","Value":"dev"}]}]}`,
readBody(t, resp))
}
func TestTaggingPipeline_GetResources_GoldenPagination(t *testing.T) {
elb, ec2 := taggingFixture()
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(elb, ec2))
body := []byte(`{"ResourcesPerPage":1}`)
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", body))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.JSONEq(t, `{"PaginationToken":"1","ResourceTagMappingList":[`+
`{"ResourceARN":"arn:aws:ec2:`+testRegion+`:`+authzAccountID+`:instance/i-1","Tags":[{"Key":"env","Value":"dev"}]}]}`,
readBody(t, resp))
}
// A lister error surfaces with its own awserrors code through the envelope.
func TestTaggingPipeline_GetResources_ListerErrorPropagates(t *testing.T) {
failing := &fakeTaggedLister{err: errors.New(awserrors.ErrorServerInternal)}
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator(failing))
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "ServerInternalException", decodeJSONError(t, resp).Type)
}
// --- Target parsing and telemetry ------------------------------------------
func TestTaggingActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
cases := []struct {
name, target, wantAction string
@@ -266,145 +60,3 @@ func TestTaggingPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T
assert.Equal(t, "GetResources", got["aws.action"])
assert.Equal(t, "tagging", got["aws.service"])
}
func TestTaggingPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := taggingPipelineApp(t, allowAllTaggingDocs(), tagging.NewAggregator())
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
doRequest(r, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "GetResources", auditField(got, "action"))
}
// --- Oversized body over HTTP ----------------------------------------------
func TestTaggingPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(testAccessKey, testSecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", taggingTargetPrefix+"GetResources")
signTestRequestAs(t, req, oversized, testAccessKey, testSecretKey, "tagging", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "RequestEntityTooLargeException", decodeJSONError(t, resp).Type)
}
// --- Unavailable dependencies -----------------------------------------------
// A nil NATS connection fails at the request-level gate, before dispatch.
func TestRequest_ClusterUnavailableNilConn_Tagging(t *testing.T) {
gw := withTagging(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), tagging.NewAggregator())
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", taggingTargetPrefix+"GetResources")
req = req.WithContext(reqctx.WithService(req.Context(), "tagging"))
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "ServiceUnavailableException", decodeJSONError(t, resp).Type)
}
// A connected gateway with no aggregator wired answers ServerInternal.
func TestTaggingPipeline_NoAggregator_500ServerInternal(t *testing.T) {
_, app := taggingPipelineApp(t, allowAllTaggingDocs(), nil)
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "ServerInternalException", decodeJSONError(t, resp).Type)
}
// --- Authorization ----------------------------------------------------------
// Every tagging action is account-level: a grant scoped to a resource ARN does
// not reach it, and a wildcard grant does.
func TestTaggingPipeline_Authz_ScopedAllowDenied_WildcardPermitted(t *testing.T) {
scoped := scopedTaggingDocs("Allow", "tagging:GetResources", "arn:aws:ec2:"+testRegion+":"+authzAccountID+":instance/i-dev")
lister := &fakeTaggedLister{}
_, app := taggingPipelineApp(t, scoped, tagging.NewAggregator(lister))
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, "AccessDeniedException", decodeJSONError(t, resp).Type)
assert.Zero(t, lister.calls, "the aggregator must not run once the gate denies")
_, app = taggingPipelineApp(t, scopedTaggingDocs("Allow", "tagging:GetResources", "*"), tagging.NewAggregator())
resp = doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// A Deny scoped to a resource ARN must not fire: every tagging action is
// evaluated against "*".
func TestTaggingPipeline_Authz_ScopedDenyDoesNotFire(t *testing.T) {
docs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"tagging:*"}, Resource: iamdomain.StringOrArr{"*"}},
{Effect: "Deny", Action: iamdomain.StringOrArr{"tagging:GetResources"}, Resource: iamdomain.StringOrArr{"arn:aws:ec2:" + testRegion + ":" + authzAccountID + ":instance/i-prod"}},
},
}}
_, app := taggingPipelineApp(t, docs, tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
// An unrelated action grant does not carry to GetResources, and an explicit
// Deny on the action wins over a wildcard Allow.
func TestTaggingPipeline_Authz_UnrelatedGrantAndExplicitDeny(t *testing.T) {
_, app := taggingPipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
docs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
{Effect: "Deny", Action: iamdomain.StringOrArr{"tagging:GetResources"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
_, app = taggingPipelineApp(t, docs, tagging.NewAggregator())
resp = doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte("{}")))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
// An unknown action is rejected before authorization: with no grant at all it
// still answers InvalidAction rather than AccessDenied.
func TestTaggingPipeline_Authz_UnknownActionRejectedBeforeGate(t *testing.T) {
_, app := taggingPipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"TagResources", []byte("{}")))
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, "InvalidActionException", decodeJSONError(t, resp).Type)
}
// A denied request is answered AccessDenied even when its body is also bad:
// the gate runs before the body is read.
func TestTaggingPipeline_Authz_DenyPrecedesBodyHandling(t *testing.T) {
_, app := taggingPipelineApp(t, scopedTaggingDocs("Allow", "ec2:DescribeTags", "*"), tagging.NewAggregator())
resp := doRequest(app, signTaggingRequest(t, taggingTargetPrefix+"GetResources", []byte(strings.Repeat("{", 8))))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, "AccessDeniedException", decodeJSONError(t, resp).Type)
}
@@ -1,89 +1,14 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helper setupEC2Request.
package gateway
import (
"context"
"fmt"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
)
// The TestCurrentBehaviour_Tagging_ tests here pin today's EC2 tag-write
// outcomes at the AWSGW door. They drive the real router over the real EC2 tag
// service on an embedded JetStream, and describe current behaviour, not the
// target design. The daemon's record-mirror fan-out is not wired here, so only
// the central tag index is exercised.
// newTaggingCBGateway serves ec2.CreateTags from a real TagsServiceImpl
// over a fresh JetStream bucket, with no legacy object store.
func newTaggingCBGateway(t *testing.T) (*GatewayConfig, *ec2tags.TagsServiceImpl) {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
kv, err := ec2tags.GetOrCreateTagsBucket(t.Context(), js)
require.NoError(t, err)
svc := ec2tags.NewTagsServiceImplWithStore(&config.Config{}, nil, kv)
serve := func(subject string, fn func(*nats.Msg)) {
sub, err := nc.Subscribe(subject, fn)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
serve("ec2.CreateTags", func(msg *nats.Msg) {
natsmsg.ServeNATSRequestCtx(msg, func(ctx context.Context, in *ec2.CreateTagsInput) (*ec2.CreateTagsOutput, error) {
return svc.CreateTags(ctx, in, natsmsg.AccountIDFromMsg(msg))
})
})
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService(), NATSConn: nc})
return gw, svc
}
// ec2TagQuery builds a CreateTags or DeleteTags query body.
func ec2TagQuery(action string, resources []string, tags map[string]string) string {
q := url.Values{"Action": {action}}
for i, r := range resources {
q.Set(fmt.Sprintf("ResourceId.%d", i+1), r)
}
i := 0
for k, v := range tags {
i++
q.Set(fmt.Sprintf("Tag.%d.Key", i), k)
q.Set(fmt.Sprintf("Tag.%d.Value", i), v)
}
return q.Encode()
}
func requireEC2OK(t *testing.T, gw *GatewayConfig, body string) {
t.Helper()
w := serveEC2(gw, body)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
func centralTagCount(t *testing.T, svc *ec2tags.TagsServiceImpl, resourceID string) int {
t.Helper()
out, err := svc.DescribeTags(t.Context(), &ec2.DescribeTagsInput{
Filters: []*ec2.Filter{{Name: aws.String("resource-id"), Values: []*string{aws.String(resourceID)}}},
}, authzAccountID)
require.NoError(t, err)
return len(out.Tags)
}
// CreateTags refuses an aws: key but applies no tag-count, key-length or
// value-length limit: 51 tags, a 129-character key and a 257-character value
// are all stored.
+5 -5
View File
@@ -1,10 +1,10 @@
package gateway
import "github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
import "github.com/mulgadc/spinifex/internal/testkit/gwfix"
// withVerifier authenticates through the same IAM and STS instances the config
// holds, so a test that changes one of them changes authentication too.
// withVerifier authenticates through the IAM service the config holds, so a
// test that changes it changes authentication too.
func withVerifier(gw *GatewayConfig) *GatewayConfig {
gw.Credentials = credverify.New(gw.IAMService, gw.STSService)
return gw
gw.Credentials = &gwfix.Verifier{IAM: gw.IAMService}
return withFakeCapabilities(gw)
}
@@ -0,0 +1,276 @@
// Characterization tests for ACM control-plane dispatch as it exists in
// acm.go today, pinned ahead of moving dispatch out of gateway into a
// registration seam. These assert current behaviour, not AWS parity.
package awsgw_test
import (
"bytes"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// acmPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS, with docs as every principal's policy. The region is
// gwfix.Region because gwfix.SignRequestAs always signs for it.
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*gateway.GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
NATSConn: nc,
IAMService: &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
Status: "Active",
},
},
},
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withACM(gw, acmawsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllACMDocs is the policy acmPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllACMDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signACMRequest builds a POST / signed with credential-scope service "acm",
// the shape every ACM JSON 1.1 client sends.
func signACMRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, "acm", time.Now().UTC())
return req
}
// subscribeACMCounter answers subject with payload and counts invocations, so
// a denial can be proven to short-circuit before the handler ever runs.
func subscribeACMCounter(t *testing.T, nc *nats.Conn, subject string, payload []byte) *int {
t.Helper()
calls := 0
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
calls++
_ = msg.Respond(payload)
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
t.Cleanup(func() { _ = sub.Unsubscribe() })
return &calls
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestACMPipeline_MissingTarget_400MissingActionException pins that an ACM
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestACMPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
resp := doRequest(app, signACMRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestACMPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from acmActions fails with InvalidAction.
func TestACMPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := acmPipelineApp(t, allowAllACMDocs())
target := "CertificateManager.MadeUpAction"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestACMPipeline_ListCertificates_200JSONContentType pins that a successful
// ACM action answers 200 with the AWS JSON 1.1 content type through the full
// pipeline, once the daemon-side NATS responder answers.
func TestACMPipeline_ListCertificates_200JSONContentType(t *testing.T) {
gw, app := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
target := "CertificateManager.ListCertificates"
resp := doRequest(app, signACMRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, acmawsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// --- Oversized body over HTTP ---------------------------------------------
// TestACMPipeline_OversizedBody_413OverHTTP pins that an ACM-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ACM dispatch ever runs, with the JSON error envelope.
func TestACMPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", "CertificateManager.ListCertificates")
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, "acm", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before the NATS-backed action ----------------------
// TestACMPipeline_AuthzGateBlocksAction pins that the policy gate in ACM
// dispatch runs, and denies, before the NATS-backed action handler is ever
// invoked, through the full pipeline.
func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"acm:DeleteCertificate"},
Resource: iamdomain.StringOrArr{"arn:aws:acm:" + gwfix.Region + ":" + authzAccountID + ":certificate/other"},
},
},
}}
gw, app := acmPipelineApp(t, scopedDocs)
calls := subscribeACMCounter(t, gw.NATSConn, "acm.DeleteCertificate", []byte(`{}`))
target := "CertificateManager.DeleteCertificate"
body := []byte(`{"CertificateArn":"arn:aws:acm:` + gwfix.Region + `:` + authzAccountID + `:certificate/prod-1111"}`)
resp := doRequest(app, signACMRequest(t, target, body))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
respBody := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", respBody.Type)
assert.Equal(t, 0, *calls, "the NATS-backed handler must not run once the gate denies")
}
// --- NATS unavailable for ACM ---------------------------------------------
// TestRequest_ClusterUnavailableNilConn_ACM pins that ACM fails at the
// request-level NATS gate before dispatch reads the action, so ACM's own
// nil-connection check is unreachable over HTTP.
func TestRequest_ClusterUnavailableNilConn_ACM(t *testing.T) {
for _, action := range []string{"ListCertificates", "DeleteCertificate"} {
t.Run(action, func(t *testing.T) {
gw := withACM(withVerifier(&gateway.GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), acmawsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", "CertificateManager."+action)
ctx := reqctx.WithService(req.Context(), "acm")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ACM request ---------------------------------
// TestACMPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ACM action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestACMPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := acmPipelineApp(t, allowAllACMDocs())
subscribeACMCounter(t, gw.NATSConn, "acm.ListCertificates", []byte(`{}`))
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := "CertificateManager.ListCertificates"
doRequest(r, signACMRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListCertificates", auditField(got, "action"))
}
// --- Inventory pin ----------------------------------------------------------
// TestACMInventory_RegisteredArePinned pins the ACM inventory so a refactor
// that drops or adds an action, including by moving ACM onto the
// registration seam, fails here.
func TestACMInventory_RegisteredArePinned(t *testing.T) {
acm := gateway.AWSOperationInventory(acmRegistrationInventory())["acm"]
want := []string{
"ImportCertificate", "RequestCertificate", "DescribeCertificate", "GetCertificate",
"ListCertificates", "DeleteCertificate", "ListTagsForCertificate", "AddTagsToCertificate",
"RemoveTagsFromCertificate",
}
assert.ElementsMatch(t, want, acm.Registered)
assert.Empty(t, acm.Stubbed)
assert.Empty(t, acm.Unsupported)
}
@@ -1,13 +1,14 @@
package gateway
package awsgw_test
import (
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// withACM registers the ACM control plane on gw backed by deps, as the awsgw
// role composes it. An unset deps.NATS takes gw.NATSConn.
func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
func withACM(gw *gateway.GatewayConfig, deps acmawsapi.Deps) *gateway.GatewayConfig {
if deps.NATS == nil {
deps.NATS = gw.NATSConn
}
@@ -22,5 +23,5 @@ func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
// acmRegistrationInventory is the inventory the ACM registration declares to
// a registry, keyed as AWSOperationInventory expects.
func acmRegistrationInventory() map[string]dispatch.Inventory {
return withACM(&GatewayConfig{}, acmawsapi.Deps{}).Services.Inventory()
return withACM(&gateway.GatewayConfig{}, acmawsapi.Deps{}).Services.Inventory()
}
@@ -0,0 +1,21 @@
package awsgw_test
//test:in-package — the status gate is an unexported step of the auth
// middleware, and its cache is deliberately not part of the package's API.
import (
"net/http"
"testing"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
)
// Until this gate existed, account status was enforced only in ECR's principal
// resolution, so suspending an account left EC2, S3 and STS wide open.
func TestAuthDeniesASuspendedAccount(t *testing.T) {
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusSuspended))
if resp := doRequest(r, req); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a SUSPENDED account, got %d", resp.StatusCode)
}
}
@@ -0,0 +1,439 @@
package awsgw_test
//test:in-package — this characterization drives unexported gateway wiring.
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
)
// Pins CURRENT behaviour: wire outcomes for invalid long-lived credentials, and
// that key status changes apply to the very next request (no credential cache).
func TestCurrentBehaviour_Auth_LongLivedRejections(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
gci := cbForm("GetCallerIdentity")
t.Run("unknown_key", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), got)
})
t.Run("wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{akid: e.alice.akid, secret: cbBadSecret}, "sts", gci))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
t.Run("malformed_authorization_header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(gci.Encode()))
req.RemoteAddr = "10.200.0.1:40000"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=garbage")
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
assert.Equal(t, cbRegistry(awserrors.ErrorIncompleteSignature), cbOutcomeOf(rec))
})
t.Run("key_made_inactive_after_use", func(t *testing.T) {
creds := e.newKey(t, "alice")
require.Equal(t, http.StatusOK, e.callerIdentity(t, creds).Code)
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(creds.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, creds)))
})
t.Run("key_deleted_after_use", func(t *testing.T) {
u, _ := e.newUser(t, "dave")
require.Equal(t, http.StatusOK, e.callerIdentity(t, u).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("dave"), AccessKeyId: aws.String(u.akid),
})
require.NoError(t, err)
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, u)))
})
}
// Pins CURRENT behaviour: AssumeRole session credentials at the SigV4 door.
func TestCurrentBehaviour_Session_AssumeRoleCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, roleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
other := e.assumeRole(t, roleARN, "s2")
t.Run("valid_session", func(t *testing.T) {
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
body := rec.Body.String()
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/app/s1", cbXML(body, "Arn"))
assert.Equal(t, roleID+":s1", cbXML(body, "UserId"))
assert.Equal(t, e.accountID, cbXML(body, "Account"))
})
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
for name, c := range map[string]cbCreds{
"missing_token": {akid: s.akid, secret: s.secret},
"other_sessions_token": {akid: s.akid, secret: s.secret, token: other.token},
"tampered_token": {akid: s.akid, secret: s.secret, token: tamper(s.token)},
"unknown_session_akid": {akid: cbUnknownSes, secret: s.secret, token: s.token},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
// CURRENT: an AKIA key ignores X-Amz-Security-Token entirely; the header is
// only signed, never checked, so a long-lived key plus any token succeeds.
t.Run("long_lived_with_session_token", func(t *testing.T) {
rec := e.callerIdentity(t, cbCreds{akid: e.alice.akid, secret: e.alice.secret, token: s.token})
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
})
t.Run("valid_token_wrong_secret", func(t *testing.T) {
got := cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: cbBadSecret, token: s.token}))
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch), got)
})
}
// Pins CURRENT behaviour: an expired session answers ExpiredToken (HTTP 400)
// before its session token is examined.
func TestCurrentBehaviour_Session_Expired(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
roleARN, _ := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
e.expireSession(t, s.akid)
// CURRENT: ExpiredToken is 400 here; AWS STS/IAM answer it as 403 (unverified).
expired := cbRegistry(awserrors.ErrorExpiredToken)
require.Equal(t, http.StatusBadRequest, expired.status)
for name, c := range map[string]cbCreds{
"valid_token": s,
// CURRENT: expiry is checked ahead of the token, so a caller holding only
// the cleartext ASIA key ID learns the session has expired.
"no_token": {akid: s.akid, secret: s.secret},
"wrong_token": {akid: s.akid, secret: cbBadSecret, token: tamper(s.token)},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, expired, cbOutcomeOf(e.callerIdentity(t, c)))
})
}
}
// Pins CURRENT behaviour: a GetSessionToken session resolves to the IAM user and
// outlives the long-lived key that minted it.
func TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
mintKey := e.newKey(t, "alice")
s := e.getSessionToken(t, mintKey)
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:iam::"+e.accountID+":user/alice", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, e.aliceID, cbXML(rec.Body.String(), "UserId"))
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid),
})
require.NoError(t, err)
// CURRENT: the session record carries no reference to the minting key, so
// deactivating and deleting that key leaves the session authenticating.
assert.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
// A session cannot mint a further session.
assert.Equal(t, cbRegistry(awserrors.ErrorAccessDenied).code,
cbOutcomeOf(e.request(t, e.h, s, "sts", cbForm("GetSessionToken"))).code)
}
// Pins CURRENT behaviour: an AssumeRoleWithWebIdentity session authenticates at
// the SigV4 door exactly as an AssumeRole session does.
func TestCurrentBehaviour_Session_WebIdentityCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
issuer := testDiscIssuer
iamKV, err := kvutil.GetOrCreateBucket(t.Context(), e.js,
iamdomain.IAMAccountBucketName(e.accountID), iamdomain.KVBucketIAMAccountVersion)
require.NoError(t, err)
_, err = iamKV.Put(t.Context(), iamdomain.OIDCProviderKey(issuer), []byte(`{"registered":true}`))
require.NoError(t, err)
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
point, err := priv.PublicKey.Bytes()
require.NoError(t, err)
jwks, err := json.Marshal(stsdomain.JWKS{Keys: []stsdomain.JWK{{
Kty: "EC", Kid: "k1", Alg: "ES256", Use: "sig", Crv: "P-256",
X: base64.RawURLEncoding.EncodeToString(point[1:33]),
Y: base64.RawURLEncoding.EncodeToString(point[33:65]),
}}})
require.NoError(t, err)
eksKV, err := ekscluster.GetOrCreateAccountBucket(t.Context(), e.js, e.accountID)
require.NoError(t, err)
_, err = eksKV.Put(t.Context(), ekscluster.OIDCJWKSKey(testDiscCluster), jwks)
require.NoError(t, err)
federated := iamdomain.OIDCProviderARN(e.accountID, strings.TrimPrefix(issuer, "https://"))
role, err := e.iam.CreateRole(e.accountID, &iam.CreateRoleInput{
RoleName: aws.String("irsa"),
AssumeRolePolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"` +
federated + `"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`),
})
require.NoError(t, err)
now := time.Now().UTC()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": issuer, "sub": "system:serviceaccount:default:sa", "aud": []string{"sts.amazonaws.com"},
"exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(),
})
tok.Header["kid"] = "k1"
signed, err := tok.SignedString(priv)
require.NoError(t, err)
// Unsigned: the anonymous STS interceptor serves it ahead of SigV4.
s := cbSessionFrom(t, e.request(t, e.h, cbCreds{}, "sts", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", aws.StringValue(role.Role.Arn), "RoleSessionName", "pod", "WebIdentityToken", signed)))
rec := e.callerIdentity(t, s)
require.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, "arn:aws:sts::"+e.accountID+":assumed-role/irsa/pod", cbXML(rec.Body.String(), "Arn"))
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId),
cbOutcomeOf(e.callerIdentity(t, cbCreds{akid: s.akid, secret: s.secret, token: tamper(s.token)})))
}
// Pins CURRENT behaviour: deleting a user and recreating it under the same name
// strands its old key and sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_UserDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
oldKey, oldID := e.newUser(t, "bob")
session := e.getSessionToken(t, oldKey)
require.Equal(t, http.StatusOK, e.callerIdentity(t, session).Code)
_, err := e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(oldKey.akid),
})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("bob")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, session.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "session after delete")
newKey, newID := e.newUser(t, "bob")
require.NotEqual(t, oldID, newID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, oldKey)), "old key after recreate")
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, session)), "old session after recreate")
rec := e.callerIdentity(t, newKey)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, newID, cbXML(rec.Body.String(), "UserId"))
})
}
}
// Pins CURRENT behaviour: deleting a role and recreating it under the same name
// strands its old sessions, by revocation or by the continuity check.
func TestCurrentBehaviour_Revocation_RoleDeletedAndRecreated(t *testing.T) {
for _, tc := range []struct {
name string
noRevoker bool
storedAfter bool
}{
{name: "revoker_installed", noRevoker: false, storedAfter: false},
{name: "revocation_missed", noRevoker: true, storedAfter: true},
} {
t.Run(tc.name, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{noRevoker: tc.noRevoker})
roleARN, oldRoleID := e.createAssumableRole(t, "app")
s := e.assumeRole(t, roleARN, "s1")
require.Equal(t, http.StatusOK, e.callerIdentity(t, s).Code)
_, err := e.iam.DeleteRole(e.accountID, &iam.DeleteRoleInput{RoleName: aws.String("app")})
require.NoError(t, err)
assert.Equal(t, tc.storedAfter, e.sessionStored(t, s.akid))
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "session after delete")
newARN, newRoleID := e.createAssumableRole(t, "app")
require.Equal(t, roleARN, newARN)
require.NotEqual(t, oldRoleID, newRoleID)
assert.Equal(t, invalid, cbOutcomeOf(e.callerIdentity(t, s)), "old session after recreate")
assert.Equal(t, http.StatusOK, e.callerIdentity(t, e.assumeRole(t, newARN, "s2")).Code)
})
}
}
// Pins CURRENT behaviour: whose identity policy the policy check applies to an
// orphaned key once its user is deleted and recreated under the same name.
func TestCurrentBehaviour_Revocation_OrphanedKeyPermissionsAfterRecreation(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
key, oldID := e.newUser(t, "frank")
listUsers := cbForm("ListUsers")
getUser := cbForm("GetUser", "UserName", "frank")
// Policy A: ListUsers only.
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`)
require.Equal(t, http.StatusOK, e.request(t, e.h, key, "iam", listUsers).Code, "A allows ListUsers")
require.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", getUser)).code, "A denies GetUser")
e.orphanKeys(t, "frank")
_, err := e.iam.DeleteUserPolicy(e.accountID, &iam.DeleteUserPolicyInput{UserName: aws.String("frank"), PolicyName: aws.String("p")})
require.NoError(t, err)
_, err = e.iam.DeleteUser(e.accountID, &iam.DeleteUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
// Policy B, on the replacement user: GetUser only.
u, err := e.iam.CreateUser(e.accountID, &iam.CreateUserInput{UserName: aws.String("frank")})
require.NoError(t, err)
require.NotEqual(t, oldID, aws.StringValue(u.User.UserId))
e.putUserPolicy(t, "frank", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
// CURRENT: the orphaned key is evaluated against the replacement user's
// policy B, so it gains GetUser and loses the ListUsers that A granted.
rec := e.request(t, e.h, key, "iam", getUser)
assert.Equalf(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, awserrors.ErrorAccessDenied, cbOutcomeOf(e.request(t, e.h, key, "iam", listUsers)).code)
}
// Pins CURRENT behaviour: unsigned requests are refused with
// MissingAuthenticationToken unless the action is anonymous STS.
func TestCurrentBehaviour_Auth_MissingCredentials(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
missing := cbRegistry(awserrors.ErrorMissingAuthenticationToken)
for _, f := range []url.Values{
cbForm("ListUsers"),
cbForm("GetCallerIdentity"),
cbForm("GetSessionToken"),
cbForm("AssumeRole", "RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s"),
} {
t.Run(f.Get("Action"), func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", f)))
})
}
// Session token alone, with no Authorization header.
t.Run("token_without_signature", func(t *testing.T) {
assert.Equal(t, missing, cbOutcomeOf(e.request(t, e.h, cbCreds{token: "anything"}, "", cbForm("GetCallerIdentity"))))
})
// The anonymous path reaches STS itself: its verdict, not the door's.
t.Run("AssumeRoleWithWebIdentity", func(t *testing.T) {
got := cbOutcomeOf(e.request(t, e.h, cbCreds{}, "", cbForm("AssumeRoleWithWebIdentity",
"RoleArn", "arn:aws:iam::"+e.accountID+":role/x", "RoleSessionName", "s1", "WebIdentityToken", "not-a-jwt")))
assert.Equal(t, http.StatusBadRequest, got.status)
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, got.code)
})
}
// Pins CURRENT behaviour: which credential failures a caller can tell apart on
// the wire, and whether the door decrypted a secret before answering.
func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
bobKey, _ := e.newUser(t, "bob")
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
UserName: aws.String("bob"), AccessKeyId: aws.String(bobKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
})
require.NoError(t, err)
carolKey, _ := e.newUser(t, "carol")
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{UserName: aws.String("carol"), AccessKeyId: aws.String(carolKey.akid)})
require.NoError(t, err)
live := e.getSessionToken(t, e.alice)
expired := e.getSessionToken(t, e.alice)
e.expireSession(t, expired.akid)
invalid := cbRegistry(awserrors.ErrorInvalidClientTokenId)
sigMismatch := cbRegistry(awserrors.ErrorSignatureDoesNotMatch)
for _, tc := range []struct {
name string
creds cbCreds
want cbOutcome
decrypts int32
}{
{"unknown_key", cbCreds{akid: cbUnknownKey, secret: cbBadSecret}, invalid, 0},
{"unknown_prefix", cbCreds{akid: "AKIBCBUNKNOWNPREFIXA", secret: cbBadSecret}, invalid, 0},
{"deleted_key_right_secret", carolKey, invalid, 0},
{"inactive_key_right_secret", bobKey, invalid, 0},
// CURRENT: an inactive key answers InvalidClientTokenId without a
// signature check, while an active key with a bad signature answers
// SignatureDoesNotMatch, so a caller with no secret can tell the two apart.
{"inactive_key_bad_signature", cbCreds{akid: bobKey.akid, secret: cbBadSecret}, invalid, 0},
{"active_key_bad_signature", cbCreds{akid: e.alice.akid, secret: cbBadSecret}, sigMismatch, 1},
{"unknown_session", cbCreds{akid: cbUnknownSes, secret: cbBadSecret, token: live.token}, invalid, 0},
{"session_no_token", cbCreds{akid: live.akid, secret: cbBadSecret}, invalid, 0},
{"session_wrong_token", cbCreds{akid: live.akid, secret: cbBadSecret, token: tamper(live.token)}, invalid, 0},
{"session_expired_no_token", cbCreds{akid: expired.akid, secret: cbBadSecret}, cbRegistry(awserrors.ErrorExpiredToken), 0},
{"session_right_token_bad_signature", cbCreds{akid: live.akid, secret: cbBadSecret, token: live.token}, sigMismatch, 1},
} {
t.Run(tc.name, func(t *testing.T) {
e.counter.decrypts.Store(0)
assert.Equal(t, tc.want, cbOutcomeOf(e.callerIdentity(t, tc.creds)))
assert.Equal(t, tc.decrypts, e.counter.decrypts.Load(), "secret decrypts before the verdict")
})
}
}
// Pins CURRENT behaviour: the door caches only an account's ACTIVE status, per
// gateway, for accountadmission.ActiveTTL; keys and sessions are re-read on every request.
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
require.Equal(t, 5*time.Second, accountadmission.ActiveTTL)
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
t.Run(status, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
require.Equal(t, http.StatusOK, e.callerIdentity(t, e.alice).Code)
start := time.Now()
_, err := e.iam.SetAccountStatus(e.accountID, status)
require.NoError(t, err)
// CURRENT: an out-of-band status write is not seen by a gateway
// holding a warm ACTIVE entry until that entry expires.
warm := e.callerIdentity(t, e.alice)
if time.Since(start) >= accountadmission.ActiveTTL {
t.Skip("cache window elapsed before the warm request; outcome not attributable")
}
assert.Equal(t, http.StatusOK, warm.Code)
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.ErrorLookup[awserrors.ErrorAccessDenied].Message}
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.gateway(), e.alice, "sts", cbForm("GetCallerIdentity"))))
})
}
}
@@ -0,0 +1,38 @@
package awsgw_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/stretchr/testify/require"
)
func TestSigV4Auth_UnsupportedServiceIsLogged(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
logs := captureLogs(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
sum := sha256.Sum256(nil)
payloadHash := hex.EncodeToString(sum[:])
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: gwfix.AccessKey, SecretAccessKey: gwfix.SecretKey},
req, payloadHash, "notaservice", gwfix.Region, time.Now().UTC()))
resp := doRequest(handler, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
out := logs.String()
require.Contains(t, out, "unsupported service in credential scope")
require.Contains(t, out, "service=notaservice")
}
@@ -0,0 +1,367 @@
package awsgw_test
import (
"encoding/xml"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSigV4Auth_NoAuthorizationHeader(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "MissingAuthenticationToken") {
t.Errorf("Expected MissingAuthenticationToken error, got: %s", string(body))
}
}
func TestSigV4Auth_MalformedHeader(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
testCases := []struct {
name string
authHeader string
}{
{"empty prefix", "InvalidPrefix Credential=test"},
{"missing parts", "AWS4-HMAC-SHA256 Credential=test"},
{"invalid credential format", "AWS4-HMAC-SHA256 Credential=a/b/c, SignedHeaders=host, Signature=sig"},
{"missing SignedHeaders prefix", "AWS4-HMAC-SHA256 Credential=a/b/c/d/aws4_request, Headers=host, Signature=sig"},
{"missing Signature prefix", "AWS4-HMAC-SHA256 Credential=a/b/c/d/aws4_request, SignedHeaders=host, Sig=abc"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", tc.authHeader)
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
})
}
}
func TestSigV4Auth_InvalidAccessKey(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, "INVALID_ACCESS_KEY", gwfix.SecretKey)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "InvalidClientTokenId") {
t.Errorf("Expected InvalidClientTokenId error, got: %s", string(body))
}
}
func TestSigV4Auth_InvalidSignature(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, "WRONG_SECRET_KEY")
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_InactiveAccessKey(t *testing.T) {
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
if err != nil {
t.Fatalf("Failed to encrypt secret: %v", err)
}
mockSvc := &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "root",
Status: "Inactive",
},
},
}
gw := withEC2Verifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
IAMService: mockSvc,
})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(r, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403 for inactive key, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "InvalidClientTokenId") {
t.Errorf("Expected InvalidClientTokenId error, got: %s", string(body))
}
}
func TestSigV4Auth_DecryptFailure(t *testing.T) {
// Secret encrypted under a rotated master key — unverifiable credential must
// return 403 InvalidClientTokenId, not 500, so the client re-authenticates.
otherKey, err := iamdomain.GenerateMasterKey()
if err != nil {
t.Fatalf("GenerateMasterKey() error: %v", err)
}
encryptedWithOther, err := iamdomain.EncryptSecret(gwfix.SecretKey, otherKey)
if err != nil {
t.Fatalf("EncryptSecret() error: %v", err)
}
mockSvc := &gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedWithOther, // encrypted with wrong key
UserName: "root",
Status: "Active",
},
},
}
gw := withEC2Verifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
IAMService: mockSvc,
})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
resp := doRequest(r, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403 for decrypt failure, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), awserrors.ErrorInvalidClientTokenId) {
t.Errorf("Expected InvalidClientTokenId, got: %s", string(body))
}
}
func TestSigV4Auth_ExpiredTimestamp(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
past := time.Now().UTC().Add(-16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, past)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_FutureTimestamp(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
future := time.Now().UTC().Add(16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey, future)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_MissingXAmzDate(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
req.Header.Del("X-Amz-Date")
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
}
func TestSigV4Auth_MalformedTimestamp(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
req.Header.Set("X-Amz-Date", "not-a-valid-date")
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
}
// TestSigV4Auth_RequireSignedHeaders verifies that omitting "host" or
// "x-amz-date" from SignedHeaders is rejected with IncompleteSignature before
// signature comparison. Omitting either enables Authorization header replay
// across vhosts or outside the clock-skew window.
func TestSigV4Auth_RequireSignedHeaders(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
rewriteSignedHeaders := func(authHeader, list string) string {
parts := strings.Split(authHeader, ", ")
if len(parts) != 3 {
t.Fatalf("expected 3-part auth header, got %d", len(parts))
}
parts[1] = "SignedHeaders=" + list
return strings.Join(parts, ", ")
}
cases := []struct {
name string
signedList string
}{
{"missing host", "x-amz-date"},
{"missing x-amz-date", "host"},
{"neither present", "content-type"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
gwfix.SignRequest(t, req, nil, gwfix.AccessKey, gwfix.SecretKey)
req.Header.Set("Authorization",
rewriteSignedHeaders(req.Header.Get("Authorization"), tc.signedList))
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusBadRequest {
body, _ := io.ReadAll(resp.Body)
t.Errorf("Expected status 400, got %d, body: %s", resp.StatusCode, string(body))
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
})
}
}
// The most important test in this cluster: a genuine signature mismatch on a
// SERVED scope must still return SignatureDoesNotMatch. Nothing about the
// unserved-scope InvalidAction change may make a real signature failure
// unrecognisable.
func TestSigV4Auth_ServedScopeSignatureMismatchStillSignatureDoesNotMatch(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
// Signed with the wrong secret: the credential scope (ec2) is served, so
// this must fail verification rather than the unserved-service gate.
gwfix.SignRequestAs(t, req, nil, gwfix.AccessKey, "wrong-secret-entirely", gwfix.Service, time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// ec2 speaks the query-protocol <Response><Errors><Error> shape, not the
// generic <ErrorResponse><Error> envelope other services use.
var parsed struct {
Errors struct {
Error struct {
Code string `xml:"Code"`
} `xml:"Error"`
} `xml:"Errors"`
}
require.NoError(t, xml.Unmarshal(body, &parsed), "body: %s", body)
assert.Equal(t, awserrors.ErrorSignatureDoesNotMatch, parsed.Errors.Error.Code)
}
@@ -2,12 +2,14 @@
// pipeline as bedrock*.go implement them today. They assert current
// behaviour, not AWS parity.
package gateway
package awsgw_test
import (
"bytes"
"context"
"encoding/json"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"io"
"net/http"
"net/http/httptest"
@@ -60,15 +62,15 @@ type bedrockPipelineEnv struct {
// unless mutate clears it. Policy statements are the caller's only IAM policy.
func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statements ...iamdomain.Statement) http.Handler {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
svc := &policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
svc := &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
@@ -76,7 +78,7 @@ func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statemen
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
}
_, nc, js := testutil.StartTestJetStream(t)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -91,8 +93,8 @@ func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statemen
Endpoints: ochre.NewStaticEndpointResolver(map[string]string{bedrockPipelineLlamaModel: stub.URL}),
Access: grantAllModels{},
Logging: ochre.NewLoggingConfigStore(js),
Provisioned: ochre.NewProvisionedStore(js, testRegion, nil),
Guardrails: ochre.NewGuardrailStore(js, testRegion),
Provisioned: ochre.NewProvisionedStore(js, gwfix.Region, nil),
Guardrails: ochre.NewGuardrailStore(js, gwfix.Region),
KB: ochrevector.NewKBStore(js),
DataSources: ochrevector.NewDataSourceStore(js),
Vector: unusedVector{},
@@ -100,7 +102,7 @@ func bedrockPipelineApp(t *testing.T, mutate func(*bedrockPipelineEnv), statemen
if mutate != nil {
mutate(env)
}
cfg := &GatewayConfig{DisableLogging: true, Region: testRegion, NATSConn: nc, IAMService: svc}
cfg := &gateway.GatewayConfig{DisableLogging: true, Region: gwfix.Region, NATSConn: nc, IAMService: svc}
if env.NoNATS {
cfg.NATSConn = nil
}
@@ -121,7 +123,7 @@ func signBedrockRequest(t *testing.T, scope, method, path string, body []byte) *
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Host = "localhost:9999"
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, scope, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, scope, time.Now().UTC())
return req
}
@@ -0,0 +1,6 @@
package awsgw_test
//test:in-package — the gate steps under test are unexported and take their
// capabilities through the pipeline.PolicyEvaluator and pipeline.AccountAdmission interfaces.
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (setupEC2Request, gwfix.PolicyIAM) and auth context keys.
package awsgw_test
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives the public DeleteVolume action through EC2_Request
// with the gateway's unexported test helpers.
package awsgw_test
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package awsgw_test
import ()
@@ -0,0 +1,9 @@
// Characterization tests for the labels an EC2 request carries through the
// signed pipeline today: the trace span, the audit action, the throttle key,
// and the service and action the policy gate evaluates. Also the order of the
// cluster-unavailable gate against the checks that follow it. They assert
// current behaviour, not AWS parity.
package awsgw_test
import ()
@@ -0,0 +1,7 @@
// Characterization tests for EC2 through the full SigV4 pipeline as the legacy
// gateway switch and ec2.go implement them today. They assert current
// behaviour, not AWS parity.
package awsgw_test
import ()
@@ -0,0 +1,3 @@
package awsgw_test
import ()
@@ -0,0 +1,6 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers.
package awsgw_test
import ()
@@ -1,4 +1,4 @@
package gateway
package awsgw_test
import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
@@ -6,7 +6,7 @@ import (
// ecrAllowPolicy and ecrDenyPolicy build single-statement policy documents for
// the ECR operation-authorization tests below. They are distinct from
// auth_test.go's allowPolicy/allowPolicyResource helpers because those are
// auth_test.go's gwfix.AllowPolicy/gwfix.AllowPolicyResource helpers because those are
// fixed to a single action string; ECR route classification can require
// multiple actions per operation (e.g. cross-repo mount).
func ecrAllowPolicy(actions, resources []string) iamdomain.PolicyDocument {
@@ -0,0 +1,351 @@
// Characterization tests for ECR control-plane dispatch as it exists in
// ecrapi.go/ecr.go today, pinned ahead of moving dispatch out of gateway into
// a registration seam. These assert current behaviour, not AWS parity.
package awsgw_test
import (
"bytes"
"context"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ecrPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
// live test NATS so Request dispatches. docs is every principal's policy; the
// region is gwfix.Region because gwfix.SignRequestAs always signs for it.
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*gateway.GatewayConfig, http.Handler) {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: gwfix.Region,
InternalSuffix: ecrTestSuffix,
NATSConn: nc,
IAMService: &gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: ecrTestAccount,
Status: "Active",
},
},
},
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
})
withECR(gw, awsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
return gw, r
}
// allowAllECRDocs is the policy ecrPipelineApp uses when the test is not
// exercising authorization itself.
func allowAllECRDocs() []iamdomain.PolicyDocument {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
},
}}
}
// signECRRequest builds a POST / signed with credential-scope service "ecr",
// the shape every ECR JSON 1.1 client sends.
func signECRRequest(t *testing.T, target string, body []byte) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Host = "localhost:9999"
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, "ecr", time.Now().UTC())
return req
}
// --- Full-pipeline error envelopes ---------------------------------------
// TestECRPipeline_MissingTarget_400MissingActionException pins that an ECR
// request with no X-Amz-Target fails at dispatch, not at auth, with the
// MissingAction envelope.
func TestECRPipeline_MissingTarget_400MissingActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
resp := doRequest(app, signECRRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
}
// TestECRPipeline_UnknownAction_400InvalidActionException pins that a target
// naming an action absent from awsapi.Actions fails with InvalidAction.
func TestECRPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".MadeUpAction"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
}
// TestECRPipeline_StubbedAction_501NotImplementedException pins that a
// registered-but-stubbed action (ListRepositories) still answers 501 through
// the full pipeline.
func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) {
_, app := ecrPipelineApp(t, allowAllECRDocs())
target := awsapi.TargetPrefix + ".ListRepositories"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "NotImplementedException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "NotImplementedException", body.Type)
}
// --- Full-pipeline success content type ----------------------------------
// TestECRPipeline_GetAuthorizationToken_200JSONContentType pins that a
// successful ECR action answers 200 with the AWS JSON 1.1 content type
// through the full pipeline.
func TestECRPipeline_GetAuthorizationToken_200JSONContentType(t *testing.T) {
iss, _ := newECRAuth(t)
gw, app := ecrPipelineApp(t, allowAllECRDocs())
withECR(gw, awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".GetAuthorizationToken"
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, awsapi.JSONContentType, resp.Header.Get("Content-Type"))
}
// --- Target parsing today --------------------------------------------------
// --- Exact stub inventory --------------------------------------------------
// TestECRInventory_StubbedSetIsPinned pins the exact set of actions still
// bound to awsapi.NotImplemented, independent of whether a composed capability
// intercepts them before dispatch reaches that stub.
func TestECRInventory_StubbedSetIsPinned(t *testing.T) {
want := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "BatchGetImage", "BatchCheckLayerAvailability", "BatchDeleteImage",
"PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer", "InitiateLayerUpload",
"UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy", "PutRegistryPolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview",
"PutReplicationConfiguration", "ReplicateImage",
}
assert.ElementsMatch(t, want, awsapi.StubbedActionNames())
}
// --- Oversized body over HTTP ---------------------------------------------
// TestECRPipeline_OversizedBody_413OverHTTP pins that an ECR-signed request
// whose body exceeds the SigV4 cap is rejected inside SigV4AuthMiddleware,
// before ECR dispatch ever runs, with the JSON error envelope.
func TestECRPipeline_OversizedBody_413OverHTTP(t *testing.T) {
handler := setupTestApp(gwfix.AccessKey, gwfix.SecretKey)
oversized := bytes.Repeat([]byte("x"), int(sigv4.MaxPayloadLen)+1)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversized))
req.Host = "localhost:9999"
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".ListRepositories")
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, "ecr", time.Now().UTC())
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
// --- Authorization gate before a composed action --------------------------
// recordingRepoStore is a RepositoryStore fake that records whether it was
// ever invoked, so a denial can be proven to short-circuit before the composed
// capability runs.
type recordingRepoStore struct {
calls int
}
func (s *recordingRepoStore) GetRepo(context.Context, string, string) (ecr.RepoMeta, error) {
s.calls++
return ecr.RepoMeta{}, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListRepos(context.Context, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) PutRepo(context.Context, string, ecr.RepoMeta) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) ListManifests(context.Context, string, string) ([]string, error) {
s.calls++
return nil, errors.New("recordingRepoStore: unexpected call")
}
func (s *recordingRepoStore) DeleteRepo(context.Context, string, string) error {
s.calls++
return errors.New("recordingRepoStore: unexpected call")
}
// TestECRPipeline_AuthzGateBlocksComposedAction pins that the policy gate in
// ECR dispatch runs, and denies, before a composed capability (here
// RepositoryActionService backing CreateRepository) is ever invoked.
func TestECRPipeline_AuthzGateBlocksComposedAction(t *testing.T) {
scopedDocs := []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{
{
Effect: "Allow",
Action: iamdomain.StringOrArr{"ecr:CreateRepository"},
Resource: iamdomain.StringOrArr{"arn:aws:ecr:" + gwfix.Region + ":" + ecrTestAccount + ":repository/other"},
},
},
}}
gw, app := ecrPipelineApp(t, scopedDocs)
store := &recordingRepoStore{}
withECR(gw, awsapi.Deps{Repository: awsapi.NewRepositoryActionService(store, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
target := awsapi.TargetPrefix + ".CreateRepository"
resp := doRequest(app, signECRRequest(t, target, []byte(`{"repositoryName":"team/app"}`)))
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body := decodeJSONError(t, resp)
assert.Equal(t, "AccessDeniedException", body.Type)
assert.Equal(t, 0, store.calls, "composed capability must not run once the gate denies")
}
// --- GetAuthorizationToken ignores request-supplied identity -------------
// --- NATS unavailable for ECR ---------------------------------------------
// Stub and composed ECR actions alike fail at the NATS gate, before dispatch
// reads the action.
func TestRequest_ClusterUnavailableNilConn_ECR(t *testing.T) {
for _, action := range []string{"ListRepositories", "CreateRepository"} {
t.Run(action, func(t *testing.T) {
gw := withECR(withVerifier(&gateway.GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}), awsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+"."+action)
ctx := reqctx.WithService(req.Context(), "ecr")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
gw.Request(w, req)
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
}
}
// --- Pipeline metadata for an ECR request ---------------------------------
// TestECRPipeline_RequestAudit_RecordsResolvedAction pins that the audit
// record's action field carries the resolved ECR action once a real request
// runs through audit.Middleware, SigV4AuthMiddleware and Request.
func TestECRPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
gw, _ := ecrPipelineApp(t, allowAllECRDocs())
var got *audit.Record
r := chi.NewRouter()
r.Use(audit.Middleware)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
got = audit.From(r.Context())
})
})
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
target := awsapi.TargetPrefix + ".ListRepositories"
doRequest(r, signECRRequest(t, target, []byte("{}")))
require.NotNil(t, got)
assert.Equal(t, "ListRepositories", auditField(got, "action"))
}
// --- Inventory pin --------------------------------------------------------
// Pins the ECR inventory so a refactor that drops or adds an action, including
// by changing which capability intercepts it, fails here.
func TestECRInventory_RegisteredStubbedUnsupportedArePinned(t *testing.T) {
ecr := gateway.AWSOperationInventory(ecrRegistrationInventory())["ecr"]
wantRegistered := []string{
"GetAuthorizationToken", "CreateRepository", "DeleteRepository", "DescribeRepositories",
"ListRepositories", "PutImageTagMutability", "BatchGetImage", "BatchCheckLayerAvailability",
"BatchDeleteImage", "PutImage", "ListImages", "DescribeImages", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "SetRepositoryPolicy",
"GetRepositoryPolicy", "DeleteRepositoryPolicy", "GetRegistryPolicy", "PutRegistryPolicy",
"DescribeRegistry", "PutImageScanningConfiguration", "GetImageScanningConfiguration",
"StartImageScan", "DescribeImageScanFindings", "GetRegistryScanningConfiguration",
"PutRegistryScanningConfiguration", "BatchGetRepositoryScanningConfiguration",
"PutLifecyclePolicy", "GetLifecyclePolicy", "DeleteLifecyclePolicy",
"StartLifecyclePolicyPreview", "GetLifecyclePolicyPreview", "PutReplicationConfiguration",
"ReplicateImage", "TagResource", "UntagResource", "ListTagsForResource",
}
wantStubbed := []string{
"ListRepositories", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer",
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy",
"PutRegistryPolicy", "PutReplicationConfiguration", "ReplicateImage",
}
wantUnsupported := []string{
"GetImageScanningConfiguration", "StartImageScan", "DescribeImageScanFindings",
"GetRegistryScanningConfiguration", "PutRegistryScanningConfiguration",
"BatchGetRepositoryScanningConfiguration",
}
assert.ElementsMatch(t, wantRegistered, ecr.Registered)
assert.ElementsMatch(t, wantStubbed, ecr.Stubbed)
assert.ElementsMatch(t, wantUnsupported, ecr.Unsupported)
}
@@ -0,0 +1,3 @@
package awsgw_test
import ()
@@ -1,7 +1,8 @@
package gateway
package awsgw_test
import (
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"strings"
"time"
@@ -138,19 +139,13 @@ func (m *ecrMockSTSService) VerifySessionPrincipal(cred *stsdomain.SessionCreden
if m.principalErr != nil {
return nil, m.principalErr
}
live, err := stubSessionPrincipal(cred)
if err == nil && cred.PrincipalType == principalTypeUser {
live, err := gwfix.StubSessionPrincipal(cred)
if err == nil && cred.PrincipalType == gwfix.PrincipalTypeUser {
live.UserARN = m.liveUserARN
}
return live, err
}
const (
ecrPrincipalTestAccount = "000000000042"
ecrPrincipalTestAKID = "AKIATESTLONGLIVED0001"
ecrPrincipalTestASID = "ASIATESTSESSION000001"
)
func seedECRTestUser(iamSvc *ecrMockIAMService, accountID, userName, akid string) {
iamSvc.accessKeys[akid] = &iamdomain.AccessKey{
AccessKeyID: akid,
@@ -166,8 +161,6 @@ func seedECRTestUser(iamSvc *ecrMockIAMService, accountID, userName, akid string
iamSvc.accounts[accountID] = &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}
}
const ecrPathedUserARN = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/eng/dev"
func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, accountID, userName, asid string, expiresAt time.Time) {
iamSvc.users[accountID+"|"+userName] = &iam.User{
UserName: aws.String(userName),
@@ -178,7 +171,7 @@ func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
stsSvc.sessions[asid] = &stsdomain.SessionCredential{
AccessKeyID: asid,
AccountID: accountID,
PrincipalType: principalTypeUser,
PrincipalType: gwfix.PrincipalTypeUser,
SessionName: userName,
UserID: "AIDA" + strings.ToUpper(userName),
ExpiresAt: expiresAt,
@@ -200,7 +193,7 @@ func seedECRAssumedRole(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
stsSvc.sessions[asid] = &stsdomain.SessionCredential{
AccessKeyID: asid,
AccountID: accountID,
PrincipalType: principalTypeAssumedRole,
PrincipalType: gwfix.PrincipalTypeAssumedRole,
SessionName: sessionName,
AssumedRoleARN: assumedRoleARN,
UnderlyingRoleARN: roleARN,
@@ -1,8 +1,8 @@
package gateway
package awsgw_test
import (
"github.com/mulgadc/spinifex/spinifex/gateway"
"maps"
"net/http"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -10,7 +10,7 @@ import (
// withECR registers the ECR control plane on gw backed by deps, as the awsgw
// role composes it. An unset deps.NATS takes gw.NATSConn.
func withECR(gw *GatewayConfig, deps awsapi.Deps) *GatewayConfig {
func withECR(gw *gateway.GatewayConfig, deps awsapi.Deps) *gateway.GatewayConfig {
if deps.NATS == nil {
deps.NATS = gw.NATSConn
}
@@ -22,21 +22,10 @@ func withECR(gw *GatewayConfig, deps awsapi.Deps) *GatewayConfig {
return gw
}
// serveECR dispatches r to gw's registered ECR service exactly as Request does
// past its NATS gate, first registering one with no capabilities if needed.
func (gw *GatewayConfig) serveECR(w http.ResponseWriter, r *http.Request) error {
entry, ok := gw.registered(awsapi.ServiceName)
if !ok {
withECR(gw, awsapi.Deps{})
entry, _ = gw.registered(awsapi.ServiceName)
}
return gw.dispatchRegistered(entry, w, r)
}
// ecrRegistrationInventory is the inventory the ECR registration declares to
// a registry, keyed as AWSOperationInventory expects.
func ecrRegistrationInventory() map[string]dispatch.Inventory {
inventory := withECR(&GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
maps.Copy(inventory, withContainers(&GatewayConfig{}).Services.Inventory())
inventory := withECR(&gateway.GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
maps.Copy(inventory, withContainers(&gateway.GatewayConfig{}).Services.Inventory())
return inventory
}
@@ -1,9 +1,11 @@
package gateway
package awsgw_test
import (
"encoding/base64"
"encoding/json"
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"net/http"
"net/http/httptest"
"testing"
@@ -54,7 +56,7 @@ func newV2Env(t *testing.T, opts v2Options) *v2Env {
}
iamSvc.userPolicies[v2Account+"|dev"] = policies
gw := withVerifier(&GatewayConfig{
gw := withVerifier(&gateway.GatewayConfig{
DisableLogging: true,
Region: ecrTestRegion,
InternalSuffix: ecrTestSuffix,
@@ -78,14 +80,14 @@ func (e *v2Env) mintRole(t *testing.T, roleID string) string {
t.Helper()
tok, _, err := e.issuer.Mint(ecrauth.Principal{
AccountID: v2Account, ARN: "arn:aws:sts::" + v2Account + ":assumed-role/deploy/s1",
Type: principalTypeAssumedRole, AccessKeyID: v2ASID, RoleID: roleID,
Type: gwfix.PrincipalTypeAssumedRole, AccessKeyID: v2ASID, RoleID: roleID,
})
require.NoError(t, err)
return tok
}
func (e *v2Env) userToken(t *testing.T) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeUser, v2AKID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeUser, v2AKID)
}
func basic(token string) string {
@@ -154,7 +156,7 @@ func TestV2Pipeline_VersionCheckAndStub(t *testing.T) {
func TestV2Pipeline_TokenRejections(t *testing.T) {
e := newV2Env(t, v2Options{})
good := e.userToken(t)
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", principalTypeUser, v2AKID)
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", gwfix.PrincipalTypeUser, v2AKID)
tests := []struct {
name string
@@ -200,10 +202,10 @@ func TestV2Pipeline_NonRegistryHostPassesThrough(t *testing.T) {
func TestV2Pipeline_PrincipalRehydration(t *testing.T) {
user := func(e *v2Env) string { return e.userToken(t) }
session := func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeUser, v2ASID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeUser, v2ASID)
}
role := func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:sts::"+v2Account+":assumed-role/deploy/s1", principalTypeAssumedRole, v2ASID)
return e.mint(t, v2Account, "arn:aws:sts::"+v2Account+":assumed-role/deploy/s1", gwfix.PrincipalTypeAssumedRole, v2ASID)
}
seedSession := func(e *v2Env) {
seedECRSessionUser(e.iam, e.sts, v2Account, "dev", v2ASID, time.Now().Add(time.Hour))
@@ -245,13 +247,13 @@ func TestV2Pipeline_PrincipalRehydration(t *testing.T) {
e.sts.principalErr = stsdomain.ErrSessionPrincipalReplaced
}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"unrecognized key prefix", func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeUser, "AROAUNKNOWN0000000001")
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeUser, "AROAUNKNOWN0000000001")
}, func(*v2Env) {}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"claim subject does not match the principal", func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/someone-else", principalTypeUser, v2AKID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/someone-else", gwfix.PrincipalTypeUser, v2AKID)
}, func(*v2Env) {}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"claim principal type does not match", func(e *v2Env) string {
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", principalTypeAssumedRole, v2AKID)
return e.mint(t, v2Account, "arn:aws:iam::"+v2Account+":user/dev", gwfix.PrincipalTypeAssumedRole, v2AKID)
}, func(*v2Env) {}, http.StatusUnauthorized, "UNAUTHORIZED"},
{"IAM outage", user, func(e *v2Env) { e.iam.genericErr = errors.New("nats: timeout") }, http.StatusServiceUnavailable, "UNKNOWN"},
{"STS outage", session, func(e *v2Env) {
@@ -449,7 +451,7 @@ func TestV2Pipeline_TokenEndpoint(t *testing.T) {
t.Run("token for another account than the host", func(t *testing.T) {
e := newV2Env(t, v2Options{})
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", principalTypeUser, v2AKID)
other := e.mint(t, "000000000099", "arn:aws:iam::000000000099:user/dev", gwfix.PrincipalTypeUser, v2AKID)
w := e.do(http.MethodGet, "/v2/token", v2Host, "Bearer "+other)
@@ -1,4 +1,4 @@
package gateway
package awsgw_test
import (
"crypto/rand"
@@ -2,10 +2,12 @@
// pipeline as ecs.go and eks.go implement it today. They assert current
// behaviour, not AWS parity.
package gateway
package awsgw_test
import (
"bytes"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"net/http"
"net/http/httptest"
"testing"
@@ -24,16 +26,16 @@ const ecsPipelineTarget = "AmazonEC2ContainerServiceV20141113."
// validation therefore stops at the handler with ServerInternal.
func containerPipelineApp(t *testing.T, statements ...iamdomain.Statement) http.Handler {
t.Helper()
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
require.NoError(t, err)
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
svc := &roleStoreIAMService{
policyMockIAMService: policyMockIAMService{
mockIAMService: mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*iamdomain.AccessKey{
testAccessKey: {
AccessKeyID: testAccessKey,
PolicyIAM: gwfix.PolicyIAM{
IAM: gwfix.IAM{
MasterKey: gwfix.MasterKey,
AccessKeys: map[string]*iamdomain.AccessKey{
gwfix.AccessKey: {
AccessKeyID: gwfix.AccessKey,
SecretAccessKey: encryptedSecret,
UserName: "alice",
AccountID: authzAccountID,
@@ -41,7 +43,7 @@ func containerPipelineApp(t *testing.T, statements ...iamdomain.Statement) http.
},
},
},
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
GetUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
},
roles: map[string]map[string]string{
authzAccountID: {"node-role": eksNodeRoleARN},
@@ -49,7 +51,7 @@ func containerPipelineApp(t *testing.T, statements ...iamdomain.Statement) http.
},
}
_, nc := testutil.StartTestNATS(t)
gw := withVerifier(withContainers(&GatewayConfig{DisableLogging: true, Region: testRegion, NATSConn: nc, IAMService: svc}))
gw := withVerifier(withContainers(&gateway.GatewayConfig{DisableLogging: true, Region: gwfix.Region, NATSConn: nc, IAMService: svc}))
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
@@ -63,7 +65,7 @@ func signContainerRequest(t *testing.T, service, method, path, target string, bo
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
signTestRequestAs(t, req, body, testAccessKey, testSecretKey, service, time.Now().UTC())
gwfix.SignRequestAs(t, req, body, gwfix.AccessKey, gwfix.SecretKey, service, time.Now().UTC())
return req
}
@@ -0,0 +1,3 @@
package awsgw_test
import ()
@@ -0,0 +1,3 @@
package awsgw_test
import ()
+39
View File
@@ -0,0 +1,39 @@
package awsgw_test
import (
"testing"
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
"github.com/stretchr/testify/assert"
)
// 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)
}
}
@@ -0,0 +1,32 @@
package awsgw_test
import (
"errors"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
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 {
gwfix.PolicyIAM
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)
}
@@ -1,6 +1,7 @@
package gateway
package awsgw_test
import (
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
@@ -13,7 +14,7 @@ import (
)
func TestErrorHandler_EKSEmitsJSONNotXML(t *testing.T) {
gw := withContainers(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withContainers(withVerifier(&gateway.GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
req := httptest.NewRequest(http.MethodGet, "/clusters", nil)
ctx := reqctx.WithService(req.Context(), "eks")
req = req.WithContext(ctx)
@@ -0,0 +1,20 @@
//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 awsgw_test
import (
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"net/http"
"net/http/httptest"
"strings"
)
// 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))
}
@@ -1,7 +1,7 @@
//test:in-package — the served-action list is a gateway test helper, and the
// completeness test exists to compare it against the scope table.
package gateway
package awsgw_test
import (
"testing"
@@ -0,0 +1,343 @@
// 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 awsgw_test
import (
"bytes"
"encoding/xml"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
"github.com/mulgadc/spinifex/spinifex/gateway"
"net/http"
"net/http/httptest"
"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"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 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) (*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 },
},
})
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")
gwfix.SignRequestAs(t, req, b, gwfix.AccessKey, gwfix.SecretKey, "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
}
// --- 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(&gateway.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(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("Content-Type", "application/x-www-form-urlencoded")
gwfix.SignRequestAs(t, req, oversized, gwfix.AccessKey, gwfix.SecretKey, "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_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 gwfix.Region, so ARNs must carry it to pass the
// endpoint-region check.
func pipelineLB(name, id string) string {
return arn.FormatELBv2LoadBalancer(gwfix.Region, authzAccountID, name, id, "application")
}
func pipelineListener(lbName, lbID, id string) string {
return arn.FormatELBv2Listener(gwfix.Region, authzAccountID, lbName, lbID, id, "application")
}
@@ -1,7 +1,7 @@
package gateway
package awsgw_test
import (
"net/http"
"github.com/mulgadc/spinifex/spinifex/gateway"
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -9,7 +9,7 @@ import (
// withELBv2 registers ELBv2 on gw with the NATS connection and quota it holds,
// as the awsgw role composes it. Set both on gw before calling.
func withELBv2(gw *GatewayConfig) *GatewayConfig {
func withELBv2(gw *gateway.GatewayConfig) *gateway.GatewayConfig {
b := dispatch.NewBuilder()
reg := elbv2awsapi.NewRegistration(elbv2awsapi.Deps{NATS: gw.NATSConn, Quota: quotaFor(gw)})
if err := b.Register(reg); err != nil {
@@ -19,20 +19,6 @@ func withELBv2(gw *GatewayConfig) *GatewayConfig {
return gw
}
// serveELBv2 dispatches r to the registered ELBv2 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) serveELBv2(w http.ResponseWriter, r *http.Request) error {
if gw.Services == nil {
withELBv2(gw)
}
entry, ok := gw.registered(elbv2awsapi.ServiceName)
if !ok {
panic("ELBv2 is not registered on this gateway")
}
return gw.dispatchRegistered(entry, w, r)
}
// elbv2ServedActions lists every action the ELBv2 service dispatches.
func elbv2ServedActions() []string {
return elbv2awsapi.ActionNames()
@@ -0,0 +1,89 @@
package awsgw_test
import (
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetService(t *testing.T) {
gw := withEC2Verifier(&gateway.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(&gateway.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")
}
@@ -0,0 +1,3 @@
package awsgw_test
import ()
@@ -0,0 +1,12 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helper setupEC2Request.
package awsgw_test
import ()
// 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.

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