diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
| commit | 75ba06027496df9cea0054d8d8e9c504031d1b2c (patch) | |
| tree | 861c748e84d3a9350fb55fa5fef4625e0bdc7fe2 | |
| parent | eaf25a3638568f580098bd56be76d80dc514e736 (diff) | |
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).
| -rw-r--r-- | docs/access.md | 3 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 72 |
2 files changed, 71 insertions, 4 deletions
diff --git a/docs/access.md b/docs/access.md index cd11723..7a85c94 100644 --- a/docs/access.md +++ b/docs/access.md @@ -55,7 +55,8 @@ change to it counts on a node as soon as it has reached it. * **Protocol data** is in the store too, under `_continuum`: `read` and `write` on it as on any path. * **Looking at the node** (`/admin/stats`, `GET /peers`, the dashboard): any user with - a capability. + a capability. The dashboard's document box is read through the store, as the user + may; the page is told only of changes where its user may read. * **Remakes** (`/admin/remakes`): `read` on `_continuum/remakes` to look, `write` to propose or abort one. **Forgetting a peer** (`DELETE /peers/…`): `write` on `_continuum/forgotten`. 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<Arc<Published>>, /// 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<Arc<String>>, /// 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<Arc<Hub>>) -> 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<Arc<Hub>>) -> impl IntoResponse { +async fn events( + Extension(hub): Extension<Arc<Hub>>, + Extension(access): Extension<super::access::Access>, +) -> 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<Arc<Hub>>) -> 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<String> { + 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<String> = 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::*; @@ -803,6 +839,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 = json!({ "uptimeSecs": 1, "peers": { "p": { "lastSeenSecsAgo": 3, "rttMs": 2 } } }); |
