Add user-admin CLI for password resets and account management

8b74183 · Gabriel Morell · 2026-08-25 15:49

11 files +416 -3
Message
{commit_body(@commit)}

Files changed

modified Cargo.lock
+23 −0
@@ -1314,6 +1314,7 @@ dependencies = [
1314 1314 "image",
1315 1315 "lapin",
1316 1316 "log",
1317 + "metrics",
1317 1318 "metrics-process",
1318 1319 "migration",
1319 1320 "mime",
@@ -1323,6 +1324,7 @@ dependencies = [
1323 1324 "ocrs",
1324 1325 "qrcode",
1325 1326 "reqwest",
1327 + "rpassword",
1326 1328 "rten",
1327 1329 "rten-imageproc",
1328 1330 "sea-orm",
@@ -3727,6 +3729,17 @@ dependencies = [
3727 3729 "libc",
3728 3730 ]
3729 3731
3732 +[[package]]
3733 +name = "rpassword"
3734 +version = "7.5.4"
3735 +source = "registry+https://github.com/rust-lang/crates.io-index"
3736 +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
3737 +dependencies = [
3738 + "libc",
3739 + "rtoolbox",
3740 + "windows-sys 0.61.1",
3741 +]
3742 +
3730 3743 [[package]]
3731 3744 name = "rsa"
3732 3745 version = "0.9.8"
@@ -3823,6 +3836,16 @@ dependencies = [
3823 3836 "rten-simd",
3824 3837 ]
3825 3838
3839 +[[package]]
3840 +name = "rtoolbox"
3841 +version = "0.0.5"
3842 +source = "registry+https://github.com/rust-lang/crates.io-index"
3843 +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
3844 +dependencies = [
3845 + "libc",
3846 + "windows-sys 0.59.0",
3847 +]
3848 +
3826 3849 [[package]]
3827 3850 name = "rust_decimal"
3828 3851 version = "1.38.0"
modified Dockerfile
+4 −0
@@ -52,10 +52,12 @@ RUN cargo build --release \
52 52 --bin webservice \
53 53 --bin queue-worker \
54 54 --bin registration-token \
55 + --bin user-admin \
55 56 && mkdir -p /out \
56 57 && cp target/release/webservice /out/ \
57 58 && cp target/release/queue-worker /out/ \
58 59 && cp target/release/registration-token /out/ \
60 + && cp target/release/user-admin /out/ \
59 61 && rm -rf target
60 62
61 63 # ---------------------------------------------------------------------------
@@ -87,6 +89,7 @@ WORKDIR /app
87 89 COPY --from=rust-builder /out/webservice /usr/local/bin/dms-webservice
88 90 COPY --from=rust-builder /out/queue-worker /usr/local/bin/dms-queue-worker
89 91 COPY --from=rust-builder /out/registration-token /usr/local/bin/dms-registration-token
92 +COPY --from=rust-builder /out/user-admin /usr/local/bin/dms-user-admin
90 93 COPY --from=assets /app/assets/f /app/assets/f
91 94 COPY --chown=dms:dms templates /app/templates
92 95 # OCR models bundled at a fixed image path — read-only and tied to the app
@@ -110,4 +113,5 @@ VOLUME ["/data"]
110 113 # `docker run … dms-webservice` → web (default)
111 114 # `docker run … dms-queue-worker --kind ocr` → categorizer
112 115 # `docker run … dms-registration-token …` → CLI
116 +# `docker run … dms-user-admin …` → user management CLI
113 117 CMD ["dms-webservice"]
modified Justfile
+13 −0
@@ -63,6 +63,14 @@ ocr-models:
63 63 invite days="7":
64 64 cargo run --bin registration-token -- create --days {{days}}
65 65
66 +# Manage users. Usage: just user list | just user "show alice" | just user "reset-password alice --generate"
67 +user *args:
68 + cargo run --bin user-admin -- {{args}}
69 +
70 +# Reset a user's password, prompting for the new one. Usage: just reset-password alice
71 +reset-password user:
72 + cargo run --bin user-admin -- reset-password {{user}}
73 +
66 74 # Lint + test (unit only)
67 75 check:
68 76 cargo check --bins --lib
@@ -90,3 +98,8 @@ logs:
90 98 # Bootstrap an invite token from inside the running webservice container.
91 99 invite-in-pod days="7":
92 100 podman compose exec webservice dms-registration-token create --days {{days}}
101 +
102 +# Manage users inside the running webservice container.
103 +# Usage: just user-in-pod "reset-password alice --generate"
104 +user-in-pod *args:
105 + podman compose exec webservice dms-user-admin {{args}}
modified README.md
+24 −0
@@ -37,6 +37,7 @@ replicas.
37 37 | `src/bin/webservice.rs` | HTTP + HTML server (axum + minijinja). |
38 38 | `src/bin/queue-worker.rs` | Categorizer worker. Picks one kind via `WORKER_KIND` env / `--kind`. |
39 39 | `src/bin/registration-token.rs` | CLI for generating invite tokens out of band. |
40 +| `src/bin/user-admin.rs` | CLI for managing users — password resets, activate/deactivate. |
40 41 | `src/queue/` | RabbitMQ topology (per-kind queues + DLX), publish/consume, typed `JobMessage`. |
41 42 | `src/storage/` | `Storage` trait + content-addressed `LocalStorage` (sha256 fan-out). |
42 43 | `src/workers/` | Concrete categorizers — thumbnail (image crate), OCR (ocrs), describe (pluggable), extract_tags. |
@@ -132,6 +133,29 @@ Public registration is off by default. To onboard a user:
132 133 2. Or out-of-band: `just invite` (or `just invite-in-pod` for the
133 134 containerized variant) to mint a token via the CLI.
134 135
136 +### Managing existing users
137 +
138 +There is no self-service password reset, so recovery is a CLI operation.
139 +`user-admin` takes a username, email, or UUID interchangeably:
140 +
141 +```bash
142 +just user list # who exists
143 +just user "show alice"
144 +just user "reset-password alice --generate" # random password, printed once
145 +just user "reset-password alice" # prompt for it (hidden)
146 +just user "deactivate alice" # revokes sessions too
147 +just user "activate alice"
148 +```
149 +
150 +Resetting a password revokes every active session for that account, so a
151 +compromised login is cut off immediately. Prefer `--generate` or the prompt
152 +over `--password`: an argument is visible in shell history and `ps`. The
153 +password can also be piped in for scripted use — `echo "$PW" | user-admin
154 +reset-password alice`.
155 +
156 +Inside the running container the binary is `dms-user-admin`; `just user-in-pod
157 +"reset-password alice --generate"` wraps `podman compose exec`.
158 +
135 159 ## Search
136 160
137 161 Two surfaces, same engine.
modified src/auth/mod.rs
+2 −1
@@ -16,5 +16,6 @@ pub use tokens::{
16 16 pub use users::{
17 17 CreateUserRequest, LoginRequest,
18 18 create_user, authenticate_user, get_user_by_id, update_user,
19 − change_password, deactivate_user,
19 + change_password, reset_password, find_user_by_identifier, list_users,
20 + activate_user, deactivate_user,
20 21 };
modified src/auth/password.rs
+18 −2
@@ -1,11 +1,14 @@
1 1 use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
2 2 use argon2::password_hash::SaltString;
3 +use argon2::password_hash::rand_core::OsRng;
3 4 use anyhow::{Context, Result};
4 5
5 6 /// Hash a password using Argon2
6 7 pub fn hash_password(password: &str) -> Result<String> {
7 − // Use a simple salt for now - in production you'd want proper random salt generation
8 − let salt = SaltString::encode_b64(&[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]).map_err(|e| anyhow::anyhow!("Failed to generate salt: {}", e))?;
8 + // Fresh random salt per hash. The salt is embedded in the returned PHC
9 + // string, so `verify_password` keeps working against older hashes.
10 + let salt = SaltString::try_from_rng(&mut OsRng)
11 + .map_err(|e| anyhow::anyhow!("Failed to generate salt: {}", e))?;
9 12 let argon2 = Argon2::default();
10 13 let password_hash = argon2
11 14 .hash_password(password.as_bytes(), &salt)
@@ -40,4 +43,17 @@ mod tests {
40 43 // Verify incorrect password
41 44 assert!(!verify_password("wrong_password", &hash).unwrap());
42 45 }
46 +
47 + #[test]
48 + fn test_hashes_are_salted_per_call() {
49 + let password = "test_password_123";
50 + let a = hash_password(password).expect("Failed to hash password");
51 + let b = hash_password(password).expect("Failed to hash password");
52 +
53 + // Two hashes of the same password must not collide, or the hashes
54 + // leak which accounts share a password.
55 + assert_ne!(a, b);
56 + assert!(verify_password(password, &a).unwrap());
57 + assert!(verify_password(password, &b).unwrap());
58 + }
43 59 }
modified src/auth/users.rs
+85 −0
@@ -208,6 +208,91 @@ pub async fn change_password(
208 208 Ok(())
209 209 }
210 210
211 +/// Administratively reset a user's password without knowing the old one.
212 +///
213 +/// Unlike [`change_password`] this is for out-of-band recovery (CLI), so it
214 +/// revokes every active session to lock out whoever may have had access.
215 +/// Returns the number of sessions that were revoked.
216 +pub async fn reset_password(
217 + db: &DatabaseConnection,
218 + user_id: Uuid,
219 + new_password: &str,
220 +) -> Result<u64> {
221 + let user = get_user_by_id(db, user_id)
222 + .await?
223 + .ok_or_else(|| anyhow::anyhow!("User not found"))?;
224 +
225 + let new_password_hash = password::hash_password(new_password)
226 + .context("Failed to hash new password")?;
227 +
228 + let mut user_update: users::ActiveModel = user.into();
229 + user_update.password_hash = Set(new_password_hash);
230 + user_update.updated_at = Set(Utc::now().into());
231 +
232 + user_update
233 + .update(db)
234 + .await
235 + .context("Failed to update password")?;
236 +
237 + let revoked = user_sessions::Entity::delete_many()
238 + .filter(user_sessions::Column::UserId.eq(user_id))
239 + .exec(db)
240 + .await
241 + .context("Failed to revoke user sessions")?;
242 +
243 + Ok(revoked.rows_affected)
244 +}
245 +
246 +/// Look a user up by UUID, username, or email — whichever the identifier
247 +/// happens to be. Lets admin tooling take whatever the operator has on hand.
248 +pub async fn find_user_by_identifier(
249 + db: &DatabaseConnection,
250 + identifier: &str,
251 +) -> Result<Option<users::Model>> {
252 + if let Ok(id) = Uuid::parse_str(identifier) {
253 + if let Some(user) = get_user_by_id(db, id).await? {
254 + return Ok(Some(user));
255 + }
256 + }
257 +
258 + users::Entity::find()
259 + .filter(
260 + Condition::any()
261 + .add(users::Column::Username.eq(identifier))
262 + .add(users::Column::Email.eq(identifier)),
263 + )
264 + .one(db)
265 + .await
266 + .context("Failed to query user")
267 +}
268 +
269 +/// List all users, newest first.
270 +pub async fn list_users(db: &DatabaseConnection) -> Result<Vec<users::Model>> {
271 + users::Entity::find()
272 + .order_by_desc(users::Column::CreatedAt)
273 + .all(db)
274 + .await
275 + .context("Failed to list users")
276 +}
277 +
278 +/// Reactivate a previously deactivated account
279 +pub async fn activate_user(db: &DatabaseConnection, user_id: Uuid) -> Result<()> {
280 + let user = get_user_by_id(db, user_id)
281 + .await?
282 + .ok_or_else(|| anyhow::anyhow!("User not found"))?;
283 +
284 + let mut user_update: users::ActiveModel = user.into();
285 + user_update.is_active = Set(true);
286 + user_update.updated_at = Set(Utc::now().into());
287 +
288 + user_update
289 + .update(db)
290 + .await
291 + .context("Failed to activate user")?;
292 +
293 + Ok(())
294 +}
295 +
211 296 /// Deactivate user account
212 297 pub async fn deactivate_user(db: &DatabaseConnection, user_id: Uuid) -> Result<()> {
213 298 let user = get_user_by_id(db, user_id)
added src/bin/user-admin.rs
+247 −0
@@ -0,0 +1,247 @@
1 +use clap::{Parser, Subcommand};
2 +use dms::{
3 + auth,
4 + config::{Config, create_db_connection},
5 + entities::users,
6 +};
7 +use sea_orm::DatabaseConnection;
8 +use std::sync::Arc;
9 +
10 +#[derive(Parser)]
11 +#[command(author, version, about, long_about = None)]
12 +#[command(name = "user-admin")]
13 +#[command(about = "Manage DMS user accounts from the command line")]
14 +struct Cli {
15 + #[command(subcommand)]
16 + command: Commands,
17 +}
18 +
19 +#[derive(Subcommand)]
20 +enum Commands {
21 + /// List all users
22 + List,
23 + /// Show detailed information about a user
24 + Show {
25 + /// Username, email, or user UUID
26 + user: String,
27 + },
28 + /// Reset a user's password and revoke their sessions
29 + ResetPassword {
30 + /// Username, email, or user UUID
31 + user: String,
32 + /// New password. Prefer --generate or the interactive prompt: an
33 + /// argument lands in your shell history and the process table.
34 + #[arg(short, long, conflicts_with = "generate")]
35 + password: Option<String>,
36 + /// Generate a random password and print it
37 + #[arg(short, long)]
38 + generate: bool,
39 + },
40 + /// Reactivate a deactivated account
41 + Activate {
42 + /// Username, email, or user UUID
43 + user: String,
44 + },
45 + /// Deactivate an account and revoke its sessions
46 + Deactivate {
47 + /// Username, email, or user UUID
48 + user: String,
49 + },
50 +}
51 +
52 +/// Minimum length for an operator-supplied password. Deliberately modest —
53 +/// this is a recovery tool, not the registration form.
54 +const MIN_PASSWORD_LEN: usize = 8;
55 +
56 +#[tokio::main]
57 +async fn main() -> Result<(), Box<dyn std::error::Error>> {
58 + tracing_subscriber::fmt::init();
59 +
60 + let cli = Cli::parse();
61 +
62 + let config = Config::from_env()?;
63 + let db = Arc::new(create_db_connection(&config.database_url).await?);
64 +
65 + match cli.command {
66 + Commands::List => list_users(&db).await?,
67 + Commands::Show { user } => show_user(&db, &user).await?,
68 + Commands::ResetPassword {
69 + user,
70 + password,
71 + generate,
72 + } => reset_password(&db, &user, password, generate).await?,
73 + Commands::Activate { user } => set_active(&db, &user, true).await?,
74 + Commands::Deactivate { user } => set_active(&db, &user, false).await?,
75 + }
76 +
77 + Ok(())
78 +}
79 +
80 +/// Resolve an identifier to a user, or fail with a message naming what was
81 +/// looked up so the operator can spot a typo.
82 +async fn resolve(
83 + db: &DatabaseConnection,
84 + identifier: &str,
85 +) -> Result<users::Model, Box<dyn std::error::Error>> {
86 + auth::find_user_by_identifier(db, identifier)
87 + .await?
88 + .ok_or_else(|| format!("No user matching '{identifier}' (tried UUID, username, email)").into())
89 +}
90 +
91 +async fn list_users(db: &DatabaseConnection) -> Result<(), Box<dyn std::error::Error>> {
92 + let users = auth::list_users(db).await?;
93 +
94 + if users.is_empty() {
95 + println!("No users found.");
96 + return Ok(());
97 + }
98 +
99 + println!("Users:");
100 + println!("┌──────────────────────────────────────┬──────────────────┬────────────────────────────┬──────────┬─────────────────────┐");
101 + println!("│ ID │ Username │ Email │ Status │ Last login │");
102 + println!("├──────────────────────────────────────┼──────────────────┼────────────────────────────┼──────────┼─────────────────────┤");
103 +
104 + for user in users {
105 + let last_login = user
106 + .last_login_at
107 + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
108 + .unwrap_or_else(|| "Never".to_string());
109 +
110 + println!(
111 + "│ {:<36} │ {:<16} │ {:<26} │ {:<8} │ {:<19} │",
112 + user.id,
113 + truncate(&user.username, 16),
114 + truncate(&user.email, 26),
115 + if user.is_active { "Active" } else { "Inactive" },
116 + last_login
117 + );
118 + }
119 +
120 + println!("└──────────────────────────────────────┴──────────────────┴────────────────────────────┴──────────┴─────────────────────┘");
121 +
122 + Ok(())
123 +}
124 +
125 +async fn show_user(
126 + db: &DatabaseConnection,
127 + identifier: &str,
128 +) -> Result<(), Box<dyn std::error::Error>> {
129 + let user = resolve(db, identifier).await?;
130 +
131 + println!("User Details:");
132 + println!();
133 + println!("ID: {}", user.id);
134 + println!("Username: {}", user.username);
135 + println!("Email: {}", user.email);
136 + println!("Status: {}", if user.is_active { "Active" } else { "Inactive" });
137 + println!("Created: {}", user.created_at);
138 + println!("Updated: {}", user.updated_at);
139 + println!(
140 + "Last login: {}",
141 + user.last_login_at
142 + .map(|t| t.to_string())
143 + .unwrap_or_else(|| "Never".to_string())
144 + );
145 +
146 + Ok(())
147 +}
148 +
149 +async fn reset_password(
150 + db: &DatabaseConnection,
151 + identifier: &str,
152 + password: Option<String>,
153 + generate: bool,
154 +) -> Result<(), Box<dyn std::error::Error>> {
155 + let user = resolve(db, identifier).await?;
156 +
157 + // Resolve the new password before touching the database, so a mistyped
158 + // confirmation leaves the account untouched.
159 + let (new_password, generated) = match (password, generate) {
160 + (_, true) => (generate_password(), true),
161 + (Some(password), false) => (password, false),
162 + (None, false) => (prompt_password(&user.username)?, false),
163 + };
164 +
165 + if new_password.len() < MIN_PASSWORD_LEN {
166 + return Err(format!("Password must be at least {MIN_PASSWORD_LEN} characters").into());
167 + }
168 +
169 + let revoked = auth::reset_password(db, user.id, &new_password).await?;
170 +
171 + println!("✅ Password reset for {} ({})", user.username, user.email);
172 + if generated {
173 + println!();
174 + println!("Generated password: {new_password}");
175 + println!(" Give this to the user over a secure channel; it is not stored anywhere.");
176 + }
177 + println!();
178 + println!("Sessions revoked: {revoked}");
179 + if !user.is_active {
180 + println!("⚠️ This account is deactivated — the user cannot log in until you run:");
181 + println!(" user-admin activate {}", user.username);
182 + }
183 +
184 + Ok(())
185 +}
186 +
187 +async fn set_active(
188 + db: &DatabaseConnection,
189 + identifier: &str,
190 + active: bool,
191 +) -> Result<(), Box<dyn std::error::Error>> {
192 + let user = resolve(db, identifier).await?;
193 +
194 + if active {
195 + auth::activate_user(db, user.id).await?;
196 + println!("✅ Activated {} ({})", user.username, user.email);
197 + } else {
198 + auth::deactivate_user(db, user.id).await?;
199 + println!("✅ Deactivated {} ({}); sessions revoked", user.username, user.email);
200 + }
201 +
202 + Ok(())
203 +}
204 +
205 +/// Read the new password without putting it on the command line.
206 +///
207 +/// On a terminal, prompt twice with echo off so a typo can't lock the user
208 +/// out. When stdin is a pipe (scripts, `compose exec -T`) take a single line
209 +/// from it — there's nothing to echo and no second entry to compare against.
210 +fn prompt_password(username: &str) -> Result<String, Box<dyn std::error::Error>> {
211 + use std::io::{BufRead, IsTerminal};
212 +
213 + if !std::io::stdin().is_terminal() {
214 + let mut password = String::new();
215 + std::io::stdin().lock().read_line(&mut password)?;
216 + // Strip the line ending the pipe wrote; keep any other whitespace,
217 + // which may legitimately be part of the password.
218 + let password = password.trim_end_matches(['\r', '\n']).to_string();
219 + if password.is_empty() {
220 + return Err("No password on stdin".into());
221 + }
222 + return Ok(password);
223 + }
224 +
225 + let first = rpassword::prompt_password(format!("New password for {username}: "))?;
226 + let second = rpassword::prompt_password("Confirm password: ")?;
227 +
228 + if first != second {
229 + return Err("Passwords do not match".into());
230 + }
231 +
232 + Ok(first)
233 +}
234 +
235 +/// 32 hex chars (128 bits) from a v4 UUID — the same source of randomness the
236 +/// rest of the codebase uses for tokens.
237 +fn generate_password() -> String {
238 + uuid::Uuid::new_v4().simple().to_string()
239 +}
240 +
241 +fn truncate(s: &str, width: usize) -> String {
242 + if s.chars().count() <= width {
243 + s.to_string()
244 + } else {
245 + s.chars().take(width.saturating_sub(1)).chain(['…']).collect()
246 + }
247 +}

Parents: 7ddc104