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, фильтры опциональны.
*
*
- * - {@code GET /admin/audit} — пагинированный поиск.
+ * - {@code GET /admin/audit} — пагинированный поиск (требует
+ * INTERNAL+ scope — audit видит cross-tenant события).
* - {@code GET /admin/audit/export.csv} — выгрузка по тем же фильтрам
- * (без пагинации; cap 10 000 строк против OOM).
+ * (без пагинации; cap 10 000 строк против OOM; требует RESTRICTED —
+ * bulk export — sensitive).
*
*/
@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);