From 0003fb68019a39a515f5046df3f039dc3a48f79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Thu, 14 May 2026 12:32:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(drafts):=20audit=20+=20outbox=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20record-draft=20lifecycle=20(audit=20findings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ordinis/restapi/audit/AuditLogger.java | 50 +++++++++++++ .../restapi/service/draft/DraftService.java | 72 ++++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java index 709bece..9a8b8dc 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -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, diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java index 98ad3e9..5917332 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -64,6 +64,11 @@ public class DraftService { private final Optional 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, - 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. + * + *

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 extraPayload) { + if (auditLogger != null) { + auditLogger.recordDraftEvent( + eventType, action, def, + draft.getBusinessKey(), + /* before */ null, /* after */ draft.getData()); + } + if (outboxRecorder != null) { + java.util.Map 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); + } + } + } }