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);
|
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(
|
private void write(
|
||||||
String eventType,
|
String eventType,
|
||||||
String action,
|
String action,
|
||||||
|
|||||||
+69
-3
@@ -64,6 +64,11 @@ public class DraftService {
|
|||||||
private final Optional<MeterRegistry> meterRegistry;
|
private final Optional<MeterRegistry> meterRegistry;
|
||||||
/** Phase 1d: realm role gate. Null в test ctor → skip enforcement. */
|
/** Phase 1d: realm role gate. Null в test ctor → skip enforcement. */
|
||||||
private final cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles;
|
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
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
public DraftService(
|
public DraftService(
|
||||||
@@ -72,13 +77,17 @@ public class DraftService {
|
|||||||
DictionaryRecordService recordService,
|
DictionaryRecordService recordService,
|
||||||
ScopeContext scopeContext,
|
ScopeContext scopeContext,
|
||||||
Optional<MeterRegistry> meterRegistry,
|
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.draftRepo = draftRepo;
|
||||||
this.definitionService = definitionService;
|
this.definitionService = definitionService;
|
||||||
this.recordService = recordService;
|
this.recordService = recordService;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
this.meterRegistry = meterRegistry;
|
this.meterRegistry = meterRegistry;
|
||||||
this.workflowRoles = workflowRoles;
|
this.workflowRoles = workflowRoles;
|
||||||
|
this.auditLogger = auditLogger;
|
||||||
|
this.outboxRecorder = outboxRecorder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
|
/** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
|
||||||
@@ -86,7 +95,7 @@ public class DraftService {
|
|||||||
RecordDraftRepository draftRepo,
|
RecordDraftRepository draftRepo,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
ScopeContext scopeContext) {
|
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. */
|
/** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */
|
||||||
@@ -95,7 +104,7 @@ public class DraftService {
|
|||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
DictionaryRecordService recordService,
|
DictionaryRecordService recordService,
|
||||||
ScopeContext scopeContext) {
|
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. */
|
/** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */
|
||||||
@@ -174,6 +183,7 @@ public class DraftService {
|
|||||||
log.info("Draft submitted: dict={} bk={} op={} maker={}",
|
log.info("Draft submitted: dict={} bk={} op={} maker={}",
|
||||||
dictionaryName, req.businessKey(), req.operation(), makerId);
|
dictionaryName, req.businessKey(), req.operation(), makerId);
|
||||||
countSubmit(dictionaryName, req.operation(), "ok");
|
countSubmit(dictionaryName, req.operation(), "ok");
|
||||||
|
emitDraftEvent("RecordDraftSubmitted", "DRAFT_SUBMIT", def, saved, null);
|
||||||
return saved;
|
return saved;
|
||||||
} catch (DataIntegrityViolationException ex) {
|
} catch (DataIntegrityViolationException ex) {
|
||||||
// SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
|
// 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={}",
|
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
|
||||||
draft.getId(), def.getName(), draft.getBusinessKey(),
|
draft.getId(), def.getName(), draft.getBusinessKey(),
|
||||||
draft.getOperation(), reviewerId);
|
draft.getOperation(), reviewerId);
|
||||||
|
emitDraftEvent("RecordDraftApproved", "DRAFT_APPROVE", def, saved,
|
||||||
|
java.util.Map.of("reviewedAt", now.toString()));
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +341,8 @@ public class DraftService {
|
|||||||
recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
|
recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
|
||||||
log.info("Draft rejected: id={} reviewer={} reason='{}'",
|
log.info("Draft rejected: id={} reviewer={} reason='{}'",
|
||||||
draftId, reviewerId, reviewComment);
|
draftId, reviewerId, reviewComment);
|
||||||
|
emitDraftEvent("RecordDraftRejected", "DRAFT_REJECT", def, saved,
|
||||||
|
java.util.Map.of("reviewedAt", now.toString()));
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +375,7 @@ public class DraftService {
|
|||||||
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
|
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
|
||||||
recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now);
|
recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now);
|
||||||
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
|
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
|
||||||
|
emitDraftEvent("RecordDraftWithdrawn", "DRAFT_WITHDRAW", def, saved, null);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,4 +467,55 @@ public class DraftService {
|
|||||||
|
|
||||||
/** Convenience overload: всё что нужно от datetime. */
|
/** Convenience overload: всё что нужно от datetime. */
|
||||||
static OffsetDateTime nowUtc() { return OffsetDateTime.now(); }
|
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