feat(admin): scope-gated audit и outbox endpoints
This commit is contained in:
+30
-3
@@ -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, фильтры опциональны.
|
||||
*
|
||||
* <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} — выгрузка по тем же фильтрам
|
||||
* (без пагинации; cap 10 000 строк против OOM).</li>
|
||||
* (без пагинации; cap 10 000 строк против OOM; требует RESTRICTED —
|
||||
* bulk export — sensitive).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<AuditLog> 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<AuditLog> result = repository.search(
|
||||
nullIfBlank(dictionaryName),
|
||||
nullIfBlank(userId),
|
||||
|
||||
+31
-1
@@ -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.
|
||||
*
|
||||
* <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
|
||||
@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<String, Long> stats() {
|
||||
requireInternal();
|
||||
return Map.of(
|
||||
"pending", repository.countPending(),
|
||||
"dlq", repository.countByDlqAtIsNotNull()
|
||||
@@ -41,6 +70,7 @@ public class OutboxAdminController {
|
||||
public Page<DlqEntry> dlq(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
requireRestricted();
|
||||
Page<OutboxEvent> events = repository.findByDlqAtIsNotNull(
|
||||
PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt")));
|
||||
return events.map(DlqEntry::from);
|
||||
|
||||
Reference in New Issue
Block a user