Skip to main content

kasl_server/
admin.rs

1//! Managing people and the agents that report for them.
2//!
3//! Everything here is behind a session (ADR 0007) and a role. Two rules shape
4//! the routes, both settled before they were written (ADR 0008):
5//!
6//! * **A manager reads, an administrator writes.** Until departments exist
7//!   there is nothing to scope a manager's authority to, and handing out the
8//!   power to issue agent tokens company-wide - with no audit log yet to notice
9//!   - is not a default worth shipping.
10//! * **A token is shown once.** The server keeps its SHA-256 and nothing else,
11//!   so an issued token that was not written down is replaced, not recovered.
12
13use axum::{
14    Json,
15    extract::{Path, State},
16    http::StatusCode,
17    response::IntoResponse,
18};
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::{app::AppState, audit, auth::hash_token, error::ApiError, login::CurrentUser, model::UserRole, session};
24
25/// The shortest password the server will store.
26///
27/// A floor, not a policy: complexity rules belong where they can be explained
28/// to the person typing, and a server that refuses `hunter2` while accepting
29/// `Passw0rd!` has chosen theatre over arithmetic.
30const MIN_PASSWORD_LENGTH: usize = 8;
31
32/// A person as the admin screens list them.
33#[derive(Debug, Serialize, sqlx::FromRow)]
34pub struct UserRow {
35    pub id: Uuid,
36    pub email: String,
37    pub display_name: String,
38    pub role: UserRole,
39    pub active: bool,
40    /// Whether they can sign in at all. Accounts created for an agent have no
41    /// password, and the UI needs to show that rather than imply a login that
42    /// does not exist.
43    pub has_password: bool,
44    /// Agents currently able to report for them.
45    pub agents: i64,
46    /// The most recent moment any of their agents was heard from.
47    pub last_seen_at: Option<DateTime<Utc>>,
48    pub department_id: Option<Uuid>,
49    /// Carried alongside the id so a list can be rendered without a second
50    /// request per row.
51    pub department: Option<String>,
52    pub created_at: DateTime<Utc>,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct NewUser {
57    pub email: String,
58    pub display_name: Option<String>,
59    #[serde(default = "default_role")]
60    pub role: UserRole,
61    /// Initial password. Optional: an account that only owns an agent's data
62    /// has no reason to be signed into.
63    pub password: Option<String>,
64}
65
66fn default_role() -> UserRole {
67    UserRole::Employee
68}
69
70/// A change to an existing person. Every field is optional; absent means
71/// "leave it alone" rather than "clear it".
72#[derive(Debug, Deserialize)]
73pub struct UserPatch {
74    pub display_name: Option<String>,
75    pub role: Option<UserRole>,
76    pub active: Option<bool>,
77    /// Sets or replaces the password. There is no way to remove one here: an
78    /// account that could be signed into yesterday and silently cannot today is
79    /// a support call, not a feature.
80    pub password: Option<String>,
81}
82
83/// An agent as the admin screens list it. Never the token.
84#[derive(Debug, Serialize, sqlx::FromRow)]
85pub struct AgentRow {
86    pub id: Uuid,
87    pub name: String,
88    pub revoked_at: Option<DateTime<Utc>>,
89    pub last_seen_at: Option<DateTime<Utc>>,
90    pub created_at: DateTime<Utc>,
91}
92
93#[derive(Debug, Deserialize)]
94pub struct NewAgent {
95    /// Human label, typically the machine the agent runs on.
96    pub name: String,
97}
98
99/// The one response that carries a token, and the only time it exists.
100#[derive(Debug, Serialize)]
101pub struct IssuedAgent {
102    pub id: Uuid,
103    pub name: String,
104    pub token: String,
105    /// Said plainly in the payload, because the UI that forgets to say it is
106    /// the reason someone loses a token they never wrote down.
107    pub notice: &'static str,
108}
109
110/// Lists everyone. The one route a manager may call.
111/// Lists the people the caller may see.
112///
113/// An administrator sees everyone. A manager sees the departments they run,
114/// plus themselves - a manager who could not find their own row would think
115/// the page was broken. Someone with no department is visible to the admin
116/// alone: an unfiled person is noticed at once because they are missing from
117/// every manager's list, whereas showing them to every manager would be a leak
118/// nobody sees happening (ADR 0009).
119pub async fn list_users(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
120    require_manager_or_admin(&user)?;
121
122    // `AssertSqlSafe` because the only interpolation is `VISIBLE_USERS`, a
123    // constant in this file; everything from the request is still bound.
124    let users: Vec<UserRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
125        "SELECT u.id, u.email, u.display_name, u.role, u.active,
126                (u.password_hash IS NOT NULL) AS has_password,
127                (SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents,
128                (SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
129                u.department_id,
130                d.name AS department,
131                u.created_at
132         FROM users u
133         LEFT JOIN departments d ON d.id = u.department_id
134         WHERE {VISIBLE_USERS}
135         ORDER BY u.display_name, u.email"
136    )))
137    .bind(user.role == UserRole::Admin)
138    .bind(user.user_id)
139    .fetch_all(&state.pool)
140    .await?;
141
142    Ok(Json(users))
143}
144
145/// Creates a person.
146pub async fn create_user(State(state): State<AppState>, user: CurrentUser, Json(new): Json<NewUser>) -> Result<impl IntoResponse, ApiError> {
147    user.require_admin()?;
148
149    let email = new.email.trim();
150    if !looks_like_an_email(email) {
151        return Err(ApiError::bad_request("that does not look like an email address"));
152    }
153    let password_hash = match new.password.as_deref() {
154        Some(password) => Some(hash_new_password(password)?),
155        None => None,
156    };
157    // The local part until someone sets a real one - the same default the
158    // environment-seeded accounts get, so the two look alike in a list.
159    let display_name = new
160        .display_name
161        .as_deref()
162        .map(str::trim)
163        .filter(|name| !name.is_empty())
164        .unwrap_or_else(|| email.split('@').next().unwrap_or(email));
165
166    let created: Result<Uuid, sqlx::Error> =
167        sqlx::query_scalar("INSERT INTO users (email, display_name, role, password_hash) VALUES ($1, $2, $3, $4) RETURNING id")
168            .bind(email)
169            .bind(display_name)
170            .bind(new.role)
171            .bind(password_hash.as_deref())
172            .fetch_one(&state.pool)
173            .await;
174
175    let id = match created {
176        Ok(id) => id,
177        // The unique index on lower(email). Answered as a conflict rather than
178        // a 500: the admin typed an address that is already here, which is
179        // something they can act on.
180        Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
181            return Err(ApiError::new(StatusCode::CONFLICT, "someone with that email address already exists"));
182        }
183        Err(error) => return Err(error.into()),
184    };
185
186    tracing::info!(%id, by = %user.user_id, "created a user");
187    audit::Entry::new(audit::action::USER_CREATED)
188        .by(user.user_id)
189        .by_email(&user.email)
190        .on(id)
191        .labelled(email)
192        .with(serde_json::json!({"role": new.role, "with_password": password_hash.is_some()}))
193        .record(&state.pool)
194        .await;
195
196    Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))))
197}
198
199/// Changes a person: their name, role, password, or whether they are active.
200pub async fn update_user(
201    State(state): State<AppState>,
202    user: CurrentUser,
203    Path(target): Path<Uuid>,
204    Json(patch): Json<UserPatch>,
205) -> Result<impl IntoResponse, ApiError> {
206    user.require_admin()?;
207
208    // The last administrator cannot be demoted or deactivated. Both would leave
209    // an installation nobody can administer, recoverable only by running the
210    // `admin` subcommand on the host - which is not where an admin who just
211    // clicked a toggle in a browser will think to look.
212    let losing_admin = patch.role.is_some_and(|role| role != UserRole::Admin) || patch.active == Some(false);
213    if losing_admin && is_last_admin(&state.pool, target).await? {
214        return Err(ApiError::new(
215            StatusCode::CONFLICT,
216            "this is the only administrator; promote someone else first",
217        ));
218    }
219
220    let password_hash = match patch.password.as_deref() {
221        Some(password) => Some(hash_new_password(password)?),
222        None => None,
223    };
224
225    let updated = sqlx::query(
226        "UPDATE users SET
227             display_name = coalesce($2, display_name),
228             role = coalesce($3, role),
229             active = coalesce($4, active),
230             password_hash = coalesce($5, password_hash)
231         WHERE id = $1",
232    )
233    .bind(target)
234    .bind(patch.display_name.as_deref().map(str::trim).filter(|name| !name.is_empty()))
235    .bind(patch.role)
236    .bind(patch.active)
237    .bind(password_hash.as_deref())
238    .execute(&state.pool)
239    .await?
240    .rows_affected();
241
242    if updated == 0 {
243        return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
244    }
245
246    // Deactivation and a password change both mean the old sessions should not
247    // survive: one is someone leaving, the other is usually a suspicion that
248    // someone else has been signing in.
249    if patch.active == Some(false) || patch.password.is_some() {
250        let ended = session::revoke_all(&state.pool, target).await?;
251        tracing::info!(%target, ended, "ended sessions after a change to the account");
252    }
253
254    tracing::info!(%target, by = %user.user_id, "updated a user");
255    // The fields that were touched, never their values: a password reset is
256    // worth recording, the password is not.
257    audit::Entry::new(audit::action::USER_UPDATED)
258        .by(user.user_id)
259        .by_email(&user.email)
260        .on(target)
261        .with(serde_json::json!({
262            "display_name": patch.display_name.is_some(),
263            "role": patch.role,
264            "active": patch.active,
265            "password_reset": patch.password.is_some(),
266        }))
267        .record(&state.pool)
268        .await;
269
270    Ok(StatusCode::NO_CONTENT)
271}
272
273/// Lists someone's agents, revoked ones included - a withdrawn token is part of
274/// the record of what happened.
275pub async fn list_agents(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
276    require_manager_or_admin(&user)?;
277
278    let agents: Vec<AgentRow> = sqlx::query_as("SELECT id, name, revoked_at, last_seen_at, created_at FROM agents WHERE user_id = $1 ORDER BY created_at")
279        .bind(target)
280        .fetch_all(&state.pool)
281        .await?;
282
283    Ok(Json(agents))
284}
285
286/// Issues an agent token, shown once.
287pub async fn create_agent(
288    State(state): State<AppState>,
289    user: CurrentUser,
290    Path(target): Path<Uuid>,
291    Json(new): Json<NewAgent>,
292) -> Result<impl IntoResponse, ApiError> {
293    user.require_admin()?;
294
295    let name = new.name.trim();
296    if name.is_empty() {
297        return Err(ApiError::bad_request("an agent needs a name; the machine it runs on is the usual one"));
298    }
299
300    // An agent for a deactivated person would be refused on its first upload
301    // anyway; saying so here saves someone installing kasl on a laptop to find
302    // out.
303    let active: Option<bool> = sqlx::query_scalar("SELECT active FROM users WHERE id = $1")
304        .bind(target)
305        .fetch_optional(&state.pool)
306        .await?;
307    match active {
308        None => return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user")),
309        Some(false) => return Err(ApiError::new(StatusCode::CONFLICT, "that account is deactivated")),
310        Some(true) => {}
311    }
312
313    let token = generate_token();
314    let id: Uuid = sqlx::query_scalar("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3) RETURNING id")
315        .bind(target)
316        .bind(name)
317        .bind(hash_token(&token))
318        .fetch_one(&state.pool)
319        .await?;
320
321    tracing::info!(%id, %target, by = %user.user_id, "issued an agent token");
322    // The token itself is never recorded - this table is read in a UI and
323    // pasted into tickets.
324    audit::Entry::new(audit::action::AGENT_ISSUED)
325        .by(user.user_id)
326        .by_email(&user.email)
327        .on(id)
328        .labelled(name)
329        .with(serde_json::json!({"user_id": target}))
330        .record(&state.pool)
331        .await;
332
333    Ok((
334        StatusCode::CREATED,
335        Json(IssuedAgent {
336            id,
337            name: name.to_string(),
338            token,
339            notice: "this token is shown once; the server keeps only its hash",
340        }),
341    ))
342}
343
344/// Withdraws an agent's token. The row stays, so its uploads keep an owner.
345pub async fn revoke_agent(State(state): State<AppState>, user: CurrentUser, Path(agent): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
346    user.require_admin()?;
347
348    // `revoked_at IS NULL` in the filter makes this idempotent without pretending
349    // it succeeded twice: revoking an already-revoked agent must not move the
350    // timestamp of when access actually ended.
351    let revoked = sqlx::query("UPDATE agents SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL")
352        .bind(agent)
353        .execute(&state.pool)
354        .await?
355        .rows_affected();
356
357    if revoked == 0 {
358        // Either it does not exist or it was already revoked; both mean the
359        // token does not work, which is what the caller wanted.
360        let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM agents WHERE id = $1")
361            .bind(agent)
362            .fetch_optional(&state.pool)
363            .await?;
364        if exists.is_none() {
365            return Err(ApiError::new(StatusCode::NOT_FOUND, "no such agent"));
366        }
367    }
368
369    tracing::info!(%agent, by = %user.user_id, "revoked an agent token");
370    audit::Entry::new(audit::action::AGENT_REVOKED)
371        .by(user.user_id)
372        .by_email(&user.email)
373        .on(agent)
374        .with(serde_json::json!({"already_revoked": revoked == 0}))
375        .record(&state.pool)
376        .await;
377
378    Ok(StatusCode::NO_CONTENT)
379}
380
381/// Changes one's own password.
382///
383/// Not an admin route: this is how someone stops the admin who set their
384/// initial password from knowing it.
385#[derive(Debug, Deserialize)]
386pub struct PasswordChange {
387    pub current: String,
388    pub new: String,
389}
390
391pub async fn change_own_password(State(state): State<AppState>, user: CurrentUser, Json(change): Json<PasswordChange>) -> Result<impl IntoResponse, ApiError> {
392    let stored: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
393        .bind(user.user_id)
394        .fetch_one(&state.pool)
395        .await?;
396
397    // Proving the current password is what stops a borrowed unlocked laptop
398    // from becoming a permanent one.
399    let Some(stored) = stored else {
400        return Err(ApiError::new(StatusCode::CONFLICT, "this account has no password to change"));
401    };
402    if !session::verify_password(&change.current, &stored) {
403        return Err(ApiError::new(StatusCode::UNAUTHORIZED, "the current password is wrong"));
404    }
405
406    let hash = hash_new_password(&change.new)?;
407    sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
408        .bind(&hash)
409        .bind(user.user_id)
410        .execute(&state.pool)
411        .await?;
412
413    // Every other session ends, and this one survives: changing a password is
414    // how someone reacts to a suspicion, and being logged out of the browser
415    // they just did it in would be a poor reward.
416    sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND id <> $2")
417        .bind(user.user_id)
418        .bind(user.session_id)
419        .execute(&state.pool)
420        .await?;
421
422    tracing::info!(user_id = %user.user_id, "changed their password");
423    audit::Entry::new(audit::action::PASSWORD_CHANGED)
424        .by(user.user_id)
425        .by_email(&user.email)
426        .on(user.user_id)
427        .record(&state.pool)
428        .await;
429
430    Ok(StatusCode::NO_CONTENT)
431}
432
433/// Both roles that may read the team.
434pub fn require_manager_or_admin(user: &CurrentUser) -> Result<(), ApiError> {
435    match user.role {
436        UserRole::Admin | UserRole::Manager => Ok(()),
437        UserRole::Employee => Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed")),
438    }
439}
440
441/// The `WHERE` clause deciding whose rows a reader may see, as one string.
442///
443/// Written once and pasted into every query that answers for other people:
444/// three copies of this rule would be three chances for one of them to widen
445/// quietly, and a visibility bug is invisible to the person it leaks to.
446///
447/// Takes two binds, in order: whether the reader is an administrator, and their
448/// own id. An unfiled person - no department - is deliberately visible to the
449/// administrator alone (ADR 0009).
450pub const VISIBLE_USERS: &str = "($1 OR u.id = $2 OR u.department_id IN (SELECT id FROM departments WHERE manager_id = $2))";
451
452/// Hashes a password after checking it is long enough to be one.
453fn hash_new_password(password: &str) -> Result<String, ApiError> {
454    if password.chars().count() < MIN_PASSWORD_LENGTH {
455        return Err(ApiError::bad_request(format!("the password must be at least {MIN_PASSWORD_LENGTH} characters")));
456    }
457    session::hash_password(password).map_err(Into::into)
458}
459
460/// A new agent token: 32 bytes of entropy, hex.
461///
462/// Prefixed so that a token found in a log or a config file is recognisable as
463/// one, and so a leaked-secret scanner has something to match on.
464fn generate_token() -> String {
465    use rand::RngExt;
466
467    let bytes: [u8; 32] = rand::rng().random();
468    bytes.iter().fold(String::from("kasl_"), |mut acc, byte| {
469        use std::fmt::Write;
470        let _ = write!(acc, "{byte:02x}");
471        acc
472    })
473}
474
475/// The shallowest possible check: an `@` with something either side.
476///
477/// Deliberately not a full grammar. The addresses here are typed by an admin
478/// who knows their own team, and a regex strict enough to be interesting is
479/// strict enough to reject somebody's real address.
480fn looks_like_an_email(candidate: &str) -> bool {
481    match candidate.split_once('@') {
482        Some((local, domain)) => !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'),
483        None => false,
484    }
485}
486
487/// Whether this user is the only administrator left standing.
488async fn is_last_admin(pool: &sqlx::PgPool, target: Uuid) -> Result<bool, ApiError> {
489    let others: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE role = 'admin' AND active AND id <> $1")
490        .bind(target)
491        .fetch_one(pool)
492        .await?;
493
494    // Only matters if the target is an active admin themselves; demoting an
495    // employee while zero admins exist is a different problem and not this
496    // check's business.
497    let is_admin: Option<bool> = sqlx::query_scalar("SELECT (role = 'admin' AND active) FROM users WHERE id = $1")
498        .bind(target)
499        .fetch_optional(pool)
500        .await?;
501
502    Ok(is_admin.unwrap_or(false) && others == 0)
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn a_token_is_long_random_and_recognisable() {
511        let token = generate_token();
512        assert!(token.starts_with("kasl_"), "a token in a log should be identifiable: {token}");
513        assert_eq!(token.len(), 5 + 64, "32 bytes as hex");
514        assert_ne!(token, generate_token(), "two tokens must never be the same");
515    }
516
517    #[test]
518    fn a_short_password_is_refused_before_it_is_hashed() {
519        let error = hash_new_password("short").expect_err("seven characters is not a password");
520        assert!(error.to_string().contains("at least 8"), "{error}");
521        assert!(hash_new_password("just long enough").is_ok());
522    }
523
524    #[test]
525    fn the_email_check_admits_addresses_and_refuses_obvious_mistakes() {
526        for good in ["a@b.co", "first.last@example.com", "kirill+kasl@example.co.uk"] {
527            assert!(looks_like_an_email(good), "{good} should be accepted");
528        }
529        // What an admin actually mistypes: a name, a missing domain, a stray
530        // trailing dot from a copied sentence.
531        for bad in ["kirill", "kirill@", "@example.com", "kirill@example", "kirill@example.com."] {
532            assert!(!looks_like_an_email(bad), "{bad} should be refused");
533        }
534    }
535
536    #[test]
537    fn a_manager_reads_and_an_employee_does_not() {
538        let user = |role| CurrentUser {
539            session_id: Uuid::nil(),
540            user_id: Uuid::nil(),
541            role,
542            email: "someone@example.test".to_string(),
543        };
544        assert!(require_manager_or_admin(&user(UserRole::Admin)).is_ok());
545        assert!(require_manager_or_admin(&user(UserRole::Manager)).is_ok());
546        assert!(require_manager_or_admin(&user(UserRole::Employee)).is_err());
547
548        // And reading is all a manager gets in this version.
549        assert!(user(UserRole::Manager).require_admin().is_err());
550    }
551
552    #[test]
553    fn an_absent_patch_field_means_leave_it_alone() {
554        // `coalesce($n, column)` in the UPDATE relies on this: a field the admin
555        // did not send must arrive as None, not as a default that clears it.
556        let patch: UserPatch = serde_json::from_value(serde_json::json!({"display_name": "Kirill"})).unwrap();
557        assert_eq!(patch.display_name.as_deref(), Some("Kirill"));
558        assert!(patch.role.is_none() && patch.active.is_none() && patch.password.is_none());
559    }
560
561    #[test]
562    fn a_new_user_defaults_to_the_least_authority() {
563        let new: NewUser = serde_json::from_value(serde_json::json!({"email": "a@b.co"})).unwrap();
564        assert_eq!(new.role, UserRole::Employee, "a role must be asked for, never assumed");
565        assert!(new.password.is_none(), "an account for an agent needs no password");
566    }
567}