shared/models/
admin_activity.rs

1use crate::{State, models::InsertQueryBuilder, prelude::*, storage::StorageUrlRetriever};
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
57impl BaseModel for AdminActivity {
58    const NAME: &'static str = "admin_activity";
59
60    #[inline]
61    fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
62        let prefix = prefix.unwrap_or_default();
63
64        let mut columns = BTreeMap::from([
65            (
66                "admin_activities.impersonator_uuid",
67                compact_str::format_compact!("{prefix}impersonator_uuid"),
68            ),
69            (
70                "admin_activities.api_key_uuid",
71                compact_str::format_compact!("{prefix}api_key_uuid"),
72            ),
73            (
74                "admin_activities.event",
75                compact_str::format_compact!("{prefix}event"),
76            ),
77            (
78                "admin_activities.ip",
79                compact_str::format_compact!("{prefix}ip"),
80            ),
81            (
82                "admin_activities.data",
83                compact_str::format_compact!("{prefix}data"),
84            ),
85            (
86                "admin_activities.created",
87                compact_str::format_compact!("{prefix}created"),
88            ),
89        ]);
90
91        columns.extend(super::user::User::columns(Some("user_")));
92
93        columns
94    }
95
96    #[inline]
97    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
98        let prefix = prefix.unwrap_or_default();
99
100        Ok(Self {
101            user: if row
102                .try_get::<uuid::Uuid, _>("user_uuid".to_string().as_str())
103                .is_ok()
104            {
105                Some(super::user::User::map(Some("user_"), row)?)
106            } else {
107                None
108            },
109            impersonator: super::user::User::get_fetchable_from_row(
110                row,
111                compact_str::format_compact!("{prefix}impersonator_uuid"),
112            ),
113            api_key: super::user_api_key::UserApiKey::get_fetchable_from_row(
114                row,
115                compact_str::format_compact!("{prefix}api_key_uuid").as_str(),
116            ),
117            event: row.try_get(compact_str::format_compact!("{prefix}event").as_str())?,
118            ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
119            data: row.try_get(compact_str::format_compact!("{prefix}data").as_str())?,
120            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
121        })
122    }
123}
124
125impl AdminActivity {
126    pub async fn all_with_pagination(
127        database: &crate::database::Database,
128        page: i64,
129        per_page: i64,
130        search: Option<&str>,
131    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
132        let offset = (page - 1) * per_page;
133
134        let rows = sqlx::query(&format!(
135            r#"
136            SELECT {}, COUNT(*) OVER() AS total_count
137            FROM admin_activities
138            LEFT JOIN users ON users.uuid = admin_activities.user_uuid
139            LEFT JOIN roles ON roles.uuid = users.role_uuid
140            WHERE ($1 IS NULL OR admin_activities.event ILIKE '%' || $1 || '%' OR users.username ILIKE '%' || $1 || '%')
141            ORDER BY admin_activities.created DESC
142            LIMIT $2 OFFSET $3
143            "#,
144            Self::columns_sql(None)
145        ))
146        .bind(search)
147        .bind(per_page)
148        .bind(offset)
149        .fetch_all(database.read())
150        .await?;
151
152        Ok(super::Pagination {
153            total: rows
154                .first()
155                .map_or(Ok(0), |row| row.try_get("total_count"))?,
156            per_page,
157            page,
158            data: rows
159                .into_iter()
160                .map(|row| Self::map(None, &row))
161                .try_collect_vec()?,
162        })
163    }
164
165    pub async fn delete_older_than(
166        database: &crate::database::Database,
167        cutoff: chrono::DateTime<chrono::Utc>,
168    ) -> Result<u64, crate::database::DatabaseError> {
169        let result = sqlx::query!(
170            r#"
171            DELETE FROM admin_activities
172            WHERE created < $1
173            "#,
174            cutoff.naive_utc()
175        )
176        .execute(database.write())
177        .await?;
178
179        Ok(result.rows_affected())
180    }
181
182    #[inline]
183    pub async fn into_admin_api_object(
184        self,
185        database: &crate::database::Database,
186        storage_url_retriever: &StorageUrlRetriever<'_>,
187    ) -> Result<AdminApiAdminActivity, anyhow::Error> {
188        Ok(AdminApiAdminActivity {
189            user: self
190                .user
191                .map(|user| user.into_api_object(storage_url_retriever)),
192            impersonator: if let Some(impersonator) = self.impersonator {
193                Some(
194                    impersonator
195                        .fetch_cached(database)
196                        .await?
197                        .into_api_object(storage_url_retriever),
198                )
199            } else {
200                None
201            },
202            event: self.event,
203            ip: self.ip.map(|ip| ip.ip().to_compact_string()),
204            data: self.data,
205            is_api: self.api_key.is_some(),
206            created: self.created.and_utc(),
207        })
208    }
209}
210
211#[derive(ToSchema, Deserialize, Validate)]
212pub struct CreateAdminActivityOptions {
213    #[garde(skip)]
214    pub user_uuid: Option<uuid::Uuid>,
215    #[garde(skip)]
216    pub impersonator_uuid: Option<uuid::Uuid>,
217    #[garde(skip)]
218    pub api_key_uuid: Option<uuid::Uuid>,
219    #[garde(length(chars, min = 1, max = 255))]
220    #[schema(min_length = 1, max_length = 255)]
221    pub event: compact_str::CompactString,
222    #[garde(skip)]
223    #[schema(value_type = Option<String>)]
224    pub ip: Option<sqlx::types::ipnetwork::IpNetwork>,
225    #[garde(skip)]
226    pub data: serde_json::Value,
227    #[garde(skip)]
228    pub created: Option<chrono::NaiveDateTime>,
229}
230
231#[async_trait::async_trait]
232impl CreatableModel for AdminActivity {
233    type CreateOptions<'a> = CreateAdminActivityOptions;
234    type CreateResult = ();
235
236    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
237        static CREATE_LISTENERS: LazyLock<CreateListenerList<AdminActivity>> =
238            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
239
240        &CREATE_LISTENERS
241    }
242
243    async fn create(
244        state: &crate::State,
245        mut options: Self::CreateOptions<'_>,
246    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
247        options.validate()?;
248
249        let mut transaction = state.database.write().begin().await?;
250
251        let mut query_builder = InsertQueryBuilder::new("admin_activities");
252
253        Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
254            .await?;
255
256        query_builder
257            .set("user_uuid", options.user_uuid)
258            .set("impersonator_uuid", options.impersonator_uuid)
259            .set("api_key_uuid", options.api_key_uuid)
260            .set("event", &options.event)
261            .set("ip", options.ip)
262            .set("data", options.data);
263
264        if let Some(created) = options.created {
265            query_builder.set("created", created);
266        }
267
268        query_builder.execute(&mut *transaction).await?;
269
270        transaction.commit().await?;
271
272        Ok(())
273    }
274}
275
276#[derive(ToSchema, Serialize)]
277#[schema(title = "AdminAdminActivity")]
278pub struct AdminApiAdminActivity {
279    pub user: Option<super::user::ApiUser>,
280    pub impersonator: Option<super::user::ApiUser>,
281
282    pub event: compact_str::CompactString,
283    pub ip: Option<compact_str::CompactString>,
284    pub data: serde_json::Value,
285
286    pub is_api: bool,
287
288    pub created: chrono::DateTime<chrono::Utc>,
289}