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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ PLUGIN_AUTH_ENABLED=true
# 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

# Optional. When "true", the middleware fails closed: if auth is disabled
# (PLUGIN_AUTH_ENABLED=false) or misconfigured (empty PLUGIN_AUTH_ADDRESS),
# every protected route refuses to serve (HTTP 503 / gRPC Unavailable) instead
# of passing through unauthenticated. Defaults to false, preserving the prior
# fail-open behavior. Set it in security-sensitive deployments so a
# missing/typo'd address cannot silently downgrade a protected service to open.
AUTH_REQUIRED=false
```

### 2. Create a new instance of the middleware:
Expand Down
48 changes: 44 additions & 4 deletions auth/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ type AuthClient struct {
// Gating it by env keeps deploy != release: flipping the flag activates M2M
// product isolation without a code change in consumers.
ForwardM2MProduct bool

// Required, when true, makes the middleware fail closed: if auth is disabled
// or misconfigured (!Enabled || Address == ""), every protected route refuses
// to serve (HTTP 503 / gRPC Unavailable) instead of passing through with
// c.Next()/handler(). It is read once from AUTH_REQUIRED at construction; the
// default (false) preserves the prior fail-open pass-through behavior exactly.
// This inverts the default posture only for deployments that opt in, so a
// missing/typo'd address can no longer silently downgrade a protected service
// to fully open.
Required bool
}

type AuthResponse struct {
Expand Down Expand Up @@ -175,13 +185,20 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient
}

forwardM2MProduct := os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true"
required := os.Getenv("AUTH_REQUIRED") == "true"

if !enabled || address == "" {
if required {
logErrorf(context.Background(), l,
"AUTH_REQUIRED is set but auth is disabled or address is empty: middleware will fail closed (refuse to serve)")
}

return &AuthClient{
Address: address,
Enabled: enabled,
Logger: l,
ForwardM2MProduct: forwardM2MProduct,
Required: required,
}
}

Expand All @@ -194,21 +211,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, ForwardM2MProduct: forwardM2MProduct}
return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required}
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
logErrorf(context.Background(), l, failedToConnectMsg, resp.Status)

return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct}
return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required}
}

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, ForwardM2MProduct: forwardM2MProduct}
return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required}
}

if string(body) == "healthy" {
Expand All @@ -222,9 +239,26 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient
Enabled: enabled,
Logger: l,
ForwardM2MProduct: forwardM2MProduct,
Required: required,
}
}

// canAuthorize reports whether the client is able to perform an authorization
// check: non-nil, Enabled, and holding an Address. When it returns false the
// caller passes the request through (default, fail open) unless Required is set.
// A nil receiver is safe and reports false, so a nil client is never usable.
func (auth *AuthClient) canAuthorize() bool {
return auth != nil && auth.Enabled && auth.Address != ""
}

// mustRefuse reports whether a client that cannot authorize must fail closed
// (refuse to serve) instead of passing the request through. It is true only when
// the client is non-nil, opted in via Required (AUTH_REQUIRED), and cannot
// authorize (disabled or no address). This is the fail-closed switch from #107.
func (auth *AuthClient) mustRefuse() bool {
return auth != nil && auth.Required && !auth.canAuthorize()
}

// 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 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.
Expand All @@ -235,7 +269,13 @@ func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handle

_, tracer, reqID, _ := observability.NewTrackingFromContext(ctx)

if !auth.Enabled || auth.Address == "" {
if auth.mustRefuse() {
// AUTH_REQUIRED opted in but auth is disabled/misconfigured: refuse to
// serve (fail closed) instead of silently passing the request through.
return c.Status(http.StatusServiceUnavailable).SendString("Service Unavailable")
}

if !auth.canAuthorize() {
return c.Next()
}

Expand Down
80 changes: 43 additions & 37 deletions auth/middleware/middlewareGRPC.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ type PolicyConfig struct {
// 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 == "" {
if auth.mustRefuse() {
// AUTH_REQUIRED opted in but auth is disabled/misconfigured: refuse the
// RPC (fail closed) instead of silently passing it through.
return nil, status.Error(codes.Unavailable, "service unavailable")
}

if !auth.canAuthorize() {
return handler(ctx, req)
}

Expand Down Expand Up @@ -106,28 +112,39 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer
}

// Propagate tenant claims if multi-tenant mode is enabled
if os.Getenv("MULTI_TENANT_ENABLED") == "true" {
tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token)
md, _ := metadata.FromIncomingContext(ctx)
md = md.Copy()
ctx, _ = tenantContext(ctx, token)

if tenantID != "" {
md.Set("md-tenant-id", tenantID)
}
return handler(ctx, req)
}
}

if tenantSlug != "" {
md.Set("md-tenant-slug", tenantSlug)
}
// tenantContext returns ctx augmented with tenant metadata (md-tenant-id/slug/owner)
// extracted from the token when MULTI_TENANT_ENABLED is set, along with whether it
// added anything. When multi-tenant mode is off it returns ctx unchanged and false.
// Shared by the unary and stream interceptors to avoid duplicating the propagation
// logic; the bool lets the stream interceptor decide whether to wrap its stream.
func tenantContext(ctx context.Context, token string) (context.Context, bool) {
if os.Getenv("MULTI_TENANT_ENABLED") != "true" {
return ctx, false
}

if tOwner != "" {
md.Set("md-tenant-owner", tOwner)
}
tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token)
md, _ := metadata.FromIncomingContext(ctx)
md = md.Copy()

ctx = metadata.NewIncomingContext(ctx, md)
}
if tenantID != "" {
md.Set("md-tenant-id", tenantID)
}

return handler(ctx, req)
if tenantSlug != "" {
md.Set("md-tenant-slug", tenantSlug)
}

if tOwner != "" {
md.Set("md-tenant-owner", tOwner)
}

return metadata.NewIncomingContext(ctx, md), true
}

// extractTokenFromMD returns the bearer token from incoming metadata "authorization".
Expand Down Expand Up @@ -274,7 +291,13 @@ func extractTenantClaims(tokenString string) (tenantID, tenantSlug, owner string
// - Propagates tenant claims when MULTI_TENANT_ENABLED=true.
func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if auth == nil || !auth.Enabled || auth.Address == "" {
if auth.mustRefuse() {
// AUTH_REQUIRED opted in but auth is disabled/misconfigured: refuse the
// stream (fail closed) instead of silently passing it through.
return status.Error(codes.Unavailable, "service unavailable")
}

if !auth.canAuthorize() {
return handler(srv, ss)
}

Expand Down Expand Up @@ -314,25 +337,8 @@ func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServ
}

// Propagate tenant claims if multi-tenant mode is enabled
if os.Getenv("MULTI_TENANT_ENABLED") == "true" {
tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token)
md, _ := metadata.FromIncomingContext(ctx)
md = md.Copy()

if tenantID != "" {
md.Set("md-tenant-id", tenantID)
}

if tenantSlug != "" {
md.Set("md-tenant-slug", tenantSlug)
}

if tOwner != "" {
md.Set("md-tenant-owner", tOwner)
}

ctx = metadata.NewIncomingContext(ctx, md)
ss = &wrappedServerStream{ServerStream: ss, ctx: ctx}
if tenantCtx, wrapped := tenantContext(ctx, token); wrapped {
ss = &wrappedServerStream{ServerStream: ss, ctx: tenantCtx}
}

return handler(srv, ss)
Expand Down
68 changes: 68 additions & 0 deletions auth/middleware/middlewareGRPC_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,50 @@ func TestNewGRPCAuthUnaryPolicy(t *testing.T) {
assert.True(t, called)
})

t.Run("required_and_disabled_refuses_with_unavailable", func(t *testing.T) {
t.Parallel()

called := false
handler := func(_ context.Context, _ any) (any, error) {
called = true
return "ok", nil
}

auth := &AuthClient{Address: "http://localhost:9999", Enabled: false, Required: true}
interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{})

resp, err := interceptor(context.Background(), "req", dummyInfo, handler)
require.Error(t, err)
assert.Nil(t, resp)
assert.False(t, called)

st, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.Unavailable, st.Code())
})

t.Run("required_and_empty_address_refuses_with_unavailable", func(t *testing.T) {
t.Parallel()

called := false
handler := func(_ context.Context, _ any) (any, error) {
called = true
return "ok", nil
}

auth := &AuthClient{Address: "", Enabled: true, Required: true}
interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{})

resp, err := interceptor(context.Background(), "req", dummyInfo, handler)
require.Error(t, err)
assert.Nil(t, resp)
assert.False(t, called)

st, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.Unavailable, st.Code())
})

t.Run("missing_token_returns_unauthenticated", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -1047,6 +1091,30 @@ func TestNewGRPCAuthStreamPolicy(t *testing.T) {
assert.True(t, called)
})

t.Run("required_and_disabled_refuses_with_unavailable", func(t *testing.T) {
t.Parallel()

called := false

handler := func(_ any, _ grpc.ServerStream) error {
called = true
return nil
}

auth := &AuthClient{Address: "http://localhost:9999", Enabled: false, Required: true}
interceptor := NewGRPCAuthStreamPolicy(auth, PolicyConfig{})

ss := &fakeServerStream{ctx: context.Background()}

err := interceptor(nil, ss, dummyInfo, handler)
require.Error(t, err)
assert.False(t, called)

st, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.Unavailable, st.Code())
})

t.Run("missing_token_returns_unauthenticated", func(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading