diff --git a/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/HmacSignerTest.java b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/HmacSignerTest.java new file mode 100644 index 0000000..4ae59de --- /dev/null +++ b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/HmacSignerTest.java @@ -0,0 +1,73 @@ +package cloud.nstart.terravault.ordinis.outbox.webhook; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HmacSignerTest { + + // Reference value computed via: + // echo -n "hello" | openssl dgst -sha256 -hmac "secret" + // = 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b + private static final String KNOWN_SECRET = "secret"; + private static final String KNOWN_BODY = "hello"; + private static final String KNOWN_SIG = "sha256=88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b"; + + @Test + void signsKnownVectorCorrectly() { + String sig = HmacSigner.sign(KNOWN_SECRET, KNOWN_BODY.getBytes(StandardCharsets.UTF_8)); + assertThat(sig).isEqualTo(KNOWN_SIG); + } + + @Test + void signaturePrefixedWithSha256() { + String sig = HmacSigner.sign("any-secret", "body".getBytes()); + assertThat(sig).startsWith("sha256="); + } + + @Test + void hexLengthIs64Chars() { + String sig = HmacSigner.sign("any-secret", "body".getBytes()); + // "sha256=" = 7 chars + 64 hex = 71 + assertThat(sig).hasSize(71); + assertThat(sig.substring(7)).matches("[0-9a-f]{64}"); + } + + @Test + void differentBodyProducesDifferentSignature() { + String sig1 = HmacSigner.sign("secret", "body1".getBytes()); + String sig2 = HmacSigner.sign("secret", "body2".getBytes()); + assertThat(sig1).isNotEqualTo(sig2); + } + + @Test + void differentSecretProducesDifferentSignature() { + String sig1 = HmacSigner.sign("secret-a", "body".getBytes()); + String sig2 = HmacSigner.sign("secret-b", "body".getBytes()); + assertThat(sig1).isNotEqualTo(sig2); + } + + @Test + void emptySecretRejected() { + assertThatThrownBy(() -> HmacSigner.sign("", "body".getBytes())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> HmacSigner.sign(null, "body".getBytes())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void emptyBodySignsCleanly() { + // Empty body must produce a deterministic signature, not throw + String sig = HmacSigner.sign("secret", new byte[0]); + assertThat(sig).startsWith("sha256="); + assertThat(sig.substring(7)).matches("[0-9a-f]{64}"); + } + + @Test + void headerNameConstant() { + assertThat(HmacSigner.HEADER_NAME).isEqualTo("X-Ordinis-Signature"); + } +} diff --git a/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/SsrfGuardTest.java b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/SsrfGuardTest.java new file mode 100644 index 0000000..986bba1 --- /dev/null +++ b/ordinis-outbox/src/test/java/cloud/nstart/terravault/ordinis/outbox/webhook/SsrfGuardTest.java @@ -0,0 +1,116 @@ +package cloud.nstart.terravault.ordinis.outbox.webhook; + +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SSRF guard тесты. Часть тестов используют InetAddress.getByName() с + * IP literal — DNS не нужен. Тесты с hostname (e.g. example.com) не + * запускаем чтобы CI работал офлайн. + */ +class SsrfGuardTest { + + private final SsrfGuard guard = new SsrfGuard(List.of("trusted.internal.test")); + + @Test + void rejectsLoopbackIp() { + String reason = guard.validate("http://127.0.0.1:8080/hook"); + assertThat(reason).contains("private/loopback"); + } + + @Test + void rejectsLinkLocalIp() { + String reason = guard.validate("http://169.254.169.254/latest/meta-data"); + assertThat(reason).contains("private/loopback"); + } + + @Test + void rejectsRfc1918Ip10() { + String reason = guard.validate("http://10.0.0.1/hook"); + assertThat(reason).contains("private/loopback"); + } + + @Test + void rejectsRfc1918Ip192_168() { + String reason = guard.validate("http://192.168.100.161/hook"); + assertThat(reason).contains("private/loopback"); + } + + @Test + void rejectsAnyLocalIp() { + String reason = guard.validate("http://0.0.0.0/hook"); + assertThat(reason).contains("private/loopback"); + } + + @Test + void rejectsNonHttpScheme() { + String reason = guard.validate("ftp://example.com/hook"); + assertThat(reason).contains("URL scheme must be http or https"); + } + + @Test + void rejectsMissingHost() { + String reason = guard.validate("http:///hook"); + assertThat(reason).contains("must have a host"); + } + + @Test + void rejectsUnresolvableHost() { + String reason = guard.validate("http://this-host-must-not-exist-ordinis-test.invalid/"); + assertThat(reason).contains("DNS resolution failed"); + } + + @Test + void allowedHostsBypassPrivateCheck() { + // trusted.internal.test в allowlist — даже если резолвится в private, OK + SsrfGuard guardWithAllow = new SsrfGuard(List.of("127.0.0.1")); + // Подаём 127.0.0.1 как hostname — но literal IP всё равно private check + // НО allowlist обрабатывается ПЕРЕД resolution через host string match, + // не IP. Если URL hostname == allowlist entry, OK. + String reason = guardWithAllow.validate("http://127.0.0.1/hook"); + assertThat(reason).isNull(); + } + + @Test + void emptyAllowlistDefaultsToBlockingPrivate() { + SsrfGuard guardNone = new SsrfGuard(List.of()); + assertThat(guardNone.validate("http://127.0.0.1/")).isNotNull(); + } + + @Test + void nullAllowlistTreatedAsEmpty() { + SsrfGuard guardNull = new SsrfGuard(null); + assertThat(guardNull.validate("http://127.0.0.1/")).isNotNull(); + } + + @Test + void isPrivateRecognizesLoopback() throws UnknownHostException { + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("127.0.0.1"))).isTrue(); + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("::1"))).isTrue(); + } + + @Test + void isPrivateRecognizesLinkLocal() throws UnknownHostException { + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("169.254.0.1"))).isTrue(); + } + + @Test + void isPrivateRecognizesSiteLocal() throws UnknownHostException { + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("10.0.0.1"))).isTrue(); + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("172.16.0.1"))).isTrue(); + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("192.168.1.1"))).isTrue(); + } + + @Test + void isPrivateAllowsPublicIp() throws UnknownHostException { + // 1.1.1.1 = Cloudflare DNS, public + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("1.1.1.1"))).isFalse(); + // 8.8.8.8 = Google DNS, public + assertThat(SsrfGuard.isPrivate(InetAddress.getByName("8.8.8.8"))).isFalse(); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/CreateWebhookSubscriptionRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/CreateWebhookSubscriptionRequest.java new file mode 100644 index 0000000..c55605d --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/CreateWebhookSubscriptionRequest.java @@ -0,0 +1,28 @@ +package cloud.nstart.terravault.ordinis.restapi.dto.webhook; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * Request на создание/обновление webhook subscription. + * URL валидируется regex (http/https), фильтры optional. + */ +public record CreateWebhookSubscriptionRequest( + @NotBlank @Size(max = 128) String name, + @NotBlank + @Size(max = 2048) + @Pattern(regexp = "^https?://.+", message = "URL must start with http:// or https://") + String url, + /** NULL/empty = match all scopes. */ + List scopeFilter, + /** NULL/empty = match all dictionaries. */ + List dictionaryFilter, + /** NULL/empty = match all event types. */ + List eventTypeFilter, + Boolean active, + @Size(max = 1024) String description) { +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/WebhookDeliveryResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/WebhookDeliveryResponse.java new file mode 100644 index 0000000..630b705 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/WebhookDeliveryResponse.java @@ -0,0 +1,38 @@ +package cloud.nstart.terravault.ordinis.restapi.dto.webhook; + +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** Response для delivery history listing (drilldown в admin UI). */ +public record WebhookDeliveryResponse( + Long id, + UUID subscriptionId, + Long outboxEventId, + WebhookDelivery.Status status, + int attemptCount, + OffsetDateTime createdAt, + OffsetDateTime nextAttemptAt, + OffsetDateTime lastAttemptAt, + OffsetDateTime deliveredAt, + String lastError, + Integer lastStatusCode, + OffsetDateTime dlqAt) { + + public static WebhookDeliveryResponse from(WebhookDelivery d) { + return new WebhookDeliveryResponse( + d.getId(), + d.getSubscriptionId(), + d.getOutboxEventId(), + d.getStatus(), + d.getAttemptCount(), + d.getCreatedAt(), + d.getNextAttemptAt(), + d.getLastAttemptAt(), + d.getDeliveredAt(), + d.getLastError(), + d.getLastStatusCode(), + d.getDlqAt()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/WebhookSubscriptionResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/WebhookSubscriptionResponse.java new file mode 100644 index 0000000..d9a301f --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/webhook/WebhookSubscriptionResponse.java @@ -0,0 +1,66 @@ +package cloud.nstart.terravault.ordinis.restapi.dto.webhook; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Response для list/get/update endpoints. {@link #hmacSecret} ВСЕГДА + * замаскирован ({@code sk_****}). Полный secret виден только в + * response создания (single-time reveal). + */ +public record WebhookSubscriptionResponse( + UUID id, + String name, + String url, + List scopeFilter, + List dictionaryFilter, + List eventTypeFilter, + String hmacSecret, + boolean active, + OffsetDateTime createdAt, + String createdBy, + OffsetDateTime updatedAt, + String description) { + + public static WebhookSubscriptionResponse from(WebhookSubscription s) { + return new WebhookSubscriptionResponse( + s.getId(), + s.getName(), + s.getUrl(), + s.getScopeFilter(), + s.getDictionaryFilter(), + s.getEventTypeFilter(), + maskSecret(s.getHmacSecret()), + s.isActive(), + s.getCreatedAt(), + s.getCreatedBy(), + s.getUpdatedAt(), + s.getDescription()); + } + + /** Same payload + plaintext HMAC secret — только для create response. */ + public static WebhookSubscriptionResponse fromWithSecret(WebhookSubscription s, String plaintextSecret) { + return new WebhookSubscriptionResponse( + s.getId(), + s.getName(), + s.getUrl(), + s.getScopeFilter(), + s.getDictionaryFilter(), + s.getEventTypeFilter(), + plaintextSecret, + s.isActive(), + s.getCreatedAt(), + s.getCreatedBy(), + s.getUpdatedAt(), + s.getDescription()); + } + + private static String maskSecret(String secret) { + if (secret == null || secret.length() < 8) return "sk_****"; + return "sk_****" + secret.substring(secret.length() - 4); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookSubscriptionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookSubscriptionService.java new file mode 100644 index 0000000..f283cc1 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/webhook/WebhookSubscriptionService.java @@ -0,0 +1,127 @@ +package cloud.nstart.terravault.ordinis.restapi.service.webhook; + +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription; +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscriptionRepository; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.dto.webhook.CreateWebhookSubscriptionRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.UUID; + +/** + * Service для CRUD над {@link WebhookSubscription}. HMAC secret генерируется + * server-side через {@link SecureRandom} 32 bytes, hex-encoded в 64 chars. + * + *

Возвращается plaintext secret ТОЛЬКО при create (single-time reveal). + * Update/list — secret замаскирован ({@code sk_****}). + */ +@Service +public class WebhookSubscriptionService { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + + private final WebhookSubscriptionRepository repository; + + public WebhookSubscriptionService(WebhookSubscriptionRepository repository) { + this.repository = repository; + } + + /** + * Создать subscription. Возвращает entity + plaintext secret отдельно + * чтобы caller мог вернуть его once в response. + */ + @Transactional + public CreateResult create(CreateWebhookSubscriptionRequest req, String createdBy) { + String plaintextSecret = generateSecret(); + WebhookSubscription sub = new WebhookSubscription( + req.name(), + req.url(), + plaintextSecret, + createdBy); + sub.setScopeFilter(req.scopeFilter()); + sub.setDictionaryFilter(req.dictionaryFilter()); + sub.setEventTypeFilter(req.eventTypeFilter()); + if (req.active() != null) sub.setActive(req.active()); + if (req.description() != null) sub.setDescription(req.description()); + + WebhookSubscription saved = repository.save(sub); + return new CreateResult(saved, plaintextSecret); + } + + @Transactional(readOnly = true) + public WebhookSubscription findById(UUID id) { + return repository.findById(id) + .orElseThrow(() -> OrdinisException.notFound( + "webhook_subscription_not_found", + "Webhook subscription not found: " + id)); + } + + @Transactional + public WebhookSubscription update(UUID id, CreateWebhookSubscriptionRequest req) { + WebhookSubscription sub = findById(id); + sub.setName(req.name()); + sub.setUrl(req.url()); + sub.setScopeFilter(req.scopeFilter()); + sub.setDictionaryFilter(req.dictionaryFilter()); + sub.setEventTypeFilter(req.eventTypeFilter()); + if (req.active() != null) sub.setActive(req.active()); + if (req.description() != null) sub.setDescription(req.description()); + return repository.save(sub); + } + + @Transactional + public void delete(UUID id) { + if (!repository.existsById(id)) { + throw OrdinisException.notFound( + "webhook_subscription_not_found", + "Webhook subscription not found: " + id); + } + repository.deleteById(id); + } + + /** + * Rotate secret — generate new, return plaintext once. Старый secret + * сразу не работает (FYI receiver надо обновить раньше rotate). + */ + @Transactional + public CreateResult rotateSecret(UUID id) { + WebhookSubscription sub = findById(id); + String newSecret = generateSecret(); + // Reflection не надо — добавим setter (но мы намеренно не сделали setter + // для hmacSecret чтобы избежать случайного assignment). Используем + // recreate pattern: тот же id, новый secret через JPA detached → save. + WebhookSubscription rotated = new WebhookSubscription( + sub.getName(), sub.getUrl(), newSecret, sub.getCreatedBy()); + setIdReflection(rotated, sub.getId()); + rotated.setScopeFilter(sub.getScopeFilter()); + rotated.setDictionaryFilter(sub.getDictionaryFilter()); + rotated.setEventTypeFilter(sub.getEventTypeFilter()); + rotated.setActive(sub.isActive()); + rotated.setDescription(sub.getDescription()); + WebhookSubscription saved = repository.save(rotated); + return new CreateResult(saved, newSecret); + } + + private static String generateSecret() { + byte[] bytes = new byte[32]; // 256 bits + RANDOM.nextBytes(bytes); + return HEX.formatHex(bytes); + } + + /** JPA fields private; нет clean setter для id поэтому reflection. */ + private static void setIdReflection(WebhookSubscription sub, UUID id) { + try { + var field = WebhookSubscription.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(sub, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalStateException("Failed to set id via reflection", e); + } + } + + public record CreateResult(WebhookSubscription subscription, String plaintextSecret) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java new file mode 100644 index 0000000..a133cfb --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/webhook/WebhookSubscriptionController.java @@ -0,0 +1,169 @@ +package cloud.nstart.terravault.ordinis.restapi.web.webhook; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery; +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDeliveryRepository; +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription; +import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscriptionRepository; +import cloud.nstart.terravault.ordinis.restapi.dto.webhook.CreateWebhookSubscriptionRequest; +import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookDeliveryResponse; +import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookSubscriptionResponse; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookSubscriptionService; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +/** + * Webhook subscriptions admin API. RBAC: only RESTRICTED scope can mutate + * (create/update/delete/rotate-secret), INTERNAL+ может listing'и читать. + * + *

Endpoints: + *

+ *   GET    /api/v1/admin/webhooks/subscriptions       — list
+ *   POST   /api/v1/admin/webhooks/subscriptions       — create (returns plaintext secret)
+ *   GET    /api/v1/admin/webhooks/subscriptions/{id}  — get one
+ *   PUT    /api/v1/admin/webhooks/subscriptions/{id}  — update
+ *   DELETE /api/v1/admin/webhooks/subscriptions/{id}  — delete
+ *   POST   /api/v1/admin/webhooks/subscriptions/{id}/rotate-secret
+ *   GET    /api/v1/admin/webhooks/subscriptions/{id}/deliveries
+ *   GET    /api/v1/admin/webhooks/deliveries/dlq
+ * 
+ */ +@RestController +@RequestMapping("/api/v1/admin/webhooks") +public class WebhookSubscriptionController { + + private final WebhookSubscriptionService service; + private final WebhookSubscriptionRepository subscriptionRepository; + private final WebhookDeliveryRepository deliveryRepository; + private final ScopeContext scopeContext; + + public WebhookSubscriptionController( + WebhookSubscriptionService service, + WebhookSubscriptionRepository subscriptionRepository, + WebhookDeliveryRepository deliveryRepository, + ScopeContext scopeContext) { + this.service = service; + this.subscriptionRepository = subscriptionRepository; + this.deliveryRepository = deliveryRepository; + this.scopeContext = scopeContext; + } + + // === Subscriptions CRUD === + + @GetMapping("/subscriptions") + public List list() { + requireInternal(); + return subscriptionRepository.findAll().stream() + .map(WebhookSubscriptionResponse::from) + .toList(); + } + + @GetMapping("/subscriptions/{id}") + public WebhookSubscriptionResponse get(@PathVariable UUID id) { + requireInternal(); + WebhookSubscription sub = service.findById(id); + return WebhookSubscriptionResponse.from(sub); + } + + @PostMapping("/subscriptions") + @ResponseStatus(HttpStatus.CREATED) + public WebhookSubscriptionResponse create( + @Valid @RequestBody CreateWebhookSubscriptionRequest req) { + requireAdmin(); + WebhookSubscriptionService.CreateResult result = service.create(req, currentUserId()); + // Plaintext secret виден ТОЛЬКО здесь — caller должен сохранить. + return WebhookSubscriptionResponse.fromWithSecret(result.subscription(), result.plaintextSecret()); + } + + @PutMapping("/subscriptions/{id}") + public WebhookSubscriptionResponse update( + @PathVariable UUID id, + @Valid @RequestBody CreateWebhookSubscriptionRequest req) { + requireAdmin(); + return WebhookSubscriptionResponse.from(service.update(id, req)); + } + + @DeleteMapping("/subscriptions/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable UUID id) { + requireAdmin(); + service.delete(id); + } + + @PostMapping("/subscriptions/{id}/rotate-secret") + public WebhookSubscriptionResponse rotateSecret(@PathVariable UUID id) { + requireAdmin(); + WebhookSubscriptionService.CreateResult result = service.rotateSecret(id); + return WebhookSubscriptionResponse.fromWithSecret(result.subscription(), result.plaintextSecret()); + } + + // === Delivery history === + + @GetMapping("/subscriptions/{id}/deliveries") + public Page deliveries( + @PathVariable UUID id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + requireInternal(); + Page deliveries = deliveryRepository + .findBySubscriptionIdOrderByCreatedAtDesc( + id, PageRequest.of(page, Math.min(size, 200))); + return deliveries.map(WebhookDeliveryResponse::from); + } + + @GetMapping("/deliveries/dlq") + public Page dlq( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + requireInternal(); + Page deliveries = deliveryRepository.findByStatus( + WebhookDelivery.Status.dlq, + PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt"))); + return deliveries.map(WebhookDeliveryResponse::from); + } + + // === Helpers === + + /** RESTRICTED scope обязателен для mutating endpoints. */ + private void requireAdmin() { + if (!scopeContext.canAccess(DataScope.RESTRICTED)) { + throw OrdinisException.forbidden( + "webhook_admin_required", + "Webhook subscription management requires RESTRICTED scope (admin role)."); + } + } + + /** INTERNAL+ scope для read endpoints. PUBLIC отказывается. */ + private void requireInternal() { + if (!scopeContext.canAccess(DataScope.INTERNAL)) { + throw OrdinisException.forbidden( + "webhook_internal_required", + "Webhook subscriptions accessible only with INTERNAL or RESTRICTED scope."); + } + } + + private static String currentUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth == null ? "anonymous" : auth.getName(); + } +}