Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 38 additions & 12 deletions auth/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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" {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}

Expand Down
27 changes: 14 additions & 13 deletions auth/middleware/middlewareGRPC.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 == "" {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
115 changes: 109 additions & 6 deletions auth/middleware/middlewareGRPC_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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{
Expand All @@ -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)
})
Expand All @@ -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"])
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading