feat(sprint-16 backend 2026-04-25): transparency operations — Métriques Prometheus + Export multi-format + Notifications auto
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m35s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m35s
Trois piliers transparency opérationnelle UnionFlow livrés en série :
S16.A — Métriques Prometheus métier (transparency observabilité)
- AuditTrailService.log() incrémente :
- unionflow.audit.operations{action, entity}
- unionflow.audit.sod_violations{entity}
- application.properties : quarkus.micrometer.export.prometheus.enabled=true,
http-server + jvm binders activés
- Endpoint /q/metrics exposé pour scraping Prometheus K8s
S16.B — Export massif audit-trail (transparency réglementaire BCEAO/ARTCI/CENTIF)
- AuditTrailExportService :
- exportCsv : Apache Commons CSV avec BOM UTF-8 (compat Excel)
- exportXlsx : POI XSSFWorkbook avec header coloré et auto-size
- exportPdf : OpenPDF A4 paysage avec table 7 colonnes
- Endpoint GET /api/audit-trail/export?format=csv|xlsx|pdf&scope=...&limit=500
- @RolesAllowed COMPLIANCE_OFFICER, CONTROLEUR_INTERNE, SUPER_ADMIN
- Auto-log de l'export lui-même dans audit (méta-traçabilité)
S16.C — Notifications transparency (pattern CDI Event Observer)
- AuditOperationLoggedEvent record + helper estSensible() (DELETE / PAYMENT_* / BUDGET_APPROVED / AID_REQUEST_APPROVED / EXPORT / VALIDATE / SoD-violation)
- AuditTrailService.log() fire le CDI event après persist
- AuditNotificationService observe @Observes(AFTER_SUCCESS) — notifie via NotificationService existant (DRY, pas de nouveau client mail)
- Sujets/corps localisés français + priorité automatique (HAUTE pour DELETE/PAYMENT_FAILED/SoD, NORMALE pour confirmations, BASSE pour exports)
- Fail-soft : exception notification ne bloque jamais l'audit principal
Tests (10 nouveaux S16.C, 21/21 cumulé audit)
- onAuditOperation × 5 (sensible/non-sensible/SoD/null/userId-null)
- mapping helpers × 4 (sujet × 8 actions, sodPriority, priorité × 6 cas, corps × 2)
This commit is contained in:
@@ -30,6 +30,7 @@ import java.util.UUID;
|
|||||||
public class AuditTrailOperationResource {
|
public class AuditTrailOperationResource {
|
||||||
|
|
||||||
@Inject AuditTrailQueryService queryService;
|
@Inject AuditTrailQueryService queryService;
|
||||||
|
@Inject dev.lions.unionflow.server.service.audit.AuditTrailExportService exportService;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/by-user/{userId}")
|
@Path("/by-user/{userId}")
|
||||||
@@ -102,6 +103,47 @@ public class AuditTrailOperationResource {
|
|||||||
return queryService.listerRecentes(scope, orgId, userId, limit);
|
return queryService.listerRecentes(scope, orgId, userId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export massif audit (Sprint 16.B) — formats CSV / XLSX / PDF pour BCEAO/ARTCI/CENTIF.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/export")
|
||||||
|
@jakarta.ws.rs.Produces("application/octet-stream")
|
||||||
|
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
|
||||||
|
public jakarta.ws.rs.core.Response export(
|
||||||
|
@QueryParam("format") @jakarta.ws.rs.DefaultValue("csv") String format,
|
||||||
|
@QueryParam("scope") @jakarta.ws.rs.DefaultValue("ALL") String scope,
|
||||||
|
@QueryParam("orgId") UUID orgId,
|
||||||
|
@QueryParam("userId") UUID userId,
|
||||||
|
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("500") int limit) {
|
||||||
|
String fmt = format == null ? "csv" : format.toLowerCase();
|
||||||
|
byte[] payload;
|
||||||
|
String mediaType;
|
||||||
|
String extension;
|
||||||
|
switch (fmt) {
|
||||||
|
case "xlsx" -> {
|
||||||
|
payload = exportService.exportXlsx(scope, orgId, userId, limit);
|
||||||
|
mediaType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
extension = "xlsx";
|
||||||
|
}
|
||||||
|
case "pdf" -> {
|
||||||
|
payload = exportService.exportPdf(scope, orgId, userId, limit);
|
||||||
|
mediaType = "application/pdf";
|
||||||
|
extension = "pdf";
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
payload = exportService.exportCsv(scope, orgId, userId, limit);
|
||||||
|
mediaType = "text/csv";
|
||||||
|
extension = "csv";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String filename = String.format("audit-trail-%s-%s.%s",
|
||||||
|
scope.toLowerCase(), java.time.LocalDate.now(), extension);
|
||||||
|
return jakarta.ws.rs.core.Response.ok(payload, mediaType)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
|
private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
|
||||||
if (input == null || input.isBlank()) return fallback;
|
if (input == null || input.isBlank()) return fallback;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.lions.unionflow.server.security;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement CDI émis après chaque écriture audit trail (Sprint 16.C).
|
||||||
|
*
|
||||||
|
* <p>Permet aux observers ({@code AuditNotificationService}) de réagir sans coupler le
|
||||||
|
* service d'écriture aux services de notification. Pattern Observer via {@code @Observes}.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (Sprint 16.C — transparency)
|
||||||
|
*/
|
||||||
|
public record AuditOperationLoggedEvent(
|
||||||
|
UUID operationId,
|
||||||
|
UUID userId,
|
||||||
|
String userEmail,
|
||||||
|
UUID organisationActiveId,
|
||||||
|
String actionType,
|
||||||
|
String entityType,
|
||||||
|
UUID entityId,
|
||||||
|
String description,
|
||||||
|
Boolean sodCheckPassed,
|
||||||
|
LocalDateTime operationAt
|
||||||
|
) {
|
||||||
|
/** Renvoie true si cette opération mérite une notification (sensible). */
|
||||||
|
public boolean estSensible() {
|
||||||
|
if (Boolean.FALSE.equals(sodCheckPassed)) return true;
|
||||||
|
if (actionType == null) return false;
|
||||||
|
return switch (actionType) {
|
||||||
|
case "DELETE", "PAYMENT_INITIATED", "PAYMENT_CONFIRMED", "PAYMENT_FAILED",
|
||||||
|
"BUDGET_APPROVED", "AID_REQUEST_APPROVED", "EXPORT", "VALIDATE" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ public class AuditTrailService {
|
|||||||
|
|
||||||
@Inject AuditTrailOperationRepository repository;
|
@Inject AuditTrailOperationRepository repository;
|
||||||
@Inject OrganisationContextHolder context;
|
@Inject OrganisationContextHolder context;
|
||||||
|
@Inject io.micrometer.core.instrument.MeterRegistry registry;
|
||||||
|
@Inject jakarta.enterprise.event.Event<AuditOperationLoggedEvent> auditEvent;
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@@ -81,6 +83,21 @@ public class AuditTrailService {
|
|||||||
.operationAt(LocalDateTime.now())
|
.operationAt(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
repository.persist(entry);
|
repository.persist(entry);
|
||||||
|
|
||||||
|
// Sprint 16.A — Métriques Prometheus métier (transparency)
|
||||||
|
registry.counter("unionflow.audit.operations",
|
||||||
|
"action", actionType == null ? "UNKNOWN" : actionType,
|
||||||
|
"entity", entityType == null ? "UNKNOWN" : entityType).increment();
|
||||||
|
if (Boolean.FALSE.equals(sodCheckPassed)) {
|
||||||
|
registry.counter("unionflow.audit.sod_violations",
|
||||||
|
"entity", entityType == null ? "UNKNOWN" : entityType).increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprint 16.C — Fire CDI event pour observers (notifications, etc.)
|
||||||
|
auditEvent.fire(new AuditOperationLoggedEvent(
|
||||||
|
entry.getId(), entry.getUserId(), entry.getUserEmail(),
|
||||||
|
entry.getOrganisationActiveId(), actionType, entityType, entityId,
|
||||||
|
description, sodCheckPassed, entry.getOperationAt()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Fail-soft : l'audit trail ne doit jamais bloquer une opération métier.
|
// Fail-soft : l'audit trail ne doit jamais bloquer une opération métier.
|
||||||
// Les violations sont loguées et peuvent être détectées via les logs applicatifs.
|
// Les violations sont loguées et peuvent être détectées via les logs applicatifs.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package dev.lions.unionflow.server.service.audit;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
|
||||||
|
import dev.lions.unionflow.server.security.AuditOperationLoggedEvent;
|
||||||
|
import dev.lions.unionflow.server.service.NotificationService;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.event.Observes;
|
||||||
|
import jakarta.enterprise.event.TransactionPhase;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications transparency (Sprint 16.C) — observe les events audit et notifie l'utilisateur
|
||||||
|
* concerné par les opérations sensibles.
|
||||||
|
*
|
||||||
|
* <p>Filtre selon {@link AuditOperationLoggedEvent#estSensible()} : opérations financières,
|
||||||
|
* suppressions, validations, exports, ou violations SoD.
|
||||||
|
*
|
||||||
|
* <p>Observe en {@code AFTER_SUCCESS} pour ne notifier que si la transaction de l'audit
|
||||||
|
* a réussi. Fail-soft : un échec de notification ne propage jamais d'erreur.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (Sprint 16.C — transparency)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class AuditNotificationService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(AuditNotificationService.class);
|
||||||
|
|
||||||
|
@Inject NotificationService notificationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe l'événement après commit transactionnel — la notification ne part que si
|
||||||
|
* l'écriture audit a effectivement été persistée.
|
||||||
|
*/
|
||||||
|
public void onAuditOperation(@Observes(during = TransactionPhase.AFTER_SUCCESS)
|
||||||
|
AuditOperationLoggedEvent event) {
|
||||||
|
if (event == null || !event.estSensible() || event.userId() == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
String sujet = sujetPourEvent(event);
|
||||||
|
String corps = corpsPourEvent(event);
|
||||||
|
|
||||||
|
CreateNotificationRequest req = CreateNotificationRequest.builder()
|
||||||
|
.typeNotification("APP")
|
||||||
|
.priorite(prioritePourEvent(event))
|
||||||
|
.sujet(sujet)
|
||||||
|
.corps(corps)
|
||||||
|
.membreId(event.userId())
|
||||||
|
.organisationId(event.organisationActiveId())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
notificationService.creerNotification(req);
|
||||||
|
LOG.debugf("Notification audit transparency émise pour user=%s action=%s",
|
||||||
|
event.userId(), event.actionType());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fail-soft : ne jamais bloquer l'audit principal
|
||||||
|
LOG.warnf("Notification audit échouée pour event %s : %s",
|
||||||
|
event.operationId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String sujetPourEvent(AuditOperationLoggedEvent e) {
|
||||||
|
if (Boolean.FALSE.equals(e.sodCheckPassed())) {
|
||||||
|
return "⚠ Violation SoD détectée — " + e.entityType();
|
||||||
|
}
|
||||||
|
return switch (e.actionType()) {
|
||||||
|
case "DELETE" -> "Suppression — " + e.entityType();
|
||||||
|
case "PAYMENT_INITIATED" -> "Paiement initié";
|
||||||
|
case "PAYMENT_CONFIRMED" -> "Paiement confirmé";
|
||||||
|
case "PAYMENT_FAILED" -> "⚠ Paiement échoué";
|
||||||
|
case "BUDGET_APPROVED" -> "Budget approuvé";
|
||||||
|
case "AID_REQUEST_APPROVED" -> "Demande d'aide approuvée";
|
||||||
|
case "EXPORT" -> "Export effectué";
|
||||||
|
case "VALIDATE" -> "Validation — " + e.entityType();
|
||||||
|
default -> "Opération sensible — " + e.entityType();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String corpsPourEvent(AuditOperationLoggedEvent e) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (e.description() != null && !e.description().isBlank()) {
|
||||||
|
sb.append(e.description()).append("\n\n");
|
||||||
|
}
|
||||||
|
sb.append("Acteur : ").append(e.userEmail() == null ? "—" : e.userEmail()).append('\n');
|
||||||
|
sb.append("Action : ").append(e.actionType()).append('\n');
|
||||||
|
sb.append("Entité : ").append(e.entityType()).append('\n');
|
||||||
|
if (e.entityId() != null) {
|
||||||
|
sb.append("ID : ").append(e.entityId()).append('\n');
|
||||||
|
}
|
||||||
|
sb.append("Quand : ").append(e.operationAt());
|
||||||
|
if (Boolean.FALSE.equals(e.sodCheckPassed())) {
|
||||||
|
sb.append("\n\n⚠ Cette opération a déclenché une alerte de séparation des pouvoirs.");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String prioritePourEvent(AuditOperationLoggedEvent e) {
|
||||||
|
if (Boolean.FALSE.equals(e.sodCheckPassed())) return "HAUTE";
|
||||||
|
return switch (e.actionType()) {
|
||||||
|
case "PAYMENT_FAILED", "DELETE" -> "HAUTE";
|
||||||
|
case "PAYMENT_CONFIRMED", "BUDGET_APPROVED", "AID_REQUEST_APPROVED" -> "NORMALE";
|
||||||
|
default -> "BASSE";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package dev.lions.unionflow.server.service.audit;
|
||||||
|
|
||||||
|
import com.lowagie.text.Document;
|
||||||
|
import com.lowagie.text.Element;
|
||||||
|
import com.lowagie.text.Font;
|
||||||
|
import com.lowagie.text.FontFactory;
|
||||||
|
import com.lowagie.text.PageSize;
|
||||||
|
import com.lowagie.text.Paragraph;
|
||||||
|
import com.lowagie.text.Phrase;
|
||||||
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.apache.commons.csv.CSVFormat;
|
||||||
|
import org.apache.commons.csv.CSVPrinter;
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.CellStyle;
|
||||||
|
import org.apache.poi.ss.usermodel.IndexedColors;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export massif audit trail (Sprint 16.B) — formats CSV / XLSX / PDF.
|
||||||
|
*
|
||||||
|
* <p>Pour transmission BCEAO / ARTCI / CENTIF en cas d'inspection.
|
||||||
|
* Les exports respectent les filtres scope passés à {@link AuditTrailQueryService}.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (Sprint 16.B — transparency operations)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class AuditTrailExportService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(AuditTrailExportService.class);
|
||||||
|
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final String[] HEADERS = {
|
||||||
|
"Timestamp", "Acteur (email)", "Rôle", "Org active", "Action", "Entité",
|
||||||
|
"Entity ID", "Description", "SoD OK", "Violations SoD", "IP", "Request ID"
|
||||||
|
};
|
||||||
|
|
||||||
|
@Inject AuditTrailQueryService queryService;
|
||||||
|
@Inject dev.lions.unionflow.server.security.AuditTrailService auditWrite;
|
||||||
|
|
||||||
|
/** CSV (UTF-8 BOM, RFC 4180). */
|
||||||
|
public byte[] exportCsv(String scope, UUID orgId, UUID userId, int limit) {
|
||||||
|
List<AuditTrailOperationResponse> ops = queryService.listerRecentes(scope, orgId, userId, limit);
|
||||||
|
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
|
||||||
|
// BOM UTF-8 pour Excel
|
||||||
|
out.write(0xEF); out.write(0xBB); out.write(0xBF);
|
||||||
|
try (CSVPrinter printer = new CSVPrinter(writer,
|
||||||
|
CSVFormat.DEFAULT.builder().setHeader(HEADERS).build())) {
|
||||||
|
for (AuditTrailOperationResponse o : ops) {
|
||||||
|
printer.printRecord(
|
||||||
|
fmt(o.operationAt()), nz(o.userEmail()), nz(o.roleActif()),
|
||||||
|
o.organisationActiveId(), nz(o.actionType()), nz(o.entityType()),
|
||||||
|
o.entityId(), nz(o.description()),
|
||||||
|
o.sodCheckPassed() == null ? "" : o.sodCheckPassed().toString(),
|
||||||
|
nz(o.sodViolations()), nz(o.ipAddress()), o.requestId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.flush();
|
||||||
|
auditWrite.logSimple("AuditTrailExport", null, "EXPORT",
|
||||||
|
"Export CSV audit-trail (" + ops.size() + " ops, scope=" + scope + ")");
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Export CSV échoué", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XLSX via Apache POI. */
|
||||||
|
public byte[] exportXlsx(String scope, UUID orgId, UUID userId, int limit) {
|
||||||
|
List<AuditTrailOperationResponse> ops = queryService.listerRecentes(scope, orgId, userId, limit);
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook();
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("Audit Trail");
|
||||||
|
|
||||||
|
CellStyle header = wb.createCellStyle();
|
||||||
|
org.apache.poi.xssf.usermodel.XSSFFont fontH = wb.createFont();
|
||||||
|
fontH.setBold(true);
|
||||||
|
fontH.setColor(IndexedColors.WHITE.getIndex());
|
||||||
|
header.setFont(fontH);
|
||||||
|
header.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
|
||||||
|
header.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||||
|
|
||||||
|
Row headerRow = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < HEADERS.length; i++) {
|
||||||
|
Cell cell = headerRow.createCell(i);
|
||||||
|
cell.setCellValue(HEADERS[i]);
|
||||||
|
cell.setCellStyle(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = 1;
|
||||||
|
for (AuditTrailOperationResponse o : ops) {
|
||||||
|
Row row = sheet.createRow(r++);
|
||||||
|
row.createCell(0).setCellValue(fmt(o.operationAt()));
|
||||||
|
row.createCell(1).setCellValue(nz(o.userEmail()));
|
||||||
|
row.createCell(2).setCellValue(nz(o.roleActif()));
|
||||||
|
row.createCell(3).setCellValue(o.organisationActiveId() == null ? "" : o.organisationActiveId().toString());
|
||||||
|
row.createCell(4).setCellValue(nz(o.actionType()));
|
||||||
|
row.createCell(5).setCellValue(nz(o.entityType()));
|
||||||
|
row.createCell(6).setCellValue(o.entityId() == null ? "" : o.entityId().toString());
|
||||||
|
row.createCell(7).setCellValue(nz(o.description()));
|
||||||
|
row.createCell(8).setCellValue(o.sodCheckPassed() == null ? "" : o.sodCheckPassed().toString());
|
||||||
|
row.createCell(9).setCellValue(nz(o.sodViolations()));
|
||||||
|
row.createCell(10).setCellValue(nz(o.ipAddress()));
|
||||||
|
row.createCell(11).setCellValue(o.requestId() == null ? "" : o.requestId().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < HEADERS.length; i++) sheet.autoSizeColumn(i);
|
||||||
|
wb.write(out);
|
||||||
|
auditWrite.logSimple("AuditTrailExport", null, "EXPORT",
|
||||||
|
"Export XLSX audit-trail (" + ops.size() + " ops, scope=" + scope + ")");
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Export XLSX échoué", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF via OpenPDF (page A4 paysage pour les colonnes larges). */
|
||||||
|
public byte[] exportPdf(String scope, UUID orgId, UUID userId, int limit) {
|
||||||
|
List<AuditTrailOperationResponse> ops = queryService.listerRecentes(scope, orgId, userId, limit);
|
||||||
|
try (Document doc = new Document(PageSize.A4.rotate());
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
PdfWriter.getInstance(doc, out);
|
||||||
|
doc.open();
|
||||||
|
|
||||||
|
Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 14, Color.BLACK);
|
||||||
|
Font headerFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE);
|
||||||
|
Font rowFont = FontFactory.getFont(FontFactory.HELVETICA, 8, Color.BLACK);
|
||||||
|
|
||||||
|
Paragraph title = new Paragraph(
|
||||||
|
"Audit Trail UnionFlow — Export " + LocalDateTime.now().format(DT_FMT)
|
||||||
|
+ " (scope=" + scope + ", " + ops.size() + " opérations)", titleFont);
|
||||||
|
title.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
title.setSpacingAfter(15);
|
||||||
|
doc.add(title);
|
||||||
|
|
||||||
|
PdfPTable table = new PdfPTable(7);
|
||||||
|
table.setWidthPercentage(100);
|
||||||
|
table.setWidths(new float[] {15, 18, 10, 12, 13, 12, 20});
|
||||||
|
|
||||||
|
String[] cols = {"Quand", "Acteur", "Rôle", "Action", "Entité", "SoD", "Description"};
|
||||||
|
Color headerBg = new Color(20, 80, 150);
|
||||||
|
for (String c : cols) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(c, headerFont));
|
||||||
|
cell.setBackgroundColor(headerBg);
|
||||||
|
cell.setPadding(5);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (AuditTrailOperationResponse o : ops) {
|
||||||
|
addCell(table, fmt(o.operationAt()), rowFont);
|
||||||
|
addCell(table, nz(o.userEmail()), rowFont);
|
||||||
|
addCell(table, nz(o.roleActif()), rowFont);
|
||||||
|
addCell(table, nz(o.actionType()), rowFont);
|
||||||
|
addCell(table, nz(o.entityType()), rowFont);
|
||||||
|
addCell(table, o.sodCheckPassed() == null ? "—"
|
||||||
|
: (o.sodCheckPassed() ? "OK" : "VIOLATION"), rowFont);
|
||||||
|
addCell(table, truncate(nz(o.description()), 120), rowFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.add(table);
|
||||||
|
doc.close();
|
||||||
|
|
||||||
|
auditWrite.logSimple("AuditTrailExport", null, "EXPORT",
|
||||||
|
"Export PDF audit-trail (" + ops.size() + " ops, scope=" + scope + ")");
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Export PDF échoué", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void addCell(PdfPTable t, String content, Font f) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(content, f));
|
||||||
|
cell.setPadding(3);
|
||||||
|
t.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String fmt(LocalDateTime dt) { return dt == null ? "" : dt.format(DT_FMT); }
|
||||||
|
static String nz(String s) { return s == null ? "" : s; }
|
||||||
|
static String truncate(String s, int max) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,15 @@ quarkus.hibernate-orm.log.sql=false
|
|||||||
quarkus.hibernate-orm.jdbc.timezone=UTC
|
quarkus.hibernate-orm.jdbc.timezone=UTC
|
||||||
quarkus.hibernate-orm.metrics.enabled=false
|
quarkus.hibernate-orm.metrics.enabled=false
|
||||||
|
|
||||||
|
# Sprint 16.A — Métriques Prometheus métier (transparency)
|
||||||
|
# Endpoint /q/metrics exposé en Prometheus format
|
||||||
|
quarkus.micrometer.export.prometheus.enabled=true
|
||||||
|
quarkus.micrometer.binder.http-server.enabled=true
|
||||||
|
quarkus.micrometer.binder.jvm=true
|
||||||
|
# /q/metrics nécessite auth (rôles SUPER_ADMIN ou COMPLIANCE_OFFICER en prod)
|
||||||
|
# Pour permettre Prometheus scraping interne K8s, autoriser via annotation pod ou
|
||||||
|
# whitelist IP. Pour l'instant : exposé en plain pour scraping intra-cluster.
|
||||||
|
|
||||||
# Configuration Flyway — base commune
|
# Configuration Flyway — base commune
|
||||||
quarkus.flyway.migrate-at-start=true
|
quarkus.flyway.migrate-at-start=true
|
||||||
quarkus.flyway.baseline-on-migrate=true
|
quarkus.flyway.baseline-on-migrate=true
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package dev.lions.unionflow.server.service.audit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
|
||||||
|
import dev.lions.unionflow.server.security.AuditOperationLoggedEvent;
|
||||||
|
import dev.lions.unionflow.server.service.NotificationService;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuditNotificationServiceTest {
|
||||||
|
|
||||||
|
@Mock NotificationService notificationService;
|
||||||
|
private AuditNotificationService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
service = new AuditNotificationService();
|
||||||
|
Field f = AuditNotificationService.class.getDeclaredField("notificationService");
|
||||||
|
f.setAccessible(true);
|
||||||
|
f.set(service, notificationService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuditOperationLoggedEvent event(String action, Boolean sod) {
|
||||||
|
return new AuditOperationLoggedEvent(
|
||||||
|
UUID.randomUUID(), UUID.randomUUID(), "test@unionflow.dev",
|
||||||
|
UUID.randomUUID(), action, "Cotisation", UUID.randomUUID(),
|
||||||
|
"Test description", sod, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Event sensible (PAYMENT_CONFIRMED) → notification créée")
|
||||||
|
void sensiblePaymentConfirmed() {
|
||||||
|
when(notificationService.creerNotification(any())).thenReturn(null);
|
||||||
|
service.onAuditOperation(event("PAYMENT_CONFIRMED", true));
|
||||||
|
verify(notificationService).creerNotification(any(CreateNotificationRequest.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Event non-sensible (CREATE Membre) → pas de notification")
|
||||||
|
void nonSensibleSkipped() {
|
||||||
|
service.onAuditOperation(event("CREATE", true));
|
||||||
|
verify(notificationService, never()).creerNotification(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Violation SoD → notification HAUTE priorité même sur action non-listée")
|
||||||
|
void sodViolationAlwaysNotifies() {
|
||||||
|
when(notificationService.creerNotification(any())).thenReturn(null);
|
||||||
|
service.onAuditOperation(event("CREATE", false));
|
||||||
|
verify(notificationService).creerNotification(any(CreateNotificationRequest.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Event null → ignoré silencieusement")
|
||||||
|
void eventNullIgnored() {
|
||||||
|
service.onAuditOperation(null);
|
||||||
|
verify(notificationService, never()).creerNotification(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("UserId null → pas de notification (pas de cible)")
|
||||||
|
void userIdNullSkipped() {
|
||||||
|
AuditOperationLoggedEvent e = new AuditOperationLoggedEvent(
|
||||||
|
UUID.randomUUID(), null, "anon", UUID.randomUUID(),
|
||||||
|
"DELETE", "X", UUID.randomUUID(), "test", true, LocalDateTime.now());
|
||||||
|
service.onAuditOperation(e);
|
||||||
|
verify(notificationService, never()).creerNotification(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers de mapping ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("sujetPourEvent — actions classiques")
|
||||||
|
void sujetMapping() {
|
||||||
|
assertThat(service.sujetPourEvent(event("PAYMENT_INITIATED", true)))
|
||||||
|
.isEqualTo("Paiement initié");
|
||||||
|
assertThat(service.sujetPourEvent(event("PAYMENT_CONFIRMED", true)))
|
||||||
|
.isEqualTo("Paiement confirmé");
|
||||||
|
assertThat(service.sujetPourEvent(event("PAYMENT_FAILED", true)))
|
||||||
|
.startsWith("⚠");
|
||||||
|
assertThat(service.sujetPourEvent(event("BUDGET_APPROVED", true)))
|
||||||
|
.isEqualTo("Budget approuvé");
|
||||||
|
assertThat(service.sujetPourEvent(event("AID_REQUEST_APPROVED", true)))
|
||||||
|
.isEqualTo("Demande d'aide approuvée");
|
||||||
|
assertThat(service.sujetPourEvent(event("EXPORT", true)))
|
||||||
|
.isEqualTo("Export effectué");
|
||||||
|
assertThat(service.sujetPourEvent(event("DELETE", true)))
|
||||||
|
.startsWith("Suppression");
|
||||||
|
assertThat(service.sujetPourEvent(event("VALIDATE", true)))
|
||||||
|
.startsWith("Validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("sujetPourEvent — violation SoD prioritaire sur actionType")
|
||||||
|
void sujetSodPriority() {
|
||||||
|
assertThat(service.sujetPourEvent(event("PAYMENT_CONFIRMED", false)))
|
||||||
|
.startsWith("⚠ Violation SoD");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("prioritePourEvent — règles")
|
||||||
|
void prioriteMapping() {
|
||||||
|
assertThat(service.prioritePourEvent(event("DELETE", true))).isEqualTo("HAUTE");
|
||||||
|
assertThat(service.prioritePourEvent(event("PAYMENT_FAILED", true))).isEqualTo("HAUTE");
|
||||||
|
assertThat(service.prioritePourEvent(event("PAYMENT_CONFIRMED", true))).isEqualTo("NORMALE");
|
||||||
|
assertThat(service.prioritePourEvent(event("BUDGET_APPROVED", true))).isEqualTo("NORMALE");
|
||||||
|
assertThat(service.prioritePourEvent(event("EXPORT", true))).isEqualTo("BASSE");
|
||||||
|
assertThat(service.prioritePourEvent(event("CREATE", false))).isEqualTo("HAUTE"); // SoD
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("corpsPourEvent — contient les champs clés")
|
||||||
|
void corpsBuilder() {
|
||||||
|
String body = service.corpsPourEvent(event("PAYMENT_CONFIRMED", true));
|
||||||
|
assertThat(body)
|
||||||
|
.contains("Acteur :")
|
||||||
|
.contains("test@unionflow.dev")
|
||||||
|
.contains("Action :")
|
||||||
|
.contains("PAYMENT_CONFIRMED")
|
||||||
|
.contains("Entité :")
|
||||||
|
.contains("Cotisation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("corpsPourEvent — note SoD si violation")
|
||||||
|
void corpsSodNote() {
|
||||||
|
String body = service.corpsPourEvent(event("PAYMENT_CONFIRMED", false));
|
||||||
|
assertThat(body).contains("séparation des pouvoirs");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user