Skip to content
Open
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
17 changes: 17 additions & 0 deletions app/config/ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ func (c KafkaConfiguration) Validate() error {
errs = append(errs, errors.New("broker is required"))
}

// When a SASL security protocol is selected, the mechanism and credentials must be provided.
// Otherwise SASL is enabled with empty auth fields and the misconfiguration only surfaces later
// as an opaque broker connection failure instead of a clear startup validation error.
if c.SecurityProtocol == "SASL_SSL" || c.SecurityProtocol == "SASL_PLAINTEXT" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Share SASL validation

This check only runs for the ingest KafkaConfiguration, but the sink Kafka config uses the same SASL fields through the shared Kafka config type and still does not reject empty mechanism, username, or password. When the sink is configured with SASL_SSL or SASL_PLAINTEXT and the SASL fields are left empty, startup validation can still pass and Kafka authentication fails later with the same opaque broker connection error. Please enforce this SASL-field invariant in the shared Kafka validation path used by the sink as well.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/config/ingest.go
Line: 97

Comment:
**Share SASL validation**

This check only runs for the ingest `KafkaConfiguration`, but the sink Kafka config uses the same SASL fields through the shared Kafka config type and still does not reject empty mechanism, username, or password. When the sink is configured with `SASL_SSL` or `SASL_PLAINTEXT` and the SASL fields are left empty, startup validation can still pass and Kafka authentication fails later with the same opaque broker connection error. Please enforce this SASL-field invariant in the shared Kafka validation path used by the sink as well.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

if c.SaslMechanisms == "" {
errs = append(errs, errors.New("SASL mechanism is required when using a SASL security protocol"))
}

if c.SaslUsername == "" {
errs = append(errs, errors.New("SASL username is required when using a SASL security protocol"))
}

if c.SaslPassword == "" {
errs = append(errs, errors.New("SASL password is required when using a SASL security protocol"))
}
}

if c.StatsInterval > 0 && c.StatsInterval.Duration() < 5*time.Second {
errs = append(errs, errors.New("StatsInterval must be >=5s"))
}
Expand Down
56 changes: 56 additions & 0 deletions app/config/ingest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,59 @@ func TestKafkaIngestConfiguration(t *testing.T) {
})
}
}

func TestKafkaConfigurationValidate(t *testing.T) {
tests := []struct {
Name string
KafkaConfig KafkaConfiguration
ExpectError bool
}{
{
Name: "Plaintext without SASL is valid",
KafkaConfig: KafkaConfiguration{
Broker: "127.0.0.1:29092",
},
ExpectError: false,
},
{
Name: "SASL_PLAINTEXT with full credentials is valid",
KafkaConfig: KafkaConfiguration{
Broker: "127.0.0.1:29092",
SecurityProtocol: "SASL_PLAINTEXT",
SaslMechanisms: "PLAIN",
SaslUsername: "user",
SaslPassword: "pass",
},
ExpectError: false,
},
{
Name: "SASL_PLAINTEXT without mechanism or credentials is invalid",
KafkaConfig: KafkaConfiguration{
Broker: "127.0.0.1:29092",
SecurityProtocol: "SASL_PLAINTEXT",
},
ExpectError: true,
},
{
Name: "SASL_SSL missing password is invalid",
KafkaConfig: KafkaConfiguration{
Broker: "127.0.0.1:29092",
SecurityProtocol: "SASL_SSL",
SaslMechanisms: "PLAIN",
SaslUsername: "user",
},
ExpectError: true,
},
}

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
err := test.KafkaConfig.Validate()
if test.ExpectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
23 changes: 15 additions & 8 deletions openmeter/watermill/driver/kafka/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,32 +80,39 @@ func (o *BrokerOptions) createKafkaConfig(role string) (*sarama.Config, error) {
loggerFunc: logger.Debug,
}

if o.KafkaConfig.SecurityProtocol == "SASL_SSL" {
// Both SASL_SSL and SASL_PLAINTEXT authenticate via SASL. They differ only in
// whether the connection is wrapped in TLS: SASL_SSL uses TLS, SASL_PLAINTEXT
// sends the SASL exchange (including credentials) over an unencrypted socket.
// PLAINTEXT and SSL (no SASL) are left untouched and need no SASL/credentials setup.
switch o.KafkaConfig.SecurityProtocol {
case "SASL_SSL", "SASL_PLAINTEXT":
Comment on lines +87 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 SASL Config Allows Empty Mechanism

When securityProtocol is SASL_PLAINTEXT but saslMechanisms or credentials are left at their default empty values, this new branch enables Sarama SASL with empty auth fields. That config passes the existing validation and only fails later during broker connection, so a bad SASL_PLAINTEXT config reports as a runtime Kafka auth/connection failure instead of a clear startup validation error.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openmeter/watermill/driver/kafka/broker.go
Line: 87-88

Comment:
**SASL Config Allows Empty Mechanism**

When `securityProtocol` is `SASL_PLAINTEXT` but `saslMechanisms` or credentials are left at their default empty values, this new branch enables Sarama SASL with empty auth fields. That config passes the existing validation and only fails later during broker connection, so a bad SASL_PLAINTEXT config reports as a runtime Kafka auth/connection failure instead of a clear startup validation error.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

config.Net.SASL.Enable = true
config.Net.SASL.Handshake = true

config.Net.TLS.Enable = true
// Sarama has issues with min version TLS 1.3, so let's use the defaults for now
// remote error: tls: protocol version not supported
config.Net.TLS.Config = &tls.Config{} // nosemgrep

config.Net.SASL.User = o.KafkaConfig.SaslUsername
config.Net.SASL.Password = o.KafkaConfig.SaslPassword
// We rely on sarama to validate the SASL mechanism
config.Net.SASL.Mechanism = sarama.SASLMechanism(o.KafkaConfig.SaslMechanisms)

switch o.KafkaConfig.SaslMechanisms {
case sarama.SASLTypeSCRAMSHA256:
config.Net.SASL.Mechanism = sarama.SASLMechanism(o.KafkaConfig.SaslMechanisms)
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
return &XDGSCRAMClient{HashGeneratorFcn: SHA256}
}
case sarama.SASLTypeSCRAMSHA512:
config.Net.SASL.Mechanism = sarama.SASLMechanism(o.KafkaConfig.SaslMechanisms)
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
return &XDGSCRAMClient{HashGeneratorFcn: SHA512}
}
}

// Only SASL_SSL wraps the SASL exchange in TLS. SASL_PLAINTEXT authenticates
// over an unencrypted socket, so we must NOT enable TLS for it.
if o.KafkaConfig.SecurityProtocol == "SASL_SSL" {
config.Net.TLS.Enable = true
// Sarama has issues with min version TLS 1.3, so let's use the defaults for now
// remote error: tls: protocol version not supported
config.Net.TLS.Config = &tls.Config{} // nosemgrep
}
}

config.Producer.Retry.Max = 10
Expand Down
124 changes: 124 additions & 0 deletions openmeter/watermill/driver/kafka/broker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package kafka

import (
"log/slog"
"testing"

"github.com/IBM/sarama"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"

"github.com/openmeterio/openmeter/app/config"
)

func TestBrokerOptions_createKafkaConfig_SASL(t *testing.T) {
tests := []struct {
name string
kafkaConfig config.KafkaConfiguration
expectSASLEnabled bool
expectTLSEnabled bool
expectSCRAMGenerator bool
}{
{
name: "PLAINTEXT does not enable SASL or TLS",
kafkaConfig: config.KafkaConfiguration{
Broker: "localhost:9092",
},
expectSASLEnabled: false,
expectTLSEnabled: false,
},
{
name: "SASL_PLAINTEXT enables SASL without TLS",
kafkaConfig: config.KafkaConfiguration{
Broker: "localhost:9092",
SecurityProtocol: "SASL_PLAINTEXT",
SaslMechanisms: sarama.SASLTypePlaintext,
SaslUsername: "user",
SaslPassword: "pass",
},
expectSASLEnabled: true,
expectTLSEnabled: false,
},
{
name: "SASL_SSL enables both SASL and TLS",
kafkaConfig: config.KafkaConfiguration{
Broker: "localhost:9092",
SecurityProtocol: "SASL_SSL",
SaslMechanisms: sarama.SASLTypePlaintext,
SaslUsername: "user",
SaslPassword: "pass",
},
expectSASLEnabled: true,
expectTLSEnabled: true,
},
{
name: "SASL_PLAINTEXT with SCRAM-SHA-512 wires up a SCRAM client generator",
kafkaConfig: config.KafkaConfiguration{
Broker: "localhost:9092",
SecurityProtocol: "SASL_PLAINTEXT",
SaslMechanisms: sarama.SASLTypeSCRAMSHA512,
SaslUsername: "user",
SaslPassword: "pass",
},
expectSASLEnabled: true,
expectTLSEnabled: false,
expectSCRAMGenerator: true,
},
{
name: "SASL_PLAINTEXT with SCRAM-SHA-256 wires up a SCRAM client generator",
kafkaConfig: config.KafkaConfiguration{
Broker: "localhost:9092",
SecurityProtocol: "SASL_PLAINTEXT",
SaslMechanisms: sarama.SASLTypeSCRAMSHA256,
SaslUsername: "user",
SaslPassword: "pass",
},
expectSASLEnabled: true,
expectTLSEnabled: false,
expectSCRAMGenerator: true,
},
{
name: "SASL_SSL with SCRAM wires up a SCRAM client generator and enables TLS",
kafkaConfig: config.KafkaConfiguration{
Broker: "localhost:9092",
SecurityProtocol: "SASL_SSL",
SaslMechanisms: sarama.SASLTypeSCRAMSHA512,
SaslUsername: "user",
SaslPassword: "pass",
},
expectSASLEnabled: true,
expectTLSEnabled: true,
expectSCRAMGenerator: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := &BrokerOptions{
KafkaConfig: tt.kafkaConfig,
ClientID: "test-client",
Logger: slog.Default(),
MetricMeter: noop.NewMeterProvider().Meter("test"),
}

cfg, err := o.createKafkaConfig("publisher")
require.NoError(t, err)

assert.Equal(t, tt.expectSASLEnabled, cfg.Net.SASL.Enable)
assert.Equal(t, tt.expectTLSEnabled, cfg.Net.TLS.Enable)

if tt.expectSASLEnabled {
assert.Equal(t, tt.kafkaConfig.SaslUsername, cfg.Net.SASL.User)
assert.Equal(t, tt.kafkaConfig.SaslPassword, cfg.Net.SASL.Password)
assert.Equal(t, sarama.SASLMechanism(tt.kafkaConfig.SaslMechanisms), cfg.Net.SASL.Mechanism)
}

if tt.expectSCRAMGenerator {
assert.NotNil(t, cfg.Net.SASL.SCRAMClientGeneratorFunc)
} else {
assert.Nil(t, cfg.Net.SASL.SCRAMClientGeneratorFunc)
}
})
}
}