feat(drafts): audit + outbox для record-draft lifecycle (audit findings)
This commit is contained in:
+50
@@ -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,
|
||||
|
||||
+69
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user