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-05-28 — mqdb-core 0.7.1, mqdb-agent 0.8.5, mqdb-vault 0.1.1, mqdb-wasm 0.3.3, mqdb-cli 0.8.5

### Added

- Resource sharing for agent mode. An owner can grant another user `view` or `edit` on any ownership-enabled entity (the motivating case is diagrams) through four new resource-scoped MQTT topics: `$DB/{entity}/{id}/share` (`{"grantee","permission":"view|edit","cascade":true}`), `$DB/{entity}/{id}/unshare` (`{"grantee","cascade":true}`), `$DB/{entity}/{id}/shares` (owner/admin lists a resource's grants), and `$DB/{entity}/shared` (caller lists resources shared with them). Grants are stored in a server-managed `_shares` entity that is not reachable through generic CRUD — direct `$DB/_shares/...` returns 403. Read now requires `view` and update requires `edit`; delete stays owner-only, and deleting a resource clears its grants so a record reusing the same id cannot inherit them. `share`/`unshare` cascade by default over self-referencing diagram→diagram references (bounded at 256, cycle-safe; a direct share sets the level while a cascade only raises an existing grant). In OAuth deployments the grantee email is resolved to its canonical id via `_identity_links` (resolve-existing only — sharing with an unregistered email returns 404; password/SCRAM use the username verbatim). The authorization core is verified in TLA+ (`specs/DiagramSharing.tla`, `CascadeClosure.tla`, `GrantLifecycle.tla`).
- New `mqdb-core` public API backing the feature: `AccessLevel` (`View`/`Edit`, ordered), `SHARES_ENTITY`, and the `Share`/`Unshare`/`Shares`/`Shared` variants on `Request` and `DbOp`. Cluster parity is tracked in #75; the embedded `mqdb-wasm` build rejects these operations with "sharing is not supported in embedded mode".

## 2026-05-24 — mqdb-agent 0.8.4

### Removed
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

15 changes: 15 additions & 0 deletions Makefile.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,24 @@ args = ["fmt", "--manifest-path", "crates/mqdb-wasm/Cargo.toml", "--check"]

[tasks.check]
description = "Quick compilation check"
dependencies = ["check-native", "check-wasm"]

[tasks.check-native]
description = "Quick compilation check (native)"
command = "cargo"
args = ["check", "--all-targets", "--all-features"]

[tasks.check-wasm]
description = "Quick compilation check (WASM)"
command = "cargo"
args = [
"check",
"--target",
"wasm32-unknown-unknown",
"--manifest-path",
"crates/mqdb-wasm/Cargo.toml",
]

[tasks.build]
description = "Build all targets (debug)"
command = "cargo"
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,19 @@ ready_rx.changed().await?; // wait until broker + handler are ready
| `$DB/{entity}/list` | List entities (payload: filters, sort, projection) |
| `$DB/{entity}/events/#` | Subscribe to change events |

#### Sharing Operations

An owner can share any ownership-enabled entity (the motivating case is diagrams) with a named user at `view` or `edit`. Grants are stored in a server-managed `_shares` entity that is **not** reachable via generic CRUD — mutate them only through these topics. Agent mode only (cluster parity tracked separately); the embedded WASM build rejects these operations.

| Topic | Action |
|-------|--------|
| `$DB/{entity}/{id}/share` | Grant access (payload: `{"grantee","permission","cascade"}`; permission is `view` or `edit`, cascade defaults to `true`) |
| `$DB/{entity}/{id}/unshare` | Revoke a grant (payload: `{"grantee","cascade"}`) |
| `$DB/{entity}/{id}/shares` | List a resource's grants (owner/admin only) |
| `$DB/{entity}/shared` | List resources shared with the caller |

`share`/`unshare` cascade by default over self-references (a diagram and the diagrams it links to); a direct share sets the level while a cascade only raises an existing grant. Read requires `view`, update requires `edit`; delete stays owner-only and clears the resource's grants. In OAuth deployments `grantee` is an email resolved to the registered user's canonical id (an unregistered email returns 404); with password/SCRAM auth it is the username.

#### Admin Operations

| Topic | Action |
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.4"
version = "0.8.5"
edition.workspace = true
license = "Apache-2.0"
authors.workspace = true
Expand Down
15 changes: 15 additions & 0 deletions crates/mqdb-agent/src/agent/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ pub(super) struct AuthProviderConfig<'a> {
}

impl MqdbAgent {
async fn register_share_indexes(&self) {
if self.ownership_config.is_empty() {
return;
}
let fields = vec!["resource_id".to_string(), "grantee".to_string()];
if let Err(e) = self
.db
.ensure_index(mqdb_core::types::SHARES_ENTITY.to_string(), fields)
.await
{
tracing::warn!(error = %e, "failed to register _shares indexes");
}
}

pub(super) async fn build_broker_config(
&self,
) -> Result<
Expand All @@ -37,6 +51,7 @@ impl MqdbAgent {
),
Box<dyn std::error::Error + Send + Sync>,
> {
self.register_share_indexes().await;
let mqtt_storage_dir = self.db.path().join("mqtt_storage");
let mut config = BrokerConfig {
bind_addresses: vec![self.bind_address],
Expand Down
134 changes: 134 additions & 0 deletions crates/mqdb-agent/src/agent/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,77 @@ pub(super) struct MessageContext<'a> {
pub jti_revocation: Option<&'a Arc<crate::http::JtiRevocationStore>>,
}

/// Resolve a grantee email to its canonical identity via `_identity_links`.
/// Returns `None` when no registered identity matches the email.
#[cfg(feature = "http-api")]
pub(crate) async fn resolve_grantee_email(
db: &Database,
crypto: &crate::http::IdentityCrypto,
email: &str,
) -> Option<String> {
let hash = crypto.blind_index("_identity_links", email);
let records =
crate::db_helpers::list_entities_db(db, "_identity_links", &format!("email_hash={hash}"))
.await
.ok()?;
records
.first()?
.get("canonical_id")
.and_then(Value::as_str)
.map(String::from)
}

/// For `Share`/`Unshare` in identity (OAuth) deployments, rewrite the grantee email
/// to its canonical id. A `Share` for an unregistered email is rejected (`Err(email)`);
/// an `Unshare` for an unknown email is left as-is (a harmless no-op revoke).
/// When no identity crypto is configured (password mode), the grantee is used verbatim.
#[cfg(feature = "http-api")]
async fn resolve_share_request(
db: &Database,
crypto: Option<&Arc<crate::http::IdentityCrypto>>,
request: mqdb_core::transport::Request,
) -> Result<mqdb_core::transport::Request, String> {
use mqdb_core::transport::Request;
let Some(crypto) = crypto else {
return Ok(request);
};
match request {
Request::Share {
entity,
id,
grantee,
permission,
cascade,
} => match resolve_grantee_email(db, crypto, &grantee).await {
Some(canonical_id) => Ok(Request::Share {
entity,
id,
grantee: canonical_id,
permission,
cascade,
}),
None => Err(grantee),
},
Request::Unshare {
entity,
id,
grantee,
cascade,
} => {
let resolved = resolve_grantee_email(db, crypto, &grantee)
.await
.unwrap_or(grantee);
Ok(Request::Unshare {
entity,
id,
grantee: resolved,
cascade,
})
}
other => Ok(other),
}
}

#[allow(clippy::too_many_lines)]
pub(super) async fn handle_message(ctx: &MessageContext<'_>, message: Message) {
let db = ctx.db;
Expand Down Expand Up @@ -174,6 +245,29 @@ pub(super) async fn handle_message(ctx: &MessageContext<'_>, message: Message) {
(request, None)
};

#[cfg(feature = "http-api")]
let request = match resolve_share_request(db, ctx.identity_crypto, request).await {
Ok(resolved) => resolved,
Err(unknown_email) => {
if let Some(response_topic) = &message.properties.response_topic {
let response = Response::error(
mqdb_core::ErrorCode::NotFound,
format!("unknown user '{unknown_email}'"),
);
if let Ok(payload) = serde_json::to_vec(&response) {
publish_response(
client,
response_topic,
message.properties.correlation_data.as_deref(),
payload,
)
.await;
}
}
return;
}
};

let span = info_span!(
"database_operation",
entity = %op.entity,
Expand Down Expand Up @@ -1619,3 +1713,43 @@ async fn handle_password_reset_submit_mqtt(ctx: &AdminContext<'_>, payload: &Val

Response::ok(json!({"status": "password_reset"}))
}

#[cfg(all(test, feature = "http-api"))]
mod tests {
use super::resolve_grantee_email;
use crate::database::Database;
use crate::http::IdentityCrypto;
use serde_json::json;
use tempfile::TempDir;

#[tokio::test]
async fn resolve_grantee_email_maps_known_email_to_canonical_id() {
let tmp = TempDir::new().unwrap();
let db = Database::open_without_background_tasks(tmp.path())
.await
.unwrap();
let (crypto, _key) = IdentityCrypto::generate().unwrap();

let hash = crypto.blind_index("_identity_links", "bob@example.com");
crate::db_helpers::create_entity_db(
&db,
"_identity_links",
&json!({
"id": "google:bob",
"canonical_id": "cid-bob",
"email_hash": hash,
}),
)
.await
.unwrap();

assert_eq!(
resolve_grantee_email(&db, &crypto, "bob@example.com").await,
Some("cid-bob".to_string())
);
assert_eq!(
resolve_grantee_email(&db, &crypto, "nobody@example.com").await,
None
);
}
}
Loading
Loading