1use chrono::{DateTime, Utc};
29use sqlx::Row as _;
30
31use crate::error::{Error, Result};
32use crate::orm::Db;
33
34pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
41 id BIGSERIAL PRIMARY KEY,
42 user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
43 action_type TEXT NOT NULL,
44 model_name TEXT NOT NULL,
45 object_id BIGINT NOT NULL,
46 timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
47 ip_address TEXT,
48 summary TEXT NOT NULL DEFAULT ''
49)";
50
51pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
52 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
53 ON rustio_admin_actions(model_name, object_id)";
54
55pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
56 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
57 ON rustio_admin_actions(timestamp DESC)";
58
59pub async fn ensure_table(db: &Db) -> Result<()> {
64 sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
65 sqlx::query(CREATE_MODEL_INDEX_SQL)
66 .execute(db.pool())
67 .await?;
68 sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
69 .execute(db.pool())
70 .await?;
71 Ok(())
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum ActionType {
79 Create,
80 Update,
81 Delete,
82}
83
84impl ActionType {
85 pub fn as_str(self) -> &'static str {
86 match self {
87 Self::Create => "create",
88 Self::Update => "update",
89 Self::Delete => "delete",
90 }
91 }
92
93 pub fn parse(s: &str) -> Option<Self> {
97 match s {
98 "create" => Some(Self::Create),
99 "update" => Some(Self::Update),
100 "delete" => Some(Self::Delete),
101 _ => None,
102 }
103 }
104
105 pub fn label(self) -> &'static str {
107 match self {
108 Self::Create => "Created",
109 Self::Update => "Updated",
110 Self::Delete => "Deleted",
111 }
112 }
113
114 pub fn pill_class(self) -> &'static str {
117 match self {
118 Self::Create => "badge-success",
119 Self::Update => "badge-neutral",
120 Self::Delete => "badge-danger",
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
129pub struct AdminAction {
130 pub id: i64,
131 pub user_id: i64,
132 pub user_email: Option<String>,
133 pub action_type: String,
134 pub model_name: String,
135 pub object_id: i64,
136 pub timestamp: DateTime<Utc>,
137 pub ip_address: Option<String>,
138 pub summary: String,
139}
140
141pub struct LogEntry<'a> {
144 pub user_id: i64,
145 pub action_type: ActionType,
146 pub model_name: &'a str,
147 pub object_id: i64,
148 pub ip_address: Option<&'a str>,
149 pub summary: String,
150}
151
152pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
160 if entry.user_id <= 0 {
161 return Err(Error::Internal("admin audit: missing user_id".to_string()));
162 }
163 if entry.model_name.trim().is_empty() {
164 return Err(Error::Internal(
165 "admin audit: missing model_name".to_string(),
166 ));
167 }
168 if entry.object_id <= 0 {
169 return Err(Error::Internal(
170 "admin audit: missing object_id".to_string(),
171 ));
172 }
173
174 let now = Utc::now();
175 sqlx::query(
176 "INSERT INTO rustio_admin_actions
177 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary)
178 VALUES ($1, $2, $3, $4, $5, $6, $7)",
179 )
180 .bind(entry.user_id)
181 .bind(entry.action_type.as_str())
182 .bind(entry.model_name)
183 .bind(entry.object_id)
184 .bind(now)
185 .bind(entry.ip_address)
186 .bind(&entry.summary)
187 .execute(db.pool())
188 .await?;
189 Ok(())
190}
191
192pub async fn recent(
197 db: &Db,
198 limit: i64,
199 model_filter: Option<&str>,
200 action_filter: Option<&str>,
201) -> Result<Vec<AdminAction>> {
202 let mut sql = String::from(
206 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
207 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
208 FROM rustio_admin_actions a
209 LEFT JOIN rustio_users u ON u.id = a.user_id",
210 );
211 let mut clauses: Vec<String> = Vec::new();
212 let mut param_idx: usize = 1;
213 if model_filter.is_some() {
214 clauses.push(format!("a.model_name = ${param_idx}"));
215 param_idx += 1;
216 }
217 if action_filter.is_some() {
218 clauses.push(format!("a.action_type = ${param_idx}"));
219 param_idx += 1;
220 }
221 if !clauses.is_empty() {
222 sql.push_str(" WHERE ");
223 sql.push_str(&clauses.join(" AND "));
224 }
225 sql.push_str(&format!(
226 " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
227 ));
228
229 let mut q = sqlx::query(&sql);
230 if let Some(m) = model_filter {
231 q = q.bind(m);
232 }
233 if let Some(a) = action_filter {
234 q = q.bind(a);
235 }
236 q = q.bind(limit);
237
238 let rows = q.fetch_all(db.pool()).await?;
239 rows.iter().map(row_to_action).collect()
240}
241
242pub async fn for_object(
244 db: &Db,
245 model_name: &str,
246 object_id: i64,
247) -> Result<Vec<AdminAction>> {
248 let rows = sqlx::query(
249 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
250 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
251 FROM rustio_admin_actions a
252 LEFT JOIN rustio_users u ON u.id = a.user_id
253 WHERE a.model_name = $1 AND a.object_id = $2
254 ORDER BY a.timestamp DESC, a.id DESC",
255 )
256 .bind(model_name)
257 .bind(object_id)
258 .fetch_all(db.pool())
259 .await?;
260 rows.iter().map(row_to_action).collect()
261}
262
263fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
264 Ok(AdminAction {
265 id: r.try_get("id")?,
266 user_id: r.try_get("user_id")?,
267 user_email: r.try_get("user_email")?,
268 action_type: r.try_get("action_type")?,
269 model_name: r.try_get("model_name")?,
270 object_id: r.try_get("object_id")?,
271 timestamp: r.try_get("timestamp")?,
272 ip_address: r.try_get("ip_address")?,
273 summary: r.try_get("summary")?,
274 })
275}
276