feat(admin): scope-gated audit и outbox endpoints

This commit is contained in:
Александр Зимин
2026-05-13 16:42:11 +00:00
parent 0e165f69ca
commit da17930def
2 changed files with 61 additions and 4 deletions
@@ -1,7 +1,10 @@
package cloud.nstart.terravault.ordinis.restapi.web; 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.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -20,9 +23,11 @@ import java.util.UUID;
* Admin endpoints для audit log. Read-only, фильтры опциональны. * Admin endpoints для audit log. Read-only, фильтры опциональны.
* *
* <ul> * <ul>
* <li>{@code GET /admin/audit} — пагинированный поиск.</li> * <li>{@code GET /admin/audit} — пагинированный поиск (требует
* INTERNAL+ scope — audit видит cross-tenant события).</li>
* <li>{@code GET /admin/audit/export.csv} — выгрузка по тем же фильтрам * <li>{@code GET /admin/audit/export.csv} — выгрузка по тем же фильтрам
* (без пагинации; cap 10 000 строк против OOM).</li> * (без пагинации; cap 10 000 строк против OOM; требует RESTRICTED —
* bulk export — sensitive).</li>
* </ul> * </ul>
*/ */
@RestController @RestController
@@ -32,9 +37,29 @@ public class AuditAdminController {
private static final int MAX_EXPORT_ROWS = 10_000; private static final int MAX_EXPORT_ROWS = 10_000;
private final AuditLogRepository repository; private final AuditLogRepository repository;
private final ScopeContext scopeContext;
public AuditAdminController(AuditLogRepository repository) { public AuditAdminController(AuditLogRepository repository, ScopeContext scopeContext) {
this.repository = repository; 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 @GetMapping
@@ -47,6 +72,7 @@ public class AuditAdminController {
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) { @RequestParam(defaultValue = "50") int size) {
requireInternal();
Page<AuditLog> result = repository.search( Page<AuditLog> result = repository.search(
nullIfBlank(dictionaryName), nullIfBlank(dictionaryName),
nullIfBlank(userId), nullIfBlank(userId),
@@ -66,6 +92,7 @@ public class AuditAdminController {
@RequestParam(required = false) String businessKey, @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 from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) { @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) {
requireRestricted();
Page<AuditLog> result = repository.search( Page<AuditLog> result = repository.search(
nullIfBlank(dictionaryName), nullIfBlank(dictionaryName),
nullIfBlank(userId), nullIfBlank(userId),
@@ -1,7 +1,10 @@
package cloud.nstart.terravault.ordinis.restapi.web; 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.OutboxEvent;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; 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.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -18,19 +21,45 @@ import java.util.Map;
* Admin endpoints для outbox observability. Только чтение — replay делается * Admin endpoints для outbox observability. Только чтение — replay делается
* вручную через SQL (UPDATE outbox_events SET dlq_at=NULL, attempt_count=0 WHERE id=...). * вручную через SQL (UPDATE outbox_events SET dlq_at=NULL, attempt_count=0 WHERE id=...).
* Авто-replay сделаем когда появится первый реальный кейс DLQ. * Авто-replay сделаем когда появится первый реальный кейс DLQ.
*
* <p><b>Access:</b>
* <ul>
* <li>{@code /stats} — INTERNAL+ scope (просто counts, не sensitive).</li>
* <li>{@code /dlq} — RESTRICTED scope (lastError + payload могут содержать
* sensitive data из failed events).</li>
* </ul>
*/ */
@RestController @RestController
@RequestMapping("/api/v1/admin/outbox") @RequestMapping("/api/v1/admin/outbox")
public class OutboxAdminController { public class OutboxAdminController {
private final OutboxEventRepository repository; private final OutboxEventRepository repository;
private final ScopeContext scopeContext;
public OutboxAdminController(OutboxEventRepository repository) { public OutboxAdminController(OutboxEventRepository repository, ScopeContext scopeContext) {
this.repository = repository; 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") @GetMapping("/stats")
public Map<String, Long> stats() { public Map<String, Long> stats() {
requireInternal();
return Map.of( return Map.of(
"pending", repository.countPending(), "pending", repository.countPending(),
"dlq", repository.countByDlqAtIsNotNull() "dlq", repository.countByDlqAtIsNotNull()
@@ -41,6 +70,7 @@ public class OutboxAdminController {
public Page<DlqEntry> dlq( public Page<DlqEntry> dlq(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) { @RequestParam(defaultValue = "50") int size) {
requireRestricted();
Page<OutboxEvent> events = repository.findByDlqAtIsNotNull( Page<OutboxEvent> events = repository.findByDlqAtIsNotNull(
PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt"))); PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt")));
return events.map(DlqEntry::from); return events.map(DlqEntry::from);