Skip to main content

aster_forge_db/
audit_log.rs

1//! Database-backed audit log table and base store.
2//!
3//! Aster products share the same audit log storage shape: an actor id, stable
4//! action wire value, target entity metadata, optional detail JSON, request
5//! metadata, and creation timestamp. Products still own typed action enums,
6//! detail schemas, presentation, retention policy, and authorization. This
7//! module keeps the common SeaORM table contract, index builders, and simple
8//! write/count/delete helpers in one place.
9
10use chrono::{DateTime, Utc};
11use sea_orm::entity::prelude::*;
12use sea_orm::sea_query::{
13    Alias, ColumnDef, Index, IndexCreateStatement, Table, TableCreateStatement, TableDropStatement,
14};
15use sea_orm::{
16    ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseBackend, DatabaseConnection,
17    EntityTrait, ExprTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
18    sea_query::Expr,
19};
20
21/// Audit log table name.
22pub const AUDIT_LOGS_TABLE: &str = "audit_logs";
23/// Stable row id column.
24pub const AUDIT_LOG_ID_COLUMN: &str = "id";
25/// Actor user id column. System events use `0`.
26pub const AUDIT_LOG_USER_ID_COLUMN: &str = "user_id";
27/// Stable action wire value column.
28pub const AUDIT_LOG_ACTION_COLUMN: &str = "action";
29/// Target entity type column.
30pub const AUDIT_LOG_ENTITY_TYPE_COLUMN: &str = "entity_type";
31/// Optional target entity id column.
32pub const AUDIT_LOG_ENTITY_ID_COLUMN: &str = "entity_id";
33/// Optional target entity display name column.
34pub const AUDIT_LOG_ENTITY_NAME_COLUMN: &str = "entity_name";
35/// Optional product-owned JSON detail column.
36pub const AUDIT_LOG_DETAILS_COLUMN: &str = "details";
37/// Optional client IP column.
38pub const AUDIT_LOG_IP_ADDRESS_COLUMN: &str = "ip_address";
39/// Optional user-agent column.
40pub const AUDIT_LOG_USER_AGENT_COLUMN: &str = "user_agent";
41/// Row creation timestamp column.
42pub const AUDIT_LOG_CREATED_AT_COLUMN: &str = "created_at";
43
44/// Index name for created-at scans.
45pub const AUDIT_LOG_CREATED_AT_INDEX: &str = "idx_audit_logs_created_at";
46/// Index name for action filtering.
47pub const AUDIT_LOG_ACTION_INDEX: &str = "idx_audit_logs_action";
48/// Index name for user filtering.
49pub const AUDIT_LOG_USER_ID_INDEX: &str = "idx_audit_logs_user_id";
50/// Index name for action/time/user activity aggregation.
51pub const AUDIT_LOG_ACTION_CREATED_USER_INDEX: &str = "idx_audit_logs_action_created_user";
52/// Index name for cursor scans by created-at/id.
53pub const AUDIT_LOG_CREATED_ID_INDEX: &str = "idx_audit_logs_created_id";
54/// Index name for user cursor scans.
55pub const AUDIT_LOG_USER_CREATED_ID_INDEX: &str = "idx_audit_logs_user_created_id";
56/// Index name for action cursor scans.
57pub const AUDIT_LOG_ACTION_CREATED_ID_INDEX: &str = "idx_audit_logs_action_created_id";
58/// Index name for entity-type cursor scans.
59pub const AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX: &str = "idx_audit_logs_entity_type_created_id";
60
61const AUDIT_LOG_ACTION_MAX_LEN: u32 = 64;
62const AUDIT_LOG_ACTION_MAX_BYTES: usize = 64;
63const AUDIT_LOG_ENTITY_TYPE_MAX_LEN: u32 = 64;
64const AUDIT_LOG_ENTITY_TYPE_MAX_BYTES: usize = 64;
65const AUDIT_LOG_ENTITY_NAME_MAX_BYTES: usize = 255;
66const AUDIT_LOG_IP_ADDRESS_MAX_BYTES: usize = 128;
67const AUDIT_LOG_USER_AGENT_MAX_BYTES: usize = 512;
68
69/// Builds the shared `audit_logs` table creation statement.
70pub fn create_audit_logs_table(backend: DatabaseBackend) -> TableCreateStatement {
71    Table::create()
72        .table(audit_logs_table())
73        .if_not_exists()
74        .col(
75            ColumnDef::new(audit_log_id())
76                .big_integer()
77                .not_null()
78                .auto_increment()
79                .primary_key(),
80        )
81        .col(
82            ColumnDef::new(audit_log_user_id())
83                .big_integer()
84                .not_null()
85                .default(0),
86        )
87        .col(
88            ColumnDef::new(audit_log_action())
89                .string_len(AUDIT_LOG_ACTION_MAX_LEN)
90                .not_null(),
91        )
92        .col(
93            ColumnDef::new(audit_log_entity_type())
94                .string_len(AUDIT_LOG_ENTITY_TYPE_MAX_LEN)
95                .not_null(),
96        )
97        .col(ColumnDef::new(audit_log_entity_id()).big_integer().null())
98        .col(
99            ColumnDef::new(audit_log_entity_name())
100                .string_len(255)
101                .null(),
102        )
103        .col(ColumnDef::new(audit_log_details()).text().null())
104        .col(
105            ColumnDef::new(audit_log_ip_address())
106                .string_len(128)
107                .null(),
108        )
109        .col(
110            ColumnDef::new(audit_log_user_agent())
111                .string_len(512)
112                .null(),
113        )
114        .col(utc_datetime_column(backend, audit_log_created_at()).not_null())
115        .to_owned()
116}
117
118/// Builds the shared `audit_logs` table drop statement.
119pub fn drop_audit_logs_table() -> TableDropStatement {
120    Table::drop()
121        .table(audit_logs_table())
122        .if_exists()
123        .to_owned()
124}
125
126/// Builds the created-at index.
127pub fn create_audit_logs_created_at_index() -> IndexCreateStatement {
128    Index::create()
129        .name(AUDIT_LOG_CREATED_AT_INDEX)
130        .table(audit_logs_table())
131        .col(audit_log_created_at())
132        .if_not_exists()
133        .to_owned()
134}
135
136/// Builds the action index.
137pub fn create_audit_logs_action_index() -> IndexCreateStatement {
138    Index::create()
139        .name(AUDIT_LOG_ACTION_INDEX)
140        .table(audit_logs_table())
141        .col(audit_log_action())
142        .if_not_exists()
143        .to_owned()
144}
145
146/// Builds the user id index.
147pub fn create_audit_logs_user_id_index() -> IndexCreateStatement {
148    Index::create()
149        .name(AUDIT_LOG_USER_ID_INDEX)
150        .table(audit_logs_table())
151        .col(audit_log_user_id())
152        .if_not_exists()
153        .to_owned()
154}
155
156/// Builds the action/created/user activity index.
157pub fn create_audit_logs_action_created_user_index() -> IndexCreateStatement {
158    Index::create()
159        .name(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
160        .table(audit_logs_table())
161        .col(audit_log_action())
162        .col(audit_log_created_at())
163        .col(audit_log_user_id())
164        .if_not_exists()
165        .to_owned()
166}
167
168/// Builds the created-at/id cursor index.
169pub fn create_audit_logs_created_id_index() -> IndexCreateStatement {
170    Index::create()
171        .name(AUDIT_LOG_CREATED_ID_INDEX)
172        .table(audit_logs_table())
173        .col(audit_log_created_at())
174        .col(audit_log_id())
175        .if_not_exists()
176        .to_owned()
177}
178
179/// Builds the user/created-at/id cursor index.
180pub fn create_audit_logs_user_created_id_index() -> IndexCreateStatement {
181    Index::create()
182        .name(AUDIT_LOG_USER_CREATED_ID_INDEX)
183        .table(audit_logs_table())
184        .col(audit_log_user_id())
185        .col(audit_log_created_at())
186        .col(audit_log_id())
187        .if_not_exists()
188        .to_owned()
189}
190
191/// Builds the action/created-at/id cursor index.
192pub fn create_audit_logs_action_created_id_index() -> IndexCreateStatement {
193    Index::create()
194        .name(AUDIT_LOG_ACTION_CREATED_ID_INDEX)
195        .table(audit_logs_table())
196        .col(audit_log_action())
197        .col(audit_log_created_at())
198        .col(audit_log_id())
199        .if_not_exists()
200        .to_owned()
201}
202
203/// Builds the entity-type/created-at/id cursor index.
204pub fn create_audit_logs_entity_type_created_id_index() -> IndexCreateStatement {
205    Index::create()
206        .name(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
207        .table(audit_logs_table())
208        .col(audit_log_entity_type())
209        .col(audit_log_created_at())
210        .col(audit_log_id())
211        .if_not_exists()
212        .to_owned()
213}
214
215/// Returns the base index builders used by the current shared schema.
216pub fn create_audit_logs_base_indexes() -> [IndexCreateStatement; 3] {
217    [
218        create_audit_logs_created_at_index(),
219        create_audit_logs_action_index(),
220        create_audit_logs_user_id_index(),
221    ]
222}
223
224/// Returns the optional activity/query index builders used by admin views.
225pub fn create_audit_logs_query_indexes() -> [IndexCreateStatement; 5] {
226    [
227        create_audit_logs_action_created_user_index(),
228        create_audit_logs_created_id_index(),
229        create_audit_logs_user_created_id_index(),
230        create_audit_logs_action_created_id_index(),
231        create_audit_logs_entity_type_created_id_index(),
232    ]
233}
234
235fn audit_logs_table() -> Alias {
236    Alias::new(AUDIT_LOGS_TABLE)
237}
238
239fn audit_log_id() -> Alias {
240    Alias::new(AUDIT_LOG_ID_COLUMN)
241}
242
243fn audit_log_user_id() -> Alias {
244    Alias::new(AUDIT_LOG_USER_ID_COLUMN)
245}
246
247fn audit_log_action() -> Alias {
248    Alias::new(AUDIT_LOG_ACTION_COLUMN)
249}
250
251fn audit_log_entity_type() -> Alias {
252    Alias::new(AUDIT_LOG_ENTITY_TYPE_COLUMN)
253}
254
255fn audit_log_entity_id() -> Alias {
256    Alias::new(AUDIT_LOG_ENTITY_ID_COLUMN)
257}
258
259fn audit_log_entity_name() -> Alias {
260    Alias::new(AUDIT_LOG_ENTITY_NAME_COLUMN)
261}
262
263fn audit_log_details() -> Alias {
264    Alias::new(AUDIT_LOG_DETAILS_COLUMN)
265}
266
267fn audit_log_ip_address() -> Alias {
268    Alias::new(AUDIT_LOG_IP_ADDRESS_COLUMN)
269}
270
271fn audit_log_user_agent() -> Alias {
272    Alias::new(AUDIT_LOG_USER_AGENT_COLUMN)
273}
274
275fn audit_log_created_at() -> Alias {
276    Alias::new(AUDIT_LOG_CREATED_AT_COLUMN)
277}
278
279fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
280    let mut definition = ColumnDef::new(column);
281    match backend {
282        DatabaseBackend::MySql => {
283            definition.custom(Alias::new("datetime(6)"));
284        }
285        _ => {
286            definition.timestamp_with_time_zone();
287        }
288    }
289    definition
290}
291
292/// SeaORM model for `audit_logs` with product-neutral string action values.
293#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
294#[sea_orm(table_name = "audit_logs")]
295pub struct Model {
296    /// Stable row id.
297    #[sea_orm(primary_key)]
298    pub id: i64,
299    /// Actor user id. System events use `0`.
300    pub user_id: i64,
301    /// Stable action wire value.
302    pub action: String,
303    /// Target entity type.
304    pub entity_type: String,
305    /// Optional target entity id.
306    pub entity_id: Option<i64>,
307    /// Optional target entity display name.
308    pub entity_name: Option<String>,
309    /// Optional product-owned JSON detail payload.
310    pub details: Option<String>,
311    /// Optional client IP.
312    pub ip_address: Option<String>,
313    /// Optional user-agent.
314    pub user_agent: Option<String>,
315    /// Row creation timestamp.
316    pub created_at: DateTimeUtc,
317}
318
319/// Audit log relations.
320#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
321pub enum Relation {}
322
323impl ActiveModelBehavior for ActiveModel {}
324
325/// Product-neutral request to insert one audit log row.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct AuditLogCreate {
328    /// Actor user id. System events use `0`.
329    pub user_id: i64,
330    /// Stable action wire value.
331    pub action: String,
332    /// Target entity type.
333    pub entity_type: String,
334    /// Optional target entity id.
335    pub entity_id: Option<i64>,
336    /// Optional target entity display name.
337    pub entity_name: Option<String>,
338    /// Optional product-owned JSON detail payload.
339    pub details: Option<String>,
340    /// Optional client IP.
341    pub ip_address: Option<String>,
342    /// Optional user-agent.
343    pub user_agent: Option<String>,
344    /// Row creation timestamp.
345    pub created_at: DateTime<Utc>,
346}
347
348/// Product-neutral cursor query for audit logs sorted by `(created_at, id)` descending.
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub struct AuditLogQuery<'a> {
351    /// Optional actor user id filter.
352    pub user_id: Option<i64>,
353    /// Optional action wire value filter.
354    pub action: Option<&'a str>,
355    /// Optional entity type wire value filter.
356    pub entity_type: Option<&'a str>,
357    /// Optional entity id filter.
358    pub entity_id: Option<i64>,
359    /// Optional inclusive lower created-at bound.
360    pub after: Option<DateTime<Utc>>,
361    /// Optional inclusive upper created-at bound.
362    pub before: Option<DateTime<Utc>>,
363    /// Requested page size. Clamped to `1..=200`.
364    pub limit: u64,
365    /// Cursor from the previous page, encoded as `(created_at, id)`.
366    pub cursor: Option<(DateTime<Utc>, i64)>,
367}
368
369/// Result of an audit log cursor query.
370#[derive(Debug, Clone, PartialEq)]
371pub struct AuditLogCursorSlice {
372    /// Current page items.
373    pub items: Vec<Model>,
374    /// Total rows matching the filters before cursor slicing.
375    pub total: u64,
376    /// Whether another page exists after this slice.
377    pub has_more: bool,
378}
379
380impl AuditLogCursorSlice {
381    fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
382        let item_count = u64::try_from(items.len()).map_err(|_| {
383            crate::DbError::non_retryable("audit log cursor item count is too large")
384        })?;
385        let has_more = item_count > limit;
386        if has_more {
387            let target_len = usize::try_from(limit).map_err(|_| {
388                crate::DbError::non_retryable("audit log cursor limit is too large")
389            })?;
390            items.truncate(target_len);
391        }
392        Ok(Self {
393            items,
394            total,
395            has_more,
396        })
397    }
398}
399
400impl AuditLogCreate {
401    /// Converts the request into a validated SeaORM active model.
402    pub fn into_active_model(self) -> crate::Result<ActiveModel> {
403        validate_create(&self)?;
404        Ok(ActiveModel {
405            id: Default::default(),
406            user_id: Set(self.user_id),
407            action: Set(self.action),
408            entity_type: Set(self.entity_type),
409            entity_id: Set(self.entity_id),
410            entity_name: Set(self.entity_name),
411            details: Set(self.details),
412            ip_address: Set(self.ip_address),
413            user_agent: Set(self.user_agent),
414            created_at: Set(self.created_at),
415        })
416    }
417}
418
419/// SeaORM-backed audit log store.
420#[derive(Debug, Clone)]
421pub struct AuditLogDbStore {
422    db: DatabaseConnection,
423}
424
425impl AuditLogDbStore {
426    /// Creates an audit log store from a SeaORM database connection.
427    pub const fn new(db: DatabaseConnection) -> Self {
428        Self { db }
429    }
430
431    /// Inserts one audit log row.
432    pub async fn create(&self, request: AuditLogCreate) -> crate::Result<Model> {
433        create_audit_log_row(&self.db, request).await
434    }
435
436    /// Inserts multiple already-built audit log active models.
437    pub async fn create_many(&self, models: Vec<ActiveModel>) -> crate::Result<()> {
438        create_audit_log_rows(&self.db, models).await
439    }
440
441    /// Inserts multiple audit log create requests.
442    pub async fn create_many_requests(&self, requests: Vec<AuditLogCreate>) -> crate::Result<()> {
443        create_audit_log_requests(&self.db, requests).await
444    }
445
446    /// Counts rows created in `[start, end)`.
447    pub async fn count_created_between(
448        &self,
449        start: DateTime<Utc>,
450        end: DateTime<Utc>,
451    ) -> crate::Result<u64> {
452        count_audit_logs_created_between(&self.db, start, end).await
453    }
454
455    /// Counts rows for any of the supplied action wire values in `[start, end)`.
456    pub async fn count_created_between_with_actions(
457        &self,
458        start: DateTime<Utc>,
459        end: DateTime<Utc>,
460        actions: &[&str],
461    ) -> crate::Result<u64> {
462        count_audit_logs_created_between_with_actions(&self.db, start, end, actions).await
463    }
464
465    /// Deletes rows created before the supplied cutoff.
466    pub async fn delete_before(&self, before: DateTime<Utc>) -> crate::Result<u64> {
467        delete_audit_logs_before(&self.db, before).await
468    }
469
470    /// Finds audit logs with shared cursor filtering.
471    pub async fn find_with_filters_cursor(
472        &self,
473        query: AuditLogQuery<'_>,
474    ) -> crate::Result<AuditLogCursorSlice> {
475        find_audit_logs_with_filters_cursor(&self.db, query).await
476    }
477
478    /// Counts distinct positive user ids for any supplied action wire value in `[start, end)`.
479    pub async fn count_distinct_users_created_between_with_actions(
480        &self,
481        start: DateTime<Utc>,
482        end: DateTime<Utc>,
483        actions: &[&str],
484    ) -> crate::Result<u64> {
485        count_distinct_audit_log_users_created_between_with_actions(&self.db, start, end, actions)
486            .await
487    }
488}
489
490/// Inserts one audit log row using any SeaORM connection or transaction.
491pub async fn create_audit_log_row<C>(db: &C, request: AuditLogCreate) -> crate::Result<Model>
492where
493    C: ConnectionTrait,
494{
495    request
496        .into_active_model()?
497        .insert(db)
498        .await
499        .map_err(crate::DbError::from)
500}
501
502/// Inserts many validated audit log create requests.
503pub async fn create_audit_log_requests<C>(
504    db: &C,
505    requests: Vec<AuditLogCreate>,
506) -> crate::Result<()>
507where
508    C: ConnectionTrait,
509{
510    if requests.is_empty() {
511        return Ok(());
512    }
513    let models = requests
514        .into_iter()
515        .map(AuditLogCreate::into_active_model)
516        .collect::<crate::Result<Vec<_>>>()?;
517    create_audit_log_rows(db, models).await
518}
519
520/// Inserts many audit log active models.
521pub async fn create_audit_log_rows<C>(db: &C, models: Vec<ActiveModel>) -> crate::Result<()>
522where
523    C: ConnectionTrait,
524{
525    if models.is_empty() {
526        return Ok(());
527    }
528    Entity::insert_many(models)
529        .exec(db)
530        .await
531        .map_err(crate::DbError::from)?;
532    Ok(())
533}
534
535/// Counts audit log rows created in `[start, end)`.
536pub async fn count_audit_logs_created_between<C>(
537    db: &C,
538    start: DateTime<Utc>,
539    end: DateTime<Utc>,
540) -> crate::Result<u64>
541where
542    C: ConnectionTrait,
543{
544    Entity::find()
545        .filter(Column::CreatedAt.gte(start))
546        .filter(Column::CreatedAt.lt(end))
547        .count(db)
548        .await
549        .map_err(crate::DbError::from)
550}
551
552/// Counts audit log rows for any of the supplied action wire values in `[start, end)`.
553pub async fn count_audit_logs_created_between_with_actions<C>(
554    db: &C,
555    start: DateTime<Utc>,
556    end: DateTime<Utc>,
557    actions: &[&str],
558) -> crate::Result<u64>
559where
560    C: ConnectionTrait,
561{
562    if actions.is_empty() {
563        return Ok(0);
564    }
565    Entity::find()
566        .filter(Column::CreatedAt.gte(start))
567        .filter(Column::CreatedAt.lt(end))
568        .filter(Column::Action.is_in(actions.iter().copied()))
569        .count(db)
570        .await
571        .map_err(crate::DbError::from)
572}
573
574/// Counts distinct positive user ids for any supplied action wire value in `[start, end)`.
575pub async fn count_distinct_audit_log_users_created_between_with_actions<C>(
576    db: &C,
577    start: DateTime<Utc>,
578    end: DateTime<Utc>,
579    actions: &[&str],
580) -> crate::Result<u64>
581where
582    C: ConnectionTrait,
583{
584    if actions.is_empty() {
585        return Ok(0);
586    }
587    let count = Entity::find()
588        .select_only()
589        .column_as(
590            Expr::col(Column::UserId).count_distinct(),
591            "distinct_user_count",
592        )
593        .filter(Column::CreatedAt.gte(start))
594        .filter(Column::CreatedAt.lt(end))
595        .filter(Column::Action.is_in(actions.iter().copied()))
596        .filter(Column::UserId.gt(0))
597        .into_tuple::<i64>()
598        .one(db)
599        .await
600        .map_err(crate::DbError::from)?
601        .unwrap_or(0);
602
603    u64::try_from(count)
604        .map_err(|_| crate::DbError::non_retryable("distinct audit log user count is negative"))
605}
606
607/// Finds audit logs with shared cursor filtering.
608pub async fn find_audit_logs_with_filters_cursor<C>(
609    db: &C,
610    query: AuditLogQuery<'_>,
611) -> crate::Result<AuditLogCursorSlice>
612where
613    C: ConnectionTrait,
614{
615    let mut statement = Entity::find();
616    let limit = query.limit.clamp(1, 200);
617
618    if let Some(user_id) = query.user_id {
619        statement = statement.filter(Column::UserId.eq(user_id));
620    }
621    if let Some(action) = query.action {
622        statement = statement.filter(Column::Action.eq(action));
623    }
624    if let Some(entity_type) = query.entity_type {
625        statement = statement.filter(Column::EntityType.eq(entity_type));
626    }
627    if let Some(entity_id) = query.entity_id {
628        statement = statement.filter(Column::EntityId.eq(entity_id));
629    }
630    if let Some(after) = query.after {
631        statement = statement.filter(Column::CreatedAt.gte(after));
632    }
633    if let Some(before) = query.before {
634        statement = statement.filter(Column::CreatedAt.lte(before));
635    }
636
637    let total = statement
638        .clone()
639        .count(db)
640        .await
641        .map_err(crate::DbError::from)?;
642    if let Some((created_at, id)) = query.cursor {
643        statement = statement.filter(
644            Condition::any().add(Column::CreatedAt.lt(created_at)).add(
645                Condition::all()
646                    .add(Column::CreatedAt.eq(created_at))
647                    .add(Column::Id.lt(id)),
648            ),
649        );
650    }
651
652    let items = statement
653        .order_by_desc(Column::CreatedAt)
654        .order_by_desc(Column::Id)
655        .limit(limit.saturating_add(1))
656        .all(db)
657        .await
658        .map_err(crate::DbError::from)?;
659    AuditLogCursorSlice::from_overfetch(items, total, limit)
660}
661
662/// Deletes audit log rows created before the supplied cutoff.
663pub async fn delete_audit_logs_before<C>(db: &C, before: DateTime<Utc>) -> crate::Result<u64>
664where
665    C: ConnectionTrait,
666{
667    let result = Entity::delete_many()
668        .filter(Column::CreatedAt.lt(before))
669        .exec(db)
670        .await
671        .map_err(crate::DbError::from)?;
672    Ok(result.rows_affected)
673}
674
675fn validate_create(request: &AuditLogCreate) -> crate::Result<()> {
676    if request.user_id < 0 {
677        return Err(crate::DbError::non_retryable(
678            "audit log user id must be non-negative",
679        ));
680    }
681    validate_non_empty("audit log action", &request.action)?;
682    validate_max_len(
683        "audit log action",
684        &request.action,
685        AUDIT_LOG_ACTION_MAX_BYTES,
686    )?;
687    validate_non_empty("audit log entity type", &request.entity_type)?;
688    validate_max_len(
689        "audit log entity type",
690        &request.entity_type,
691        AUDIT_LOG_ENTITY_TYPE_MAX_BYTES,
692    )?;
693    if let Some(value) = &request.entity_name {
694        validate_max_len(
695            "audit log entity name",
696            value,
697            AUDIT_LOG_ENTITY_NAME_MAX_BYTES,
698        )?;
699    }
700    if let Some(value) = &request.ip_address {
701        validate_max_len(
702            "audit log ip address",
703            value,
704            AUDIT_LOG_IP_ADDRESS_MAX_BYTES,
705        )?;
706    }
707    if let Some(value) = &request.user_agent {
708        validate_max_len(
709            "audit log user agent",
710            value,
711            AUDIT_LOG_USER_AGENT_MAX_BYTES,
712        )?;
713    }
714    Ok(())
715}
716
717fn validate_non_empty(name: &str, value: &str) -> crate::Result<()> {
718    if value.trim().is_empty() {
719        return Err(crate::DbError::non_retryable(format!(
720            "{name} cannot be empty"
721        )));
722    }
723    Ok(())
724}
725
726fn validate_max_len(name: &str, value: &str, max_len: usize) -> crate::Result<()> {
727    if value.len() > max_len {
728        return Err(crate::DbError::non_retryable(format!(
729            "{name} must be at most {max_len} bytes",
730        )));
731    }
732    Ok(())
733}
734
735#[cfg(test)]
736mod tests {
737    use chrono::{Duration, Utc};
738    use sea_orm::sea_query::SqliteQueryBuilder;
739    use sea_orm::{ConnectionTrait, DbBackend, EntityTrait, Set};
740
741    use super::{
742        AUDIT_LOG_ACTION_CREATED_USER_INDEX, AUDIT_LOG_CREATED_ID_INDEX,
743        AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX, AUDIT_LOG_USER_CREATED_ID_INDEX, ActiveModel,
744        AuditLogCreate, AuditLogDbStore, AuditLogQuery, Entity, create_audit_log_rows,
745        create_audit_logs_action_created_user_index, create_audit_logs_base_indexes,
746        create_audit_logs_created_id_index, create_audit_logs_entity_type_created_id_index,
747        create_audit_logs_query_indexes, create_audit_logs_table,
748        create_audit_logs_user_created_id_index,
749    };
750    use crate::DatabaseErrorKind;
751
752    async fn sqlite_store() -> AuditLogDbStore {
753        let db = sea_orm::Database::connect("sqlite::memory:")
754            .await
755            .expect("audit log test database should connect");
756        db.execute(&create_audit_logs_table(DbBackend::Sqlite))
757            .await
758            .expect("audit logs table builder should execute");
759        for index in create_audit_logs_base_indexes() {
760            db.execute(&index)
761                .await
762                .expect("audit logs base index builder should execute");
763        }
764        for index in create_audit_logs_query_indexes() {
765            db.execute(&index)
766                .await
767                .expect("audit logs query index builder should execute");
768        }
769        AuditLogDbStore::new(db)
770    }
771
772    fn create_request(created_at: chrono::DateTime<Utc>, action: &str) -> AuditLogCreate {
773        AuditLogCreate {
774            user_id: 42,
775            action: action.to_string(),
776            entity_type: "system".to_string(),
777            entity_id: Some(7),
778            entity_name: Some("server".to_string()),
779            details: Some(r#"{"ok":true}"#.to_string()),
780            ip_address: Some("127.0.0.1".to_string()),
781            user_agent: Some("test".to_string()),
782            created_at,
783        }
784    }
785
786    #[tokio::test]
787    async fn create_audit_log_rows_preserves_driver_error_classification() {
788        let db = sea_orm::Database::connect("sqlite::memory:")
789            .await
790            .expect("audit log test database should connect");
791        db.execute(&create_audit_logs_table(DbBackend::Sqlite))
792            .await
793            .expect("audit logs table builder should execute");
794
795        let duplicate_row = || {
796            let mut model = create_request(Utc::now(), "audit.test")
797                .into_active_model()
798                .expect("create request should convert into an active model");
799            model.id = Set(1);
800            model
801        };
802
803        create_audit_log_rows(&db, vec![duplicate_row()])
804            .await
805            .expect("first insert should succeed");
806        let error = create_audit_log_rows(&db, vec![duplicate_row()])
807            .await
808            .expect_err("re-inserting the same id must violate the primary key");
809
810        // The helper must keep the driver-native classification (unique violation)
811        // instead of flattening the error into an unclassified operation failure.
812        assert_eq!(
813            error.database_error_kind(),
814            Some(DatabaseErrorKind::UniqueConstraint)
815        );
816    }
817
818    #[test]
819    fn create_audit_logs_table_uses_stable_shape() {
820        let sql = create_audit_logs_table(DbBackend::Sqlite).to_string(SqliteQueryBuilder);
821        assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"audit_logs\""));
822        assert!(sql.contains("\"user_id\""));
823        assert!(sql.contains("DEFAULT"));
824        assert!(sql.contains("\"action\""));
825        assert!(sql.contains("\"entity_type\""));
826        assert!(sql.contains("\"ip_address\""));
827
828        assert!(
829            create_audit_logs_action_created_user_index()
830                .to_string(SqliteQueryBuilder)
831                .contains(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
832        );
833        assert!(
834            create_audit_logs_created_id_index()
835                .to_string(SqliteQueryBuilder)
836                .contains(AUDIT_LOG_CREATED_ID_INDEX)
837        );
838        assert!(
839            create_audit_logs_user_created_id_index()
840                .to_string(SqliteQueryBuilder)
841                .contains(AUDIT_LOG_USER_CREATED_ID_INDEX)
842        );
843        assert!(
844            create_audit_logs_entity_type_created_id_index()
845                .to_string(SqliteQueryBuilder)
846                .contains(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
847        );
848    }
849
850    #[tokio::test]
851    async fn audit_log_store_creates_counts_and_deletes_rows() {
852        let store = sqlite_store().await;
853        let now = Utc::now();
854
855        let created = store
856            .create(create_request(now, "server_shutdown"))
857            .await
858            .expect("audit log row should insert");
859        assert_eq!(created.user_id, 42);
860        assert_eq!(created.action, "server_shutdown");
861
862        let count = store
863            .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
864            .await
865            .expect("audit log count should succeed");
866        assert_eq!(count, 1);
867
868        let action_count = store
869            .count_created_between_with_actions(
870                now - Duration::seconds(1),
871                now + Duration::seconds(1),
872                &["server_shutdown"],
873            )
874            .await
875            .expect("audit log action count should succeed");
876        assert_eq!(action_count, 1);
877
878        let deleted = store
879            .delete_before(now + Duration::seconds(1))
880            .await
881            .expect("audit log delete should succeed");
882        assert_eq!(deleted, 1);
883    }
884
885    #[tokio::test]
886    async fn audit_log_store_filters_cursor_pages_and_counts_distinct_users() {
887        let store = sqlite_store().await;
888        let base = Utc::now();
889
890        store
891            .create_many_requests(vec![
892                AuditLogCreate {
893                    user_id: 1,
894                    created_at: base - Duration::seconds(3),
895                    ..create_request(base - Duration::seconds(3), "user_login")
896                },
897                AuditLogCreate {
898                    user_id: 1,
899                    created_at: base - Duration::seconds(2),
900                    ..create_request(base - Duration::seconds(2), "user_login")
901                },
902                AuditLogCreate {
903                    user_id: 2,
904                    entity_type: "profile".to_string(),
905                    created_at: base - Duration::seconds(1),
906                    ..create_request(base - Duration::seconds(1), "profile_update")
907                },
908                AuditLogCreate {
909                    user_id: 0,
910                    created_at: base,
911                    ..create_request(base, "user_login")
912                },
913            ])
914            .await
915            .expect("audit query fixtures should insert");
916
917        let first_page = store
918            .find_with_filters_cursor(AuditLogQuery {
919                user_id: None,
920                action: Some("user_login"),
921                entity_type: None,
922                entity_id: None,
923                after: Some(base - Duration::seconds(10)),
924                before: Some(base + Duration::seconds(1)),
925                limit: 2,
926                cursor: None,
927            })
928            .await
929            .expect("first audit log page should query");
930        assert_eq!(first_page.total, 3);
931        assert!(first_page.has_more);
932        assert_eq!(first_page.items.len(), 2);
933        assert_eq!(first_page.items[0].user_id, 0);
934
935        let cursor = first_page
936            .items
937            .last()
938            .map(|item| (item.created_at, item.id))
939            .expect("first page should have a cursor item");
940        let second_page = store
941            .find_with_filters_cursor(AuditLogQuery {
942                user_id: None,
943                action: Some("user_login"),
944                entity_type: None,
945                entity_id: None,
946                after: Some(base - Duration::seconds(10)),
947                before: Some(base + Duration::seconds(1)),
948                limit: 2,
949                cursor: Some(cursor),
950            })
951            .await
952            .expect("second audit log page should query");
953        assert!(!second_page.has_more);
954        assert_eq!(second_page.items.len(), 1);
955
956        let distinct = store
957            .count_distinct_users_created_between_with_actions(
958                base - Duration::seconds(10),
959                base + Duration::seconds(1),
960                &["user_login", "profile_update"],
961            )
962            .await
963            .expect("distinct user count should query");
964        assert_eq!(distinct, 2);
965    }
966
967    #[tokio::test]
968    async fn audit_log_store_rejects_invalid_create_values() {
969        let store = sqlite_store().await;
970        let error = store
971            .create(AuditLogCreate {
972                action: String::new(),
973                ..create_request(Utc::now(), "server_shutdown")
974            })
975            .await
976            .expect_err("empty action should be rejected");
977        assert!(error.to_string().contains("audit log action"));
978    }
979
980    #[tokio::test]
981    async fn audit_log_create_many_accepts_empty_and_inserts_batch() {
982        let store = sqlite_store().await;
983        store
984            .create_many(Vec::new())
985            .await
986            .expect("empty batch should be accepted");
987
988        let now = Utc::now();
989        store
990            .create_many(vec![
991                ActiveModel {
992                    id: Default::default(),
993                    user_id: Set(1),
994                    action: Set("a".to_string()),
995                    entity_type: Set("system".to_string()),
996                    entity_id: Set(None),
997                    entity_name: Set(None),
998                    details: Set(None),
999                    ip_address: Set(None),
1000                    user_agent: Set(None),
1001                    created_at: Set(now),
1002                },
1003                ActiveModel {
1004                    id: Default::default(),
1005                    user_id: Set(2),
1006                    action: Set("b".to_string()),
1007                    entity_type: Set("system".to_string()),
1008                    entity_id: Set(None),
1009                    entity_name: Set(None),
1010                    details: Set(None),
1011                    ip_address: Set(None),
1012                    user_agent: Set(None),
1013                    created_at: Set(now),
1014                },
1015            ])
1016            .await
1017            .expect("batch insert should succeed");
1018
1019        let db = store.db.clone();
1020        let rows = Entity::find()
1021            .all(&db)
1022            .await
1023            .expect("audit log rows should query");
1024        assert_eq!(rows.len(), 2);
1025    }
1026
1027    #[tokio::test]
1028    async fn audit_log_create_many_requests_validates_and_inserts_batch() {
1029        let store = sqlite_store().await;
1030        let now = Utc::now();
1031
1032        store
1033            .create_many_requests(vec![
1034                create_request(now, "server_start"),
1035                create_request(now, "server_shutdown"),
1036            ])
1037            .await
1038            .expect("request batch insert should succeed");
1039
1040        let count = store
1041            .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
1042            .await
1043            .expect("audit log count should succeed");
1044        assert_eq!(count, 2);
1045
1046        let error = store
1047            .create_many_requests(vec![AuditLogCreate {
1048                action: " ".to_string(),
1049                ..create_request(now, "server_shutdown")
1050            }])
1051            .await
1052            .expect_err("invalid request batch should be rejected");
1053        assert!(error.to_string().contains("audit log action"));
1054    }
1055
1056    #[tokio::test]
1057    async fn audit_logs_builders_execute_on_sqlite_connection() {
1058        let db = sea_orm::Database::connect("sqlite::memory:")
1059            .await
1060            .expect("audit log builder test database should connect");
1061        db.execute(&create_audit_logs_table(DbBackend::Sqlite))
1062            .await
1063            .expect("audit logs table builder should execute");
1064        db.execute(&create_audit_logs_created_id_index())
1065            .await
1066            .expect("audit log index builder should execute");
1067        crate::drop_index_if_exists(&db, super::AUDIT_LOGS_TABLE, AUDIT_LOG_CREATED_ID_INDEX)
1068            .await
1069            .expect("shared index helper should drop the audit log index");
1070    }
1071}