Repository: camunda/camunda
Pull request #29215: Validate tenants for REST job activation requests
Author: login: tmetzke
Labels: name: backport main
description: Forward-port a pull request to main
name: component/zeebe
description: Related to the Zeebe component/team
name: backport stable/8.7
description: Backport a pull request to stable/8.7
name: version:8.7.0
description: Marks an issue as being completely or in parts released in 8.7.0
name: version:8.8.0-alpha3
Created: 2025-03-05T11:00:19Z | Merged: 2025-03-05T16:17:42Z

## Description

Validate the provided tenant IDs of REST job activation requests.
This ensures clients can only activate jobs for tenants they are authorized to access.

## Checklist

<!--- Please delete options that are not relevant. Boxes should be checked by reviewer. -->
- [ ] for CI changes:
  - [ ] structural/foundational changes signed off by [CI DRI](https://github.com/cmur2)
  - [ ] [ci.yml](https://github.com/camunda/camunda/blob/main/.github/workflows/ci.yml) modifications comply with ["Unified CI" requirements](https://github.com/camunda/camunda/wiki/CI-&-Automation#workflow-inclusion-criteria)
  - [ ] enable backports [when recommended](https://github.com/camunda/camunda/wiki/CI-&-Automation#when-to-backport-ci-changes)

## Related issues

closes #29194 


Linked issue #29194: Check provided tenants on job activation
**Describe the bug**

The C8 REST API to activate jobs allows activating jobs for other tenants.

**To Reproduce**

1. Enable multi-tenancy and define two tenants, `A` and `B`
2. Register an API client `XYZ` for tenant `A`
3. Deploy a model for tenant `B` that contains a service task with job type `foo` and create an instance of that model
4. Using a token for client `XYZ`, call the `POST /v2/jobs/activation` endpoint with job type `foo` and `tenantIds` as `["B"]`
5. Receive a response with an activated job for type `foo` and tenant `B`

**Expected behavior**

No jobs are activated

**Environment:**
- OS: all
- Zeebe Version: 8.6
- Configuration: multi-tenancy enabled


Diff:
diff --git a/search/search-client/src/main/java/io/camunda/util/CollectionUtil.java b/search/search-client/src/main/java/io/camunda/util/CollectionUtil.java
index de3f09db5a5d..f94cc0c96cae 100644
--- a/search/search-client/src/main/java/io/camunda/util/CollectionUtil.java
+++ b/search/search-client/src/main/java/io/camunda/util/CollectionUtil.java
@@ -10,8 +10,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 public final class CollectionUtil {
@@ -57,6 +59,29 @@ public static <T> List<T> addValuesToList(final List<T> list, final List<T> valu
     return result;
   }
 
+  /**
+   * Adds given values to a set.
+   *
+   * <p>This method can handle null-values gracefully.
+   *
+   * <p>This means, if either one of the given set is empty, and empty HashSet will be used instead.
+   *
+   * <p>Example:
+   *
+   * <p>* addValuesToSet(null, Set.of(1)) -> Set(1) * addValuesToSet(Set.of(1), null) -> Set(1) *
+   * addValuesToSet(null, null) -> Set() * addValuesToSet(Set.of(1), Set.of(2)) -> Set(1, 2)
+   *
+   * @param set where the values should be added to
+   * @param values the values which should be added
+   * @return the given set (when not empty) otherwise a new set containing the values
+   * @param <T> the set value type
+   */
+  public static <T> Set<T> addValuesToSet(final Set<T> set, final Collection<T> values) {
+    final Set<T> result = Objects.requireNonNullElse(set, new HashSet<>());
+    result.addAll(Objects.requireNonNullElse(values, new HashSet<>()));
+    return result;
+  }
+
   /**
    * Collects a given values (array) as list, and handles potential null or empty values.
    *
diff --git a/service/src/main/java/io/camunda/service/security/auth/Authentication.java b/service/src/main/java/io/camunda/service/security/auth/Authentication.java
index 59916b949045..bc5d78397041 100644
--- a/service/src/main/java/io/camunda/service/security/auth/Authentication.java
+++ b/service/src/main/java/io/camunda/service/security/auth/Authentication.java
@@ -8,15 +8,18 @@
 package io.camunda.service.security.auth;
 
 import static io.camunda.util.CollectionUtil.addValuesToList;
+import static io.camunda.util.CollectionUtil.addValuesToSet;
 
 import io.camunda.service.search.filter.FilterBase;
 import io.camunda.util.ObjectBuilder;
+import java.util.Collection;
 import java.util.List;
+import java.util.Set;
 
 public record Authentication(
     String authenticatedUserId,
     List<String> authenticatedGroupIds,
-    List<String> authenticatedTenantIds,
+    Set<String> authenticatedTenantIds,
     String token)
     implements FilterBase {
 
@@ -24,7 +27,7 @@ public static final class Builder implements ObjectBuilder<Authentication> {
 
     private String user;
     private List<String> groups;
-    private List<String> tenants;
+    private Set<String> tenants;
     private String token;
 
     public Builder user(final String value) {
@@ -42,11 +45,11 @@ public Builder groups(final List<String> values) {
     }
 
     public Builder tenant(final String tenant) {
-      return tenants(List.of(tenant));
+      return tenants(Set.of(tenant));
     }
 
-    public Builder tenants(final List<String> values) {
-      tenants = addValuesToList(tenants, values);
+    public Builder tenants(final Collection<String> values) {
+      tenants = addValuesToSet(tenants, values);
       return this;
     }
 
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/RequestMapper.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/RequestMapper.java
index 51b618505496..805b80e80724 100644
--- a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/RequestMapper.java
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/RequestMapper.java
@@ -19,7 +19,9 @@
 import static io.camunda.zeebe.gateway.rest.validator.MessageRequestValidator.validateMessageCorrelationRequest;
 import static io.camunda.zeebe.gateway.rest.validator.MessageRequestValidator.validateMessagePublicationRequest;
 import static io.camunda.zeebe.gateway.rest.validator.MultiTenancyValidator.validateAuthorization;
+import static io.camunda.zeebe.gateway.rest.validator.MultiTenancyValidator.validateAuthorizations;
 import static io.camunda.zeebe.gateway.rest.validator.MultiTenancyValidator.validateTenantId;
+import static io.camunda.zeebe.gateway.rest.validator.MultiTenancyValidator.validateTenantIds;
 import static io.camunda.zeebe.gateway.rest.validator.ProcessInstanceRequestValidator.validateCancelProcessInstanceRequest;
 import static io.camunda.zeebe.gateway.rest.validator.ProcessInstanceRequestValidator.validateCreateProcessInstanceRequest;
 import static io.camunda.zeebe.gateway.rest.validator.ProcessInstanceRequestValidator.validateMigrateProcessInstanceRequest;
@@ -88,10 +90,13 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.time.ZonedDateTime;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
 import java.util.function.Supplier;
@@ -165,15 +170,30 @@ public static Either<ProblemDetail, Long> getPinnedEpoch(final ClockPinRequest p
   }
 
   public static Either<ProblemDetail, ActivateJobsRequest> toJobsActivationRequest(
-      final JobActivationRequest activationRequest) {
+      final JobActivationRequest activationRequest, final boolean multiTenancyEnabled) {
 
-    return getResult(
-        validateJobActivationRequest(activationRequest),
-        () ->
+    final Either<ProblemDetail, List<String>> validationResponse =
+        validateTenantIds(
+                getStringListOrEmpty(activationRequest, JobActivationRequest::getTenantIds),
+                multiTenancyEnabled,
+                "Activate Jobs")
+            .flatMap(
+                tenantIds ->
+                    validateAuthorizations(tenantIds, multiTenancyEnabled, "Activate Jobs")
+                        .map(Either::<ProblemDetail, List<String>>left)
+                        .orElseGet(() -> Either.right(tenantIds)))
+            .flatMap(
+                tenantIds ->
+                    validateJobActivationRequest(activationRequest)
+                        .map(Either::<ProblemDetail, List<String>>left)
+                        .orElseGet(() -> Either.right(tenantIds)));
+
+    return validationResponse.map(
+        tenantIds ->
             new ActivateJobsRequest(
                 activationRequest.getType(),
                 activationRequest.getMaxJobsToActivate(),
-                getStringListOrEmpty(activationRequest, JobActivationRequest::getTenantIds),
+                tenantIds,
                 activationRequest.getTimeout(),
                 getStringOrEmpty(activationRequest, JobActivationRequest::getWorker),
                 getStringListOrEmpty(activationRequest, JobActivationRequest::getFetchVariable),
@@ -430,6 +450,11 @@ public static Authentication getAuthentication() {
     return new Builder().token(token).tenants(authorizedTenants).build();
   }
 
+  public static Set<String> getAuthorizedTenants() {
+    final List<String> tenantIds = TenantAttributeHolder.tenantIds();
+    return tenantIds == null ? Collections.emptySet() : new HashSet<>(tenantIds);
+  }
+
   public static Authentication getAnonymousAuthentication() {
     return new Builder()
         .token(
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/JobController.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/JobController.java
index 173255b4e642..cab47b4c42aa 100644
--- a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/JobController.java
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/JobController.java
@@ -9,6 +9,7 @@
 
 import io.camunda.service.JobServices;
 import io.camunda.service.JobServices.ActivateJobsRequest;
+import io.camunda.zeebe.gateway.impl.configuration.MultiTenancyCfg;
 import io.camunda.zeebe.gateway.protocol.rest.JobActivationRequest;
 import io.camunda.zeebe.gateway.protocol.rest.JobActivationResponse;
 import io.camunda.zeebe.gateway.protocol.rest.JobCompletionRequest;
@@ -36,19 +37,22 @@ public class JobController {
 
   private final ResponseObserverProvider responseObserverProvider;
   private final JobServices<JobActivationResponse> jobServices;
+  private final MultiTenancyCfg multiTenancyCfg;
 
   @Autowired
   public JobController(
       final JobServices<JobActivationResponse> jobServices,
-      final ResponseObserverProvider responseObserverProvider) {
+      final ResponseObserverProvider responseObserverProvider,
+      final MultiTenancyCfg multiTenancyCfg) {
     this.jobServices = jobServices;
     this.responseObserverProvider = responseObserverProvider;
+    this.multiTenancyCfg = multiTenancyCfg;
   }
 
   @CamundaPostMapping(path = "/activation")
   public CompletableFuture<ResponseEntity<Object>> activateJobs(
       @RequestBody final JobActivationRequest activationRequest) {
-    return RequestMapper.toJobsActivationRequest(activationRequest)
+    return RequestMapper.toJobsActivationRequest(activationRequest, multiTenancyCfg.isEnabled())
         .fold(RestErrorMapper::mapProblemToCompletedResponse, this::activateJobs);
   }
 
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/ErrorMessages.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/ErrorMessages.java
index 79a3c34a5108..88212354a355 100644
--- a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/ErrorMessages.java
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/ErrorMessages.java
@@ -27,6 +27,8 @@ public final class ErrorMessages {
   public static final String ERROR_MESSAGE_AT_LEAST_ONE_FIELD = "At least one of %s is required";
   public static final String ERROR_MESSAGE_INVALID_TENANT =
       "Expected to handle request %s with tenant identifier '%s', but %s";
+  public static final String ERROR_MESSAGE_INVALID_TENANTS =
+      "Expected to handle request %s with tenant identifiers %s, but %s";
   public static final String ERROR_MESSAGE_ONLY_ONE_FIELD = "Only one of %s is allowed";
   public static final String ERROR_MESSAGE_INVALID_EMAIL = "The provided email '%s' is not valid";
   public static final String ERROR_MESSAGE_ALL_REQUIRED_FIELD = "All %s are required";
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
index 4df262918eb6..180bd034dff5 100644
--- a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
@@ -8,6 +8,7 @@
 package io.camunda.zeebe.gateway.rest.validator;
 
 import static io.camunda.zeebe.gateway.rest.validator.ErrorMessages.ERROR_MESSAGE_INVALID_TENANT;
+import static io.camunda.zeebe.gateway.rest.validator.ErrorMessages.ERROR_MESSAGE_INVALID_TENANTS;
 import static io.camunda.zeebe.gateway.rest.validator.RequestValidator.createProblemDetail;
 import static io.camunda.zeebe.protocol.record.RejectionType.INVALID_ARGUMENT;
 
@@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
-    final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    final var authorizedTenants = RequestMapper.getAuthorizedTenants();
+    if (!authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "the user is not authorized for that tenant")
+                  : ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                      commandName, tenantIds, "the user is not authorized for all those tenants"),
               HttpStatus.UNAUTHORIZED.name()));
     }
 
@@ -70,39 +82,60 @@ public static Optional<ProblemDetail> validateAuthorization(
    */
   public static Either<ProblemDetail, String> validateTenantId(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
-    final var hasTenantId = tenantId != null && !tenantId.isBlank();
+    return validateTenantIds(
+            tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName)
+        .map(List::getFirst);
+  }
+
+  public static Either<ProblemDetail, List<String>> validateTenantIds(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
+    final var hasTenantId =
+        tenantIds != null
+            && !tenantIds.isEmpty()
+            && tenantIds.stream().anyMatch(id -> !id.isBlank());
     if (!multiTenancyEnabled) {
-      if (hasTenantId && !TenantOwned.DEFAULT_TENANT_IDENTIFIER.equals(tenantId)) {
+      if (hasTenantId
+          && tenantIds.stream()
+              .anyMatch(tenantId -> !TenantOwned.DEFAULT_TENANT_IDENTIFIER.equals(tenantId))) {
+        // Anything else than the default tenant was provided
         return Either.left(
             RestErrorMapper.createProblemDetail(
                 HttpStatus.BAD_REQUEST,
-                ERROR_MESSAGE_INVALID_TENANT.formatted(
-                    commandName, tenantId, "multi-tenancy is disabled"),
+                tenantIds.size() > 1
+                    ? ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                        commandName, tenantIds, "multi-tenancy is disabled")
+                    : ERROR_MESSAGE_INVALID_TENANT.formatted(
+                        commandName, tenantIds.getFirst(), "multi-tenancy is disabled"),
                 INVALID_ARGUMENT.name()));
       }
 
-      return Either.right(TenantOwned.DEFAULT_TENANT_IDENTIFIER);
+      return Either.right(List.of(TenantOwned.DEFAULT_TENANT_IDENTIFIER));
     }
 
     final List<String> violations = new ArrayList<>();
     if (!hasTenantId) {
       violations.add(
-          ERROR_MESSAGE_INVALID_TENANT.formatted(
-              commandName, tenantId, "no tenant identifier was provided"));
-    } else if (tenantId.length() > 31) {
-      violations.add(
-          ERROR_MESSAGE_INVALID_TENANT.formatted(
-              commandName, tenantId, "tenant identifier is longer than 31 characters"));
-    } else if (!TenantOwned.DEFAULT_TENANT_IDENTIFIER.equals(tenantId)
-        && !TENANT_ID_MASK.matcher(tenantId).matches()) {
-      violations.add(
-          ERROR_MESSAGE_INVALID_TENANT.formatted(
-              commandName, tenantId, "tenant identifier contains illegal characters"));
+          ERROR_MESSAGE_INVALID_TENANTS.formatted(
+              commandName, tenantIds, "no tenant identifier was provided"));
+    } else {
+      tenantIds.forEach(
+          tenantId -> {
+            if (tenantId.length() > 31) {
+              violations.add(
+                  ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName, tenantId, "tenant identifier is longer than 31 characters"));
+            } else if (!TenantOwned.DEFAULT_TENANT_IDENTIFIER.equals(tenantId)
+                && !TENANT_ID_MASK.matcher(tenantId).matches()) {
+              violations.add(
+                  ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName, tenantId, "tenant identifier contains illegal characters"));
+            }
+          });
     }
 
     final var problemDetail = createProblemDetail(violations);
     return problemDetail
-        .<Either<ProblemDetail, String>>map(Either::left)
-        .orElseGet(() -> Either.right(tenantId));
+        .<Either<ProblemDetail, List<String>>>map(Either::left)
+        .orElseGet(() -> Either.right(tenantIds));
   }
 }
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/impl/job/LongPollingActivateJobsRestTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/impl/job/LongPollingActivateJobsRestTest.java
index d77afc7714c4..f4be527da450 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/impl/job/LongPollingActivateJobsRestTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/impl/job/LongPollingActivateJobsRestTest.java
@@ -955,7 +955,7 @@ void shouldSetCurrentRetriesAndNoBackoff() {
             .maxJobsToActivate(MAX_JOBS_TO_ACTIVATE)
             .requestTimeout(500L)
             .timeout(1000L);
-    final var requestMappingResult = RequestMapper.toJobsActivationRequest(restRequest);
+    final var requestMappingResult = RequestMapper.toJobsActivationRequest(restRequest, false);
     if (requestMappingResult.isLeft()) {
       fail("REST Request mapping failed unexpectedly: " + requestMappingResult.getLeft());
     }
@@ -1040,7 +1040,7 @@ private InflightActivateJobsRequest<JobActivationResponse> getLongPollingJobActi
 
   private InflightActivateJobsRequest<JobActivationResponse> toInflightActivateJobsRequest(
       final JobActivationRequest restRequest) {
-    final var requestMappingResult = RequestMapper.toJobsActivationRequest(restRequest);
+    final var requestMappingResult = RequestMapper.toJobsActivationRequest(restRequest, false);
     if (requestMappingResult.isLeft()) {
       fail("REST Request mapping failed unexpectedly: " + requestMappingResult.getLeft());
     }
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/RestControllerTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/RestControllerTest.java
index 8717db159258..738fc02b8d25 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/RestControllerTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/RestControllerTest.java
@@ -7,9 +7,8 @@
  */
 package io.camunda.zeebe.gateway.rest;
 
-import io.camunda.service.security.auth.Authentication;
 import io.camunda.zeebe.gateway.rest.config.JacksonConfig;
-import java.util.List;
+import java.util.Set;
 import java.util.function.Function;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
@@ -32,9 +31,7 @@ public ResponseSpec withMultiTenancy(
       final String tenantId, final Function<WebTestClient, ResponseSpec> function) {
     try (final MockedStatic<RequestMapper> mockRequestMapper =
         Mockito.mockStatic(RequestMapper.class, Mockito.CALLS_REAL_METHODS)) {
-      mockRequestMapper
-          .when(RequestMapper::getAuthentication)
-          .thenReturn(new Authentication("user", List.of("group"), List.of(tenantId), "token"));
+      mockRequestMapper.when(RequestMapper::getAuthorizedTenants).thenReturn(Set.of(tenantId));
       return function.apply(webClient);
     }
   }
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerLongPollingTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerLongPollingTest.java
index 545bdc8451b5..13320588c4e3 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerLongPollingTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerLongPollingTest.java
@@ -9,6 +9,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
 
 import com.jayway.jsonpath.JsonPath;
 import io.camunda.service.JobServices;
@@ -18,6 +19,7 @@
 import io.camunda.zeebe.gateway.api.job.ActivateJobsStub;
 import io.camunda.zeebe.gateway.api.util.StubbedBrokerClient;
 import io.camunda.zeebe.gateway.impl.broker.request.BrokerActivateJobsRequest;
+import io.camunda.zeebe.gateway.impl.configuration.MultiTenancyCfg;
 import io.camunda.zeebe.gateway.impl.job.ActivateJobsHandler;
 import io.camunda.zeebe.gateway.impl.job.LongPollingActivateJobsHandler;
 import io.camunda.zeebe.gateway.metrics.LongPollingMetrics;
@@ -41,9 +43,11 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
 import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.boot.test.mock.mockito.SpyBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
 import org.springframework.util.unit.DataSize;
 
 @WebMvcTest(JobController.class)
@@ -54,6 +58,7 @@ public class JobControllerLongPollingTest extends RestControllerTest {
   @Autowired ActivateJobsHandler<JobActivationResponse> activateJobsHandler;
   @Autowired StubbedBrokerClient stubbedBrokerClient;
   @SpyBean ResettableJobActivationRequestResponseObserver responseObserver;
+  @MockBean MultiTenancyCfg multiTenancyCfg;
 
   @BeforeEach
   void setup() {
@@ -64,58 +69,21 @@ void setup() {
   void shouldActivateJobsImmediatelyIfAvailable() {
     // given
     final ActivateJobsStub stub = new ActivateJobsStub();
-    stub.addAvailableJobs("TEST", 2);
+    stub.addAvailableJobs("TEST-immediate", 2);
     stub.registerWith(stubbedBrokerClient);
 
     final var request =
         """
         {
-          "type": "TEST",
+          "type": "TEST-immediate",
           "maxJobsToActivate": 2,
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": [],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
-    final var expectedBody =
-        """
-        {
-          "jobs": [
-            {
-              "jobKey": 4503599627370496,
-              "type": "TEST",
-              "processInstanceKey": 123,
-              "processDefinitionKey": 4532,
-              "processDefinitionVersion": 23,
-              "elementInstanceKey": 459,
-              "retries": 12,
-              "deadline": 123123123,
-              "tenantId": "default",
-              "variables": {"bar": "world", "foo": 13},
-              "customHeaders": {"bar": "val", "foo": 12},
-              "processDefinitionId": "stubProcess",
-              "elementId": "stubActivity",
-              "worker": "bar"
-            },
-            {
-              "jobKey": 4503599627370497,
-              "type": "TEST",
-              "processInstanceKey": 123,
-              "processDefinitionKey": 4532,
-              "processDefinitionVersion": 23,
-              "elementInstanceKey": 459,
-              "retries": 12,
-              "deadline": 123123123,
-              "tenantId": "default",
-              "variables": {"bar": "world", "foo": 13},
-              "customHeaders": {"bar": "val", "foo": 12},
-              "processDefinitionId": "stubProcess",
-              "elementId": "stubActivity",
-              "worker": "bar"
-            }
-          ]
-        }""";
+
     // when / then
     webClient
         .post()
@@ -129,7 +97,10 @@ void shouldActivateJobsImmediatelyIfAvailable() {
         .expectHeader()
         .contentType(MediaType.APPLICATION_JSON)
         .expectBody()
-        .json(expectedBody);
+        .jsonPath("$.jobs[0].type")
+        .isEqualTo("TEST-immediate")
+        .jsonPath("$.jobs[1].type")
+        .isEqualTo("TEST-immediate");
 
     Mockito.verify(responseObserver, Mockito.times(1)).onNext(any());
     Mockito.verify(responseObserver).onCompleted();
@@ -139,58 +110,21 @@ void shouldActivateJobsImmediatelyIfAvailable() {
   void shouldActivateJobsImmediatelyIfAvailableWithStringKeys() {
     // given
     final ActivateJobsStub stub = new ActivateJobsStub();
-    stub.addAvailableJobs("TEST", 2);
+    stub.addAvailableJobs("TEST-immediate-string-keys", 2);
     stub.registerWith(stubbedBrokerClient);
 
     final var request =
         """
         {
-          "type": "TEST",
+          "type": "TEST-immediate-string-keys",
           "maxJobsToActivate": 2,
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": [],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
-    final var expectedBody =
-        """
-        {
-          "jobs": [
-            {
-              "jobKey": "2251799813685248",
-              "type": "TEST",
-              "processInstanceKey": "123",
-              "processDefinitionKey": "4532",
-              "processDefinitionVersion": 23,
-              "elementInstanceKey": "459",
-              "retries": 12,
-              "deadline": 123123123,
-              "tenantId": "default",
-              "variables": {},
-              "customHeaders": {},
-              "processDefinitionId": "stubProcess",
-              "elementId": "stubActivity",
-              "worker": "bar"
-            },
-            {
-              "jobKey": "2251799813685249",
-              "type": "TEST",
-              "processInstanceKey": "123",
-              "processDefinitionKey": "4532",
-              "processDefinitionVersion": 23,
-              "elementInstanceKey": "459",
-              "retries": 12,
-              "deadline": 123123123,
-              "tenantId": "default",
-              "variables": {},
-              "customHeaders": {},
-              "processDefinitionId": "stubProcess",
-              "elementId": "stubActivity",
-              "worker": "bar"
-            }
-          ]
-        }""";
+
     // when / then
     webClient
         .post()
@@ -204,7 +138,10 @@ void shouldActivateJobsImmediatelyIfAvailableWithStringKeys() {
         .expectHeader()
         .contentType(RequestMapper.MEDIA_TYPE_KEYS_STRING)
         .expectBody()
-        .json(expectedBody);
+        .jsonPath("$.jobs[0].type")
+        .isEqualTo("TEST-immediate-string-keys")
+        .jsonPath("$.jobs[1].type")
+        .isEqualTo("TEST-immediate-string-keys");
 
     Mockito.verify(responseObserver, Mockito.times(1)).onNext(any());
     Mockito.verify(responseObserver).onCompleted();
@@ -219,12 +156,12 @@ void shouldReturnNoJobsImmediatelyIfNoneAvailable() {
     final var request =
         """
         {
-          "type": "TEST",
+          "type": "TEST-no-jobs",
           "maxJobsToActivate": 10,
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": ["foo"],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
     final var expectedBody =
@@ -260,11 +197,11 @@ void shouldActivateJobsRoundRobin() {
     final var request =
         """
         {
-          "type": "TEST",
+          "type": "TEST-round-robin",
           "maxJobsToActivate": 2,
           "requestTimeout": 100,
           "timeout": 100,
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
 
@@ -273,7 +210,7 @@ void shouldActivateJobsRoundRobin() {
      * The job activation handler is created once for all tests, so previous tests can have moved
      * the round-robin index by any number already.
      */
-    stub.addAvailableJobs("TEST", 1);
+    stub.addAvailableJobs("TEST-round-robin", 1);
     final String result =
         webClient
             .post()
@@ -300,7 +237,7 @@ void shouldActivateJobsRoundRobin() {
       final int expectedPartitionId = (basePartition + partitionOffset - 1) % partitionsCount + 1;
       // reset the results and add new jobs that can be fetched
       responseObserver.reset();
-      stub.addAvailableJobs("TEST", 2);
+      stub.addAvailableJobs("TEST-round-robin", 2);
       // when/then
       webClient
           .post()
@@ -336,12 +273,12 @@ void shouldSendRejectionWithoutRetrying() {
     final var request =
         """
         {
-          "type": "TEST",
+          "type": "TEST-rejection",
           "maxJobsToActivate": 10,
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": ["foo"],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
     final var expectedBody =
@@ -373,6 +310,90 @@ void shouldSendRejectionWithoutRetrying() {
     assertThat(callCounter).hasValue(1);
   }
 
+  @Test
+  void shouldAllowActivateJobWithAuthorizedTenantWhenMultiTenancyEnabled() {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(true);
+    final ActivateJobsStub stub = new ActivateJobsStub();
+    stub.registerWith(stubbedBrokerClient);
+
+    final var request =
+        """
+        {
+          "type": "TEST-authorized-tenant",
+          "maxJobsToActivate": 2,
+          "requestTimeout": 100,
+          "timeout": 100,
+          "fetchVariable": [],
+          "tenantIds": ["tenantId"],
+          "worker": "bar"
+        }""";
+
+    final var expectedBody =
+        """
+        {
+          "jobs": []
+        }""";
+
+    // when then
+    final ResponseSpec response =
+        withMultiTenancy(
+            "tenantId",
+            client ->
+                client
+                    .post()
+                    .uri(JOBS_BASE_URL + "/activation")
+                    .accept(MediaType.APPLICATION_JSON)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .bodyValue(request)
+                    .exchange()
+                    .expectStatus()
+                    .isOk());
+
+    response.expectHeader().contentType(MediaType.APPLICATION_JSON).expectBody().json(expectedBody);
+  }
+
+  @Test
+  void shouldAllowActivateJobWithDefaultTenantWhenMultiTenancyDisabled() {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(false);
+    final ActivateJobsStub stub = new ActivateJobsStub();
+    stub.registerWith(stubbedBrokerClient);
+
+    final var request =
+        """
+        {
+          "type": "TEST-default-tenant",
+          "maxJobsToActivate": 2,
+          "requestTimeout": 100,
+          "timeout": 100,
+          "fetchVariable": [],
+          "tenantIds": ["<default>"],
+          "worker": "bar"
+        }""";
+
+    final var expectedBody =
+        """
+        {
+          "jobs": []
+        }""";
+
+    // when then
+    webClient
+        .post()
+        .uri(JOBS_BASE_URL + "/activation")
+        .accept(MediaType.APPLICATION_JSON)
+        .contentType(MediaType.APPLICATION_JSON)
+        .bodyValue(request)
+        .exchange()
+        .expectStatus()
+        .isOk()
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_JSON)
+        .expectBody()
+        .json(expectedBody);
+  }
+
   @TestConfiguration
   static class TestJobApplication {
 
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerRoundRobinTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerRoundRobinTest.java
index 0c075745b0c6..fae6225e030c 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerRoundRobinTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerRoundRobinTest.java
@@ -18,6 +18,7 @@
 import io.camunda.zeebe.gateway.api.job.ActivateJobsStub;
 import io.camunda.zeebe.gateway.api.util.StubbedBrokerClient;
 import io.camunda.zeebe.gateway.impl.broker.request.BrokerActivateJobsRequest;
+import io.camunda.zeebe.gateway.impl.configuration.MultiTenancyCfg;
 import io.camunda.zeebe.gateway.impl.job.ActivateJobsHandler;
 import io.camunda.zeebe.gateway.impl.job.RoundRobinActivateJobsHandler;
 import io.camunda.zeebe.gateway.protocol.rest.JobActivationResponse;
@@ -39,6 +40,7 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
 import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.boot.test.mock.mockito.SpyBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.http.MediaType;
@@ -52,6 +54,7 @@ public class JobControllerRoundRobinTest extends RestControllerTest {
   @Autowired ActivateJobsHandler<JobActivationResponse> activateJobsHandler;
   @Autowired StubbedBrokerClient stubbedBrokerClient;
   @SpyBean ResettableJobActivationRequestResponseObserver responseObserver;
+  @MockBean MultiTenancyCfg multiTenancyCfg;
 
   @BeforeEach
   void setup() {
@@ -81,7 +84,7 @@ void shouldActivateJobsImmediatelyIfAvailable() {
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": [],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
 
@@ -98,7 +101,7 @@ void shouldActivateJobsImmediatelyIfAvailable() {
               "elementInstanceKey": 459,
               "retries": 12,
               "deadline": 123123123,
-              "tenantId": "default",
+              "tenantId": "<default>",
               "variables": {},
               "customHeaders": {},
               "processDefinitionId": "stubProcess",
@@ -114,7 +117,7 @@ void shouldActivateJobsImmediatelyIfAvailable() {
               "elementInstanceKey": 459,
               "retries": 12,
               "deadline": 123123123,
-              "tenantId": "default",
+              "tenantId": "<default>",
               "variables": {},
               "customHeaders": {},
               "processDefinitionId": "stubProcess",
@@ -160,7 +163,7 @@ void shouldReturnNoJobsImmediatelyOnActivationIfNoneAvailable() {
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": ["foo"],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
     final var expectedBody =
@@ -200,7 +203,7 @@ void shouldActivateJobsRoundRobin() {
           "maxJobsToActivate": 2,
           "requestTimeout": 100,
           "timeout": 100,
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
 
@@ -255,7 +258,7 @@ void shouldSendRejectionOnActivationWithoutRetrying() {
           "requestTimeout": 100,
           "timeout": 100,
           "fetchVariable": ["foo"],
-          "tenantIds": ["default"],
+          "tenantIds": [],
           "worker": "bar"
         }""";
     final var expectedBody =
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerTest.java
index dfb01df85536..42fc51b11bdd 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerTest.java
@@ -11,22 +11,30 @@
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import io.camunda.service.JobServices;
 import io.camunda.service.JobServices.UpdateJobChangeset;
 import io.camunda.service.security.auth.Authentication;
+import io.camunda.zeebe.gateway.impl.configuration.MultiTenancyCfg;
 import io.camunda.zeebe.gateway.protocol.rest.JobActivationResponse;
 import io.camunda.zeebe.gateway.rest.RestControllerTest;
 import io.camunda.zeebe.protocol.impl.record.value.job.JobRecord;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.Mockito;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
 
 @WebMvcTest(JobController.class)
 public class JobControllerTest extends RestControllerTest {
@@ -34,6 +42,7 @@ public class JobControllerTest extends RestControllerTest {
   static final String JOBS_BASE_URL = "/v2/jobs";
 
   @MockBean JobServices<JobActivationResponse> jobServices;
+  @MockBean MultiTenancyCfg multiTenancyCfg;
   @MockBean ResponseObserverProvider responseObserverProvider;
 
   @BeforeEach
@@ -485,4 +494,110 @@ void shouldRejectUpdateJobNoBody() {
         .expectBody()
         .json(expectedBody);
   }
+
+  @ParameterizedTest
+  @MethodSource("tenantLists")
+  void shouldRejectActivateJobWithUnauthorizedTenantWhenMultiTenancyEnabled(
+      final List<String> tenantIds) {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(true);
+    final var request =
+        """
+        {
+          "type": "TEST",
+          "maxJobsToActivate": 2,
+          "requestTimeout": 100,
+          "timeout": 100,
+          "fetchVariable": [],
+          "tenantIds": %s,
+          "worker": "bar"
+        }"""
+            .formatted(tenantIds.stream().map("\"%s\""::formatted).toList());
+
+    // when then
+    final ResponseSpec response =
+        withMultiTenancy(
+            "tenantId",
+            client ->
+                client
+                    .post()
+                    .uri(JOBS_BASE_URL + "/activation")
+                    .accept(MediaType.APPLICATION_JSON)
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .bodyValue(request)
+                    .exchange()
+                    .expectStatus()
+                    .isUnauthorized());
+    response
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 401,
+              "title": "UNAUTHORIZED",
+              "detail": "Expected to handle request Activate Jobs with tenant %s, but the user is not authorized for %s",
+              "instance": "%s"
+            }"""
+                .formatted(
+                    tenantIds.size() == 1
+                        ? "identifier '" + tenantIds.getFirst() + "'"
+                        : "identifiers " + tenantIds,
+                    tenantIds.size() == 1 ? "that tenant" : "all those tenants",
+                    JOBS_BASE_URL + "/activation"));
+    verifyNoInteractions(jobServices);
+  }
+
+  @ParameterizedTest
+  @MethodSource("tenantLists")
+  void shouldRejectActivateJobWithTenantsWhenMultiTenancyDisabled(final List<String> tenantIds) {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(false);
+    final var request =
+        """
+        {
+          "type": "TEST",
+          "maxJobsToActivate": 2,
+          "requestTimeout": 100,
+          "timeout": 100,
+          "fetchVariable": [],
+          "tenantIds": %s,
+          "worker": "bar"
+        }"""
+            .formatted(tenantIds.stream().map("\"%s\""::formatted).toList());
+
+    // when then
+    webClient
+        .post()
+        .uri(JOBS_BASE_URL + "/activation")
+        .accept(MediaType.APPLICATION_JSON)
+        .contentType(MediaType.APPLICATION_JSON)
+        .bodyValue(request)
+        .exchange()
+        .expectStatus()
+        .isBadRequest()
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 400,
+              "title": "INVALID_ARGUMENT",
+              "detail": "Expected to handle request Activate Jobs with tenant %s, but multi-tenancy is disabled",
+              "instance": "%s"
+            }"""
+                .formatted(
+                    tenantIds.size() == 1
+                        ? "identifier '" + tenantIds.getFirst() + "'"
+                        : "identifiers " + tenantIds,
+                    JOBS_BASE_URL + "/activation"));
+    verifyNoInteractions(jobServices);
+  }
+
+  static Stream<Arguments> tenantLists() {
+    return Stream.of(
+        Arguments.of(List.of("unauthorizedTenant")),
+        Arguments.of(List.of("tenantId", "unauthorizedTenant")),
+        Arguments.of(List.of("tenantId", "<default>")));
+  }
 }
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/MessageControllerTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/MessageControllerTest.java
index 76192e2b0a72..b2e8d212bc18 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/MessageControllerTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/MessageControllerTest.java
@@ -266,7 +266,7 @@ void shouldRejectMessageCorrelationWithoutTenantWhenMultiTenancyEnabled() {
               "type": "about:blank",
               "status": 400,
               "title": "INVALID_ARGUMENT",
-              "detail": "Expected to handle request Correlate Message with tenant identifier 'null', but no tenant identifier was provided.",
+              "detail": "Expected to handle request Correlate Message with tenant identifiers [], but no tenant identifier was provided.",
               "instance": "%s"
             }"""
                 .formatted(CORRELATION_ENDPOINT));
@@ -415,7 +415,7 @@ void shouldRejectMessageCorrelationWithUnauthorizedTenantWhenMultiTenancyEnabled
               "type": "about:blank",
               "status": 401,
               "title": "UNAUTHORIZED",
-              "detail": "Expected to handle request Correlate Message with tenant identifier 'unauthorizedTenant', but tenant is not authorized to perform this request",
+              "detail": "Expected to handle request Correlate Message with tenant identifier 'unauthorizedTenant', but the user is not authorized for that tenant",
               "instance": "%s"
             }"""
                 .formatted(CORRELATION_ENDPOINT));
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java
index 90a9fb3ae6e8..0f1503242baf 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java
@@ -10,6 +10,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import io.camunda.service.ResourceServices;
@@ -32,6 +33,7 @@
 import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.MediaType;
 import org.springframework.http.client.MultipartBodyBuilder;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
 
 @WebMvcTest(ResourceController.class)
 public class ResourceControllerTest extends RestControllerTest {
@@ -238,6 +240,47 @@ void shouldDeployResourceWithMultitenancyDisabled() {
          """);
   }
 
+  @Test
+  void shouldRejectDeployResourcesWithUnauthorizedTenantWhenMultiTenancyEnabled() {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(true);
+
+    final var filename = "process.bpmn";
+    final var contentType = MediaType.APPLICATION_OCTET_STREAM;
+    final var content = new byte[] {1, 2, 3};
+    final var multipartBodyBuilder = new MultipartBodyBuilder();
+    multipartBodyBuilder.part("resources", content).contentType(contentType).filename(filename);
+    multipartBodyBuilder.part("tenantId", "unauthorizedTenant");
+
+    // when then
+    final ResponseSpec response =
+        withMultiTenancy(
+            "tenantId",
+            client ->
+                client
+                    .post()
+                    .uri(DEPLOY_RESOURCES_ENDPOINT)
+                    .contentType(MediaType.MULTIPART_FORM_DATA)
+                    .bodyValue(multipartBodyBuilder.build())
+                    .accept(MediaType.APPLICATION_JSON)
+                    .exchange()
+                    .expectStatus()
+                    .isUnauthorized());
+    response
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 401,
+              "title": "UNAUTHORIZED",
+              "detail": "Expected to handle request Deploy Resources with tenant identifier 'unauthorizedTenant', but the user is not authorized for that tenant",
+              "instance": "%s"
+            }"""
+                .formatted(DEPLOY_RESOURCES_ENDPOINT));
+    verifyNoInteractions(resourceServices);
+  }
+
   @Test
   void shouldDeployResourceWithMultitenancyEnabled() {
     // given


Reviews:
user: login: remcowesterhoud
body: Just some small comments, some of which a bit unrelated to your PR. So feel free to ignore them as you see fit.

I am surprised no ITs failed because of this. I guess because it's 8.6?
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:11:52Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T13:16:54Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T13:19:43Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T13:20:03Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T13:21:33Z
user: login: remcowesterhoud
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:23:30Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T13:32:35Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T13:43:55Z
user: login: remcowesterhoud
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:47:56Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:00:53Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:01:05Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:01:15Z
user: login: remcowesterhoud
body: Thanks for the not fully related fixes @tmetzke 🏅 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T14:03:21Z

Review comments:
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: remcowesterhoud
body: 💭 I know you didn't come up with the message, but does it sound weird to you too? It says the tenant is not authorized to perform the request, but really it is the user that's not authorized for the provided tenant, isn't it?
created_at: 2025-03-05T12:40:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: remcowesterhoud
body: 💭 We should probably introduce a method to just get the authenticated tenant ids. With this call we've got now we are encoding an entire JWT for no reason.

Again, also something you didn't add 😛 
created_at: 2025-03-05T12:43:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
+                  : ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                      commandName, tenantIds, "tenant is not authorized to perform this request"),
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: remcowesterhoud
body: 🔧 It'd be nice to actually mention which tenants the user lacks authorization for. Now they'd have to check for all tenants if they are assigned properly when they receive this message.
created_at: 2025-03-05T12:45:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -485,4 +494,64 @@ void shouldRejectUpdateJobNoBody() {
         .expectBody()
         .json(expectedBody);
   }
+
+  @ParameterizedTest
+  @MethodSource("tenantLists")
+  void shouldRejectActivateJobWithUnauthorizedTenantWhenMultiTenancyEnabled(
+      final List<String> tenantIds) {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(true);
+    final var request =
+        """
+        {
+          "type": "TEST",
+          "maxJobsToActivate": 2,
+          "requestTimeout": 100,
+          "timeout": 100,
+          "fetchVariable": [],
+          "tenantIds": %s,
+          "worker": "bar"
+        }"""
+            .formatted(tenantIds.stream().map("\"%s\""::formatted).toList());
path: zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerTest.java
user: login: remcowesterhoud
body: 😂 I love this. I was convinced there must be an easier way but I can't find any.
created_at: 2025-03-05T13:10:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -485,4 +494,64 @@ void shouldRejectUpdateJobNoBody() {
         .expectBody()
         .json(expectedBody);
   }
+
+  @ParameterizedTest
+  @MethodSource("tenantLists")
+  void shouldRejectActivateJobWithUnauthorizedTenantWhenMultiTenancyEnabled(
+      final List<String> tenantIds) {
+    // given
+    when(multiTenancyCfg.isEnabled()).thenReturn(true);
+    final var request =
+        """
+        {
+          "type": "TEST",
+          "maxJobsToActivate": 2,
+          "requestTimeout": 100,
+          "timeout": 100,
+          "fetchVariable": [],
+          "tenantIds": %s,
+          "worker": "bar"
+        }"""
+            .formatted(tenantIds.stream().map("\"%s\""::formatted).toList());
path: zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/JobControllerTest.java
user: login: tmetzke
body: I tried other things. This was by far the best I could come up with 😆 
I could provide the strings I need with escaped stuff as an alternative, but the verification in the assertions becomes A LOT uglier then (because we want to determine if it's one or multiple tenants). So, I decided for this to stay 🤷 
created_at: 2025-03-05T13:16:54Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: I admit, it sounds weird, yes 😆 
Let me see what I can do about it 😅 
created_at: 2025-03-05T13:19:43Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
+                  : ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                      commandName, tenantIds, "tenant is not authorized to perform this request"),
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: That's a good point, yeah 👍 
created_at: 2025-03-05T13:20:03Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: You mean, instead of doing `RequestMapper.getAuthentication().authenticatedTenantIds()` you'd want to just do `RequestMapper.getAuthenticatedTenantIds()`?
created_at: 2025-03-05T13:21:33Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: remcowesterhoud
body: Exactly, or we use `TenantAttributeHolder.getTenantIds();` directly. Not sure what makes sense from a design perspective here. Probably going through the `RequestMapper` is cleaner.
created_at: 2025-03-05T13:23:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: By the way, it's from the [gRPC mapper](https://github.com/camunda/camunda/blob/dfabe87bbeab08e503d9febb552bfb266ea4292f/zeebe/gateway-grpc/src/main/java/io/camunda/zeebe/gateway/RequestMapper.java#L369) 😆 
created_at: 2025-03-05T13:32:35Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
+                  : ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                      commandName, tenantIds, "tenant is not authorized to perform this request"),
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: Would we leak sensitive information with this, though? I can find out which tenants the user is authorized for 🤔 
created_at: 2025-03-05T13:43:55Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
+                  : ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                      commandName, tenantIds, "tenant is not authorized to perform this request"),
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: remcowesterhoud
body: Not sure if that's relevant if you already make authenticated requests with that user 😄 But I'm no security expert. I'm also fine leaving it out.
created_at: 2025-03-05T13:47:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
+                  : ERROR_MESSAGE_INVALID_TENANTS.formatted(
+                      commandName, tenantIds, "tenant is not authorized to perform this request"),
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: Yeah, I don't really know. I'll leave it like this for now. Don't want to risk this currently or spend more time on this. Nothing we cannot improve later.
created_at: 2025-03-05T14:00:52Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: Done
created_at: 2025-03-05T14:01:04Z
author_association: MEMBER
diff_hunk: @@ -36,17 +37,28 @@ public final class MultiTenancyValidator {
    */
   public static Optional<ProblemDetail> validateAuthorization(
       final String tenantId, final boolean multiTenancyEnabled, final String commandName) {
+    return validateAuthorizations(
+        tenantId == null ? List.of() : List.of(tenantId), multiTenancyEnabled, commandName);
+  }
+
+  public static Optional<ProblemDetail> validateAuthorizations(
+      final List<String> tenantIds, final boolean multiTenancyEnabled, final String commandName) {
     if (!multiTenancyEnabled) {
       return Optional.empty();
     }
 
     final var authorizedTenants = RequestMapper.getAuthentication().authenticatedTenantIds();
-    if (authorizedTenants == null || !authorizedTenants.contains(tenantId)) {
+    if (authorizedTenants == null || !authorizedTenants.containsAll(tenantIds)) {
       return Optional.of(
           RestErrorMapper.createProblemDetail(
               HttpStatus.UNAUTHORIZED,
-              ERROR_MESSAGE_INVALID_TENANT.formatted(
-                  commandName, tenantId, "tenant is not authorized to perform this request"),
+              tenantIds.size() == 1
+                  ? ERROR_MESSAGE_INVALID_TENANT.formatted(
+                      commandName,
+                      tenantIds.getFirst(),
+                      "tenant is not authorized to perform this request")
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/validator/MultiTenancyValidator.java
user: login: tmetzke
body: Adjusted
created_at: 2025-03-05T14:01:15Z
author_association: MEMBER

PR comments:
user: login: tmetzke
created_at: 2025-03-05T12:24:39Z
body: FYI, I am working on a "positive" test as well, to ensure it actually works as expected for authorized tenants. Will add this asap.
author_association: MEMBER
user: login: remcowesterhoud
created_at: 2025-03-05T13:55:34Z
body:  > I am surprised no ITs failed because of this. I guess because it's 8.6?

Regarding this, the `MultiTenanctOverIdentityIT` got deleted instead of getting migrated to the new architecture. I've added it to [this issue](https://github.com/camunda/camunda/issues/29233) to reintroduce it. It's a very valuable test to have imo 🙂 
author_association: CONTRIBUTOR
user: login: tmetzke
created_at: 2025-03-05T14:35:59Z
body: I'll move back to setting tenants as a list in the JWT, a set is not allowed.
author_association: MEMBER
user: login: backport-action
created_at: 2025-03-05T16:18:13Z
body: Created backport PR for `main`:
- #29250 with remaining conflicts!

Please cherry-pick the changes locally and resolve any conflicts.
```bash
git fetch origin backport-29215-to-main
git worktree add --checkout .worktree/backport-29215-to-main backport-29215-to-main
cd .worktree/backport-29215-to-main
git reset --hard HEAD^
git cherry-pick -x 7b14f4a92c4d9f39c308fbd6de33b8d76ac6a082 3221a6f177d8c007bd04de07f9388e7398be456d 6fb74b7c214947e8ca4d0b99ad455fda47a8cac8 b737a1d2d6a8e6e49697426c7d07bdbf8ebc3038 05a019ec4cc534059101c1654c23bf625c4209f6 42f5ced0839061f48975c1aafb17a2079c1159ba 55cc24fc68012caed0bf80c45479412b8500ba55 a4cb80e42fbe7305c556d9113594eab931db85fb 5e266f781b2d140ed7fdc30a62d6f9f6cc29cd90 d7e1e3bd72f8b744848427a141a6791ed2a1626b e58358b2d616d911e0dc9a3b8be2e0b55cb02f40
git push --force-with-lease
```
author_association: COLLABORATOR
user: login: backport-action
created_at: 2025-03-05T16:18:20Z
body: Created backport PR for `stable/8.7`:
- #29251 with remaining conflicts!

Please cherry-pick the changes locally and resolve any conflicts.
```bash
git fetch origin backport-29215-to-stable/8.7
git worktree add --checkout .worktree/backport-29215-to-stable/8.7 backport-29215-to-stable/8.7
cd .worktree/backport-29215-to-stable/8.7
git reset --hard HEAD^
git cherry-pick -x 42f5ced0839061f48975c1aafb17a2079c1159ba 55cc24fc68012caed0bf80c45479412b8500ba55 a4cb80e42fbe7305c556d9113594eab931db85fb 5e266f781b2d140ed7fdc30a62d6f9f6cc29cd90 d7e1e3bd72f8b744848427a141a6791ed2a1626b e58358b2d616d911e0dc9a3b8be2e0b55cb02f40
git push --force-with-lease
```
author_association: COLLABORATOR
user: login: tmetzke
created_at: 2025-03-06T13:52:08Z
body: > > I am surprised no ITs failed because of this. I guess because it's 8.6?
> 
> Regarding this, the `MultiTenanctOverIdentityIT` got deleted instead of getting migrated to the new architecture. I've added it to [this issue](https://github.com/camunda/camunda/issues/29233) to reintroduce it. It's a very valuable test to have imo 🙂

By the way, the test exists in 8.6 and 8.7 and still this wasn't discovered. That is because the test only tests over gRPC, as far as I can see.
author_association: MEMBER