Skip to main content

shared/models/
admin_activity.rs

1use crate::{State, models::InsertQueryBuilder, prelude::*};
2use compact_str::ToCompactString;
3use garde::Validate;
4use serde::{Deserialize, Serialize};
5use sqlx::{Row, postgres::PgRow};
6use std::{
7    collections::BTreeMap,
8    sync::{Arc, LazyLock},
9};
10use utoipa::ToSchema;
11
12pub type GetAdminActivityLogger = crate::extract::ConsumingExtension<AdminActivityLogger>;
13
14#[derive(Clone)]
15pub struct AdminActivityLogger {
16    pub state: State,
17    pub user_uuid: uuid::Uuid,
18    pub impersonator_uuid: Option<uuid::Uuid>,
19    pub api_key_uuid: Option<uuid::Uuid>,
20    pub ip: std::net::IpAddr,
21}
22
23impl AdminActivityLogger {
24    pub async fn log(&self, event: impl Into<compact_str::CompactString>, data: serde_json::Value) {
25        let options = CreateAdminActivityOptions {
26            user_uuid: Some(self.user_uuid),
27            impersonator_uuid: self.impersonator_uuid,
28            api_key_uuid: self.api_key_uuid,
29            event: event.into(),
30            ip: Some(self.ip.into()),
31            data,
32            created: None,
33        };
34        if let Err(err) = AdminActivity::create(&self.state, options).await {
35            tracing::warn!(
36                user = %self.user_uuid,
37                "failed to log admin activity: {:#?}",
38                err
39            );
40        }
41    }
42}
43
44#[derive(Serialize, Deserialize)]
45pub struct AdminActivity {
46    pub user: Option<super::user::User>,
47    pub impersonator: Option<Fetchable<super::user::User>>,
48    pub api_key: Option<Fetchable<super::user_api_key::UserApiKey>>,
49
50    pub event: compact_str::CompactString,
51    pub ip: Option<sqlx::types::ipnetwork::IpNetwork>,
52    pub data: serde_json::Value,
53
54    pub created: chrono::NaiveDateTime,
55
56    extension_data: super::ModelExtensionData,
57}
58
59impl BaseModel for AdminActivity {
60    const NAME: &'static str = "admin_activity";
61
62    fn get_extension_list() -> &'static super::ModelExtensionList {
63        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
64            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
65
66        &EXTENSIONS
67    }
68
69    fn get_extension_data(&self) -> &super::ModelExtensionData {
70        &self.extension_data
71    }
72
73    #[inline]
74    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
75        let prefix = prefix.unwrap_or_default();
76
77        let mut columns = BTreeMap::from([
78            (
79                "admin_activities.impersonator_uuid",
80                compact_str::format_compact!("{prefix}impersonator_uuid"),
81            ),
82            (
83                "admin_activities.api_key_uuid",
84                compact_str::format_compact!("{prefix}api_key_uuid"),
85            ),
86            (
87                "admin_activities.event",
88                compact_str::format_compact!("{prefix}event"),
89            ),
90            (
91                "admin_activities.ip",
92                compact_str::format_compact!("{prefix}ip"),
93            ),
94            (
95                "admin_activities.data",
96                compact_str::format_compact!("{prefix}data"),
97            ),
98            (
99                "admin_activities.created",
100                compact_str::format_compact!("{prefix}created"),
101            ),
102        ]);
103
104        columns.extend(super::user::User::base_columns(Some("user_")));
105
106        columns
107    }
108
109    #[inline]
110    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
111        let prefix = prefix.unwrap_or_default();
112
113        Ok(Self {
114            user: if row
115                .try_get::<uuid::Uuid, _>("user_uuid".to_string().as_str())
116                .is_ok()
117            {
118                Some(super::user::User::map(Some("user_"), row)?)
119            } else {
120                None
121            },
122            impersonator: super::user::User::get_fetchable_from_row(
123                row,
124                compact_str::format_compact!("{prefix}impersonator_uuid"),
125            ),
126            api_key: super::user_api_key::UserApiKey::get_fetchable_from_row(
127                row,
128                compact_str::format_compact!("{prefix}api_key_uuid").as_str(),
129            ),
130            event: row.try_get(compact_str::format_compact!("{prefix}event").as_str())?,
131            ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
132            data: row.try_get(compact_str::format_compact!("{prefix}data").as_str())?,
133            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
134            extension_data: Self::map_extensions(prefix, row)?,
135        })
136    }
137}
138
139impl AdminActivity {
140    pub async fn all_with_pagination(
141        database: &crate::database::Database,
142        page: i64,
143        per_page: i64,
144        search: Option<&str>,
145    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
146        let offset = (page - 1) * per_page;
147
148        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
149            r#"
150            SELECT {}, COUNT(*) OVER() AS total_count
151            FROM admin_activities
152            LEFT JOIN users ON users.uuid = admin_activities.user_uuid
153            LEFT JOIN roles ON roles.uuid = users.role_uuid
154            WHERE ($1 IS NULL OR admin_activities.event ILIKE '%' || $1 || '%' OR users.username ILIKE '%' || $1 || '%')
155            ORDER BY admin_activities.created DESC
156            LIMIT $2 OFFSET $3
157            "#,
158            Self::columns_sql(None)
159        )))
160        .bind(search)
161        .bind(per_page)
162        .bind(offset)
163        .fetch_all(database.read())
164        .await?;
165
166        Ok(super::Pagination {
167            total: rows
168                .first()
169                .map_or(Ok(0), |row| row.try_get("total_count"))?,
170            per_page,
171            page,
172            data: rows
173                .into_iter()
174                .map(|row| Self::map(None, &row))
175                .try_collect_vec()?,
176        })
177    }
178
179    pub async fn by_user_uuid_with_pagination(
180        database: &crate::database::Database,
181        user_uuid: uuid::Uuid,
182        page: i64,
183        per_page: i64,
184        search: Option<&str>,
185    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
186        let offset = (page - 1) * per_page;
187
188        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
189            r#"
190            SELECT {}, COUNT(*) OVER() AS total_count
191            FROM admin_activities
192            LEFT JOIN users ON users.uuid = admin_activities.user_uuid
193            LEFT JOIN roles ON roles.uuid = users.role_uuid
194            WHERE admin_activities.user_uuid = $1 AND ($2 IS NULL OR admin_activities.event ILIKE '%' || $2 || '%' OR users.username ILIKE '%' || $2 || '%')
195            ORDER BY admin_activities.created DESC
196            LIMIT $3 OFFSET $4
197            "#,
198            Self::columns_sql(None)
199        )))
200        .bind(user_uuid)
201        .bind(search)
202        .bind(per_page)
203        .bind(offset)
204        .fetch_all(database.read())
205        .await?;
206
207        Ok(super::Pagination {
208            total: rows
209                .first()
210                .map_or(Ok(0), |row| row.try_get("total_count"))?,
211            per_page,
212            page,
213            data: rows
214                .into_iter()
215                .map(|row| Self::map(None, &row))
216                .try_collect_vec()?,
217        })
218    }
219
220    pub async fn delete_older_than(
221        database: &crate::database::Database,
222        cutoff: chrono::DateTime<chrono::Utc>,
223    ) -> Result<u64, crate::database::DatabaseError> {
224        let result = sqlx::query(
225            r#"
226            DELETE FROM admin_activities
227            WHERE admin_activities.created < $1
228            "#,
229        )
230        .bind(cutoff.naive_utc())
231        .execute(database.write())
232        .await?;
233
234        Ok(result.rows_affected())
235    }
236
237    pub async fn retain_latest_logs(
238        database: &crate::database::Database,
239        keep_count: i64,
240    ) -> Result<u64, crate::database::DatabaseError> {
241        let result = sqlx::query(
242            r#"
243            DELETE FROM admin_activities
244            WHERE ctid IN (
245                SELECT ctid 
246                FROM admin_activities
247                ORDER BY admin_activities.created DESC
248                OFFSET $1
249            )
250            "#,
251        )
252        .bind(keep_count)
253        .execute(database.write())
254        .await?;
255
256        Ok(result.rows_affected())
257    }
258}
259
260#[async_trait::async_trait]
261impl IntoAdminApiObject for AdminActivity {
262    type AdminApiObject = AdminApiAdminActivity;
263    type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
264
265    async fn into_admin_api_object<'a>(
266        self,
267        state: &crate::State,
268        storage_url_retriever: Self::ExtraArgs<'a>,
269    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
270        let api_object = AdminApiAdminActivity::init_hooks(&self, state).await?;
271
272        let user = if let Some(user) = self.user {
273            Some(user.into_api_object(state, storage_url_retriever).await?)
274        } else {
275            None
276        };
277
278        let impersonator = if let Some(impersonator) = self.impersonator {
279            Some(
280                impersonator
281                    .fetch_cached(&state.database)
282                    .await?
283                    .into_api_object(state, storage_url_retriever)
284                    .await?,
285            )
286        } else {
287            None
288        };
289
290        let api_object = finish_extendible!(
291            AdminApiAdminActivity {
292                user,
293                impersonator,
294                event: self.event,
295                ip: self.ip.map(|ip| ip.ip().to_compact_string()),
296                data: self.data,
297                is_api: self.api_key.is_some(),
298                created: self.created.and_utc(),
299            },
300            api_object,
301            state
302        )?;
303
304        Ok(api_object)
305    }
306}
307
308#[derive(ToSchema, Deserialize, Validate)]
309pub struct CreateAdminActivityOptions {
310    #[garde(skip)]
311    pub user_uuid: Option<uuid::Uuid>,
312    #[garde(skip)]
313    pub impersonator_uuid: Option<uuid::Uuid>,
314    #[garde(skip)]
315    pub api_key_uuid: Option<uuid::Uuid>,
316    #[garde(length(chars, min = 1, max = 255))]
317    #[schema(min_length = 1, max_length = 255)]
318    pub event: compact_str::CompactString,
319    #[garde(skip)]
320    #[schema(value_type = Option<String>)]
321    pub ip: Option<sqlx::types::ipnetwork::IpNetwork>,
322    #[garde(skip)]
323    pub data: serde_json::Value,
324    #[garde(skip)]
325    pub created: Option<chrono::NaiveDateTime>,
326}
327
328#[async_trait::async_trait]
329impl CreatableModel for AdminActivity {
330    type CreateOptions<'a> = CreateAdminActivityOptions;
331    type CreateResult = ();
332
333    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
334        static CREATE_LISTENERS: LazyLock<CreateListenerList<AdminActivity>> =
335            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
336
337        &CREATE_LISTENERS
338    }
339
340    async fn create_with_transaction(
341        state: &crate::State,
342        mut options: Self::CreateOptions<'_>,
343        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
344    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
345        options.validate()?;
346
347        let mut query_builder = InsertQueryBuilder::new("admin_activities");
348
349        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
350
351        query_builder
352            .set("user_uuid", options.user_uuid)
353            .set("impersonator_uuid", options.impersonator_uuid)
354            .set("api_key_uuid", options.api_key_uuid)
355            .set("event", &options.event)
356            .set("ip", options.ip)
357            .set("data", &options.data);
358
359        if let Some(created) = options.created {
360            query_builder.set("created", created);
361        }
362
363        query_builder.execute(&mut **transaction).await?;
364
365        let mut result = ();
366
367        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
368
369        Ok(result)
370    }
371}
372
373#[schema_extension_derive::extendible]
374#[init_args(AdminActivity, crate::State)]
375#[hook_args(crate::State)]
376#[derive(ToSchema, Serialize)]
377#[schema(title = "AdminAdminActivity")]
378pub struct AdminApiAdminActivity {
379    pub user: Option<super::user::ApiUser>,
380    pub impersonator: Option<super::user::ApiUser>,
381
382    pub event: compact_str::CompactString,
383    pub ip: Option<compact_str::CompactString>,
384    pub data: serde_json::Value,
385
386    pub is_api: bool,
387
388    pub created: chrono::DateTime<chrono::Utc>,
389}