Skip to main content

teamcraft/services/
deactivation.rs

1//! Deactivation as an offboarding step (issue #173, step 4): an administrator
2//! deactivates a leaver and, in the same step, chooses what happens to what
3//! they still hold (see `services::offboarding::holdings`):
4//!
5//! * hand their work over to a successor (`ownership::hand_over_in`: owned
6//!   items, draft and rejected change orders, open change requests assigned
7//!   to them, pending reviews) – and their **manager role**: the successor
8//!   becomes a manager of every group the leaver managed;
9//! * revoke (delete) their API tokens, so activating the account again does
10//!   not bring old tokens back;
11//! * release their check-outs (`checkouts::release_held`).
12//!
13//! Deactivation, hand-over and token revocation run in **one** transaction;
14//! the check-outs are released afterwards, one transaction per revision,
15//! like every bulk release. Deactivating the only active manager of a group
16//! without a successor is refused unless the administrator confirms that the
17//! group is left without one.
18
19use serde::Serialize;
20use serde_json::json;
21use sqlx::{PgConnection, PgPool};
22use uuid::Uuid;
23
24use crate::{
25    auth::Actor,
26    domain::Role,
27    error::{AppError, AppResult},
28    services::{
29        audit,
30        checkouts::{self, CheckInOutcome},
31        ownership::{self, HandoverReport},
32    },
33};
34
35/// What to do besides deactivating the account.
36#[derive(Clone, Debug, Default)]
37pub struct Offboarding {
38    /// Release every check-out the user holds (in the groups the
39    /// administrator can read).
40    pub release_checkouts: bool,
41    /// Hand the work and the manager roles over to this user.
42    pub hand_over_to: Option<Uuid>,
43    /// Delete the user's API tokens (instead of only disabling them).
44    pub revoke_tokens: bool,
45    /// Leave groups without an active manager (no successor named).
46    pub confirm_no_manager: bool,
47}
48
49/// What the deactivation did.
50#[derive(Clone, Debug, Default, Serialize)]
51pub struct DeactivationReport {
52    pub username: String,
53    /// The account was active before (deactivating an inactive account runs
54    /// only the chosen offboarding steps).
55    pub was_active: bool,
56    pub handover: Option<HandoverReport>,
57    /// Codes of the groups in which the successor became a manager.
58    pub managers_moved: Vec<String>,
59    /// Codes of the groups left without an active manager (confirmed).
60    pub left_without_manager: Vec<String>,
61    pub tokens_revoked: u64,
62    /// One entry per check-out looked at (released or refused).
63    pub releases: Vec<CheckInOutcome>,
64}
65
66impl DeactivationReport {
67    /// One line for a flash message or the command line.
68    pub fn summary(&self) -> String {
69        let head = if self.was_active {
70            format!("Deactivated {}", self.username)
71        } else {
72            format!("{} was already deactivated", self.username)
73        };
74        let mut parts = vec![head];
75        if let Some(h) = &self.handover {
76            parts.push(format!(
77                "handed over to {}: {} items, {} changes, {} requests, {} reviews{}",
78                h.to_name,
79                h.count("item"),
80                h.count("change"),
81                h.count("request"),
82                h.count("review"),
83                if h.skipped.is_empty() {
84                    String::new()
85                } else {
86                    format!(" ({} stayed)", h.skipped.len())
87                }
88            ));
89        }
90        if !self.managers_moved.is_empty() {
91            parts.push(format!("manager of {}", self.managers_moved.join(", ")));
92        }
93        if !self.left_without_manager.is_empty() {
94            parts.push(format!(
95                "no active manager left in {}",
96                self.left_without_manager.join(", ")
97            ));
98        }
99        if self.tokens_revoked > 0 {
100            parts.push(format!("{} API tokens revoked", self.tokens_revoked));
101        }
102        if !self.releases.is_empty() {
103            parts.push(checkouts::release_summary(&self.releases));
104        }
105        let mut text = parts.join("; ");
106        if !text.ends_with('.') {
107            text.push('.');
108        }
109        text
110    }
111}
112
113#[derive(sqlx::FromRow)]
114struct Leaver {
115    username: String,
116    active: bool,
117}
118
119/// Groups in which `user` is the only active manager: `(id, code)`.
120async fn sole_manager_of(conn: &mut PgConnection, user: Uuid) -> AppResult<Vec<(Uuid, String)>> {
121    Ok(sqlx::query_as::<_, (Uuid, String)>(
122        "SELECT p.id, p.code FROM project_members pm JOIN projects p ON p.id = pm.project_id
123         WHERE pm.user_id = $1 AND pm.manager
124           AND NOT EXISTS (SELECT 1 FROM project_members o JOIN users ou ON ou.id = o.user_id
125                           WHERE o.project_id = pm.project_id AND o.manager AND ou.active
126                             AND o.user_id <> $1)
127         ORDER BY p.code",
128    )
129    .bind(user)
130    .fetch_all(&mut *conn)
131    .await?)
132}
133
134/// Codes of the groups in which `user` is the only active manager (for the
135/// offboarding form; administrators only).
136pub async fn sole_manager_codes(
137    pool: &PgPool,
138    actor: &Actor,
139    user: Uuid,
140) -> AppResult<Vec<String>> {
141    actor.require(Role::Admin)?;
142    let mut conn = pool.acquire().await?;
143    Ok(sole_manager_of(&mut conn, user)
144        .await?
145        .into_iter()
146        .map(|(_, code)| code)
147        .collect())
148}
149
150/// The successor becomes a manager of every group `from` manages (a member
151/// added for it, if needed); `from` stops being a manager there. One audit
152/// entry per group (`project.manager_handover`).
153async fn hand_over_manager_roles(
154    conn: &mut PgConnection,
155    actor: &Actor,
156    from: Uuid,
157    to: Uuid,
158    from_name: &str,
159) -> AppResult<Vec<String>> {
160    let groups = sqlx::query_as::<_, (Uuid, String)>(
161        "SELECT p.id, p.code FROM project_members pm JOIN projects p ON p.id = pm.project_id
162         WHERE pm.user_id = $1 AND pm.manager ORDER BY p.code FOR UPDATE OF pm",
163    )
164    .bind(from)
165    .fetch_all(&mut *conn)
166    .await?;
167    let to_name = sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = $1")
168        .bind(to)
169        .fetch_one(&mut *conn)
170        .await?;
171    let mut moved = Vec::new();
172    for (project_id, code) in groups {
173        sqlx::query(
174            "INSERT INTO project_members (project_id, user_id, source, manager)
175             VALUES ($1, $2, 'manual', true)
176             ON CONFLICT (project_id, user_id) DO UPDATE SET manager = true",
177        )
178        .bind(project_id)
179        .bind(to)
180        .execute(&mut *conn)
181        .await?;
182        sqlx::query(
183            "UPDATE project_members SET manager = false WHERE project_id = $1 AND user_id = $2",
184        )
185        .bind(project_id)
186        .bind(from)
187        .execute(&mut *conn)
188        .await?;
189        audit::record(
190            &mut *conn,
191            Some(actor),
192            "project.manager_handover",
193            "project",
194            Some(project_id),
195            format!("{to_name} manages {code} in place of {from_name}"),
196            json!({ "from": from, "to": to }),
197        )
198        .await?;
199        moved.push(code);
200    }
201    Ok(moved)
202}
203
204/// Deactivates a user as an offboarding step.
205///
206/// # Invariants
207///
208/// * **Permission:** administrator (403); an unknown user is "not found";
209///   an administrator cannot offboard themselves (400).
210/// * **Locks:** the active administrators (`FOR UPDATE`, in id order, like
211///   `users::update`), then the user's row, then what the hand-over moves.
212///   At least one active administrator remains (409).
213/// * **Data:** with a successor, the hand-over of `ownership::hand_over`
214///   plus the manager roles; without one, deactivating the only active
215///   manager of a group is refused (409) unless `confirm_no_manager`. With
216///   `revoke_tokens` the user's API tokens are deleted. The account is
217///   deactivated and its sessions are deleted.
218/// * **Audit:** those of the hand-over, `project.manager_handover` per group,
219///   `token.revoke_all` with the count, `user.update` (inactive) and one
220///   `user.offboard` with the choices; the releases audit each revision
221///   (`revision.checkout_forced_release`).
222/// * **Notifications / webhooks:** those of the releases.
223/// * **Atomic:** one transaction for everything but the releases, which run
224///   afterwards, one transaction per revision (a refused one does not stop
225///   the others).
226pub async fn deactivate(
227    pool: &PgPool,
228    actor: &Actor,
229    user_id: Uuid,
230    opts: &Offboarding,
231) -> AppResult<DeactivationReport> {
232    actor.require(Role::Admin)?;
233    if user_id == actor.id {
234        return Err(AppError::BadRequest(
235            "You cannot offboard yourself; another administrator does it.".into(),
236        ));
237    }
238    let mut tx = pool.begin().await?;
239    let admins: Vec<Uuid> = sqlx::query_scalar(
240        "SELECT id FROM users WHERE role = 'admin' AND active ORDER BY id FOR UPDATE",
241    )
242    .fetch_all(&mut *tx)
243    .await?;
244    let leaver =
245        sqlx::query_as::<_, Leaver>("SELECT username, active FROM users WHERE id = $1 FOR UPDATE")
246            .bind(user_id)
247            .fetch_optional(&mut *tx)
248            .await?
249            .ok_or_else(|| AppError::not_found("User not found."))?;
250    if admins.contains(&user_id) && admins.len() == 1 {
251        return Err(AppError::Conflict(
252            "At least one active administrator must remain.".into(),
253        ));
254    }
255    let mut report = DeactivationReport {
256        username: leaver.username.clone(),
257        was_active: leaver.active,
258        ..DeactivationReport::default()
259    };
260
261    let sole = sole_manager_of(&mut tx, user_id).await?;
262    match opts.hand_over_to {
263        Some(to) => {
264            report.handover = Some(ownership::hand_over_in(&mut tx, actor, user_id, to).await?);
265            report.managers_moved =
266                hand_over_manager_roles(&mut tx, actor, user_id, to, &leaver.username).await?;
267        }
268        None if !sole.is_empty() && !opts.confirm_no_manager => {
269            let codes: Vec<&str> = sole.iter().map(|(_, c)| c.as_str()).collect();
270            return Err(AppError::Conflict(format!(
271                "{} is the only active manager of {}. Name a successor, or confirm that the \
272                 group is left without an active manager.",
273                leaver.username,
274                codes.join(", ")
275            )));
276        }
277        None => {
278            report.left_without_manager = sole.into_iter().map(|(_, c)| c).collect();
279        }
280    }
281
282    if opts.revoke_tokens {
283        report.tokens_revoked = sqlx::query("DELETE FROM api_tokens WHERE user_id = $1")
284            .bind(user_id)
285            .execute(&mut *tx)
286            .await?
287            .rows_affected();
288        if report.tokens_revoked > 0 {
289            audit::record(
290                &mut tx,
291                Some(actor),
292                "token.revoke_all",
293                "user",
294                Some(user_id),
295                format!(
296                    "Revoked all {} API tokens of {}",
297                    report.tokens_revoked, leaver.username
298                ),
299                json!({ "count": report.tokens_revoked }),
300            )
301            .await?;
302        }
303    }
304
305    if leaver.active {
306        sqlx::query("UPDATE users SET active = false WHERE id = $1")
307            .bind(user_id)
308            .execute(&mut *tx)
309            .await?;
310        sqlx::query("DELETE FROM sessions WHERE user_id = $1")
311            .bind(user_id)
312            .execute(&mut *tx)
313            .await?;
314        audit::record(
315            &mut tx,
316            Some(actor),
317            "user.update",
318            "user",
319            Some(user_id),
320            format!("Deactivated user '{}'", leaver.username),
321            json!({ "active": false }),
322        )
323        .await?;
324    }
325    audit::record(
326        &mut tx,
327        Some(actor),
328        "user.offboard",
329        "user",
330        Some(user_id),
331        format!("Offboarded {}", leaver.username),
332        json!({
333            "hand_over_to": opts.hand_over_to,
334            "managers_moved": report.managers_moved,
335            "left_without_manager": report.left_without_manager,
336            "tokens_revoked": report.tokens_revoked,
337            "release_checkouts": opts.release_checkouts,
338        }),
339    )
340    .await?;
341    tx.commit().await?;
342
343    if opts.release_checkouts {
344        report.releases = checkouts::release_held(pool, actor, user_id, None).await?;
345    }
346    Ok(report)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn the_summary_names_every_step() {
355        let mut r = DeactivationReport {
356            username: "lena".into(),
357            was_active: true,
358            managers_moved: vec!["CHASSIS".into()],
359            tokens_revoked: 2,
360            ..DeactivationReport::default()
361        };
362        let s = r.summary();
363        assert!(s.starts_with("Deactivated lena"), "{s}");
364        assert!(s.contains("manager of CHASSIS") && s.contains("2 API tokens revoked"));
365        assert!(s.ends_with('.'));
366        r.was_active = false;
367        r.managers_moved.clear();
368        r.left_without_manager = vec!["AERO".into()];
369        let s = r.summary();
370        assert!(s.contains("already deactivated") && s.contains("no active manager left in AERO"));
371    }
372}