diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs index 555adcb0..82029a25 100644 --- a/swarm-authelia-bridge/src/main.rs +++ b/swarm-authelia-bridge/src/main.rs @@ -86,6 +86,17 @@ fn read_secret_file(path: &str) -> Result { struct AppState { config: Config, http: reqwest::Client, + /// Serializes `handle`'s load → insert → publish sequence. Without this, + /// two `EnsureAgentIdentity` requests landing close together (real: the + /// controller's job worker claims and spawns nodes without waiting for + /// each to finish, and `SwarmResourceKind` declares no resource dep + /// between two `CreateIdentity` jobs, so they run concurrently) both + /// `load_store` the same snapshot, both insert their own agent, and + /// whichever `publish`es second silently drops the first agent's entry — + /// the store is a plain file, not a database with its own concurrency + /// control. `tokio::sync::Mutex`, not `std`'s: held across the `.await`s + /// in `generate_password` and `publish`. + write_lock: tokio::sync::Mutex<()>, } #[tokio::main] @@ -101,6 +112,7 @@ async fn main() -> Result<()> { let state = Arc::new(AppState { config, http: reqwest::Client::new(), + write_lock: tokio::sync::Mutex::new(()), }); let app = Router::new() @@ -187,6 +199,14 @@ async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response async fn handle(state: &AppState, name: String) -> Result { store::validate_username(&name)?; let cfg = &state.config; + + // Held across the whole load → insert → publish sequence, not just the + // write — two concurrent `EnsureAgentIdentity` calls must not both read + // the same on-disk snapshot before either publishes. See the field's + // doc comment on `AppState::write_lock` for why this is reachable in + // practice, not just theoretically. + let _write_guard = state.write_lock.lock().await; + let mut user_store = store::load_store(&cfg.store_path, &cfg.users_file)?; if user_store.users.contains_key(&name) { return Ok(BridgeResponse::AlreadyExists);