Skip to main content

koprogo_api/infrastructure/database/repositories/
audit_log_repository_impl.rs

1use crate::application::dto::PageRequest;
2use crate::application::ports::{AuditLogFilters, AuditLogRepository};
3use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use sqlx::Row;
8use uuid::Uuid;
9
10pub struct PostgresAuditLogRepository {
11    pool: DbPool,
12}
13
14impl PostgresAuditLogRepository {
15    pub fn new(pool: DbPool) -> Self {
16        Self { pool }
17    }
18
19    /// Convert AuditEventType to string for database storage
20    fn event_type_to_string(event_type: &AuditEventType) -> String {
21        format!("{:?}", event_type)
22    }
23
24    /// Relit le type d'évènement écrit en base.
25    ///
26    /// ── Ce que cette fonction a longtemps fait ────────────────────────────
27    ///
28    /// L'écriture emploie `format!("{:?}")`, donc les **223** variantes de
29    /// `AuditEventType` arrivent correctement en base. La relecture, elle,
30    /// était une table de correspondance écrite à la main : **29 arms**, et
31    /// un repli
32    ///
33    ///     _ => AuditEventType::UnauthorizedAccess, // Default fallback
34    ///
35    /// **194 types d'évènements sur 223 se relisaient donc en accès non
36    /// autorisé.** Une élection au conseil de copropriété, un rapport généré,
37    /// un partefeuille partagé : tous ressortaient du registre comme des
38    /// alertes de sécurité.
39    ///
40    /// Le registre d'audit n'est pas décoratif — l'Art. 3.89 § 5 7° impose au
41    /// syndic de tenir le dossier de la copropriété. Un registre qui invente
42    /// des alertes est pire qu'un registre vide : il fait chercher des
43    /// intrusions qui n'ont pas eu lieu, et noie celles qui en sont.
44    ///
45    /// ── Pourquoi serde plutôt qu'une table ────────────────────────────────
46    ///
47    /// `AuditEventType` dérive `Deserialize`, et ses variantes sont toutes
48    /// sans charge utile : leur forme sérialisée est leur nom, exactement ce
49    /// que `{:?}` écrit. La correspondance devient donc **totale par
50    /// construction**, et une variante ajoutée demain se relit sans que
51    /// personne ait à penser à cette fonction. C'est ce défaut d'attention
52    /// qui a produit l'écart, deux fois : la table ne suivait plus depuis
53    /// longtemps, et j'y ai moi-même ajouté deux lignes à la main avant de
54    /// compter le reste.
55    ///
56    /// Un type vraiment inconnu — ligne ancienne, ou écrite par une version
57    /// plus récente — se lit `UnknownLegacyEvent`, pas comme autre chose.
58    fn string_to_event_type(s: &str) -> AuditEventType {
59        serde_json::from_value(serde_json::Value::String(s.to_string()))
60            .unwrap_or(AuditEventType::UnknownLegacyEvent)
61    }
62
63    /// Map database row to AuditLogEntry
64    fn row_to_entry(row: &sqlx::postgres::PgRow) -> AuditLogEntry {
65        let event_type_str: String = row.get("event_type");
66        let metadata_json: Option<serde_json::Value> = row.get("metadata");
67
68        AuditLogEntry {
69            id: row.get("id"),
70            timestamp: row.get("timestamp"),
71            event_type: Self::string_to_event_type(&event_type_str),
72            user_id: row.get("user_id"),
73            organization_id: row.get("organization_id"),
74            resource_type: row.get("resource_type"),
75            resource_id: row.get("resource_id"),
76            ip_address: row.get("ip_address"),
77            user_agent: row.get("user_agent"),
78            metadata: metadata_json,
79            success: row.get("success"),
80            error_message: row.get("error_message"),
81        }
82    }
83}
84
85#[async_trait]
86impl AuditLogRepository for PostgresAuditLogRepository {
87    async fn create(&self, entry: &AuditLogEntry) -> Result<AuditLogEntry, String> {
88        let event_type_str = Self::event_type_to_string(&entry.event_type);
89
90        sqlx::query(
91            r#"
92            INSERT INTO audit_logs (
93                id, timestamp, event_type, user_id, organization_id,
94                resource_type, resource_id, ip_address, user_agent,
95                metadata, success, error_message, created_at
96            )
97            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
98            "#,
99        )
100        .bind(entry.id)
101        .bind(entry.timestamp)
102        .bind(event_type_str)
103        .bind(entry.user_id)
104        .bind(entry.organization_id)
105        .bind(&entry.resource_type)
106        .bind(entry.resource_id)
107        .bind(&entry.ip_address)
108        .bind(&entry.user_agent)
109        .bind(&entry.metadata)
110        .bind(entry.success)
111        .bind(&entry.error_message)
112        .bind(Utc::now())
113        .execute(&self.pool)
114        .await
115        .map_err(|e| format!("Database error: {}", e))?;
116
117        Ok(entry.clone())
118    }
119
120    async fn find_by_id(&self, id: Uuid) -> Result<Option<AuditLogEntry>, String> {
121        let row = sqlx::query(
122            r#"
123            SELECT id, timestamp, event_type, user_id, organization_id,
124                   resource_type, resource_id, ip_address, user_agent,
125                   metadata, success, error_message
126            FROM audit_logs
127            WHERE id = $1
128            "#,
129        )
130        .bind(id)
131        .fetch_optional(&self.pool)
132        .await
133        .map_err(|e| format!("Database error: {}", e))?;
134
135        Ok(row.map(|r| Self::row_to_entry(&r)))
136    }
137
138    async fn find_all_paginated(
139        &self,
140        page_request: &PageRequest,
141        filters: &AuditLogFilters,
142    ) -> Result<(Vec<AuditLogEntry>, i64), String> {
143        let limit = page_request.per_page.min(100);
144        let offset = (page_request.page - 1) * limit;
145
146        // Build WHERE clause dynamically based on filters
147        let mut where_clauses = Vec::new();
148        let mut param_index = 1;
149
150        if filters.user_id.is_some() {
151            where_clauses.push(format!("user_id = ${}", param_index));
152            param_index += 1;
153        }
154        if filters.organization_id.is_some() {
155            where_clauses.push(format!("organization_id = ${}", param_index));
156            param_index += 1;
157        }
158        if filters.event_type.is_some() {
159            where_clauses.push(format!("event_type = ${}", param_index));
160            param_index += 1;
161        }
162        if filters.success.is_some() {
163            where_clauses.push(format!("success = ${}", param_index));
164            param_index += 1;
165        }
166        if filters.start_date.is_some() {
167            where_clauses.push(format!("timestamp >= ${}", param_index));
168            param_index += 1;
169        }
170        if filters.end_date.is_some() {
171            where_clauses.push(format!("timestamp <= ${}", param_index));
172            param_index += 1;
173        }
174        if filters.resource_type.is_some() {
175            where_clauses.push(format!("resource_type = ${}", param_index));
176            param_index += 1;
177        }
178        if filters.resource_id.is_some() {
179            where_clauses.push(format!("resource_id = ${}", param_index));
180            param_index += 1;
181        }
182
183        let where_clause = if where_clauses.is_empty() {
184            String::new()
185        } else {
186            format!("WHERE {}", where_clauses.join(" AND "))
187        };
188
189        // Count total matching records
190        let count_query = format!("SELECT COUNT(*) as count FROM audit_logs {}", where_clause);
191        let mut count_query_builder = sqlx::query(&count_query);
192
193        // Bind parameters for count query
194        if let Some(user_id) = filters.user_id {
195            count_query_builder = count_query_builder.bind(user_id);
196        }
197        if let Some(org_id) = filters.organization_id {
198            count_query_builder = count_query_builder.bind(org_id);
199        }
200        if let Some(ref event_type) = filters.event_type {
201            count_query_builder = count_query_builder.bind(Self::event_type_to_string(event_type));
202        }
203        if let Some(success) = filters.success {
204            count_query_builder = count_query_builder.bind(success);
205        }
206        if let Some(start_date) = filters.start_date {
207            count_query_builder = count_query_builder.bind(start_date);
208        }
209        if let Some(end_date) = filters.end_date {
210            count_query_builder = count_query_builder.bind(end_date);
211        }
212        if let Some(ref resource_type) = filters.resource_type {
213            count_query_builder = count_query_builder.bind(resource_type);
214        }
215        if let Some(resource_id) = filters.resource_id {
216            count_query_builder = count_query_builder.bind(resource_id);
217        }
218
219        let count_row = count_query_builder
220            .fetch_one(&self.pool)
221            .await
222            .map_err(|e| format!("Database error: {}", e))?;
223        let total: i64 = count_row.get("count");
224
225        // Fetch paginated results
226        let data_query = format!(
227            r#"
228            SELECT id, timestamp, event_type, user_id, organization_id,
229                   resource_type, resource_id, ip_address, user_agent,
230                   metadata, success, error_message
231            FROM audit_logs
232            {}
233            ORDER BY timestamp DESC
234            LIMIT ${} OFFSET ${}
235            "#,
236            where_clause,
237            param_index,
238            param_index + 1
239        );
240
241        let mut data_query_builder = sqlx::query(&data_query);
242
243        // Bind parameters for data query
244        if let Some(user_id) = filters.user_id {
245            data_query_builder = data_query_builder.bind(user_id);
246        }
247        if let Some(org_id) = filters.organization_id {
248            data_query_builder = data_query_builder.bind(org_id);
249        }
250        if let Some(ref event_type) = filters.event_type {
251            data_query_builder = data_query_builder.bind(Self::event_type_to_string(event_type));
252        }
253        if let Some(success) = filters.success {
254            data_query_builder = data_query_builder.bind(success);
255        }
256        if let Some(start_date) = filters.start_date {
257            data_query_builder = data_query_builder.bind(start_date);
258        }
259        if let Some(end_date) = filters.end_date {
260            data_query_builder = data_query_builder.bind(end_date);
261        }
262        if let Some(ref resource_type) = filters.resource_type {
263            data_query_builder = data_query_builder.bind(resource_type);
264        }
265        if let Some(resource_id) = filters.resource_id {
266            data_query_builder = data_query_builder.bind(resource_id);
267        }
268
269        data_query_builder = data_query_builder.bind(limit).bind(offset);
270
271        let rows = data_query_builder
272            .fetch_all(&self.pool)
273            .await
274            .map_err(|e| format!("Database error: {}", e))?;
275
276        let entries: Vec<AuditLogEntry> = rows.iter().map(Self::row_to_entry).collect();
277
278        Ok((entries, total))
279    }
280
281    async fn find_recent(&self, limit: i64) -> Result<Vec<AuditLogEntry>, String> {
282        let rows = sqlx::query(
283            r#"
284            SELECT id, timestamp, event_type, user_id, organization_id,
285                   resource_type, resource_id, ip_address, user_agent,
286                   metadata, success, error_message
287            FROM audit_logs
288            ORDER BY timestamp DESC
289            LIMIT $1
290            "#,
291        )
292        .bind(limit)
293        .fetch_all(&self.pool)
294        .await
295        .map_err(|e| format!("Database error: {}", e))?;
296
297        Ok(rows.iter().map(Self::row_to_entry).collect())
298    }
299
300    async fn find_failed_operations(
301        &self,
302        page_request: &PageRequest,
303        organization_id: Option<Uuid>,
304    ) -> Result<(Vec<AuditLogEntry>, i64), String> {
305        let limit = page_request.per_page.min(100);
306        let offset = (page_request.page - 1) * limit;
307
308        let where_clause = if organization_id.is_some() {
309            "WHERE success = false AND organization_id = $1"
310        } else {
311            "WHERE success = false"
312        };
313
314        // Count
315        let count_query = format!("SELECT COUNT(*) as count FROM audit_logs {}", where_clause);
316        let count_row = if let Some(org_id) = organization_id {
317            sqlx::query(&count_query)
318                .bind(org_id)
319                .fetch_one(&self.pool)
320                .await
321                .map_err(|e| format!("Database error: {}", e))?
322        } else {
323            sqlx::query(&count_query)
324                .fetch_one(&self.pool)
325                .await
326                .map_err(|e| format!("Database error: {}", e))?
327        };
328        let total: i64 = count_row.get("count");
329
330        // Fetch data
331        let data_query = format!(
332            r#"
333            SELECT id, timestamp, event_type, user_id, organization_id,
334                   resource_type, resource_id, ip_address, user_agent,
335                   metadata, success, error_message
336            FROM audit_logs
337            {}
338            ORDER BY timestamp DESC
339            LIMIT $2 OFFSET $3
340            "#,
341            where_clause
342        );
343
344        let rows = if let Some(org_id) = organization_id {
345            sqlx::query(&data_query)
346                .bind(org_id)
347                .bind(limit)
348                .bind(offset)
349                .fetch_all(&self.pool)
350                .await
351                .map_err(|e| format!("Database error: {}", e))?
352        } else {
353            sqlx::query(&data_query)
354                .bind(limit)
355                .bind(offset)
356                .fetch_all(&self.pool)
357                .await
358                .map_err(|e| format!("Database error: {}", e))?
359        };
360
361        let entries: Vec<AuditLogEntry> = rows.iter().map(Self::row_to_entry).collect();
362
363        Ok((entries, total))
364    }
365
366    async fn delete_older_than(&self, timestamp: DateTime<Utc>) -> Result<i64, String> {
367        let result = sqlx::query(
368            r#"
369            DELETE FROM audit_logs
370            WHERE timestamp < $1
371            "#,
372        )
373        .bind(timestamp)
374        .execute(&self.pool)
375        .await
376        .map_err(|e| format!("Database error: {}", e))?;
377
378        Ok(result.rows_affected() as i64)
379    }
380
381    async fn count_by_filters(&self, filters: &AuditLogFilters) -> Result<i64, String> {
382        let mut where_clauses = Vec::new();
383        let mut param_index = 1;
384
385        if filters.user_id.is_some() {
386            where_clauses.push(format!("user_id = ${}", param_index));
387            param_index += 1;
388        }
389        if filters.organization_id.is_some() {
390            where_clauses.push(format!("organization_id = ${}", param_index));
391            param_index += 1;
392        }
393        if filters.event_type.is_some() {
394            where_clauses.push(format!("event_type = ${}", param_index));
395            param_index += 1;
396        }
397        if filters.success.is_some() {
398            where_clauses.push(format!("success = ${}", param_index));
399            param_index += 1;
400        }
401        if filters.start_date.is_some() {
402            where_clauses.push(format!("timestamp >= ${}", param_index));
403            param_index += 1;
404        }
405        if filters.end_date.is_some() {
406            where_clauses.push(format!("timestamp <= ${}", param_index));
407            param_index += 1;
408        }
409        if filters.resource_type.is_some() {
410            where_clauses.push(format!("resource_type = ${}", param_index));
411            param_index += 1;
412        }
413        if filters.resource_id.is_some() {
414            where_clauses.push(format!("resource_id = ${}", param_index));
415        }
416
417        let where_clause = if where_clauses.is_empty() {
418            String::new()
419        } else {
420            format!("WHERE {}", where_clauses.join(" AND "))
421        };
422
423        let count_query = format!("SELECT COUNT(*) as count FROM audit_logs {}", where_clause);
424        let mut query_builder = sqlx::query(&count_query);
425
426        // Bind parameters
427        if let Some(user_id) = filters.user_id {
428            query_builder = query_builder.bind(user_id);
429        }
430        if let Some(org_id) = filters.organization_id {
431            query_builder = query_builder.bind(org_id);
432        }
433        if let Some(ref event_type) = filters.event_type {
434            query_builder = query_builder.bind(Self::event_type_to_string(event_type));
435        }
436        if let Some(success) = filters.success {
437            query_builder = query_builder.bind(success);
438        }
439        if let Some(start_date) = filters.start_date {
440            query_builder = query_builder.bind(start_date);
441        }
442        if let Some(end_date) = filters.end_date {
443            query_builder = query_builder.bind(end_date);
444        }
445        if let Some(ref resource_type) = filters.resource_type {
446            query_builder = query_builder.bind(resource_type);
447        }
448        if let Some(resource_id) = filters.resource_id {
449            query_builder = query_builder.bind(resource_id);
450        }
451
452        let row = query_builder
453            .fetch_one(&self.pool)
454            .await
455            .map_err(|e| format!("Database error: {}", e))?;
456
457        Ok(row.get("count"))
458    }
459}
460
461#[cfg(test)]
462mod tests_relecture_du_type_devenement {
463    use super::*;
464
465    /// Toutes les variantes de `AuditEventType`, extraites du fichier source.
466    ///
467    /// Les LIRE plutôt que les recopier est le point de ce test : une liste
468    /// écrite à la main est exactement ce qui a dérivé — la table de
469    /// correspondance couvrait 29 variantes sur 223, et personne ne l'a vu
470    /// parce que rien ne comparait les deux listes.
471    fn variantes_declarees() -> Vec<String> {
472        let source = include_str!("../../audit.rs");
473        let debut = source
474            .find("pub enum AuditEventType")
475            .expect("énumération AuditEventType introuvable dans audit.rs");
476        let bloc = &source[debut..];
477        let fin = bloc.find("\n}").expect("fin de l'énumération introuvable");
478        bloc[..fin]
479            .lines()
480            .map(str::trim)
481            .filter(|l| {
482                l.ends_with(',')
483                    && !l.starts_with("//")
484                    && !l.contains(' ')
485                    && l.chars().next().is_some_and(char::is_uppercase)
486            })
487            .map(|l| l.trim_end_matches(',').to_string())
488            .collect()
489    }
490
491    #[test]
492    fn happy_le_registre_relit_toutes_les_variantes_pour_ce_quelles_sont() {
493        let variantes = variantes_declarees();
494        assert!(
495            variantes.len() > 200,
496            "seulement {} variantes extraites : l'extraction ne lit plus \
497             l'énumération. Vérifiez avant de vous réjouir.",
498            variantes.len()
499        );
500
501        let mut travesties = Vec::new();
502        for nom in &variantes {
503            let relu = PostgresAuditLogRepository::string_to_event_type(nom);
504            let ecrit = PostgresAuditLogRepository::event_type_to_string(&relu);
505            if &ecrit != nom {
506                travesties.push(format!("{nom} se relit {ecrit}"));
507            }
508        }
509
510        assert!(
511            travesties.is_empty(),
512            "{} types d'évènements sur {} ne se relisent pas pour ce qu'ils \
513             sont.\n\n\
514             Le registre d'audit est un document de la copropriété \
515             (Art. 3.89 § 5 7°). Un type travesti à la relecture y invente \
516             des évènements qui n'ont pas eu lieu.\n\n{}",
517            travesties.len(),
518            variantes.len(),
519            travesties.join("\n")
520        );
521    }
522
523    /// Le défaut historique, nommé : le repli fabriquait des alertes.
524    #[test]
525    fn security_un_type_inconnu_ne_se_deguise_pas_en_acces_non_autorise() {
526        let relu = PostgresAuditLogRepository::string_to_event_type("EvenementDunFuturLointain");
527        assert_eq!(
528            relu,
529            AuditEventType::UnknownLegacyEvent,
530            "un type inconnu doit se lire inconnu. Le repli était \
531             `UnauthorizedAccess` : 194 types sur 223 ressortaient du \
532             registre en alertes de sécurité, et une élection au conseil de \
533             copropriété se lisait comme une intrusion."
534        );
535        assert_ne!(relu, AuditEventType::UnauthorizedAccess);
536    }
537
538    /// Une variante ajoutée demain n'a pas besoin qu'on pense à la relecture.
539    #[test]
540    fn happy_une_variante_recente_se_relit_sans_table_a_tenir() {
541        for nom in [
542            "MeetingCancelled",
543            "MeetingRescheduled",
544            "PortfolioShared",
545            "ExpenseMarkedOverdue",
546            "ExpenseCancelled",
547            "ExpenseReactivated",
548            "ExpenseUnpaid",
549        ] {
550            let relu = PostgresAuditLogRepository::string_to_event_type(nom);
551            assert_eq!(PostgresAuditLogRepository::event_type_to_string(&relu), nom);
552        }
553    }
554}