Merge branch 'feat/admin-role-gate-audit-outbox-webhooks' into 'main'
feat(admin): scope-gated audit и outbox endpoints See merge request 2-6/2-6-4/terravault/ordinis!176
This commit is contained in:
+30
-3
@@ -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),
|
||||||
|
|||||||
+31
-1
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user