From 2dc81ad0e0d488e7612805c976e54189afe35f5d Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:03:05 +0300 Subject: [PATCH] API Consistency fixes Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/api/mod.rs | 63 +++++++++++++++++++++++++++++++++++++++--------- src/api/model.rs | 7 ++++++ src/api/users.rs | 56 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 788c60c..e60a375 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -37,12 +37,12 @@ mod runtime_watch; mod runtime_zero; mod users; -use config_store::{current_revision, parse_if_match}; +use config_store::{current_revision, load_config_from_disk, parse_if_match}; use events::ApiEventStore; use http_utils::{error_response, read_json, read_optional_json, success_response}; use model::{ - ApiFailure, CreateUserRequest, HealthData, PatchUserRequest, RotateSecretRequest, SummaryData, - UserActiveIps, + ApiFailure, CreateUserRequest, DeleteUserResponse, HealthData, PatchUserRequest, + RotateSecretRequest, SummaryData, UserActiveIps, }; use runtime_edge::{ EdgeConnectionsCacheEntry, build_runtime_connections_summary_data, @@ -380,13 +380,16 @@ async fn handle( } ("GET", "/v1/stats/users") | ("GET", "/v1/users") => { let revision = current_revision(&shared.config_path).await?; + let disk_cfg = load_config_from_disk(&shared.config_path).await?; + let runtime_cfg = config_rx.borrow().clone(); let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); let users = users_from_config( - &cfg, + &disk_cfg, &shared.stats, &shared.ip_tracker, detected_ip_v4, detected_ip_v6, + Some(runtime_cfg.as_ref()), ) .await; Ok(success_response(StatusCode::OK, users, revision)) @@ -405,7 +408,7 @@ async fn handle( let expected_revision = parse_if_match(req.headers()); let body = read_json::(req.into_body(), body_limit).await?; let result = create_user(body, expected_revision, &shared).await; - let (data, revision) = match result { + let (mut data, revision) = match result { Ok(ok) => ok, Err(error) => { shared @@ -414,11 +417,18 @@ async fn handle( return Err(error); } }; + let runtime_cfg = config_rx.borrow().clone(); + data.user.in_runtime = runtime_cfg.access.users.contains_key(&data.user.username); shared.runtime_events.record( "api.user.create.ok", format!("username={}", data.user.username), ); - Ok(success_response(StatusCode::CREATED, data, revision)) + let status = if data.user.in_runtime { + StatusCode::CREATED + } else { + StatusCode::ACCEPTED + }; + Ok(success_response(status, data, revision)) } _ => { if let Some(user) = path.strip_prefix("/v1/users/") @@ -427,13 +437,16 @@ async fn handle( { if method == Method::GET { let revision = current_revision(&shared.config_path).await?; + let disk_cfg = load_config_from_disk(&shared.config_path).await?; + let runtime_cfg = config_rx.borrow().clone(); let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips(); let users = users_from_config( - &cfg, + &disk_cfg, &shared.stats, &shared.ip_tracker, detected_ip_v4, detected_ip_v6, + Some(runtime_cfg.as_ref()), ) .await; if let Some(user_info) = @@ -461,7 +474,7 @@ async fn handle( let body = read_json::(req.into_body(), body_limit).await?; let result = patch_user(user, body, expected_revision, &shared).await; - let (data, revision) = match result { + let (mut data, revision) = match result { Ok(ok) => ok, Err(error) => { shared.runtime_events.record( @@ -471,10 +484,17 @@ async fn handle( return Err(error); } }; + let runtime_cfg = config_rx.borrow().clone(); + data.in_runtime = runtime_cfg.access.users.contains_key(&data.username); shared .runtime_events .record("api.user.patch.ok", format!("username={}", data.username)); - return Ok(success_response(StatusCode::OK, data, revision)); + let status = if data.in_runtime { + StatusCode::OK + } else { + StatusCode::ACCEPTED + }; + return Ok(success_response(status, data, revision)); } if method == Method::DELETE { if api_cfg.read_only { @@ -502,7 +522,18 @@ async fn handle( shared .runtime_events .record("api.user.delete.ok", format!("username={}", deleted_user)); - return Ok(success_response(StatusCode::OK, deleted_user, revision)); + let runtime_cfg = config_rx.borrow().clone(); + let in_runtime = runtime_cfg.access.users.contains_key(&deleted_user); + let response = DeleteUserResponse { + username: deleted_user, + in_runtime, + }; + let status = if response.in_runtime { + StatusCode::ACCEPTED + } else { + StatusCode::OK + }; + return Ok(success_response(status, response, revision)); } if method == Method::POST && let Some(base_user) = user.strip_suffix("/rotate-secret") @@ -530,7 +561,7 @@ async fn handle( &shared, ) .await; - let (data, revision) = match result { + let (mut data, revision) = match result { Ok(ok) => ok, Err(error) => { shared.runtime_events.record( @@ -540,11 +571,19 @@ async fn handle( return Err(error); } }; + let runtime_cfg = config_rx.borrow().clone(); + data.user.in_runtime = + runtime_cfg.access.users.contains_key(&data.user.username); shared.runtime_events.record( "api.user.rotate_secret.ok", format!("username={}", base_user), ); - return Ok(success_response(StatusCode::OK, data, revision)); + let status = if data.user.in_runtime { + StatusCode::OK + } else { + StatusCode::ACCEPTED + }; + return Ok(success_response(status, data, revision)); } if method == Method::POST { return Ok(error_response( diff --git a/src/api/model.rs b/src/api/model.rs index 164042f..ebc67d7 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -428,6 +428,7 @@ pub(super) struct UserLinks { #[derive(Serialize)] pub(super) struct UserInfo { pub(super) username: String, + pub(super) in_runtime: bool, pub(super) user_ad_tag: Option, pub(super) max_tcp_conns: Option, pub(super) expiration_rfc3339: Option, @@ -454,6 +455,12 @@ pub(super) struct CreateUserResponse { pub(super) secret: String, } +#[derive(Serialize)] +pub(super) struct DeleteUserResponse { + pub(super) username: String, + pub(super) in_runtime: bool, +} + #[derive(Deserialize)] pub(super) struct CreateUserRequest { pub(super) username: String, diff --git a/src/api/users.rs b/src/api/users.rs index 0b8a471..5a09714 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -136,6 +136,7 @@ pub(super) async fn create_user( &shared.ip_tracker, detected_ip_v4, detected_ip_v6, + None, ) .await; let user = users @@ -143,6 +144,7 @@ pub(super) async fn create_user( .find(|entry| entry.username == body.username) .unwrap_or(UserInfo { username: body.username.clone(), + in_runtime: false, user_ad_tag: None, max_tcp_conns: cfg .access @@ -243,6 +245,7 @@ pub(super) async fn patch_user( &shared.ip_tracker, detected_ip_v4, detected_ip_v6, + None, ) .await; let user_info = users @@ -300,6 +303,7 @@ pub(super) async fn rotate_secret( &shared.ip_tracker, detected_ip_v4, detected_ip_v6, + None, ) .await; let user_info = users @@ -372,6 +376,7 @@ pub(super) async fn users_from_config( ip_tracker: &UserIpTracker, startup_detected_ip_v4: Option, startup_detected_ip_v6: Option, + runtime_cfg: Option<&ProxyConfig>, ) -> Vec { let mut names = cfg.access.users.keys().cloned().collect::>(); names.sort(); @@ -401,6 +406,9 @@ pub(super) async fn users_from_config( tls: Vec::new(), }); users.push(UserInfo { + in_runtime: runtime_cfg + .map(|runtime| runtime.access.users.contains_key(&username)) + .unwrap_or(false), user_ad_tag: cfg.access.user_ad_tags.get(&username).cloned(), max_tcp_conns: cfg .access @@ -605,35 +613,75 @@ mod tests { let stats = Stats::new(); let tracker = UserIpTracker::new(); - let users = users_from_config(&cfg, &stats, &tracker, None, None).await; + let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; let alice = users .iter() .find(|entry| entry.username == "alice") .expect("alice must be present"); + assert!(!alice.in_runtime); assert_eq!(alice.max_tcp_conns, Some(7)); cfg.access.user_max_tcp_conns.insert("alice".to_string(), 5); - let users = users_from_config(&cfg, &stats, &tracker, None, None).await; + let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; let alice = users .iter() .find(|entry| entry.username == "alice") .expect("alice must be present"); + assert!(!alice.in_runtime); assert_eq!(alice.max_tcp_conns, Some(5)); cfg.access.user_max_tcp_conns.insert("alice".to_string(), 0); - let users = users_from_config(&cfg, &stats, &tracker, None, None).await; + let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; let alice = users .iter() .find(|entry| entry.username == "alice") .expect("alice must be present"); + assert!(!alice.in_runtime); assert_eq!(alice.max_tcp_conns, Some(7)); cfg.access.user_max_tcp_conns_global_each = 0; - let users = users_from_config(&cfg, &stats, &tracker, None, None).await; + let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await; let alice = users .iter() .find(|entry| entry.username == "alice") .expect("alice must be present"); + assert!(!alice.in_runtime); assert_eq!(alice.max_tcp_conns, None); } + + #[tokio::test] + async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() { + let mut disk_cfg = ProxyConfig::default(); + disk_cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + disk_cfg.access.users.insert( + "bob".to_string(), + "fedcba9876543210fedcba9876543210".to_string(), + ); + + let mut runtime_cfg = ProxyConfig::default(); + runtime_cfg.access.users.insert( + "alice".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ); + + let stats = Stats::new(); + let tracker = UserIpTracker::new(); + let users = + users_from_config(&disk_cfg, &stats, &tracker, None, None, Some(&runtime_cfg)).await; + + let alice = users + .iter() + .find(|entry| entry.username == "alice") + .expect("alice must be present"); + let bob = users + .iter() + .find(|entry| entry.username == "bob") + .expect("bob must be present"); + + assert!(alice.in_runtime); + assert!(!bob.in_runtime); + } }