Merge branch 'feat/record-draft-audit-outbox' into 'main'

feat(drafts): audit + outbox для record-draft lifecycle (audit findings)

See merge request 2-6/2-6-4/terravault/ordinis!184
This commit is contained in:
Александр Зимин
2026-05-14 12:32:06 +00:00
2 changed files with 119 additions and 3 deletions
@@ -97,6 +97,56 @@ public class AuditLogger {
write(eventType, action, d, null, before, after);
}
/**
* Record-draft workflow audit events. Mirrors {@link #schemaDraftEvent} but
* for record-level drafts (DraftService — submit / approve / reject /
* withdraw). Closes the audit gap surfaced by the 2026-05-14 approval
* workflow audit: until now record-draft decisions left no trail beyond
* downstream RECORD_CREATE/UPDATE/CLOSE events emitted by
* DictionaryRecordService.applyApproved*. Compliance needs the
* decision-level event (who approved, when, why) explicitly.
*
* @param eventType e.g. {@code RECORD_DRAFT_SUBMITTED}, {@code RECORD_DRAFT_APPROVED}
* @param action e.g. {@code DRAFT_SUBMIT}, {@code DRAFT_APPROVE}
* @param d живой dict (для scope + id)
* @param businessKey записи которой касается draft (null для dict-wide events)
* @param before previous record data (null для CREATE)
* @param after proposed record data (null для CLOSE / WITHDRAW)
*/
public void recordDraftEvent(
String eventType, String action, DictionaryDefinition d,
String businessKey, JsonNode before, JsonNode after) {
try {
AuditLog entry = new AuditLog();
entry.setEventTime(OffsetDateTime.now());
entry.setEventType(eventType);
entry.setAction(action);
entry.setDictionaryId(d.getId());
entry.setDictionaryName(d.getName());
if (businessKey != null) entry.setBusinessKey(businessKey);
entry.setPayloadBefore(before);
entry.setPayloadAfter(after);
User user = currentUser();
entry.setUserId(user.id);
entry.setUserScope(user.scope);
RequestContext rc = currentRequest();
entry.setIpAddress(rc.ip);
entry.setUserAgent(rc.userAgent);
entry.setRequestId(rc.requestId);
entry.setTraceId(MDC.get("traceId"));
repository.save(entry);
} catch (Exception ex) {
// Mirror schema-draft path — audit failure must not break the user-facing
// operation. Log + carry on; downstream observability picks up the gap.
org.slf4j.LoggerFactory.getLogger(AuditLogger.class)
.warn("recordDraftEvent audit write failed: type={} action={} dict={} bk={}",
eventType, action, d.getName(), businessKey, ex);
}
}
private void write(
String eventType,
String action,
@@ -64,6 +64,11 @@ public class DraftService {
private final Optional<MeterRegistry> meterRegistry;
/** Phase 1d: realm role gate. Null в test ctor → skip enforcement. */
private final cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles;
/** Audit + outbox for record-draft lifecycle. Null in legacy test ctors —
* helpers below null-check. Closes 2026-05-14 audit gap: record-draft
* decisions used to leave no trail beyond downstream RECORD_* events. */
private final cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger auditLogger;
private final cloud.nstart.terravault.ordinis.outbox.OutboxRecorder outboxRecorder;
@org.springframework.beans.factory.annotation.Autowired
public DraftService(
@@ -72,13 +77,17 @@ public class DraftService {
DictionaryRecordService recordService,
ScopeContext scopeContext,
Optional<MeterRegistry> meterRegistry,
cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles) {
cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles,
cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger auditLogger,
cloud.nstart.terravault.ordinis.outbox.OutboxRecorder outboxRecorder) {
this.draftRepo = draftRepo;
this.definitionService = definitionService;
this.recordService = recordService;
this.scopeContext = scopeContext;
this.meterRegistry = meterRegistry;
this.workflowRoles = workflowRoles;
this.auditLogger = auditLogger;
this.outboxRecorder = outboxRecorder;
}
/** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
@@ -86,7 +95,7 @@ public class DraftService {
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this(draftRepo, definitionService, null, scopeContext, Optional.empty(), null);
this(draftRepo, definitionService, null, scopeContext, Optional.empty(), null, null, null);
}
/** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */
@@ -95,7 +104,7 @@ public class DraftService {
DictionaryDefinitionService definitionService,
DictionaryRecordService recordService,
ScopeContext scopeContext) {
this(draftRepo, definitionService, recordService, scopeContext, Optional.empty(), null);
this(draftRepo, definitionService, recordService, scopeContext, Optional.empty(), null, null, null);
}
/** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */
@@ -174,6 +183,7 @@ public class DraftService {
log.info("Draft submitted: dict={} bk={} op={} maker={}",
dictionaryName, req.businessKey(), req.operation(), makerId);
countSubmit(dictionaryName, req.operation(), "ok");
emitDraftEvent("RecordDraftSubmitted", "DRAFT_SUBMIT", def, saved, null);
return saved;
} catch (DataIntegrityViolationException ex) {
// SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
@@ -286,6 +296,8 @@ public class DraftService {
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
draft.getId(), def.getName(), draft.getBusinessKey(),
draft.getOperation(), reviewerId);
emitDraftEvent("RecordDraftApproved", "DRAFT_APPROVE", def, saved,
java.util.Map.of("reviewedAt", now.toString()));
return saved;
}
@@ -329,6 +341,8 @@ public class DraftService {
recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
log.info("Draft rejected: id={} reviewer={} reason='{}'",
draftId, reviewerId, reviewComment);
emitDraftEvent("RecordDraftRejected", "DRAFT_REJECT", def, saved,
java.util.Map.of("reviewedAt", now.toString()));
return saved;
}
@@ -361,6 +375,7 @@ public class DraftService {
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now);
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
emitDraftEvent("RecordDraftWithdrawn", "DRAFT_WITHDRAW", def, saved, null);
return saved;
}
@@ -452,4 +467,55 @@ public class DraftService {
/** Convenience overload: всё что нужно от datetime. */
static OffsetDateTime nowUtc() { return OffsetDateTime.now(); }
/**
* Emit one record-draft lifecycle event to BOTH audit_log + outbox. No-op
* when either dependency is null (legacy test ctors). Failures swallowed
* inside AuditLogger / OutboxRecorder so a sink hiccup never breaks the
* user-facing transaction.
*
* <p>Outbox payload is a flat {@code Map} (matches SchemaDraftService
* pattern — flexible JSON, no per-event class needed). Webhook subscribers
* + notifications service can subscribe to {@code RecordDraftSubmitted /
* Approved / Rejected / Withdrawn} now that they actually fire.
*/
private void emitDraftEvent(
String eventType,
String action,
DictionaryDefinition def,
RecordDraft draft,
java.util.Map<String, Object> extraPayload) {
if (auditLogger != null) {
auditLogger.recordDraftEvent(
eventType, action, def,
draft.getBusinessKey(),
/* before */ null, /* after */ draft.getData());
}
if (outboxRecorder != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("draftId", draft.getId().toString());
payload.put("dictionaryName", def.getName());
payload.put("businessKey", draft.getBusinessKey());
payload.put("operation", draft.getOperation().name());
payload.put("status", draft.getStatus().name());
payload.put("makerId", draft.getMakerId());
if (draft.getReviewerId() != null) payload.put("reviewerId", draft.getReviewerId());
if (draft.getReviewComment() != null) payload.put("reviewComment", draft.getReviewComment());
if (extraPayload != null) payload.putAll(extraPayload);
try {
outboxRecorder.record(
eventType,
"RecordDraft",
draft.getId().toString(),
def.getName(),
def.getScope(),
def.getBundle(),
"record-draft:" + draft.getId(),
payload);
} catch (RuntimeException ex) {
log.warn("emitDraftEvent outbox failed: type={} draftId={} dict={}",
eventType, draft.getId(), def.getName(), ex);
}
}
}
}