Skip to main content

systemprompt_users/repository/user/
archive.rs

1//! Archiving a user instead of deleting them (NFR-5.2).
2//!
3//! An archive keeps the `users` row and everything that keys on it: the row
4//! moves to status `deleted` — which every listing, search and sign-in path
5//! already excludes — and records who archived it, when and why. The same
6//! transaction revokes the user's sessions, API keys and device certificates,
7//! so nothing issued before the archive authenticates after it. A restore
8//! inside the retention window reverses the status and the archive fields;
9//! revoked credentials stay revoked and are re-issued by signing in again.
10//! Physical removal is the purge in `updates.rs`, run for an archive past the
11//! window by `database_cleanup` and refused while `legal_hold` is set.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use chrono::{DateTime, Utc};
17use systemprompt_identifiers::UserId;
18
19use crate::error::{Result, UserError};
20use crate::models::UserStatus;
21use crate::repository::UserRepository;
22
23/// Who archives a user, why, and whether the archive is under legal hold.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct ArchiveParams<'a> {
26    pub archived_by: Option<&'a UserId>,
27    pub reason: Option<&'a str>,
28    pub legal_hold: bool,
29}
30
31/// The credentials an archive revoked.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct ArchiveOutcome {
34    pub sessions: u64,
35    pub api_keys: u64,
36    pub device_certs: u64,
37}
38
39/// A user's archive fields. `archived_at` is `None` for a row deleted before
40/// archiving existed, which is restorable but never purged automatically.
41#[derive(Debug, Clone)]
42pub struct ArchiveState {
43    pub id: UserId,
44    pub status: String,
45    pub archived_at: Option<DateTime<Utc>>,
46    pub archived_by: Option<String>,
47    pub archive_reason: Option<String>,
48    pub legal_hold: bool,
49}
50
51impl ArchiveState {
52    #[must_use]
53    pub fn is_archived(&self) -> bool {
54        self.status == UserStatus::Deleted.as_str()
55    }
56}
57
58impl UserRepository {
59    pub async fn archive(&self, id: &UserId, params: ArchiveParams<'_>) -> Result<ArchiveOutcome> {
60        let mut tx = self.write_pool.begin().await?;
61        let archived = sqlx::query!(
62            r#"
63            UPDATE users
64            SET status = $2,
65                archived_at = COALESCE(archived_at, NOW()),
66                archived_by = COALESCE(archived_by, $3),
67                archive_reason = COALESCE(archive_reason, $4),
68                legal_hold = legal_hold OR $5,
69                updated_at = NOW()
70            WHERE id = $1
71            "#,
72            id.as_str(),
73            UserStatus::Deleted.as_str(),
74            params.archived_by.map(UserId::as_str),
75            params.reason,
76            params.legal_hold
77        )
78        .execute(&mut *tx)
79        .await?;
80        if archived.rows_affected() == 0 {
81            return Err(UserError::NotFound(id.clone()));
82        }
83        let sessions = sqlx::query!(
84            "UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
85            id.as_str()
86        )
87        .execute(&mut *tx)
88        .await?;
89        let api_keys = sqlx::query!(
90            "UPDATE user_api_keys SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
91            id.as_str()
92        )
93        .execute(&mut *tx)
94        .await?;
95        let device_certs = sqlx::query!(
96            "UPDATE user_device_certs SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
97            id.as_str()
98        )
99        .execute(&mut *tx)
100        .await?;
101        tx.commit().await?;
102        Ok(ArchiveOutcome {
103            sessions: sessions.rows_affected(),
104            api_keys: api_keys.rows_affected(),
105            device_certs: device_certs.rows_affected(),
106        })
107    }
108
109    pub async fn restore(&self, id: &UserId, window_days: u32) -> Result<bool> {
110        let restored = sqlx::query!(
111            r#"
112            UPDATE users
113            SET status = $2, archived_at = NULL, archived_by = NULL,
114                archive_reason = NULL, updated_at = NOW()
115            WHERE id = $1 AND status = $3
116              AND (archived_at IS NULL
117                   OR archived_at > NOW() - make_interval(days => $4::int))
118            "#,
119            id.as_str(),
120            UserStatus::Active.as_str(),
121            UserStatus::Deleted.as_str(),
122            i32::try_from(window_days).unwrap_or(i32::MAX)
123        )
124        .execute(&*self.write_pool)
125        .await?;
126        Ok(restored.rows_affected() > 0)
127    }
128
129    pub async fn set_legal_hold(&self, id: &UserId, hold: bool) -> Result<()> {
130        let updated = sqlx::query!(
131            "UPDATE users SET legal_hold = $2, updated_at = NOW() WHERE id = $1",
132            id.as_str(),
133            hold
134        )
135        .execute(&*self.write_pool)
136        .await?;
137        if updated.rows_affected() == 0 {
138            return Err(UserError::NotFound(id.clone()));
139        }
140        Ok(())
141    }
142
143    pub async fn find_archive_state(&self, id: &UserId) -> Result<Option<ArchiveState>> {
144        let row = sqlx::query!(
145            r#"
146            SELECT id, status, archived_at, archived_by, archive_reason, legal_hold
147            FROM users WHERE id = $1
148            "#,
149            id.as_str()
150        )
151        .fetch_optional(&*self.pool)
152        .await?;
153        Ok(row.map(|r| ArchiveState {
154            id: UserId::new(r.id),
155            status: r.status,
156            archived_at: r.archived_at,
157            archived_by: r.archived_by,
158            archive_reason: r.archive_reason,
159            legal_hold: r.legal_hold,
160        }))
161    }
162
163    pub async fn list_purgeable_archives(
164        &self,
165        window_days: u32,
166        limit: i64,
167    ) -> Result<Vec<UserId>> {
168        let ids = sqlx::query_scalar!(
169            r#"
170            SELECT id FROM users
171            WHERE status = $1 AND NOT legal_hold AND archived_at IS NOT NULL
172              AND archived_at < NOW() - make_interval(days => $2::int)
173            ORDER BY archived_at
174            LIMIT $3
175            "#,
176            UserStatus::Deleted.as_str(),
177            i32::try_from(window_days).unwrap_or(i32::MAX),
178            limit
179        )
180        .fetch_all(&*self.pool)
181        .await?;
182        Ok(ids.into_iter().map(UserId::new).collect())
183    }
184}