Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-agent/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 40 additions & 0 deletions crates/mqdb-agent/src/topic_protection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
38 changes: 38 additions & 0 deletions crates/mqdb-agent/src/topic_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::fmt;
pub enum ProtectionTier {
BlockAll,
ReadOnly,
WriteOnly,
AdminRequired,
}

Expand Down Expand Up @@ -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,
Expand All @@ -67,6 +72,7 @@ pub const PROTECTED_TOPICS: &[TopicRule] = &[
pub enum BlockReason {
InternalTopicBlocked,
ReadOnlyTopic,
WriteOnlyTopic,
AdminRequired,
InternalEntityAccess,
}
Expand All @@ -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"),
}
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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(()));
Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading