refactor: register ACM control plane from its domain

domains/acm/awsapi registers the acm JSON 1.1 surface through the
ingress dispatch registry, wired by the awsgw role. The gateway drops
ACM_Request, the ACM action table and every legacy acm entry; the
import invariant now covers each registered domain.
This commit is contained in:
joshsiv-mulga
2026-10-08 00:41:50 +10:00
parent 4b0a4dd6b3
commit fdaf589ca3
19 changed files with 486 additions and 200 deletions
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/mulgadc/predastore/s3api"
"github.com/mulgadc/spinifex/internal/awsmodel"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -48,7 +49,8 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
// Registered services contribute their declared inventory, which needs no
// live dependencies; nothing here serves a request.
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
})
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
for _, service := range awsmodel.Services() {
+159
View File
@@ -0,0 +1,159 @@
package awsapi
import (
"context"
"errors"
"io"
"log/slog"
"maps"
"net/http"
"slices"
"strings"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/nats-io/nats.go"
)
// ServiceName is the SigV4 credential-scope service ACM clients sign for.
const ServiceName = "acm"
// Handler is the signature every ACM control-plane action implements. nc is
// the gateway NATS connection (handlers relay onto acm.* subjects); accountID
// is the resolved caller account; body is the raw JSON 1.1 request payload.
type Handler func(ctx context.Context, nc *nats.Conn, accountID string, body []byte) (any, error)
// Deps are the composed capabilities the ACM control plane dispatches to. A
// nil NATS connection answers every action with ServerInternal.
type Deps struct {
NATS *nats.Conn
}
// Actions maps the ACM control-plane action namespace onto the domain's
// existing NATS-backed adapters.
var Actions = map[string]Handler{
"ImportCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return ImportCertificate(ctx, nc, acct, b)
},
"RequestCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return RequestCertificate(ctx, nc, acct, b)
},
"DescribeCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return DescribeCertificate(ctx, nc, acct, b)
},
"GetCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return GetCertificate(ctx, nc, acct, b)
},
"ListCertificates": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return ListCertificates(ctx, nc, acct, b)
},
"DeleteCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return DeleteCertificate(ctx, nc, acct, b)
},
"ListTagsForCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return ListTagsForCertificate(ctx, nc, acct, b)
},
"AddTagsToCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return AddTagsToCertificate(ctx, nc, acct, b)
},
"RemoveTagsFromCertificate": func(ctx context.Context, nc *nats.Conn, acct string, b []byte) (any, error) {
return RemoveTagsFromCertificate(ctx, nc, acct, b)
},
}
// NewRegistration returns the ACM AWS JSON 1.1 control-plane registration.
func NewRegistration(deps Deps) dispatch.Registration {
return dispatch.Registration{
Service: ServiceName,
ResolveAction: resolveAction,
Dispatch: deps.dispatch,
Errors: dispatch.ErrorEnvelopeJSON,
Inventory: OperationInventory(),
}
}
// OperationInventory classifies the actions the registration serves. Every
// ACM action is implemented, so none is Stubbed or Unsupported.
func OperationInventory() dispatch.Inventory {
return dispatch.Inventory{
Registered: slices.Sorted(maps.Keys(Actions)),
}
}
// resolveAction is the one X-Amz-Target parser telemetry and dispatch share.
func resolveAction(r *http.Request) string {
return actionFromTarget(r.Header.Get("X-Amz-Target"))
}
// actionFromTarget extracts the action suffix from an X-Amz-Target header.
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
func actionFromTarget(target string) string {
if i := strings.LastIndex(target, "."); i >= 0 {
return target[i+1:]
}
return target
}
// dispatch resolves, scopes and authorizes the request from one read of its
// body, then runs the action. Errors are awserrors codes the gateway renders.
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
r := inv.Request
action := resolveAction(r)
if action == "" {
return errors.New(awserrors.ErrorMissingAction)
}
handler, ok := Actions[action]
if !ok {
slog.DebugContext(r.Context(), "ACM: unknown action", "action", action)
return errors.New(awserrors.ErrorInvalidAction)
}
accountID := inv.AccountID
if accountID == "" {
slog.ErrorContext(r.Context(), "ACM dispatch: no account ID in auth context")
// InternalError, not ServerInternal: the policy gate used to reach this
// case first and that is the code the caller has always seen.
return errors.New(awserrors.ErrorInternalError)
}
body, err := readBoundedBody(r)
if err != nil {
slog.ErrorContext(r.Context(), "ACM dispatch: failed to read body", "err", err)
return err
}
resources, err := ResourceARNs(action, inv.Region, accountID, body)
if err != nil {
return err
}
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
return err
}
if d.NATS == nil {
return errors.New(awserrors.ErrorServerInternal)
}
output, err := handler(r.Context(), d.NATS, accountID, body)
if err != nil {
return err
}
WriteJSONResponse(w, output)
return nil
}
// readBoundedBody reads the body under the SigV4 payload cap, so a body read
// ahead of the policy gate can neither bypass it nor exhaust memory.
func readBoundedBody(r *http.Request) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
if int64(len(body)) > sigv4.MaxPayloadLen {
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
}
return body, nil
}
@@ -0,0 +1,179 @@
package awsapi
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
regAccount = "123456789012"
regRegion = "us-east-1"
)
// authCall is one inv.Authorize call as the dispatcher made it.
type authCall struct {
service, action string
resources []string
keys iampolicy.ConditionKeys
}
// fakeInvocation records every Authorize call into log, answering each with
// deny.
type fakeInvocation struct {
log []string
auths []authCall
deny error
}
func (f *fakeInvocation) invocation(target, body string) dispatch.Invocation {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
return dispatch.Invocation{
Request: req,
AccountID: regAccount,
Region: regRegion,
Authorize: func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
f.log = append(f.log, "authorize")
f.auths = append(f.auths, authCall{service, action, resources, keys})
return f.deny
},
}
}
func dispatchWith(t *testing.T, deps Deps, inv dispatch.Invocation) (*httptest.ResponseRecorder, error) {
t.Helper()
w := httptest.NewRecorder()
return w, NewRegistration(deps).Dispatch(w, inv)
}
func TestActionFromTarget(t *testing.T) {
for target, want := range map[string]string{
"CertificateManager.ListCertificates": "ListCertificates",
"a.b.ListCertificates": "ListCertificates",
"ListCertificates": "ListCertificates",
"CertificateManager.": "",
"": "",
} {
assert.Equal(t, want, actionFromTarget(target), "target %q", target)
}
}
// TestActionsScopeTableIsExhaustive is what stops the next ACM action being
// added with a silent account-wide grant. It asserts both directions, so a
// scope left behind by a deleted or renamed action fails too.
func TestActionsScopeTableIsExhaustive(t *testing.T) {
for action := range Actions {
assert.True(t, HasScope(action),
"ACM action %q has no resource scope entry: add one to authz.go", action)
}
for _, action := range ScopedActions() {
_, ok := Actions[action]
assert.True(t, ok,
"acmScopes has an entry for %q, which Actions does not serve: remove it from authz.go", action)
}
}
// The coverage tool reads OperationInventory directly; a live registration
// must declare exactly the same inventory to the registry.
func TestRegistrationInventoryIsTheDeclaredInventory(t *testing.T) {
reg := NewRegistration(Deps{})
assert.Equal(t, OperationInventory(), reg.Inventory)
b := dispatch.NewBuilder()
require.NoError(t, b.Register(reg))
assert.Equal(t, map[string]dispatch.Inventory{ServiceName: OperationInventory()}, b.Build().Inventory())
}
// Every ACM action is implemented: none is Stubbed or Unsupported.
func TestRegistrationInventoryHasNoStubsOrUnsupported(t *testing.T) {
inv := OperationInventory()
assert.Len(t, inv.Registered, 9)
assert.Empty(t, inv.Stubbed)
assert.Empty(t, inv.Unsupported)
}
func TestRegistrationDeclaresJSONEnvelopeForACM(t *testing.T) {
reg := NewRegistration(Deps{})
assert.Equal(t, ServiceName, reg.Service)
assert.Equal(t, dispatch.ErrorEnvelopeJSON, reg.Errors)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", "CertificateManager.ListCertificates")
assert.Equal(t, "ListCertificates", reg.ResolveAction(req))
}
// A denied request must never reach the NATS gate: Authorize runs first, and
// its own error — not ServerInternal from a nil connection — is what the
// caller sees. A permitted request reaches the NATS gate and fails there,
// which is what proves Authorize ran ahead of it.
func TestDispatch_AuthorizesBeforeTheNATSGate(t *testing.T) {
denied := &fakeInvocation{deny: errors.New(awserrors.ErrorAccessDenied)}
_, err := dispatchWith(t, Deps{}, denied.invocation("CertificateManager.ListCertificates", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
assert.Equal(t, []string{"authorize"}, denied.log)
permitted := &fakeInvocation{}
_, err = dispatchWith(t, Deps{}, permitted.invocation("CertificateManager.ListCertificates", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error(), "authorize passed; the nil NATS connection is what fails next")
require.Len(t, permitted.auths, 1)
assert.Equal(t, authCall{
service: ServiceName,
action: "ListCertificates",
resources: []string{"*"},
}, permitted.auths[0])
}
// Requests dispatch rejects before scoping never reach Authorize.
func TestDispatch_RejectsBeforeAuthorize(t *testing.T) {
cases := []struct {
name, target, body string
account string
want string
}{
{"absent target", "", "{}", regAccount, awserrors.ErrorMissingAction},
{"trailing dot", "CertificateManager.", "{}", regAccount, awserrors.ErrorMissingAction},
{"unknown action", "CertificateManager.MadeUpAction", "{}", regAccount, awserrors.ErrorInvalidAction},
{"other service's action", "AmazonEC2ContainerServiceV20141113.ListClusters", "{}", regAccount, awserrors.ErrorInvalidAction},
{"missing account", "CertificateManager.ListCertificates", "{}", "", awserrors.ErrorInternalError},
{"oversized body", "CertificateManager.ListCertificates", strings.Repeat("x", sigv4.MaxPayloadLen+1), regAccount, awserrors.ErrorRequestEntityTooLarge},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := &fakeInvocation{}
inv := f.invocation(tc.target, tc.body)
inv.AccountID = tc.account
_, err := dispatchWith(t, Deps{}, inv)
require.Error(t, err)
assert.Equal(t, tc.want, err.Error())
assert.Empty(t, f.auths)
})
}
}
// Every registered action answers ServerInternal once authorized, with no
// NATS connection to relay onto.
func TestDispatch_NilNATSIsServerInternal(t *testing.T) {
for action := range Actions {
t.Run(action, func(t *testing.T) {
f := &fakeInvocation{}
_, err := dispatchWith(t, Deps{}, f.invocation("CertificateManager."+action, "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Equal(t, []string{"authorize"}, f.log)
})
}
}
-107
View File
@@ -1,107 +0,0 @@
package gateway
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// acmHandler invokes a per-action ACM domain adapter after generic gateway
// authentication and authorization have completed.
type acmHandler func(ctx context.Context, gw *GatewayConfig, accountID string, body []byte) (any, error)
// acmActions maps the action suffix of X-Amz-Target (CertificateManager.<Action>) to its handler.
var acmActions = map[string]acmHandler{
"ImportCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.ImportCertificate(ctx, gw.NATSConn, acct, b)
},
"RequestCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.RequestCertificate(ctx, gw.NATSConn, acct, b)
},
"DescribeCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.DescribeCertificate(ctx, gw.NATSConn, acct, b)
},
"GetCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.GetCertificate(ctx, gw.NATSConn, acct, b)
},
"ListCertificates": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.ListCertificates(ctx, gw.NATSConn, acct, b)
},
"DeleteCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.DeleteCertificate(ctx, gw.NATSConn, acct, b)
},
"ListTagsForCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.ListTagsForCertificate(ctx, gw.NATSConn, acct, b)
},
"AddTagsToCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.AddTagsToCertificate(ctx, gw.NATSConn, acct, b)
},
"RemoveTagsFromCertificate": func(ctx context.Context, gw *GatewayConfig, acct string, b []byte) (any, error) {
return acmawsapi.RemoveTagsFromCertificate(ctx, gw.NATSConn, acct, b)
},
}
// acmActionFromTarget extracts the action suffix from an X-Amz-Target header.
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
func acmActionFromTarget(target string) string {
if i := strings.LastIndex(target, "."); i >= 0 {
return target[i+1:]
}
return target
}
// ACM_Request dispatches AWS JSON 1.1 ACM requests. The action comes from
// X-Amz-Target; errors are returned as awserrors codes.
func (gw *GatewayConfig) ACM_Request(w http.ResponseWriter, r *http.Request) error {
action := acmActionFromTarget(r.Header.Get("X-Amz-Target"))
if action == "" {
return errors.New(awserrors.ErrorMissingAction)
}
handler, ok := acmActions[action]
if !ok {
slog.DebugContext(r.Context(), "ACM: unknown action", "action", action)
return errors.New(awserrors.ErrorInvalidAction)
}
// Hoisted above the policy check because the resolver builds ARNs from the
// caller's account and from the same body bytes the handler unmarshals.
accountID, _ := r.Context().Value(ctxAccountID).(string)
if accountID == "" {
slog.ErrorContext(r.Context(), "ACM_Request: no account ID in auth context")
// InternalError, not ServerInternal: the policy gate used to reach this
// case first and that is the code the caller has always seen.
return errors.New(awserrors.ErrorInternalError)
}
body, err := readBoundedBody(r)
if err != nil {
slog.ErrorContext(r.Context(), "ACM_Request: failed to read body", "err", err)
return err
}
resources, err := acmawsapi.ResourceARNs(action, gw.Region, accountID, body)
if err != nil {
return err
}
if err := gw.checkPolicyResources(r, "acm", action, resources); err != nil {
return err
}
if gw.NATSConn == nil {
return errors.New(awserrors.ErrorServerInternal)
}
output, err := handler(r.Context(), gw, accountID, body)
if err != nil {
return err
}
acmawsapi.WriteJSONResponse(w, output)
return nil
}
@@ -1,27 +0,0 @@
//test:in-package — acmActions is unexported here, and the completeness test
// exists to compare it against the scope table.
package gateway
import (
"testing"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
"github.com/stretchr/testify/assert"
)
// TestACMScopeTableIsExhaustive is what stops the next ACM action being added
// with a silent account-wide grant. It asserts both directions, so a scope left
// behind by a deleted or renamed action fails too.
func TestACMScopeTableIsExhaustive(t *testing.T) {
for action := range acmActions {
assert.True(t, acmawsapi.HasScope(action),
"acm action %q has no resource scope entry: add one to domains/acm/awsapi/authz.go", action)
}
for _, action := range acmawsapi.ScopedActions() {
_, ok := acmActions[action]
assert.True(t, ok,
"acmScopes has an entry for %q, which the dispatch table does not serve: remove it from domains/acm/awsapi/authz.go", action)
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ func acmCertARN(id string) string {
// which is what proves the policy check ran ahead of the certificate existing.
func dispatchACM(t *testing.T, gw *GatewayConfig, action, body string) error {
t.Helper()
return gw.ACM_Request(httptest.NewRecorder(), setupACMRequest("CertificateManager."+action, body))
return gw.serveACM(httptest.NewRecorder(), setupACMRequest("CertificateManager."+action, body))
}
// TestACMRequest_ScopedDenyFires is the bypass this work closes. An operator
@@ -185,7 +185,7 @@ func TestACMRequest_MissingAccountID(t *testing.T) {
req := setupACMRequest("CertificateManager.ListCertificates", `{}`)
req = req.WithContext(context.WithValue(req.Context(), ctxAccountID, ""))
err := gw.ACM_Request(httptest.NewRecorder(), req)
err := gw.serveACM(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
@@ -58,6 +58,8 @@ func acmPipelineApp(t *testing.T, docs []handlers_iam.PolicyDocument) (*GatewayC
},
}
withACM(gw, acmawsapi.Deps{})
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", gw.Request)
@@ -165,12 +167,14 @@ func TestACMActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
{"bare action", "ListCertificates", "ListCertificates"},
{"trailing dot", "CertificateManager.", ""},
}
acmEntry, ok := withACM(&GatewayConfig{}, acmawsapi.Deps{}).registered(acmawsapi.ServiceName)
require.True(t, ok)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.wantAction, acmActionFromTarget(tc.target))
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", tc.target)
assert.Equal(t, tc.wantAction, acmEntry.ResolveAction(req))
assert.Equal(t, tc.wantAction, resolveNonQueryAction(req, "acm"),
"dispatch and telemetry parsers must agree on %q", tc.target)
})
@@ -236,7 +240,7 @@ func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
func TestRequest_ClusterUnavailableNilConn_ACM(t *testing.T) {
for _, action := range []string{"ListCertificates", "DeleteCertificate"} {
t.Run(action, func(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
gw := withACM(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}, acmawsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Amz-Target", "CertificateManager."+action)
@@ -324,7 +328,7 @@ func TestACMPipeline_RequestAudit_RecordsResolvedAction(t *testing.T) {
// that drops or adds an action, including by moving ACM onto the
// registration seam, fails here.
func TestACMInventory_RegisteredArePinned(t *testing.T) {
acm := AWSOperationInventory(nil)["acm"]
acm := AWSOperationInventory(acmRegistrationInventory())["acm"]
want := []string{
"ImportCertificate", "RequestCertificate", "DescribeCertificate", "GetCertificate",
+39
View File
@@ -0,0 +1,39 @@
package gateway
import (
"net/http"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
"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 {
if deps.NATS == nil {
deps.NATS = gw.NATSConn
}
b := dispatch.NewBuilder()
if err := b.Register(acmawsapi.NewRegistration(deps)); err != nil {
panic(err)
}
gw.Services = b.Build()
return gw
}
// serveACM dispatches r to gw's registered ACM service exactly as Request does
// past its NATS gate, first registering one with no capabilities if needed.
func (gw *GatewayConfig) serveACM(w http.ResponseWriter, r *http.Request) error {
entry, ok := gw.registered(acmawsapi.ServiceName)
if !ok {
withACM(gw, acmawsapi.Deps{})
entry, _ = gw.registered(acmawsapi.ServiceName)
}
return gw.dispatchRegistered(entry, w, r)
}
// 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()
}
+3 -28
View File
@@ -22,35 +22,10 @@ func setupACMRequest(target, body string) *http.Request {
return withTestIdentity(req.WithContext(ctx))
}
func TestACMActionFromTarget(t *testing.T) {
assert.Equal(t, "ImportCertificate", acmActionFromTarget("CertificateManager.ImportCertificate"))
assert.Equal(t, "ListCertificates", acmActionFromTarget("ListCertificates"))
assert.Empty(t, acmActionFromTarget(""))
}
func TestACMActionsMap_AllActionsRegistered(t *testing.T) {
expected := []string{
"ImportCertificate",
"DescribeCertificate",
"GetCertificate",
"ListCertificates",
"DeleteCertificate",
"ListTagsForCertificate",
"AddTagsToCertificate",
"RemoveTagsFromCertificate",
"RequestCertificate",
}
for _, action := range expected {
_, ok := acmActions[action]
assert.True(t, ok, "action %q should be registered in acmActions", action)
}
assert.Len(t, acmActions, len(expected))
}
func TestACMRequest_MissingTarget(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
w := httptest.NewRecorder()
err := gw.ACM_Request(w, setupACMRequest("", ""))
err := gw.serveACM(w, setupACMRequest("", ""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
@@ -58,7 +33,7 @@ func TestACMRequest_MissingTarget(t *testing.T) {
func TestACMRequest_UnknownAction(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
w := httptest.NewRecorder()
err := gw.ACM_Request(w, setupACMRequest("CertificateManager.BogusAction", "{}"))
err := gw.serveACM(w, setupACMRequest("CertificateManager.BogusAction", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
@@ -68,7 +43,7 @@ func TestACMRequest_UnknownAction(t *testing.T) {
func TestACMRequest_KnownActionNoNATS(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
w := httptest.NewRecorder()
err := gw.ACM_Request(w, setupACMRequest("CertificateManager.ListCertificates", "{}"))
err := gw.serveACM(w, setupACMRequest("CertificateManager.ListCertificates", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
+12 -2
View File
@@ -8,8 +8,10 @@ import (
"encoding/xml"
"errors"
"fmt"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"io"
@@ -2674,7 +2676,11 @@ func TestSigV4Auth_UnservedServiceNoJSONTellGetsXMLEnvelope(t *testing.T) {
// envelope by exactly the path it did before the fallback was added — a
// regression guard proving the fallback widened nothing for served scopes.
func TestWriteSigV4Error_JSONErrorServicesUnaffectedByFallback(t *testing.T) {
gw := withECR(&GatewayConfig{DisableLogging: true}, awsapi.Deps{})
gw := &GatewayConfig{DisableLogging: true}
b := dispatch.NewBuilder()
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
gw.Services = b.Build()
for _, svc := range []string{"eks", "ecr", "acm", "ecs", "tagging",
"bedrock", "bedrock-runtime", "bedrock-agent", "bedrock-agent-runtime"} {
@@ -2745,7 +2751,11 @@ func TestSigV4Auth_ResolvesCtxActionForNonQueryServices(t *testing.T) {
},
},
}
gw := withECR(&GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: mockSvc}, awsapi.Deps{})
gw := &GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: mockSvc}
b := dispatch.NewBuilder()
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
gw.Services = b.Build()
var gotAction, gotThrottleKey string
r := chi.NewRouter()
+1 -1
View File
@@ -419,7 +419,7 @@ func TestValidateServices(t *testing.T) {
t.Run("claiming a legacy name fails", func(t *testing.T) {
b := dispatch.NewBuilder()
require.NoError(t, b.Register(dispatch.Registration{
Service: "acm",
Service: "ec2",
Dispatch: func(http.ResponseWriter, dispatch.Invocation) error { return nil },
Errors: dispatch.ErrorEnvelopeJSON,
Inventory: dispatch.Inventory{Registered: []string{fakeAction}},
+1 -4
View File
@@ -299,7 +299,6 @@ var supportedServices = map[string]bool{
"elasticloadbalancing": true,
"eks": true,
"ecs": true,
"acm": true,
"rds": true,
"tagging": true,
"spinifex": true,
@@ -401,7 +400,7 @@ func (gw *GatewayConfig) throttleKeyFuncs() []ratelimit.KeyFunc {
// XML body to these clients is an unparseable "<?xml…" deserialization error.
func jsonErrorService(svc string) bool {
switch svc {
case "eks", "acm", "ecs", "tagging",
case "eks", "ecs", "tagging",
"bedrock", "bedrock-runtime", "bedrock-agent", "bedrock-agent-runtime":
return true
}
@@ -523,8 +522,6 @@ func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
err = gw.BedrockAgentRuntime_Request(w, r)
case "ecs":
err = gw.ECS_Request(w, r)
case "acm":
err = gw.ACM_Request(w, r)
case "rds":
err = gw.RDS_Request(w, r)
case "tagging":
+9 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/xml"
"errors"
"fmt"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"io"
@@ -23,6 +24,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
@@ -171,7 +173,7 @@ func TestErrorHandler_PrefersCallSiteMessage(t *testing.T) {
// ResourceInUseException collision check: ACM's DeleteCertificate must not
// surface EKS's "cluster already exists" wording for the shared wire code.
func TestErrorHandler_ACMResourceInUse_UsesACMWording(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
gw := withACM(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}, acmawsapi.Deps{})
req := httptest.NewRequest(http.MethodPost, "/", nil)
ctx := context.WithValue(req.Context(), ctxService, "acm")
req = req.WithContext(ctx)
@@ -234,8 +236,12 @@ func TestErrorHandler_NoMessageSupplied_MatchesErrorLookup(t *testing.T) {
// validation code outside EC2: the default must not be EC2's Reserved Instance
// or resource-ID wording, whichever envelope the service uses.
func TestErrorHandler_NonEC2ValidationDefaults_DropEC2Wording(t *testing.T) {
gw := withECR(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}, awsapi.Deps{})
services := append(slices.Collect(maps.Keys(supportedServices)), awsapi.ServiceName)
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
b := dispatch.NewBuilder()
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
gw.Services = b.Build()
services := append(slices.Collect(maps.Keys(supportedServices)), awsapi.ServiceName, acmawsapi.ServiceName)
for _, svc := range services {
if svc == "ec2" {
continue
-3
View File
@@ -24,9 +24,6 @@ type ServiceOperationInventory struct {
// operations and delegates that REST surface to Predastore.
func AWSOperationInventory(registered map[string]dispatch.Inventory) map[string]ServiceOperationInventory {
inventory := map[string]ServiceOperationInventory{
"acm": {
Registered: mapKeys(acmActions),
},
"ec2": {
Registered: mapKeys(ec2Actions),
},
+30 -11
View File
@@ -77,13 +77,24 @@ var declaredInventoryReaders = map[string]bool{
"cmd/aws-model-coverage": true,
}
// domainAWSAPIImpls are the registered domains' awsapi packages: the
// implementations ADR-0002 acceptance 3 confines to the awsgw role,
// alongside the registration contract itself.
var domainAWSAPIImpls = map[string]string{
modulePrefix + "domains/ecr/awsapi": "ECR implementation",
modulePrefix + "domains/acm/awsapi": "ACM implementation",
}
// ADR-0002 acceptance 3: the awsgw role is the only production composition
// point that imports both the registration contract and ECR implementation.
// point that imports both the registration contract and a registered
// domain's awsapi implementation (ECR, ACM, …).
func TestS4_OnlyAWSGWRoleImportsDispatchAndECRAWSAPI(t *testing.T) {
const (
contract = modulePrefix + "ingress/aws/dispatch"
ecrImpl = modulePrefix + "domains/ecr/awsapi"
)
const contract = modulePrefix + "ingress/aws/dispatch"
watched := map[string]bool{contract: true}
for impl := range domainAWSAPIImpls {
watched[impl] = true
}
moduleRoot := filepath.Dir(filepath.Dir(filepath.Dir(ingressAWSDir(t))))
imports := map[string]map[string]token.Position{}
fset := token.NewFileSet()
@@ -112,7 +123,7 @@ func TestS4_OnlyAWSGWRoleImportsDispatchAndECRAWSAPI(t *testing.T) {
pkg := filepath.ToSlash(rel)
for _, imp := range f.Imports {
p, _ := strconv.Unquote(imp.Path.Value)
if p != contract && p != ecrImpl {
if !watched[p] {
continue
}
if imports[pkg] == nil {
@@ -127,14 +138,22 @@ func TestS4_OnlyAWSGWRoleImportsDispatchAndECRAWSAPI(t *testing.T) {
}
}
if len(imports[compositionRoot]) != 2 {
t.Fatalf("%s no longer imports both %s and %s; the invariant would pass vacuously", compositionRoot, contract, ecrImpl)
if len(imports[compositionRoot]) != len(watched) {
t.Fatalf("%s no longer imports the registration contract and every registered domain awsapi (%d of %d); the invariant would pass vacuously",
compositionRoot, len(imports[compositionRoot]), len(watched))
}
for pkg, found := range imports {
if len(found) < 2 || pkg == compositionRoot || declaredInventoryReaders[pkg] {
if pkg == compositionRoot || declaredInventoryReaders[pkg] {
continue
}
t.Errorf("ADR-0002 acceptance 3 \"The awsgw role is the only production composition point that imports both the registration contract and ECR implementation\": %s imports %s (%s) and %s (%s); register ECR in runtime/roles/awsgw instead",
pkg, contract, found[contract], ecrImpl, found[ecrImpl])
if _, hasContract := found[contract]; !hasContract {
continue
}
for impl, label := range domainAWSAPIImpls {
if pos, ok := found[impl]; ok {
t.Errorf("ADR-0002 acceptance 3 \"The awsgw role is the only production composition point that imports both the registration contract and a registered domain's awsapi implementation\": %s imports %s (%s) and %s (%s, the %s); register it in runtime/roles/awsgw instead",
pkg, contract, found[contract], impl, pos, label)
}
}
}
}
+3 -1
View File
@@ -24,6 +24,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/daemon"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
@@ -502,7 +503,8 @@ func launchService(config *config.ClusterConfig) error {
AuthorizationToken: awsapi.NewAuthorizationTokenActionService(ecrIssuer, ecrEndpoint),
NATS: natsConn,
})
if err := wireServiceRegistry(&gw, ecrControlPlane); err != nil {
acmControlPlane := acmawsapi.NewRegistration(acmawsapi.Deps{NATS: natsConn})
if err := wireServiceRegistry(&gw, ecrControlPlane, acmControlPlane); err != nil {
return fmt.Errorf("awsgw: %w", err)
}
+31 -4
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -14,9 +15,10 @@ import (
)
// wireServiceRegistry is the exact call launchService makes to wire
// gw.Services before serving a request. Production's ECR registration must
// wire an overlap-free registry serving ecr; a duplicate or a registration
// claiming a legacy name must fail it, so the role refuses to start.
// gw.Services before serving a request. Production's ECR and ACM
// registrations must wire an overlap-free registry serving ecr and acm; a
// duplicate or a registration claiming a legacy name must fail it, so the
// role refuses to start.
func TestWireServiceRegistry(t *testing.T) {
t.Run("ECR registration wires and passes the overlap guard", func(t *testing.T) {
gw := &gateway.GatewayConfig{}
@@ -32,10 +34,35 @@ func TestWireServiceRegistry(t *testing.T) {
require.Error(t, wireServiceRegistry(gw, reg, reg))
})
t.Run("ACM registration wires and passes the overlap guard", func(t *testing.T) {
gw := &gateway.GatewayConfig{}
require.NoError(t, wireServiceRegistry(gw, acmawsapi.NewRegistration(acmawsapi.Deps{})))
require.NotNil(t, gw.Services)
_, ok := gw.Services.Lookup("acm")
assert.True(t, ok)
})
t.Run("a duplicate ACM registration fails startup", func(t *testing.T) {
gw := &gateway.GatewayConfig{}
reg := acmawsapi.NewRegistration(acmawsapi.Deps{})
require.Error(t, wireServiceRegistry(gw, reg, reg))
})
t.Run("ECR and ACM registrations wire together, as production does", func(t *testing.T) {
gw := &gateway.GatewayConfig{}
require.NoError(t, wireServiceRegistry(gw,
awsapi.NewRegistration(awsapi.Deps{}), acmawsapi.NewRegistration(acmawsapi.Deps{})))
require.NotNil(t, gw.Services)
_, ecrOK := gw.Services.Lookup("ecr")
_, acmOK := gw.Services.Lookup("acm")
assert.True(t, ecrOK)
assert.True(t, acmOK)
})
t.Run("a registration claiming a legacy name fails startup", func(t *testing.T) {
gw := &gateway.GatewayConfig{}
err := wireServiceRegistry(gw, dispatch.Registration{
Service: "acm",
Service: "ec2",
Dispatch: func(http.ResponseWriter, dispatch.Invocation) error { return nil },
Errors: dispatch.ErrorEnvelopeJSON,
Inventory: dispatch.Inventory{Registered: []string{"Whatever"}},
+3 -1
View File
@@ -20,8 +20,8 @@
package integration
import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"encoding/json"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"net/http/httptest"
"testing"
@@ -34,6 +34,7 @@ import (
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
@@ -180,6 +181,7 @@ func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option)
AuthorizationToken: awsapi.NewAuthorizationTokenActionService(ecrIssuer, ecrEndpoint),
NATS: nc,
})))
require.NoError(t, services.Register(acmawsapi.NewRegistration(acmawsapi.Deps{NATS: nc})))
cfg := &gateway.GatewayConfig{
DisableLogging: true,
@@ -15,6 +15,7 @@ import (
awscreds "github.com/aws/aws-sdk-go/aws/credentials"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/mulgadc/spinifex/internal/awsmodel"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
awsdispatch "github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -34,7 +35,8 @@ const slowRequest = 500 * time.Millisecond
// daemon-lite wired, and records each verdict for the suite report.
func TestRequestConformance(t *testing.T) {
inventory := gateway.AWSOperationInventory(map[string]awsdispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
})
for _, service := range awsmodel.Services() {
// Predastore serves S3, not the gateway.