ADR-0037: RabbitMQ, Amazon MQ, Kafka, and MSK access
- Status: Accepted (the owner asked for the implementation on 2026-10-18; the open questions were settled as the recommendations below, see “As built”)
- Date: 2026-10-18
Context
Section titled “Context”docs/design.md §13 lists Phase 11 as “RabbitMQ and Amazon MQ, then Kafka and MSK (ADR-0018): an AMQP client and kcat work through Limen against compose brokers; connection and topic/exchange audit with no message bodies; the Kafka multi-broker design from the spike is proven”. ADR-0018 fixed the shape per target: AMQP 0-9-1 for RabbitMQ, the Kafka binary protocol for Kafka, the worker holds the credential, message bodies are never logged, and Kafka is the hard one because a client connects to every broker at the address the cluster advertises.
The data side already exists as stubs: policy accepts rabbitmq and kafka targets (BrokerTarget) and mq: { users } and kafka: { users } grants, the proxy knows the principals MqUser and KafkaUser, the console lists the kinds with their brand marks, and limen mq connect and limen kafka connect parse their options and answer “planned for Phase 11”. No wire protocol is implemented.
The pattern from ADR-0002, ADR-0015, ADR-0034 holds: the worker terminates the protocol next to the target and holds the credential; the proxy only authorizes and routes; the CLI opens loopback listeners so the user’s own clients work unchanged and never see a credential. The user asked that these sessions are recorded, “like the databases and redis”: not video, but a durable, ordered, per-session record. See “Session record”.
Decision
Section titled “Decision”Common
Section titled “Common”- Wire is ours. Both codecs are written in the worker (ADR-0012: no AMQP or Kafka client library is a dependency). Each is small for what a policy point needs: AMQP framing and about thirty method argument layouts; Kafka’s size-prefixed requests, a header, and a hand-written reader for the fields policy needs of about twenty API keys at pinned versions. They are fuzz-friendly pure functions and are unit-tested with fixtures captured from real clients and servers.
- ALPN and CLI.
limen-mq/1andlimen-kafka/1through the proxy exactly likelimen-redis/1(one local connection is one Limen session, with the same grant, lock, kill, and drain machinery).limen mq connectandlimen kafka connectprint a ready-to-use URL or--bootstrapline with no password (clients that insist on credentials get a placeholder the worker discards). - Client authentication is discarded. The outer hop is mTLS plus the grant. SASL and AMQP
PLAINoffered by the local client are accepted with any value and dropped; the worker never forwards them. - Credentials are the worker’s and never logged (
limen_core::secret::Secret): a JSON file--broker-credentials-fileof{"<resource>": {"<broker user>": "<password>" | {password, client_cert_file, client_key_file}}}, and--broker-ca-filefor TLS roots. TLS toward the target is on by default withverify-full;tls: falseis for local brokers and is shown as a warning in the console. - Policy is checked in the worker for every operation, default deny, with the broker’s own ACLs as the second layer. A denial is an
auth.deniedevent (action: mqorkafka) and an error the client’s library understands (below). Audit fails closed: an operation whose event can’t be written isn’t forwarded. - Message bodies, message properties and headers, and record keys and values are never read into an event, whatever the setting. There is no
log_valuesequivalent. Names (exchange, queue, routing key, topic, group, client ID) are logged: they are what makes the trail useful.
RabbitMQ and Amazon MQ (AMQP 0-9-1)
Section titled “RabbitMQ and Amazon MQ (AMQP 0-9-1)”- Targets. RabbitMQ 3.x and 4.x speaking AMQP 0-9-1 (including the 0-9-1 listener on Amazon MQ for RabbitMQ,
amqpson 5671). Not in scope: AMQP 1.0, the Streams protocol, MQTT, STOMP (a client that offers another protocol header gets the supported one back and is closed), Amazon MQ for ActiveMQ, and the management API (a web resource, ADR-0022, Phase 12). - The worker is a frame-aware relay. It plays the server toward the client (
Start,Tunewith frame-max 131072 and the client’s heartbeat,Open), then connects to the broker as the broker user the role names (PLAIN), and relays frames in both directions. Only the client-to-broker method frames are decoded: those that name an exchange, queue, vhost, or routing key, and a few channel-lifecycle methods. Content headers and bodies are relayed as opaque frames (header size is read for the event). Broker-to-client frames pass untouched. A frame over the negotiated maximum, a bad end byte, or an unknown frame type ends the session (501 frame-error). - Where the vhost comes from. From the client’s
Connection.Open, checked against the grant; the worker opens the broker connection only after that, so the broker sees exactly the allowed vhost. A vhost the grant doesn’t name isConnection.Close 530 not-allowed. - Policy, in the shape RabbitMQ experts know (configure, write, read):
mq:users: [limen-app] # broker user (credential selector)vhosts: ["/", "orders-*"] # default ["/"]classes: [read, write] # read | write | configure. Default [read, write]names: ["orders.*", "events"] # exchange and queue name patterns, `*` only. Default: any non-reserved name
- write:
Basic.publish,Tx.*,Confirm.select. The target is the exchange; for the default exchange ("") it is the routing key, because that is the queue it delivers to. - read:
Basic.consume,Basic.get,Basic.ack/nack/reject/recover/qos/cancel. The target is the queue. - configure:
Exchange.declare/delete/bind/unbind,Queue.declare/delete/bind/unbind/purge. A server-named queue (Queue.declarewith an empty name) is allowed whenconfigureis granted. - Reserved names (
amq.*) match only a pattern that itself starts withamq., so*never opensamq.rabbitmq.trace. Anything that isn’t a known method is refused. - A refused method closes that channel with
403 access-refused(the broker’s own behaviour for permission errors, which clients handle): the worker answersChannel.Closeitself, closes the broker-side channel, and discards the frames of the refused message. Other channels are unaffected. A refusal on channel 0 closes the connection.
- write:
- Heartbeats. The worker keeps both sides alive with its own heartbeat frames and consumes theirs.
- Audit. One
mq.operationevent per decoded operation, written before the method is forwarded: action (publish,consume,get,declare_queue, …), vhost, exchange, queue, routing key, channel, and for a publish the body size (taken from the content header, so the method frame is held until it arrives). Deliveries are not audited one by one (a consumer receives them by the million); the session’s totals are insession.endand the delivery count in a closingmq.summaryevent.Basic.getis audited per call. - Credentials. The broker user’s password from the credentials file. Amazon MQ IAM (the STS-JWT exchange on RabbitMQ 3.13 and 4.2+) is not built: the AWS documentation describes it, but it can’t be tested here and brokers older than that need a password anyway. Amazon MQ works with a broker user whose password is in the file (the operator keeps it in Secrets Manager and renders the file).
Kafka and MSK
Section titled “Kafka and MSK”- Targets. Apache Kafka 2.8 to 4.x (KRaft or ZooKeeper), Amazon MSK provisioned (IAM, SCRAM, mTLS), Confluent-compatible brokers. MSK Serverless is the same protocol with IAM and is unverified.
tls: trueis the default. - The multi-broker design (the spike). A Kafka client dials the bootstrap address, reads
Metadata, then dials each broker at the address in the answer. Limen resolves this without the client noticing:limen kafka connectbinds one loopback listener for bootstrap and one per broker slot (--brokers N, default 8; ports are chosen by the OS and printed). Every accepted connection is a Limen session; the session request names the slot and carries the port table (database = "slot=2;ports=41001,41002,…", validated).- A session for slot 0 connects to the target’s
endpoint. A session for slot s connects to the broker that has rank s among the cluster’s node IDs, sorted ascending (the worker asks the bootstrap forMetadataand caches the answer for 10 s). - In every
Metadata,FindCoordinator, andDescribeClusteranswer, the worker rewrites each broker’s host and port to127.0.0.1:<port of that broker's slot>and keeps the node IDs, so partition leadership,NOT_LEADERretries, and coordinator lookups keep their meaning. A cluster with more brokers than slots fails the rewrite with a clear error on the session (reconnect with --brokers N) instead of handing out an address that doesn’t exist. - Rank is stable while the membership is. If a broker is added or removed while connected, the client’s next
Metadatarefresh yields the new mapping and its connections re-resolve; the worst case is a fewNOT_LEADER_OR_FOLLOWERretries, which Kafka clients are built for. - Clients use
security.protocol=PLAINTEXTtoward the loopback listeners: the outer hop is mTLS and the loopback hop is local to the user, as for Redis.advertised.listenerson the cluster is never changed.
- The worker parses every request header and the body fields policy needs, and pins the API versions it can read. It answers
ApiVersionsitself with the intersection of its own table and what the broker supports, so clients negotiate down to versions the worker understands. Pinned (non-flexible encodings, which keeps the hand-written readers small):Produce3–8,Fetch4–11,ListOffsets1–5,Metadata1–8,OffsetCommit2–7,OffsetFetch1–5,FindCoordinator0–2,JoinGroup0–5,Heartbeat0–3,LeaveGroup0–3,SyncGroup0–3,DescribeGroups0–4,ListGroups0–2,CreateTopics0–4,DeleteTopics0–3,InitProducerId0–1,ApiVersions0–3. Any API key or version outside the table is not advertised and is refused: transactions, ACL and quota changes, config alteration, partition reassignment, and the broker-to-broker APIs cannot be used through Limen. - Policy, in the shape of Kafka ACLs:
kafka:users: [consumer] # broker user (credential selector)classes: [read] # read | write | admin. Default [read]topics: ["orders.*"] # `*` only. Default: any non-internal topicgroups: ["reports-*"] # consumer groups. Default: any
- read:
Fetch,ListOffsets,OffsetFetch, group membership (JoinGroup,SyncGroup,Heartbeat,LeaveGroup,OffsetCommit,DescribeGroups),Metadata,FindCoordinator. write:Produce,InitProducerId. admin:CreateTopics,DeleteTopics. - Every topic named in a request must be covered by a pattern (as text:
orders.a*is covered byorders.*); every group by a group pattern. A refused request gets the protocol’s error (TOPIC_AUTHORIZATION_FAILEDorGROUP_AUTHORIZATION_FAILED) for that topic, the others in the request are still served, like a broker’s ACLs. - Internal topics (names starting with
_, such as__consumer_offsets) match only a pattern that starts with_. Metadataanswers are filtered to the topics the grant covers (a request for all topics is answered with the covered ones), so topic names the user may not touch aren’t disclosed.
- read:
- Authentication to the cluster, in the worker, per target
sasl:—plain,scram-sha-256,scram-sha-512,aws-msk-iam, ornone(mTLS or an open listener), reusing the Phase 6 SCRAM and SigV4 code.aws-msk-iamsigns theAWS_MSK_IAMpayload (SigV4,kafka-cluster:Connect, 15 minutes) with the worker’s AWS credentials and re-authenticates before the broker’ssession_lifetime_ms, never mid-request.OAUTHBEARERandGSSAPIare not built. - Audit.
kafka.requestevents, written before the request is forwarded: API, client ID, topic(s), partition count, group, and forProducethe record batch bytes (never contents).ProduceandFetchpolls arrive hundreds of times a second, so they are audited at first contact with each (API, topic) in a session and once per 60 s after, and each event carries the counts of requests and bytes since the last (akafka.summaryevent closes the session with the totals). Group, admin, and coordinator operations are audited every time;HeartbeatandApiVersionsare counted, not logged. Denials areauth.deniedevery time.
Session record
Section titled “Session record”- What “recorded” means for these kinds (as for databases and Redis): the audit events above are the record; they are hash-chained, shipped to S3 and Datadog, and carry the session ID. The control plane projects them into the existing
db_statementstable (mq.operation,kafka.request, and the broker-kindauth.denied, statusallowedordenied), and the console’s session page lists a messaging session’s operations in order, with the refused ones marked, like a Redis session’s commands. No new table, no terminal recording, no message content. - Exit criteria demonstrated in the demo and tests are listed below; the session page showing each protocol’s operations is part of the demo.
Tests and demo
Section titled “Tests and demo”- Unit: AMQP frame and method codec (round-trips, truncation, malformed lengths, oversize), policy matrices for both grants, the Kafka request readers and the
Metadatarewrite and filter against fixtures captured from real clients, SCRAM andAWS_MSK_IAMpayload fixtures, the dedupe window, audit fail-closed with a dead sink. - Session tests against an in-process fake broker (as for Redis), including the refused-channel path and the slot table.
- Compose: RabbitMQ 4 (with a second vhost and permissions), a 3-broker KRaft Kafka cluster with SASL (
PLAINandSCRAM) listeners. Scenarios drive real clients:pika(AMQP) andlibrdkafka, the librarykcatis built on, throughlimen mq connectandlimen kafka connect; the multi-broker proof creates a 6-partition topic with replication factor 3 and checks that the partition leaders are on all three brokers, every partition is produced to and consumed through Limen, a consumer group rebalances across two clients, and killing one broker’s leadership (controlled shutdown) does not break the session after a metadata refresh. - Demo:
demos/demo-phase11.sh.
Dependencies
Section titled “Dependencies”None new in the workspace: the codecs are ours, HMAC and SHA-2 come from aws-lc-rs and sha2, SigV4 and SCRAM are the Phase 6 modules. The demo scripts install pika and confluent-kafka into a throw-away virtualenv; they are not Rust dependencies and not in the product.
Open questions (decided as recommended)
Section titled “Open questions (decided as recommended)”- Fetch and Produce audit sampling. Per-request events for a polling consumer would drown the trail and the control plane. The recommendation (item 19) is first contact plus one event per minute with counts. The alternative is an event per request with a per-resource opt-out.
- Kafka API version pinning. Capping versions means a client library newer than the pinned set negotiates down (Java 3.x and librdkafka do this transparently), and some features need the missing APIs (transactions, exactly-once, share groups). Is that acceptable for Phase 11? The alternative is flexible-version readers for all of them, which is most of the work again.
- Slot count default (8). MSK clusters are three to six brokers per AZ set; bigger clusters need
--brokers. Alternatively the CLI could bind lazily on demand, which needs a control channel from the worker; not built. - AMQP publish auditing at volume. One event per publish, grouped per read of the socket so one fsync covers a batch. A producer that sends one message at a time pays one fsync (measured in Phase 7 at about 9 ms in the dev stack). Is a per-exchange sampling mode wanted?
- Management plane. The RabbitMQ management API and Kafka REST proxies/UIs are web resources (Phase 12), not part of this phase.
Consequences
Section titled “Consequences”- + AMQP and Kafka clients work unchanged and never see a credential; the same session, audit, lock, and kill machinery as SSH applies.
- + Policy reads like the brokers’ own permission models.
- + The multi-broker design needs no change on the cluster and no wildcard DNS.
- − Two more wire codecs to own. Kafka’s version pinning is a deliberate limit that must be documented.
- − A Kafka client that caches broker addresses across a Limen reconnect (a new CLI invocation prints new ports) must be restarted.
- − Producing one message at a time is slower through Limen than directly (audit before forward).
- Threat model: no new entry point beyond the loopback listeners already listed, and two new data-store connections;
docs/threat-model.mdgets the rows when the code lands. - Unverified against real services: Amazon MQ, MSK (IAM payload acceptance, session re-authentication, TLS with the AWS CA bundle), and MSK Serverless. Locally the SigV4 inputs are tested against fixtures.
As built (2026-10-18)
Section titled “As built (2026-10-18)”Where this section and the text above differ, this section is right.
- Credentials and TLS are one pair of worker flags for both protocols:
--broker-credentials-file(LIMEN_BROKER_CREDENTIALS_FILE) and--broker-ca-file. A broker user’s entry is a password, or an object with a password and a client certificate and key for mutual TLS. A TLS target without a CA file is refused. - RabbitMQ names. The reserved prefix is
amq.rabbitmq.(the firehose tracer, direct reply-to handled separately), not all ofamq.*: the standard exchanges (amq.topic, …) are ordinary names, as in RabbitMQ’s own permissions. A queue the broker names for the session (amq.gen-...) is the session’s own and usable whatever the patterns say. The worker offers frame-max 131072 to the client and refuses a broker that allows less; the vhost is opened at the broker only after the client’sConnection.Open. - RabbitMQ audit has
connect,publish(exchange, routing key, size),consume,get, and the declare, bind, purge, and delete operations. A publish is held until its content header gives the size. Acknowledgements,Basic.qos, and channel housekeeping are checked but not logged.mq.summaryholds the publish and delivery counts and bytes. - Kafka table.
DescribeGroupsandListGroupswere left out (they return other members’ data and need response filtering);SaslHandshakev1 andSaslAuthenticatev0-1 were added, answered locally so a client configured for SASL still connects (its login is discarded).Metadatarewrites the request to switch off automatic topic creation unless the role hasadmin. - Kafka audit. Dedupe is per (API, topic) with a 60 s window kept in memory per session; a window rollover logs the counts since.
Metadatais deduped on its topic list. Fetch bytes are not counted (only Produce’s, as the span size of the request); the session’ssession.endhas the byte totals. An audit outage answers withREQUEST_TIMED_OUT(retriable), and ends the connection forMetadata. - Broker slots. The slot table travels in the session request’s
databasefield and is validated by the proxy and the worker. A cluster with more brokers than listeners makes the worker end the session and writeauth.deniedwith the--brokers Nhint; the CLI prints it on the next refused connection only if the proxy refused it, so the reason is in the audit trail and the worker’s log. - Not built: MSK IAM re-authentication before
session_lifetime_ms(the broker closes the connection and the client reconnects, which opens a new session), Amazon MQ IAM sign-in, SCRAM andAWS_MSK_IAMagainst real services (SCRAM is tested against a real Kafka 3.9; the SCRAM maths against RFC 7677 and an independent implementation),OAUTHBEARER,GSSAPI. - Tests. Unit: 32 AMQP and 52 Kafka tests including whole sessions against fake brokers on loopback sockets (the multi-broker slot logic is tested with two of them).
demos/demo-phase11.shis the exit test and the scenario test in one: real RabbitMQ 4.0 with pika, and a real three-broker Apache Kafka 3.9 cluster with librdkafka. - Unverified against real services: Amazon MQ for RabbitMQ (TLS on 5671 with the AWS bundle), Amazon MSK (IAM payload acceptance, TLS), MSK Serverless.
Review (2026-10-18)
Section titled “Review (2026-10-18)”Read once, by the author, from three seats: a RabbitMQ engineer, a Kafka engineer, and an SRE. The demo had already passed against real brokers.
Fixed after the review
- No metrics for the new paths: added
limen_broker_refused_totalandlimen_broker_audit_failures_total(labelled by protocol), documented in the guide. - Each passthrough Kafka request was copied once more than needed; the common case now frames the message straight from the read buffer.
- The guide gained an operators’ section (sessions per client, upgrade order, troubleshooting, audit volume).
Left, and why
- Kafka (expert).
Fetchisn’t audited per request or per offset range: a reader is visible as “topic X was read, N requests, in this minute”, not which offsets. Per-request events were the alternative (open question 1).JoinGroupsubscriptions can name topics the role doesn’t reach; they yieldTOPIC_AUTHORIZATION_FAILEDon fetch and are absent fromMetadata, but the subscription itself is not rewritten.DescribeGroups,ListGroups, transactions, andOffsetForLeaderEpochare not offered, so some tools (kafka-consumer-groups --describe, exactly-once producers) don’t work through Limen. Rank-based broker slots shift when a broker with a lower node ID is added or removed; clients heal throughNOT_LEADERretries, but a restart of the CLI listener is the clean answer. - RabbitMQ (expert). Permission checks are by name pattern only; the broker’s regex permissions and topic authorization (
topic-permissionsfor routing keys on topic exchanges) are not mirrored, so a role that may publish toamq.topicmay use any routing key (the broker’s own topic permissions stay the second layer).Queue.bindneedsconfigureon both names, where RabbitMQ askswriteon the queue andreadon the exchange. Consumers started before a policy change keep consuming until the session ends.Basic.publishwithmandatoryreturns pass through unaudited. - SRE. One audit event per AMQP publish is the largest cost (a lone unbatched publisher pays one fsync each); a per-exchange sampling mode is the follow-up. A Kafka client multiplies sessions by its broker count. Request memory is bounded by 128 MiB per message (response 256 MiB) per session, so many sessions with huge batches can use gigabytes: lower
max.request.sizeon clients or the worker’s session cap. MSK IAM connections end at the broker’s session lifetime and the client reconnects; there is no in-session re-authentication.
Review after merging main (2026-10-19)
Section titled “Review after merging main (2026-10-19)”The branch was brought up to date with Phases 8, 9, and 10 and the reorganised dev stack, then read again from the same three seats (RabbitMQ, Kafka, SRE) and checked against real brokers.
Adapted to main
- Sessions are merged per run, as for MongoDB and search:
limen mq connectandlimen kafka connectgive every session of a run one group id (SessionRequest.group), which the proxy puts onsession.startand the console uses to show one row for the run (“N connections”) with the operations of all of them. This matters most for Kafka, where a client holds a connection to every broker. The check the command makes at startup carries no traffic and doesn’t count. - The compose stack moved to
stacks/messaging.yml(profilemessaging, selectable insetup.sh), the target files toconfig/targets/messaging/with aprovision.shfor the Kafka topics, ACLs, and SCRAM users, the images todeploy/compose/.env, and the demo todemos/demo-phase11.sh. tests: a phase 11 end-to-end test (limen-e2e/tests/phase11.rs) runs an AMQP client through control plane, proxy, and worker against a stand-in broker, including a refused publish, the trail without the body, a malformed Kafka slot table, and a lock ending the session.
Bugs found and fixed
- RabbitMQ (expert). A queue declared with
x-dead-letter-exchange, or an exchange withalternate-exchange, let a role withconfigureroute messages to an exchange it has nowriteon; the broker accepted it because the worker’s broker user may. The worker now reads the declare’s argument table and requireswriteon the target, as RabbitMQ does for its own users. - RabbitMQ (expert). A passive declare was refused for a role without
configure, which breaks clients that only check that a queue exists. It now needs any one permission on the name. - Kafka and SRE. One audit event could be as large as the request that caused it (a refused
MetadataorProducenaming thousands of topics, a 32 KB client id): lists are cut to 32 names and strings to 256 characters, with the total recorded. The per-session state for the audit window grew with every distinct topic name a wildcard grant covers: it is now capped. - SRE. Every Kafka read allocated a fresh 64 KiB buffer, and an unmodified response was copied twice; the buffer is reused and the response passes through borrowed.
Left, and why
- Refusals are audited one event each (bounded in size now), so a client that retries a refused request in a loop is a rate of events, not a size. Per-session refusal sampling is the follow-up; the
limen_broker_refused_totalmetric shows it. - Per-session memory is still up to 128 MiB for a request, 256 MiB for a response, and a 512 MiB backlog for a client that isn’t reading (64 MiB for AMQP); size workers by sessions times that, or lower it when there are many clients.
- The run stays active while the CLI listens. The CLI’s startup session is kept open (
database: anchor, the same idea as the search command’s anchor). The proxy and worker authorize it like any session (policy, a credential exists) and the worker then serves no protocol on it, so it ends when the CLI stops, atmax_session_ttl, on a lock, or on a drain. Without it a run was active only while a client was connected, so an idle listener looked ended in the console.
Amendment: concise broker-user selectors (2026-10-23)
Section titled “Amendment: concise broker-user selectors (2026-10-23)”mq.users and kafka.users select broker users directly and do not require a Limen prefix. The guides and examples use consumer and admin.