From 8a5dcc5b114568165d97dfde9bdf3b9c39598984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Fri, 31 Jul 2026 16:08:19 -0300 Subject: [PATCH] allow non-admin publish to the subscribe request channel --- CHANGELOG.md | 6 ++++ Cargo.lock | 4 +-- README.md | 3 +- crates/mqdb-agent/Cargo.toml | 2 +- crates/mqdb-agent/src/topic_protection.rs | 40 +++++++++++++++++++++++ crates/mqdb-agent/src/topic_rules.rs | 38 +++++++++++++++++++++ crates/mqdb-cli/Cargo.toml | 2 +- 7 files changed, 90 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1c8d27..79ee2bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Each entry lists the date and the crate versions that were released. +## 2026-07-31 — mqdb-cli 0.8.24, mqdb-agent 0.8.17 + +### Fixed + +- **`mqdb subscribe` works for non-admin users.** `$DB/_sub/subscribe` (and the `$DB/_sub/{id}/heartbeat` / `unsubscribe` control topics the `mqdb subscribe` command publishes) were treated as internal `$DB/_*` topics, so any non-admin publish was rejected with `internal entity access denied`. The `$DB/_sub/#` channel is now a new **WriteOnly** protection tier: any authenticated user may **publish** subscribe requests (subject to ACL, like a normal topic), but **subscribing** to `$DB/_sub/#` is denied for non-service clients so one user cannot snoop another's requests or response topics. The server (internal service) bypasses topic protection and still consumes the requests. + ## 2026-07-30 — mqdb-cli 0.8.23, mqdb-agent 0.8.16, mqdb-cluster 0.4.7 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 076fc7bb..2bdbc33a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "mqdb-agent" -version = "0.8.16" +version = "0.8.17" dependencies = [ "arc-swap", "argon2", @@ -1455,7 +1455,7 @@ dependencies = [ [[package]] name = "mqdb-cli" -version = "0.8.23" +version = "0.8.24" dependencies = [ "base64", "bebytes", diff --git a/README.md b/README.md index 257b0f18..3a7a03bb 100644 --- a/README.md +++ b/README.md @@ -478,9 +478,10 @@ MQDB enforces hardcoded protection on internal topics that cannot be overridden |------|--------|----------| | BlockAll | `_mqdb/#`, `$DB/_idx/#`, `$DB/_unique/#`, `$DB/_fk/#`, `$DB/_query/#`, `$DB/p+/#` | All access denied | | ReadOnly | `$SYS/#`, `$DB/+/events/#`, `$DB/u/#` | Subscribe allowed, publish denied | +| WriteOnly | `$DB/_sub/#` | Publish allowed, subscribe denied | | AdminRequired | `$DB/_admin/#`, `$DB/_verify/#`, `$DB/_oauth_tokens/#`, `$DB/_identities/#`, `$DB/_identity_links/#` | Requires admin user or explicit ACL grant | -Entities starting with `_` (e.g., `_sessions`, `_mqtt_subs`) require admin access. Exceptions: `$DB/_health`, `$DB/_vault/*`, and `$DB/_auth/*` are accessible to any authenticated user. For `AdminRequired` topics, non-admin users with an explicit ACL grant for the specific topic are also allowed access. This enables operator-provisioned service accounts (e.g., an email verifier with ACL grants for `$DB/_verify/#`) without requiring full admin privileges. +Entities starting with `_` (e.g., `_sessions`, `_mqtt_subs`) require admin access. Exceptions: `$DB/_health`, `$DB/_vault/*`, and `$DB/_auth/*` are accessible to any authenticated user, and `$DB/_sub/*` is a user-callable request channel (the `mqdb subscribe` command) — any authenticated user may publish subscribe/heartbeat/unsubscribe requests, but only the server consumes them, so subscribing to `$DB/_sub/*` is denied to prevent request snooping. For `AdminRequired` topics, non-admin users with an explicit ACL grant for the specific topic are also allowed access. This enables operator-provisioned service accounts (e.g., an email verifier with ACL grants for `$DB/_verify/#`) without requiring full admin privileges. `$DB/u/#` is the reserved per-user scoped-events namespace (`--scoped-events`). Publishing is service-only — blocked for all external users regardless of the flag — so only the internal event publisher writes there. Subscribing is allowed by the ReadOnly tier, and when scoped events are enabled a user may only subscribe to their own `$DB/u/{me}/events/#`. Because the top-level `u` segment is reserved, `u` cannot be used as a regular entity name. diff --git a/crates/mqdb-agent/Cargo.toml b/crates/mqdb-agent/Cargo.toml index 88a13617..a944cdd9 100644 --- a/crates/mqdb-agent/Cargo.toml +++ b/crates/mqdb-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-agent" -version = "0.8.16" +version = "0.8.17" edition.workspace = true license = "Apache-2.0" authors.workspace = true diff --git a/crates/mqdb-agent/src/topic_protection.rs b/crates/mqdb-agent/src/topic_protection.rs index 4f2525df..a31b16d9 100644 --- a/crates/mqdb-agent/src/topic_protection.rs +++ b/crates/mqdb-agent/src/topic_protection.rs @@ -630,4 +630,44 @@ mod tests { .await ); } + + #[tokio::test] + async fn non_admin_can_publish_sub_channel() { + let provider = create_test_provider(HashSet::new()); + assert!( + provider + .authorize_publish("c", Some("alice"), "$DB/_sub/subscribe") + .await + ); + assert!( + provider + .authorize_publish("c", Some("alice"), "$DB/_sub/abc123/heartbeat") + .await + ); + } + + #[tokio::test] + async fn non_admin_cannot_subscribe_sub_channel() { + let provider = create_test_provider(HashSet::new()); + assert!( + !provider + .authorize_subscribe("c", Some("alice"), "$DB/_sub/subscribe") + .await + ); + assert!( + !provider + .authorize_subscribe("c", Some("alice"), "$DB/_sub/#") + .await + ); + } + + #[tokio::test] + async fn internal_service_can_consume_sub_channel() { + let provider = create_test_provider_with_internal("mqdb-internal"); + assert!( + provider + .authorize_subscribe("c", Some("mqdb-internal"), "$DB/_sub/subscribe") + .await + ); + } } diff --git a/crates/mqdb-agent/src/topic_rules.rs b/crates/mqdb-agent/src/topic_rules.rs index 2e3dd6d7..0fe48f2f 100644 --- a/crates/mqdb-agent/src/topic_rules.rs +++ b/crates/mqdb-agent/src/topic_rules.rs @@ -7,6 +7,7 @@ use std::fmt; pub enum ProtectionTier { BlockAll, ReadOnly, + WriteOnly, AdminRequired, } @@ -49,6 +50,10 @@ pub const PROTECTED_TOPICS: &[TopicRule] = &[ pattern: "$DB/u/#", tier: ProtectionTier::ReadOnly, }, + TopicRule { + pattern: "$DB/_sub/#", + tier: ProtectionTier::WriteOnly, + }, TopicRule { pattern: "$SYS/mqdb/cluster/#", tier: ProtectionTier::AdminRequired, @@ -67,6 +72,7 @@ pub const PROTECTED_TOPICS: &[TopicRule] = &[ pub enum BlockReason { InternalTopicBlocked, ReadOnlyTopic, + WriteOnlyTopic, AdminRequired, InternalEntityAccess, } @@ -76,6 +82,7 @@ impl fmt::Display for BlockReason { match self { Self::InternalTopicBlocked => write!(f, "internal topic blocked"), Self::ReadOnlyTopic => write!(f, "read-only topic"), + Self::WriteOnlyTopic => write!(f, "write-only topic"), Self::AdminRequired => write!(f, "admin role required"), Self::InternalEntityAccess => write!(f, "internal entity access denied"), } @@ -173,6 +180,13 @@ pub fn check_topic_access( Ok(()) } } + ProtectionTier::WriteOnly => { + if is_publish { + Ok(()) + } else { + Err(BlockReason::WriteOnlyTopic) + } + } ProtectionTier::AdminRequired => { if is_admin { Ok(()) @@ -398,6 +412,30 @@ mod tests { ); } + #[test] + fn check_access_sub_channel_write_only() { + assert_eq!( + check_topic_access("$DB/_sub/subscribe", true, false), + Ok(()) + ); + assert_eq!( + check_topic_access("$DB/_sub/abc123/heartbeat", true, false), + Ok(()) + ); + assert_eq!( + check_topic_access("$DB/_sub/abc123/unsubscribe", true, false), + Ok(()) + ); + assert_eq!( + check_topic_access("$DB/_sub/subscribe", false, false), + Err(BlockReason::WriteOnlyTopic) + ); + assert_eq!( + check_topic_access("$DB/_sub/subscribe", false, true), + Err(BlockReason::WriteOnlyTopic) + ); + } + #[test] fn check_access_regular_topics_allowed() { assert_eq!(check_topic_access("$DB/users/create", true, false), Ok(())); diff --git a/crates/mqdb-cli/Cargo.toml b/crates/mqdb-cli/Cargo.toml index fa877df2..ace04f8c 100644 --- a/crates/mqdb-cli/Cargo.toml +++ b/crates/mqdb-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-cli" -version = "0.8.23" +version = "0.8.24" publish = false edition.workspace = true license = "AGPL-3.0-only"