diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | 645bae8fb8065a353237d24aff274009f37d694e (patch) | |
| tree | 78df4d80af37ea1e92950f3a43b075befb75b684 /src | |
| parent | 7fed3e4478ad28151fab288d569a1268c1f3e4cf (diff) | |
access: a node registers before it writes
Under a policy, a change by a node that isn't a member is refused with
all built on it: a node's registration is written before any write of
its own or through the API, not after (the registration itself
exempt).
Auth plan M6 (fix).
Diffstat (limited to 'src')
| -rw-r--r-- | src/state/mod.rs | 102 | ||||
| -rw-r--r-- | src/swarm/members.rs | 49 |
2 files changed, 114 insertions, 37 deletions
diff --git a/src/state/mod.rs b/src/state/mod.rs index 294b446..7ee0acb 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -474,6 +474,21 @@ impl AppState { .map(|(out, _)| out) } + /// [`mutate_doc`](Self::mutate_doc) of this node's registration in the member list + /// (`swarm::members::ensure_registered`): the one write not preceded by it. + pub(crate) async fn mutate_doc_registering<T, E>( + &self, + f: impl FnOnce(&mut Transaction<'_>) -> Result<T, E> + Send + 'static, + ) -> Result<T, E> + where + T: Send + 'static, + E: From<StoreError> + Send + 'static, + { + self.mutate_doc_as(None, Sign::Protocol, false, f) + .await + .map(|(out, _)| out) + } + /// [`mutate_doc`](Self::mutate_doc) for `user`, who wrote through the API: the /// change names them (`replica::Writer`), and is signed as `sign` says, with this /// node's key. With the heads of the document written to after the @@ -488,6 +503,22 @@ impl AppState { T: Send + 'static, E: From<StoreError> + Send + 'static, { + self.mutate_doc_as(user, sign, true, f).await + } + + /// [`mutate_doc_for`](Self::mutate_doc_for); with `registering_first`, after this + /// node's registration if the policy needs it first. + async fn mutate_doc_as<T, E>( + &self, + user: Option<String>, + sign: Sign, + registering_first: bool, + f: impl FnOnce(&mut Transaction<'_>) -> Result<T, E> + Send + 'static, + ) -> Result<(T, Vec<crate::document::ChangeHash>), E> + where + T: Send + 'static, + E: From<StoreError> + Send + 'static, + { let state = self.clone(); tokio::task::spawn_blocking(move || { @@ -525,6 +556,29 @@ impl AppState { heads.sort_unstable(); return Ok((out, heads)); } + // Under a policy judging every change, a change by a node that isn't a + // member is refused everywhere, and so is every change built on it: a + // write before this node registered (`ensure_registered`), through the API + // or its own, comes after its registration. + if registering_first + && crate::access::policy::of(&replica.doc).enforced() + && !crate::swarm::members::registered(&replica.doc, state.local_id()) + { + let path = crate::swarm::members::member_path(state.local_id()); + let entry = crate::swarm::members::entry(&state); + let writer = crate::document::replica::Writer { + node: state.local_id(), + user: None, + }; + let before = replica.doc.get_heads(); + replica + .transact(state.clock.now_millis(), &writer, |doc| { + crate::document::store::write_path(doc, &path, &entry) + }) + .map_err(E::from)?; + state.sign_made(&mut replica, &before, &Sign::Protocol); + tracing::info!("Registered in the swarm's member list, before writing"); + } // One API write = one change; on error, its partial writes (e.g. the old // value deleted, the new one not written) are dropped. let writer = crate::document::replica::Writer { @@ -533,25 +587,8 @@ impl AppState { }; let before = replica.doc.get_heads(); let result = replica.transact(state.clock.now_millis(), &writer, f); - if result.is_ok() - && let Some(key) = &state.node_key - { - // The change made, if any: the one head after it, as it builds on - // every head before. - let after = replica.doc.get_heads(); - if after != before - && let [hash] = after[..] - { - if state.sign_every == SignEvery::Change - || sign.wanted(&replica.doc) - || crate::access::policy::of(&replica.doc).required() - { - let replica = &mut *replica; - replica.signatures.sign(&replica.doc, hash, key); - } else { - replica.signatures.left_unsigned(hash); - } - } + if result.is_ok() { + state.sign_made(&mut replica, &before, &sign); } replica.refresh_heads(); result.map(|out| (out, replica.heads().to_vec())) @@ -566,6 +603,33 @@ impl AppState { .expect("document write task panicked") } + /// Signs the change just made in `replica`, if any (the one head after `before`, as + /// it builds on every head before), as `sign` says, or as `--sign-every` or the + /// policy has it; or keeps it as left unsigned. + fn sign_made( + &self, + replica: &mut crate::document::replica::Replica, + before: &[crate::document::ChangeHash], + sign: &Sign, + ) { + let Some(key) = &self.node_key else { + return; + }; + let after = replica.doc.get_heads(); + if after != before + && let [hash] = after[..] + { + if self.sign_every == SignEvery::Change + || sign.wanted(&replica.doc) + || crate::access::policy::of(&replica.doc).required() + { + replica.signatures.sign(&replica.doc, hash, key); + } else { + replica.signatures.left_unsigned(hash); + } + } + } + /// This node's latest change signed, if no signature vouches for it yet, every /// `every` (`--sign-every`): every change it makes is vouched for within that. pub(crate) async fn sign_latest_every(self, every: Duration) { diff --git a/src/swarm/members.rs b/src/swarm/members.rs index cbd970d..0cadb00 100644 --- a/src/swarm/members.rs +++ b/src/swarm/members.rs @@ -293,6 +293,34 @@ const RENEWAL_MOST_OFTEN: Duration = Duration::from_secs(3600); pub(crate) const MIN_ELECTORATE_WINDOW: Duration = Duration::from_secs(7 * RENEWAL_MOST_OFTEN.as_secs()); +/// Whether `node` is registered in `doc`'s member list. +pub(crate) fn registered(doc: &impl ReadDoc, node: &str) -> bool { + store::read_path(doc, &member_path(node)) + .ok() + .flatten() + .is_some() +} + +/// This node's entry in the member list, as it registers (`ensure_registered`). +pub(crate) fn entry(state: &AppState) -> JsonValue { + let now = state.clock.now_millis().as_u64(); + let mut addresses = state.transport.local_addresses(); + addresses.truncate(MAX_ADDRESSES); + let mut entry = json!({ + "since": now, + "alive": now, + "addresses": addresses, + "window": state.electorate_window.as_millis() as u64, + }); + if let Some(alias) = &state.alias { + entry["alias"] = json!(alias); + } + if let Some(url) = &state.dashboard_url { + entry["dashboard"] = json!(url); + } + entry +} + /// Registers this node if it is ready and its entry is missing, keeps its alias and its /// dashboard's URL as configured (set, changed or removed after a restart with another /// `--alias` or `--dashboard-url`) and its @@ -315,12 +343,8 @@ pub(crate) async fn ensure_registered(state: &AppState) { shortest, ) = { let replica = state.doc.read().await; - let registered = store::read_path(&replica.doc, &own) - .ok() - .flatten() - .is_some(); ( - registered, + registered(&replica.doc, &local), alias(&replica.doc, &local), dashboard(&replica.doc, &local), addresses(&replica.doc, &local), @@ -335,20 +359,9 @@ pub(crate) async fn ensure_registered(state: &AppState) { let now = state.clock.now_millis().as_u64(); if !registered { - let mut entry = json!({ - "since": now, - "alive": now, - "addresses": own_addresses, - "window": window, - }); - if let Some(alias) = &wanted { - entry["alias"] = json!(alias); - } - if let Some(url) = &state.dashboard_url { - entry["dashboard"] = json!(url); - } + let entry = entry(state); match state - .mutate_doc(move |doc| store::write_path(doc, &own, &entry)) + .mutate_doc_registering(move |doc| store::write_path(doc, &own, &entry)) .await { Ok(()) => info!( |
