diff --git a/cmd/aws-model-coverage/main.go b/cmd/aws-model-coverage/main.go index 0ff4bcc1a..fda08b5ee 100644 --- a/cmd/aws-model-coverage/main.go +++ b/cmd/aws-model-coverage/main.go @@ -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() { diff --git a/spinifex/domains/acm/awsapi/registration.go b/spinifex/domains/acm/awsapi/registration.go new file mode 100644 index 000000000..3355e1d89 --- /dev/null +++ b/spinifex/domains/acm/awsapi/registration.go @@ -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 "." or bare "" 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 +} diff --git a/spinifex/domains/acm/awsapi/registration_test.go b/spinifex/domains/acm/awsapi/registration_test.go new file mode 100644 index 000000000..c15ed054f --- /dev/null +++ b/spinifex/domains/acm/awsapi/registration_test.go @@ -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) + }) + } +} diff --git a/spinifex/gateway/acm.go b/spinifex/gateway/acm.go deleted file mode 100644 index a5a234ac2..000000000 --- a/spinifex/gateway/acm.go +++ /dev/null @@ -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.) 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 "." or bare "" 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 -} diff --git a/spinifex/gateway/acm_authz_completeness_test.go b/spinifex/gateway/acm_authz_completeness_test.go deleted file mode 100644 index 9b2dd18af..000000000 --- a/spinifex/gateway/acm_authz_completeness_test.go +++ /dev/null @@ -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) - } -} diff --git a/spinifex/gateway/acm_authz_test.go b/spinifex/gateway/acm_authz_test.go index 3cf9fffbf..35d6d5967 100644 --- a/spinifex/gateway/acm_authz_test.go +++ b/spinifex/gateway/acm_authz_test.go @@ -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()) } diff --git a/spinifex/gateway/acm_characterization_test.go b/spinifex/gateway/acm_characterization_test.go index e53a05e2f..5e40acebc 100644 --- a/spinifex/gateway/acm_characterization_test.go +++ b/spinifex/gateway/acm_characterization_test.go @@ -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", diff --git a/spinifex/gateway/acm_registration_test.go b/spinifex/gateway/acm_registration_test.go new file mode 100644 index 000000000..cefe06dd1 --- /dev/null +++ b/spinifex/gateway/acm_registration_test.go @@ -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() +} diff --git a/spinifex/gateway/acm_test.go b/spinifex/gateway/acm_test.go index 9b9cd31cd..4b7fce3cf 100644 --- a/spinifex/gateway/acm_test.go +++ b/spinifex/gateway/acm_test.go @@ -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()) } diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index 9fd35a801..ca6014bf2 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -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() diff --git a/spinifex/gateway/dispatch_seam_test.go b/spinifex/gateway/dispatch_seam_test.go index 72a5b2770..7f98255d6 100644 --- a/spinifex/gateway/dispatch_seam_test.go +++ b/spinifex/gateway/dispatch_seam_test.go @@ -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}}, diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index d53d863e4..b403984da 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -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 "