Slot Budget + Config Store Atomic Writer fixes + Trusted Command

This commit is contained in:
Alexey
2026-09-16 22:06:59 +03:00
parent 55f3d19ee0
commit 9a683d8b3d
33 changed files with 1680 additions and 687 deletions
+27 -3
View File
@@ -9,8 +9,11 @@ use super::ApiShared;
use super::config_store::{
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section,
load_candidate_snapshot, load_config_snapshot, render_server_listeners,
render_top_level_section, resolve_single_source_owner, upsert_toml_table, write_atomic,
render_top_level_section, resolve_single_source_owner, upsert_toml_table,
write_atomic_if_unchanged,
};
#[cfg(test)]
use super::config_store::write_atomic;
use super::model::ApiFailure;
use crate::config::ProxyConfig;
use crate::config::hot_reload::classify_config_changes;
@@ -43,7 +46,10 @@ pub(super) struct PatchConfigResponse {
}
struct PreparedConfigPatch {
config_path: PathBuf,
expected_revision: String,
owner_path: PathBuf,
expected_owner_contents: String,
owner_contents: String,
desired_config: Arc<ProxyConfig>,
response: PatchConfigResponse,
@@ -77,7 +83,14 @@ pub(super) async fn patch_config(
} else {
None
};
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
write_atomic_if_unchanged(
prepared.config_path,
prepared.expected_revision,
prepared.owner_path,
prepared.expected_owner_contents,
prepared.owner_contents,
)
.await?;
if let Some(reservation) = reservation {
prepared.response.reload = Some(reservation.enqueue(prepared.desired_config));
}
@@ -111,7 +124,14 @@ pub(super) async fn apply_patch_to_path(
expected_revision: Option<String>,
) -> Result<PatchConfigResponse, ApiFailure> {
let prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
write_atomic_if_unchanged(
prepared.config_path,
prepared.expected_revision,
prepared.owner_path,
prepared.expected_owner_contents,
prepared.owner_contents,
)
.await?;
Ok(prepared.response)
}
@@ -197,6 +217,7 @@ async fn prepare_patch_to_path(
.get(&owner_path)
.cloned()
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
let expected_owner_contents = owner_contents.clone();
for section in &touched {
if *section == "server" {
let rendered = render_server_listeners(&requested_cfg)?;
@@ -233,7 +254,10 @@ async fn prepare_patch_to_path(
deferred_process_fields(&old_cfg, &new_cfg).map_err(ApiFailure::bad_request)?;
Ok(PreparedConfigPatch {
config_path: config_path.to_path_buf(),
expected_revision: current,
owner_path,
expected_owner_contents,
owner_contents,
desired_config: Arc::new(new_cfg),
response: PatchConfigResponse {
+24
View File
@@ -323,6 +323,30 @@ async fn patch_writes_the_included_section_owner_only() {
);
}
#[tokio::test]
async fn prepared_patch_rejects_external_edit_before_commit() {
let (path, _directory) = temp_config("[censorship]\ntls_domain = \"old.example\"\n");
let patch: Json = serde_json::json!({
"censorship": {"tls_domain": "api.example"}
});
let prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
let external = "[censorship]\ntls_domain = \"external.example\"\n";
tokio::fs::write(&path, external).await.unwrap();
let error = write_atomic_if_unchanged(
prepared.config_path,
prepared.expected_revision,
prepared.owner_path,
prepared.expected_owner_contents,
prepared.owner_contents,
)
.await
.unwrap_err();
assert_eq!(error.code, "revision_conflict");
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), external);
}
#[tokio::test]
async fn patch_rejects_multiple_source_owners_without_writing() {
let dir = tempfile::tempdir().unwrap();
+11 -9
View File
@@ -10,13 +10,16 @@ use super::model::ApiFailure;
// Source-preserving TOML rendering and atomic persistence helpers.
mod persistence;
// Compare-and-replace file persistence and metadata preservation.
mod atomic;
#[cfg(test)]
use persistence::{find_toml_table_bounds, render_access_section, save_sections_to_disk};
pub(in crate::api) use persistence::{
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
upsert_toml_table, write_atomic,
save_access_sections_to_disk_if_revision, upsert_toml_table,
};
pub(in crate::api) use atomic::{write_atomic, write_atomic_if_unchanged};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AccessSection {
@@ -54,22 +57,21 @@ pub(super) fn parse_if_match(headers: &hyper::HeaderMap) -> Option<String> {
.map(|value| value.trim_matches('"').to_string())
}
pub(super) async fn ensure_expected_revision(
/// Loads one mutation base and validates its revision from the same source snapshot.
pub(super) async fn load_config_for_mutation(
config_path: &Path,
expected_revision: Option<&str>,
) -> Result<(), ApiFailure> {
let Some(expected) = expected_revision else {
return Ok(());
};
let current = current_revision(config_path).await?;
if current != expected {
) -> Result<(ProxyConfig, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let revision = compute_snapshot_revision(&loaded);
if expected_revision.is_some_and(|expected| expected != revision) {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"revision_conflict",
"Config revision mismatch",
));
}
Ok(())
Ok((loaded.config, revision))
}
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
+185
View File
@@ -0,0 +1,185 @@
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use super::compute_source_revision;
use crate::api::model::ApiFailure;
use crate::config::ProxyConfig;
enum AtomicWriteError {
Conflict,
ReadGraph(String),
Io(std::io::Error),
}
struct ExistingTarget {
contents: String,
metadata: std::fs::Metadata,
}
/// Replaces one config source through a durable same-directory rename.
pub(in crate::api) async fn write_atomic(
path: PathBuf,
contents: String,
) -> Result<(), ApiFailure> {
tokio::task::spawn_blocking(move || write_atomic_sync(&path, None, &contents))
.await
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
.map_err(|error| ApiFailure::internal(format!("failed to write config: {error}")))
}
/// Replaces one source only if both its graph revision and owner contents are unchanged.
pub(in crate::api) async fn write_atomic_if_unchanged(
config_path: PathBuf,
expected_revision: String,
path: PathBuf,
expected_contents: String,
contents: String,
) -> Result<(), ApiFailure> {
tokio::task::spawn_blocking(move || {
let graph = ProxyConfig::read_source_graph(&config_path)
.map_err(|error| AtomicWriteError::ReadGraph(error.to_string()))?;
if compute_source_revision(&graph) != expected_revision {
return Err(AtomicWriteError::Conflict);
}
write_atomic_sync(&path, Some(&expected_contents), &contents).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
AtomicWriteError::Conflict
} else {
AtomicWriteError::Io(error)
}
})
})
.await
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
.map_err(|error| match error {
AtomicWriteError::Conflict => revision_conflict(),
AtomicWriteError::ReadGraph(error) => {
ApiFailure::internal(format!("failed to verify config graph: {error}"))
}
AtomicWriteError::Io(error) => {
ApiFailure::internal(format!("failed to write config: {error}"))
}
})
}
fn revision_conflict() -> ApiFailure {
ApiFailure::new(
hyper::StatusCode::CONFLICT,
"revision_conflict",
"Config revision changed before persistence",
)
}
fn open_existing_target(path: &Path) -> std::io::Result<Option<ExistingTarget>> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
let mut file = match options.open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config target must be a regular file",
));
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(Some(ExistingTarget { contents, metadata }))
}
fn same_target(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
left.dev() == right.dev() && left.ino() == right.ino()
}
#[cfg(not(unix))]
{
left.len() == right.len() && left.modified().ok() == right.modified().ok()
}
}
fn write_atomic_sync(
path: &Path,
expected_contents: Option<&str>,
contents: &str,
) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let existing = open_existing_target(path)?;
if expected_contents.is_some_and(|expected| {
existing
.as_ref()
.is_none_or(|target| target.contents != expected)
}) {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"config source changed before persistence",
));
}
let tmp_name = format!(
".{}.tmp-{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("config.toml"),
rand::random::<u64>()
);
let tmp_path = parent.join(tmp_name);
let write_result = (|| {
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&tmp_path)?;
#[cfg(unix)]
if let Some(existing) = existing.as_ref() {
use nix::unistd::{Gid, Uid, fchown};
fchown(
&file,
Some(Uid::from_raw(existing.metadata.uid())),
Some(Gid::from_raw(existing.metadata.gid())),
)
.map_err(|error| std::io::Error::from_raw_os_error(error as i32))?;
file.set_permissions(std::fs::Permissions::from_mode(
existing.metadata.mode() & 0o7777,
))?;
}
file.write_all(contents.as_bytes())?;
file.sync_all()?;
let current = open_existing_target(path)?;
let target_unchanged = match (&existing, &current) {
(Some(expected), Some(current)) => {
same_target(&expected.metadata, &current.metadata)
&& expected.contents == current.contents
}
(None, None) => true,
_ => false,
};
if !target_unchanged {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"config target changed during persistence",
));
}
std::fs::rename(&tmp_path, path)?;
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
write_result
}
+6 -171
View File
@@ -1,9 +1,5 @@
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::Serialize;
@@ -13,9 +9,12 @@ use crate::config::{ProxyConfig, RateLimitBps};
#[cfg(test)]
use super::compute_revision;
use super::{
AccessSection, compute_snapshot_revision, compute_source_revision, load_candidate_snapshot,
load_config_snapshot, resolve_single_source_owner, toml_path_exists,
AccessSection, compute_snapshot_revision, load_candidate_snapshot, load_config_snapshot,
resolve_single_source_owner, toml_path_exists,
};
use super::atomic::write_atomic_if_unchanged;
#[cfg(test)]
use super::atomic::write_atomic;
use crate::api::model::ApiFailure;
/// Re-render the given top-level tables from `cfg` and upsert each into the
@@ -398,58 +397,6 @@ fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)>
blocks
}
/// Replaces one config source through a durable same-directory rename.
pub(in crate::api) async fn write_atomic(
path: PathBuf,
contents: String,
) -> Result<(), ApiFailure> {
tokio::task::spawn_blocking(move || write_atomic_sync(&path, None, &contents))
.await
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
}
/// Replaces one source only if both its graph revision and owner contents are unchanged.
pub(in crate::api) async fn write_atomic_if_unchanged(
config_path: PathBuf,
expected_revision: String,
path: PathBuf,
expected_contents: String,
contents: String,
) -> Result<(), ApiFailure> {
tokio::task::spawn_blocking(move || {
let graph = ProxyConfig::read_source_graph(&config_path)
.map_err(|error| AtomicWriteError::ReadGraph(error.to_string()))?;
if compute_source_revision(&graph) != expected_revision {
return Err(AtomicWriteError::Conflict);
}
write_atomic_sync(&path, Some(&expected_contents), &contents)
.map_err(AtomicWriteError::Io)
})
.await
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
.map_err(|error| match error {
AtomicWriteError::Conflict => revision_conflict(),
AtomicWriteError::ReadGraph(error) => {
ApiFailure::internal(format!("failed to verify config graph: {error}"))
}
AtomicWriteError::Io(error) => {
ApiFailure::internal(format!("failed to write config: {error}"))
}
})
}
enum AtomicWriteError {
Conflict,
ReadGraph(String),
Io(std::io::Error),
}
struct ExistingTarget {
contents: String,
metadata: std::fs::Metadata,
}
fn revision_conflict() -> ApiFailure {
ApiFailure::new(
hyper::StatusCode::CONFLICT,
@@ -457,115 +404,3 @@ fn revision_conflict() -> ApiFailure {
"Config revision changed before persistence",
)
}
fn open_existing_target(path: &Path) -> std::io::Result<Option<ExistingTarget>> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
let mut file = match options.open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config target must be a regular file",
));
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(Some(ExistingTarget { contents, metadata }))
}
fn same_target(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
left.dev() == right.dev() && left.ino() == right.ino()
}
#[cfg(not(unix))]
{
left.len() == right.len() && left.modified().ok() == right.modified().ok()
}
}
fn write_atomic_sync(
path: &Path,
expected_contents: Option<&str>,
contents: &str,
) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let existing = open_existing_target(path)?;
if expected_contents.is_some_and(|expected| {
existing
.as_ref()
.is_none_or(|target| target.contents != expected)
}) {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"config source changed before persistence",
));
}
let tmp_name = format!(
".{}.tmp-{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("config.toml"),
rand::random::<u64>()
);
let tmp_path = parent.join(tmp_name);
let write_result = (|| {
let mut file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
#[cfg(unix)]
.mode(0o600)
.open(&tmp_path)?;
#[cfg(unix)]
if let Some(existing) = existing.as_ref() {
use nix::unistd::{Gid, Uid, fchown};
fchown(
&file,
Some(Uid::from_raw(existing.metadata.uid())),
Some(Gid::from_raw(existing.metadata.gid())),
)
.map_err(|error| std::io::Error::from_raw_os_error(error as i32))?;
file.set_permissions(std::fs::Permissions::from_mode(
existing.metadata.mode() & 0o7777,
))?;
}
file.write_all(contents.as_bytes())?;
file.sync_all()?;
let current = open_existing_target(path)?;
let target_unchanged = match (&existing, &current) {
(Some(expected), Some(current)) => {
same_target(&expected.metadata, &current.metadata)
&& expected.contents == current.contents
}
(None, None) => true,
_ => false,
};
if !target_unchanged {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"config target changed during persistence",
));
}
std::fs::rename(&tmp_path, path)?;
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
write_result
}
+54
View File
@@ -260,6 +260,60 @@ async fn access_mutation_writes_only_the_single_included_owner() {
assert_eq!(revision, current_revision(&root).await.unwrap());
}
#[tokio::test]
async fn access_mutation_rejects_source_graph_change_after_snapshot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("config.toml");
let included = dir.path().join("users.toml");
let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"one.example\"\n";
let external_root =
"include = \"users.toml\"\n[censorship]\ntls_domain = \"two.example\"\n";
let included_body = "[access.users]\nalice = \"00000000000000000000000000000000\"\n";
tokio::fs::write(&root, root_body).await.unwrap();
tokio::fs::write(&included, included_body).await.unwrap();
let (mut cfg, revision) = load_config_for_mutation(&root, None).await.unwrap();
cfg.access.users.insert(
"bob".to_string(),
"11111111111111111111111111111111".to_string(),
);
tokio::fs::write(&root, external_root).await.unwrap();
let error = save_access_sections_to_disk_if_revision(
&root,
&cfg,
&[AccessSection::Users],
Some(&revision),
)
.await
.unwrap_err();
assert_eq!(error.code, "revision_conflict");
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), external_root);
assert_eq!(
tokio::fs::read_to_string(&included).await.unwrap(),
included_body
);
}
#[cfg(unix)]
#[tokio::test]
async fn atomic_write_preserves_existing_file_mode() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
tokio::fs::write(&path, "old").await.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
let before = std::fs::metadata(&path).unwrap();
write_atomic(path.clone(), "new".to_string()).await.unwrap();
let after = std::fs::metadata(&path).unwrap();
assert_eq!(after.mode() & 0o7777, 0o640);
assert_eq!(after.uid(), before.uid());
assert_eq!(after.gid(), before.gid());
}
#[tokio::test]
async fn access_mutation_rejects_sections_with_different_source_owners() {
let dir = tempfile::tempdir().unwrap();
+2 -2
View File
@@ -134,8 +134,8 @@ pub(super) async fn handle(
}
let expected_revision = parse_if_match(req.headers());
let _mutation_guard = shared.mutation_lock.lock().await;
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
let (disk_cfg, _) =
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
if !disk_cfg.access.users.contains_key(user) {
return Ok(error_response(
request_id,
+1 -1
View File
@@ -59,7 +59,7 @@ mod web_runtime;
mod web_status;
use config_store::{
current_revision, ensure_expected_revision, load_config_for_reload, load_config_from_disk,
current_revision, load_config_for_mutation, load_config_for_reload, load_config_from_disk,
parse_if_match,
};
use events::ApiEventStore;
+2 -2
View File
@@ -9,8 +9,8 @@ use crate::stats::Stats;
use super::ApiShared;
use super::config_store::{
AccessSection, current_revision, ensure_expected_revision, load_config_from_disk,
save_access_sections_to_disk,
AccessSection, current_revision, load_config_for_mutation,
save_access_sections_to_disk_if_revision,
};
use super::model::{
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
+9 -4
View File
@@ -42,8 +42,8 @@ pub(in crate::api) async fn create_user(
let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?;
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
let (mut cfg, base_revision) =
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
if cfg.access.users.contains_key(&body.username) {
return Err(ApiFailure::new(
@@ -122,8 +122,13 @@ pub(in crate::api) async fn create_user(
touched_sections.push(AccessSection::UserEnabled);
}
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
let revision = save_access_sections_to_disk_if_revision(
&shared.config_path,
&cfg,
&touched_sections,
Some(&base_revision),
)
.await?;
shared
.proxy_shared
.stage_user(
+18 -8
View File
@@ -15,8 +15,8 @@ pub(in crate::api) async fn rotate_secret(
}
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
let (mut cfg, base_revision) =
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
if !cfg.access.users.contains_key(user) {
return Err(ApiFailure::new(
@@ -29,8 +29,13 @@ pub(in crate::api) async fn rotate_secret(
cfg.access.users.insert(user.to_string(), secret.clone());
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?;
let revision = save_access_sections_to_disk_if_revision(
&shared.config_path,
&cfg,
&[AccessSection::Users],
Some(&base_revision),
)
.await?;
shared
.proxy_shared
.stage_user(user, &secret, cfg.access.is_user_enabled(user))
@@ -67,8 +72,8 @@ pub(in crate::api) async fn delete_user(
shared: &ApiShared,
) -> Result<(String, String), ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
let (mut cfg, base_revision) =
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
if !cfg.access.users.contains_key(user) {
return Err(ApiFailure::new(
@@ -111,8 +116,13 @@ pub(in crate::api) async fn delete_user(
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
let revision = save_access_sections_to_disk_if_revision(
&shared.config_path,
&cfg,
&touched_sections,
Some(&base_revision),
)
.await?;
let deleted_incarnation = shared.proxy_shared.delete_user(user).incarnation;
let configured_users = cfg.access.users.keys().cloned().collect();
if let Err(error) = shared
+18 -8
View File
@@ -32,8 +32,8 @@ pub(in crate::api) async fn patch_user(
}
let expiration = parse_patch_expiration(&body.expiration_rfc3339)?;
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
let (mut cfg, base_revision) =
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
if !cfg.access.users.contains_key(user) {
return Err(ApiFailure::new(
@@ -168,7 +168,13 @@ pub(in crate::api) async fn patch_user(
let revision = if touched_sections.is_empty() {
current_revision(&shared.config_path).await?
} else {
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?
save_access_sections_to_disk_if_revision(
&shared.config_path,
&cfg,
&touched_sections,
Some(&base_revision),
)
.await?
};
if touches_users || touches_user_enabled {
let secret = cfg
@@ -212,8 +218,8 @@ pub(in crate::api) async fn set_user_enabled(
shared: &ApiShared,
) -> Result<(UserInfo, String), ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let mut cfg = load_config_from_disk(&shared.config_path).await?;
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
let (mut cfg, base_revision) =
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
if !cfg.access.users.contains_key(user) {
return Err(ApiFailure::new(
@@ -231,9 +237,13 @@ pub(in crate::api) async fn set_user_enabled(
cfg.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
let revision =
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
.await?;
let revision = save_access_sections_to_disk_if_revision(
&shared.config_path,
&cfg,
&[AccessSection::UserEnabled],
Some(&base_revision),
)
.await?;
let secret = cfg
.access
.users