From f065b818f77cb814f51305f62f46c62c151f71a9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 22:43:04 -0700 Subject: [PATCH] Seed the built-in roles when a role was made in recovery mode first Recovery mode on a server with no roles now leaves a note, and the first normal start seeds the built-in roles, the default Authentication and the new-install privacy defaults when there are no roles or the note is there, then clears it. A role made in recovery mode no longer leaves every account without permissions. Servers that have started normally before have roles and no note, and are unchanged. Fixes #265 --- crates/common/src/manager/defaults.rs | 58 ++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index 1e68083e..24a1024a 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -21,12 +21,15 @@ use registry::{ }; use std::str::FromStr; use store::{ + SUBSPACE_INBUXA, ValueKey, rand::{RngExt, distr::Alphanumeric, rng}, registry::{ bootstrap::Bootstrap, write::{RegistryWrite, RegistryWriteResult}, }, + write::{AnyClass, BatchBuilder, ValueClass}, }; +use trc::AddContext; pub const WEBUI_LATEST_URL: &str = "https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"; @@ -85,9 +88,17 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { } if is_recovery_mode { + // inbuxa: a server that has never started normally is still a new + // install after recovery mode, even once a role is made there (#265) + if bp.registry.count_object(ObjectType::Role).await? == 0 { + set_first_boot_pending(bp, true).await?; + } return Ok(()); } + let is_new_install = + bp.registry.count_object(ObjectType::Role).await? == 0 || first_boot_pending(bp).await?; + // inbuxa: a store brought over from Stalwart doesn't serve upstream's web // interface (docs/spec/import-0.16.md), before the applications load super::first_party::disable_imported_web_interface(bp).await?; @@ -400,11 +411,11 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { // inbuxa: personal-data catalog, defaults D2, D3, D4 and D6 (settled // 2026-09-28): privacy-leaning values, for new installs only. A server - // with roles is not new, and keeps its settings whether saved or left at - // the default. Each singleton is read, changed and written back whole, so - // anything already in it stays. + // that has started normally before is not new, and keeps its settings + // whether saved or left at the default. Each singleton is read, changed + // and written back whole, so anything already in it stays. #[cfg(not(feature = "test_mode"))] - if bp.registry.count_object(ObjectType::Role).await? == 0 { + if is_new_install { let mut security = bp.setting_infallible::().await; let mut classifier = bp.setting_infallible::().await; let mut pyzor = bp.setting_infallible::().await; @@ -437,7 +448,7 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { } } - if bp.registry.count_object(ObjectType::Role).await? == 0 { + if is_new_install { let permissions = DefaultPermissions::default(); let mut role_ids = Vec::with_capacity(4); @@ -492,6 +503,8 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { )) .await?; } + + set_first_boot_pending(bp, false).await?; } // inbuxa: administrator roles stored before a permission existed get it once @@ -581,7 +594,6 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { #[cfg(not(feature = "test_mode"))] { - use store::write::BatchBuilder; use types::id::Id; // inbuxa: rules are always to hand, since a copy ships with the server @@ -612,6 +624,40 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { Ok(()) } +/// inbuxa: noted by recovery mode on a server with no roles yet, so the +/// first normal start still seeds the built-in roles and the new-install +/// defaults when a role was made in recovery mode (#265). Cleared once they +/// are seeded. A server from before this note has roles and no note, and +/// keeps its settings as they are. +fn first_boot_key() -> ValueClass { + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: b"Bd".to_vec(), + }) +} + +async fn first_boot_pending(bp: &Bootstrap) -> trc::Result { + bp.data_store + .get_value::(ValueKey::from(first_boot_key())) + .await + .caused_by(trc::location!()) + .map(|note| note.is_some()) +} + +async fn set_first_boot_pending(bp: &Bootstrap, pending: bool) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + if pending { + batch.set(first_boot_key(), b"1".to_vec()); + } else { + batch.clear(first_boot_key()); + } + bp.data_store + .write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + /// inbuxa: the new-install values of defaults D2, D3, D4 and D6 from the /// personal-data catalog spec. Automatic IP bans expire after 30 days instead /// of never; spam training samples are kept 90 days instead of 180; Pyzor, -- 2.54.0