Skip to content
Open
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-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
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.

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

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.18"
version = "0.8.19"
edition.workspace = true
license = "Apache-2.0"
authors.workspace = true
Expand Down
64 changes: 47 additions & 17 deletions crates/mqdb-agent/src/database/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ impl Database {
}
}

async fn delete_grants(&self, filters: Vec<Filter>) -> Result<()> {
async fn delete_grants(&self, filters: Vec<Filter>, ownership: &OwnershipConfig) -> Result<()> {
let records = self
.list_core(
SHARES_ENTITY.to_string(),
Expand All @@ -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(
Expand All @@ -71,27 +70,39 @@ impl Database {
None,
None,
&scope,
&ownership,
ownership,
)
.await?;
}
}
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
/// so stale grants cannot be inherited by a later record reusing the same id.
///
/// # 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(
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -241,13 +253,14 @@ impl Database {
cascade: bool,
) -> Result<Value> {
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 }))
Expand Down Expand Up @@ -370,13 +383,30 @@ impl Database {
data: Option<&Value>,
) -> Result<Option<Vec<String>>> {
if entity == SHARES_ENTITY {
let grantee = data
let mut recipients: Vec<String> = 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());
}
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) {
Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-agent/src/transport_execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading
Loading