From 75ba06027496df9cea0054d8d8e9c504031d1b2c Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:09 +0100 Subject: dashboard: what its user may read The dashboard's document box is read through the store, as its user may, and the page is told only of changes where its user may read. Auth plan M1 (the dashboard). --- src/api/dashboard/mod.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) (limited to 'src/api/dashboard/mod.rs') diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index bea5493..b89955a 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -100,7 +100,8 @@ struct Hub { /// Each patch, to the clients connected. patches: broadcast::Sender>, /// Where the document changed since the last tick, to the clients connected: - /// `{"paths": [...]}`, or `{"all": true}` if too many or not known. + /// `{"paths": [...]}`, or `{"all": true}` if too many or not known. Shared by every page + /// open; each is told of the paths its user may read at least partly (`changed_for`). documents: broadcast::Sender>, /// The messages exchanged since the last tick, one by one (`traffic::MessageLog`), /// to the clients connected: a JSON list. @@ -672,7 +673,10 @@ async fn current_state(Extension(hub): Extension>) -> Response { /// changed (`Hub::documents`, at most every tick), `messages` with the messages since /// the last tick (`Hub::messages`), and `offline` when the node stops, which ends the /// stream. -async fn events(Extension(hub): Extension>) -> impl IntoResponse { +async fn events( + Extension(hub): Extension>, + Extension(access): Extension, +) -> impl IntoResponse { // Subscribed before the snapshot is read: no patch after it is missed. let mut patches = hub.patches.subscribe(); let mut documents = hub.documents.subscribe(); @@ -696,7 +700,9 @@ async fn events(Extension(hub): Extension>) -> impl IntoResponse { let received = match next { Next::Patch(received) => received, Next::Document(Ok(changed)) => { - yield Ok(Event::default().event("document").data(changed.as_str())); + if let Some(changed) = changed_for(&changed, &access) { + yield Ok(Event::default().event("document").data(changed)); + } continue; } Next::Document(Err(broadcast::error::RecvError::Lagged(_))) => { @@ -756,6 +762,36 @@ fn snapshot_event(published: &Published) -> Event { .data(published.json()) } +/// A `document` event (`Hub::documents`) as the page of a user with `access` is told +/// it: of the changed paths, those it may read at least partly (`access::Visible`), so +/// that it isn't told where in the document others write what it may not read. None if +/// none is left. A few prefix comparisons per path and capability, once a tick per +/// page. +fn changed_for(changed: &str, access: &super::access::Access) -> Option { + use super::access::{Access, Visible}; + if matches!(access, Access::Everything) { + return Some(changed.to_owned()); + } + let mut parsed: JsonValue = serde_json::from_str(changed).ok()?; + let Some(paths) = parsed.get_mut("paths").and_then(JsonValue::as_array_mut) else { + // `{"all": true}`: the page reads again what it shows, as its user may. + return Some(changed.to_owned()); + }; + paths.retain(|path| { + let path: Vec = path + .as_array() + .map(|segments| { + segments + .iter() + .filter_map(|segment| segment.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + access.visible(&path) != Visible::Nothing + }); + (!paths.is_empty()).then(|| parsed.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -802,6 +838,36 @@ mod tests { ); } + #[test] + fn each_page_is_told_of_what_its_user_may_read() { + use super::super::access::Access; + use crate::access::{Capability, Caps}; + let reader = Access::Caps(Caps::new(vec![Capability { + path: vec!["servers".to_owned(), "eu".to_owned()], + rights: ["read".to_owned()].into(), + by: String::new(), + since: 0, + }])); + let changed = json!({ "paths": [["servers", "eu", "a"], ["servers", "us"], ["servers"]] }) + .to_string(); + let told: JsonValue = + serde_json::from_str(&changed_for(&changed, &reader).unwrap()).unwrap(); + assert_eq!( + told, + json!({ "paths": [["servers", "eu", "a"], ["servers"]] }) + ); + let elsewhere = json!({ "paths": [["accounts"]] }).to_string(); + assert_eq!(changed_for(&elsewhere, &reader), None); + assert_eq!( + changed_for(r#"{"all":true}"#, &reader).as_deref(), + Some(r#"{"all":true}"#) + ); + assert_eq!( + changed_for(&elsewhere, &Access::Everything), + Some(elsewhere.clone()) + ); + } + #[test] fn ages_alone_are_no_change() { let old = -- cgit v1.3.1