From 93c44dbc7155db5a4d5e857139e9b35d7ba5847f Mon Sep 17 00:00:00 2001 From: Brecci Date: Fri, 17 Jul 2026 18:26:20 -0300 Subject: [PATCH] feat(middleware): forward product on M2M auth (flag-gated) M2M (application-token) authorization calls sent no product, so once Custom permission resources are prefixed with "{product}/" (Item 17 F2), a bare M2M request stops matching the stored resource and the auth service denies it (403). Forward the route product for application tokens too, gated by AUTH_M2M_PRODUCT_FORWARD_ENABLED (default off) so deploy != release: prefix isolation activates by flipping the flag, with no consumer code change. Normal-user behavior is unchanged. A shared shouldForwardProduct helper keeps the request body and telemetry payload in lockstep. X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 ++ auth/middleware/middleware.go | 50 ++++++--- auth/middleware/middlewareGRPC.go | 27 ++--- auth/middleware/middlewareGRPC_test.go | 115 +++++++++++++++++++-- auth/middleware/middleware_test.go | 137 ++++++++++++++++++++++++- 5 files changed, 303 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3b65fc3..2349735 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ In your environment configuration or `.env` file, set the following environment ```dotenv PLUGIN_AUTH_ADDRESS=http://localhost:4000 PLUGIN_AUTH_ENABLED=true + +# Optional. When "true", the client also forwards the route product on M2M +# (application-token) authorization calls, so the auth service can isolate +# permissions by product (matching product-prefixed resources). Defaults to +# false, preserving the previous behavior of sending no product for M2M. +AUTH_M2M_PRODUCT_FORWARD_ENABLED=false ``` ### 2. Create a new instance of the middleware: diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index b572ee5..ee55324 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -30,6 +30,15 @@ type AuthClient struct { Address string Enabled bool Logger log.Logger + + // ForwardM2MProduct, when true, forwards the route product on M2M + // (application-token) authorization calls, letting the auth service strip the + // "{product}/" prefix from stored resources and dual-match a bare request. + // It is read once from AUTH_M2M_PRODUCT_FORWARD_ENABLED at construction; the + // default (false) preserves the prior behavior of sending no product for M2M. + // Gating it by env keeps deploy != release: flipping the flag activates M2M + // product isolation without a code change in consumers. + ForwardM2MProduct bool } type AuthResponse struct { @@ -165,11 +174,14 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } } + forwardM2MProduct := os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true" + if !enabled || address == "" { return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, + Address: address, + Enabled: enabled, + Logger: l, + ForwardM2MProduct: forwardM2MProduct, } } @@ -182,21 +194,21 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient if err != nil { logErrorf(context.Background(), l, failedToConnectMsg, err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logErrorf(context.Background(), l, failedToConnectMsg, resp.Status) - return &AuthClient{Address: address, Enabled: enabled, Logger: l} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} } body, err := io.ReadAll(resp.Body) if err != nil { logErrorf(context.Background(), l, "Failed to read response body: %v", err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} } if string(body) == "healthy" { @@ -206,15 +218,16 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, + Address: address, + Enabled: enabled, + Logger: l, + ForwardM2MProduct: forwardM2MProduct, } } // Authorize is a middleware function for the Fiber framework that checks if a user is authorized to perform a specific action on a resource. -// product identifies the product/application owning the route (e.g. "midaz"); it is forwarded only for normal-user flows so the auth service can -// isolate permissions by product. M2M (application) tokens carry no product isolation and are identified by their own subject claim. +// product identifies the product/application owning the route (e.g. "midaz"); it is forwarded for normal-user flows, and for M2M (application) +// flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set, so the auth service can isolate permissions by product. M2M tokens are identified by their own subject claim. // If the user is authorized, the request is passed to the next handler; otherwise, a 403 Forbidden status is returned. func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handler { return func(c fiber.Ctx) error { @@ -322,6 +335,19 @@ func (auth *AuthClient) deriveSubject(ctx context.Context, span trace.Span, clai } } +// shouldForwardProduct reports whether the route product must be forwarded to the +// auth service so it can isolate permissions by product (strip the "{product}/" +// prefix from stored resources and dual-match a bare request). It is forwarded for +// normal-user flows, and for M2M (application) flows when forwardM2MProduct is +// enabled; an empty product is never forwarded (gate-by-presence). +func shouldForwardProduct(userType, product string, forwardM2MProduct bool) bool { + if product == "" { + return false + } + + return userType == normalUser || (userType == application && forwardM2MProduct) +} + func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resource, action, accessToken string) (bool, int, error) { _, tracer, reqID, _ := observability.NewTrackingFromContext(ctx) @@ -367,7 +393,7 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc "action": action, } - if userType == normalUser && product != "" { + if shouldForwardProduct(userType, product, auth.ForwardM2MProduct) { requestBody["product"] = product } diff --git a/auth/middleware/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index b032e5a..41d6f54 100644 --- a/auth/middleware/middlewareGRPC.go +++ b/auth/middleware/middlewareGRPC.go @@ -30,9 +30,9 @@ type Policy struct { // - MethodPolicies keyed by info.FullMethod ("/pkg.Service/Method"). // - DefaultPolicy used when a method mapping is absent. // - SubResolver derives the product identifier (e.g., "midaz") passed to -// checkAuthorization as its product argument. It is forwarded only for -// normal-user tokens (product isolation); M2M (application) tokens carry no -// product isolation and are identified by their own subject claim. Return "" +// checkAuthorization as its product argument. It is forwarded for normal-user +// tokens, and for M2M (application) tokens when AUTH_M2M_PRODUCT_FORWARD_ENABLED +// is set; M2M tokens are identified by their own subject claim. Return "" // when not applicable. type PolicyConfig struct { MethodPolicies map[string]Policy @@ -48,8 +48,8 @@ type PolicyConfig struct { // Telemetry: // - Sets app.request.request_id. // - Sets app.request.payload with {resource, action}, including product only when -// it is actually forwarded to the auth service (normal-user flows with a -// non-empty product), mirroring the request body. +// it is actually forwarded to the auth service (normal-user, or M2M when +// AUTH_M2M_PRODUCT_FORWARD_ENABLED is set), mirroring the request body. func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { if auth == nil || !auth.Enabled || auth.Address == "" { @@ -76,8 +76,8 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } // product is the resolved product identifier passed as checkAuthorization's - // product argument. It is forwarded only for normal-user flows; M2M - // (application) tokens carry no product isolation. + // product argument. It is forwarded for normal-user flows, and for M2M + // (application) flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set. var product string if cfg.SubResolver != nil { @@ -91,7 +91,7 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } } - payload := authPayload(token, product, pol.Resource, pol.Action) + payload := authPayload(token, product, pol.Resource, pol.Action, auth.ForwardM2MProduct) if err := tracing.SetSpanAttributesFromValue(span, "app.request.payload", payload, nil); err != nil { tracing.HandleSpanError(span, "failed to set span payload", err) } @@ -211,14 +211,15 @@ func SubFromMetadata(key string) func(ctx context.Context, fullMethod string, re // authPayload builds the telemetry payload mirroring the request body sent to the // auth service by checkAuthorization: product is included only when it is actually -// forwarded (normal-user flows with a non-empty product). -func authPayload(token, product, resource, action string) map[string]string { +// forwarded — normal-user flows with a non-empty product, or M2M (application) +// flows with a non-empty product when forwardM2MProduct is enabled. +func authPayload(token, product, resource, action string, forwardM2MProduct bool) map[string]string { payload := map[string]string{ "resource": resource, "action": action, } - if tokenTypeClaim(token) == normalUser && product != "" { + if shouldForwardProduct(tokenTypeClaim(token), product, forwardM2MProduct) { payload["product"] = product } @@ -290,8 +291,8 @@ func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServ } // product is the resolved product identifier passed as checkAuthorization's - // product argument. It is forwarded only for normal-user flows; M2M - // (application) tokens carry no product isolation. + // product argument. It is forwarded for normal-user flows, and for M2M + // (application) flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set. var product string if cfg.SubResolver != nil { diff --git a/auth/middleware/middlewareGRPC_test.go b/auth/middleware/middlewareGRPC_test.go index a89ba97..a6f942f 100644 --- a/auth/middleware/middlewareGRPC_test.go +++ b/auth/middleware/middlewareGRPC_test.go @@ -767,7 +767,7 @@ func TestAuthPayload_ProductGating(t *testing.T) { "sub": "user123", }) - payload := authPayload(token, "midaz", "resource", "read") + payload := authPayload(token, "midaz", "resource", "read", false) // resource/action are always present. assert.Equal(t, "resource", payload["resource"]) @@ -776,7 +776,7 @@ func TestAuthPayload_ProductGating(t *testing.T) { assert.Equal(t, "midaz", payload["product"]) }) - t.Run("application_omits_product", func(t *testing.T) { + t.Run("application_flag_off_omits_product", func(t *testing.T) { t.Parallel() token := createTestJWT(jwt.MapClaims{ @@ -785,11 +785,46 @@ func TestAuthPayload_ProductGating(t *testing.T) { "sub": "acme-org/my-app", }) - payload := authPayload(token, "midaz", "resource", "read") + payload := authPayload(token, "midaz", "resource", "read", false) assert.Equal(t, "resource", payload["resource"]) assert.Equal(t, "read", payload["action"]) - // M2M carries no product isolation, so product is not recorded. + // With the flag off, M2M forwards no product isolation, so it is not recorded. + _, hasProduct := payload["product"] + assert.False(t, hasProduct) + }) + + t.Run("application_flag_on_includes_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + payload := authPayload(token, "midaz", "resource", "read", true) + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // With the flag on, M2M forwards the product, so telemetry mirrors it. + assert.Equal(t, "midaz", payload["product"]) + }) + + t.Run("application_flag_on_empty_product_omits_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + payload := authPayload(token, "", "resource", "read", true) + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // Empty product is never forwarded, even with the flag on. _, hasProduct := payload["product"] assert.False(t, hasProduct) }) @@ -803,7 +838,7 @@ func TestAuthPayload_ProductGating(t *testing.T) { "sub": "user123", }) - payload := authPayload(token, "", "resource", "read") + payload := authPayload(token, "", "resource", "read", false) assert.Equal(t, "resource", payload["resource"]) assert.Equal(t, "read", payload["action"]) @@ -879,11 +914,79 @@ func TestNewGRPCAuthUnaryPolicy_ApplicationSubject(t *testing.T) { // Subject is the real sub of the application token. assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) - // Product is not forwarded for M2M tokens. + // Product is not forwarded for M2M tokens when the flag is off (default). _, hasProduct := capturedBody["product"] assert.False(t, hasProduct) } +func TestNewGRPCAuthUnaryPolicy_ApplicationSubject_ForwardM2MProductEnabled(t *testing.T) { + t.Parallel() + + // With ForwardM2MProduct enabled, an application (M2M) token flowing through the + // gRPC unary path must forward the resolved product in the request body so the + // auth service can dual-match the "{product}/" prefix on stored resources. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(AuthResponse{Authorized: true}); err != nil { + t.Errorf("mock server: failed to encode response: %v", err) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + ForwardM2MProduct: true, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + defaultPol := Policy{Resource: "res", Action: "read"} + cfg := PolicyConfig{ + DefaultPolicy: &defaultPol, + SubResolver: func(_ context.Context, _ string, _ any) (string, error) { + return "midaz", nil + }, + } + interceptor := NewGRPCAuthUnaryPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + info := &grpc.UnaryServerInfo{FullMethod: "/pkg.Service/DoThing"} + + resp, err := interceptor(ctx, "req", info, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + assert.True(t, called) + + // Subject is the real sub of the application token. + assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) + // Product IS forwarded for M2M tokens when the flag is on. + assert.Equal(t, "midaz", capturedBody["product"]) +} + // --------------------------------------------------------------------------- // NewGRPCAuthStreamPolicy // --------------------------------------------------------------------------- diff --git a/auth/middleware/middleware_test.go b/auth/middleware/middleware_test.go index bfaae7f..948f3a1 100644 --- a/auth/middleware/middleware_test.go +++ b/auth/middleware/middleware_test.go @@ -162,7 +162,110 @@ func TestCheckAuthorization_ApplicationUser_SubjectConstruction(t *testing.T) { // For M2M, the subject is the real sub claim of the application token. assert.Equal(t, "app-sub", capturedBody["sub"]) - // Product is NOT forwarded for non-normal-user tokens. + // Product is NOT forwarded for application tokens when ForwardM2MProduct is off (default). + _, hasProduct := capturedBody["product"] + assert.False(t, hasProduct) +} + +func TestCheckAuthorization_Application_ForwardM2MProductEnabled_ForwardsProduct(t *testing.T) { + t.Parallel() + + // With ForwardM2MProduct enabled, an application (M2M) token forwards the route + // product so the auth service can strip the "{product}/" prefix from stored + // resources and dual-match a bare request. The subject stays the real sub claim. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + ForwardM2MProduct: true, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + + // Subject is still the real sub of the application token. + assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) + // Product IS forwarded for M2M when ForwardM2MProduct is enabled. + assert.Equal(t, "midaz", capturedBody["product"]) +} + +func TestCheckAuthorization_Application_ForwardM2MProductEnabled_EmptyProduct_NotForwarded(t *testing.T) { + t.Parallel() + + // Even with ForwardM2MProduct enabled, an empty product is never forwarded + // (gate-by-presence preserved). + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + ForwardM2MProduct: true, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + _, hasProduct := capturedBody["product"] assert.False(t, hasProduct) } @@ -560,6 +663,38 @@ func TestCheckAuthorization_ServerReturnsInvalidJSON(t *testing.T) { assert.Contains(t, err.Error(), "failed to unmarshal") } +// --------------------------------------------------------------------------- +// NewAuthClient - ForwardM2MProduct flag +// --------------------------------------------------------------------------- + +func TestNewAuthClient_ReadsForwardM2MProductFlag(t *testing.T) { + // Cannot use t.Parallel(): subtests use t.Setenv which modifies process env. + // enabled=false / empty address returns early without any network call, so the + // flag wiring is exercised in isolation. + logger := log.Logger(&testLogger{}) + + t.Run("flag_true_enables_forward", func(t *testing.T) { + t.Setenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED", "true") + + client := NewAuthClient("", false, &logger) + assert.True(t, client.ForwardM2MProduct) + }) + + t.Run("flag_absent_defaults_false", func(t *testing.T) { + t.Setenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED", "") + + client := NewAuthClient("", false, &logger) + assert.False(t, client.ForwardM2MProduct) + }) + + t.Run("flag_non_true_value_is_false", func(t *testing.T) { + t.Setenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED", "1") + + client := NewAuthClient("", false, &logger) + assert.False(t, client.ForwardM2MProduct) + }) +} + // --------------------------------------------------------------------------- // GetApplicationToken // ---------------------------------------------------------------------------