aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 09:41:05 +0000
committertv <tv@krebsco.de>2026-11-08 14:28:56 +0100
commit4ecb4ee5c0a49fc6d78ae06e21f6bb1571e290cb (patch)
treed7e6858f48b654070b2ffd7650c7b6313c290cdd /src
parentb08c0bfd3ffcfd39ef2689882fafafbc31f16ffd (diff)
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).
Diffstat (limited to 'src')
-rw-r--r--src/remake/quarantine.rs47
1 files changed, 35 insertions, 12 deletions
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<H: Host>(
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<H: Host>(
}
};
+ 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<H: Host>(
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<H: Host>(State(host): State<H>, Path(id): Path<String>) -> Response {