feat(webhook): Phase 3 — REST API CRUD + HMAC/SSRF tests

REST API endpoints под /api/v1/admin/webhooks/:
- GET    /subscriptions               — list (INTERNAL+ scope)
- POST   /subscriptions                — create (RESTRICTED scope)
- GET    /subscriptions/{id}           — get one (INTERNAL+)
- PUT    /subscriptions/{id}           — update (RESTRICTED)
- DELETE /subscriptions/{id}           — delete (RESTRICTED)
- POST   /subscriptions/{id}/rotate-secret  — regenerate HMAC (RESTRICTED)
- GET    /subscriptions/{id}/deliveries     — delivery history per sub
- GET    /deliveries/dlq                    — DLQ listing

RBAC через ScopeContext: RESTRICTED для mutating, INTERNAL+ для read.
PUBLIC scope получает 403.

DTOs:
- CreateWebhookSubscriptionRequest (Bean Validation: URL regex, length)
- WebhookSubscriptionResponse (с factory `from()` маскированный secret
  vs `fromWithSecret()` plaintext только для create response)
- WebhookDeliveryResponse

Service:
- HMAC secret 32 random bytes → 64 hex chars через SecureRandom
- Plaintext secret виден только в create/rotate response (single-time)
- list/get показывают `sk_****<last4>` masked

Tests (15 SsrfGuard + 8 HmacSigner = 23 new):
- HmacSigner: known vector verification, prefix, hex length, distinct
  inputs produce distinct sigs, empty secret rejected
- SsrfGuard: rejects 127/8, 169.254/16, 10/8, 192.168/16, 0.0.0.0,
  non-http schemes, missing host, unresolvable DNS. Allowlist bypass.
  isPrivate() unit tests для loopback/link-local/site-local/public IP.

Total: 109 → 132 unit tests.

Phase 4 далее: admin-ui /webhooks page (list + create + delivery history).
This commit is contained in:
Zimin A.N.
2026-05-06 15:12:28 +03:00
parent 9ac35e9ff2
commit e9a7278709
7 changed files with 617 additions and 0 deletions
@@ -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<DataScope> scopeFilter,
/** NULL/empty = match all dictionaries. */
List<String> dictionaryFilter,
/** NULL/empty = match all event types. */
List<String> eventTypeFilter,
Boolean active,
@Size(max = 1024) String description) {
}
@@ -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());
}
}
@@ -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_****<last4>}). Полный secret виден только в
* response создания (single-time reveal).
*/
public record WebhookSubscriptionResponse(
UUID id,
String name,
String url,
List<DataScope> scopeFilter,
List<String> dictionaryFilter,
List<String> 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);
}
}
@@ -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.
*
* <p>Возвращается plaintext secret ТОЛЬКО при create (single-time reveal).
* Update/list — secret замаскирован ({@code sk_****<last4>}).
*/
@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) {}
}
@@ -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'и читать.
*
* <p>Endpoints:
* <pre>
* 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
* </pre>
*/
@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<WebhookSubscriptionResponse> 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<WebhookDeliveryResponse> deliveries(
@PathVariable UUID id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
requireInternal();
Page<WebhookDelivery> deliveries = deliveryRepository
.findBySubscriptionIdOrderByCreatedAtDesc(
id, PageRequest.of(page, Math.min(size, 200)));
return deliveries.map(WebhookDeliveryResponse::from);
}
@GetMapping("/deliveries/dlq")
public Page<WebhookDeliveryResponse> dlq(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
requireInternal();
Page<WebhookDelivery> 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();
}
}