diff --git a/src/auth.rs b/src/auth.rs index a9b4f94db..ed3e967d6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,4 @@ +use crate::secrets; use anyhow::Context; use anyhow::Result; use hmac::{Hmac, Mac}; @@ -12,13 +13,50 @@ use std::sync::Mutex; type Claims = BTreeMap; +/// Holds the admin JWT signing key. `None` until a key is configured +/// (or until a random key is generated on first use when nothing is configured). pub static JWT_KEY: Lazy>> = Lazy::new(|| Mutex::new(Default::default())); -pub fn set_jwt_key(b: &str) { + +/// Install the HMAC secret used to sign and verify admin JWTs. +/// +/// Empty / whitespace-only keys are rejected: with an empty key anyone could +/// forge an admin token. Callers should treat the returned error as fatal. +pub fn set_jwt_key(b: &str) -> Result<()> { + if b.trim().is_empty() { + anyhow::bail!("jwt key cannot be empty"); + } *JWT_KEY.lock().unwrap() = Some(b.to_string()); + Ok(()) +} + +/// Install the signing key from a stack config, where an empty string means +/// "not configured" (local dev): [`get_jwt_key`] then generates a random key +/// on first use instead. A configured-but-blank key is still a hard error. +pub fn set_jwt_key_from_config(b: &str) -> Result<()> { + if b.is_empty() { + return Ok(()); + } + set_jwt_key(b) } -fn get_jwt_key() -> String { - let jk = &*JWT_KEY.lock().unwrap(); - jk.clone().unwrap_or("some-secret".to_string()).to_owned() + +/// The admin JWT signing key: +/// - a key installed via [`set_jwt_key`] is used as-is; +/// - a key that is somehow empty is an error (never sign with an empty key); +/// - when nothing is configured, a random key is generated once and kept for +/// the process lifetime. There is no hardcoded fallback secret; note that a +/// generated key rotates on restart, invalidating previously issued tokens +/// (local dev: set `jwt_key` in the stack config to keep tokens stable). +pub fn get_jwt_key() -> Result { + let mut jk = JWT_KEY.lock().unwrap(); + match jk.as_deref() { + Some(k) if k.trim().is_empty() => anyhow::bail!("jwt key is set but empty"), + Some(k) => Ok(k.to_string()), + None => { + let k = secrets::random_word(48); + *jk = Some(k.clone()); + Ok(k) + } + } } #[derive(Clone)] @@ -35,8 +73,9 @@ impl AdminJwtClaims { }) } pub fn check(token: &str) -> std::result::Result { + let key = jwt_key().map_err(|_| JwtError::Invalid)?; let claims: Claims = token - .verify_with_key(&jwt_key()) + .verify_with_key(&key) .map_err(|_| JwtError::Invalid)?; let jwtc = AdminJwtClaims::from_claims(claims).map_err(|_| JwtError::Missing)?; if jwtc.clone().exp < now() { @@ -47,17 +86,18 @@ impl AdminJwtClaims { } } -fn jwt_key() -> Hmac { - let jk = get_jwt_key(); - let key: Hmac = Hmac::new_from_slice(jk.as_bytes()).expect("failed"); - key +fn jwt_key() -> Result> { + let jk = get_jwt_key()?; + let key: Hmac = Hmac::new_from_slice(jk.as_bytes()) + .context("failed to build hmac key from jwt key")?; + Ok(key) } pub fn make_jwt(user: u32) -> Result { let mut claims = BTreeMap::new(); claims.insert("exp", now() + days(7)); claims.insert("user", user); - let token = claims.sign_with_key(&jwt_key())?; + let token = claims.sign_with_key(&jwt_key()?)?; Ok(token) } @@ -105,3 +145,108 @@ pub fn hash_pass(pwd: &str) -> Result { let valid = bcrypt::verify(pwd, &hashed)?; Ok(valid) } + +#[cfg(test)] +mod tests { + use super::*; + + /// JWT_KEY is process-global state: serialize every test that touches it. + static KEY_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn reset_key() { + *JWT_KEY.lock().unwrap() = None; + } + + #[test] + fn get_jwt_key_generates_random_key_when_unset() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + let k1 = get_jwt_key().expect("should generate a random key"); + assert!(!k1.is_empty()); + assert_ne!(k1, "some-secret"); + assert_eq!(k1.len(), 48); + assert!(k1.chars().all(|c| c.is_ascii_alphanumeric())); + // the generated key is stable for the process lifetime + let k2 = get_jwt_key().expect("should reuse the generated key"); + assert_eq!(k1, k2); + reset_key(); + } + + #[test] + fn set_jwt_key_rejects_empty_and_blank() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + assert!(set_jwt_key("").is_err()); + assert!(set_jwt_key(" ").is_err()); + // failed installs leave no key behind + assert!(JWT_KEY.lock().unwrap().is_none()); + reset_key(); + } + + #[test] + fn set_jwt_key_accepts_real_key() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + set_jwt_key("a-real-key").expect("valid key should install"); + assert_eq!(get_jwt_key().unwrap(), "a-real-key"); + reset_key(); + } + + #[test] + fn get_jwt_key_errors_when_key_set_but_empty() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + // install directly, bypassing set_jwt_key, to simulate a legacy empty key + *JWT_KEY.lock().unwrap() = Some("".to_string()); + assert!(get_jwt_key().is_err()); + *JWT_KEY.lock().unwrap() = Some(" ".to_string()); + assert!(get_jwt_key().is_err()); + reset_key(); + } + + #[test] + fn set_jwt_key_from_config_empty_means_not_configured() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + // an empty config value means "not configured": a random key is + // generated on first use instead of a hardcoded fallback + set_jwt_key_from_config("").expect("empty = not configured"); + let k = get_jwt_key().unwrap(); + assert!(!k.is_empty()); + assert_ne!(k, "some-secret"); + reset_key(); + } + + #[test] + fn set_jwt_key_from_config_blank_is_fatal() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + assert!(set_jwt_key_from_config(" ").is_err()); + reset_key(); + } + + #[test] + fn make_jwt_roundtrips_through_check() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + let token = make_jwt(7).expect("sign with generated key"); + let claims = AdminJwtClaims::check(&token).expect("token verifies with same key"); + assert_eq!(claims.user, 7); + assert!(claims.exp > 0); + reset_key(); + } + + #[test] + fn check_rejects_token_signed_with_different_key() { + let _g = KEY_TEST_LOCK.lock().unwrap(); + reset_key(); + let token = make_jwt(7).unwrap(); + set_jwt_key("another-key").unwrap(); + match AdminJwtClaims::check(&token) { + Err(JwtError::Invalid) => {} + Err(e) => panic!("expected Invalid, got JwtError::{:?}", e), + Ok(_) => panic!("expected Invalid, got a valid token"), + } + reset_key(); + } +} diff --git a/src/bin/cln/mod.rs b/src/bin/cln/mod.rs index c927f3874..6898b7958 100644 --- a/src/bin/cln/mod.rs +++ b/src/bin/cln/mod.rs @@ -30,7 +30,7 @@ pub async fn main() -> Result<()> { let stack = make_stack(); log::info!("STACK {:?}", stack); - sphinx_swarm::auth::set_jwt_key(&stack.jwt_key); + sphinx_swarm::auth::set_jwt_key_from_config(&stack.jwt_key)?; handler::hydrate_stack(stack.clone()).await; let (tx, rx) = mpsc::channel::(1000); diff --git a/src/bin/cln_mainnet_test.rs b/src/bin/cln_mainnet_test.rs index 675bc9bf8..c77682120 100644 --- a/src/bin/cln_mainnet_test.rs +++ b/src/bin/cln_mainnet_test.rs @@ -21,7 +21,7 @@ pub async fn main() -> Result<()> { let stack = make_stack(); let clients = builder::build_stack(proj, &docker, &stack).await?; - sphinx_swarm::auth::set_jwt_key(&stack.jwt_key); + sphinx_swarm::auth::set_jwt_key_from_config(&stack.jwt_key)?; handler::hydrate(stack, clients).await; diff --git a/src/bin/stack/mod.rs b/src/bin/stack/mod.rs index cc39206b6..1c126c853 100644 --- a/src/bin/stack/mod.rs +++ b/src/bin/stack/mod.rs @@ -37,7 +37,9 @@ async fn main() -> Result<()> { } // put the jwt key into a var - sphinx_swarm::auth::set_jwt_key(&stack.jwt_key); + // (an empty config value means "not configured": a random key is generated + // at boot; a configured-but-blank key fails startup) + sphinx_swarm::auth::set_jwt_key_from_config(&stack.jwt_key)?; // hydrate the "stack" without clients handler::hydrate_stack(stack.clone()).await; diff --git a/src/bin/super/mod.rs b/src/bin/super/mod.rs index 911b938d7..5a6cb53ff 100644 --- a/src/bin/super/mod.rs +++ b/src/bin/super/mod.rs @@ -69,7 +69,7 @@ async fn main() -> Result<()> { let s: state::Super = load_config_file(project).await.expect("YAML CONFIG FAIL"); log::info!("SUPER!!! {:?}", s); - sphinx_swarm::auth::set_jwt_key(&s.jwt_key); + sphinx_swarm::auth::set_jwt_key_from_config(&s.jwt_key)?; state::hydrate(s).await; @@ -170,7 +170,14 @@ fn access(cmd: &Cmd, state: &Super, user_id: &Option) -> bool { if user.is_none() { return false; } - + // self-service commands: the payload's user_id must be the authenticated + // caller — never let it target another user's record (IDOR guard) + if let Cmd::Swarm(c) = cmd { + match c { + SwarmCmd::ChangePassword(cp) => return cp.user_id == user_id, + _ => {} + } + } return match user.unwrap().role { Role::Super => true, Role::Admin => false, diff --git a/src/bin/tome/mod.rs b/src/bin/tome/mod.rs index 1490e9760..236b09e25 100644 --- a/src/bin/tome/mod.rs +++ b/src/bin/tome/mod.rs @@ -23,7 +23,7 @@ pub async fn main() -> Result<()> { let stack = make_stack(); log::info!("STACK {:?}", stack); - sphinx_swarm::auth::set_jwt_key(&stack.jwt_key); + sphinx_swarm::auth::set_jwt_key_from_config(&stack.jwt_key)?; handler::hydrate_stack(stack.clone()).await; let (tx, rx) = mpsc::channel::(1000); diff --git a/src/bin/v1/mod.rs b/src/bin/v1/mod.rs index 2b9f3f558..349c1dda0 100644 --- a/src/bin/v1/mod.rs +++ b/src/bin/v1/mod.rs @@ -57,7 +57,7 @@ pub async fn main() -> Result<()> { // println!("{}", st); // return Ok(()); - sphinx_swarm::auth::set_jwt_key(&stack.jwt_key); + sphinx_swarm::auth::set_jwt_key_from_config(&stack.jwt_key)?; handler::hydrate_stack(stack.clone()).await; let (tx, rx) = mpsc::channel::(1000); diff --git a/src/cmd.rs b/src/cmd.rs index 188776325..6d9013043 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -7,6 +7,11 @@ use anyhow::Context; use serde::{Deserialize, Serialize}; use sphinx_auther::secp256k1::PublicKey; +/// Placeholder printed instead of secret values in `Debug` output, so that +/// `log::info!("=> CMD: {:?}", cmd)` never writes passwords or env values +/// to the log stream. +const REDACTED: &str = "[REDACTED]"; + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", content = "data")] pub enum Cmd { @@ -25,20 +30,39 @@ pub struct ImageRequest { pub page: u8, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Clone)] pub struct LoginInfo { pub username: String, pub password: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +impl std::fmt::Debug for LoginInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoginInfo") + .field("username", &self.username) + .field("password", &REDACTED) + .finish() + } +} + +#[derive(Serialize, Deserialize, Clone)] pub struct ChangePasswordInfo { pub user_id: u32, pub old_pass: String, pub password: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +impl std::fmt::Debug for ChangePasswordInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChangePasswordInfo") + .field("user_id", &self.user_id) + .field("old_pass", &REDACTED) + .field("password", &REDACTED) + .finish() + } +} + +#[derive(Serialize, Deserialize, Clone)] pub struct ChangeAdminInfo { pub user_id: u32, pub old_pass: String, @@ -46,6 +70,17 @@ pub struct ChangeAdminInfo { pub email: String, } +impl std::fmt::Debug for ChangeAdminInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChangeAdminInfo") + .field("user_id", &self.user_id) + .field("old_pass", &REDACTED) + .field("password", &REDACTED) + .field("email", &self.email) + .finish() + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct UpdateNode { pub id: String, @@ -121,13 +156,23 @@ pub struct FeatureFlagUserRoles { pub admin: bool, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Clone)] pub struct ChangeUserPasswordBySuperAdminInfo { pub new_password: String, pub current_password: String, pub username: String, } +impl std::fmt::Debug for ChangeUserPasswordBySuperAdminInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChangeUserPasswordBySuperAdminInfo") + .field("new_password", &REDACTED) + .field("current_password", &REDACTED) + .field("username", &self.username) + .finish() + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BoltwallUser { pub id: i64, @@ -161,19 +206,41 @@ pub struct UpdateNeo4jConfigRequest { pub checkpoint_iops: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Clone)] pub struct UpdateEnvRequest { pub id: Option, pub values: HashMap, } -#[derive(Serialize, Deserialize, Debug, Clone)] +impl std::fmt::Debug for UpdateEnvRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // env values can hold passwords / API keys — log the keys only + let keys: Vec<&String> = self.values.keys().collect(); + f.debug_struct("UpdateEnvRequest") + .field("id", &self.id) + .field("values", &keys) + .finish() + } +} + +#[derive(Serialize, Deserialize, Clone)] pub struct AssignSwarmNewDetails { pub new_password: Option, pub old_password: Option, pub env: Option>, } +impl std::fmt::Debug for AssignSwarmNewDetails { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let env_keys: Option> = self.env.as_ref().map(|m| m.keys().collect()); + f.debug_struct("AssignSwarmNewDetails") + .field("new_password", &self.new_password.as_ref().map(|_| REDACTED)) + .field("old_password", &self.old_password.as_ref().map(|_| REDACTED)) + .field("env", &env_keys) + .finish() + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ContainerLogsRequest { pub name: String, @@ -495,4 +562,120 @@ mod tests { assert!(true == true) } + + #[test] + fn login_debug_redacts_password() { + let info = LoginInfo { + username: "admin".to_string(), + password: "s3cret-pass".to_string(), + }; + let dbg = format!("{:?}", info); + assert!(!dbg.contains("s3cret-pass")); + assert!(dbg.contains("admin")); + assert!(dbg.contains("[REDACTED]")); + } + + #[test] + fn change_password_debug_redacts_both_passwords() { + let info = ChangePasswordInfo { + user_id: 1, + old_pass: "old-pass-123".to_string(), + password: "new-pass-456".to_string(), + }; + let dbg = format!("{:?}", info); + assert!(!dbg.contains("old-pass-123")); + assert!(!dbg.contains("new-pass-456")); + assert!(dbg.contains("user_id: 1")); + assert!(dbg.contains("[REDACTED]")); + } + + #[test] + fn change_admin_debug_redacts_passwords() { + let info = ChangeAdminInfo { + user_id: 2, + old_pass: "old-pass-123".to_string(), + password: "new-pass-456".to_string(), + email: "a@b.c".to_string(), + }; + let dbg = format!("{:?}", info); + assert!(!dbg.contains("old-pass-123")); + assert!(!dbg.contains("new-pass-456")); + assert!(dbg.contains("a@b.c")); + } + + #[test] + fn change_user_password_debug_redacts_passwords() { + let info = ChangeUserPasswordBySuperAdminInfo { + new_password: "fresh-pass-789".to_string(), + current_password: "current-pass-000".to_string(), + username: "admin".to_string(), + }; + let dbg = format!("{:?}", info); + assert!(!dbg.contains("fresh-pass-789")); + assert!(!dbg.contains("current-pass-000")); + assert!(dbg.contains("admin")); + } + + #[test] + fn update_env_debug_logs_keys_only() { + let mut values = HashMap::new(); + values.insert("HOST".to_string(), "https://hidden-host.example".to_string()); + values.insert( + "NEO4J_PASSWORD".to_string(), + "env-secret-value-1".to_string(), + ); + let req = UpdateEnvRequest { + id: Some("boltwall".to_string()), + values, + }; + let dbg = format!("{:?}", req); + // keys are visible... + assert!(dbg.contains("HOST")); + assert!(dbg.contains("NEO4J_PASSWORD")); + // ...but values never are + assert!(!dbg.contains("env-secret-value-1")); + assert!(!dbg.contains("hidden-host.example")); + } + + #[test] + fn assign_swarm_new_details_debug_redacts_passwords_and_env() { + let mut env = HashMap::new(); + env.insert("OWNER_PUBKEY".to_string(), "assign-secret-value-2".to_string()); + let details = AssignSwarmNewDetails { + new_password: Some("new-swarm-pass-1".to_string()), + old_password: Some("old-swarm-pass-1".to_string()), + env: Some(env), + }; + let dbg = format!("{:?}", details); + assert!(!dbg.contains("new-swarm-pass-1")); + assert!(!dbg.contains("old-swarm-pass-1")); + assert!(!dbg.contains("assign-secret-value-2")); + assert!(dbg.contains("OWNER_PUBKEY")); + } + + #[test] + fn swarm_cmd_debug_redacts_nested_secrets() { + // the "=> CMD: {:?}" log path formats the whole command enum + let cmd = Cmd::Swarm(SwarmCmd::Login(LoginInfo { + username: "admin".to_string(), + password: "s3cret-pass".to_string(), + })); + let dbg = format!("{:?}", cmd); + assert!(!dbg.contains("s3cret-pass")); + assert!(dbg.contains("admin")); + assert!(dbg.contains("Login")); + } + + #[test] + fn redacted_structs_still_serialize_real_values() { + // Debug is redacted for logs; serde must still carry the real payload + let info = LoginInfo { + username: "admin".to_string(), + password: "s3cret-pass".to_string(), + }; + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("s3cret-pass")); + let back: LoginInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(back.password, "s3cret-pass"); + } } diff --git a/src/conn/swarm/mod.rs b/src/conn/swarm/mod.rs index 94673657c..89e861977 100644 --- a/src/conn/swarm/mod.rs +++ b/src/conn/swarm/mod.rs @@ -500,10 +500,11 @@ pub async fn update_env_variables( docker: &Docker, update_value: &mut UpdateEnvRequest, ) -> SwarmResponse { + // log the env keys only — values can hold passwords / API keys log::info!( - "Updating env variables for {:?}: {:?}", + "Updating env variables for {:?}: keys={:?}", update_value.id, - update_value.values + update_value.values.keys().collect::>() ); // 1. Write to .env file (no lock needed) diff --git a/src/handler.rs b/src/handler.rs index 852514f07..b551deb54 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -107,6 +107,15 @@ fn access(cmd: &Cmd, stack: &Stack, user_id: &Option) -> bool { if user.is_none() { return false; } + // self-service commands: the payload's user_id must be the authenticated + // caller — never let it target another user's record (IDOR guard) + if let Cmd::Swarm(c) = cmd { + match c { + SwarmCmd::ChangePassword(cp) => return cp.user_id == user_id, + SwarmCmd::ChangeAdmin(cp) => return cp.user_id == user_id, + _ => {} + } + } match user.unwrap().role { Role::Admin => true, Role::SubAdmin => true, @@ -1055,3 +1064,147 @@ pub fn spawn_handler(proj: &str, mut rx: mpsc::Receiver, docker: Doc fn fmt_err(err: &str) -> String { format!("{{\"stack_error\":\"{}\"}}", err.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::{ChangeAdminInfo, ChangePasswordInfo}; + use crate::config::{Role, Stack, User}; + + fn stack_with_users(users: Vec) -> Stack { + Stack { + network: "regtest".to_string(), + nodes: vec![], + host: None, + users, + jwt_key: "test-jwt-key".to_string(), + ready: true, + ip: None, + auto_update: None, + auto_restart: None, + custom_2b_domain: None, + global_mem_limit: None, + backup_services: None, + backup_files: None, + lightning_peers: None, + ssl_cert_last_modified: None, + instance_id: None, + } + } + + fn admin_user(id: u32) -> User { + User { + id, + username: format!("user-{}", id), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Admin, + } + } + + #[test] + fn change_password_for_self_is_allowed() { + let stack = stack_with_users(vec![User { + id: 7, + username: "admin".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Admin, + }]); + let cmd = Cmd::Swarm(SwarmCmd::ChangePassword(ChangePasswordInfo { + user_id: 7, + old_pass: "old".to_string(), + password: "new".to_string(), + })); + assert!(access(&cmd, &stack, &Some(7))); + } + + #[test] + fn change_password_for_another_user_is_denied() { + // IDOR: payload user_id != authenticated caller id + let stack = stack_with_users(vec![ + User { + id: 7, + username: "sub".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Admin, + }, + User { + id: 1, + username: "root".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Admin, + }, + ]); + let cmd = Cmd::Swarm(SwarmCmd::ChangePassword(ChangePasswordInfo { + user_id: 1, + old_pass: "old".to_string(), + password: "new".to_string(), + })); + // denied for every role — this command is self-service only + assert!(!access(&cmd, &stack, &Some(7))); // Admin caller + assert!(!access(&cmd, &stack, &Some(2))); // unknown caller + let sub = stack_with_users(vec![User { + id: 7, + username: "sub".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::SubAdmin, + }]); + assert!(!access(&cmd, &sub, &Some(7))); + // super role cannot target another user either + let sup = stack_with_users(vec![User { + id: 7, + username: "sub".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Super, + }]); + assert!(!access(&cmd, &sup, &Some(7))); + } + + #[test] + fn change_admin_for_another_user_is_denied() { + let stack = stack_with_users(vec![User { + id: 7, + username: "admin".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Admin, + }]); + let cmd = Cmd::Swarm(SwarmCmd::ChangeAdmin(ChangeAdminInfo { + user_id: 1, + old_pass: "old".to_string(), + password: "new".to_string(), + email: "evil@x.c".to_string(), + })); + assert!(!access(&cmd, &stack, &Some(7))); + let own = Cmd::Swarm(SwarmCmd::ChangeAdmin(ChangeAdminInfo { + user_id: 7, + old_pass: "old".to_string(), + password: "new".to_string(), + email: "me@x.c".to_string(), + })); + assert!(access(&own, &stack, &Some(7))); + } + + #[test] + fn unauthenticated_caller_still_denied() { + let stack = stack_with_users(vec![User { + id: 7, + username: "admin".to_string(), + pass_hash: "x".to_string(), + pubkey: None, + role: Role::Admin, + }]); + let cmd = Cmd::Swarm(SwarmCmd::ChangePassword(ChangePasswordInfo { + user_id: 7, + old_pass: "old".to_string(), + password: "new".to_string(), + })); + // no JWT caller + assert!(!access(&cmd, &stack, &None)); + } +} diff --git a/tests/handler_test.rs b/tests/handler_test.rs index a38a1bd3e..b9b1ef641 100644 --- a/tests/handler_test.rs +++ b/tests/handler_test.rs @@ -326,7 +326,7 @@ async fn test_concurrent_reads(docker: &Docker) -> Result<()> { hydrate(stack, Clients::default()).await; // Set the JWT key so handle() doesn't error - sphinx_swarm::auth::set_jwt_key("test-jwt-key"); + sphinx_swarm::auth::set_jwt_key("test-jwt-key").expect("set jwt key"); let start = Instant::now(); let mut handles = Vec::new(); @@ -381,7 +381,7 @@ async fn test_concurrent_reads(docker: &Docker) -> Result<()> { async fn test_login_and_change_password(docker: &Docker) -> Result<()> { let stack = make_auth_stack(); hydrate(stack, Clients::default()).await; - sphinx_swarm::auth::set_jwt_key("test-jwt-key"); + sphinx_swarm::auth::set_jwt_key("test-jwt-key").expect("set jwt key"); // 1. Login with correct password let res = handle( @@ -506,7 +506,7 @@ async fn test_read_during_write(docker: &Docker) -> Result<()> { // Build a stack with a user whose password hash is expensive to verify let stack = make_auth_stack(); hydrate(stack, Clients::default()).await; - sphinx_swarm::auth::set_jwt_key("test-jwt-key"); + sphinx_swarm::auth::set_jwt_key("test-jwt-key").expect("set jwt key"); // Spawn a Login call — bcrypt::verify is CPU-expensive but runs outside the lock let docker_clone = docker.clone(); @@ -560,7 +560,7 @@ async fn test_read_during_write(docker: &Docker) -> Result<()> { async fn test_stack_mutations_persist(docker: &Docker) -> Result<()> { let stack = make_auth_stack(); hydrate(stack, Clients::default()).await; - sphinx_swarm::auth::set_jwt_key("test-jwt-key"); + sphinx_swarm::auth::set_jwt_key("test-jwt-key").expect("set jwt key"); // Set global_mem_limit to 1234 let res = handle( @@ -686,7 +686,7 @@ async fn test_concurrent_bitcoind_calls(docker: &Docker) -> Result<()> { async fn test_access_control(docker: &Docker) -> Result<()> { let stack = make_auth_stack(); hydrate(stack, Clients::default()).await; - sphinx_swarm::auth::set_jwt_key("test-jwt-key"); + sphinx_swarm::auth::set_jwt_key("test-jwt-key").expect("set jwt key"); // 1. GetConfig with no user_id -> access denied let res = handle(