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:
+73
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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) {
|
||||||
|
}
|
||||||
+38
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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) {}
|
||||||
|
}
|
||||||
+169
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user