From 51c1ea9e62ffb99d081c0941a3c9ce9492dbdf39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Sun, 2 Aug 2026 16:32:48 -0300 Subject: [PATCH 1/2] notify resource owner when admin shares or unshares --- CHANGELOG.md | 6 + Cargo.lock | 4 +- README.md | 2 +- crates/mqdb-agent/Cargo.toml | 2 +- crates/mqdb-agent/src/database/sharing.rs | 66 ++++++-- crates/mqdb-agent/src/transport_execute.rs | 2 +- crates/mqdb-agent/tests/integration_test.rs | 163 ++++++++++++++++++++ crates/mqdb-cli/Cargo.toml | 2 +- 8 files changed, 224 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35ad45f..8dfa0736 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-08-02 — mqdb-cli 0.8.26, mqdb-agent 0.8.19 + +### Fixed + +- **A resource owner is notified when an admin shares or unshares their resource (scoped events).** Share/unshare already delivered a `_shares` event to the affected grantee's `$DB/u/{grantee}/events/#` namespace, but the resource owner was not told when someone else (e.g. an admin acting on their behalf) changed their resource's share set. `event_recipients` now also routes a `_shares` event to the resource owner, skipping the owner when they performed the share themselves (no self-notification for the ordinary owner-initiated flow). + ## 2026-08-01 — mqdb-cli 0.8.25, mqdb-core 0.7.8, mqdb-agent 0.8.18 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 585d0d90..0d29a422 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "mqdb-agent" -version = "0.8.18" +version = "0.8.19" dependencies = [ "arc-swap", "argon2", @@ -1455,7 +1455,7 @@ dependencies = [ [[package]] name = "mqdb-cli" -version = "0.8.25" +version = "0.8.26" dependencies = [ "base64", "bebytes", diff --git a/README.md b/README.md index 3a7a03bb..1acd657f 100644 --- a/README.md +++ b/README.md @@ -417,7 +417,7 @@ An owner can share any ownership-enabled entity (the motivating case is diagrams **Child-entity derivation.** Child records (e.g. a diagram's nodes/edges) can inherit access from their parent via `--ownership-derive` (env `MQDB_OWNERSHIP_DERIVE`), a comma-separated map of `child=fk_field>parent_entity` (e.g. `nodes=diagramId>diagrams,edges=diagramId>diagrams`). A derived child's read requires `view` on the parent and create/update/delete require `edit`; the parent reference is immutable on update, so an editor cannot move a child into a diagram they cannot edit. Without a mapping a child entity is unrestricted (default), so derivation is opt-in per deployment. -**Event confidentiality.** By default change events broadcast on `$DB/{entity}/events/#` to every authenticated subscriber. Enabling `--scoped-events` (env `MQDB_SCOPED_EVENTS`) routes events for ownership-enabled and derived entities to per-recipient topics `$DB/u/{recipient}/events/{entity}/{id}` — the owner plus its share grantees (children resolve recipients through the parent). The broker only lets a user subscribe to their own `$DB/u/{me}/events/#`. Global entities keep the broadcast topic. **This is a breaking change for subscribers** (subscribe to `$DB/u/{me}/events/#` instead of `$DB/{entity}/events/#`), so it is opt-in; enable it on the broker and the client together. +**Event confidentiality.** By default change events broadcast on `$DB/{entity}/events/#` to every authenticated subscriber. Enabling `--scoped-events` (env `MQDB_SCOPED_EVENTS`) routes events for ownership-enabled and derived entities to per-recipient topics `$DB/u/{recipient}/events/{entity}/{id}` — the owner plus its share grantees (children resolve recipients through the parent). The broker only lets a user subscribe to their own `$DB/u/{me}/events/#`. Global entities keep the broadcast topic. **This is a breaking change for subscribers** (subscribe to `$DB/u/{me}/events/#` instead of `$DB/{entity}/events/#`), so it is opt-in; enable it on the broker and the client together. Granting or revoking access itself emits a `_shares` event to the affected grantee's namespace (so a client learns of gained or lost access without polling); when an admin shares or unshares on an owner's behalf, the owner is notified too. #### Admin Operations diff --git a/crates/mqdb-agent/Cargo.toml b/crates/mqdb-agent/Cargo.toml index 23edead1..67500483 100644 --- a/crates/mqdb-agent/Cargo.toml +++ b/crates/mqdb-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-agent" -version = "0.8.18" +version = "0.8.19" edition.workspace = true license = "Apache-2.0" authors.workspace = true diff --git a/crates/mqdb-agent/src/database/sharing.rs b/crates/mqdb-agent/src/database/sharing.rs index 1398020f..064336ee 100644 --- a/crates/mqdb-agent/src/database/sharing.rs +++ b/crates/mqdb-agent/src/database/sharing.rs @@ -50,7 +50,7 @@ impl Database { } } - async fn delete_grants(&self, filters: Vec) -> Result<()> { + async fn delete_grants(&self, filters: Vec, ownership: &OwnershipConfig) -> Result<()> { let records = self .list_core( SHARES_ENTITY.to_string(), @@ -62,7 +62,6 @@ impl Database { ) .await?; let scope = ScopeConfig::default(); - let ownership = OwnershipConfig::default(); for rec in &records { if let Some(sid) = rec.get("id").and_then(Value::as_str) { self.delete( @@ -71,7 +70,7 @@ impl Database { None, None, &scope, - &ownership, + ownership, ) .await?; } @@ -79,10 +78,16 @@ impl Database { Ok(()) } - async fn clear_grant(&self, entity: &str, id: &str, grantee_key: &str) -> Result<()> { + async fn clear_grant( + &self, + entity: &str, + id: &str, + grantee_key: &str, + ownership: &OwnershipConfig, + ) -> Result<()> { let mut filters = Self::resource_filters(entity, id); filters.push(eq_filter("grantee_key", grantee_key)); - self.delete_grants(filters).await + self.delete_grants(filters, ownership).await } /// Remove every grant on a resource. Called when the resource itself is deleted @@ -90,8 +95,14 @@ impl Database { /// /// # Errors /// Returns an error if scanning or deleting the share records fails. - pub(crate) async fn clear_all_resource_grants(&self, entity: &str, id: &str) -> Result<()> { - self.delete_grants(Self::resource_filters(entity, id)).await + pub(crate) async fn clear_all_resource_grants( + &self, + entity: &str, + id: &str, + ownership: &OwnershipConfig, + ) -> Result<()> { + self.delete_grants(Self::resource_filters(entity, id), ownership) + .await } async fn write_grant( @@ -101,8 +112,9 @@ impl Database { grantee: &str, level: AccessLevel, granted_by: &str, + ownership: &OwnershipConfig, ) -> Result<()> { - self.clear_grant(entity, id, grantee).await?; + self.clear_grant(entity, id, grantee, ownership).await?; let record = json!({ "resource_entity": entity, "resource_id": id, @@ -201,7 +213,7 @@ impl Database { }); } let granted_by = sender.unwrap_or_default(); - self.write_grant(entity, id, grantee, level, granted_by) + self.write_grant(entity, id, grantee, level, granted_by, ownership) .await?; let mut shared = 1usize; if cascade { @@ -211,7 +223,7 @@ impl Database { } let existing = self.share_level(entity, &ref_id, grantee).await?; if existing.is_none_or(|current| current < level) { - self.write_grant(entity, &ref_id, grantee, level, granted_by) + self.write_grant(entity, &ref_id, grantee, level, granted_by, ownership) .await?; } shared += 1; @@ -241,13 +253,14 @@ impl Database { cascade: bool, ) -> Result { self.require_owner_or_admin(ownership, entity, id, sender)?; - self.clear_grant(entity, id, grantee).await?; + self.clear_grant(entity, id, grantee, ownership).await?; if cascade { for ref_id in self.referenced_closure(entity, id).await? { if ref_id == id { continue; } - self.clear_grant(entity, &ref_id, grantee).await?; + self.clear_grant(entity, &ref_id, grantee, ownership) + .await?; } } Ok(json!({ "status": "unshared", "grantee": grantee })) @@ -370,13 +383,32 @@ impl Database { data: Option<&Value>, ) -> Result>> { if entity == SHARES_ENTITY { - let grantee = data + let mut recipients: Vec = Vec::new(); + if let Some(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(), - )); + .filter(|g| !g.is_empty()) + { + recipients.push(grantee.to_string()); + } + // Notify the resource owner too (e.g. when an admin shares on their + // behalf), unless the owner performed the share themselves. + if let Some(res_entity) = data + .and_then(|d| d.get("resource_entity")) + .and_then(Value::as_str) + && let Some(res_id) = data + .and_then(|d| d.get("resource_id")) + .and_then(Value::as_str) + && let Some(owner) = self.record_owner(res_entity, res_id, ownership)? + { + let granted_by = data + .and_then(|d| d.get("granted_by")) + .and_then(Value::as_str); + if granted_by != Some(owner.as_str()) && !recipients.contains(&owner) { + recipients.push(owner); + } + } + return Ok(Some(recipients)); } let (res_entity, res_id, owner) = if let Some(owner_field) = ownership.owner_field(entity) { diff --git a/crates/mqdb-agent/src/transport_execute.rs b/crates/mqdb-agent/src/transport_execute.rs index 015a2d8f..75ca4f3a 100644 --- a/crates/mqdb-agent/src/transport_execute.rs +++ b/crates/mqdb-agent/src/transport_execute.rs @@ -240,7 +240,7 @@ impl Database { Ok(()) => { if shareable && let Err(e) = self - .clear_all_resource_grants(&entity_clone, &id_clone) + .clear_all_resource_grants(&entity_clone, &id_clone, ownership) .await { tracing::warn!( diff --git a/crates/mqdb-agent/tests/integration_test.rs b/crates/mqdb-agent/tests/integration_test.rs index c46b81c1..2322b808 100644 --- a/crates/mqdb-agent/tests/integration_test.rs +++ b/crates/mqdb-agent/tests/integration_test.rs @@ -2768,6 +2768,169 @@ async fn test_share_events_route_to_grantee_namespace() { ); } +#[tokio::test] +async fn test_share_events_notify_owner_when_admin_shares() { + let tmp = TempDir::new().unwrap(); + let db = Database::open_without_background_tasks(tmp.path()) + .await + .unwrap(); + let ownership = OwnershipConfig::parse("diagrams=userId").unwrap(); + + db.create( + "diagrams".into(), + json!({"id": "d1", "userId": "alice", "title": "D"}), + None, + None, + None, + &ScopeConfig::default(), + ) + .await + .unwrap(); + + let mut recipients = db + .event_recipients( + &ownership, + mqdb_core::types::SHARES_ENTITY, + "share-1", + Some(&json!({ + "resource_entity": "diagrams", + "resource_id": "d1", + "grantee": "bob", + "granted_by": "admin", + "permission": "view", + })), + ) + .await + .unwrap() + .expect("recipients"); + recipients.sort(); + assert_eq!( + recipients, + vec!["alice".to_string(), "bob".to_string()], + "an admin-initiated share must notify both the grantee and the resource owner" + ); +} + +#[tokio::test] +async fn test_share_events_skip_owner_self_notification() { + let tmp = TempDir::new().unwrap(); + let db = Database::open_without_background_tasks(tmp.path()) + .await + .unwrap(); + let ownership = OwnershipConfig::parse("diagrams=userId").unwrap(); + + db.create( + "diagrams".into(), + json!({"id": "d1", "userId": "alice", "title": "D"}), + None, + None, + None, + &ScopeConfig::default(), + ) + .await + .unwrap(); + + let recipients = db + .event_recipients( + &ownership, + mqdb_core::types::SHARES_ENTITY, + "share-1", + Some(&json!({ + "resource_entity": "diagrams", + "resource_id": "d1", + "grantee": "bob", + "granted_by": "alice", + "permission": "view", + })), + ) + .await + .unwrap() + .expect("recipients"); + assert_eq!( + recipients, + vec!["bob".to_string()], + "an owner-initiated share must notify only the grantee (no self-notification)" + ); +} + +#[tokio::test] +async fn test_unshare_by_admin_notifies_owner() { + use mqdb_core::Request; + use std::collections::HashSet; + + let tmp = TempDir::new().unwrap(); + let db = Database::open_without_background_tasks(tmp.path()) + .await + .unwrap(); + let ownership = OwnershipConfig::parse("diagrams=userId") + .unwrap() + .with_admin_users(HashSet::from(["admin".to_string()])); + let scope = ScopeConfig::default(); + + db.create( + "diagrams".into(), + json!({"id": "d1", "userId": "alice", "title": "D"}), + None, + None, + None, + &scope, + ) + .await + .unwrap(); + + db.execute_with_sender( + Request::Share { + entity: "diagrams".into(), + id: "d1".into(), + grantee: "bob".into(), + permission: "view".into(), + cascade: false, + }, + Some("admin"), + None, + &ownership, + &scope, + None, + ) + .await; + + let mut receiver = db.event_receiver(); + + db.execute_with_sender( + Request::Unshare { + entity: "diagrams".into(), + id: "d1".into(), + grantee: "bob".into(), + cascade: false, + }, + Some("admin"), + None, + &ownership, + &scope, + None, + ) + .await; + + let mut recipients = loop { + let Ok(Ok(event)) = + tokio::time::timeout(tokio::time::Duration::from_millis(500), receiver.recv()).await + else { + panic!("unshare did not emit a shares delete event"); + }; + if event.entity == mqdb_core::types::SHARES_ENTITY { + break event + .recipients + .expect("shares delete event missing recipients"); + } + }; + recipients.sort(); + assert_eq!( + recipients, + vec!["alice".to_string(), "bob".to_string()], + "an admin-initiated unshare must notify both the owner and the grantee" + ); +} + #[tokio::test] async fn test_cascade_delete_events_carry_recipients() { use mqdb_core::{OnDeleteAction, Request, Response}; diff --git a/crates/mqdb-cli/Cargo.toml b/crates/mqdb-cli/Cargo.toml index c97f7ac2..7489b49f 100644 --- a/crates/mqdb-cli/Cargo.toml +++ b/crates/mqdb-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-cli" -version = "0.8.25" +version = "0.8.26" publish = false edition.workspace = true license = "AGPL-3.0-only" From e13d0d33164be0f27b217b81fa7a61809e4f3bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Mon, 3 Aug 2026 10:23:30 -0300 Subject: [PATCH 2/2] cover cascade unshare owner notification and drop stray comment --- crates/mqdb-agent/src/database/sharing.rs | 2 - crates/mqdb-agent/tests/integration_test.rs | 98 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/crates/mqdb-agent/src/database/sharing.rs b/crates/mqdb-agent/src/database/sharing.rs index 064336ee..d1f3d52b 100644 --- a/crates/mqdb-agent/src/database/sharing.rs +++ b/crates/mqdb-agent/src/database/sharing.rs @@ -391,8 +391,6 @@ impl Database { { recipients.push(grantee.to_string()); } - // Notify the resource owner too (e.g. when an admin shares on their - // behalf), unless the owner performed the share themselves. if let Some(res_entity) = data .and_then(|d| d.get("resource_entity")) .and_then(Value::as_str) diff --git a/crates/mqdb-agent/tests/integration_test.rs b/crates/mqdb-agent/tests/integration_test.rs index 2322b808..36026d46 100644 --- a/crates/mqdb-agent/tests/integration_test.rs +++ b/crates/mqdb-agent/tests/integration_test.rs @@ -2931,6 +2931,104 @@ async fn test_unshare_by_admin_notifies_owner() { ); } +#[tokio::test] +async fn test_cascade_unshare_notifies_per_record_owner() { + use mqdb_core::{Request, Response}; + use std::collections::{HashMap, HashSet}; + + let tmp = TempDir::new().unwrap(); + let db = Database::open_without_background_tasks(tmp.path()) + .await + .unwrap(); + let ownership = OwnershipConfig::parse("diagrams=userId") + .unwrap() + .with_admin_users(HashSet::from(["admin".to_string()])); + let scope = ScopeConfig::default(); + + db.add_relationship("diagrams".into(), "parent".into(), "diagrams".into()) + .await; + + let make = async |title: &str, owner: &str, parent: Option<&str>| match db + .execute(Request::Create { + entity: "diagrams".into(), + data: match parent { + Some(p) => json!({"userId": owner, "title": title, "parent_id": p}), + None => json!({"userId": owner, "title": title}), + }, + }) + .await + { + Response::Ok { data } => data["id"].as_str().unwrap().to_string(), + Response::Error { code, message } => panic!("create failed {code}: {message}"), + }; + let d2 = make("D2", "carol", None).await; + let d1 = make("D1", "alice", Some(&d2)).await; + + db.execute_with_sender( + Request::Share { + entity: "diagrams".into(), + id: d1.clone(), + grantee: "bob".into(), + permission: "view".into(), + cascade: true, + }, + Some("admin"), + None, + &ownership, + &scope, + None, + ) + .await; + + let mut receiver = db.event_receiver(); + + db.execute_with_sender( + Request::Unshare { + entity: "diagrams".into(), + id: d1.clone(), + grantee: "bob".into(), + cascade: true, + }, + Some("admin"), + None, + &ownership, + &scope, + None, + ) + .await; + + let mut by_owner: HashMap> = HashMap::new(); + while by_owner.len() < 2 { + let Ok(Ok(event)) = + tokio::time::timeout(tokio::time::Duration::from_millis(500), receiver.recv()).await + else { + break; + }; + if event.entity == mqdb_core::types::SHARES_ENTITY { + let res_id = event + .data + .as_ref() + .and_then(|d| d.get("resource_id")) + .and_then(serde_json::Value::as_str) + .unwrap() + .to_string(); + let mut r = event.recipients.expect("recipients"); + r.sort(); + by_owner.insert(res_id, r); + } + } + assert_eq!( + by_owner.get(&d1), + Some(&vec!["alice".to_string(), "bob".to_string()]), + "d1 unshare notifies its owner alice + grantee bob" + ); + assert_eq!( + by_owner.get(&d2), + Some(&vec!["bob".to_string(), "carol".to_string()]), + "d2 unshare notifies its own owner carol (not root owner) + grantee bob" + ); +} + #[tokio::test] async fn test_cascade_delete_events_carry_recipients() { use mqdb_core::{OnDeleteAction, Request, Response};