hubcio commented on code in PR #4222:
URL: https://github.com/apache/iggy/pull/4222#discussion_r4061941930


##########
gateways/kafka/src/server.rs:
##########
@@ -307,17 +411,168 @@ fn enable_tcp_keepalive(stream: &TcpStream) -> 
std::io::Result<()> {
     Ok(())
 }
 
+/// Per-connection borrows that every frame's routing needs and none of it 
changes.
+///
+/// Bundled rather than passed one by one: the routing decision already takes 
the mutable SASL
+/// state, the header and the body, and four more parameters would put it over 
the argument limit
+/// for no gain in clarity.
+struct ConnectionContext<'a> {
+    config: &'a GatewayConfig,
+    broker: &'a BrokerAdvertise,
+    authenticator: Option<&'a dyn SaslAuthenticator>,
+    auth_slots: &'a Semaphore,
+    peer: &'a SocketAddr,
+}
+
+/// Decides what one decoded frame earns, advancing `sasl_state` when it 
authenticates.
+///
+/// Split out of [`handle_connection`] so the loop stays about framing and 
this stays about the
+/// SASL state machine.
+async fn route_frame(
+    ctx: &ConnectionContext<'_>,
+    sasl_state: &mut SaslState,
+    req: &RequestHeader,
+    body: Bytes,
+) -> HandleOutcome {
+    let peer = ctx.peer;
+
+    // The mechanism name lives in the handshake body, and the state machine 
needs it to decide.
+    // Decoding it here keeps `classify` free of wire concerns; a body that 
will not decode yields
+    // `None`, which `classify` already treats as an unsupported mechanism.
+    let mechanism = if req.request_api_key == API_KEY_SASL_HANDSHAKE {
+        decode_sasl_mechanism(req.request_api_version, body.clone()).ok()
+    } else {
+        None
+    };
+
+    let action = sasl_state.classify(
+        req.request_api_key,
+        req.request_api_version,
+        mechanism.as_deref(),
+    );
+    match action {
+        SaslAction::Dispatch => handle_request_bounded(
+            req.request_api_key,
+            req.request_api_version,
+            body,
+            ctx.broker,
+            ctx.config.max_frame_size,
+            ctx.config.sasl_enabled,
+        ),
+        SaslAction::DispatchFirstApiVersions => {
+            let outcome = handle_request_bounded(
+                req.request_api_key,
+                req.request_api_version,
+                body,
+                ctx.broker,
+                ctx.config.max_frame_size,
+                ctx.config.sasl_enabled,
+            );
+            // Spend the allowance only on an answer the client can use. A 
refusal it is entitled
+            // to retry at a lower version, which is what the KIP-511 
downgrade path does, must not
+            // consume the single attempt and strand a conformant client on 
the retry.
+            if answered_successfully(&outcome) {

Review Comment:
   warning: the pre-auth ApiVersions allowance is spent only on a success 
answer, so a peer that repeats a rejected version holds a max_connections 
permit forever. count every answered frame against it, and keep the KIP-511 
downgrade retry working.



##########
gateways/kafka/src/server.rs:
##########
@@ -554,8 +927,9 @@ pub async fn read_frame(
 /// Returns the [`WorkerGuard`]; it must be held for the lifetime of `main` 
(dropping it stops the
 /// worker thread and any buffered-but-unflushed log lines are lost) - see 
`main.rs`.
 pub fn init_tracing() -> WorkerGuard {
-    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
-        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
+    let filter = tracing_subscriber::EnvFilter::new(sdk_quieted_filter(
+        std::env::var("RUST_LOG").ok().as_deref(),

Review Comment:
   nit: the doc at line 1305 still names `EnvFilter::try_from_default_env`, but 
this line reads RUST_LOG with `std::env::var`. update the doc.



##########
gateways/kafka/src/main.rs:
##########
@@ -164,6 +195,19 @@ fn load_config() -> Result<GatewayConfig, String> {
         config.write_timeout =
             
Duration::from_secs(parse_positive("IGGY_KAFKA_WRITE_TIMEOUT_SECS", &raw)?);
     }
+    if let Some(raw) = env_var("IGGY_KAFKA_SASL_ENABLED") {
+        config.sasl_enabled = parse_bool("IGGY_KAFKA_SASL_ENABLED", &raw)?;
+    }
+    if let Some(raw) = env_var("IGGY_KAFKA_PRE_AUTH_TIMEOUT_SECS") {
+        config.pre_auth_timeout =
+            
Duration::from_secs(parse_positive("IGGY_KAFKA_PRE_AUTH_TIMEOUT_SECS", &raw)?);
+    }
+    if let Some(raw) = env_var("IGGY_KAFKA_MAX_CONCURRENT_AUTHENTICATIONS") {

Review Comment:
   warning: this only rejects zero, so a value above Semaphore::MAX_PERMITS 
panics Semaphore::new at startup. add the guard that IGGY_KAFKA_MAX_CONNECTIONS 
already gets.



##########
gateways/kafka/docs/SCOPE.md:
##########
@@ -142,7 +142,15 @@ Offset persistence design 
([#3540](https://github.com/apache/iggy/issues/3540)):
 InitProducerId and idempotent producers
 ([#3545](https://github.com/apache/iggy/issues/3545)): 
[`IDEMPOTENCE.md`](IDEMPOTENCE.md).
 
-- [ ] SASL (17, 36) if required by deployment
+Authentication design ([#3549](https://github.com/apache/iggy/issues/3549)):
+[`AUTHENTICATION.md`](AUTHENTICATION.md).
+
+- [x] SASL/PLAIN (17, 36), opt-in via `IGGY_KAFKA_SASL_ENABLED`, credentials 
verified against Iggy.
+      Kept out of `SUPPORTED_RANGES` on purpose: the connection loop routes 
both keys through the
+      SASL state machine before dispatch, so a gateway with the feature off 
refuses them like any

Review Comment:
   nit: a sasl-off gateway answers both keys with error 34 and keeps the 
connection rather than closing like an unlisted key. describe the real shape, 
as AUTHENTICATION.md does.



##########
gateways/kafka/docs/AUTHENTICATION.md:
##########
@@ -0,0 +1,250 @@
+# Kafka authentication and Iggy identity
+
+Status: proposed. Answers [#3549](https://github.com/apache/iggy/issues/3549) 
and needs a TLS listener
+first (see [Transport security](#transport-security)). The mechanism choice 
below is forced by how Iggy
+stores credentials, not preferred.
+
+## Decision
+
+Four parts.
+
+**Mechanism: SASL/PLAIN only.** SCRAM cannot be implemented against Iggy's 
credential store at all, and
+nothing else in Kafka's mechanism set maps without new server-side storage.
+
+**Credentials pass straight through.** The username and password a Kafka 
client sends in its PLAIN payload
+are an Iggy username and password. The gateway forwards them to a login and 
keeps no credential store and
+no mapping table of its own. An Iggy account is created for each Kafka 
principal, and the Iggy server stays
+the only place a credential is defined or verified.
+
+**Identity: one Iggy user per Kafka principal.** Not one shared account for 
everyone. Credentials are
+verified on every connection, against Iggy, and that verification is 
deliberately not cached. See
+[Verification is per 
connection](#verification-is-per-connection-and-cannot-be-cached).
+
+**Authorization stays in Iggy.** The gateway does not implement its own ACL 
model. It authenticates a Kafka
+client into an Iggy identity and lets the server's existing permission checks 
decide what that identity can do.
+
+## Why SCRAM is out
+
+Iggy stores exactly one credential per user, an Argon2id PHC string 
(`core/metadata/src/stm/user.rs:57`,
+hashed at `core/server_common/src/crypto.rs:30`, verified at 
`core/server/src/dispatch/session_ops.rs:126`).
+
+SCRAM-SHA-256 and SCRAM-SHA-512 require the server to hold `StoredKey` and 
`ServerKey`, both derived from
+`PBKDF2(password, salt, iterations)`, and to send the salt and iteration count 
to the client in the
+server-first message. None of that is derivable from an Argon2 hash, and the 
stored hash is never exposed
+through any API. There is no second credential column and no per-mechanism 
credential table anywhere in
+the tree.
+
+Supporting SCRAM therefore means a new replicated credential table plus 
derivation at user-creation time,
+which is a change to Iggy's own wire format and state machine. That is a 
product decision and does not
+belong to this gateway.
+
+SASL/OAUTHBEARER is the one other candidate, because a bearer token maps onto 
an Iggy personal access
+token, which is verified by a BLAKE3 hash lookup 
(`core/common/src/utils/hash.rs:25`) rather than Argon2.
+It is out of scope here and worth revisiting, since it avoids putting a 
password on the wire.
+
+## Why not one Iggy session per Kafka connection
+
+The obvious reading of "one-to-one user mapping" is one authenticated Iggy 
connection behind every Kafka
+connection. The cost rules it out.
+
+| Per login | Cost |
+| ----------- | ------ |
+| Argon2id verify, run inline on the shard thread | 19 MiB, t=2, p=1 |
+| Replicated VSR `Register` | one consensus commit |
+| Leader settlement round trip, plus a second login if it redirects | one or 
two round trips |
+| Session slots, cluster-wide across all transports | 8192, oldest committed 
evicted past it |
+
+The Argon2 verify runs on the shard's compio runtime, which has no blocking 
pool. A burst of Kafka
+connections therefore stalls that shard's entire request loop, including 
produce and fetch traffic that has
+nothing to do with authentication. The 8192 ceiling 
(`core/consensus/src/impls.rs:178`) is cluster-wide, so
+connection churn on the Kafka side turns directly into session-table pressure 
and eviction on the Iggy side.
+
+## Why not a shared service account
+
+The other option in the issue is authenticating every Kafka client against one 
`kafka-bridge` account. It
+removes the cost above and creates a worse problem: every Kafka client would 
reach Iggy as the same
+privileged principal, so Iggy's own permission model could no longer 
distinguish them. The gateway would
+have to carry an ACL model of its own, and become the only thing standing 
between any authenticated Kafka
+client and every stream the bridge account can reach.
+
+That is a larger security surface than the one it removes.
+
+## Verification is per connection, and cannot be cached
+
+A Kafka principal is the SASL `authcid`. Every SASL exchange verifies the 
presented credentials by
+logging into Iggy with them, and that verification happens on every connection.
+
+The gateway reaches Iggy over the binary TCP transport only. Nothing uses the 
HTTP API, so there is no way
+to check a password without establishing a full session. First contact for a 
connection therefore costs one
+Argon2 verify, run inline on the shard thread, and one replicated `Register`.
+
+An earlier revision of this document proposed caching the authenticated client 
per principal and attaching
+later connections to it. That is wrong, and implementing it surfaced why. A 
cache keyed on the username
+alone lets a second connection present *any* password for a principal already 
seen, which is an
+authentication bypass rather than an optimisation. Keying it on the credential 
instead means holding
+something password-equivalent in gateway memory for every principal seen. 
Neither is acceptable, so
+verification stays on the connection path.
+
+Two consequences worth naming rather than discovering:
+
+- **Login rate tracks connection rate.** A client that reconnects frequently 
drives an Argon2 verify per
+  reconnect, on a shard runtime with no blocking pool, where it competes with 
ordinary produce and fetch
+  traffic on that shard. Measured on debug builds, a single login takes about 
10 ms and 32 concurrent
+  logins take about 75 ms each, with throughput flattening near 410 per 
second. The shape is the point,
+  latency climbing while throughput plateaus, not the absolute numbers. 
`MANUAL_TESTING.md` has the table.
+- **Nothing holds the verified session.** The connection that proved a 
credential drops it immediately,
+  because no handler consumes an Iggy session yet. When Produce and Fetch need 
one
+  ([#3535](https://github.com/apache/iggy/issues/3535),
+  [#3536](https://github.com/apache/iggy/issues/3536)), the question of how a 
principal's data client is
+  pooled becomes live, and it is a separate question from how its credentials 
are verified. Pooling clients
+  is gateway-side code over the existing public SDK and needs no SDK change.
+
+If either consequence starts to hurt, the lever is below, and it is a smaller 
change than a credential
+cache because it moves cost off the server without weakening what the gateway 
checks.
+
+### Personal access tokens, if login cost becomes a problem
+
+Not part of the baseline. The gateway logs in with the username and password 
the Kafka client presented,
+and that is the whole credential path. This section records why a token is the 
lever to reach for if the
+login rate turns out to hurt, and what it costs to pull.
+
+Argon2 is the expensive half of a login and the only half that runs inline on 
a shard thread with no
+blocking pool. A token login replaces it with a hash lookup. The replicated 
`Register` is unchanged either
+way, so tokens buy server CPU, not consensus, and only under churn: a cached 
client that stays connected
+pays neither.
+
+Minting is self-scoped, so a token can only be created for a principal while 
authenticated as that
+principal. First contact therefore always pays the password path regardless, 
and a token would pay off only
+afterwards, on internal SDK reconnects, pool growth and cache repopulation. 
Credentials also cannot be
+swapped on a live client, so a token would apply to the next client built for 
that principal, never the one
+that minted it.
+
+What makes it a real cost rather than a free optimisation: the raw token is 
returned exactly once and is
+never retrievable, so a restarted gateway cannot recover what it minted and 
must delete before creating
+again, handling `PersonalAccessTokenAlreadyExists` (51). Names have to be 
instance-scoped or two gateways
+fight over one token. A user holds at most `max_tokens_per_user` tokens, 100 
by default
+(`core/server/config.toml:305`), and tokens leaked by instances that never 
cleaned up count against that.
+Token expiry has to outlive the cache TTL with margin. The gateway would also 
be holding bearer credentials
+carrying the full rights of their users with no scope narrowing, in memory, 
for every principal it has seen.
+
+Measure the login rate before taking any of that on.
+
+## Handshake
+
+Per connection, with SASL enabled. Only ApiVersions and SaslHandshake are 
legal before authentication.
+
+| State | Input | Action |
+| ------- | ------- | -------- |
+| `AwaitHandshake` | ApiVersions (18) | answer, stay |
+| | SaslHandshake (17) v1, supported mechanism | answer `error_code` 0, go to 
`AwaitToken` |
+| | SaslHandshake (17) v1, unknown mechanism | answer 33 with the mechanism 
list, then close |
+| | SaslHandshake (17) v0 | answer 35 with the mechanism list, then close |
+| | anything else | answer 34 shaped for that API, then close |
+| `AwaitToken` | SaslAuthenticate (36), credentials valid | answer 0, empty 
`auth_bytes`, go to `Authenticated` |
+| | SaslAuthenticate (36), credentials rejected | answer 58 with a generic 
message, then close |
+| | anything else | answer 34, then close |
+| `Authenticated` | any supported API, including ApiVersions again | serve |
+| `AwaitHandshake` | a second ApiVersions | answer 34, then close (one is 
allowed, as on a real broker) |
+| `AwaitToken` | SaslAuthenticate above the advertised ceiling | close, no 
schema exists at that version |
+| any | Metadata or Produce while unauthenticated | close with no body (no 
error field, and acks=0 forbids one) |
+| | SaslHandshake or SaslAuthenticate | answer 34, keep the connection |
+
+Notes that decide the implementation.
+
+- **Handshake v1 only.** The handshake version selects the token framing 
(KIP-152). Refusing v0 means the
+  legacy bare-token mode is never entered, so the frame reader stays 
header-parsing-only. Only pre-1.0
+  Kafka clients are excluded.
+- **`session_lifetime_ms` is 0.** No KIP-368 re-authentication, which keeps 
mid-connection identity change
+  out of scope. That matters beyond convenience, see the re-authentication 
note below.
+- **ApiVersions is answered twice.** The Java client sends it once before the 
handshake and again after
+  authenticating, so it must stay legal in both states.
+- **Produce with `acks=0` stays silent.** Answering an unauthenticated 
fire-and-forget produce desyncs the
+  client's correlation stream, so that case closes without writing. The 
existing rationale in
+  `protocol/api.rs` applies unchanged.
+- **Pre-authentication deadline.** An unauthenticated connection currently 
holds a `max_connections` permit
+  for up to the idle timeout, ten minutes by default. Authentication needs its 
own, much shorter deadline.
+
+## Error mapping
+
+At authentication time a rejected credential becomes 
`SASL_AUTHENTICATION_FAILED` (58) with a generic
+message. An Iggy that cannot be reached, or an overloaded gateway, closes the 
connection without a body
+instead: Kafka clients treat 58 as fatal and raise it to the application, so 
borrowing it for a transient
+condition turns a blip into a permanent failure for credentials that were 
always correct. A close reads as
+a transport failure, which is retriable, and still says nothing about whether 
the account exists. Iggy's login path already runs a dummy hash for unknown 
users to avoid a user-enumeration oracle,
+so the gateway must not reintroduce one by distinguishing unknown user from 
wrong password in the message
+or by returning early.
+
+After authentication, Iggy reports exactly one permission-denied code, 
`IggyError::Unauthorized` (41,
+`core/common/src/error/iggy_error.rs:97`), alongside `Unauthenticated` (40). 
Kafka distinguishes
+`TOPIC_AUTHORIZATION_FAILED` (29), `GROUP_AUTHORIZATION_FAILED` (30) and 
`CLUSTER_AUTHORIZATION_FAILED`
+(31). The gateway therefore picks the Kafka code from the operation it was 
performing, not from the Iggy
+error, because the Iggy error cannot tell them apart.
+
+Iggy's data-plane permission checks read the local shard's view, so a 
permission revocation is visible on
+the control plane immediately and on the data plane only after that shard 
applies it. Combined with the
+principal cache TTL above, revocation is eventually consistent by two 
mechanisms rather than one. Say so
+in the README rather than leaving an operator to discover it.
+
+## Transport security
+
+SASL/PLAIN puts the password in the clear on the wire, and so does Iggy's own 
login. Both hops need TLS.
+
+The gateway listener has no TLS at all today, which makes a TLS listener a 
prerequisite rather than a
+follow-up. The Iggy side already supports it on both ends (client at
+`core/common/src/types/configuration/tcp_config/tcp_client_config.rs:30`, 
server at
+`core/configs/src/server_config/tcp.rs:31`), shipped disabled.
+
+The two hops are configured independently. `IGGY_KAFKA_IGGY_TLS_ENABLED` and 
its companions encrypt
+the gateway's link to Iggy and are what make the gateway usable at all against 
a TLS-only Iggy
+server, where every verification would otherwise fail as unreachable. The 
Kafka-side listener is the
+half that is still missing.
+
+Two limits worth recording. There is no mTLS anywhere in the tree, every 
rustls config uses
+`with_no_client_auth()`, so certificate-based Kafka client authentication 
cannot map to an Iggy identity
+without an external terminator. And an Iggy password is capped at 100 bytes 
with a `u8` length prefix on
+the wire (`core/common/src/http/users/defaults.rs:20`), so a Kafka client with 
a longer PLAIN secret is
+rejected.
+
+## Re-authentication
+
+Iggy's only correct re-authentication is logout followed by login, which drops 
and re-mints the VSR session.
+Sending a login on a still-bound connection takes a replay branch that 
verifies the new credentials and
+returns the new user id to the caller while leaving the server bound to the 
previous user
+(`core/server/src/dispatch/session_ops.rs:232`). Building Kafka 
re-authentication on that would silently
+cross identities.
+
+Returning `session_lifetime_ms` of 0 avoids the question entirely for now. 
Whoever implements KIP-368 later
+has to resolve it first.
+
+## Out of scope
+
+- SCRAM-SHA-256 and SCRAM-SHA-512, blocked on credential storage that does not 
exist.
+- SASL/OAUTHBEARER and GSSAPI.
+- mTLS and certificate-based identity.
+- Mapping Kafka ACL administration APIs onto Iggy permissions. Authorization 
is enforced, but the
+  `DescribeAcls` and `CreateAcls` API keys stay unimplemented.
+- KIP-368 re-authentication.
+
+## Open questions
+
+1. **Who creates the Iggy accounts?** Each Kafka principal needs an Iggy user, 
since its credentials are
+   the ones the Kafka client sends. An operator can create them with the 
existing user API
+   (`core/common/src/traits/user_client.rs:38`), or the gateway can provision 
on first SASL login, which
+   needs the `manage_users` permission and means the gateway holds a 
credential that can create users.
+   Auto-provisioning also cannot work here: the gateway only ever sees a 
password it cannot validate
+   against anything until a user already exists, so provisioning would accept 
any credential as a new
+   account. Default: an operator creates the accounts, and the gateway only 
consumes them.
+2. **What is the principal cache TTL?** It bounds how long a revoked Iggy user 
keeps working through the

Review Comment:
   warning: this sets a 5 minute default for a cache that lines 81-86 call an 
authentication bypass, and line 185 treats that same TTL as live. drop the 
question and both references.



##########
gateways/kafka/src/server.rs:
##########
@@ -350,23 +605,124 @@ async fn handle_connection(
 
         // `RequestHeader::decode` advances `body` past the header fields it 
consumed via
         // `Buf::advance`, so `body` is already exactly the request payload.
-        let outcome = handle_request_bounded(
-            req.request_api_key,
-            req.request_api_version,
-            body,
-            &broker,
-            config.max_frame_size,
-        );
+        let outcome = route_frame(&ctx, &mut sasl_state, &req, body).await;
         if dispatch_outcome(&mut stream, &peer, &config, &req, resp_hdr_ver, 
outcome).await? {
             return Ok(());
         }
     }
 }
 
+/// Verifies a `SaslAuthenticate` token and answers it.
+///
+/// Every failure answers with the same code and the same message. A malformed 
token, an unknown
+/// user, a wrong password and an unreachable Iggy are indistinguishable to 
the client on purpose;
+/// the gateway's own log carries the difference.
+async fn authenticate_token(

Review Comment:
   simplification: six parameters, four of which come straight from `ctx`. pass 
`ctx` with `api_version` and `body` instead.



##########
gateways/kafka/src/server.rs:
##########
@@ -350,23 +605,124 @@ async fn handle_connection(
 
         // `RequestHeader::decode` advances `body` past the header fields it 
consumed via
         // `Buf::advance`, so `body` is already exactly the request payload.
-        let outcome = handle_request_bounded(
-            req.request_api_key,
-            req.request_api_version,
-            body,
-            &broker,
-            config.max_frame_size,
-        );
+        let outcome = route_frame(&ctx, &mut sasl_state, &req, body).await;
         if dispatch_outcome(&mut stream, &peer, &config, &req, resp_hdr_ver, 
outcome).await? {
             return Ok(());
         }
     }
 }
 
+/// Verifies a `SaslAuthenticate` token and answers it.
+///
+/// Every failure answers with the same code and the same message. A malformed 
token, an unknown
+/// user, a wrong password and an unreachable Iggy are indistinguishable to 
the client on purpose;
+/// the gateway's own log carries the difference.
+async fn authenticate_token(
+    authenticator: Option<&dyn SaslAuthenticator>,
+    auth_slots: &Semaphore,
+    budget: Duration,
+    api_version: i16,
+    body: Bytes,
+    peer: &SocketAddr,
+) -> HandleOutcome {
+    let failed = || sasl_authenticate_outcome(api_version, 
ERROR_SASL_AUTHENTICATION_FAILED, true);
+
+    let Some(authenticator) = authenticator else {
+        // Unreachable: `run` refuses to start in this combination. Fail 
closed anyway, since the
+        // alternative is admitting an unauthenticated connection.
+        error!(%peer, "SASL is enabled but no authenticator is configured");
+        return failed();
+    };
+    let Ok(auth_bytes) = decode_sasl_auth_bytes(api_version, body) else {
+        debug!(%peer, "malformed SaslAuthenticate token");
+        return failed();
+    };
+    let Ok(credentials) = parse_plain(&auth_bytes) else {
+        debug!(%peer, "malformed PLAIN initial response");
+        return failed();
+    };
+
+    // The wait for a slot and the verification itself share one deadline, and 
it is the same
+    // pre-authentication budget every other unauthenticated read gets. 
Without it the queue is the
+    // bound: an unauthenticated connection would sit in `acquire()` for as 
long as the backlog
+    // takes to drain, holding a `max_connections` permit the whole time, 
which is precisely the
+    // invariant `pre_auth_timeout` is documented to enforce.
+    let verified = tokio::time::timeout(budget, async {
+        // Acquire fails only once the semaphore is closed, which this gateway 
never does.
+        let Ok(_slot) = auth_slots.acquire().await else {
+            error!(%peer, "authentication slots unavailable");
+            return None;
+        };
+        Some(authenticator.authenticate(&credentials).await)
+    })
+    .await;
+
+    let Ok(Some(result)) = verified else {
+        // Overloaded or shutting down. Close rather than answer 58: a Kafka 
client treats that
+        // code as fatal and surfaces it to the application, and nothing here 
says the credentials
+        // were wrong. A close reads as a transport failure, which is 
retriable.
+        warn!(%peer, "authentication did not complete within the 
pre-authentication budget");
+        return HandleOutcome::Close;
+    };
+
+    match result {
+        Ok(()) => {
+            debug!(%peer, "SASL authentication succeeded");
+            sasl_authenticate_outcome(api_version, ERROR_NONE, false)
+        }
+        // A rejection is the client's problem and is terminal, so it earns a 
parseable 58.
+        Err(AuthError::Rejected) => {
+            debug!(%peer, "SASL authentication rejected");
+            failed()
+        }
+        // An outage is not. Kafka clients treat 58 as fatal and surface it to 
the application, so
+        // answering with it would turn a momentary Iggy blip into a permanent 
authentication error
+        // for credentials that were always correct. Closing without a body 
reads as a transport
+        // failure instead, which is retriable, and still tells the client 
nothing about whether
+        // the account exists.
+        Err(AuthError::Unavailable) => {
+            warn!(%peer, "SASL authentication could not be completed; Iggy is 
unreachable");
+            HandleOutcome::Close
+        }
+    }
+}
+
+/// Whether an `ApiVersions` outcome carries `error_code` 0, meaning the 
client got a usable
+/// answer rather than one it is expected to retry at a lower version.
+fn answered_successfully(outcome: &HandleOutcome) -> bool {
+    let (HandleOutcome::Respond(body) | HandleOutcome::RespondThenClose(body)) 
= outcome else {
+        return false;
+    };
+    // `ApiVersions` puts its error code in the first two bytes of the body at 
every version.
+    body.len() >= 2 && i16::from_be_bytes([body[0], body[1]]) == ERROR_NONE
+}
+
+/// Answers a request that is well-formed but not legal in this connection's 
SASL state.
+///
+/// `keep_open` marks the one case a real broker does not treat as fatal: a 
SASL request arriving
+/// on a connection that already authenticated.
+fn illegal_state_outcome(
+    api_key: i16,
+    api_version: i16,
+    keep_open: bool,
+    sasl_enabled: bool,
+) -> HandleOutcome {
+    match api_key {

Review Comment:
   nit: an authenticated client that sends SaslHandshake v2 gets a closed 
connection instead of the error 34 this path promises. move the version check 
ahead of the encoders.



##########
gateways/kafka/src/protocol/api.rs:
##########
@@ -108,7 +134,7 @@ impl HandleOutcome {
     #[must_use]
     pub fn expect_response(self, msg: &str) -> Bytes {
         match self {
-            Self::Respond(body) => body,
+            Self::Respond(body) | Self::RespondThenClose(body) => body,

Review Comment:
   nit: the summary at line 129 promises a panic for anything but 
`Self::Respond`, but this arm also returns the body of 
`Self::RespondThenClose`. name both variants in the doc.



##########
gateways/kafka/src/server.rs:
##########
@@ -76,6 +133,9 @@ impl Default for GatewayConfig {
             read_timeout: Duration::from_secs(15),
             write_timeout: Duration::from_secs(10),
             shutdown_drain_timeout: Duration::from_secs(25),
+            sasl_enabled: false,
+            pre_auth_timeout: Duration::from_secs(15),
+            max_concurrent_authentications: 16,

Review Comment:
   warning: 16 concurrent authentications by default is above the shard count 
of any node with 16 or fewer physical cores. lower it to 4 and let large nodes 
raise it.



##########
gateways/kafka/docs/AUTHENTICATION.md:
##########
@@ -0,0 +1,250 @@
+# Kafka authentication and Iggy identity
+
+Status: proposed. Answers [#3549](https://github.com/apache/iggy/issues/3549) 
and needs a TLS listener

Review Comment:
   nit: this ships sasl, so the "proposed" status is stale. mark the document 
implemented, since the TLS prerequisite still holds.



##########
gateways/kafka/src/auth.rs:
##########
@@ -0,0 +1,352 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Turning SASL credentials into a verified Iggy identity.
+//!
+//! The gateway keeps no credential store and no user mapping. A Kafka 
principal's username and
+//! password are an Iggy username and password, so authenticating is 
forwarding them to an Iggy
+//! login and seeing whether it succeeds. `docs/AUTHENTICATION.md` has the 
reasoning.
+
+use std::time::Duration;
+
+use async_trait::async_trait;
+use iggy::prelude::{AutoLogin, Client, Credentials, IggyClientBuilder, 
IggyError};
+use tracing::{debug, warn};
+
+use crate::protocol::sasl::PlainCredentials;
+
+/// Bound on one credential verification.
+///
+/// Covers the dial and the login. Teardown has its own, much smaller budget
+/// ([`TEARDOWN_TIMEOUT`]) so that one attempt cannot hold an authentication 
permit for twice this
+/// long. The caller bounds the whole thing again from outside, against its 
pre-authentication
+/// budget, because a permit wait is not covered here at all.
+///
+/// A verification that has not answered inside this is indistinguishable, 
from the Kafka client's
+/// side, from one that failed, and the client is holding a connection open 
waiting for it. Shorter
+/// than the bridge's own 15s request budget because a login is a bounded 
handshake against a
+/// server that is either reachable or not, not an arbitrary data operation.
+///
+/// "Bounded" is not "one round trip". `establish_session` also performs a 
cluster-metadata lookup
+/// for leader settlement, and on a redirect it reconnects and logs in a 
second time, so a single
+/// verification can cost two logins against a clustered deployment. The 
measurements in
+/// `docs/MANUAL_TESTING.md` were taken against one node and therefore never 
exercise that path.
+const VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Retries the dial makes before giving up, not the SDK's unlimited default.
+///
+/// A Kafka client retries the whole authentication itself, so an unbounded 
inner loop would only
+/// hide the failure underneath one the client cannot see.
+const VERIFY_RECONNECTION_RETRIES: u32 = 1;
+
+/// Budget for tearing the verification client down again.
+///
+/// Deliberately far shorter than [`VERIFY_TIMEOUT`]. Teardown happens while 
the caller still holds
+/// an authentication permit, so giving it the full verify budget would let 
one attempt occupy a
+/// slot for twice as long as the doc on [`VERIFY_TIMEOUT`] claims the whole 
operation can take.
+/// Nothing is lost by cutting it short: `Drop` aborts the heartbeat task 
regardless.
+const TEARDOWN_TIMEOUT: Duration = Duration::from_secs(1);
+
+/// Why a SASL exchange did not produce a verified identity.
+///
+/// Both variants reach the client as the same `SASL_AUTHENTICATION_FAILED` 
with the same generic
+/// message. They are distinct here only so the gateway's own log can tell an 
operator whether
+/// their Iggy server is unreachable or their user typed the wrong password.
+#[derive(Debug)]
+pub enum AuthError {
+    /// Iggy rejected the credentials.
+    Rejected,
+    /// Iggy could not be reached, or did not answer inside the verification 
timeout.
+    Unavailable,
+}
+
+/// Verifies Kafka-supplied credentials.
+///
+/// A trait rather than a concrete type so the protocol tests can drive the 
whole SASL exchange
+/// over a socket without an Iggy server behind it.
+#[async_trait]
+pub trait SaslAuthenticator: Send + Sync + std::fmt::Debug {
+    /// Returns `Ok(())` when `credentials` name a real, active Iggy user.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`AuthError::Rejected`] when Iggy refuses the credentials and
+    /// [`AuthError::Unavailable`] when it cannot be asked.
+    async fn authenticate(&self, credentials: &PlainCredentials) -> Result<(), 
AuthError>;
+}
+
+/// How the verifier reaches Iggy.
+///
+/// Separate from the gateway's own listener security. A deployment can 
terminate TLS on the Kafka
+/// side and still speak plain TCP to a co-located Iggy, or the reverse, and 
the two are configured
+/// independently because they protect different hops.
+#[derive(Debug, Clone, Default)]
+pub struct IggyTls {
+    pub enabled: bool,
+    /// Name checked against the server certificate. Empty means derive it 
from the address.
+    pub domain: String,
+    /// PEM roots to trust. Unset uses the SDK's bundled roots, not the system 
trust store.
+    pub ca_file: Option<String>,
+}
+
+/// Verifies credentials by logging into a real Iggy server with them.
+///
+/// Every verification opens its own connection, logs in, and shuts down 
again. That is one Argon2
+/// verify and one replicated `Register` per authenticated Kafka connection, 
which is the cost
+/// `docs/AUTHENTICATION.md` describes and does not hide.
+///
+/// It is also why there is no credential cache here. Caching a verification 
keyed on the username
+/// alone would let a second connection present any password for a principal 
already seen, which
+/// is an authentication bypass rather than an optimisation. Caching it keyed 
on the credential
+/// means storing something password-equivalent in gateway memory. Neither is 
worth doing before
+/// there is a handler whose throughput the login cost actually limits, and 
today Produce and Fetch
+/// are still stubs, so no verified session has a consumer to be held for.
+#[derive(Debug)]
+pub struct IggyAuthenticator {
+    address: String,
+    tls: IggyTls,
+}
+
+/// Iggy address used when `IGGY_KAFKA_IGGY_ADDR` is unset.
+///
+/// Matches `bridge::config`'s own default. The two read the same variable for 
the same purpose, so
+/// they must not disagree about what it falls back to.
+const DEFAULT_IGGY_ADDR: &str = "127.0.0.1:8090";
+
+impl IggyAuthenticator {
+    /// The complete set of `IGGY_KAFKA_*` vars this type reads, for `main`'s 
unknown-var guard.
+    ///
+    /// `IGGY_KAFKA_IGGY_ADDR` is listed here even though `bridge::config` 
also carries it. This
+    /// type reads it directly, and relying on the bridge's list to cover it 
is the cross-list
+    /// coupling the guard exists to avoid: a bridge rename would then break 
SASL startup on a
+    /// variable SASL reads itself.
+    pub const KNOWN_ENV_VARS: &'static [&'static str] = &[
+        "IGGY_KAFKA_IGGY_ADDR",
+        "IGGY_KAFKA_IGGY_TLS_ENABLED",
+        "IGGY_KAFKA_IGGY_TLS_DOMAIN",
+        "IGGY_KAFKA_IGGY_TLS_CA_FILE",
+    ];
+
+    #[must_use]
+    pub const fn new(address: String) -> Self {
+        Self {
+            address,
+            tls: IggyTls {
+                enabled: false,
+                domain: String::new(),
+                ca_file: None,
+            },
+        }
+    }
+
+    #[must_use]
+    pub fn with_tls(mut self, tls: IggyTls) -> Self {
+        self.tls = tls;
+        self
+    }
+
+    #[must_use]
+    pub const fn is_tls_enabled(&self) -> bool {
+        self.tls.enabled
+    }
+
+    /// Reads the Iggy address and transport security from the environment.
+    ///
+    /// No credentials of its own: every verification uses the credentials the 
Kafka client
+    /// presented, which is the whole point of forwarding them rather than 
mapping them.
+    ///
+    /// # Errors
+    ///
+    /// Returns a message naming the offending variable when 
`IGGY_KAFKA_IGGY_TLS_ENABLED` is set
+    /// to anything but `true` or `false`. Defaulting a mistyped security 
switch to off is how a
+    /// deployment ends up sending passwords in the clear while believing it 
does not.
+    pub fn from_env() -> Result<Self, String> {
+        let address =
+            std::env::var("IGGY_KAFKA_IGGY_ADDR").unwrap_or_else(|_| 
DEFAULT_IGGY_ADDR.to_string());
+        let enabled = match 
std::env::var("IGGY_KAFKA_IGGY_TLS_ENABLED").as_deref() {
+            Ok("true") => true,

Review Comment:
   nit: this repeats the true/false parse from `parse_bool` in main.rs, and 
nothing tests `from_env` or the TLS guard below. move the helper into the 
library and reuse it here.



##########
gateways/kafka/src/server.rs:
##########
@@ -185,6 +264,25 @@ impl KafkaGateway {
         listener: TcpListener,
         mut shutdown: broadcast::Receiver<()>,
     ) -> Result<()> {
+        if !self.config.sasl_enabled && self.authenticator.is_some() {
+            // The mirror of the guard below, and the quieter mistake: a 
verifier attached while the
+            // flag is off means every connection is served unauthenticated, 
with nothing in the log
+            // to say so. Refusing to start is the only way that failure is 
visible.
+            return Err(KafkaProtocolError::InvalidConfig(
+                "an authenticator is configured but SASL is disabled; every 
connection would be \
+                 served unauthenticated. Set IGGY_KAFKA_SASL_ENABLED=true, or 
remove the \
+                 authenticator"
+                    .into(),
+            ));
+        }
+        if self.config.sasl_enabled && self.authenticator.is_none() {
+            return Err(KafkaProtocolError::InvalidConfig(
+                "SASL is enabled but no authenticator is configured; every 
client would be \
+                 rejected. Set IGGY_KAFKA_IGGY_ADDR and the Iggy credentials, 
or unset \

Review Comment:
   nit: the gateway reads no credential variable, so telling operators to set 
"the Iggy credentials" sends them looking for a setting that does not exist. 
drop the clause or name the real variable.



##########
gateways/kafka/tests/common/server.rs:
##########
@@ -38,6 +41,7 @@ pub async fn spawn_test_server() -> (SocketAddr, 
broadcast::Sender<()>) {
         read_timeout: Duration::from_secs(5),
         write_timeout: Duration::from_secs(5),
         shutdown_drain_timeout: Duration::from_secs(5),
+        ..GatewayConfig::default()

Review Comment:
   simplification: the default supplies five of these fields, and 
`spawn_test_server_with_config` overwrites `bind_addr` anyway. drop the dead 
fields, also at lines 91, 123, 154, 306, 503 of 
`tests/listener_robustness_tests.rs`.



##########
gateways/kafka/src/auth.rs:
##########
@@ -0,0 +1,352 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Turning SASL credentials into a verified Iggy identity.
+//!
+//! The gateway keeps no credential store and no user mapping. A Kafka 
principal's username and
+//! password are an Iggy username and password, so authenticating is 
forwarding them to an Iggy
+//! login and seeing whether it succeeds. `docs/AUTHENTICATION.md` has the 
reasoning.
+
+use std::time::Duration;
+
+use async_trait::async_trait;
+use iggy::prelude::{AutoLogin, Client, Credentials, IggyClientBuilder, 
IggyError};
+use tracing::{debug, warn};
+
+use crate::protocol::sasl::PlainCredentials;
+
+/// Bound on one credential verification.
+///
+/// Covers the dial and the login. Teardown has its own, much smaller budget
+/// ([`TEARDOWN_TIMEOUT`]) so that one attempt cannot hold an authentication 
permit for twice this
+/// long. The caller bounds the whole thing again from outside, against its 
pre-authentication
+/// budget, because a permit wait is not covered here at all.
+///
+/// A verification that has not answered inside this is indistinguishable, 
from the Kafka client's
+/// side, from one that failed, and the client is holding a connection open 
waiting for it. Shorter
+/// than the bridge's own 15s request budget because a login is a bounded 
handshake against a
+/// server that is either reachable or not, not an arbitrary data operation.
+///
+/// "Bounded" is not "one round trip". `establish_session` also performs a 
cluster-metadata lookup
+/// for leader settlement, and on a redirect it reconnects and logs in a 
second time, so a single
+/// verification can cost two logins against a clustered deployment. The 
measurements in
+/// `docs/MANUAL_TESTING.md` were taken against one node and therefore never 
exercise that path.
+const VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Retries the dial makes before giving up, not the SDK's unlimited default.
+///
+/// A Kafka client retries the whole authentication itself, so an unbounded 
inner loop would only
+/// hide the failure underneath one the client cannot see.
+const VERIFY_RECONNECTION_RETRIES: u32 = 1;
+
+/// Budget for tearing the verification client down again.
+///
+/// Deliberately far shorter than [`VERIFY_TIMEOUT`]. Teardown happens while 
the caller still holds
+/// an authentication permit, so giving it the full verify budget would let 
one attempt occupy a
+/// slot for twice as long as the doc on [`VERIFY_TIMEOUT`] claims the whole 
operation can take.
+/// Nothing is lost by cutting it short: `Drop` aborts the heartbeat task 
regardless.
+const TEARDOWN_TIMEOUT: Duration = Duration::from_secs(1);
+
+/// Why a SASL exchange did not produce a verified identity.
+///
+/// Both variants reach the client as the same `SASL_AUTHENTICATION_FAILED` 
with the same generic
+/// message. They are distinct here only so the gateway's own log can tell an 
operator whether
+/// their Iggy server is unreachable or their user typed the wrong password.
+#[derive(Debug)]
+pub enum AuthError {
+    /// Iggy rejected the credentials.
+    Rejected,
+    /// Iggy could not be reached, or did not answer inside the verification 
timeout.
+    Unavailable,
+}
+
+/// Verifies Kafka-supplied credentials.
+///
+/// A trait rather than a concrete type so the protocol tests can drive the 
whole SASL exchange
+/// over a socket without an Iggy server behind it.
+#[async_trait]
+pub trait SaslAuthenticator: Send + Sync + std::fmt::Debug {
+    /// Returns `Ok(())` when `credentials` name a real, active Iggy user.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`AuthError::Rejected`] when Iggy refuses the credentials and
+    /// [`AuthError::Unavailable`] when it cannot be asked.
+    async fn authenticate(&self, credentials: &PlainCredentials) -> Result<(), 
AuthError>;
+}
+
+/// How the verifier reaches Iggy.
+///
+/// Separate from the gateway's own listener security. A deployment can 
terminate TLS on the Kafka
+/// side and still speak plain TCP to a co-located Iggy, or the reverse, and 
the two are configured
+/// independently because they protect different hops.
+#[derive(Debug, Clone, Default)]
+pub struct IggyTls {
+    pub enabled: bool,
+    /// Name checked against the server certificate. Empty means derive it 
from the address.
+    pub domain: String,
+    /// PEM roots to trust. Unset uses the SDK's bundled roots, not the system 
trust store.
+    pub ca_file: Option<String>,
+}
+
+/// Verifies credentials by logging into a real Iggy server with them.
+///
+/// Every verification opens its own connection, logs in, and shuts down 
again. That is one Argon2
+/// verify and one replicated `Register` per authenticated Kafka connection, 
which is the cost
+/// `docs/AUTHENTICATION.md` describes and does not hide.
+///
+/// It is also why there is no credential cache here. Caching a verification 
keyed on the username
+/// alone would let a second connection present any password for a principal 
already seen, which
+/// is an authentication bypass rather than an optimisation. Caching it keyed 
on the credential
+/// means storing something password-equivalent in gateway memory. Neither is 
worth doing before
+/// there is a handler whose throughput the login cost actually limits, and 
today Produce and Fetch
+/// are still stubs, so no verified session has a consumer to be held for.
+#[derive(Debug)]
+pub struct IggyAuthenticator {
+    address: String,
+    tls: IggyTls,
+}
+
+/// Iggy address used when `IGGY_KAFKA_IGGY_ADDR` is unset.
+///
+/// Matches `bridge::config`'s own default. The two read the same variable for 
the same purpose, so
+/// they must not disagree about what it falls back to.
+const DEFAULT_IGGY_ADDR: &str = "127.0.0.1:8090";

Review Comment:
   simplification: this duplicates `DEFAULT_IGGY_ADDR` from `bridge/config.rs` 
and nothing enforces that the two agree. make the bridge constant `pub(crate)` 
and use it here.



##########
gateways/kafka/src/protocol/api.rs:
##########
@@ -123,6 +149,15 @@ impl HandleOutcome {
     pub const fn is_close(&self) -> bool {
         matches!(self, Self::Close)
     }
+
+    /// Whether applying this outcome ends the connection, whether or not it 
writes first.
+    ///
+    /// Distinct from [`Self::is_close`], which asks only whether the 
connection ends *without* a
+    /// response. Callers deciding whether to keep reading want this one.
+    #[must_use]
+    pub const fn closes_connection(&self) -> bool {

Review Comment:
   simplification: nothing calls this - 0 call sites in the repo, and the close 
flag is computed inline in `dispatch_outcome`. delete it and its doc.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to