Story CP-004: Audit Trail Backend Service¶
Module: control-plane Slice: Slice 4 from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 4 Depends on: CP-001 Estimated complexity: M
Objective¶
Build the shared, immutable audit trail backend service used by ALL modules across the platform. Every create, update, or delete operation must produce an audit entry with before/after snapshots. This satisfies AUDCIF Art. 24 (10-year retention). The audit service is a shared infrastructure component — it's consumed by every story that follows. The audit UI viewer is in CP-016.
Backend Scope¶
Entities & Records¶
AuditEntry entity (tenant DB, time-partitioned):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK, server-generated |
| tenant_id | UUID | NOT NULL |
| user_id | UUID | NOT NULL (FK to User — who performed the action) |
| timestamp | Instant | NOT NULL, UTC |
| entity_type | String | NOT NULL (e.g., "Employee", "LeaveRequest", "Tenant") |
| entity_id | UUID | NOT NULL |
| module_code | String | NOT NULL (e.g., "CP", "ABSMGT", "PAYROL") |
| action | AuditAction enum | NOT NULL |
| before_value | JSONB | Nullable (null for CREATE) |
| after_value | JSONB | Nullable (null for DELETE) |
| metadata | JSONB | Nullable (extra context, e.g., "reason for suspension") |
AuditAction enum:
DTOs:
public record AuditEntryResponse(
UUID id, UUID userId, String userFullName, Instant timestamp,
String entityType, UUID entityId, String moduleCode,
AuditAction action, JsonNode beforeValue, JsonNode afterValue,
JsonNode metadata
) {}
public record AuditPageResponse(
List<AuditEntryResponse> entries, int totalCount, int page, int pageSize
) {}
public record AuditQueryParams(
@Nullable String entityType,
@Nullable UUID entityId,
@Nullable UUID userId,
@Nullable AuditAction action,
@Nullable String moduleCode,
@Nullable Instant dateFrom,
@Nullable Instant dateTo,
int page, // default 0
int pageSize // default 50
) {}
Repository Layer¶
AuditRepository.java— Tenant-aware:save(AuditEntry) → AuditEntry— INSERT only (never update)findByFilters(UUID tenantId, AuditQueryParams) → Page<AuditEntry>— Paginated query with optional filters on entity_type, entity_id, user_id, action, module_code, date range- No delete method (immutable)
- No update method (immutable)
Event Contract — AuditableEvent interface¶
Location: com.altarys.papillon.events (shared events module)
All auditable domain events MUST implement this interface. It is the only contract between modules and the audit system — no module ever calls the audit service directly.
// com.altarys.papillon.events.AuditableEvent
public interface AuditableEvent {
String entityType(); // e.g. "Employee", "LeaveRequest", "Tenant"
UUID entityId();
String moduleCode(); // e.g. "CP", "ABSMGT", "PAYROL"
AuditAction action(); // CREATE | UPDATE | DELETE
@Nullable Object before(); // null for CREATE
@Nullable Object after(); // null for DELETE
}
Rules for module developers:
- Every event that must produce an audit entry MUST implement AuditableEvent.
- Events that do NOT require an audit entry (e.g. internal read-only signals) must NOT implement it.
- The concrete event class lives in its own module's root package (Spring Modulith public API), not in com.altarys.papillon.events. The events module owns the interface contract only.
- before() and after() return the entity snapshot (a record or DTO); the audit listener serializes them to JSONB. Never pass raw DB entities.
Service Layer¶
AuditEventListener.java (shared — event-driven per ADR-21):
- No direct .log() method exposed to other modules. Modules publish domain events implementing AuditableEvent; this listener captures all of them automatically.
- Single @ApplicationModuleListener on the AuditableEvent interface — never needs to be modified when new modules add events:
@ApplicationModuleListener
public void on(AuditableEvent event) {
auditRepository.save(toEntry(event));
}
tenant_id from TenantContext and user_id from SecurityContext
- Serializes before() and after() to JSONB via ObjectMapper
- Generates UUID v7 for the entry
- Runs asynchronously — guaranteed delivery via Spring Modulith's EVENT_PUBLICATION table
- Per ADR-21: "Zero coupling — modules publish events, Audit module listens. Guaranteed delivery via EVENT_PUBLICATION table. No explicit audit service calls."
AuditQueryService.java:
- query(AuditQueryParams params) → AuditPageResponse — Tenant-scoped query for the UI (consumed by CP-016)
Implementation notes:
- @ApplicationModuleListener is async and transactional: the audit write happens in its own transaction, after the publishing transaction commits. A failed audit write does NOT roll back the main transaction.
- If the audit write fails, Spring Modulith retries via the EVENT_PUBLICATION table. Failed entries remain in EVENT_PUBLICATION with status FAILED for ops monitoring.
- Coverage = event coverage: every data mutation MUST publish an AuditableEvent, which the listener captures automatically. No per-event wiring needed.
API Endpoints¶
No REST endpoints in this story. The query endpoint (GET /api/v1/audit) is in CP-016.
Validation Rules¶
- Audit entries are immutable: no PUT, PATCH, or DELETE endpoints exist
entity_typemax 100 charsmodule_codemax 20 chars- Query date range:
date_frommust be beforedate_to - Page size: 1–100 (default 50)
Multi-Tenant Considerations¶
- Audit entries are in the tenant DB (tenant-scoped)
- Query API automatically scopes by
TenantContext.current().tenantId() - Platform admin (CP-012) can query across tenants — that endpoint is in the Admin Console story
- Time partitioning: monthly partitions on
timestampcolumn for performance at scale
Flyway Migrations¶
V002__create_audit_entry_partitioned.sql— Tenant DB:audit_entriestable with time-based partitioning (monthly). Create initial partitions for current month and next 3 months. Include a scheduled job or trigger to create future partitions.V001__create_event_publication.sql— Platform DB: Spring ModulithEVENT_PUBLICATIONtable. Required for@ApplicationModuleListenerguaranteed delivery. Use the DDL from Spring Modulith's schema for the target database (PostgreSQL).
Frontend Scope¶
None — this story is backend-only. All audit UI (AuditTrailPage, AuditEntryCard, AuditDetailSheet, AuditFilterBar) is in CP-016.
Acceptance Criteria¶
AC-026: Audit entry created on data mutation
Given I create an employee via POST /api/v1/employees
When the employee is persisted
Then an audit entry is created with action=CREATE, entityType="Employee", afterValue=employee JSON, beforeValue=null
AC-027: Audit entry captures before/after on update
Given employee "Moussa Koné" exists with department="Production"
When I update the department to "Commercial"
Then an audit entry is created with action=UPDATE, beforeValue containing department="Production", afterValue containing department="Commercial"
AC-028: Audit entries are immutable
Given an audit entry exists
When any attempt is made to update or delete it (via API or direct DB)
Then the operation is rejected (no DELETE or UPDATE API exists; DB constraint prevents DELETE)
AC-030: Audit is tenant-scoped
Given tenant A has 20 audit entries and tenant B has 10
When tenant A queries GET /api/v1/audit
Then only tenant A's 20 entries are returned (zero from tenant B)
Moved ACs: AC-029, AC-031, AC-032, AC-033 → CP-016.
OHADA & Regulatory Rules¶
- AUDCIF Art. 24: All financial and accounting records must be retained for a minimum of 10 years. Audit entries satisfy this requirement for traceability of ALL data mutations.
- AUDCIF Art. 22: Audit entries must be immutable — no modification, no deletion. The DB schema enforces this (no DELETE trigger, no UPDATE permission on the table). Note: full AUDCIF Art. 22 compliance (hash chains, period locking) is for the Accounting module, not the general audit trail.
- Loi 2013-450: Audit trail provides the accountability required for personal data processing. Every access and modification of personal data (employee records) is traced.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Entities, §Async processing, §Application Eventsdocs/standards/database-guidelines.md— §JSONB columns, §Time-partitioned tables, §Immutable tablesdocs/standards/api-guidelines.md— §Pagination, §Query parameter conventionsdocs/standards/react-typescript-guidelines.md— §List/table components, §Filter pattern, §Bottom sheet detail
Testing Requirements¶
Unit Tests¶
AuditEventListener.on(): correctAuditEntrybuilt from anAuditableEvent— field mapping, JSONB serializationAuditEventListener.on(): nullbefore()on CREATE action →before_valueis nullAuditEventListener.on(): nullafter()on DELETE action →after_valueis nullAuditEventListener.on():tenant_idanduser_idinjected from context, not from event
Integration Tests¶
- Full flow: publish an
AuditableEvent→ audit entry persisted with correct data - Full flow: UPDATE event → audit entry has correct before/after JSONB
- Tenant isolation: audit entries for tenant B invisible to tenant A queries
- Immutability: attempt DELETE on
audit_entriestable → rejected by DB constraint - Async delivery: audit write failure does NOT roll back the publishing transaction; entry remains in
EVENT_PUBLICATIONwith statusFAILED AuditableEventcontract: a non-AuditableEventdomain event is NOT captured by the listener
What QA Will Validate¶
- Audit entries are created for every data mutation (verify via DB or API)
- Tenant isolation: no cross-tenant audit leaks
Out of Scope¶
- Audit UI (AuditTrailPage, filters, detail view, access control) — CP-016
- Audit query endpoint with filters (GET /api/v1/audit with filter params) — CP-016
- CSV export of audit trail — V2
- Cross-tenant audit query (for platform admin) — CP-012
- AUDCIF Art. 22 hash chains — Accounting module
- Partition management automation (creating new monthly partitions) — DevOps/cron, not application code in this story. Create partitions for 6 months ahead in the migration.
- Audit trail for admin console actions — CP-012
Definition of Done¶
- [ ]
AuditableEventinterface defined incom.altarys.papillon.events - [ ]
AuditEventListeneruses single@ApplicationModuleListeneronAuditableEvent— no per-event wiring - [ ] AuditEntry entity persisted to tenant DB with correct tenant isolation
- [ ] Flyway migration creates partitioned table
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified (no cross-tenant data leaks)
- [ ] No Java raw types — all generics explicit
- [ ] Audit write failure (via
@ApplicationModuleListener) does not roll back the publishing transaction; failed entry visible inEVENT_PUBLICATIONtable