| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use std::path::Path; |
| 8 | use std::process::ExitCode; |
| 9 | |
| 10 | use clap::Subcommand; |
| 11 | use config::Config; |
| 12 | use model::User; |
| 13 | use store::{NewInvite, NewUser, Store}; |
| 14 | |
| 15 | use crate::context::{block_on, open_store}; |
| 16 | use crate::prompt::{confirm, resolve_password}; |
| 17 | |
| 18 | |
| 19 | #[derive(Debug, Subcommand)] |
| 20 | pub(crate) enum UserCommand { |
| 21 | |
| 22 | |
| 23 | Add { |
| 24 | |
| 25 | name: String, |
| 26 | |
| 27 | email: String, |
| 28 | |
| 29 | |
| 30 | |
| 31 | #[arg(long)] |
| 32 | pass: Option<String>, |
| 33 | |
| 34 | #[arg(long)] |
| 35 | admin: bool, |
| 36 | |
| 37 | #[arg(long = "display-name")] |
| 38 | display_name: Option<String>, |
| 39 | }, |
| 40 | |
| 41 | |
| 42 | |
| 43 | Passwd { |
| 44 | |
| 45 | name: String, |
| 46 | |
| 47 | #[arg(long)] |
| 48 | pass: Option<String>, |
| 49 | }, |
| 50 | |
| 51 | List, |
| 52 | |
| 53 | |
| 54 | Disable { |
| 55 | |
| 56 | name: String, |
| 57 | }, |
| 58 | |
| 59 | Enable { |
| 60 | |
| 61 | name: String, |
| 62 | }, |
| 63 | |
| 64 | |
| 65 | Verify { |
| 66 | |
| 67 | name: String, |
| 68 | }, |
| 69 | |
| 70 | Admin { |
| 71 | |
| 72 | name: String, |
| 73 | |
| 74 | #[arg(long)] |
| 75 | revoke: bool, |
| 76 | }, |
| 77 | |
| 78 | |
| 79 | Del { |
| 80 | |
| 81 | name: String, |
| 82 | |
| 83 | #[arg(long)] |
| 84 | purge: bool, |
| 85 | |
| 86 | #[arg(long)] |
| 87 | yes: bool, |
| 88 | }, |
| 89 | } |
| 90 | |
| 91 | |
| 92 | pub(crate) fn run( |
| 93 | command: &UserCommand, |
| 94 | config_path: Option<&Path>, |
| 95 | json: bool, |
| 96 | ) -> anyhow::Result<ExitCode> { |
| 97 | match command { |
| 98 | UserCommand::Add { |
| 99 | name, |
| 100 | email, |
| 101 | pass, |
| 102 | admin, |
| 103 | display_name, |
| 104 | } => block_on(add( |
| 105 | config_path, |
| 106 | name, |
| 107 | email, |
| 108 | pass.clone(), |
| 109 | *admin, |
| 110 | display_name.clone(), |
| 111 | )), |
| 112 | UserCommand::Passwd { name, pass } => block_on(passwd(config_path, name, pass.clone())), |
| 113 | UserCommand::List => block_on(list(config_path, json)), |
| 114 | UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)), |
| 115 | UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)), |
| 116 | UserCommand::Verify { name } => block_on(verify(config_path, name)), |
| 117 | UserCommand::Admin { name, revoke } => block_on(set_admin(config_path, name, !*revoke)), |
| 118 | UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)), |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | |
| 123 | fn hash_password(config: &Config, password: &str) -> anyhow::Result<String> { |
| 124 | let params = auth::HashParams { |
| 125 | m_cost: config.auth.argon2_m_cost, |
| 126 | t_cost: config.auth.argon2_t_cost, |
| 127 | p_cost: config.auth.argon2_p_cost, |
| 128 | }; |
| 129 | Ok(auth::hash_password(password, params)?) |
| 130 | } |
| 131 | |
| 132 | |
| 133 | async fn require_user(store: &Store, name: &str) -> anyhow::Result<User> { |
| 134 | store |
| 135 | .user_by_username(name) |
| 136 | .await? |
| 137 | .ok_or_else(|| anyhow::anyhow!("no such user {name:?}")) |
| 138 | } |
| 139 | |
| 140 | async fn add( |
| 141 | config_path: Option<&Path>, |
| 142 | name: &str, |
| 143 | email: &str, |
| 144 | pass: Option<String>, |
| 145 | admin: bool, |
| 146 | display_name: Option<String>, |
| 147 | ) -> anyhow::Result<ExitCode> { |
| 148 | let (config, store) = open_store(config_path).await?; |
| 149 | |
| 150 | |
| 151 | |
| 152 | let password_hash = match pass { |
| 153 | Some(explicit) => { |
| 154 | let password = resolve_password(Some(explicit))?; |
| 155 | Some(hash_password(&config, &password)?) |
| 156 | } |
| 157 | None => None, |
| 158 | }; |
| 159 | let has_password = password_hash.is_some(); |
| 160 | |
| 161 | let user = store |
| 162 | .create_user(NewUser { |
| 163 | username: name.to_string(), |
| 164 | email: email.to_string(), |
| 165 | display_name, |
| 166 | password_hash, |
| 167 | is_admin: admin, |
| 168 | must_change_password: false, |
| 169 | }) |
| 170 | .await?; |
| 171 | |
| 172 | println!( |
| 173 | "created user {} <{}>{}", |
| 174 | user.username, |
| 175 | user.email, |
| 176 | if user.is_admin { " (admin)" } else { "" } |
| 177 | ); |
| 178 | if !has_password { |
| 179 | |
| 180 | |
| 181 | send_activation(&config, &store, &user).await?; |
| 182 | } |
| 183 | Ok(ExitCode::SUCCESS) |
| 184 | } |
| 185 | |
| 186 | |
| 187 | |
| 188 | async fn send_activation(config: &Config, store: &Store, user: &User) -> anyhow::Result<()> { |
| 189 | |
| 190 | let token = auth::new_session_token(); |
| 191 | let ttl_ms = i64::from(config.auth.invite_ttl_hours).saturating_mul(3_600_000); |
| 192 | store |
| 193 | .create_invite(NewInvite { |
| 194 | user_id: user.id.clone(), |
| 195 | token_hash: token.id, |
| 196 | purpose: mail::Purpose::Activate.as_str().to_string(), |
| 197 | expires_at: now_ms().saturating_add(ttl_ms), |
| 198 | }) |
| 199 | .await?; |
| 200 | |
| 201 | let link = format!( |
| 202 | "{}/invite/{}", |
| 203 | config.instance.url.trim_end_matches('/'), |
| 204 | token.cookie |
| 205 | ); |
| 206 | |
| 207 | match mail::Mailer::build(&config.mail) { |
| 208 | Ok(mailer) => match mailer |
| 209 | .send_invite( |
| 210 | &config.instance.name, |
| 211 | &user.email, |
| 212 | user.display_name.as_deref(), |
| 213 | &link, |
| 214 | mail::Purpose::Activate, |
| 215 | ) |
| 216 | .await |
| 217 | { |
| 218 | Ok(()) => println!("sent activation email to {}", user.email), |
| 219 | Err(err) => eprintln!("warning: could not send activation email: {err}"), |
| 220 | }, |
| 221 | Err(err) => eprintln!("warning: mail not configured: {err}"), |
| 222 | } |
| 223 | println!( |
| 224 | "activation link (valid {}h): {link}", |
| 225 | config.auth.invite_ttl_hours |
| 226 | ); |
| 227 | Ok(()) |
| 228 | } |
| 229 | |
| 230 | |
| 231 | fn now_ms() -> i64 { |
| 232 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 233 | SystemTime::now() |
| 234 | .duration_since(UNIX_EPOCH) |
| 235 | .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) |
| 236 | } |
| 237 | |
| 238 | async fn passwd( |
| 239 | config_path: Option<&Path>, |
| 240 | name: &str, |
| 241 | pass: Option<String>, |
| 242 | ) -> anyhow::Result<ExitCode> { |
| 243 | let (config, store) = open_store(config_path).await?; |
| 244 | let user = require_user(&store, name).await?; |
| 245 | let password = resolve_password(pass)?; |
| 246 | let hash = hash_password(&config, &password)?; |
| 247 | store.set_password(&user.id, Some(&hash)).await?; |
| 248 | println!("password updated for {}", user.username); |
| 249 | Ok(ExitCode::SUCCESS) |
| 250 | } |
| 251 | |
| 252 | async fn list(config_path: Option<&Path>, json: bool) -> anyhow::Result<ExitCode> { |
| 253 | let (_config, store) = open_store(config_path).await?; |
| 254 | let users = store.list_users().await?; |
| 255 | |
| 256 | if json { |
| 257 | let view: Vec<_> = users.iter().map(user_json).collect(); |
| 258 | println!("{}", serde_json::to_string_pretty(&view)?); |
| 259 | return Ok(ExitCode::SUCCESS); |
| 260 | } |
| 261 | |
| 262 | if users.is_empty() { |
| 263 | println!("no users"); |
| 264 | return Ok(ExitCode::SUCCESS); |
| 265 | } |
| 266 | |
| 267 | let width = users |
| 268 | .iter() |
| 269 | .map(|u| u.username.len()) |
| 270 | .max() |
| 271 | .unwrap_or(0) |
| 272 | .max("USERNAME".len()); |
| 273 | println!("{:<width$} {:<28} FLAGS", "USERNAME", "EMAIL"); |
| 274 | for user in &users { |
| 275 | println!( |
| 276 | "{:<width$} {:<28} {}", |
| 277 | user.username, |
| 278 | user.email, |
| 279 | flags(user).join(",") |
| 280 | ); |
| 281 | } |
| 282 | Ok(ExitCode::SUCCESS) |
| 283 | } |
| 284 | |
| 285 | async fn set_disabled( |
| 286 | config_path: Option<&Path>, |
| 287 | name: &str, |
| 288 | disabled: bool, |
| 289 | ) -> anyhow::Result<ExitCode> { |
| 290 | let (_config, store) = open_store(config_path).await?; |
| 291 | let user = require_user(&store, name).await?; |
| 292 | store.set_disabled(&user.id, disabled).await?; |
| 293 | println!( |
| 294 | "{} user {}", |
| 295 | if disabled { "disabled" } else { "enabled" }, |
| 296 | user.username |
| 297 | ); |
| 298 | Ok(ExitCode::SUCCESS) |
| 299 | } |
| 300 | |
| 301 | |
| 302 | async fn verify(config_path: Option<&Path>, name: &str) -> anyhow::Result<ExitCode> { |
| 303 | let (_config, store) = open_store(config_path).await?; |
| 304 | let user = require_user(&store, name).await?; |
| 305 | if store.verify_primary_email(&user.id).await? { |
| 306 | println!( |
| 307 | "verified {}'s primary email ({})", |
| 308 | user.username, user.email |
| 309 | ); |
| 310 | } else { |
| 311 | println!("{} has no primary email to verify", user.username); |
| 312 | } |
| 313 | Ok(ExitCode::SUCCESS) |
| 314 | } |
| 315 | |
| 316 | |
| 317 | async fn set_admin( |
| 318 | config_path: Option<&Path>, |
| 319 | name: &str, |
| 320 | admin: bool, |
| 321 | ) -> anyhow::Result<ExitCode> { |
| 322 | let (_config, store) = open_store(config_path).await?; |
| 323 | let user = require_user(&store, name).await?; |
| 324 | if user.is_admin == admin { |
| 325 | println!( |
| 326 | "{} is already {}an administrator", |
| 327 | user.username, |
| 328 | if admin { "" } else { "not " } |
| 329 | ); |
| 330 | return Ok(ExitCode::SUCCESS); |
| 331 | } |
| 332 | store.set_admin(&user.id, admin).await?; |
| 333 | println!( |
| 334 | "{} admin for {}", |
| 335 | if admin { "granted" } else { "revoked" }, |
| 336 | user.username |
| 337 | ); |
| 338 | Ok(ExitCode::SUCCESS) |
| 339 | } |
| 340 | |
| 341 | async fn del( |
| 342 | config_path: Option<&Path>, |
| 343 | name: &str, |
| 344 | purge: bool, |
| 345 | yes: bool, |
| 346 | ) -> anyhow::Result<ExitCode> { |
| 347 | let (_config, store) = open_store(config_path).await?; |
| 348 | let user = require_user(&store, name).await?; |
| 349 | let repos = store.repos_by_owner(&user.id).await?; |
| 350 | |
| 351 | if !repos.is_empty() && !purge { |
| 352 | eprintln!( |
| 353 | "{} still owns {} repositor{}:", |
| 354 | user.username, |
| 355 | repos.len(), |
| 356 | plural(repos.len()) |
| 357 | ); |
| 358 | for repo in &repos { |
| 359 | eprintln!(" {}", repo.path); |
| 360 | } |
| 361 | anyhow::bail!( |
| 362 | "refusing to delete a user who owns repositories; pass --purge to remove them" |
| 363 | ); |
| 364 | } |
| 365 | |
| 366 | let question = if repos.is_empty() { |
| 367 | format!("Delete user {}?", user.username) |
| 368 | } else { |
| 369 | format!( |
| 370 | "Delete user {} and {} owned repositor{}?", |
| 371 | user.username, |
| 372 | repos.len(), |
| 373 | plural(repos.len()) |
| 374 | ) |
| 375 | }; |
| 376 | if !confirm(&question, yes)? { |
| 377 | println!("aborted"); |
| 378 | return Ok(ExitCode::SUCCESS); |
| 379 | } |
| 380 | |
| 381 | |
| 382 | |
| 383 | |
| 384 | |
| 385 | store.delete_user(&user.id).await?; |
| 386 | println!("deleted user {}", user.username); |
| 387 | Ok(ExitCode::SUCCESS) |
| 388 | } |
| 389 | |
| 390 | |
| 391 | fn flags(user: &User) -> Vec<&'static str> { |
| 392 | let mut flags = Vec::new(); |
| 393 | if user.is_admin { |
| 394 | flags.push("admin"); |
| 395 | } |
| 396 | if user.disabled_at.is_some() { |
| 397 | flags.push("disabled"); |
| 398 | } |
| 399 | if user.password_hash.is_none() { |
| 400 | flags.push("no-password"); |
| 401 | } |
| 402 | if flags.is_empty() { |
| 403 | flags.push("-"); |
| 404 | } |
| 405 | flags |
| 406 | } |
| 407 | |
| 408 | |
| 409 | |
| 410 | fn user_json(user: &User) -> serde_json::Value { |
| 411 | serde_json::json!({ |
| 412 | "id": user.id, |
| 413 | "username": user.username, |
| 414 | "email": user.email, |
| 415 | "display_name": user.display_name, |
| 416 | "is_admin": user.is_admin, |
| 417 | "has_password": user.password_hash.is_some(), |
| 418 | "must_change_password": user.must_change_password, |
| 419 | "disabled": user.disabled_at.is_some(), |
| 420 | "created_at": user.created_at, |
| 421 | "updated_at": user.updated_at, |
| 422 | }) |
| 423 | } |
| 424 | |
| 425 | |
| 426 | fn plural(n: usize) -> &'static str { |
| 427 | if n == 1 { "y" } else { "ies" } |
| 428 | } |
| 429 | |
| 430 | #[cfg(test)] |
| 431 | mod tests { |
| 432 | #![allow(clippy::unwrap_used, clippy::expect_used)] |
| 433 | |
| 434 | use std::path::PathBuf; |
| 435 | |
| 436 | use store::NewRepo; |
| 437 | use tempfile::TempDir; |
| 438 | |
| 439 | use super::*; |
| 440 | |
| 441 | |
| 442 | |
| 443 | struct Env { |
| 444 | _dir: TempDir, |
| 445 | config_path: PathBuf, |
| 446 | db_url: String, |
| 447 | } |
| 448 | |
| 449 | fn env() -> Env { |
| 450 | let dir = tempfile::tempdir().unwrap(); |
| 451 | let repos = dir.path().join("repos"); |
| 452 | let db = dir.path().join("fabrica.db"); |
| 453 | let db_url = format!("sqlite://{}", db.display()); |
| 454 | let config_path = dir.path().join("fabrica.toml"); |
| 455 | let toml = format!( |
| 456 | "[storage]\ndata_dir = {:?}\nrepo_dir = {:?}\n\n[database]\nurl = {:?}\n", |
| 457 | dir.path().display(), |
| 458 | repos.display(), |
| 459 | db_url, |
| 460 | ); |
| 461 | std::fs::write(&config_path, toml).unwrap(); |
| 462 | Env { |
| 463 | _dir: dir, |
| 464 | config_path, |
| 465 | db_url, |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | impl Env { |
| 470 | |
| 471 | |
| 472 | #[allow(clippy::unnecessary_wraps)] |
| 473 | fn path(&self) -> Option<&Path> { |
| 474 | Some(self.config_path.as_path()) |
| 475 | } |
| 476 | |
| 477 | async fn store(&self) -> Store { |
| 478 | Store::connect(&self.db_url, 1).await.unwrap() |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn clap_command_tree_is_valid() { |
| 484 | use clap::CommandFactory; |
| 485 | crate::Cli::command().debug_assert(); |
| 486 | } |
| 487 | |
| 488 | #[test] |
| 489 | fn add_with_password_creates_a_usable_admin() { |
| 490 | let env = env(); |
| 491 | let code = block_on(add( |
| 492 | env.path(), |
| 493 | "alice", |
| 494 | "alice@example.com", |
| 495 | Some("s3cr3t".to_string()), |
| 496 | true, |
| 497 | Some("Alice".to_string()), |
| 498 | )) |
| 499 | .unwrap(); |
| 500 | assert_eq!(code, ExitCode::SUCCESS); |
| 501 | |
| 502 | let created = block_on(async { |
| 503 | let user = require_user(&env.store().await, "alice").await?; |
| 504 | anyhow::Ok(user) |
| 505 | }) |
| 506 | .unwrap(); |
| 507 | assert!(created.is_admin); |
| 508 | assert_eq!(created.display_name.as_deref(), Some("Alice")); |
| 509 | let hash = created.password_hash.expect("password should be set"); |
| 510 | assert!(auth::verify_password("s3cr3t", Some(&hash))); |
| 511 | } |
| 512 | |
| 513 | #[test] |
| 514 | fn add_without_password_is_inactive_then_passwd_activates() { |
| 515 | let env = env(); |
| 516 | block_on(add(env.path(), "bob", "bob@example.com", None, false, None)).unwrap(); |
| 517 | |
| 518 | let before = |
| 519 | block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap(); |
| 520 | assert!(before.password_hash.is_none(), "no password until set"); |
| 521 | |
| 522 | |
| 523 | block_on(passwd(env.path(), "bob", Some("hunter2".to_string()))).unwrap(); |
| 524 | let after = |
| 525 | block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap(); |
| 526 | let hash = after.password_hash.expect("password now set"); |
| 527 | assert!(auth::verify_password("hunter2", Some(&hash))); |
| 528 | } |
| 529 | |
| 530 | #[test] |
| 531 | fn disable_then_enable_toggles_the_flag() { |
| 532 | let env = env(); |
| 533 | block_on(add( |
| 534 | env.path(), |
| 535 | "carol", |
| 536 | "carol@example.com", |
| 537 | Some("pw".to_string()), |
| 538 | false, |
| 539 | None, |
| 540 | )) |
| 541 | .unwrap(); |
| 542 | |
| 543 | block_on(set_disabled(env.path(), "carol", true)).unwrap(); |
| 544 | let disabled = |
| 545 | block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) }) |
| 546 | .unwrap(); |
| 547 | assert!(disabled.disabled_at.is_some()); |
| 548 | |
| 549 | block_on(set_disabled(env.path(), "carol", false)).unwrap(); |
| 550 | let enabled = |
| 551 | block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) }) |
| 552 | .unwrap(); |
| 553 | assert!(enabled.disabled_at.is_none()); |
| 554 | } |
| 555 | |
| 556 | #[test] |
| 557 | fn admin_grants_then_revokes_the_flag() { |
| 558 | let env = env(); |
| 559 | block_on(add( |
| 560 | env.path(), |
| 561 | "erin", |
| 562 | "erin@example.com", |
| 563 | Some("pw".to_string()), |
| 564 | false, |
| 565 | None, |
| 566 | )) |
| 567 | .unwrap(); |
| 568 | |
| 569 | block_on(set_admin(env.path(), "erin", true)).unwrap(); |
| 570 | let promoted = |
| 571 | block_on(async { anyhow::Ok(require_user(&env.store().await, "erin").await?) }) |
| 572 | .unwrap(); |
| 573 | assert!(promoted.is_admin); |
| 574 | |
| 575 | block_on(set_admin(env.path(), "erin", false)).unwrap(); |
| 576 | let demoted = |
| 577 | block_on(async { anyhow::Ok(require_user(&env.store().await, "erin").await?) }) |
| 578 | .unwrap(); |
| 579 | assert!(!demoted.is_admin); |
| 580 | } |
| 581 | |
| 582 | #[test] |
| 583 | fn del_refuses_repo_owner_without_purge_then_purges() { |
| 584 | let env = env(); |
| 585 | block_on(add( |
| 586 | env.path(), |
| 587 | "dave", |
| 588 | "dave@example.com", |
| 589 | Some("pw".to_string()), |
| 590 | false, |
| 591 | None, |
| 592 | )) |
| 593 | .unwrap(); |
| 594 | |
| 595 | |
| 596 | let dave_id = block_on(async { |
| 597 | let store = env.store().await; |
| 598 | let dave = require_user(&store, "dave").await?; |
| 599 | store |
| 600 | .create_repo(NewRepo { |
| 601 | owner_id: dave.id.clone(), |
| 602 | group_id: None, |
| 603 | name: "proj".to_string(), |
| 604 | path: "proj".to_string(), |
| 605 | description: None, |
| 606 | visibility: model::Visibility::Private, |
| 607 | default_branch: "main".to_string(), |
| 608 | }) |
| 609 | .await?; |
| 610 | anyhow::Ok(dave.id) |
| 611 | }) |
| 612 | .unwrap(); |
| 613 | |
| 614 | |
| 615 | let refused = block_on(del(env.path(), "dave", false, true)); |
| 616 | assert!(refused.is_err(), "owning a repo should block deletion"); |
| 617 | assert!( |
| 618 | block_on(async { anyhow::Ok(env.store().await.user_by_id(&dave_id).await?) }) |
| 619 | .unwrap() |
| 620 | .is_some() |
| 621 | ); |
| 622 | |
| 623 | |
| 624 | block_on(del(env.path(), "dave", true, true)).unwrap(); |
| 625 | block_on(async { |
| 626 | let store = env.store().await; |
| 627 | assert!(store.user_by_id(&dave_id).await?.is_none()); |
| 628 | assert!(store.repos_by_owner(&dave_id).await?.is_empty()); |
| 629 | anyhow::Ok(()) |
| 630 | }) |
| 631 | .unwrap(); |
| 632 | } |
| 633 | |
| 634 | #[test] |
| 635 | fn passwd_on_missing_user_errors() { |
| 636 | let env = env(); |
| 637 | |
| 638 | block_on(add( |
| 639 | env.path(), |
| 640 | "eve", |
| 641 | "eve@example.com", |
| 642 | Some("pw".to_string()), |
| 643 | false, |
| 644 | None, |
| 645 | )) |
| 646 | .unwrap(); |
| 647 | let err = block_on(passwd(env.path(), "nobody", Some("x".to_string()))).unwrap_err(); |
| 648 | assert!(err.to_string().contains("no such user"), "got {err}"); |
| 649 | } |
| 650 | } |