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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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-26 — mqdb-cli 0.8.21, mqdb-agent 0.8.14

### Fixed

- **Per-user event namespace `$DB/u/#` is now publish-protected unconditionally.** The per-user publish/subscribe guards were gated on `--scoped-events`; with the flag off, no rule matched `$DB/u/...` and any authenticated user could publish to (or subscribe to) another user's `$DB/u/{anyone}/events/#`. A static `$DB/u/#` read-only rule now blocks all publishes to the namespace regardless of the flag (the internal service still publishes there); the cross-user subscribe restriction remains flag-gated, since scoped events are the only feature that uses the namespace.
- **Grantees are notified when a resource is shared or unshared (scoped events).** Under `--scoped-events`, sharing a diagram with a user emitted no event the grantee could see — the `_shares` change event is a Global entity and broadcast to the admin-only `$DB/_shares/events/...` topic. Share grant/revoke events are now scoped to the affected grantee's `$DB/u/{grantee}/events/_shares/{id}` namespace (a `_shares` event's recipient is its grantee), so a client learns of gained or lost access without polling. With scoped events off, `_shares` events stay admin-only as before.

## 2026-07-25 — mqdb-cli 0.8.20, mqdb-core 0.7.6, mqdb-agent 0.8.13, mqdb-cluster 0.4.5, mqdb-wasm 0.3.5

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

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,11 +477,13 @@ MQDB enforces hardcoded protection on internal topics that cannot be overridden
| Tier | Topics | Behavior |
|------|--------|----------|
| BlockAll | `_mqdb/#`, `$DB/_idx/#`, `$DB/_unique/#`, `$DB/_fk/#`, `$DB/_query/#`, `$DB/p+/#` | All access denied |
| ReadOnly | `$SYS/#` | Subscribe allowed, publish denied |
| ReadOnly | `$SYS/#`, `$DB/+/events/#`, `$DB/u/#` | Subscribe allowed, publish 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.

`$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.

#### Admin User Configuration

```bash
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.13"
version = "0.8.14"
edition.workspace = true
license = "Apache-2.0"
authors.workspace = true
Expand Down
10 changes: 10 additions & 0 deletions crates/mqdb-agent/src/database/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,16 @@ impl Database {
id: &str,
data: Option<&Value>,
) -> Result<Option<Vec<String>>> {
if entity == SHARES_ENTITY {
let grantee = data
.and_then(|d| d.get("grantee"))
.and_then(Value::as_str)
.filter(|g| !g.is_empty());
return Ok(Some(
grantee.map(|g| vec![g.to_string()]).unwrap_or_default(),
));
}

let (res_entity, res_id, owner) = if let Some(owner_field) = ownership.owner_field(entity) {
let owner = data
.and_then(|d| d.get(owner_field))
Expand Down
25 changes: 25 additions & 0 deletions crates/mqdb-agent/src/topic_protection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,4 +605,29 @@ mod tests {
.await
);
}

#[tokio::test]
async fn user_namespace_publish_blocked_even_with_scoped_events_off() {
let provider = create_test_provider(HashSet::new());
assert!(
!provider
.authorize_publish("c", Some("alice"), "$DB/u/bob/events/diagrams/1")
.await
);
assert!(
!provider
.authorize_publish("c", Some("alice"), "$DB/u/alice/events/diagrams/1")
.await
);
}

#[tokio::test]
async fn internal_service_publishes_user_namespace_with_scoped_events_off() {
let provider = create_test_provider_with_internal("mqdb-internal");
assert!(
provider
.authorize_publish("c", Some("mqdb-internal"), "$DB/u/alice/events/diagrams/1")
.await
);
}
}
24 changes: 24 additions & 0 deletions crates/mqdb-agent/src/topic_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub const PROTECTED_TOPICS: &[TopicRule] = &[
pattern: "$DB/+/events/#",
tier: ProtectionTier::ReadOnly,
},
TopicRule {
pattern: "$DB/u/#",
tier: ProtectionTier::ReadOnly,
},
TopicRule {
pattern: "$SYS/mqdb/cluster/#",
tier: ProtectionTier::AdminRequired,
Expand Down Expand Up @@ -374,6 +378,26 @@ mod tests {
);
}

#[test]
fn check_access_user_namespace_read_only() {
assert_eq!(
check_topic_access("$DB/u/bob/events/created", true, false),
Err(BlockReason::ReadOnlyTopic)
);
assert_eq!(
check_topic_access("$DB/u/bob/events/diagrams/1", true, false),
Err(BlockReason::ReadOnlyTopic)
);
assert_eq!(
check_topic_access("$DB/u/bob/events/created", true, true),
Err(BlockReason::ReadOnlyTopic)
);
assert_eq!(
check_topic_access("$DB/u/bob/events/created", false, false),
Ok(())
);
}

#[test]
fn check_access_regular_topics_allowed() {
assert_eq!(check_topic_access("$DB/users/create", true, false), Ok(()));
Expand Down
45 changes: 45 additions & 0 deletions crates/mqdb-agent/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2723,6 +2723,51 @@ async fn test_event_recipients_owner_grantees_and_global() {
);
}

#[tokio::test]
async fn test_share_events_route_to_grantee_namespace() {
let tmp = TempDir::new().unwrap();
let db = Database::open_without_background_tasks(tmp.path())
.await
.unwrap();
let ownership = OwnershipConfig::parse("diagrams=userId").unwrap();

let recipients = db
.event_recipients(
&ownership,
mqdb_core::types::SHARES_ENTITY,
"share-1",
Some(&json!({
"resource_entity": "diagrams",
"resource_id": "d1",
"grantee": "bob",
"permission": "view",
})),
)
.await
.unwrap()
.expect("a share grant/revoke is scoped to its grantee");
assert_eq!(
recipients,
vec!["bob".to_string()],
"share event must reach only the grantee's per-user namespace"
);

let pending = db
.event_recipients(
&ownership,
mqdb_core::types::SHARES_ENTITY,
"share-2",
Some(&json!({"resource_entity": "diagrams", "resource_id": "d1", "grantee": ""})),
)
.await
.unwrap();
assert_eq!(
pending,
Some(vec![]),
"a share with no resolved grantee has no recipients and is not broadcast"
);
}

#[tokio::test]
async fn test_cascade_delete_events_carry_recipients() {
use mqdb_core::{OnDeleteAction, Request, Response};
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.20"
version = "0.8.21"
publish = false
edition.workspace = true
license = "AGPL-3.0-only"
Expand Down
Loading