fabrica

hanna/fabrica

20681 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `fabrica user` — account administration, operating directly on the store.
6
7use std::path::Path;
8use std::process::ExitCode;
9
10use clap::Subcommand;
11use config::Config;
12use model::User;
13use store::{NewInvite, NewUser, Store};
14
15use crate::context::{block_on, open_store};
16use crate::prompt::{confirm, resolve_password};
17
18/// Actions under `fabrica user`.
19#[derive(Debug, Subcommand)]
20pub(crate) enum UserCommand {
21 /// Create a user. Without `--pass` the account is created with no password
22 /// and cannot log in until one is set with `fabrica user passwd`.
23 Add {
24 /// Login name (validated: see the name rules).
25 name: String,
26 /// Primary email address.
27 email: String,
28 /// Set this password now instead of leaving the account inactive. Omit
29 /// the value to be prompted; omit the flag entirely to create an
30 /// inactive account.
31 #[arg(long)]
32 pass: Option<String>,
33 /// Grant site-administrator privileges.
34 #[arg(long)]
35 admin: bool,
36 /// Human-friendly display name.
37 #[arg(long = "display-name")]
38 display_name: Option<String>,
39 },
40 /// Set a user's password. With no `--pass`, prompts twice on a terminal, or
41 /// reads a single line from stdin when piped. Rotating a password logs the
42 /// user out of every existing session.
43 Passwd {
44 /// The user whose password to set.
45 name: String,
46 /// The new password. Omit to be prompted (or to read it from stdin).
47 #[arg(long)]
48 pass: Option<String>,
49 },
50 /// List all users.
51 List,
52 /// Disable a user: they can no longer log in, and existing sessions are
53 /// dropped.
54 Disable {
55 /// The user to disable.
56 name: String,
57 },
58 /// Re-enable a previously disabled user.
59 Enable {
60 /// The user to enable.
61 name: String,
62 },
63 /// Manually mark a user's primary email verified (for when SMTP is not set
64 /// up, or to vouch for an account).
65 Verify {
66 /// The user to verify.
67 name: String,
68 },
69 /// Grant (or, with `--revoke`, remove) a user's site-administrator flag.
70 Admin {
71 /// The user to promote or demote.
72 name: String,
73 /// Revoke admin instead of granting it.
74 #[arg(long)]
75 revoke: bool,
76 },
77 /// Delete a user. Refuses if the user still owns repositories unless
78 /// `--purge` is given.
79 Del {
80 /// The user to delete.
81 name: String,
82 /// Also delete every repository the user owns.
83 #[arg(long)]
84 purge: bool,
85 /// Skip the confirmation prompt (required when stdin is not a terminal).
86 #[arg(long)]
87 yes: bool,
88 },
89}
90
91/// Dispatch a `user` subcommand, blocking on the async store work.
92pub(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/// Hash a plaintext password with the instance's configured Argon2id cost.
123fn 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/// Look a user up by name, turning "not found" into a clean error.
133async 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
140async 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 // A password is set eagerly only when `--pass` is present. `--pass` with no
151 // value prompts; the flag being absent leaves the account inactive.
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 // No password was set, so mint an activation invite, try to email it, and
180 // always print the link so an operator can deliver it when SMTP is off.
181 send_activation(&config, &store, &user).await?;
182 }
183 Ok(ExitCode::SUCCESS)
184}
185
186/// Mint an activation invite for `user`, attempt to email it (best-effort), and
187/// print the activation URL to stdout regardless.
188async fn send_activation(config: &Config, store: &Store, user: &User) -> anyhow::Result<()> {
189 // The emailed token is opaque 32-byte randomness; only its SHA-256 is stored.
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/// The current time in Unix milliseconds.
231fn 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
238async 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
252async 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
285async 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/// Manually mark a user's primary email verified.
302async 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/// Grant or revoke a user's site-administrator flag.
317async 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
341async 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 // Cascades to the user's repos, keys, tokens, and sessions in the database.
382 // On-disk bare repositories are not yet created by `repo add`, so there is
383 // nothing to remove from the filesystem here; that cleanup lands with the
384 // git-storage phases.
385 store.delete_user(&user.id).await?;
386 println!("deleted user {}", user.username);
387 Ok(ExitCode::SUCCESS)
388}
389
390/// The single-character-free flag labels shown by `user list`.
391fn 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/// Render a user for `--json`, deliberately exposing only whether a password is
409/// set (never the hash).
410fn 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/// The plural suffix `y`/`ies` helper for "repositor{y,ies}".
426fn plural(n: usize) -> &'static str {
427 if n == 1 { "y" } else { "ies" }
428}
429
430#[cfg(test)]
431mod 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 /// A temp directory holding a SQLite database and a config file that points
442 /// at it, so the handlers can run end-to-end with no external services.
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 /// The `--config` argument the handlers expect: `Some(path)`. The
471 /// `Option` is intrinsic to the handler signature, not incidental.
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 // The SMTP-disabled fallback: set the password directly.
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 // Give Dave a repo directly through the store.
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 // Without --purge the delete is refused and the user survives.
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 // With --purge (and --yes to skip the prompt) the user and repo go.
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 // Create the database (via one successful command) first.
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}