ryerraguntla commented on code in PR #4245:
URL: https://github.com/apache/iggy/pull/4245#discussion_r4082239453


##########
gateways/kafka/docs/TEST_SUITE.md:
##########
@@ -73,6 +74,27 @@ is test-only primitive encode/decode scaffolding for 
hand-building legacy/advers
 
 ---
 
+## Real-client end-to-end suite
+
+`kafka_client_e2e_tests.rs` needs two things the rest of the suite does not: 
Docker, and an
+already-built `iggy-server` in the same target directory. Missing either makes 
it skip with a
+printed reason rather than fail, which is what lets `cargo test -p 
iggy-gateway-kafka` stay usable
+without either.
+
+```bash
+cargo build --bin iggy-server
+KAFKA_E2E_REQUIRED=1 cargo test -p iggy-gateway-kafka --test 
kafka_client_e2e_tests
+```
+
+`KAFKA_E2E_REQUIRED=1` turns a skip into a failure, mirroring 
`KAFKA_FIXTURES_REQUIRED`, so a CI
+job that means to run these cannot report a pass over zero assertions. Set it 
there.
+

Review Comment:
    Claims kafka_bridge group. Nextest is kafka_client_e2e (nextest.toml:67). 
Fix: rewrite. 



##########
gateways/kafka/src/server.rs:
##########
@@ -682,7 +704,57 @@ async fn authenticate_token(
         // the account exists.
         Err(AuthError::Unavailable) => {
             warn!(%peer, "SASL authentication could not be completed; Iggy is 
unreachable");
-            HandleOutcome::Close
+            (HandleOutcome::Close, None)
+        }
+    }
+}
+
+/// Answers `DescribeAcls` from the permissions captured when this connection 
authenticated.
+///
+/// Only reachable on an authenticated connection: the SASL gate refuses every 
key but one before a
+/// principal exists, and the caller additionally gates this on the feature 
being on, so a gateway
+/// with SASL off never routes here. The `None` arm is a fail-closed guard, 
not a reachable path.
+fn describe_acls(
+    principal: Option<&AuthenticatedPrincipal>,
+    api_version: i16,
+    body: Bytes,
+    peer: &SocketAddr,
+) -> HandleOutcome {
+    let Some(principal) = principal else {
+        // `error!` rather than `debug!` on purpose, unlike every other 
refusal here: reaching this
+        // means the routing guards above disagree with each other, which is a 
gateway fault and not
+        // something a client can provoke.
+        error!(%peer, "DescribeAcls reached a connection with no authenticated 
principal");
+        // Not `encode_error_for_key`: key 29 is absent from 
`SUPPORTED_RANGES`, so that helper
+        // always returns `Close` here and the code would read as if it 
answers when it cannot.
+        return respond_describe_acls_error(api_version, 
ERROR_ILLEGAL_SASL_STATE, true);
+    };
+    // The firewall table cannot cover this key: it is kept out of 
`SUPPORTED_RANGES` on purpose,
+    // so the advertised range would otherwise be enforced only by whatever 
`kafka_protocol`'s
+    // schema happens to accept. A crate bump adding v4 would start answering 
v4 while ApiVersions
+    // still says 3, which is exactly what the sibling SASL keys pin 
explicitly against.
+    if !SASL_ADVERTISED_DESCRIBE_ACLS_VERSIONS.contains(&api_version) {
+        debug!(%peer, api_version, "DescribeAcls version outside the 
advertised range");
+        return HandleOutcome::Close;
+    }
+    if !principal.permissions_known {
+        // The permission read failed after a successful login, so this 
connection holds no real
+        // answer. Reporting the empty fallback would tell an operator the 
principal has no access,
+        // which is a different statement from "we could not find out".
+        warn!(%peer, "DescribeAcls asked on a connection whose permissions 
were never read");
+        return respond_describe_acls_error(api_version, 
ERROR_UNKNOWN_SERVER_ERROR, false);

Review Comment:
   permissions_known=false → -1 keep-open. Fakes always known: true 
(sasl_tests.rs:84). Empty-0 vs -1 can swap unseen. Java fails immediately, no 
hang. **Fix**: stub known=false; assert -1 + stay-open.



##########
gateways/kafka/src/protocol/acl.rs:
##########
@@ -0,0 +1,522 @@
+// 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.
+
+//! Rendering an Iggy principal's permissions as Kafka ACL bindings.
+//!
+//! Pure and synchronous, and deliberately free of any Iggy type: the 
permissions arrive as
+//! [`PrincipalPermissions`], which `auth` fills in. That keeps the protocol 
layer independent of
+//! the SDK and makes the mapping unit-testable without a server.
+//!
+//! See `docs/ACL_MAPPING.md` for the decisions this implements, including 
what is not mapped.
+
+/// Kafka resource types (`org.apache.kafka.common.resource.ResourceType`). 
`kafka_protocol` carries
+/// these as bare `i8` with no enum, so the values live here.
+pub mod resource_type {
+    pub const ANY: i8 = 1;
+    pub const TOPIC: i8 = 2;
+    pub const GROUP: i8 = 3;
+    pub const CLUSTER: i8 = 4;
+}
+
+/// Kafka pattern types (`org.apache.kafka.common.resource.PatternType`).
+pub mod pattern_type {
+    pub const ANY: i8 = 1;
+    /// Kafka's own "match anything of this type" lookup, which a filter may 
ask for.
+    pub const MATCH: i8 = 2;
+    pub const LITERAL: i8 = 3;
+}
+
+/// Kafka ACL operations (`org.apache.kafka.common.acl.AclOperation`).
+pub mod operation {
+    pub const ANY: i8 = 1;
+    pub const ALL: i8 = 2;
+    pub const READ: i8 = 3;
+    pub const WRITE: i8 = 4;
+    pub const CREATE: i8 = 5;
+    pub const DELETE: i8 = 6;
+    pub const ALTER: i8 = 7;
+    pub const DESCRIBE: i8 = 8;
+}
+
+/// Kafka ACL permission types 
(`org.apache.kafka.common.acl.AclPermissionType`).
+pub mod permission_type {
+    pub const ANY: i8 = 1;
+    pub const ALLOW: i8 = 3;
+}
+
+/// Name Kafka gives the cluster resource. There is exactly one, and it is 
always called this.
+pub const CLUSTER_NAME: &str = "kafka-cluster";
+
+/// How Kafka spells "every resource of this type": a literal pattern named 
`*`.
+pub const WILDCARD: &str = "*";
+
+/// Host scope on every binding this gateway renders. Iggy has no host-scoped 
permissions.
+pub const ANY_HOST: &str = "*";
+
+/// The subset of an Iggy principal's global permissions that has a Kafka 
meaning.
+///
+/// Stream-level flags are folded into the topic ones by the caller: Kafka has 
no resource above a
+/// topic, and every Kafka topic lives inside one Iggy stream, so a stream 
grant is in practice a
+/// grant over the topics a Kafka client can reach.
+///
+/// The boolean count mirrors Iggy's own `GlobalPermissions`, which is a flat 
set of independent
+/// grants. Collapsing them into a bitfield would hide which grant is which at 
every call site for
+/// no gain, so the lint is allowed here the way it is elsewhere in this 
repository.
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct PrincipalPermissions {
+    /// True when the principal holds either of Iggy's server flags. 
`manage_servers` is not
+    /// carried separately: Iggy reads it in exactly one rule, as an alias for 
this one, so it
+    /// gates no mutation anywhere and renders nothing of its own. 
`docs/ACL_MAPPING.md` has the
+    /// argument, which is the same one that keeps `manage_users` out of the 
table.
+    pub read_servers: bool,
+    pub read_topics: bool,
+    pub manage_topics: bool,
+    pub poll_messages: bool,
+    pub send_messages: bool,
+}
+
+/// One rendered binding. Principal, host and permission type are constant for 
every binding this
+/// gateway produces, so they are not carried here.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AclBinding {
+    pub resource_type: i8,
+    pub resource_name: &'static str,
+    pub operation: i8,
+}
+
+impl AclBinding {
+    const fn new(resource_type: i8, resource_name: &'static str, operation: 
i8) -> Self {
+        Self {
+            resource_type,
+            resource_name,
+            operation,
+        }
+    }
+}
+
+/// Renders a principal's permissions as Kafka ACL bindings.
+///
+/// Only global permissions are rendered, as wildcard bindings. Iggy keys its 
per-stream and
+/// per-topic permissions by numeric id, and the topic mapping is one-way, so 
a named Kafka binding
+/// cannot be reconstructed from them without a reverse index that does not 
exist. The result
+/// under-reports rather than over-reports, which is the safe direction for an 
authorization view.
+#[must_use]
+pub fn bindings_for(permissions: &PrincipalPermissions) -> Vec<AclBinding> {
+    let mut bindings = Vec::new();
+
+    if permissions.read_servers {
+        bindings.push(AclBinding::new(
+            resource_type::CLUSTER,
+            CLUSTER_NAME,
+            operation::DESCRIBE,
+        ));
+    }
+    if permissions.read_topics {
+        bindings.push(AclBinding::new(
+            resource_type::TOPIC,
+            WILDCARD,
+            operation::DESCRIBE,
+        ));
+    }
+    if permissions.manage_topics {
+        for op in [operation::CREATE, operation::DELETE, operation::ALTER] {
+            bindings.push(AclBinding::new(resource_type::TOPIC, WILDCARD, op));
+        }
+    }
+    if permissions.poll_messages {
+        bindings.push(AclBinding::new(
+            resource_type::TOPIC,
+            WILDCARD,
+            operation::READ,
+        ));
+    }
+    // Derived, not stored: Iggy has no group-level permission. Group 
*membership* operations
+    // (create, delete, get, join, leave) route through 
`Permissioner::get_topic`
+    // (`permissioner_rules/consumer_groups.rs`), which admits on the read and 
manage flags and
+    // never consults `poll_messages`. Deriving this from polling instead 
granted a group to
+    // principals Iggy denies, which is the over-report the design commits 
against.
+    //
+    // Not every group-shaped operation goes that way: offset commit and fetch 
route through
+    // `poll_messages` (`permissioner_rules/consumer_offsets.rs`), while Kafka 
gates them on this
+    // same GROUP READ. A principal with polling but no read grant is 
therefore shown no group
+    // binding while Iggy would let it commit an offset. That under-reports, 
which is the safe
+    // direction, and the alternative over-reports membership.
+    if permissions.read_topics {
+        bindings.push(AclBinding::new(
+            resource_type::GROUP,
+            WILDCARD,
+            operation::READ,
+        ));
+    }
+    if permissions.send_messages {
+        bindings.push(AclBinding::new(
+            resource_type::TOPIC,
+            WILDCARD,
+            operation::WRITE,
+        ));
+    }
+
+    bindings
+}
+
+/// The filter carried by a `DescribeAcls` request, already decoded.
+#[derive(Debug, Clone)]
+pub struct AclFilter {
+    pub resource_type: i8,
+    pub resource_name: Option<String>,
+    pub pattern_type: i8,
+    pub principal: Option<String>,
+    pub host: Option<String>,
+    pub operation: i8,
+    pub permission_type: i8,
+}
+
+impl AclFilter {
+    /// Whether this filter selects `binding`, belonging to `principal`.
+    ///
+    /// Kafka's `ANY` sentinel matches everything, and an absent string field 
is the same as `ANY`.
+    /// A principal filter naming anyone else matches nothing: the gateway 
holds no administrative
+    /// credentials and can only ever read the caller's own record, so 
claiming an empty result for
+    /// another user is the only honest answer it can give.
+    #[must_use]
+    pub fn matches(&self, binding: &AclBinding, principal: &str) -> bool {
+        if self.resource_type != resource_type::ANY && self.resource_type != 
binding.resource_type {
+            return false;
+        }
+        // PREFIXED selects nothing, because nothing here is prefix-scoped.
+        if !matches!(
+            self.pattern_type,
+            pattern_type::ANY | pattern_type::MATCH | pattern_type::LITERAL
+        ) {
+            return false;
+        }
+        if !self.matches_resource_name(binding.resource_name) {
+            return false;
+        }
+        if !matches_name(self.principal.as_deref(), 
&format!("User:{principal}")) {

Review Comment:
   acl.rs:211 / README.md:136 — kafka-acls.sh --list uses ANY, prints caller 
bindings as cluster catalog. Other User: empty 0, not 31. Documented self-only; 
operator still reads as full dump. **Fix**: README: --list = self-snapshot, not 
broker dump. 



##########
gateways/kafka/tests/kafka_client_e2e_tests.rs:
##########
@@ -0,0 +1,648 @@
+// 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.
+
+//! End-to-end tests driving **real Kafka clients** against the gateway.
+//!
+//! Every other suite in this crate hand-builds wire frames, which cannot 
catch a client
+//! compatibility problem by construction. One already slipped through that 
way: advertising
+//! `SaslHandshake` from v1 rather than v0 passed every hand-built test and 
made librdkafka report
+//! "SASL Handshake not supported by broker" before it sent anything. These 
tests exist so that
+//! class of bug fails in CI instead of during manual testing.
+//!
+//! The stack is real on all three sides: a spawned `iggy-server` process, the 
gateway in-process
+//! with a real `IggyAuthenticator`, and a client from a container. They 
automate categories S and T
+//! of `docs/MANUAL_TESTING.md`.
+//!
+//! Prerequisites are Docker and an already-built `iggy-server`. Missing 
either skips, the way the
+//! wire-fixture suites do, unless `KAFKA_E2E_REQUIRED=1` is set, which turns 
a skip into a failure
+//! so a broken CI step cannot leave these silently green.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::process::{Child, Command, Stdio};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use iggy_gateway_kafka::GatewayConfig;
+use iggy_gateway_kafka::auth::IggyAuthenticator;
+
+#[path = "common/server.rs"]
+mod server;
+
+use server::spawn_test_server_with_authenticator;
+
+const KCAT_IMAGE: &str = "edenhill/kcat:1.7.1";
+const KAFKA_IMAGE: &str = "apache/kafka:3.9.0";
+const ROOT_USER: &str = "iggy";
+const ROOT_PASSWORD: &str = "iggy";
+/// Password for every non-root principal these tests create.
+const USER_PASSWORD: &str = "s3cretpass";
+
+/// Budget for `iggy-server` to start listening. Generous: it is a cold 
process start, and a debug
+/// build on a loaded machine is not quick.
+const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(45);
+
+/// Reports why the suite cannot run, and whether that is fatal.
+///
+/// Returns `true` when the caller should skip. `KAFKA_E2E_REQUIRED=1` makes 
it panic instead, so a
+/// CI job that means to run these fails loudly rather than reporting a pass 
over zero assertions.
+fn skip(reason: &str) -> bool {
+    assert!(
+        std::env::var("KAFKA_E2E_REQUIRED").as_deref() != Ok("1"),
+        "KAFKA_E2E_REQUIRED=1 but the suite cannot run: {reason}"
+    );
+    eprintln!("skipping real-client end-to-end test: {reason}");
+    true
+}
+
+fn docker_missing() -> bool {
+    let available = Command::new("docker")
+        .arg("info")
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .status()
+        .is_ok_and(|status| status.success());
+    if available {
+        false
+    } else {
+        skip("docker is unavailable")
+    }
+}
+
+/// Locates the already-built `iggy-server` alongside this test binary. Does 
not build it.
+fn iggy_server_binary() -> Option<PathBuf> {
+    let mut dir = std::env::current_exe().ok()?;
+    // .../target/<profile>/deps/<test binary> -> .../target/<profile>
+    dir.pop();
+    dir.pop();
+    let candidate = dir.join(format!("iggy-server{}", 
std::env::consts::EXE_SUFFIX));
+    candidate.is_file().then_some(candidate)
+}
+
+/// Outcome of waiting for the spawned server to come up.
+enum Ready {
+    Listening,
+    /// The process exited before it bound, carrying its status.
+    Exited(String),
+    TimedOut,
+}
+
+/// A spawned `iggy-server`, killed on drop.
+///
+/// The environment recipe mirrors `bridge_iggy_integration_tests.rs`, which 
explains each setting:
+/// the other listeners are off so concurrently spawned servers do not fight 
over their fixed
+/// ports, and the shard pool is capped so a spawned server does not size 
itself to the whole
+/// machine against unrelated packages' tests in the same run.
+struct TestServer {
+    child: Child,
+    address: String,
+    http_address: String,
+    _data_dir: tempfile::TempDir,
+}
+
+impl TestServer {
+    fn spawn() -> Result<Self, String> {
+        let Some(binary) = iggy_server_binary() else {
+            return Err(
+                "iggy-server is not built; run `cargo build --bin iggy-server` 
first".to_string(),
+            );
+        };
+        let data_dir = tempfile::tempdir().expect("create server data dir");
+        let port = free_port();
+        let address = format!("127.0.0.1:{port}");
+        let http_address = format!("127.0.0.1:{}", free_port());
+
+        let child = Command::new(binary)
+            .arg("--fresh")
+            .env("IGGY_PATH", data_dir.path().display().to_string())
+            .env("IGGY_TCP_ADDRESS", &address)
+            // Left on, unlike the sibling suite: the three-principal 
procedure creates its
+            // non-root users over the HTTP API, which is the only 
administrative surface reachable
+            // from a test without pulling in the SDK.
+            .env("IGGY_HTTP_ENABLED", "true")
+            .env("IGGY_HTTP_ADDRESS", &http_address)
+            .env("IGGY_QUIC_ENABLED", "false")
+            .env("IGGY_WEBSOCKET_ENABLED", "false")
+            .env("IGGY_SHARDING_PIN_CORES", "false")
+            .env("IGGY_SHARDING_CPU_ALLOCATION", "0..4")
+            .env("IGGY_ROOT_USERNAME", ROOT_USER)
+            .env("IGGY_ROOT_PASSWORD", ROOT_PASSWORD)
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .spawn()
+            .expect("spawn iggy-server");
+
+        let mut server = Self {
+            child,
+            address,
+            http_address,
+            _data_dir: data_dir,
+        };
+        match server.wait_ready() {
+            Ready::Listening => Ok(server),
+            Ready::Exited(status) => Err(format!(
+                "iggy-server exited during startup with {status}; its output 
is suppressed, so \
+                 rerun it by hand with the same environment to see why"
+            )),
+            Ready::TimedOut => Err(format!(
+                "iggy-server never bound {} within {SERVER_READY_TIMEOUT:?}",
+                server.address
+            )),
+        }
+    }
+
+    fn wait_ready(&mut self) -> Ready {
+        let deadline = Instant::now() + SERVER_READY_TIMEOUT;
+        while Instant::now() < deadline {
+            // Both listeners, not just the data one: the server binds HTTP 
after TCP, so probing
+            // TCP alone declares readiness while the provisioning calls that 
follow would still
+            // be refused.
+            if std::net::TcpStream::connect(&self.address).is_ok()
+                && std::net::TcpStream::connect(&self.http_address).is_ok()
+            {
+                return Ready::Listening;
+            }
+            // Without this a server that dies at boot burns the whole budget 
and is then reported
+            // as a timeout, which sends the reader looking at the wrong thing 
entirely.
+            if let Ok(Some(status)) = self.child.try_wait() {
+                return Ready::Exited(status.to_string());
+            }
+            std::thread::sleep(Duration::from_millis(200));
+        }
+        Ready::TimedOut
+    }
+}
+
+impl Drop for TestServer {
+    fn drop(&mut self) {
+        let _ = self.child.kill();
+        let _ = self.child.wait();
+    }
+}
+
+/// Binds an ephemeral port and releases it, so the server can take it.
+///
+/// A window exists between release and rebind. Acceptable here because this 
suite is serialized
+/// into its own nextest group, so nothing else in it is drawing ports 
concurrently.
+fn free_port() -> u16 {
+    let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 
ephemeral port");
+    listener.local_addr().expect("local addr").port()
+}
+
+/// Starts the gateway with SASL on, verifying credentials against 
`iggy_address`.
+async fn spawn_gateway(iggy_address: &str) -> SocketAddr {
+    let config = GatewayConfig {
+        sasl_enabled: true,
+        ..GatewayConfig::default()
+    };
+    let authenticator = 
Arc::new(IggyAuthenticator::new(iggy_address.to_string()));
+    let (addr, shutdown) = spawn_test_server_with_authenticator(config, 
authenticator).await;
+    // Held for the test's lifetime: dropping the sender shuts the gateway 
down mid-exchange.
+    std::mem::forget(shutdown);
+    addr
+}
+
+/// Runs a container against the host network and returns its combined output.
+///
+/// `--network host` is what lets a containerised client reach a gateway bound 
to the host's
+/// loopback. It is Linux-specific, which matches where CI runs.
+///
+/// This blocks the calling thread for the life of the container, which is why 
every test here uses
+/// a multi-threaded runtime: the gateway runs as a spawned task, and on the 
single-threaded runtime
+/// `#[tokio::test]` gives by default, this call would starve it and nothing 
would ever listen.
+fn run_client(image: &str, args: &[&str], mounts: &[(&str, &str)]) -> 
ClientRun {
+    let mut command = Command::new("docker");
+    command.args(["run", "--rm", "--network", "host"]);
+    for (host, guest) in mounts {
+        command.args(["-v", &format!("{host}:{guest}:ro")]);
+    }
+    command.arg(image).args(args);
+    let output = command.output().expect("run client container");
+    ClientRun {
+        succeeded: output.status.success(),
+        text: format!(
+            "{}{}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        ),
+    }
+}
+
+/// What a client container produced, and whether it ran at all.
+///
+/// The exit status is carried deliberately. Every negative assertion in this 
suite is of the form
+/// "the output does not contain X", and a container that never started 
produces output satisfying
+/// all of them. Without this a wrong image tag, a malformed config or a 
Docker failure reads as a
+/// passing test.
+struct ClientRun {
+    succeeded: bool,
+    text: String,
+}
+
+impl ClientRun {
+    /// Asserts the client itself ran, then hands back its output for content 
assertions.
+    fn expect_ran(self, what: &str) -> String {
+        assert!(
+            self.succeeded,
+            "{what}: the client container failed to run, so any assertion 
about its output would \
+             be meaningless. Output: {}",
+            self.text
+        );
+        self.text
+    }
+
+    /// For the cases where the client is *expected* to fail, so only the 
output matters.
+    fn text(self) -> String {
+        self.text
+    }
+}
+
+fn kcat(addr: SocketAddr, username: &str, password: &str, mechanism: &str) -> 
ClientRun {
+    run_client(
+        KCAT_IMAGE,
+        &[
+            "-b",
+            &addr.to_string(),
+            "-X",
+            "security.protocol=SASL_PLAINTEXT",
+            "-X",
+            &format!("sasl.mechanisms={mechanism}"),
+            "-X",
+            &format!("sasl.username={username}"),
+            "-X",
+            &format!("sasl.password={password}"),
+            "-L",
+        ],
+        &[],
+    )
+}
+
+/// Creates an Iggy user over the HTTP API with the given global permissions.
+///
+/// The spec's three-principal procedure needs non-root accounts, and root 
holds every flag, so a
+/// suite that only ever authenticates as root checks neither the inheritance 
projection nor the
+/// empty view against a real client.
+fn create_user(http: &str, token: &str, username: &str, permissions: &str) {
+    let body = format!(
+        
r#"{{"username":"{username}","password":"{USER_PASSWORD}","status":"active","permissions":{permissions}}}"#
+    );
+    let status = Command::new("curl")
+        .args([
+            "-s",
+            "-o",
+            "/dev/null",
+            "-w",
+            "%{http_code}",
+            "-X",
+            "POST",
+            &format!("http://{http}/users";),
+            "-H",
+            &format!("Authorization: Bearer {token}"),
+            "-H",
+            "Content-Type: application/json",
+            "-d",
+            &body,
+        ])
+        .output()
+        .expect("create user");
+    let code = String::from_utf8_lossy(&status.stdout).to_string();
+    assert!(
+        code.starts_with('2'),
+        "creating {username} returned HTTP {code}"
+    );
+}
+
+/// Logs in as root over HTTP and returns the bearer token.
+fn root_token(http: &str) -> String {
+    let output = Command::new("curl")
+        .args([
+            "-s",
+            "-X",
+            "POST",
+            &format!("http://{http}/users/login";),
+            "-H",
+            "Content-Type: application/json",
+            "-d",
+            
&format!(r#"{{"username":"{ROOT_USER}","password":"{ROOT_PASSWORD}"}}"#),
+        ])
+        .output()
+        .expect("root login");
+    let body = String::from_utf8_lossy(&output.stdout);
+    // Avoids a JSON dependency for one field: the token is the value after 
this key.
+    let key = "\"token\":\"";
+    let start = body.find(key).expect("login response carries a token") + 
key.len();
+    let end = start + body[start..].find('"').expect("token is terminated");
+    body[start..end].to_string()
+}
+
+/// Writes a JAAS client config for the Java tools and returns its path, kept 
alive by the handle.
+fn java_client_config(username: &str, password: &str) -> (tempfile::TempDir, 
PathBuf) {

Review Comment:
    no request.timeout.ms; list_acls ×5. Hang ≈ 300s kill. **Fix**: 10s in 
command-config.



##########
gateways/kafka/src/protocol/acl.rs:
##########
@@ -0,0 +1,522 @@
+// 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.
+
+//! Rendering an Iggy principal's permissions as Kafka ACL bindings.
+//!
+//! Pure and synchronous, and deliberately free of any Iggy type: the 
permissions arrive as
+//! [`PrincipalPermissions`], which `auth` fills in. That keeps the protocol 
layer independent of
+//! the SDK and makes the mapping unit-testable without a server.
+//!
+//! See `docs/ACL_MAPPING.md` for the decisions this implements, including 
what is not mapped.
+
+/// Kafka resource types (`org.apache.kafka.common.resource.ResourceType`). 
`kafka_protocol` carries
+/// these as bare `i8` with no enum, so the values live here.
+pub mod resource_type {
+    pub const ANY: i8 = 1;
+    pub const TOPIC: i8 = 2;
+    pub const GROUP: i8 = 3;
+    pub const CLUSTER: i8 = 4;
+}
+
+/// Kafka pattern types (`org.apache.kafka.common.resource.PatternType`).
+pub mod pattern_type {
+    pub const ANY: i8 = 1;
+    /// Kafka's own "match anything of this type" lookup, which a filter may 
ask for.
+    pub const MATCH: i8 = 2;
+    pub const LITERAL: i8 = 3;
+}
+
+/// Kafka ACL operations (`org.apache.kafka.common.acl.AclOperation`).
+pub mod operation {
+    pub const ANY: i8 = 1;
+    pub const ALL: i8 = 2;
+    pub const READ: i8 = 3;
+    pub const WRITE: i8 = 4;
+    pub const CREATE: i8 = 5;
+    pub const DELETE: i8 = 6;
+    pub const ALTER: i8 = 7;
+    pub const DESCRIBE: i8 = 8;
+}
+
+/// Kafka ACL permission types 
(`org.apache.kafka.common.acl.AclPermissionType`).
+pub mod permission_type {
+    pub const ANY: i8 = 1;
+    pub const ALLOW: i8 = 3;
+}
+
+/// Name Kafka gives the cluster resource. There is exactly one, and it is 
always called this.
+pub const CLUSTER_NAME: &str = "kafka-cluster";
+
+/// How Kafka spells "every resource of this type": a literal pattern named 
`*`.
+pub const WILDCARD: &str = "*";
+
+/// Host scope on every binding this gateway renders. Iggy has no host-scoped 
permissions.
+pub const ANY_HOST: &str = "*";
+
+/// The subset of an Iggy principal's global permissions that has a Kafka 
meaning.
+///
+/// Stream-level flags are folded into the topic ones by the caller: Kafka has 
no resource above a
+/// topic, and every Kafka topic lives inside one Iggy stream, so a stream 
grant is in practice a
+/// grant over the topics a Kafka client can reach.
+///
+/// The boolean count mirrors Iggy's own `GlobalPermissions`, which is a flat 
set of independent
+/// grants. Collapsing them into a bitfield would hide which grant is which at 
every call site for
+/// no gain, so the lint is allowed here the way it is elsewhere in this 
repository.
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct PrincipalPermissions {

Review Comment:
    PrincipalPermissions global bools. From drops Permissions.streams. 
DescribeAcls under-report OK. Later Produce off this snapshot over-denies 
per-topic grants. Not authorizer today. **Fix**: store full Permissions or 
never authz off this type. 



##########
gateways/kafka/src/auth.rs:
##########
@@ -265,7 +316,92 @@ impl SaslAuthenticator for IggyAuthenticator {
             }
         }
 
-        outcome
+        outcome.map(|(permissions, permissions_known)| AuthenticatedPrincipal {
+            username: credentials.username.clone(),
+            permissions,
+            permissions_known,
+        })
+    }
+}
+
+/// Reads the just-authenticated user's own record and projects its global 
permissions.
+///
+/// A missing record or absent permissions both yield an empty set rather than 
an error: the
+/// credentials were already accepted, so refusing the connection here would 
reject a valid login
+/// over an authorization view it never asked for.
+async fn fetch_permissions(
+    client: &impl Client,
+    username: &str,
+) -> Result<(PrincipalPermissions, bool), AuthError> {
+    // Unreachable in practice: a username that reached a successful login is 
already inside
+    // Identifier's own length bounds. Propagating rather than degrading is 
still the wrong shape
+    // for this function, so it degrades like every other failure below.
+    let Ok(identifier) = Identifier::named(username) else {
+        warn!("authenticated, but the principal's name is not a valid Iggy 
identifier");
+        return Ok((PrincipalPermissions::default(), false));
+    };
+    // Deliberately not propagated as a failure. The credentials were already 
accepted by the login
+    // above, so turning a stumble on this second round trip into a rejection 
would answer a correct
+    // password with `SASL_AUTHENTICATION_FAILED`, which a Kafka client treats 
as fatal and raises
+    // to the application. Losing the ACL view is the lesser harm, and it 
degrades to an empty one.
+    let fetched = tokio::time::timeout(PERMISSION_READ_TIMEOUT, 
client.get_user(&identifier)).await;

Review Comment:
    1s get_user timeout cancels wait only. SDK tokio::spawn holds stream lock 
until RESPONSE_READ_TIMEOUT 30s. Slot free ~12s. Logged-in Iggy TCP zombie. New 
success-path leak after login already proved. **Fix**: drop slot after login; 
abort/join spawn or skip shutdown wait.



##########
gateways/kafka/tests/kafka_client_e2e_tests.rs:
##########
@@ -0,0 +1,648 @@
+// 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.
+
+//! End-to-end tests driving **real Kafka clients** against the gateway.
+//!
+//! Every other suite in this crate hand-builds wire frames, which cannot 
catch a client
+//! compatibility problem by construction. One already slipped through that 
way: advertising
+//! `SaslHandshake` from v1 rather than v0 passed every hand-built test and 
made librdkafka report
+//! "SASL Handshake not supported by broker" before it sent anything. These 
tests exist so that
+//! class of bug fails in CI instead of during manual testing.
+//!
+//! The stack is real on all three sides: a spawned `iggy-server` process, the 
gateway in-process
+//! with a real `IggyAuthenticator`, and a client from a container. They 
automate categories S and T
+//! of `docs/MANUAL_TESTING.md`.
+//!
+//! Prerequisites are Docker and an already-built `iggy-server`. Missing 
either skips, the way the
+//! wire-fixture suites do, unless `KAFKA_E2E_REQUIRED=1` is set, which turns 
a skip into a failure
+//! so a broken CI step cannot leave these silently green.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::process::{Child, Command, Stdio};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use iggy_gateway_kafka::GatewayConfig;
+use iggy_gateway_kafka::auth::IggyAuthenticator;
+
+#[path = "common/server.rs"]
+mod server;
+
+use server::spawn_test_server_with_authenticator;
+
+const KCAT_IMAGE: &str = "edenhill/kcat:1.7.1";
+const KAFKA_IMAGE: &str = "apache/kafka:3.9.0";
+const ROOT_USER: &str = "iggy";
+const ROOT_PASSWORD: &str = "iggy";
+/// Password for every non-root principal these tests create.
+const USER_PASSWORD: &str = "s3cretpass";
+
+/// Budget for `iggy-server` to start listening. Generous: it is a cold 
process start, and a debug
+/// build on a loaded machine is not quick.
+const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(45);
+
+/// Reports why the suite cannot run, and whether that is fatal.
+///
+/// Returns `true` when the caller should skip. `KAFKA_E2E_REQUIRED=1` makes 
it panic instead, so a
+/// CI job that means to run these fails loudly rather than reporting a pass 
over zero assertions.
+fn skip(reason: &str) -> bool {
+    assert!(
+        std::env::var("KAFKA_E2E_REQUIRED").as_deref() != Ok("1"),
+        "KAFKA_E2E_REQUIRED=1 but the suite cannot run: {reason}"
+    );
+    eprintln!("skipping real-client end-to-end test: {reason}");
+    true
+}
+
+fn docker_missing() -> bool {
+    let available = Command::new("docker")
+        .arg("info")
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .status()
+        .is_ok_and(|status| status.success());
+    if available {
+        false
+    } else {
+        skip("docker is unavailable")
+    }
+}
+
+/// Locates the already-built `iggy-server` alongside this test binary. Does 
not build it.
+fn iggy_server_binary() -> Option<PathBuf> {
+    let mut dir = std::env::current_exe().ok()?;
+    // .../target/<profile>/deps/<test binary> -> .../target/<profile>
+    dir.pop();
+    dir.pop();
+    let candidate = dir.join(format!("iggy-server{}", 
std::env::consts::EXE_SUFFIX));
+    candidate.is_file().then_some(candidate)
+}
+
+/// Outcome of waiting for the spawned server to come up.
+enum Ready {
+    Listening,
+    /// The process exited before it bound, carrying its status.
+    Exited(String),
+    TimedOut,
+}
+
+/// A spawned `iggy-server`, killed on drop.
+///
+/// The environment recipe mirrors `bridge_iggy_integration_tests.rs`, which 
explains each setting:
+/// the other listeners are off so concurrently spawned servers do not fight 
over their fixed
+/// ports, and the shard pool is capped so a spawned server does not size 
itself to the whole
+/// machine against unrelated packages' tests in the same run.
+struct TestServer {
+    child: Child,
+    address: String,
+    http_address: String,
+    _data_dir: tempfile::TempDir,
+}
+
+impl TestServer {
+    fn spawn() -> Result<Self, String> {
+        let Some(binary) = iggy_server_binary() else {
+            return Err(
+                "iggy-server is not built; run `cargo build --bin iggy-server` 
first".to_string(),
+            );
+        };
+        let data_dir = tempfile::tempdir().expect("create server data dir");
+        let port = free_port();
+        let address = format!("127.0.0.1:{port}");
+        let http_address = format!("127.0.0.1:{}", free_port());
+
+        let child = Command::new(binary)
+            .arg("--fresh")
+            .env("IGGY_PATH", data_dir.path().display().to_string())
+            .env("IGGY_TCP_ADDRESS", &address)
+            // Left on, unlike the sibling suite: the three-principal 
procedure creates its
+            // non-root users over the HTTP API, which is the only 
administrative surface reachable
+            // from a test without pulling in the SDK.
+            .env("IGGY_HTTP_ENABLED", "true")
+            .env("IGGY_HTTP_ADDRESS", &http_address)
+            .env("IGGY_QUIC_ENABLED", "false")
+            .env("IGGY_WEBSOCKET_ENABLED", "false")
+            .env("IGGY_SHARDING_PIN_CORES", "false")
+            .env("IGGY_SHARDING_CPU_ALLOCATION", "0..4")
+            .env("IGGY_ROOT_USERNAME", ROOT_USER)
+            .env("IGGY_ROOT_PASSWORD", ROOT_PASSWORD)
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .spawn()
+            .expect("spawn iggy-server");
+
+        let mut server = Self {
+            child,
+            address,
+            http_address,
+            _data_dir: data_dir,
+        };
+        match server.wait_ready() {
+            Ready::Listening => Ok(server),
+            Ready::Exited(status) => Err(format!(
+                "iggy-server exited during startup with {status}; its output 
is suppressed, so \
+                 rerun it by hand with the same environment to see why"
+            )),
+            Ready::TimedOut => Err(format!(
+                "iggy-server never bound {} within {SERVER_READY_TIMEOUT:?}",
+                server.address
+            )),
+        }
+    }
+
+    fn wait_ready(&mut self) -> Ready {
+        let deadline = Instant::now() + SERVER_READY_TIMEOUT;
+        while Instant::now() < deadline {
+            // Both listeners, not just the data one: the server binds HTTP 
after TCP, so probing
+            // TCP alone declares readiness while the provisioning calls that 
follow would still
+            // be refused.
+            if std::net::TcpStream::connect(&self.address).is_ok()
+                && std::net::TcpStream::connect(&self.http_address).is_ok()
+            {
+                return Ready::Listening;
+            }
+            // Without this a server that dies at boot burns the whole budget 
and is then reported
+            // as a timeout, which sends the reader looking at the wrong thing 
entirely.
+            if let Ok(Some(status)) = self.child.try_wait() {
+                return Ready::Exited(status.to_string());
+            }
+            std::thread::sleep(Duration::from_millis(200));
+        }
+        Ready::TimedOut
+    }
+}
+
+impl Drop for TestServer {
+    fn drop(&mut self) {
+        let _ = self.child.kill();
+        let _ = self.child.wait();
+    }
+}
+
+/// Binds an ephemeral port and releases it, so the server can take it.
+///
+/// A window exists between release and rebind. Acceptable here because this 
suite is serialized
+/// into its own nextest group, so nothing else in it is drawing ports 
concurrently.
+fn free_port() -> u16 {
+    let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 
ephemeral port");
+    listener.local_addr().expect("local addr").port()
+}
+
+/// Starts the gateway with SASL on, verifying credentials against 
`iggy_address`.
+async fn spawn_gateway(iggy_address: &str) -> SocketAddr {
+    let config = GatewayConfig {
+        sasl_enabled: true,
+        ..GatewayConfig::default()
+    };
+    let authenticator = 
Arc::new(IggyAuthenticator::new(iggy_address.to_string()));
+    let (addr, shutdown) = spawn_test_server_with_authenticator(config, 
authenticator).await;
+    // Held for the test's lifetime: dropping the sender shuts the gateway 
down mid-exchange.
+    std::mem::forget(shutdown);
+    addr
+}
+
+/// Runs a container against the host network and returns its combined output.
+///
+/// `--network host` is what lets a containerised client reach a gateway bound 
to the host's
+/// loopback. It is Linux-specific, which matches where CI runs.
+///
+/// This blocks the calling thread for the life of the container, which is why 
every test here uses
+/// a multi-threaded runtime: the gateway runs as a spawned task, and on the 
single-threaded runtime
+/// `#[tokio::test]` gives by default, this call would starve it and nothing 
would ever listen.
+fn run_client(image: &str, args: &[&str], mounts: &[(&str, &str)]) -> 
ClientRun {

Review Comment:
   docker run no timeout, no --pull never. Pre-pull fail then ~400MB inside 
nextest 60s×5. **Fix**: wrap timeout + --pull never. 



##########
gateways/kafka/tests/kafka_client_e2e_tests.rs:
##########
@@ -0,0 +1,648 @@
+// 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.
+
+//! End-to-end tests driving **real Kafka clients** against the gateway.
+//!
+//! Every other suite in this crate hand-builds wire frames, which cannot 
catch a client
+//! compatibility problem by construction. One already slipped through that 
way: advertising
+//! `SaslHandshake` from v1 rather than v0 passed every hand-built test and 
made librdkafka report
+//! "SASL Handshake not supported by broker" before it sent anything. These 
tests exist so that
+//! class of bug fails in CI instead of during manual testing.
+//!
+//! The stack is real on all three sides: a spawned `iggy-server` process, the 
gateway in-process
+//! with a real `IggyAuthenticator`, and a client from a container. They 
automate categories S and T
+//! of `docs/MANUAL_TESTING.md`.
+//!
+//! Prerequisites are Docker and an already-built `iggy-server`. Missing 
either skips, the way the
+//! wire-fixture suites do, unless `KAFKA_E2E_REQUIRED=1` is set, which turns 
a skip into a failure
+//! so a broken CI step cannot leave these silently green.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::process::{Child, Command, Stdio};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use iggy_gateway_kafka::GatewayConfig;
+use iggy_gateway_kafka::auth::IggyAuthenticator;
+
+#[path = "common/server.rs"]
+mod server;
+
+use server::spawn_test_server_with_authenticator;
+
+const KCAT_IMAGE: &str = "edenhill/kcat:1.7.1";
+const KAFKA_IMAGE: &str = "apache/kafka:3.9.0";
+const ROOT_USER: &str = "iggy";
+const ROOT_PASSWORD: &str = "iggy";
+/// Password for every non-root principal these tests create.
+const USER_PASSWORD: &str = "s3cretpass";
+
+/// Budget for `iggy-server` to start listening. Generous: it is a cold 
process start, and a debug
+/// build on a loaded machine is not quick.
+const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(45);
+
+/// Reports why the suite cannot run, and whether that is fatal.
+///
+/// Returns `true` when the caller should skip. `KAFKA_E2E_REQUIRED=1` makes 
it panic instead, so a
+/// CI job that means to run these fails loudly rather than reporting a pass 
over zero assertions.
+fn skip(reason: &str) -> bool {
+    assert!(
+        std::env::var("KAFKA_E2E_REQUIRED").as_deref() != Ok("1"),
+        "KAFKA_E2E_REQUIRED=1 but the suite cannot run: {reason}"
+    );
+    eprintln!("skipping real-client end-to-end test: {reason}");
+    true
+}
+
+fn docker_missing() -> bool {
+    let available = Command::new("docker")
+        .arg("info")
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .status()
+        .is_ok_and(|status| status.success());
+    if available {
+        false
+    } else {
+        skip("docker is unavailable")
+    }
+}
+
+/// Locates the already-built `iggy-server` alongside this test binary. Does 
not build it.
+fn iggy_server_binary() -> Option<PathBuf> {
+    let mut dir = std::env::current_exe().ok()?;
+    // .../target/<profile>/deps/<test binary> -> .../target/<profile>
+    dir.pop();
+    dir.pop();
+    let candidate = dir.join(format!("iggy-server{}", 
std::env::consts::EXE_SUFFIX));
+    candidate.is_file().then_some(candidate)
+}
+
+/// Outcome of waiting for the spawned server to come up.
+enum Ready {
+    Listening,
+    /// The process exited before it bound, carrying its status.
+    Exited(String),
+    TimedOut,
+}
+
+/// A spawned `iggy-server`, killed on drop.
+///
+/// The environment recipe mirrors `bridge_iggy_integration_tests.rs`, which 
explains each setting:
+/// the other listeners are off so concurrently spawned servers do not fight 
over their fixed
+/// ports, and the shard pool is capped so a spawned server does not size 
itself to the whole
+/// machine against unrelated packages' tests in the same run.
+struct TestServer {
+    child: Child,
+    address: String,
+    http_address: String,
+    _data_dir: tempfile::TempDir,
+}
+
+impl TestServer {
+    fn spawn() -> Result<Self, String> {
+        let Some(binary) = iggy_server_binary() else {
+            return Err(
+                "iggy-server is not built; run `cargo build --bin iggy-server` 
first".to_string(),
+            );
+        };
+        let data_dir = tempfile::tempdir().expect("create server data dir");
+        let port = free_port();
+        let address = format!("127.0.0.1:{port}");
+        let http_address = format!("127.0.0.1:{}", free_port());
+
+        let child = Command::new(binary)
+            .arg("--fresh")
+            .env("IGGY_PATH", data_dir.path().display().to_string())
+            .env("IGGY_TCP_ADDRESS", &address)
+            // Left on, unlike the sibling suite: the three-principal 
procedure creates its
+            // non-root users over the HTTP API, which is the only 
administrative surface reachable
+            // from a test without pulling in the SDK.
+            .env("IGGY_HTTP_ENABLED", "true")
+            .env("IGGY_HTTP_ADDRESS", &http_address)
+            .env("IGGY_QUIC_ENABLED", "false")
+            .env("IGGY_WEBSOCKET_ENABLED", "false")
+            .env("IGGY_SHARDING_PIN_CORES", "false")
+            .env("IGGY_SHARDING_CPU_ALLOCATION", "0..4")
+            .env("IGGY_ROOT_USERNAME", ROOT_USER)
+            .env("IGGY_ROOT_PASSWORD", ROOT_PASSWORD)
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .spawn()
+            .expect("spawn iggy-server");
+
+        let mut server = Self {
+            child,
+            address,
+            http_address,
+            _data_dir: data_dir,
+        };
+        match server.wait_ready() {
+            Ready::Listening => Ok(server),
+            Ready::Exited(status) => Err(format!(
+                "iggy-server exited during startup with {status}; its output 
is suppressed, so \
+                 rerun it by hand with the same environment to see why"
+            )),
+            Ready::TimedOut => Err(format!(
+                "iggy-server never bound {} within {SERVER_READY_TIMEOUT:?}",
+                server.address
+            )),
+        }
+    }
+
+    fn wait_ready(&mut self) -> Ready {
+        let deadline = Instant::now() + SERVER_READY_TIMEOUT;
+        while Instant::now() < deadline {
+            // Both listeners, not just the data one: the server binds HTTP 
after TCP, so probing
+            // TCP alone declares readiness while the provisioning calls that 
follow would still
+            // be refused.
+            if std::net::TcpStream::connect(&self.address).is_ok()
+                && std::net::TcpStream::connect(&self.http_address).is_ok()
+            {
+                return Ready::Listening;
+            }
+            // Without this a server that dies at boot burns the whole budget 
and is then reported
+            // as a timeout, which sends the reader looking at the wrong thing 
entirely.
+            if let Ok(Some(status)) = self.child.try_wait() {
+                return Ready::Exited(status.to_string());
+            }
+            std::thread::sleep(Duration::from_millis(200));
+        }
+        Ready::TimedOut
+    }
+}
+
+impl Drop for TestServer {
+    fn drop(&mut self) {
+        let _ = self.child.kill();
+        let _ = self.child.wait();
+    }
+}
+
+/// Binds an ephemeral port and releases it, so the server can take it.
+///
+/// A window exists between release and rebind. Acceptable here because this 
suite is serialized
+/// into its own nextest group, so nothing else in it is drawing ports 
concurrently.
+fn free_port() -> u16 {
+    let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind 
ephemeral port");
+    listener.local_addr().expect("local addr").port()
+}
+
+/// Starts the gateway with SASL on, verifying credentials against 
`iggy_address`.
+async fn spawn_gateway(iggy_address: &str) -> SocketAddr {
+    let config = GatewayConfig {
+        sasl_enabled: true,
+        ..GatewayConfig::default()
+    };
+    let authenticator = 
Arc::new(IggyAuthenticator::new(iggy_address.to_string()));
+    let (addr, shutdown) = spawn_test_server_with_authenticator(config, 
authenticator).await;
+    // Held for the test's lifetime: dropping the sender shuts the gateway 
down mid-exchange.
+    std::mem::forget(shutdown);
+    addr
+}
+
+/// Runs a container against the host network and returns its combined output.
+///
+/// `--network host` is what lets a containerised client reach a gateway bound 
to the host's
+/// loopback. It is Linux-specific, which matches where CI runs.
+///
+/// This blocks the calling thread for the life of the container, which is why 
every test here uses
+/// a multi-threaded runtime: the gateway runs as a spawned task, and on the 
single-threaded runtime
+/// `#[tokio::test]` gives by default, this call would starve it and nothing 
would ever listen.
+fn run_client(image: &str, args: &[&str], mounts: &[(&str, &str)]) -> 
ClientRun {
+    let mut command = Command::new("docker");
+    command.args(["run", "--rm", "--network", "host"]);
+    for (host, guest) in mounts {
+        command.args(["-v", &format!("{host}:{guest}:ro")]);
+    }
+    command.arg(image).args(args);
+    let output = command.output().expect("run client container");
+    ClientRun {
+        succeeded: output.status.success(),
+        text: format!(
+            "{}{}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        ),
+    }
+}
+
+/// What a client container produced, and whether it ran at all.
+///
+/// The exit status is carried deliberately. Every negative assertion in this 
suite is of the form
+/// "the output does not contain X", and a container that never started 
produces output satisfying
+/// all of them. Without this a wrong image tag, a malformed config or a 
Docker failure reads as a
+/// passing test.
+struct ClientRun {
+    succeeded: bool,
+    text: String,
+}
+
+impl ClientRun {
+    /// Asserts the client itself ran, then hands back its output for content 
assertions.
+    fn expect_ran(self, what: &str) -> String {
+        assert!(
+            self.succeeded,
+            "{what}: the client container failed to run, so any assertion 
about its output would \
+             be meaningless. Output: {}",
+            self.text
+        );
+        self.text
+    }
+
+    /// For the cases where the client is *expected* to fail, so only the 
output matters.
+    fn text(self) -> String {
+        self.text
+    }
+}
+
+fn kcat(addr: SocketAddr, username: &str, password: &str, mechanism: &str) -> 
ClientRun {
+    run_client(
+        KCAT_IMAGE,
+        &[
+            "-b",
+            &addr.to_string(),
+            "-X",
+            "security.protocol=SASL_PLAINTEXT",
+            "-X",
+            &format!("sasl.mechanisms={mechanism}"),
+            "-X",
+            &format!("sasl.username={username}"),
+            "-X",
+            &format!("sasl.password={password}"),
+            "-L",
+        ],
+        &[],
+    )
+}
+
+/// Creates an Iggy user over the HTTP API with the given global permissions.
+///
+/// The spec's three-principal procedure needs non-root accounts, and root 
holds every flag, so a
+/// suite that only ever authenticates as root checks neither the inheritance 
projection nor the
+/// empty view against a real client.
+fn create_user(http: &str, token: &str, username: &str, permissions: &str) {
+    let body = format!(
+        
r#"{{"username":"{username}","password":"{USER_PASSWORD}","status":"active","permissions":{permissions}}}"#
+    );
+    let status = Command::new("curl")
+        .args([
+            "-s",
+            "-o",
+            "/dev/null",
+            "-w",
+            "%{http_code}",
+            "-X",
+            "POST",
+            &format!("http://{http}/users";),
+            "-H",
+            &format!("Authorization: Bearer {token}"),
+            "-H",
+            "Content-Type: application/json",
+            "-d",
+            &body,
+        ])
+        .output()
+        .expect("create user");
+    let code = String::from_utf8_lossy(&status.stdout).to_string();
+    assert!(
+        code.starts_with('2'),
+        "creating {username} returned HTTP {code}"
+    );
+}
+
+/// Logs in as root over HTTP and returns the bearer token.
+fn root_token(http: &str) -> String {
+    let output = Command::new("curl")
+        .args([
+            "-s",
+            "-X",
+            "POST",
+            &format!("http://{http}/users/login";),
+            "-H",
+            "Content-Type: application/json",
+            "-d",
+            
&format!(r#"{{"username":"{ROOT_USER}","password":"{ROOT_PASSWORD}"}}"#),
+        ])
+        .output()
+        .expect("root login");
+    let body = String::from_utf8_lossy(&output.stdout);
+    // Avoids a JSON dependency for one field: the token is the value after 
this key.
+    let key = "\"token\":\"";
+    let start = body.find(key).expect("login response carries a token") + 
key.len();
+    let end = start + body[start..].find('"').expect("token is terminated");
+    body[start..end].to_string()
+}
+
+/// Writes a JAAS client config for the Java tools and returns its path, kept 
alive by the handle.
+fn java_client_config(username: &str, password: &str) -> (tempfile::TempDir, 
PathBuf) {
+    let dir = tempfile::tempdir().expect("create config dir");
+    let path = dir.path().join("client.properties");
+    std::fs::write(
+        &path,
+        format!(
+            "security.protocol=SASL_PLAINTEXT\n\
+             sasl.mechanism=PLAIN\n\
+             
sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule 
required \
+             username=\"{username}\" password=\"{password}\";\n"
+        ),
+    )
+    .expect("write client config");
+    (dir, path)
+}
+
+/// Brings up a server and a gateway, or reports why it could not.
+async fn stack() -> Option<(TestServer, SocketAddr)> {
+    if docker_missing() {
+        return None;
+    }
+    let server = match TestServer::spawn() {
+        Ok(server) => server,
+        Err(reason) => {
+            skip(&reason);
+            return None;
+        }
+    };
+    let gateway = spawn_gateway(&server.address).await;
+    Some((server, gateway))
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
given_valid_credentials_when_a_real_client_connects_should_serve_metadata() {
+    let Some((_server, gateway)) = stack().await else {
+        return;
+    };
+    let output = kcat(gateway, ROOT_USER, ROOT_PASSWORD, 
"PLAIN").expect_ran("valid credentials");
+    assert!(
+        output.contains("Metadata for all topics"),
+        "librdkafka must authenticate and receive metadata, got: {output}"
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
given_a_wrong_password_when_a_real_client_connects_should_report_an_auth_failure()
 {
+    let Some((_server, gateway)) = stack().await else {
+        return;
+    };
+    // kcat exits non-zero here by design, so only its output is asserted.
+    let output = kcat(gateway, ROOT_USER, "definitely-not-the-password", 
"PLAIN").text();
+    assert!(
+        output.contains("Authentication failed"),
+        "a rejected credential must reach the client as an auth failure, got: 
{output}"
+    );
+    assert!(
+        !output.contains("Metadata for all topics"),
+        "nothing may be served to a rejected client"
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn given_an_unsupported_mechanism_should_name_the_supported_one_back() {
+    let Some((_server, gateway)) = stack().await else {
+        return;
+    };
+    let output = kcat(gateway, ROOT_USER, ROOT_PASSWORD, 
"SCRAM-SHA-256").text();
+    assert!(
+        output.contains("PLAIN"),
+        "the refusal must name what is supported or an operator cannot act on 
it, got: {output}"
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn given_no_credentials_when_a_real_client_connects_should_be_refused() {
+    let Some((_server, gateway)) = stack().await else {
+        return;
+    };
+    // kcat exits non-zero here by design, so only its output is asserted, and 
the assertion has
+    // to be a positive one: a container that never started also fails to 
print the metadata this
+    // test forbids, which would pass over nothing. librdkafka only reports 
this particular
+    // diagnosis after it connected and the broker then dropped it before 
authenticating, so it
+    // stands in for the refusal itself.
+    let output = run_client(KCAT_IMAGE, &["-b", &gateway.to_string(), "-L"], 
&[]).text();
+    assert!(
+        output.contains("broker might require SASL authentication"),
+        "an unauthenticated client must be disconnected by the broker, got: 
{output}"
+    );
+    assert!(
+        !output.contains("Metadata for all topics"),
+        "an unauthenticated client must not be served, got: {output}"
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn given_the_java_client_should_authenticate_and_list_the_sasl_apis() {
+    // The Java client is the one that negotiates `SaslAuthenticate` v2 and 
sends `ApiVersions`
+    // both before and after authenticating, none of which kcat exercises 
identically.
+    let Some((_server, gateway)) = stack().await else {
+        return;
+    };
+    let (_dir, config) = java_client_config(ROOT_USER, ROOT_PASSWORD);
+    let output = run_client(
+        KAFKA_IMAGE,
+        &[
+            "/opt/kafka/bin/kafka-broker-api-versions.sh",
+            "--bootstrap-server",
+            &gateway.to_string(),
+            "--command-config",
+            "/tmp/client.properties",
+        ],
+        &[(
+            config.to_str().expect("config path is utf-8"),
+            "/tmp/client.properties",
+        )],
+    )
+    .expect_ran("java client api-versions");
+    assert!(
+        output.contains("SaslHandshake(17)"),
+        "the Java client must authenticate and read the advertisement, got: 
{output}"
+    );
+    assert!(
+        output.contains("SaslAuthenticate(36)"),
+        "both SASL keys must be advertised while the feature is on, got: 
{output}"
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
given_distinct_principals_when_listing_acls_should_describe_each_differently() {
+    // The spec's category T procedure. Root alone proves nothing about the 
mapping, because it
+    // holds every flag: a fixed set would satisfy it. Each of the others is 
chosen to fail
+    // differently if the projection is wrong. The consume-only principal 
shows the projection
+    // distinguishes principals and applies Iggy's inheritance; the poll-only 
one is the single
+    // case that separates the group binding's real source from 
`poll_messages`; the produce-only
+    // one shows a write grant drags in neither a read nor a group; and the 
ungranted one shows an
+    // empty view is a successful answer rather than a failure.
+    let Some((server, gateway)) = stack().await else {
+        return;
+    };
+    let token = root_token(&server.http_address);
+    create_user(
+        &server.http_address,
+        &token,
+        "consumer-only",
+        
r#"{"global":{"manage_servers":false,"read_servers":false,"manage_users":false,
+           
"read_users":false,"manage_streams":false,"read_streams":false,"manage_topics":false,
+           
"read_topics":true,"poll_messages":false,"send_messages":false},"streams":null}"#,
+    );
+    create_user(&server.http_address, &token, "no-grants", "null");
+    // Discriminates the group-derivation fix. `poll_messages` alone leaves 
the projected topic
+    // read false, so Iggy would refuse this principal a consumer group, yet 
the old derivation
+    // rendered one from polling. Reverting that fix makes this principal's 
listing grow a GROUP
+    // section, which the assertion below catches.
+    create_user(
+        &server.http_address,
+        &token,
+        "poller-only",
+        
r#"{"global":{"manage_servers":false,"read_servers":false,"manage_users":false,
+           
"read_users":false,"manage_streams":false,"read_streams":false,"manage_topics":false,
+           
"read_topics":false,"poll_messages":true,"send_messages":false},"streams":null}"#,
+    );
+    create_user(
+        &server.http_address,
+        &token,
+        "producer-only",
+        
r#"{"global":{"manage_servers":false,"read_servers":false,"manage_users":false,
+           
"read_users":false,"manage_streams":false,"read_streams":false,"manage_topics":false,
+           
"read_topics":false,"poll_messages":false,"send_messages":true},"streams":null}"#,
+    );
+
+    let root = list_acls(gateway, ROOT_USER, ROOT_PASSWORD);

Review Comment:
    Root pin TOPIC WRITE only. CLUSTER DESCRIBE present / ALTER absent 
unenforced vs MANUAL T1. **Fix**: grants(root, CLUSTER, DESCRIBE) && 
!grants(..., ALTER). 



-- 
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