This is an automated email from the ASF dual-hosted git repository.

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 14bc3123b64 Validate segment upload destinations (#19231)
14bc3123b64 is described below

commit 14bc3123b64aa526bf45b84e8472687f3d248ab2
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Aug 25 15:49:56 2026 -0700

    Validate segment upload destinations (#19231)
---
 .../api/access/AuthenticationFilter.java           |  12 +-
 .../PinotSegmentUploadDownloadRestletResource.java | 158 ++++++--
 .../api/PinotSegmentUploadAuthorizationTest.java   | 428 +++++++++++++++++++++
 ...otSegmentUploadDownloadRestletResourceTest.java |  60 +++
 .../pinot/core/auth/FineGrainedAuthUtils.java      |  13 +-
 .../pinot/core/auth/FineGrainedAuthUtilsTest.java  |  48 +++
 .../minion/tasks/purge/PurgeTaskGenerator.java     |   4 +-
 .../RefreshSegmentTaskGenerator.java               |   4 +-
 .../RefreshSegmentTaskGeneratorTest.java           |  98 +++++
 9 files changed, 779 insertions(+), 46 deletions(-)

diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java
index 015a97d59b3..f2bca693357 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java
@@ -30,13 +30,16 @@ import javax.inject.Provider;
 import javax.ws.rs.DELETE;
 import javax.ws.rs.POST;
 import javax.ws.rs.PUT;
+import javax.ws.rs.WebApplicationException;
 import javax.ws.rs.container.ContainerRequestContext;
 import javax.ws.rs.container.ContainerRequestFilter;
 import javax.ws.rs.container.ResourceInfo;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriInfo;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.common.auth.AuthProviderUtils;
 import org.apache.pinot.common.utils.DatabaseUtils;
 import org.apache.pinot.core.auth.Authorize;
@@ -103,8 +106,15 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
     // authorization. If table name is not available, it means the endpoint is 
not a table-level endpoint.
     String tableName = extractTableName(endpointMethod, 
uriInfo.getPathParameters(), uriInfo.getQueryParameters());
     if (tableName != null) {
+      if (StringUtils.isBlank(tableName)) {
+        throw new WebApplicationException("Table name must not be blank", 
Response.Status.BAD_REQUEST);
+      }
       // If table name is present, translate it to the fully qualified name 
based on database header.
-      tableName = DatabaseUtils.translateTableName(tableName, _httpHeaders);
+      try {
+        tableName = DatabaseUtils.translateTableName(tableName, _httpHeaders);
+      } catch (RuntimeException e) {
+        throw new WebApplicationException("Invalid table name: " + 
e.getMessage(), e, Response.Status.BAD_REQUEST);
+      }
     }
     AccessType accessType = extractAccessType(endpointMethod);
     AccessControlUtils.validatePermission(tableName, accessType, _httpHeaders, 
endpointUrl, accessControl);
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
index 599464a75af..5446967d9e3 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResource.java
@@ -260,20 +260,12 @@ public class PinotSegmentUploadDownloadRestletResource {
 
   private SuccessResponse uploadSegment(@Nullable String tableName, TableType 
tableType,
       @Nullable FormDataMultiPart multiPart, boolean 
copySegmentToFinalLocation, boolean enableParallelPushProtection,
-      boolean allowRefresh, HttpHeaders headers, Request request) {
+      boolean allowRefresh, boolean requireMatchingMetadataTable, HttpHeaders 
headers, Request request) {
     long segmentUploadStartTimeMs = System.currentTimeMillis();
-    if (StringUtils.isNotEmpty(tableName)) {
-      TableType tableTypeFromTableName = 
TableNameBuilder.getTableTypeFromTableName(tableName);
-      if (tableTypeFromTableName != null && tableTypeFromTableName != 
tableType) {
-        throw new ControllerApplicationException(LOGGER,
-            String.format("Table name: %s does not match table type: %s", 
tableName, tableType),
-            Response.Status.BAD_REQUEST);
-      }
-    }
-
-    // TODO: Consider validating the segment name and table name from the 
header against the actual segment
+    // TODO: Consider validating the segment name from the header against the 
actual segment
     extractHttpHeader(headers, 
CommonConstants.Controller.SEGMENT_NAME_HTTP_HEADER);
-    extractHttpHeader(headers, 
CommonConstants.Controller.TABLE_NAME_HTTP_HEADER);
+    String tableNameInHeader = extractHttpHeader(headers, 
CommonConstants.Controller.TABLE_NAME_HTTP_HEADER);
+    String requestedTableName = resolveRequestedTableName(tableName, 
tableNameInHeader, tableType, headers);
 
     String uploadTypeStr = extractHttpHeader(headers, 
FileUploadDownloadClient.CustomHeaders.UPLOAD_TYPE);
     String sourceDownloadURIStr = extractHttpHeader(headers, 
FileUploadDownloadClient.CustomHeaders.DOWNLOAD_URI);
@@ -318,7 +310,7 @@ public class PinotSegmentUploadDownloadRestletResource {
                 "Source download URI is required in header field 
'DOWNLOAD_URI' for URI upload mode",
                 Response.Status.BAD_REQUEST);
           }
-          downloadSegmentFileFromURI(sourceDownloadURIStr, destFile, 
tableName);
+          downloadSegmentFileFromURI(sourceDownloadURIStr, destFile, 
requestedTableName);
           segmentSizeInBytes = destFile.length();
           break;
         case METADATA:
@@ -367,19 +359,15 @@ public class PinotSegmentUploadDownloadRestletResource {
       // Fetch segment name
       String segmentName = segmentMetadata.getName();
 
-      // Fetch table name. Try to derive the table name from the parameter and 
then from segment metadata
-      String rawTableName;
-      if (StringUtils.isNotEmpty(tableName)) {
-        rawTableName = TableNameBuilder.extractRawTableName(tableName);
-      } else {
-        // TODO: remove this when we completely deprecate the table name from 
segment metadata
-        rawTableName = segmentMetadata.getTableName();
-        LOGGER.warn("Table name is not provided as request query parameter 
when uploading segment: {} for table: {}",
-            segmentName, rawTableName);
-      }
-      String tableNameWithType = tableType == TableType.OFFLINE
-          ? TableNameBuilder.OFFLINE.tableNameWithType(rawTableName)
-          : TableNameBuilder.REALTIME.tableNameWithType(rawTableName);
+      String rawTableName = resolveDestinationTableName(requestedTableName, 
segmentMetadata.getTableName(), tableType,
+          headers, requireMatchingMetadataTable);
+      String tableNameWithType = 
TableNameBuilder.forType(tableType).tableNameWithType(rawTableName);
+
+      // The v1 endpoints are authorized at cluster scope before segment 
extraction because tableName is optional.
+      // Re-authorize all variants against the canonical destination so no 
AccessControl implementation can permit a
+      // different table through cluster-level CREATE access or 
database-header translation.
+      ResourceUtils.checkPermissionAndAccess(rawTableName, request, headers, 
AccessType.CREATE,
+          Actions.Table.UPLOAD_SEGMENT, _accessControlFactory, LOGGER);
 
       if 
(UploadedRealtimeSegmentName.isUploadedRealtimeSegmentName(segmentName) && 
tableType != TableType.REALTIME) {
         throw new ControllerApplicationException(LOGGER, "Cannot upload 
segment: " + segmentName
@@ -574,9 +562,20 @@ public class PinotSegmentUploadDownloadRestletResource {
   private SuccessResponse uploadSegments(String tableName, TableType 
tableType, FormDataMultiPart multiPart,
       boolean enableParallelPushProtection, boolean allowRefresh, HttpHeaders 
headers, Request request) {
     long segmentsUploadStartTimeMs = System.currentTimeMillis();
-    String rawTableName = TableNameBuilder.extractRawTableName(tableName);
-    String tableNameWithType = tableType == TableType.OFFLINE ? 
TableNameBuilder.OFFLINE.tableNameWithType(rawTableName)
-        : TableNameBuilder.REALTIME.tableNameWithType(rawTableName);
+    String rawTableName = normalizeTableName(tableName, tableType, headers, 
"request tableName");
+    if (rawTableName == null) {
+      throw new ControllerApplicationException(LOGGER, "tableName is required 
for batch segment upload",
+          Response.Status.BAD_REQUEST);
+    }
+    String tableNameInHeader = normalizeTableName(
+        extractHttpHeader(headers, 
CommonConstants.Controller.TABLE_NAME_HTTP_HEADER), tableType, headers,
+        CommonConstants.Controller.TABLE_NAME_HTTP_HEADER + " header");
+    validateMatchingTableName(rawTableName, tableNameInHeader,
+        CommonConstants.Controller.TABLE_NAME_HTTP_HEADER + " header");
+    String tableNameWithType = 
TableNameBuilder.forType(tableType).tableNameWithType(rawTableName);
+
+    ResourceUtils.checkPermissionAndAccess(rawTableName, request, headers, 
AccessType.CREATE,
+        Actions.Table.UPLOAD_SEGMENT, _accessControlFactory, LOGGER);
 
     TableConfig tableConfig = 
_pinotHelixResourceManager.getTableConfig(tableNameWithType);
     if (tableConfig == null) {
@@ -648,6 +647,9 @@ public class PinotSegmentUploadDownloadRestletResource {
 
         String metadataProviderClass = 
DefaultMetadataExtractor.class.getName();
         SegmentMetadata segmentMetadata = 
getSegmentMetadata(tempDecryptedFile, tempSegmentDir, metadataProviderClass);
+        String segmentMetadataTableName =
+            normalizeTableName(segmentMetadata.getTableName(), tableType, 
headers, "segment metadata table name");
+        validateMatchingTableName(rawTableName, segmentMetadataTableName, 
"segment metadata table name");
         LOGGER.info("Processing upload request for segment: {} of table: {} 
with upload type: {} from client: {}, "
                 + "ingestion descriptor: {}", segmentName, tableNameWithType, 
uploadType, clientAddress,
             ingestionDescriptor);
@@ -735,6 +737,84 @@ public class PinotSegmentUploadDownloadRestletResource {
     }
   }
 
+  @VisibleForTesting
+  static String resolveDestinationTableName(@Nullable String requestTableName, 
@Nullable String headerTableName,
+      @Nullable String metadataTableName, TableType tableType, HttpHeaders 
headers,
+      boolean requireMatchingMetadataTable) {
+    String requestedTableName = resolveRequestedTableName(requestTableName, 
headerTableName, tableType, headers);
+    return resolveDestinationTableName(requestedTableName, metadataTableName, 
tableType, headers,
+        requireMatchingMetadataTable);
+  }
+
+  @Nullable
+  private static String resolveRequestedTableName(@Nullable String 
requestTableName, @Nullable String headerTableName,
+      TableType tableType, HttpHeaders headers) {
+    String normalizedRequestTable = normalizeTableName(requestTableName, 
tableType, headers, "request tableName");
+    String normalizedHeaderTable = normalizeTableName(headerTableName, 
tableType, headers,
+        CommonConstants.Controller.TABLE_NAME_HTTP_HEADER + " header");
+    String destinationTable = normalizedRequestTable != null ? 
normalizedRequestTable
+        : normalizedHeaderTable;
+    if (destinationTable != null) {
+      validateMatchingTableName(destinationTable, normalizedHeaderTable,
+          CommonConstants.Controller.TABLE_NAME_HTTP_HEADER + " header");
+    }
+    return destinationTable;
+  }
+
+  private static String resolveDestinationTableName(@Nullable String 
requestedTableName,
+      @Nullable String metadataTableName, TableType tableType, HttpHeaders 
headers,
+      boolean requireMatchingMetadataTable) {
+    String normalizedMetadataTable = null;
+    if (requireMatchingMetadataTable || requestedTableName == null) {
+      normalizedMetadataTable =
+          normalizeTableName(metadataTableName, tableType, headers, "segment 
metadata table name");
+    }
+    String destinationTable = requestedTableName != null ? requestedTableName 
: normalizedMetadataTable;
+    if (destinationTable == null) {
+      throw new ControllerApplicationException(LOGGER,
+          "Table name is required in the request, " + 
CommonConstants.Controller.TABLE_NAME_HTTP_HEADER
+              + " header, or segment metadata",
+          Response.Status.BAD_REQUEST);
+    }
+
+    if (requireMatchingMetadataTable) {
+      validateMatchingTableName(destinationTable, normalizedMetadataTable, 
"segment metadata table name");
+    }
+    return destinationTable;
+  }
+
+  @Nullable
+  private static String normalizeTableName(@Nullable String tableName, 
TableType tableType, HttpHeaders headers,
+      String source) {
+    if (tableName == null) {
+      return null;
+    }
+    if (StringUtils.isBlank(tableName)) {
+      throw new ControllerApplicationException(LOGGER, "Invalid " + source + 
": table name must not be blank",
+          Response.Status.BAD_REQUEST);
+    }
+    try {
+      TableType tableTypeFromName = 
TableNameBuilder.getTableTypeFromTableName(tableName);
+      if (tableTypeFromName != null && tableTypeFromName != tableType) {
+        throw new IllegalArgumentException(
+            String.format("Table name: %s does not match table type: %s", 
tableName, tableType));
+      }
+      return 
DatabaseUtils.translateTableName(TableNameBuilder.extractRawTableName(tableName),
 headers);
+    } catch (RuntimeException e) {
+      throw new ControllerApplicationException(LOGGER, "Invalid " + source + 
": " + e.getMessage(),
+          Response.Status.BAD_REQUEST, e);
+    }
+  }
+
+  private static void validateMatchingTableName(String destinationTable, 
@Nullable String suppliedTable,
+      String source) {
+    if (suppliedTable != null && !destinationTable.equals(suppliedTable)) {
+      throw new ControllerApplicationException(LOGGER,
+          String.format("%s '%s' does not match destination table '%s'", 
source, suppliedTable, destinationTable),
+          Response.Status.BAD_REQUEST);
+    }
+  }
+
   @Nullable
   private String extractHttpHeader(HttpHeaders headers, String name) {
     String value = headers.getHeaderString(name);
@@ -778,7 +858,8 @@ public class PinotSegmentUploadDownloadRestletResource {
     return out;
   }
 
-  private void downloadSegmentFileFromURI(String currentSegmentLocationURI, 
File destFile, String tableName)
+  private void downloadSegmentFileFromURI(String currentSegmentLocationURI, 
File destFile,
+      @Nullable String tableName)
       throws Exception {
     if (currentSegmentLocationURI == null || 
currentSegmentLocationURI.isEmpty()) {
       throw new ControllerApplicationException(LOGGER, "Failed to get 
downloadURI, needed for URI upload",
@@ -830,8 +911,8 @@ public class PinotSegmentUploadDownloadRestletResource {
   // request if a multipart object is not sent. This endpoint does not move 
the segment to its final location;
   // it keeps it at the downloadURI header that is set. We will not support 
this endpoint going forward.
   public void uploadSegmentAsJson(String segmentJsonStr,
-      @ApiParam(value = "Name of the table to upload into. Overrides 
segment.table.name in segment metadata when set "
-          + "(allows promoting a segment built for another table). Falls back 
to metadata when omitted.")
+      @ApiParam(value = "Name of the table to upload into. Must match 
segment.table.name in segment metadata when both "
+          + "are set. Falls back to metadata when omitted.")
       @QueryParam(FileUploadDownloadClient.QueryParameters.TABLE_NAME)
       String tableName,
       @ApiParam(value = "Type of the table") 
@QueryParam(FileUploadDownloadClient.QueryParameters.TABLE_TYPE)
@@ -844,7 +925,7 @@ public class PinotSegmentUploadDownloadRestletResource {
       @Context HttpHeaders headers, @Context Request request, @Suspended 
AsyncResponse asyncResponse) {
     try {
       asyncResponse.resume(uploadSegment(tableName, 
TableType.valueOf(tableType.toUpperCase()), null, false,
-          enableParallelPushProtection, allowRefresh, headers, request));
+          enableParallelPushProtection, allowRefresh, true, headers, request));
     } catch (Throwable t) {
       asyncResponse.resume(t);
     }
@@ -871,8 +952,8 @@ public class PinotSegmentUploadDownloadRestletResource {
   @TrackedByGauge(gauge = ControllerGauge.SEGMENT_UPLOADS_IN_PROGRESS)
   // For the multipart endpoint, we will always move segment to final location 
regardless of the segment endpoint.
   public void uploadSegmentAsMultiPart(FormDataMultiPart multiPart,
-      @ApiParam(value = "Name of the table to upload into. Overrides 
segment.table.name in segment metadata when set "
-          + "(allows promoting a segment built for another table). Falls back 
to metadata when omitted.")
+      @ApiParam(value = "Name of the table to upload into. Must match 
segment.table.name in segment metadata when both "
+          + "are set. Falls back to metadata when omitted.")
       @QueryParam(FileUploadDownloadClient.QueryParameters.TABLE_NAME)
       String tableName,
       @ApiParam(value = "Type of the table") 
@QueryParam(FileUploadDownloadClient.QueryParameters.TABLE_TYPE)
@@ -885,7 +966,7 @@ public class PinotSegmentUploadDownloadRestletResource {
       @Context HttpHeaders headers, @Context Request request, @Suspended 
AsyncResponse asyncResponse) {
     try {
       asyncResponse.resume(uploadSegment(tableName, 
TableType.valueOf(tableType.toUpperCase()), multiPart, true,
-          enableParallelPushProtection, allowRefresh, headers, request));
+          enableParallelPushProtection, allowRefresh, true, headers, request));
     } catch (Throwable t) {
       asyncResponse.resume(t);
     }
@@ -989,7 +1070,7 @@ public class PinotSegmentUploadDownloadRestletResource {
     try {
       asyncResponse.resume(
           uploadSegment(tableName, TableType.valueOf(tableType.toUpperCase()), 
null, true, enableParallelPushProtection,
-              allowRefresh, headers, request));
+              allowRefresh, false, headers, request));
     } catch (Throwable t) {
       asyncResponse.resume(t);
     }
@@ -1014,7 +1095,6 @@ public class PinotSegmentUploadDownloadRestletResource {
   })
   @TrackInflightRequestMetrics
   @TrackedByGauge(gauge = ControllerGauge.SEGMENT_UPLOADS_IN_PROGRESS)
-  // This behavior does not differ from v1 of the same endpoint.
   public void uploadSegmentAsMultiPartV2(FormDataMultiPart multiPart,
       @ApiParam(value = "Name of the table to upload into. Overrides 
segment.table.name in segment metadata when set.")
       @QueryParam(FileUploadDownloadClient.QueryParameters.TABLE_NAME)
@@ -1029,7 +1109,7 @@ public class PinotSegmentUploadDownloadRestletResource {
       @Context HttpHeaders headers, @Context Request request, @Suspended 
AsyncResponse asyncResponse) {
     try {
       asyncResponse.resume(uploadSegment(tableName, 
TableType.valueOf(tableType.toUpperCase()), multiPart, true,
-          enableParallelPushProtection, allowRefresh, headers, request));
+          enableParallelPushProtection, allowRefresh, false, headers, 
request));
     } catch (Throwable t) {
       asyncResponse.resume(t);
     }
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSegmentUploadAuthorizationTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSegmentUploadAuthorizationTest.java
new file mode 100644
index 00000000000..b078fe249bb
--- /dev/null
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSegmentUploadAuthorizationTest.java
@@ -0,0 +1,428 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.controller.api;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
+import org.apache.commons.io.FileUtils;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.apache.hc.core5.net.URIBuilder;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.exception.HttpErrorStatusException;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.utils.FileUploadDownloadClient;
+import org.apache.pinot.common.utils.SimpleHttpResponse;
+import org.apache.pinot.common.utils.TarCompressionUtils;
+import org.apache.pinot.common.utils.http.HttpClient;
+import org.apache.pinot.controller.ControllerConf;
+import org.apache.pinot.controller.api.access.AccessControl;
+import org.apache.pinot.controller.api.access.AccessControlFactory;
+import org.apache.pinot.controller.api.access.AccessType;
+import org.apache.pinot.controller.helix.ControllerTest;
+import org.apache.pinot.core.auth.TargetType;
+import org.apache.pinot.segment.local.constants.SegmentUploadConstants;
+import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.spi.V1Constants;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.CommonConstants;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+
+@Test(groups = "stateless")
+public class PinotSegmentUploadAuthorizationTest extends ControllerTest {
+  private static final String TABLE_A = "segmentAuthTableA";
+  private static final String TABLE_B = "segmentAuthTableB";
+  private static final String DATABASE = "segmentAuthDatabase";
+  private static final String DATABASE_TABLE_NAME = "segmentAuthDatabaseTable";
+  private static final String DATABASE_TABLE = DATABASE + "." + 
DATABASE_TABLE_NAME;
+  private static final String TABLE_A_WITH_TYPE = 
TableNameBuilder.OFFLINE.tableNameWithType(TABLE_A);
+  private static final String TABLE_B_WITH_TYPE = 
TableNameBuilder.OFFLINE.tableNameWithType(TABLE_B);
+  private static final String DATABASE_TABLE_WITH_TYPE =
+      TableNameBuilder.OFFLINE.tableNameWithType(DATABASE_TABLE);
+  private static final String SEGMENT_A = "segmentA";
+  private static final String SEGMENT_B = "segmentB";
+  private static final String SEGMENT_DATABASE = "segmentDatabase";
+
+  private File _testDir;
+  private File _segmentA;
+  private File _segmentB;
+  private File _segmentBRefresh;
+  private File _segmentDatabase;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    startZk();
+    Map<String, Object> controllerConfig = getDefaultControllerConfiguration();
+    controllerConfig.put(ControllerConf.ACCESS_CONTROL_FACTORY_CLASS,
+        DestinationAccessControlFactory.class.getName());
+    startController(controllerConfig);
+    addFakeBrokerInstancesToAutoJoinHelixCluster(1, true);
+    addFakeServerInstancesToAutoJoinHelixCluster(1, true);
+
+    _testDir = new File(FileUtils.getTempDirectory(), 
getClass().getSimpleName());
+    FileUtils.deleteQuietly(_testDir);
+    FileUtils.forceMkdir(_testDir);
+
+    addTable(TABLE_A);
+    addTable(TABLE_B);
+    addTable(DATABASE_TABLE);
+    _segmentA = buildSegment(TABLE_A, SEGMENT_A, SEGMENT_A, 1);
+    _segmentB = buildSegment(TABLE_B, SEGMENT_B, SEGMENT_B, 1);
+    _segmentBRefresh = buildSegment(TABLE_B, SEGMENT_B, SEGMENT_B + "Refresh", 
2);
+    _segmentDatabase = buildSegment(DATABASE_TABLE, SEGMENT_DATABASE, 
SEGMENT_DATABASE, 1);
+    DestinationAccessControlFactory.setAllowedTables(Set.of(TABLE_A));
+  }
+
+  @Test
+  public void testDestinationAuthorizationForSegmentUploads()
+      throws Exception {
+    URI uploadUri = URI.create(getControllerBaseApiUrl() + "/segments");
+    URI v2UploadUri = URI.create(getControllerBaseApiUrl() + "/v2/segments");
+    URI batchUploadUri = URI.create(getControllerBaseApiUrl() + 
"/segments/batchUpload");
+    AtomicInteger segmentDownloadCount = new AtomicInteger();
+    HttpServer segmentServer = startSegmentServer(
+        Map.of(SEGMENT_A, _segmentA, SEGMENT_B, _segmentB, SEGMENT_DATABASE, 
_segmentDatabase),
+        segmentDownloadCount);
+    File batchMetadataTar = null;
+    try (FileUploadDownloadClient client = new FileUploadDownloadClient()) {
+      batchMetadataTar = createBatchMetadataTar(_segmentDatabase, 
SEGMENT_DATABASE,
+          segmentUrl(segmentServer, SEGMENT_DATABASE));
+      File batchMetadataTarForRequest = batchMetadataTar;
+      SimpleHttpResponse uploadResponse = uploadMultipart(client, uploadUri, 
_segmentA, TABLE_A, List.of());
+      Assert.assertEquals(uploadResponse.getStatusCode(), 
Response.Status.OK.getStatusCode());
+
+      SegmentZKMetadata segmentAMetadata =
+          _helixResourceManager.getSegmentZKMetadata(TABLE_A_WITH_TYPE, 
SEGMENT_A);
+      Assert.assertNotNull(segmentAMetadata);
+      Assert.assertEquals(segmentAMetadata.getRefreshTime(), Long.MIN_VALUE);
+
+      String segmentAUri = segmentUrl(segmentServer, SEGMENT_A);
+      int downloadsBeforeInvalidRequest = segmentDownloadCount.get();
+      HttpErrorStatusException earlyHeaderMismatch = 
assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadJson(client, uploadUri, segmentAUri, TABLE_A,
+              List.of(new 
BasicHeader(CommonConstants.Controller.TABLE_NAME_HTTP_HEADER, TABLE_B))));
+      Assert.assertTrue(
+          
earlyHeaderMismatch.getMessage().contains(CommonConstants.Controller.TABLE_NAME_HTTP_HEADER),
+          earlyHeaderMismatch.getMessage());
+      Assert.assertEquals(segmentDownloadCount.get(), 
downloadsBeforeInvalidRequest);
+
+      SimpleHttpResponse refreshResponse = uploadJson(client, uploadUri, 
segmentAUri, TABLE_A, List.of());
+      Assert.assertEquals(refreshResponse.getStatusCode(), 
Response.Status.OK.getStatusCode());
+      segmentAMetadata = 
_helixResourceManager.getSegmentZKMetadata(TABLE_A_WITH_TYPE, SEGMENT_A);
+      Assert.assertTrue(segmentAMetadata.getRefreshTime() > 0);
+
+      assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadMultipart(client, v2UploadUri, _segmentA, null, 
List.of()));
+      assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadMultipart(client, v2UploadUri, _segmentA, " ", 
List.of()));
+      assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadMultipart(client, v2UploadUri, _segmentA, 
"otherDatabase." + TABLE_A,
+              List.of(new BasicHeader(CommonConstants.DATABASE, DATABASE))));
+
+      // V2 remains request-bound and keeps the supported promotion behavior 
when segment metadata names another table.
+      Assert.assertEquals(uploadMultipart(client, v2UploadUri, _segmentB, 
TABLE_A, List.of()).getStatusCode(),
+          Response.Status.OK.getStatusCode());
+      
Assert.assertNotNull(_helixResourceManager.getSegmentMetadataZnRecord(TABLE_A_WITH_TYPE,
 SEGMENT_B));
+      assertNoSegmentState(TABLE_B, TABLE_B_WITH_TYPE, SEGMENT_B);
+
+      assertHttpStatus(Response.Status.FORBIDDEN,
+          () -> uploadMultipart(client, v2UploadUri, _segmentA, TABLE_B, 
List.of()));
+      assertNoSegmentState(TABLE_B, TABLE_B_WITH_TYPE, SEGMENT_A);
+
+      assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadMultipart(client, batchUploadUri, _segmentDatabase, 
TABLE_A,
+              List.of(new 
BasicHeader(CommonConstants.Controller.TABLE_NAME_HTTP_HEADER, TABLE_B))));
+      assertNoSegmentState(TABLE_A, TABLE_A_WITH_TYPE, SEGMENT_DATABASE);
+
+      List<Header> batchUploadHeaders = List.of(new 
BasicHeader(FileUploadDownloadClient.CustomHeaders.UPLOAD_TYPE,
+          FileUploadDownloadClient.FileUploadType.METADATA.toString()));
+      HttpErrorStatusException batchMetadataMismatch = 
assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> client.uploadSegmentMetadata(batchUploadUri, 
batchMetadataTarForRequest.getName(),
+              batchMetadataTarForRequest, batchUploadHeaders, 
uploadParameters(TABLE_A),
+              HttpClient.DEFAULT_SOCKET_TIMEOUT_MS));
+      Assert.assertTrue(batchMetadataMismatch.getMessage().contains("segment 
metadata table name"),
+          batchMetadataMismatch.getMessage());
+      assertNoSegmentState(TABLE_A, TABLE_A_WITH_TYPE, SEGMENT_DATABASE);
+
+      List<Header> blankTableHeader = List.of(
+          new BasicHeader(FileUploadDownloadClient.CustomHeaders.UPLOAD_TYPE,
+              FileUploadDownloadClient.FileUploadType.METADATA.toString()),
+          new BasicHeader(CommonConstants.Controller.TABLE_NAME_HTTP_HEADER, " 
"));
+      assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> client.uploadSegmentMetadata(batchUploadUri, 
batchMetadataTarForRequest.getName(),
+              batchMetadataTarForRequest, blankTableHeader, 
uploadParameters(TABLE_A),
+              HttpClient.DEFAULT_SOCKET_TIMEOUT_MS));
+      assertNoSegmentState(TABLE_A, TABLE_A_WITH_TYPE, SEGMENT_DATABASE);
+
+      DestinationAccessControlFactory.setAllowedTables(Set.of(TABLE_A, 
DATABASE_TABLE));
+      List<Header> databaseHeaders = List.of(
+          new BasicHeader(FileUploadDownloadClient.CustomHeaders.UPLOAD_TYPE,
+              FileUploadDownloadClient.FileUploadType.METADATA.toString()),
+          new BasicHeader(CommonConstants.DATABASE, DATABASE));
+      Assert.assertEquals(client.uploadSegmentMetadata(batchUploadUri, 
batchMetadataTar.getName(), batchMetadataTar,
+          databaseHeaders, uploadParameters(DATABASE_TABLE_NAME), 
HttpClient.DEFAULT_SOCKET_TIMEOUT_MS).getStatusCode(),
+          Response.Status.OK.getStatusCode());
+      Assert.assertNotNull(
+          
_helixResourceManager.getSegmentMetadataZnRecord(DATABASE_TABLE_WITH_TYPE, 
SEGMENT_DATABASE));
+      Assert.assertNull(_helixResourceManager.getSegmentMetadataZnRecord(
+          TableNameBuilder.OFFLINE.tableNameWithType(DATABASE_TABLE_NAME), 
SEGMENT_DATABASE));
+      DestinationAccessControlFactory.setAllowedTables(Set.of(TABLE_A));
+
+      assertHttpStatus(Response.Status.FORBIDDEN, () -> uploadMultipart(
+          client, uploadUri, _segmentB, null, List.of()));
+      assertNoSegmentState(TABLE_B, TABLE_B_WITH_TYPE, SEGMENT_B);
+
+      assertHttpStatus(Response.Status.FORBIDDEN,
+          () -> uploadJson(client, uploadUri, segmentUrl(segmentServer, 
SEGMENT_B), null, List.of()));
+      assertNoSegmentState(TABLE_B, TABLE_B_WITH_TYPE, SEGMENT_B);
+
+      HttpErrorStatusException mismatch = 
assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadMultipart(client, uploadUri, _segmentB, TABLE_A, 
List.of()));
+      Assert.assertTrue(mismatch.getMessage().contains("segment metadata table 
name"), mismatch.getMessage());
+      assertNoSegmentState(TABLE_B, TABLE_B_WITH_TYPE, SEGMENT_B);
+
+      long segmentARefreshTime = segmentAMetadata.getRefreshTime();
+      HttpErrorStatusException headerMismatch = 
assertHttpStatus(Response.Status.BAD_REQUEST,
+          () -> uploadMultipart(client, uploadUri, _segmentA, TABLE_A,
+              List.of(new 
BasicHeader(CommonConstants.Controller.TABLE_NAME_HTTP_HEADER, TABLE_B))));
+      
Assert.assertTrue(headerMismatch.getMessage().contains(CommonConstants.Controller.TABLE_NAME_HTTP_HEADER),
+          headerMismatch.getMessage());
+      
Assert.assertEquals(_helixResourceManager.getSegmentZKMetadata(TABLE_A_WITH_TYPE,
 SEGMENT_A).getRefreshTime(),
+          segmentARefreshTime);
+
+      DestinationAccessControlFactory.setAllowedTables(Set.of(TABLE_A, 
TABLE_B));
+      Assert.assertEquals(uploadMultipart(client, uploadUri, _segmentB, 
TABLE_B, List.of()).getStatusCode(),
+          Response.Status.OK.getStatusCode());
+      DestinationAccessControlFactory.setAllowedTables(Set.of(TABLE_A));
+
+      ZNRecord segmentBZkBefore = 
_helixResourceManager.getSegmentMetadataZnRecord(TABLE_B_WITH_TYPE, SEGMENT_B);
+      Assert.assertNotNull(segmentBZkBefore);
+      File segmentBDeepStoreFile = new File(new 
File(_controllerConfig.getDataDir(), TABLE_B), SEGMENT_B);
+      Assert.assertTrue(segmentBDeepStoreFile.isFile());
+      byte[] segmentBBytesBefore = 
Files.readAllBytes(segmentBDeepStoreFile.toPath());
+      FileTime segmentBModifiedBefore = 
Files.getLastModifiedTime(segmentBDeepStoreFile.toPath());
+      
Assert.assertFalse(Arrays.equals(Files.readAllBytes(_segmentBRefresh.toPath()), 
segmentBBytesBefore));
+
+      // Omit tableName so the request passes cluster CREATE authorization and 
reaches the final-table gate.
+      assertHttpStatus(Response.Status.FORBIDDEN,
+          () -> uploadMultipart(client, uploadUri, _segmentBRefresh, null, 
List.of()));
+
+      ZNRecord segmentBZkAfter = 
_helixResourceManager.getSegmentMetadataZnRecord(TABLE_B_WITH_TYPE, SEGMENT_B);
+      Assert.assertEquals(segmentBZkAfter.getVersion(), 
segmentBZkBefore.getVersion());
+      Assert.assertEquals(segmentBZkAfter.getSimpleFields(), 
segmentBZkBefore.getSimpleFields());
+      Assert.assertEquals(segmentBZkAfter.getListFields(), 
segmentBZkBefore.getListFields());
+      Assert.assertEquals(segmentBZkAfter.getMapFields(), 
segmentBZkBefore.getMapFields());
+      
Assert.assertTrue(Arrays.equals(Files.readAllBytes(segmentBDeepStoreFile.toPath()),
 segmentBBytesBefore));
+      
Assert.assertEquals(Files.getLastModifiedTime(segmentBDeepStoreFile.toPath()), 
segmentBModifiedBefore);
+    } finally {
+      DestinationAccessControlFactory.setAllowedTables(Set.of(TABLE_A));
+      FileUtils.deleteQuietly(batchMetadataTar);
+      segmentServer.stop(0);
+    }
+  }
+
+  private void addTable(String tableName)
+      throws Exception {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(tableName)
+        .addSingleValueDimension("value", DataType.INT).build();
+    _helixResourceManager.addSchema(schema, false, false);
+    _helixResourceManager.addTable(
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(tableName).setNumReplicas(1).build());
+  }
+
+  private File buildSegment(String tableName, String segmentName, String 
artifactName, int value)
+      throws Exception {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(tableName)
+        .addSingleValueDimension("value", DataType.INT).build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(tableName).build();
+    SegmentGeneratorConfig generatorConfig = new 
SegmentGeneratorConfig(tableConfig, schema);
+    File outputDir = new File(_testDir, artifactName + "-output");
+    generatorConfig.setOutDir(outputDir.getAbsolutePath());
+    generatorConfig.setSegmentName(segmentName);
+
+    GenericRow row = new GenericRow();
+    row.putValue("value", value);
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(generatorConfig, new GenericRowRecordReader(List.of(row)));
+    driver.build();
+
+    File segmentTar = new File(_testDir, artifactName + 
TarCompressionUtils.TAR_GZ_FILE_EXTENSION);
+    TarCompressionUtils.createCompressedTarFile(new File(outputDir, 
segmentName), segmentTar);
+    return segmentTar;
+  }
+
+  private File createBatchMetadataTar(File segmentTar, String segmentName, 
String segmentUri)
+      throws IOException {
+    File metadataDir = new File(_testDir, segmentName + "-batch-metadata");
+    FileUtils.forceMkdir(metadataDir);
+    TarCompressionUtils.untarOneFile(segmentTar, 
V1Constants.SEGMENT_CREATION_META,
+        new File(metadataDir, segmentName + "." + 
V1Constants.SEGMENT_CREATION_META));
+    TarCompressionUtils.untarOneFile(segmentTar, 
V1Constants.MetadataKeys.METADATA_FILE_NAME,
+        new File(metadataDir, segmentName + "." + 
V1Constants.MetadataKeys.METADATA_FILE_NAME));
+    Files.writeString(new File(metadataDir, 
SegmentUploadConstants.ALL_SEGMENTS_METADATA_FILENAME).toPath(),
+        segmentName + System.lineSeparator() + segmentUri + 
System.lineSeparator(), StandardCharsets.UTF_8);
+    File metadataTar = new File(_testDir,
+        SegmentUploadConstants.ALL_SEGMENTS_METADATA_TAR_FILE_PREFIX + 
segmentName
+            + TarCompressionUtils.TAR_GZ_FILE_EXTENSION);
+    TarCompressionUtils.createCompressedTarFile(metadataDir, metadataTar);
+    return metadataTar;
+  }
+
+  private static SimpleHttpResponse uploadMultipart(FileUploadDownloadClient 
client, URI uploadUri, File segment,
+      String tableName, List<Header> headers)
+      throws Exception {
+    return client.uploadSegment(uploadUri, segment.getName(), segment, headers,
+        uploadParameters(tableName), HttpClient.DEFAULT_SOCKET_TIMEOUT_MS);
+  }
+
+  private static SimpleHttpResponse uploadJson(FileUploadDownloadClient 
client, URI uploadUri, String segmentUri,
+      String tableName, List<Header> headers)
+      throws Exception {
+    URI requestUri = new 
URIBuilder(uploadUri).addParameters(uploadParameters(tableName)).build();
+    return client.sendSegmentUri(requestUri, segmentUri, headers, null,
+        HttpClient.DEFAULT_SOCKET_TIMEOUT_MS);
+  }
+
+  private static List<NameValuePair> uploadParameters(String tableName) {
+    NameValuePair tableType =
+        new 
BasicNameValuePair(FileUploadDownloadClient.QueryParameters.TABLE_TYPE, 
TableType.OFFLINE.name());
+    return tableName == null ? List.of(tableType)
+        : List.of(new 
BasicNameValuePair(FileUploadDownloadClient.QueryParameters.TABLE_NAME, 
tableName), tableType);
+  }
+
+  private static HttpServer startSegmentServer(Map<String, File> segments, 
AtomicInteger segmentDownloadCount)
+      throws IOException {
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    for (Map.Entry<String, File> entry : segments.entrySet()) {
+      server.createContext("/" + entry.getKey(), exchange -> {
+        segmentDownloadCount.incrementAndGet();
+        long length = entry.getValue().length();
+        exchange.sendResponseHeaders(Response.Status.OK.getStatusCode(), 
length);
+        try (OutputStream outputStream = exchange.getResponseBody()) {
+          Files.copy(entry.getValue().toPath(), outputStream);
+        }
+      });
+    }
+    server.start();
+    return server;
+  }
+
+  private static String segmentUrl(HttpServer server, String segmentName) {
+    return "http://localhost:"; + server.getAddress().getPort() + "/" + 
segmentName;
+  }
+
+  private void assertNoSegmentState(String rawTableName, String 
tableNameWithType, String segmentName) {
+    
Assert.assertNull(_helixResourceManager.getSegmentMetadataZnRecord(tableNameWithType,
 segmentName));
+    Assert.assertFalse(new File(new File(_controllerConfig.getDataDir(), 
rawTableName), segmentName).exists());
+  }
+
+  private static HttpErrorStatusException assertHttpStatus(Response.Status 
expectedStatus, ThrowingRunnable request)
+      throws Exception {
+    try {
+      request.run();
+      Assert.fail("Expected HTTP status " + expectedStatus);
+      return null;
+    } catch (HttpErrorStatusException e) {
+      Assert.assertEquals(e.getStatusCode(), expectedStatus.getStatusCode(), 
e.getMessage());
+      return e;
+    }
+  }
+
+  @AfterClass
+  public void tearDown() {
+    FileUtils.deleteQuietly(_testDir);
+    stopFakeInstances();
+    stopController();
+    stopZk();
+  }
+
+  @FunctionalInterface
+  private interface ThrowingRunnable {
+    void run()
+        throws Exception;
+  }
+
+  public static class DestinationAccessControlFactory implements 
AccessControlFactory {
+    private static volatile Set<String> _allowedTables = Set.of(TABLE_A);
+
+    static void setAllowedTables(Set<String> allowedTables) {
+      _allowedTables = allowedTables;
+    }
+
+    @Override
+    public AccessControl create() {
+      return new AccessControl() {
+        @Override
+        public boolean protectAnnotatedOnly() {
+          return false;
+        }
+
+        @Override
+        public boolean hasAccess(String tableName, AccessType accessType, 
HttpHeaders httpHeaders,
+            String endpointUrl) {
+          return tableName == null || _allowedTables.contains(tableName);
+        }
+
+        @Override
+        public boolean hasAccess(AccessType accessType, HttpHeaders 
httpHeaders, String endpointUrl) {
+          return true;
+        }
+
+        @Override
+        public boolean hasAccess(HttpHeaders httpHeaders, TargetType 
targetType, String targetId, String action) {
+          return targetType == TargetType.CLUSTER || 
_allowedTables.contains(targetId);
+        }
+
+        @Override
+        public boolean hasAccess(HttpHeaders httpHeaders, TargetType 
targetType) {
+          return true;
+        }
+      };
+    }
+  }
+}
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResourceTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResourceTest.java
index 60aaa346d46..cd6107e581c 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResourceTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotSegmentUploadDownloadRestletResourceTest.java
@@ -30,15 +30,19 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.pinot.common.utils.TarCompressionUtils;
 import org.apache.pinot.controller.ControllerConf;
 import 
org.apache.pinot.controller.api.exception.ControllerApplicationException;
 import org.apache.pinot.controller.api.upload.SegmentMetadataInfo;
+import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.crypt.NoOpPinotCrypter;
 import org.apache.pinot.spi.crypt.PinotCrypterFactory;
 import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants;
 import org.glassfish.jersey.media.multipart.BodyPart;
 import org.glassfish.jersey.media.multipart.FormDataBodyPart;
 import org.glassfish.jersey.media.multipart.FormDataMultiPart;
@@ -260,6 +264,62 @@ public class PinotSegmentUploadDownloadRestletResourceTest 
{
     
PinotSegmentUploadDownloadRestletResource.validateMultiPartForBatchSegmentUpload(bodyParts);
   }
 
+  @Test
+  public void testResolveDestinationTableName() {
+    HttpHeaders headers = mock(HttpHeaders.class);
+
+    
assertEquals(PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, TABLE_NAME + "_OFFLINE", TABLE_NAME, TableType.OFFLINE, 
headers, true), TABLE_NAME);
+    
assertEquals(PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        null, null, TABLE_NAME, TableType.OFFLINE, headers, true), TABLE_NAME);
+
+    // V2 keeps its request-table override behavior because the request table 
is already the authorized destination.
+    
assertEquals(PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, null, "source_table", TableType.OFFLINE, headers, false), 
TABLE_NAME);
+
+    
when(headers.getHeaderString(CommonConstants.DATABASE)).thenReturn("testDatabase");
+    
assertEquals(PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, TABLE_NAME, TABLE_NAME, TableType.OFFLINE, headers, true), 
"testDatabase." + TABLE_NAME);
+    
assertEquals(PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, null, "sourceDatabase." + TABLE_NAME, TableType.OFFLINE, 
headers, false),
+        "testDatabase." + TABLE_NAME);
+  }
+
+  @Test
+  public void testRejectMissingOrMismatchedDestinationTableName() {
+    HttpHeaders headers = mock(HttpHeaders.class);
+
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        null, null, null, TableType.OFFLINE, headers, true), "Table name is 
required");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        " ", null, TABLE_NAME, TableType.OFFLINE, headers, true), "Invalid 
request tableName");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, "\t", TABLE_NAME, TableType.OFFLINE, headers, true),
+        "Invalid " + CommonConstants.Controller.TABLE_NAME_HTTP_HEADER + " 
header");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, null, " ", TableType.OFFLINE, headers, true), "Invalid 
segment metadata table name");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, "other_table", TABLE_NAME, TableType.OFFLINE, headers, 
true),
+        CommonConstants.Controller.TABLE_NAME_HTTP_HEADER + " header");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME, null, "other_table", TableType.OFFLINE, headers, true), 
"segment metadata table name");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        TABLE_NAME + "_REALTIME", null, TABLE_NAME, TableType.OFFLINE, 
headers, true),
+        "does not match table type");
+
+    
when(headers.getHeaderString(CommonConstants.DATABASE)).thenReturn("databaseA");
+    assertBadRequest(() -> 
PinotSegmentUploadDownloadRestletResource.resolveDestinationTableName(
+        "databaseB." + TABLE_NAME, null, "databaseB." + TABLE_NAME, 
TableType.OFFLINE, headers, true),
+        "does not match database name");
+  }
+
+  private static void assertBadRequest(Runnable runnable, String 
expectedMessage) {
+    ControllerApplicationException exception =
+        Assert.expectThrows(ControllerApplicationException.class, () -> 
runnable.run());
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
+    Assert.assertTrue(exception.getMessage().contains(expectedMessage), 
exception.getMessage());
+  }
+
   @Test
   public void testCreateSegmentFileFromMultipart()
       throws NoSuchMethodException, InvalidControllerConfigException, 
IOException {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/auth/FineGrainedAuthUtils.java 
b/pinot-core/src/main/java/org/apache/pinot/core/auth/FineGrainedAuthUtils.java
index aca98e01fce..384bf439dca 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/auth/FineGrainedAuthUtils.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/auth/FineGrainedAuthUtils.java
@@ -100,14 +100,19 @@ public class FineGrainedAuthUtils {
         // find the paramName in the path or query params
         targetId = findRawTargetId(auth, uriInfo.getPathParameters(), 
uriInfo.getQueryParameters());
 
-        if (StringUtils.isEmpty(targetId)) {
+        if (StringUtils.isBlank(targetId)) {
           throw new WebApplicationException(
-              "Could not find paramName " + auth.paramName() + " in path or 
query params of the API: "
-                  + uriInfo.getRequestUri(), 
Response.Status.INTERNAL_SERVER_ERROR);
+              "Missing required table parameter '" + auth.paramName() + "' for 
API: " + uriInfo.getRequestUri(),
+              Response.Status.BAD_REQUEST);
         }
 
         // Table name may contain type, hence get raw table name for checking 
access
-        targetId = 
DatabaseUtils.translateTableName(TableNameBuilder.extractRawTableName(targetId),
 httpHeaders);
+        try {
+          targetId = 
DatabaseUtils.translateTableName(TableNameBuilder.extractRawTableName(targetId),
 httpHeaders);
+        } catch (RuntimeException e) {
+          throw new WebApplicationException("Invalid table parameter '" + 
auth.paramName() + "': " + e.getMessage(),
+              e, Response.Status.BAD_REQUEST);
+        }
 
         accessDeniedMsg = "Access denied to " + auth.action() + " for table: " 
+ targetId;
       } else if (auth.targetType() == TargetType.CLUSTER) {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/auth/FineGrainedAuthUtilsTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/auth/FineGrainedAuthUtilsTest.java
index 1328065b7e6..4d9ec48c527 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/auth/FineGrainedAuthUtilsTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/auth/FineGrainedAuthUtilsTest.java
@@ -19,12 +19,14 @@
 package org.apache.pinot.core.auth;
 
 import java.lang.reflect.Method;
+import java.net.URI;
 import javax.ws.rs.WebApplicationException;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedHashMap;
 import javax.ws.rs.core.MultivaluedMap;
 import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriInfo;
+import org.apache.pinot.spi.utils.CommonConstants;
 import org.mockito.Mockito;
 import org.testng.Assert;
 import org.testng.annotations.Test;
@@ -102,6 +104,40 @@ public class FineGrainedAuthUtilsTest {
     }
   }
 
+  @Test
+  public void testMissingTableParameterIsBadRequest() {
+    UriInfo mockUriInfo = Mockito.mock(UriInfo.class);
+    Mockito.when(mockUriInfo.getPathParameters()).thenReturn(new 
MultivaluedHashMap<>());
+    Mockito.when(mockUriInfo.getQueryParameters()).thenReturn(new 
MultivaluedHashMap<>());
+    
Mockito.when(mockUriInfo.getRequestUri()).thenReturn(URI.create("http://localhost/v2/segments";));
+
+    WebApplicationException exception = 
Assert.expectThrows(WebApplicationException.class,
+        () -> 
FineGrainedAuthUtils.validateFineGrainedAuth(getTableAnnotatedMethod(), 
mockUriInfo,
+            Mockito.mock(HttpHeaders.class), 
Mockito.mock(FineGrainedAccessControl.class)));
+
+    Assert.assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
+    Assert.assertTrue(exception.getMessage().contains("Missing required table 
parameter 'tableName'"));
+  }
+
+  @Test
+  public void testInvalidTableParameterIsBadRequest() {
+    UriInfo mockUriInfo = Mockito.mock(UriInfo.class);
+    MultivaluedHashMap<String, String> queryParameters = new 
MultivaluedHashMap<>();
+    queryParameters.putSingle("tableName", "databaseB.testTable");
+    Mockito.when(mockUriInfo.getPathParameters()).thenReturn(new 
MultivaluedHashMap<>());
+    Mockito.when(mockUriInfo.getQueryParameters()).thenReturn(queryParameters);
+    
Mockito.when(mockUriInfo.getRequestUri()).thenReturn(URI.create("http://localhost/v2/segments";));
+    HttpHeaders headers = Mockito.mock(HttpHeaders.class);
+    
Mockito.when(headers.getHeaderString(CommonConstants.DATABASE)).thenReturn("databaseA");
+
+    WebApplicationException exception = 
Assert.expectThrows(WebApplicationException.class,
+        () -> 
FineGrainedAuthUtils.validateFineGrainedAuth(getTableAnnotatedMethod(), 
mockUriInfo, headers,
+            Mockito.mock(FineGrainedAccessControl.class)));
+
+    Assert.assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
+    Assert.assertTrue(exception.getMessage().contains("Invalid table parameter 
'tableName'"));
+  }
+
   static class TestResource {
     @Authorize(targetType = TargetType.CLUSTER, action = "getCluster")
     void getCluster() {
@@ -110,6 +146,10 @@ public class FineGrainedAuthUtilsTest {
     @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action 
= "getTable")
     void getTable() {
     }
+
+    @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action 
= "uploadSegment")
+    void uploadSegment() {
+    }
   }
 
   private Method getAnnotatedMethod() {
@@ -119,4 +159,12 @@ public class FineGrainedAuthUtilsTest {
       throw new RuntimeException(e);
     }
   }
+
+  private Method getTableAnnotatedMethod() {
+    try {
+      return TestResource.class.getDeclaredMethod("uploadSegment");
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException(e);
+    }
+  }
 }
diff --git 
a/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/purge/PurgeTaskGenerator.java
 
b/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/purge/PurgeTaskGenerator.java
index 16506881cef..31c5359027b 100644
--- 
a/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/purge/PurgeTaskGenerator.java
+++ 
b/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/purge/PurgeTaskGenerator.java
@@ -144,8 +144,10 @@ public class PurgeTaskGenerator extends BaseTaskGenerator {
           break;
         }
         configs.put(MinionConstants.DOWNLOAD_URL_KEY, 
segmentZKMetadata.getDownloadUrl());
+        // Purge can reuse the original index directory, whose metadata may 
name the source table. Other conversion
+        // tasks use v1 because they regenerate destination-bound metadata; 
any future artifact-reuse path must use v2.
         configs.put(MinionConstants.UPLOAD_URL_KEY,
-            _clusterInfoAccessor.getVipUrlForLeadController(tableName) + 
"/segments");
+            _clusterInfoAccessor.getVipUrlForLeadController(tableName) + 
"/v2/segments");
         configs.put(MinionConstants.ORIGINAL_SEGMENT_CRC_KEY, 
String.valueOf(segmentZKMetadata.getCrc()));
         pinotTaskConfigs.add(new PinotTaskConfig(taskType, configs));
         tableNumTasks++;
diff --git 
a/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGenerator.java
 
b/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGenerator.java
index fd4a5a72ee0..b65d8332fc6 100644
--- 
a/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGenerator.java
+++ 
b/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/main/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGenerator.java
@@ -129,8 +129,10 @@ public class RefreshSegmentTaskGenerator extends 
BaseTaskGenerator {
       Map<String, String> configs = new 
HashMap<>(getBaseTaskConfigs(tableConfig, List.of(segmentName)));
       configs.putAll(MinionTaskUtils.getPushTaskConfig(tableNameWithType, 
taskConfigs, _clusterInfoAccessor));
       configs.put(MinionConstants.DOWNLOAD_URL_KEY, 
segmentZKMetadata.getDownloadUrl());
+      // Refresh can reuse the original index directory, whose metadata may 
name the source table. Other conversion
+      // tasks use v1 because they regenerate destination-bound metadata; any 
future artifact-reuse path must use v2.
       configs.put(MinionConstants.UPLOAD_URL_KEY,
-          _clusterInfoAccessor.getVipUrlForLeadController(tableNameWithType) + 
"/segments");
+          _clusterInfoAccessor.getVipUrlForLeadController(tableNameWithType) + 
"/v2/segments");
       configs.put(MinionConstants.ORIGINAL_SEGMENT_CRC_KEY, 
String.valueOf(segmentZKMetadata.getCrc()));
       pinotTaskConfigs.add(new PinotTaskConfig(taskType, configs));
       tableNumTasks++;
diff --git 
a/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/test/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGeneratorTest.java
 
b/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/test/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGeneratorTest.java
new file mode 100644
index 00000000000..9e8be717f0f
--- /dev/null
+++ 
b/pinot-plugins/pinot-minion-tasks/pinot-minion-builtin-tasks/src/test/java/org/apache/pinot/plugin/minion/tasks/refreshsegment/RefreshSegmentTaskGeneratorTest.java
@@ -0,0 +1,98 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.plugin.minion.tasks.refreshsegment;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.helix.model.IdealState;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
+import org.apache.pinot.controller.helix.core.minion.ClusterInfoAccessor;
+import org.apache.pinot.core.common.MinionConstants;
+import org.apache.pinot.core.minion.PinotTaskConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.zookeeper.data.Stat;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+
+
+/// Tests task configuration generated by [RefreshSegmentTaskGenerator].
+public class RefreshSegmentTaskGeneratorTest {
+  @Test
+  public void testGeneratesV2SegmentUploadUrl()
+      throws Exception {
+    String rawTableName = "testTable";
+    String tableNameWithType = 
TableNameBuilder.OFFLINE.tableNameWithType(rawTableName);
+    String segmentName = "testSegment";
+    String controllerUrl = "http://controller:9000";;
+
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(rawTableName).build();
+    Schema schema = new Schema.SchemaBuilder()
+        .setSchemaName(rawTableName)
+        .addSingleValueDimension("value", FieldSpec.DataType.INT)
+        .build();
+
+    Stat tableStat = new Stat();
+    tableStat.setMtime(1L);
+    Stat schemaStat = new Stat();
+    schemaStat.setMtime(1L);
+
+    SegmentZKMetadata segmentMetadata = new SegmentZKMetadata(segmentName);
+    segmentMetadata.setDownloadUrl("http://deep-store/testSegment.tar.gz";);
+    segmentMetadata.setCrc(123L);
+
+    IdealState idealState = new IdealState(tableNameWithType);
+    idealState.setRebalanceMode(IdealState.RebalanceMode.CUSTOMIZED);
+    idealState.setPartitionState(segmentName, "Server_0", "ONLINE");
+
+    PinotHelixResourceManager resourceManager = 
mock(PinotHelixResourceManager.class);
+    
when(resourceManager.getTableStat(tableNameWithType)).thenReturn(tableStat);
+    when(resourceManager.getTableSchema(tableNameWithType)).thenReturn(schema);
+    when(resourceManager.getSchemaStat(rawTableName)).thenReturn(schemaStat);
+
+    ClusterInfoAccessor accessor = mock(ClusterInfoAccessor.class);
+    when(accessor.getPinotHelixResourceManager()).thenReturn(resourceManager);
+    
when(accessor.getTaskStates(MinionConstants.RefreshSegmentTask.TASK_TYPE)).thenReturn(Map.of());
+    when(accessor.getIdealState(tableNameWithType)).thenReturn(idealState);
+    
when(accessor.getSegmentsZKMetadata(tableNameWithType)).thenReturn(List.of(segmentMetadata));
+    when(accessor.getDataDir()).thenReturn("file:///tmp");
+    
when(accessor.getVipUrlForLeadController(tableNameWithType)).thenReturn(controllerUrl);
+
+    RefreshSegmentTaskGenerator generator = new RefreshSegmentTaskGenerator();
+    generator.init(accessor);
+
+    List<PinotTaskConfig> tasks = generator.generateTasks(tableConfig, new 
HashMap<>());
+
+    assertEquals(tasks.size(), 1);
+    Map<String, String> configs = tasks.get(0).getConfigs();
+    assertEquals(configs.get(MinionConstants.UPLOAD_URL_KEY), controllerUrl + 
"/v2/segments");
+    assertEquals(configs.get(MinionConstants.TABLE_NAME_KEY), 
tableNameWithType);
+    assertEquals(configs.get(MinionConstants.SEGMENT_NAME_KEY), segmentName);
+    assertEquals(configs.get(MinionConstants.ORIGINAL_SEGMENT_CRC_KEY), "123");
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to