Module: audit¶
Purpose¶
Immutable audit log of all significant actions. AuditService is called by other modules to record events; no direct writes from outside the audit module. SecurityAuditListener captures authentication events (login, logout, registration); WriteAuditListener captures write events via Spring @EventListener. AuditLevel.SECURITY is always enabled regardless of config; WRITE and ALL are toggled via audit_config.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
AuditEvent |
audit_events |
timestamp TIMESTAMPTZ, userId UUID? FK→users (SET NULL on delete), userEmail VARCHAR(255), projectId UUID? FK→projects (SET NULL on delete), action: AuditAction, level: AuditLevel, resourceType VARCHAR(50)?, resourceId VARCHAR(100)?, details TEXT? (JSON string), ipAddress VARCHAR(45)?, userAgent VARCHAR(500)? |
AuditConfig |
audit_config |
level: AuditLevel (PK), enabled BOOLEAN |
AuditAction values: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, PASSWORD_CHANGED, ROLE_CHANGED, API_KEY_CREATED, API_KEY_DELETED, OAUTH_LOGIN, USER_REGISTERED, ISSUE_CREATED, ISSUE_UPDATED, ISSUE_DELETED, ISSUE_TRANSITIONED, COMMENT_CREATED, COMMENT_DELETED, SPRINT_STARTED, SPRINT_COMPLETED, MEMBER_ADDED, MEMBER_REMOVED, WEBHOOK_CREATED, WEBHOOK_DELETED, SLA_BREACHED, ISSUE_VIEWED, BOARD_OPENED, REPORT_VIEWED.
AuditLevel values: SECURITY, WRITE, ALL.
Seed data: audit_config is pre-populated with (SECURITY, true), (WRITE, false), (ALL, false).
DB Schema¶
audit_events, audit_config (V17 + V22)¶
CREATE TABLE audit_events (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255) NOT NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
level VARCHAR(20) NOT NULL,
resource_type VARCHAR(50),
resource_id VARCHAR(100),
details JSONB,
ip_address VARCHAR(45),
user_agent VARCHAR(500)
);
-- V22: ALTER TABLE audit_events ALTER COLUMN details TYPE text;
V17 created details as JSONB. V22 (V22__audit_details_text.sql) changed it to TEXT. The AuditEvent.details field is String?; callers must serialize maps to a JSON string before passing to AuditService.log() and deserialize on read.
Indexes: idx_audit_timestamp on (timestamp DESC); idx_audit_project on (project_id, timestamp DESC); idx_audit_user on (user_id, timestamp DESC).
V19 added org_id UUID REFERENCES organizations(id) to audit_events to support multi-tenancy scoping.
API Endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/admin/audit |
ADMIN | List all events; query params: page, size, from (Instant), to (Instant), userId (UUID), action (String), level (String); paginated |
GET |
/api/v1/admin/audit/export |
ADMIN | Export all events; ?format=json (default) or ?format=csv; CSV response includes Content-Disposition: attachment; filename=audit.csv |
GET |
/api/v1/projects/{key}/audit |
ADMIN | List events for project; params: page, size, from, to, action; enforces hasRole('ADMIN') or @projectSecurity.isProjectAdmin |
GET |
/api/v1/admin/audit/config |
ADMIN | Get current audit level toggles; returns list of {"level": "...", "enabled": ...} |
PUT |
/api/v1/admin/audit/config |
ADMIN | Update a level toggle; body: {"level": "WRITE", "enabled": true} |
All endpoints on /api/v1/admin/audit require @PreAuthorize("hasRole('ADMIN')"). The project audit endpoint requires ADMIN role or project admin role.
Events Emitted¶
None. The audit module does not publish domain events.
Events Consumed¶
| Event | Handler | AuditAction logged |
|---|---|---|
IssueCreatedEvent |
WriteAuditListener.onIssueCreated() |
ISSUE_CREATED |
IssueFieldChangedEvent |
WriteAuditListener.onIssueUpdated() |
ISSUE_UPDATED |
IssueStatusChangedEvent |
WriteAuditListener.onIssueTransitioned() |
ISSUE_TRANSITIONED (actor may be null → userEmail = "system") |
CommentCreatedEvent |
WriteAuditListener.onCommentCreated() |
COMMENT_CREATED |
SprintStartedEvent |
WriteAuditListener.onSprintStarted() |
SPRINT_STARTED |
SprintCompletedEvent |
WriteAuditListener.onSprintCompleted() |
SPRINT_COMPLETED |
SecurityAuditListener is not an @EventListener; it is called directly by AuthService and OAuth handlers. Methods: onLoginSuccess(), onLoginFailed(), onLogout(), onRegister(), onOAuthLogin(). All log at AuditLevel.SECURITY.
Key Files¶
backend/src/main/kotlin/com/taskowolf/audit/domain/AuditEvent.ktbackend/src/main/kotlin/com/taskowolf/audit/domain/AuditAction.ktbackend/src/main/kotlin/com/taskowolf/audit/domain/AuditLevel.ktbackend/src/main/kotlin/com/taskowolf/audit/domain/AuditConfig.ktbackend/src/main/kotlin/com/taskowolf/audit/application/AuditService.ktbackend/src/main/kotlin/com/taskowolf/audit/application/SecurityAuditListener.ktbackend/src/main/kotlin/com/taskowolf/audit/application/WriteAuditListener.ktbackend/src/main/kotlin/com/taskowolf/audit/api/AuditController.ktbackend/src/main/kotlin/com/taskowolf/audit/infrastructure/AuditEventRepository.ktbackend/src/main/kotlin/com/taskowolf/audit/infrastructure/AuditConfigRepository.ktbackend/src/main/resources/db/migration/V17__audit.sqlbackend/src/main/resources/db/migration/V22__audit_details_text.sql
Extension Points¶
- To audit a new action: inject
AuditServiceinto the relevant module's Service and callauditService.log(level, action, userEmail, ...). Do NOT callAuditEventRepositorydirectly from outside the audit module. - To add a new
AuditAction: add the value to theAuditActionenum; no migration is required (stored as VARCHAR). - CSV export formula-injection protection:
AuditController.escapeCsvCell()prefixes cells starting with=,+,-,@, tab, or carriage-return with a single quote. Apply this to any new exported fields.
Common Pitfalls¶
detailsis TEXT, not JSONB. V22 changed the column type. The JPA entity mapsdetailsasString?. Pass a serialized JSON string (e.g., viaObjectMapper.writeValueAsString(map)) when recording structured details; deserialize manually on read. JSONB operators and PostgreSQL->path queries do not work on this column.- Audit records are immutable. There are no
UPDATEorDELETEoperations onaudit_events. Never add them.AuditEventRepositoryextendsJpaRepositorybut onlyfindFilteredandfindByProjectquery methods are used;save()is called once at insert time. GET /auditis ADMIN-only. Never relax the@PreAuthorize("hasRole('ADMIN')")on admin audit endpoints. The project-scoped endpoint requires project-admin or system admin.AuditService.log()is@Async. It runs on a separate thread pool. Logs are not available within the same transaction that triggered the event; do not query for audit records in tests immediately after callinglog()without flushing the async executor.SECURITYlevel cannot be disabled.AuditService.isEnabled()short-circuits totrueforAuditLevel.SECURITYregardless ofaudit_config. Do not attempt to disable security-level events via the config API.
Example¶
Calling AuditService.log() with a serialized details payload, as done in SlaMonitorJob when an SLA is breached:
fun recordBreach(issue: Issue, policy: SlaPolicy) {
// breach-detection logic (elapsed check) runs before this call
val details = objectMapper.writeValueAsString(
mapOf("resolutionMinutes" to policy.resolutionMinutes)
)
auditService.log(
level = AuditLevel.WRITE,
action = AuditAction.SLA_BREACHED,
userEmail = "system",
projectId = issue.project.id,
resourceType = "ISSUE",
resourceId = issue.id.toString(),
details = details
)
}
userEmail = "system" is the convention for machine-initiated events where no human actor exists. The details column is TEXT (see V22), so always serialize structured data to a JSON string via ObjectMapper before passing it — JSONB operators will not work on this column.
Test Patterns¶
AuditServiceTest— pure unit test with MockK. Verifies:SECURITYevents are saved regardless of theenabledflag in config;WRITEevents are skipped when disabled;WRITEevents are saved when enabled.SecurityAuditListenerTest— pure unit test with MockK; mocksAuditService. VerifiesonLoginSuccess()andonLoginFailed()pass the correctAuditLevelandAuditActiontoauditService.log().WriteAuditListenerTest— pure unit test with MockK; mocksAuditServiceasrelaxed. Verifies each@EventListenermethod callsauditService.log()with the correct level, action, andresourceType. VerifiesIssueStatusChangedEventwithactor = nullproducesuserEmail = "system".AuditEventRepositoryTest—@DataJpaTestagainst H2 (DB_CLOSE_DELAY=-1, Flyway disabled, DDL viaddl-auto=create-drop). H2 INIT script mapsJSONBtype toTEXTfor compatibility with V17 schema. VerifiesfindFiltered()returns only matching events when filtered byaction.AuditCsvEscapeTest— pure unit test. VerifiesAuditController.escapeCsvCell()prefixes formula-injection characters (=,+,-,@,\t,\r) with'; passes safe values includinguser@example.comthrough unchanged (leadinguis not a trigger character).