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
48 changes: 31 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
A multi-tenant Kafka proxy written in Go.

`kroxy` sits in front of a single Apache Kafka cluster and turns it into a
multi-tenant service. It terminates SASL/PLAIN at the edge, uses the
client's username to look up a tenant, and rewrites every topic, consumer
group and transactional ID with a per-tenant prefix on the way to the
upstream broker (and back). Each tenant sees a flat namespace that looks
like its own dedicated cluster; the broker sees fully-qualified,
prefixed names.
multi-tenant service. It terminates SASL (PLAIN, SCRAM-SHA-256,
SCRAM-SHA-512) at the edge, uses the client's username to look up a
tenant, and rewrites every topic, consumer group and transactional ID
with a per-tenant prefix on the way to the upstream broker (and back).
Each tenant sees a flat namespace that looks like its own dedicated
cluster; the broker sees fully-qualified, prefixed names.

The proxy is a single static binary with no external dependencies beyond
Kafka itself.
Expand Down Expand Up @@ -181,20 +181,33 @@ Notes:

## Authentication model

kroxy is a SASL/PLAIN **pass-through**. The SASL username on the wire
is the tenant ID; the password is forwarded verbatim to the tenant's
upstream Kafka cluster, which is the sole auth authority. **kroxy
stores no client secrets** and does not validate passwords itself.
kroxy is a SASL **pass-through**. It supports three mechanisms:

- **PLAIN** — single-shot. Username == tenant ID, password forwarded
verbatim to the upstream broker.
- **SCRAM-SHA-256** — challenge/response. kroxy peeks only at the SASLname
in the SCRAM `client-first-message` (== tenant ID) for routing, then
relays every `SaslAuthenticate` frame between client and upstream
unchanged.
- **SCRAM-SHA-512** — same model as SCRAM-SHA-256.

The upstream Kafka cluster is the sole authentication authority for all
three mechanisms. **kroxy stores no client secrets** and does not validate
passwords or SCRAM proofs itself.

Consequences:

- Every tenant ID must be a real principal in the upstream broker
(declared in its JAAS file or auth backend, e.g. `kafka_jaas.conf`).
- Unknown tenant IDs are rejected at the proxy before any upstream
dial, returning a SASL authentication failure.
- Passwords are held in memory for the duration of the client
connection (to be able to reconnect to upstream on failure) and never
written to logs.
(declared in its JAAS file for PLAIN, or registered as SCRAM credentials
via `kafka-configs.sh --add-config 'SCRAM-SHA-256=[password=...]'` for
SCRAM).
- Unknown tenant IDs are rejected at the proxy before any upstream dial,
returning a SASL authentication failure.
- For PLAIN, the password is held in memory for the duration of the client
connection (to support upstream reconnection) and never written to logs.
For SCRAM, kroxy never observes the password at all.
- SASL channel binding (`y`, `p=...`) is not supported — kroxy is not the
TLS terminator for the SCRAM exchange.

The only thing kroxy needs to know about a tenant is the mapping
`id → (topic_prefix, upstream)`.
Expand Down Expand Up @@ -278,7 +291,8 @@ deferred:

- **No TLS** on either the client or upstream side. Run kroxy on a
trusted network or behind a TLS-terminating sidecar.
- **SASL/PLAIN only.** No SCRAM, no OAUTHBEARER, no mTLS, no Kerberos.
- **SASL/PLAIN, SCRAM-SHA-256, SCRAM-SHA-512.** No OAUTHBEARER, no
mTLS, no Kerberos. No SASL channel binding.
- **Single shared upstream cluster.** Per-tenant `upstream` is plumbed
through but every tenant in the demo points at the same broker.
- **No hot config reload.** Restart to pick up YAML changes; use the
Expand Down
133 changes: 133 additions & 0 deletions auth/sasl_scram.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package auth

import (
"strings"

"github.com/pkg/errors"
)

// SCRAM mechanisms advertised and accepted by the proxy alongside PLAIN.
// kroxy implements these in pass-through "relay" mode: the SaslAuthenticate
// payloads are forwarded verbatim between client and the upstream broker,
// which is the sole authentication authority. kroxy peeks only at the first
// client message in order to extract the SASLname (== tenant ID) for
// routing.
const (
MechanismSCRAMSHA256 = "SCRAM-SHA-256"
MechanismSCRAMSHA512 = "SCRAM-SHA-512"
)

// IsSCRAMMechanism reports whether mech is one of the SCRAM mechanisms
// supported by the proxy.
func IsSCRAMMechanism(mech string) bool {
return mech == MechanismSCRAMSHA256 || mech == MechanismSCRAMSHA512
}

// ParseSCRAMClientFirstUsername extracts the SASLname (== tenant ID) from a
// SCRAM client-first-message as defined by RFC 5802 §7. The grammar we
// accept is:
//
// gs2-cbind-flag "," [ authzid ] "," "n=" saslname "," "r=" c-nonce ...
// gs2-cbind-flag = "n" | "y" | "p=..."
//
// kroxy does NOT support SASL channel binding, so only the "n" flag is
// accepted; any "y" or "p=..." flag is rejected. authzid (if present) is
// ignored. SASLname escapes "=2C" / "=3D" are decoded.
func ParseSCRAMClientFirstUsername(payload []byte) (string, error) {
s := string(payload)

// gs2-cbind-flag.
cb, rest, ok := cutByte(s, ',')
if !ok {
return "", errors.New("ParseSCRAMClientFirstUsername: missing gs2 cbind-flag separator")
}
switch {
case cb == "n":
// no channel binding, ok.
case cb == "y" || strings.HasPrefix(cb, "p="):
return "", errors.New("ParseSCRAMClientFirstUsername: channel binding not supported")
default:
return "", errors.Errorf("ParseSCRAMClientFirstUsername: invalid gs2 cbind-flag %q", cb)
}

// optional authzid then "," then client-first-message-bare.
_, bare, ok := cutByte(rest, ',')
if !ok {
return "", errors.New("ParseSCRAMClientFirstUsername: missing authzid separator")
}

// client-first-message-bare = [reserved-mext ","] username "," nonce ["," extensions]
// Skip any leading m=... reserved-mext attribute.
if strings.HasPrefix(bare, "m=") {
_, after, ok := cutByte(bare, ',')
if !ok {
return "", errors.New("ParseSCRAMClientFirstUsername: malformed reserved-mext")
}
bare = after
}

if !strings.HasPrefix(bare, "n=") {
return "", errors.New("ParseSCRAMClientFirstUsername: missing n= attribute")
}
rest = bare[2:]
rawName, _, ok := cutByte(rest, ',')
if !ok {
return "", errors.New("ParseSCRAMClientFirstUsername: missing nonce separator")
}
if rawName == "" {
return "", errors.New("ParseSCRAMClientFirstUsername: empty username")
}
Comment thread
bubunyo marked this conversation as resolved.
name, err := decodeSASLname(rawName)
if err != nil {
return "", errors.Wrap(err, "ParseSCRAMClientFirstUsername")
}
return name, nil
}

// cutByte splits s at the first occurrence of sep. It is a tiny helper to
// avoid pulling in strings.Cut's allocation pattern repeatedly.
func cutByte(s string, sep byte) (before, after string, found bool) {
if i := strings.IndexByte(s, sep); i >= 0 {
return s[:i], s[i+1:], true
}
return s, "", false
}

// decodeSASLname reverses the "=2C" / "=3D" escapes used by SCRAM SASLnames
// (RFC 5802 §5.1). Any other "=XX" sequence, or a stray '=' or ',' in the
// raw name, is rejected.
func decodeSASLname(raw string) (string, error) {
if !strings.ContainsRune(raw, '=') {
// fast path: no escapes.
if strings.ContainsRune(raw, ',') {
return "", errors.New("decodeSASLname: unescaped comma")
}
return raw, nil
}
var b strings.Builder
b.Grow(len(raw))
for i := 0; i < len(raw); i++ {
c := raw[i]
switch c {
case ',':
return "", errors.New("decodeSASLname: unescaped comma")
case '=':
if i+2 >= len(raw) {
return "", errors.New("decodeSASLname: truncated escape")
}
esc := raw[i+1 : i+3]
switch esc {
case "2C":
b.WriteByte(',')
case "3D":
b.WriteByte('=')
default:
return "", errors.Errorf("decodeSASLname: invalid escape =%s", esc)
}
i += 2
default:
b.WriteByte(c)
}
}
return b.String(), nil
}
119 changes: 119 additions & 0 deletions auth/sasl_scram_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package auth_test

import (
"testing"

"github.com/bubunyo/kroxy/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestIsSCRAMMechanism(t *testing.T) {
t.Parallel()

assert.True(t, auth.IsSCRAMMechanism(auth.MechanismSCRAMSHA256))
assert.True(t, auth.IsSCRAMMechanism(auth.MechanismSCRAMSHA512))
assert.False(t, auth.IsSCRAMMechanism(auth.MechanismPlain))
assert.False(t, auth.IsSCRAMMechanism(""))
assert.False(t, auth.IsSCRAMMechanism("scram-sha-256"))
}

func TestParseSCRAMClientFirstUsername(t *testing.T) {
t.Parallel()

tests := []struct {
name string
in string
want string
wantErr bool
}{
{
name: "no channel binding, no authzid",
in: "n,,n=alice,r=fyko+d2lbbFgONRv9qkxdawL",
want: "alice",
},
{
name: "no channel binding, with authzid (ignored)",
in: "n,a=admin,n=alice,r=abc",
want: "alice",
},
{
name: "escaped comma in name",
in: "n,,n=al=2Cice,r=abc",
want: "al,ice",
},
{
name: "escaped equals in name",
in: "n,,n=al=3Dice,r=abc",
want: "al=ice",
},
{
name: "with extensions after nonce",
in: "n,,n=tenantA,r=abc,m=foo",
want: "tenantA",
},
{
name: "leading reserved-mext skipped",
in: "n,,m=ignored,n=tenantA,r=abc",
want: "tenantA",
},
{
name: "channel binding y rejected",
in: "y,,n=alice,r=abc",
wantErr: true,
},
{
name: "channel binding p= rejected",
in: "p=tls-unique,,n=alice,r=abc",
wantErr: true,
},
{
name: "missing gs2 cbind separator",
in: "n",
wantErr: true,
},
{
name: "missing authzid separator",
in: "n,",
wantErr: true,
},
{
name: "missing n= attribute",
in: "n,,r=abc,n=alice",
wantErr: true,
},
{
name: "missing nonce",
in: "n,,n=alice",
wantErr: true,
},
{
name: "empty username",
in: "n,,n=,r=abc",
wantErr: true,
},
{
name: "invalid escape",
in: "n,,n=al=FFice,r=abc",
wantErr: true,
},
{
name: "truncated escape",
in: "n,,n=alice=2,r=abc",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := auth.ParseSCRAMClientFirstUsername([]byte(tt.in))
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
10 changes: 9 additions & 1 deletion dockerfiles/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,16 @@ services:
KAFKA_INTER_BROKER_LISTENER_NAME: SASL_PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9094
KAFKA_SASL_ENABLED_MECHANISMS: PLAIN
KAFKA_SASL_ENABLED_MECHANISMS: PLAIN,SCRAM-SHA-256,SCRAM-SHA-512
KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: PLAIN
# SCRAM is advertised by the broker but no SCRAM credentials are
# pre-provisioned in this demo stack. To bootstrap a SCRAM user, run:
# docker exec kroxy-kafka /opt/kafka/bin/kafka-configs.sh \
# --bootstrap-server kafka:9093 \
# --command-config /etc/kafka/client.properties \
# --alter --add-config 'SCRAM-SHA-256=[password=tenantApw]' \
# --entity-type users --entity-name tenantA
# PLAIN works out of the box for tenantA / tenantApw and tenantB / tenantBpw.
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
Expand Down
1 change: 1 addition & 0 deletions dockerfiles/kafka_jaas.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ KafkaServer {
user_broker="brokerpw"
user_tenantA="tenantApw"
user_tenantB="tenantBpw";
org.apache.kafka.common.security.scram.ScramLoginModule required;
};

KafkaClient {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
Expand Down
Loading
Loading