From da17930def89b2fea51be1b8e45b4e5fabe752d1 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: Wed, 13 May 2026 16:42:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20scope-gated=20audit=20=D0=B8=20o?= =?UTF-8?q?utbox=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restapi/web/AuditAdminController.java | 33 +++++++++++++++++-- .../restapi/web/OutboxAdminController.java | 32 +++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java index 18eabe5..9b659b0 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/AuditAdminController.java @@ -1,7 +1,10 @@ package cloud.nstart.terravault.ordinis.restapi.web; +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.domain.audit.AuditLog; import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -20,9 +23,11 @@ import java.util.UUID; * Admin endpoints для audit log. Read-only, фильтры опциональны. * * */ @RestController @@ -32,9 +37,29 @@ public class AuditAdminController { private static final int MAX_EXPORT_ROWS = 10_000; private final AuditLogRepository repository; + private final ScopeContext scopeContext; - public AuditAdminController(AuditLogRepository repository) { + public AuditAdminController(AuditLogRepository repository, ScopeContext scopeContext) { this.repository = repository; + this.scopeContext = scopeContext; + } + + /** INTERNAL+ scope для просмотра audit log. PUBLIC отказывается. */ + private void requireInternal() { + if (!scopeContext.canAccess(DataScope.INTERNAL)) { + throw OrdinisException.forbidden( + "audit_internal_required", + "Audit log доступен только с INTERNAL или RESTRICTED scope."); + } + } + + /** RESTRICTED scope для bulk-операций (CSV экспорт). */ + private void requireRestricted() { + if (!scopeContext.canAccess(DataScope.RESTRICTED)) { + throw OrdinisException.forbidden( + "audit_restricted_required", + "Audit bulk export требует RESTRICTED scope (admin role)."); + } } @GetMapping @@ -47,6 +72,7 @@ public class AuditAdminController { @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size) { + requireInternal(); Page result = repository.search( nullIfBlank(dictionaryName), nullIfBlank(userId), @@ -66,6 +92,7 @@ public class AuditAdminController { @RequestParam(required = false) String businessKey, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) { + requireRestricted(); Page result = repository.search( nullIfBlank(dictionaryName), nullIfBlank(userId), diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/OutboxAdminController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/OutboxAdminController.java index 145999b..e234d11 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/OutboxAdminController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/OutboxAdminController.java @@ -1,7 +1,10 @@ package cloud.nstart.terravault.ordinis.restapi.web; +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent; import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -18,19 +21,45 @@ import java.util.Map; * Admin endpoints для outbox observability. Только чтение — replay делается * вручную через SQL (UPDATE outbox_events SET dlq_at=NULL, attempt_count=0 WHERE id=...). * Авто-replay сделаем когда появится первый реальный кейс DLQ. + * + *

Access: + *

    + *
  • {@code /stats} — INTERNAL+ scope (просто counts, не sensitive).
  • + *
  • {@code /dlq} — RESTRICTED scope (lastError + payload могут содержать + * sensitive data из failed events).
  • + *
*/ @RestController @RequestMapping("/api/v1/admin/outbox") public class OutboxAdminController { private final OutboxEventRepository repository; + private final ScopeContext scopeContext; - public OutboxAdminController(OutboxEventRepository repository) { + public OutboxAdminController(OutboxEventRepository repository, ScopeContext scopeContext) { this.repository = repository; + this.scopeContext = scopeContext; + } + + private void requireInternal() { + if (!scopeContext.canAccess(DataScope.INTERNAL)) { + throw OrdinisException.forbidden( + "outbox_internal_required", + "Outbox stats доступен только с INTERNAL или RESTRICTED scope."); + } + } + + private void requireRestricted() { + if (!scopeContext.canAccess(DataScope.RESTRICTED)) { + throw OrdinisException.forbidden( + "outbox_restricted_required", + "Outbox DLQ payloads требуют RESTRICTED scope (admin role)."); + } } @GetMapping("/stats") public Map stats() { + requireInternal(); return Map.of( "pending", repository.countPending(), "dlq", repository.countByDlqAtIsNotNull() @@ -41,6 +70,7 @@ public class OutboxAdminController { public Page dlq( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size) { + requireRestricted(); Page events = repository.findByDlqAtIsNotNull( PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt"))); return events.map(DlqEntry::from);