diff --git a/src/main/java/org/hibernate/infra/replicate/jira/JiraConfig.java b/src/main/java/org/hibernate/infra/replicate/jira/JiraConfig.java index b252294..3f4677d 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/JiraConfig.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/JiraConfig.java @@ -192,6 +192,11 @@ interface JiraProject { * Allows enabling signature verification. */ WebHookSecurity security(); + + /** + * Allows enabling signature verification of downstream events. + */ + WebHookSecurity downstreamSecurity(); } interface WebHookSecurity { @@ -205,11 +210,20 @@ interface WebHookSecurity { @WithDefault("false") boolean enabled(); + @WithDefault("SIGNATURE") + Type type(); + /** - * The secret used to sing the web hook request body. + * Verification secret, e.g. the secret used to sing the web hook request body. + * Can also be just some token that we will compare. Depends on the security + * type. */ @WithDefault("not-a-secret") String secret(); + + enum Type { + SIGNATURE, TOKEN + } } interface Instance { @@ -374,6 +388,9 @@ interface UserValueMapping extends ValueMapping { */ @WithDefault("not-a-user") Set ignoredUpstreamUsers(); + + @WithDefault("not-a-user") + Set ignoredDownstreamUsers(); } /** diff --git a/src/main/java/org/hibernate/infra/replicate/jira/resource/JiraWebHookListenerResource.java b/src/main/java/org/hibernate/infra/replicate/jira/resource/JiraWebHookListenerResource.java index 8ab4c5e..c32a6c0 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/resource/JiraWebHookListenerResource.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/resource/JiraWebHookListenerResource.java @@ -1,6 +1,7 @@ package org.hibernate.infra.replicate.jira.resource; import org.hibernate.infra.replicate.jira.service.jira.JiraService; +import org.hibernate.infra.replicate.jira.service.jira.model.action.JiraActionEvent; import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraWebHookEvent; import org.hibernate.infra.replicate.jira.service.validation.ConfiguredProject; @@ -30,4 +31,14 @@ public String somethingHappenedUpstream(@RestPath @NotNull @ConfiguredProject St jiraService.acknowledge(project, event, triggeredByUser); return "ack"; } + + @POST + @Path("/downstream/{project}") + @Consumes(MediaType.APPLICATION_JSON) + public String somethingHappenedDownstream(@RestPath @NotNull @ConfiguredProject(upstream = false) String project, + JiraActionEvent data) { + Log.infof("Received a downstream notification about %s project: %s...", project, data); + jiraService.downstreamAcknowledge(project, data); + return "ack"; + } } diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectContext.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectContext.java index 72b4882..e3ff728 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectContext.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectContext.java @@ -181,6 +181,10 @@ public void startProcessingEvent() throws InterruptedException { projectGroupContext.startProcessingEvent(); } + public void startProcessingDownstreamEvent() throws InterruptedException { + projectGroupContext.startProcessingDownstreamEvent(); + } + public JiraUser notMappedAssignee() { return notMappedAssignee; } @@ -229,6 +233,14 @@ public void submitTask(Runnable runnable) { projectGroupContext.submitTask(runnable); } + public int pendingDownstreamEventsInCurrentContext() { + return projectGroupContext.pendingDownstreamEventsInCurrentContext(); + } + + public void submitDownstreamTask(Runnable runnable) { + projectGroupContext.submitDownstreamTask(runnable); + } + public Optional contextForProjectInSameGroup(String project) { if (!projectGroup().projects().containsKey(project)) { // different project group, don't bother @@ -351,4 +363,12 @@ private static boolean versionNeedsUpdate(JiraVersion upstreamVersion, JiraVersi public boolean isUserIgnored(String triggeredByUser) { return projectGroupContext.projectGroup().users().ignoredUpstreamUsers().contains(triggeredByUser); } + + public boolean isDownstreamUserIgnored(String triggeredByUser) { + return projectGroupContext.projectGroup().users().ignoredDownstreamUsers().contains(triggeredByUser); + } + + public String upstreamUser(String mappedValue) { + return projectGroupContext.upstreamUser(mappedValue); + } } diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectGroupContext.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectGroupContext.java index 5c85030..2c1cf54 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectGroupContext.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/HandlerProjectGroupContext.java @@ -1,5 +1,8 @@ package org.hibernate.infra.replicate.jira.service.jira; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingDeque; @@ -17,9 +20,14 @@ public final class HandlerProjectGroupContext implements AutoCloseable { private final ExecutorService eventHandlingExecutor; private final Supplier workQueueSize; + + private final ExecutorService downstreamEventHandlingExecutor; + private final Supplier downstreamWorkQueueSize; private final ScheduledExecutorService rateLimiterExecutor = Executors.newScheduledThreadPool(1); private final Semaphore rateLimiter; + private final Semaphore downstreamRateLimiter; private final JiraConfig.JiraProjectGroup projectGroup; + private final Map invertedUsers; public HandlerProjectGroupContext(JiraConfig.JiraProjectGroup projectGroup) { this.projectGroup = projectGroup; @@ -28,21 +36,39 @@ public HandlerProjectGroupContext(JiraConfig.JiraProjectGroup projectGroup) { final int permits = processing.eventsPerTimeframe(); this.rateLimiter = new Semaphore(permits); + this.downstreamRateLimiter = new Semaphore(permits); rateLimiterExecutor.scheduleAtFixedRate(() -> { rateLimiter.drainPermits(); rateLimiter.release(permits); + downstreamRateLimiter.drainPermits(); + downstreamRateLimiter.release(permits); }, processing.timeframeInSeconds(), processing.timeframeInSeconds(), TimeUnit.SECONDS); LinkedBlockingDeque workQueue = new LinkedBlockingDeque<>(processing.queueSize()); workQueueSize = workQueue::size; eventHandlingExecutor = new ThreadPoolExecutor(processing.threads(), processing.threads(), 0L, TimeUnit.MILLISECONDS, workQueue); + + LinkedBlockingDeque downstreamWorkQueue = new LinkedBlockingDeque<>(processing.queueSize()); + downstreamWorkQueueSize = downstreamWorkQueue::size; + downstreamEventHandlingExecutor = new ThreadPoolExecutor(processing.threads(), processing.threads(), 0L, + TimeUnit.MILLISECONDS, downstreamWorkQueue); + + Map invertedUsers = new HashMap<>(); + for (var entry : projectGroup.users().mapping().entrySet()) { + invertedUsers.put(entry.getValue(), entry.getKey()); + } + this.invertedUsers = Collections.unmodifiableMap(invertedUsers); } public void startProcessingEvent() throws InterruptedException { rateLimiter.acquire(1); } + public void startProcessingDownstreamEvent() throws InterruptedException { + downstreamRateLimiter.acquire(1); + } + public JiraConfig.JiraProjectGroup projectGroup() { return projectGroup; } @@ -51,10 +77,18 @@ public int pendingEventsInCurrentContext() { return workQueueSize.get(); } + public int pendingDownstreamEventsInCurrentContext() { + return downstreamWorkQueueSize.get(); + } + public void submitTask(Runnable task) { eventHandlingExecutor.submit(task); } + public void submitDownstreamTask(Runnable task) { + downstreamEventHandlingExecutor.submit(task); + } + @Override public void close() { // when requesting to close the context we aren't expecting to process any other @@ -62,10 +96,15 @@ public void close() { if (!rateLimiterExecutor.isShutdown()) { rateLimiterExecutor.shutdownNow(); } - if (!eventHandlingExecutor.isShutdown()) { + closeEventExecutor(eventHandlingExecutor); + closeEventExecutor(downstreamEventHandlingExecutor); + } + + private static void closeEventExecutor(ExecutorService executor) { + if (!executor.isShutdown()) { try { - eventHandlingExecutor.shutdown(); - if (!eventHandlingExecutor.awaitTermination(2, TimeUnit.MINUTES)) { + executor.shutdown(); + if (!executor.awaitTermination(2, TimeUnit.MINUTES)) { Log.warnf("Not all events were processed before the shutdown"); } } catch (InterruptedException e) { @@ -73,4 +112,8 @@ public void close() { } } } + + public String upstreamUser(String mappedValue) { + return invertedUsers.get(mappedValue); + } } diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/JiraService.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/JiraService.java index 44a54f7..8f7feed 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/JiraService.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/JiraService.java @@ -18,6 +18,7 @@ import org.hibernate.infra.replicate.jira.service.jira.handler.JiraIssueDeleteEventHandler; import org.hibernate.infra.replicate.jira.service.jira.handler.JiraIssueSimpleUpsertEventHandler; import org.hibernate.infra.replicate.jira.service.jira.handler.JiraIssueTransitionOnlyEventHandler; +import org.hibernate.infra.replicate.jira.service.jira.model.action.JiraActionEvent; import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraWebHookEvent; import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraWebHookIssue; import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraWebHookIssueLink; @@ -322,6 +323,29 @@ public void acknowledge(String project, JiraWebHookEvent event, String triggered }, () -> Log.infof("Event type %s is not supported and cannot be handled.", event.webhookEvent)); } + public void downstreamAcknowledge(String project, JiraActionEvent event) { + event.eventType().ifPresentOrElse(eventType -> { + var context = contextPerProject.get(project); + if (context == null) { + FailureCollector failureCollector = FailureCollector.collector(reportingConfig); + failureCollector.critical("Unable to determine handler context for project %s. Was it not configured ?" + .formatted(project)); + failureCollector.close(); + throw new ConstraintViolationException("Project " + project + " is not configured.", Set.of()); + } + + if (context.isDownstreamUserIgnored(event.triggeredByUser)) { + Log.infof("Event was triggered by %s user that is in the ignore list.", event.triggeredByUser); + return; + } + + for (Runnable handler : eventType.handlers(reportingConfig, event, context)) { + context.submitDownstreamTask(handler); + } + }, () -> Log.infof("Event type %s is not supported and cannot be handled.", event.event)); + + } + public void syncLastUpdated(String projectGroup) { try (FailureCollector failureCollector = FailureCollector.collector(reportingConfig)) { Log.infof("Starting scheduled sync of issues for the project group %s", projectGroup); diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/handler/action/JiraActionEventHandler.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/handler/action/JiraActionEventHandler.java new file mode 100644 index 0000000..e0298f4 --- /dev/null +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/handler/action/JiraActionEventHandler.java @@ -0,0 +1,49 @@ +package org.hibernate.infra.replicate.jira.service.jira.handler.action; + +import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext; +import org.hibernate.infra.replicate.jira.service.jira.model.action.JiraActionEvent; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue; +import org.hibernate.infra.replicate.jira.service.reporting.FailureCollector; +import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig; + +import io.quarkus.logging.Log; + +public abstract class JiraActionEventHandler implements Runnable { + + protected final JiraActionEvent event; + protected final FailureCollector failureCollector; + protected final HandlerProjectContext context; + + protected JiraActionEventHandler(ReportingConfig reportingConfig, HandlerProjectContext context, + JiraActionEvent event) { + this.event = event; + this.failureCollector = FailureCollector.collector(reportingConfig); + this.context = context; + } + + @Override + public final void run() { + try { + context.startProcessingDownstreamEvent(); + doRun(); + } catch (RuntimeException e) { + failureCollector.critical("Failed to handled the event: %s".formatted(this), e); + } catch (InterruptedException e) { + failureCollector.critical("Interrupted while waiting in the queue", e); + Thread.currentThread().interrupt(); + } finally { + failureCollector.close(); + Log.infof("Finished processing %s. Pending events in %s to process: %s", this.toString(), + context.projectGroupName(), context.pendingDownstreamEventsInCurrentContext()); + } + } + + protected String toSourceKey(String key) { + return "%s-%d".formatted(context.project().originalProjectKey(), JiraIssue.keyToLong(key)); + } + + protected abstract void doRun(); + + public abstract String toString(); + +} diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/handler/action/JiraAssigneeActionEventHandler.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/handler/action/JiraAssigneeActionEventHandler.java new file mode 100644 index 0000000..ee64b6c --- /dev/null +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/handler/action/JiraAssigneeActionEventHandler.java @@ -0,0 +1,41 @@ +package org.hibernate.infra.replicate.jira.service.jira.handler.action; + +import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext; +import org.hibernate.infra.replicate.jira.service.jira.model.action.JiraActionEvent; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraFields; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraUser; +import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig; + +public class JiraAssigneeActionEventHandler extends JiraActionEventHandler { + + public JiraAssigneeActionEventHandler(ReportingConfig reportingConfig, HandlerProjectContext context, + JiraActionEvent event) { + super(reportingConfig, context, event); + } + + @Override + protected void doRun() { + JiraIssue issue = context.destinationJiraClient().getIssue(event.key); + + JiraIssue updated = new JiraIssue(); + updated.fields = JiraFields.empty(); + if (issue.fields.assignee != null) { + String accountId = context.upstreamUser( + issue.fields.assignee.mappedIdentifier(context.projectGroup().users().mappedPropertyName())); + + if (accountId != null) { + updated.fields.assignee = new JiraUser(accountId); + + } + } else { + updated.fields.assignee = new JiraUser("-1"); + } + context.sourceJiraClient().update(toSourceKey(event.key), updated); + } + + @Override + public String toString() { + return "JiraAssigneeActionEventHandler[" + "event=" + event + ", project=" + context.projectName() + ']'; + } +} diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/action/JiraActionEvent.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/action/JiraActionEvent.java new file mode 100644 index 0000000..3ce87c4 --- /dev/null +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/action/JiraActionEvent.java @@ -0,0 +1,26 @@ +package org.hibernate.infra.replicate.jira.service.jira.model.action; + +import java.util.Optional; + +import org.hibernate.infra.replicate.jira.service.jira.model.JiraBaseObject; +import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraActionEventType; + +public class JiraActionEvent extends JiraBaseObject { + public String id; + public String key; + public String event; + public String assignee; + public String status; + + public String triggeredByUser; + + public Optional eventType() { + return JiraActionEventType.of(event); + } + + @Override + public String toString() { + return "JiraActionEvent{" + "id='" + id + '\'' + ", key='" + key + '\'' + ", event='" + event + '\'' + + ", assignee='" + assignee + '\'' + ", status='" + status + '\'' + '}'; + } +} diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/hook/JiraActionEventType.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/hook/JiraActionEventType.java new file mode 100644 index 0000000..3162eba --- /dev/null +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/hook/JiraActionEventType.java @@ -0,0 +1,57 @@ +package org.hibernate.infra.replicate.jira.service.jira.model.hook; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext; +import org.hibernate.infra.replicate.jira.service.jira.handler.action.JiraAssigneeActionEventHandler; +import org.hibernate.infra.replicate.jira.service.jira.model.action.JiraActionEvent; +import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig; + +public enum JiraActionEventType { + ISSUE_ASSIGNED("jira:issue_assigned") { + @Override + public Collection handlers(ReportingConfig reportingConfig, JiraActionEvent event, + HandlerProjectContext context) { + if (event.assignee == null || event.key == null) { + throw new IllegalStateException( + "Trying to handle an issue event but issue id is null: %s".formatted(event)); + } + return List.of(new JiraAssigneeActionEventHandler(reportingConfig, context, event)); + } + }, + ISSUE_TRANSITIONED("jira:issue_transitioned") { + @Override + public Collection handlers(ReportingConfig reportingConfig, JiraActionEvent event, + HandlerProjectContext context) { + throw new UnsupportedOperationException("jira:issue_transitioned not supported yet"); + } + }; + + private final String name; + + JiraActionEventType(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static Optional of(String webhookEvent) { + if (webhookEvent == null) { + return Optional.empty(); + } + for (JiraActionEventType value : values()) { + if (value.name.equals(webhookEvent.toLowerCase(Locale.ROOT))) { + return Optional.of(value); + } + } + return Optional.empty(); + } + + public abstract Collection handlers(ReportingConfig reportingConfig, JiraActionEvent event, + HandlerProjectContext context); +} diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraFields.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraFields.java index 53e77c5..844e474 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraFields.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraFields.java @@ -26,6 +26,14 @@ public class JiraFields extends JiraBaseObject { public ZonedDateTime created; public ZonedDateTime updated; + public static JiraFields empty() { + JiraFields fields = new JiraFields(); + fields.priority = null; + fields.issuetype = null; + fields.project = null; + return fields; + } + @Override public String toString() { return "JiraFields{" + "summary='" + summary + '\'' + ", description=" + description + ", priority=" + priority diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraUser.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraUser.java index 33b2805..62bba01 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraUser.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraUser.java @@ -1,5 +1,7 @@ package org.hibernate.infra.replicate.jira.service.jira.model.rest; +import java.util.Objects; + import org.hibernate.infra.replicate.jira.service.jira.model.JiraBaseObject; public class JiraUser extends JiraBaseObject { @@ -22,6 +24,14 @@ public JiraUser(String propertyName, String value) { } } + public String mappedIdentifier(String propertyName) { + if ("accountId".equals(propertyName)) { + return this.accountId; + } else { + return Objects.toString(properties().get(propertyName), null); + } + } + public static JiraUser unassigned(String propertyName) { // { // "name": "-1" diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraVersion.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraVersion.java index 1b0d71c..eec8f16 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraVersion.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/model/rest/JiraVersion.java @@ -54,7 +54,7 @@ public static Optional findVersion(String versionId, List macs; + private static final Pattern PATH_UPSTREAM_WEBHOOK_PATTERN = Pattern.compile("/jira/webhooks/(.+)"); + private static final Pattern PATH_DOWNSTREAM_WEBHOOK_PATTERN = Pattern.compile("/jira/webhooks/downstream/(.+)"); + private static final BiPredicate ALLOW_ALL = (a, b) -> true; + + private final Map> upstreamChecks; + private final Map> downstreamChecks; @Inject public RequestSignatureFilter(JiraConfig jiraConfig) { - Map macs = new HashMap<>(); + Map> up = new HashMap<>(); + Map> down = new HashMap<>(); for (JiraConfig.JiraProjectGroup group : jiraConfig.projectGroup().values()) { for (var entry : group.projects().entrySet()) { - JiraConfig.WebHookSecurity security = entry.getValue().security(); - if (security.enabled()) { - macs.put(entry.getKey(), fromSecret(security.secret())); - } + up.put(entry.getKey(), check(entry.getValue().security())); + down.put(entry.getKey(), check(entry.getValue().downstreamSecurity())); } } - this.macs = Collections.unmodifiableMap(macs); + this.upstreamChecks = Collections.unmodifiableMap(up); + this.downstreamChecks = Collections.unmodifiableMap(down); + } + + private BiPredicate check(JiraConfig.WebHookSecurity security) { + if (security.enabled()) { + switch (security.type()) { + case TOKEN -> { + String secret = security.secret(); + return (header, body) -> secret.equals(header); + } + case SIGNATURE -> { + Mac mac = fromSecret(security.secret()); + return (header, body) -> header.equals(signBytes(mac, body)); + } + default -> throw new IllegalArgumentException("Unsupported security type: " + security.type()); + } + } else { + return null; + } } @WithFormRead @ServerRequestFilter public Response checkSignature(ContainerRequestContext requestContext) throws IOException { String path = requestContext.getUriInfo().getPath(); - Matcher matcher = PATH_WEBHOOK_PATTERN.matcher(path); - if ("POST".equals(requestContext.getMethod()) && matcher.matches()) { - String project = matcher.group(1); - Mac mac = macs.get(project); - if (mac == null) { - // means security is not enabled for this project... - return null; + Matcher downstream = PATH_DOWNSTREAM_WEBHOOK_PATTERN.matcher(path); + Matcher upstream = PATH_UPSTREAM_WEBHOOK_PATTERN.matcher(path); + if ("POST".equals(requestContext.getMethod())) { + BiPredicate check = null; + if (downstream.matches()) { + // for downstream automated actions we just send something in the header that we + // compare here:3 + String project = downstream.group(1); + check = downstreamChecks.get(project); + } else if (upstream.matches()) { + String project = upstream.group(1); + check = upstreamChecks.get(project); } + if (check != null) { + String signature = requestContext.getHeaderString("x-hub-signature"); - String signature = requestContext.getHeaderString("x-hub-signature"); - - if (signature == null || !requestContext.hasEntity()) { - Log.warnf("Rejecting a web hook event because of the missing signature. Posted to %s", path); - return Response.status(401).entity("Invalid request. Missing x-hub-signature header.").build(); - } - try (InputStream entityStream = requestContext.getEntityStream()) { - byte[] payload = entityStream.readAllBytes(); + if (signature == null || !requestContext.hasEntity()) { + Log.warnf("Rejecting a web hook event because of the missing signature. Posted to %s", path); + return Response.status(401).entity("Invalid request. Missing x-hub-signature header.").build(); + } - final String calculatedSignature = sign(mac, payload); - if (!calculatedSignature.equals(signature)) { - Log.warnf("Rejecting a web hook event because of the signature mismatch. Posted to %s", path); + if (check(requestContext, check, signature, path)) { return Response.status(401).entity("Signatures do not match.").build(); } - requestContext.setEntityStream(new ByteArrayInputStream(payload)); } } return null; } + private static boolean check(ContainerRequestContext requestContext, BiPredicate check, + String signature, String path) throws IOException { + try (InputStream entityStream = requestContext.getEntityStream()) { + byte[] payload = entityStream.readAllBytes(); + + if (!check.test(signature, payload)) { + Log.warnf("Rejecting a web hook event because of the signature mismatch. Posted to %s", path); + return true; + } + requestContext.setEntityStream(new ByteArrayInputStream(payload)); + } + return false; + } + public static String sign(String secret, String payload) { - return sign(fromSecret(secret), payload.getBytes(StandardCharsets.UTF_8)); + return signBytes(fromSecret(secret), payload.getBytes(StandardCharsets.UTF_8)); } - public static String sign(Mac mac, byte[] payload) { + public static String signBytes(Mac mac, byte[] payload) { final byte[] digest = mac.doFinal(payload); final HexFormat hex = HexFormat.of(); return "sha256=" + hex.formatHex(digest); diff --git a/src/test/java/org/hibernate/infra/replicate/jira/service/validation/RequestSignatureFilterTest.java b/src/test/java/org/hibernate/infra/replicate/jira/service/validation/RequestSignatureFilterTest.java index db86c2d..95f4af1 100644 --- a/src/test/java/org/hibernate/infra/replicate/jira/service/validation/RequestSignatureFilterTest.java +++ b/src/test/java/org/hibernate/infra/replicate/jira/service/validation/RequestSignatureFilterTest.java @@ -69,7 +69,7 @@ private static RequestSignatureFilter createFilter(boolean enabled, String secre throws NoSuchAlgorithmException, InvalidKeyException { JiraConfig.JiraProjectGroup group = Mockito.mock(JiraConfig.JiraProjectGroup.class); JiraConfig.JiraProject project = Mockito.mock(JiraConfig.JiraProject.class); - Mockito.when(project.security()).thenReturn(new JiraConfig.WebHookSecurity() { + JiraConfig.WebHookSecurity value = new JiraConfig.WebHookSecurity() { @Override public boolean enabled() { return enabled; @@ -79,7 +79,14 @@ public boolean enabled() { public String secret() { return secret; } - }); + + @Override + public Type type() { + return Type.SIGNATURE; + } + }; + Mockito.when(project.security()).thenReturn(value); + Mockito.when(project.downstreamSecurity()).thenReturn(value); Mockito.when(group.projects()).thenReturn(Map.of("PROJECT_KEY", project));