1use chrono::{DateTime, Utc};
16use sqlx::Row as _;
17
18use crate::error::{Error, Result};
19use crate::orm::Db;
20
21pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
22 id BIGSERIAL PRIMARY KEY,
23 user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
24 action_type TEXT NOT NULL,
25 model_name TEXT NOT NULL,
26 object_id BIGINT NOT NULL,
27 timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28 ip_address TEXT,
29 summary TEXT NOT NULL DEFAULT ''
30)";
31
32pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
33 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
34 ON rustio_admin_actions(model_name, object_id)";
35
36pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
37 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
38 ON rustio_admin_actions(timestamp DESC)";
39
40pub async fn ensure_table(db: &Db) -> Result<()> {
43 sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
44 sqlx::query(CREATE_MODEL_INDEX_SQL)
45 .execute(db.pool())
46 .await?;
47 sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
48 .execute(db.pool())
49 .await?;
50 Ok(())
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ActionType {
55 Create,
56 Update,
57 Delete,
58}
59
60impl ActionType {
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Self::Create => "create",
64 Self::Update => "update",
65 Self::Delete => "delete",
66 }
67 }
68
69 pub fn parse(s: &str) -> Option<Self> {
70 match s {
71 "create" => Some(Self::Create),
72 "update" => Some(Self::Update),
73 "delete" => Some(Self::Delete),
74 _ => None,
75 }
76 }
77
78 pub fn label(self) -> &'static str {
79 match self {
80 Self::Create => "Created",
81 Self::Update => "Updated",
82 Self::Delete => "Deleted",
83 }
84 }
85
86 pub fn pill_class(self) -> &'static str {
87 match self {
88 Self::Create => "badge-success",
89 Self::Update => "badge-neutral",
90 Self::Delete => "badge-danger",
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
96pub struct AdminAction {
97 pub id: i64,
98 pub user_id: i64,
99 pub user_email: Option<String>,
100 pub action_type: String,
101 pub model_name: String,
102 pub object_id: i64,
103 pub timestamp: DateTime<Utc>,
104 pub ip_address: Option<String>,
105 pub summary: String,
106}
107
108pub struct LogEntry<'a> {
109 pub user_id: i64,
110 pub action_type: ActionType,
111 pub model_name: &'a str,
112 pub object_id: i64,
113 pub ip_address: Option<&'a str>,
114 pub summary: String,
115}
116
117pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
120 if entry.user_id <= 0 {
121 return Err(Error::Internal("admin audit: missing user_id".to_string()));
122 }
123 if entry.model_name.trim().is_empty() {
124 return Err(Error::Internal(
125 "admin audit: missing model_name".to_string(),
126 ));
127 }
128 if entry.object_id <= 0 {
129 return Err(Error::Internal(
130 "admin audit: missing object_id".to_string(),
131 ));
132 }
133
134 let now = Utc::now();
135 sqlx::query(
136 "INSERT INTO rustio_admin_actions
137 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary)
138 VALUES ($1, $2, $3, $4, $5, $6, $7)",
139 )
140 .bind(entry.user_id)
141 .bind(entry.action_type.as_str())
142 .bind(entry.model_name)
143 .bind(entry.object_id)
144 .bind(now)
145 .bind(entry.ip_address)
146 .bind(&entry.summary)
147 .execute(db.pool())
148 .await?;
149 Ok(())
150}
151
152pub async fn recent(
154 db: &Db,
155 limit: i64,
156 model_filter: Option<&str>,
157 action_filter: Option<&str>,
158) -> Result<Vec<AdminAction>> {
159 let mut sql = String::from(
160 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
161 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
162 FROM rustio_admin_actions a
163 LEFT JOIN rustio_users u ON u.id = a.user_id",
164 );
165 let mut clauses: Vec<String> = Vec::new();
166 let mut param_idx: usize = 1;
167 if model_filter.is_some() {
168 clauses.push(format!("a.model_name = ${param_idx}"));
169 param_idx += 1;
170 }
171 if action_filter.is_some() {
172 clauses.push(format!("a.action_type = ${param_idx}"));
173 param_idx += 1;
174 }
175 if !clauses.is_empty() {
176 sql.push_str(" WHERE ");
177 sql.push_str(&clauses.join(" AND "));
178 }
179 sql.push_str(&format!(
180 " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
181 ));
182
183 let mut q = sqlx::query(&sql);
184 if let Some(m) = model_filter {
185 q = q.bind(m);
186 }
187 if let Some(a) = action_filter {
188 q = q.bind(a);
189 }
190 q = q.bind(limit);
191
192 let rows = q.fetch_all(db.pool()).await?;
193 rows.iter().map(row_to_action).collect()
194}
195
196pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
198 let rows = sqlx::query(
199 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
200 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
201 FROM rustio_admin_actions a
202 LEFT JOIN rustio_users u ON u.id = a.user_id
203 WHERE a.model_name = $1 AND a.object_id = $2
204 ORDER BY a.timestamp DESC, a.id DESC",
205 )
206 .bind(model_name)
207 .bind(object_id)
208 .fetch_all(db.pool())
209 .await?;
210 rows.iter().map(row_to_action).collect()
211}
212
213fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
214 Ok(AdminAction {
215 id: r.try_get("id")?,
216 user_id: r.try_get("user_id")?,
217 user_email: r.try_get("user_email")?,
218 action_type: r.try_get("action_type")?,
219 model_name: r.try_get("model_name")?,
220 object_id: r.try_get("object_id")?,
221 timestamp: r.try_get("timestamp")?,
222 ip_address: r.try_get("ip_address")?,
223 summary: r.try_get("summary")?,
224 })
225}