From 4ecb4ee5c0a49fc6d78ae06e21f6bb1571e290cb Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 09:41:05 +0000 Subject: remake: replay from the quarantine where the user may write POST /admin/quarantine/{id}/replay needs write on _continuum, which never covers user data, and replayed every path of the entry as the user who asked: every node refused the user data in it. It now replays only the paths the user may write and keeps the rest in the entry (left), for one who may; 403 if the user may write none. Review finding 3 (quarantine replays). --- src/remake/quarantine.rs | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) (limited to 'src/remake') diff --git a/src/remake/quarantine.rs b/src/remake/quarantine.rs index e7214d6..24262fc 100644 --- a/src/remake/quarantine.rs +++ b/src/remake/quarantine.rs @@ -177,21 +177,29 @@ pub(crate) async fn replay( let replayed = { let entry = Arc::clone(&entry); let user = user.map(|axum::Extension(user)| user.0); - host.commit_doc_for(user, move |doc| { + host.commit_doc_for(user.clone(), move |doc| { + // Only where the operator may write, as every node judges the change by it + // (`access::rules`); the rest stays here. + let caps = user + .as_deref() + .map(|user| crate::access::registry::caps_of(&*doc, user)); + let registry = crate::access::registry::exists(&*doc); + let (paths, kept): (Vec<_>, Vec<_>) = entry.paths.iter().cloned().partition(|path| { + !registry + || caps + .as_ref() + .is_some_and(|caps| caps.allows(crate::access::Right::Write, path)) + }); // As they are: the operator decided they belong at their old paths. - Ok::<_, StoreError>(delta::replay_paths( - &entry.document, - doc, - &entry.paths, - &Translator::default(), - )) + let stats = delta::replay_paths(&entry.document, doc, &paths, &Translator::default()); + Ok::<_, StoreError>((stats, kept)) }) .await }; // The commit's task held the only other reference, and it is done. - let entry = Arc::into_inner(entry).expect("the replay's reference is gone"); - let stats = match replayed { - Ok(stats) => stats, + let mut entry = Arc::into_inner(entry).expect("the replay's reference is gone"); + let (stats, kept) = match replayed { + Ok(replayed) => replayed, Err(error) => { // Rejoining since the check above: keep the entry for later. host.remake().restore_quarantined(entry); @@ -199,6 +207,14 @@ pub(crate) async fn replay( } }; + if stats.replayed == 0 && !kept.is_empty() && kept.len() == entry.paths.len() { + host.remake().restore_quarantined(entry); + return error_response( + StatusCode::FORBIDDEN, + "the user may write none of its paths", + None, + ); + } if stats.failed > 0 && stats.replayed == 0 { // Nothing went in; keep the entry for another look. host.remake().restore_quarantined(entry); @@ -208,9 +224,16 @@ pub(crate) async fn replay( Some(json!({ "failed": stats.failed })), ); } + let left = kept.len(); + if !kept.is_empty() { + // Those the user may not write, for one who may. + entry.paths = kept; + host.remake().restore_quarantined(entry); + } - info!(quarantine = %id, replayed = stats.replayed, failed = stats.failed, "Replayed quarantined changes"); - Json(json!({ "replayed": stats.replayed, "failed": stats.failed })).into_response() + info!(quarantine = %id, replayed = stats.replayed, failed = stats.failed, left, "Replayed quarantined changes"); + Json(json!({ "replayed": stats.replayed, "failed": stats.failed, "left": left })) + .into_response() } pub(crate) async fn discard(State(host): State, Path(id): Path) -> Response { -- cgit v1.3.1