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 b0e597c3a7b [controller] Honor cluster access permissions (#19234)
b0e597c3a7b is described below

commit b0e597c3a7baf59cd8384d8883429fd32ac1bdf1
Author: Xiang Fu <[email protected]>
AuthorDate: Mon Aug 17 15:51:50 2026 -0700

    [controller] Honor cluster access permissions (#19234)
---
 .../config/provider/AccessControlUserCache.java    |  26 +++
 .../provider/AccessControlUserCacheTest.java       |  92 ++++++++
 .../api/access/AuthenticationFilter.java           |  18 +-
 .../api/access/BaseBasicAuthAccessControl.java     |  78 +++++++
 .../api/access/BasicAuthAccessControlFactory.java  |  34 +--
 .../access/ZkBasicAuthAccessControlFactory.java    |  68 +++---
 .../resources/LLCSegmentCompletionHandlers.java    |  15 +-
 .../api/resources/PinotBrokerRestletResource.java  |   2 +-
 .../api/resources/PinotControllerAuthResource.java |   8 +-
 .../api/resources/PinotControllerLogger.java       |   4 +-
 ...PinotControllerPeriodicTaskRestletResource.java |   1 +
 .../resources/PinotInstanceRestletResource.java    |   1 +
 .../api/resources/PinotQueryResource.java          |   3 +
 .../api/resources/PinotTableRestletResource.java   |   2 +-
 .../api/access/AuthenticationFilterTest.java       |  96 ++++++++
 .../access/BasicAuthAccessControlFactoryTest.java  | 247 ++++++++++++++++++++
 ...ontrollerClusterBasicAuthAuthorizationTest.java | 254 +++++++++++++++++++++
 .../resources/PinotControllerAuthResourceTest.java |  72 ++++++
 .../pinot/core/auth/FineGrainedAuthUtils.java      |  15 +-
 .../pinot/core/auth/FineGrainedAuthUtilsTest.java  |  28 +++
 20 files changed, 978 insertions(+), 86 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/config/provider/AccessControlUserCache.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/config/provider/AccessControlUserCache.java
index e284fab02f3..e9ccc32651b 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/config/provider/AccessControlUserCache.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/config/provider/AccessControlUserCache.java
@@ -87,6 +87,32 @@ public class AccessControlUserCache {
     return 
_userControllerConfigMap.values().stream().collect(Collectors.toList());
   }
 
+  /// Returns the cache-owned controller user config for the given raw 
username, or `null` when no such user exists.
+  /// Callers must treat the returned config as read-only.
+  @Nullable
+  public UserConfig getControllerUserConfigForUsername(String rawUsername) {
+    if (rawUsername == null || !rawUsername.equals(rawUsername.trim())) {
+      return null;
+    }
+    String normalizedUsername = rawUsername;
+    UserConfig userConfig = _userControllerConfigMap.get(normalizedUsername + 
"_" + ComponentType.CONTROLLER);
+    if (userConfig != null) {
+      return userConfig;
+    }
+    // Principal names were historically trimmed after loading all users. 
Retain that compatibility on the uncommon
+    // slow path where a stored username itself contains leading or trailing 
whitespace.
+    UserConfig matchedUserConfig = null;
+    for (UserConfig candidate : _userControllerConfigMap.values()) {
+      if (candidate.getUserName().trim().equals(normalizedUsername)) {
+        if (matchedUserConfig != null) {
+          return null;
+        }
+        matchedUserConfig = candidate;
+      }
+    }
+    return matchedUserConfig;
+  }
+
   public List<UserConfig> getAllBrokerUserConfig() {
     return _userBrokerConfigMap.values().stream().collect(Collectors.toList());
   }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/config/provider/AccessControlUserCacheTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/config/provider/AccessControlUserCacheTest.java
new file mode 100644
index 00000000000..4d3cc6c744e
--- /dev/null
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/config/provider/AccessControlUserCacheTest.java
@@ -0,0 +1,92 @@
+/**
+ * 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.common.config.provider;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.helix.AccessOption;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.utils.config.AccessControlUserConfigUtils;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+import org.apache.pinot.spi.config.user.UserConfig;
+import org.testng.annotations.Test;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+
+
+public class AccessControlUserCacheTest {
+  @Test
+  public void testControllerUserLookup() throws Exception {
+    UserConfig userConfig = new UserConfig("controllerUser", "password", 
ComponentType.CONTROLLER.name(),
+        RoleType.USER.name(), List.of("table"), null, List.of());
+    UserConfig whitespaceUserConfig = new UserConfig(" whitespaceUser ", 
"password",
+        ComponentType.CONTROLLER.name(), RoleType.USER.name(), 
List.of("otherTable"), null, List.of());
+    UserConfig shadowUserConfig = new UserConfig(" controllerUser ", 
"shadowPassword",
+        ComponentType.CONTROLLER.name(), RoleType.USER.name(), 
List.of("shadowTable"), null, List.of());
+    AccessControlUserCache cache = createCache(List.of(userConfig, 
whitespaceUserConfig, shadowUserConfig));
+
+    UserConfig cachedUser = 
cache.getControllerUserConfigForUsername("controllerUser");
+    assertEquals(cachedUser.getUserName(), "controllerUser");
+    assertEquals(cachedUser.getTables(), List.of("table"));
+    UserConfig whitespaceCachedUser = 
cache.getControllerUserConfigForUsername("whitespaceUser");
+    assertEquals(whitespaceCachedUser.getUserName(), " whitespaceUser ");
+    assertEquals(whitespaceCachedUser.getTables(), List.of("otherTable"));
+    assertNull(cache.getControllerUserConfigForUsername(" whitespaceUser "));
+    assertNull(cache.getControllerUserConfigForUsername("missing"));
+  }
+
+  @Test
+  public void testAmbiguousTrimmedControllerUserLookupFailsClosed()
+      throws Exception {
+    UserConfig whitespaceUserConfig = new UserConfig(" ambiguousUser ", 
"password",
+        ComponentType.CONTROLLER.name(), RoleType.USER.name(), null, null, 
List.of());
+    UserConfig duplicateWhitespaceUserConfig = new UserConfig("  ambiguousUser 
 ", "otherPassword",
+        ComponentType.CONTROLLER.name(), RoleType.USER.name(), null, null, 
List.of());
+    AccessControlUserCache cache = createCache(List.of(whitespaceUserConfig, 
duplicateWhitespaceUserConfig));
+
+    assertNull(cache.getControllerUserConfigForUsername("ambiguousUser"));
+  }
+
+  private static AccessControlUserCache createCache(List<UserConfig> 
userConfigs)
+      throws Exception {
+    @SuppressWarnings("unchecked")
+    ZkHelixPropertyStore<ZNRecord> propertyStore = 
mock(ZkHelixPropertyStore.class);
+    List<String> usernamesWithComponent =
+        
userConfigs.stream().map(UserConfig::getUsernameWithComponent).collect(Collectors.toList());
+    List<String> paths = usernamesWithComponent.stream().map(username -> 
"/CONFIGS/USER/" + username)
+        .collect(Collectors.toList());
+    List<ZNRecord> userRecords = userConfigs.stream().map(userConfig -> {
+      try {
+        return AccessControlUserConfigUtils.toZNRecord(userConfig);
+      } catch (Exception e) {
+        throw new RuntimeException(e);
+      }
+    }).collect(Collectors.toList());
+    when(propertyStore.getChildNames("/CONFIGS/USER", 
AccessOption.PERSISTENT)).thenReturn(usernamesWithComponent);
+    when(propertyStore.get(eq(paths), isNull(), eq(AccessOption.PERSISTENT), 
eq(false))).thenReturn(userRecords);
+    return new AccessControlUserCache(propertyStore);
+  }
+}
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 fb6bb13fb83..015a97d59b3 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
@@ -39,8 +39,10 @@ import javax.ws.rs.core.MultivaluedMap;
 import javax.ws.rs.core.UriInfo;
 import org.apache.pinot.common.auth.AuthProviderUtils;
 import org.apache.pinot.common.utils.DatabaseUtils;
+import org.apache.pinot.core.auth.Authorize;
 import org.apache.pinot.core.auth.FineGrainedAuthUtils;
 import org.apache.pinot.core.auth.ManualAuthorization;
+import org.apache.pinot.core.auth.TargetType;
 import org.glassfish.grizzly.http.server.Request;
 
 
@@ -96,8 +98,10 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
     //     - "tableName",
     //     - "tableNameWithType", or
     //     - "schemaName"
-    // If table name is not available, it means the endpoint is not a 
table-level endpoint.
-    String tableName = extractTableName(uriInfo.getPathParameters(), 
uriInfo.getQueryParameters());
+    // A declared table target can identify a custom parameter name. For 
cluster-targeted annotations, retain the
+    // parameter-name heuristics because several legacy table-scoped endpoints 
use cluster actions for fine-grained
+    // 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 table name is present, translate it to the fully qualified name 
based on database header.
       tableName = DatabaseUtils.translateTableName(tableName, _httpHeaders);
@@ -126,6 +130,16 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
     return AccessType.READ;
   }
 
+  @VisibleForTesting
+  static String extractTableName(Method endpointMethod, MultivaluedMap<String, 
String> pathParameters,
+      MultivaluedMap<String, String> queryParameters) {
+    Authorize authorize = endpointMethod.getAnnotation(Authorize.class);
+    if (authorize != null && authorize.targetType() == TargetType.TABLE) {
+      return FineGrainedAuthUtils.findRawTargetId(authorize, pathParameters, 
queryParameters);
+    }
+    return extractTableName(pathParameters, queryParameters);
+  }
+
   @VisibleForTesting
   static String extractTableName(MultivaluedMap<String, String> pathParameters,
       MultivaluedMap<String, String> queryParameters) {
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BaseBasicAuthAccessControl.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BaseBasicAuthAccessControl.java
new file mode 100644
index 00000000000..e411ffd8a0e
--- /dev/null
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BaseBasicAuthAccessControl.java
@@ -0,0 +1,78 @@
+/**
+ * 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.access;
+
+import java.util.Objects;
+import java.util.Optional;
+import javax.ws.rs.NotAuthorizedException;
+import javax.ws.rs.core.HttpHeaders;
+import org.apache.pinot.core.auth.BasicAuthPrincipal;
+import org.apache.pinot.core.auth.TargetType;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+
+
+/// Shared controller BasicAuth policy, independent of how principals are 
loaded and credentials are verified.
+/// This class is stateless and thread-safe; subclass principal resolution 
must also be thread-safe.
+abstract class BaseBasicAuthAccessControl<P extends BasicAuthPrincipal> 
implements AccessControl {
+  @Override
+  public final boolean protectAnnotatedOnly() {
+    return false;
+  }
+
+  @Override
+  public final boolean hasAccess(String tableName, AccessType accessType, 
HttpHeaders httpHeaders,
+      String endpointUrl) {
+    Optional<P> principal = getPrincipal(httpHeaders);
+    if (principal.isEmpty()) {
+      throw new NotAuthorizedException("Basic");
+    }
+    String rawTableName = TableNameBuilder.extractRawTableName(tableName);
+    P authenticatedPrincipal = principal.get();
+    return authenticatedPrincipal.hasTable(rawTableName)
+        && authenticatedPrincipal.hasPermission(Objects.toString(accessType));
+  }
+
+  @Override
+  public final boolean hasAccess(AccessType accessType, HttpHeaders 
httpHeaders, String endpointUrl) {
+    Optional<P> principal = getPrincipal(httpHeaders);
+    if (principal.isEmpty()) {
+      throw new NotAuthorizedException("Basic");
+    }
+    return principal.get().hasPermission(Objects.toString(accessType));
+  }
+
+  @Override
+  public final boolean hasAccess(HttpHeaders httpHeaders, TargetType 
targetType, String targetId, String action) {
+    // Basic auth permissions are CRUD access types, not action names. 
AuthenticationFilter enforces the resolved
+    // AccessType before invoking this fine-grained check, so this overload 
must only prevent unauthenticated access.
+    return getPrincipal(httpHeaders).isPresent();
+  }
+
+  @Override
+  public final boolean hasAccess(HttpHeaders httpHeaders, TargetType 
targetType) {
+    return getPrincipal(httpHeaders).isPresent();
+  }
+
+  @Override
+  public final AuthWorkflowInfo getAuthWorkflowInfo() {
+    return new AuthWorkflowInfo(AccessControl.WORKFLOW_BASIC);
+  }
+
+  protected abstract Optional<P> getPrincipal(HttpHeaders headers);
+}
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactory.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactory.java
index b973c29c1e4..3bebb1e1641 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactory.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactory.java
@@ -24,12 +24,10 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
-import javax.ws.rs.NotAuthorizedException;
 import javax.ws.rs.core.HttpHeaders;
 import org.apache.pinot.common.auth.BasicAuthTokenUtils;
 import org.apache.pinot.core.auth.BasicAuthPrincipal;
 import org.apache.pinot.core.auth.BasicAuthPrincipalUtils;
-import org.apache.pinot.core.auth.TargetType;
 import org.apache.pinot.spi.env.PinotConfiguration;
 
 
@@ -63,7 +61,7 @@ public class BasicAuthAccessControlFactory implements 
AccessControlFactory {
   }
 
   /// Access Control using header-based basic http authentication
-  private static class BasicAuthAccessControl implements AccessControl {
+  private static class BasicAuthAccessControl extends 
BaseBasicAuthAccessControl<BasicAuthPrincipal> {
     private final Map<String, BasicAuthPrincipal> _token2principal;
 
     public BasicAuthAccessControl(Collection<BasicAuthPrincipal> principals) {
@@ -71,30 +69,7 @@ public class BasicAuthAccessControlFactory implements 
AccessControlFactory {
     }
 
     @Override
-    public boolean protectAnnotatedOnly() {
-      return false;
-    }
-
-    @Override
-    public boolean hasAccess(String tableName, AccessType accessType, 
HttpHeaders httpHeaders, String endpointUrl) {
-      return getPrincipal(httpHeaders)
-          .filter(p -> p.hasTable(tableName) && 
p.hasPermission(Objects.toString(accessType))).isPresent();
-    }
-
-    @Override
-    public boolean hasAccess(AccessType accessType, HttpHeaders httpHeaders, 
String endpointUrl) {
-      if (getPrincipal(httpHeaders).isEmpty()) {
-        throw new NotAuthorizedException("Basic");
-      }
-      return true;
-    }
-
-    @Override
-    public boolean hasAccess(HttpHeaders httpHeaders, TargetType targetType) {
-      return getPrincipal(httpHeaders).isPresent();
-    }
-
-    private Optional<BasicAuthPrincipal> getPrincipal(HttpHeaders headers) {
+    protected Optional<BasicAuthPrincipal> getPrincipal(HttpHeaders headers) {
       if (headers == null) {
         return Optional.empty();
       }
@@ -108,10 +83,5 @@ public class BasicAuthAccessControlFactory implements 
AccessControlFactory {
           .map(_token2principal::get)
           .filter(Objects::nonNull).findFirst();
     }
-
-    @Override
-    public AuthWorkflowInfo getAuthWorkflowInfo() {
-      return new AuthWorkflowInfo(AccessControl.WORKFLOW_BASIC);
-    }
   }
 }
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/ZkBasicAuthAccessControlFactory.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/ZkBasicAuthAccessControlFactory.java
index 5175724d568..bea6e0e8fcd 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/ZkBasicAuthAccessControlFactory.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/ZkBasicAuthAccessControlFactory.java
@@ -20,10 +20,7 @@ package org.apache.pinot.controller.api.access;
 
 import java.io.IOException;
 import java.util.List;
-import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
-import java.util.stream.Collectors;
 import javax.ws.rs.core.HttpHeaders;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.common.auth.BasicAuthTokenUtils;
@@ -32,10 +29,11 @@ import org.apache.pinot.common.utils.BcryptUtils;
 import org.apache.pinot.controller.ControllerConf;
 import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
 import org.apache.pinot.core.auth.BasicAuthPrincipalUtils;
-import org.apache.pinot.core.auth.TargetType;
 import org.apache.pinot.core.auth.ZkBasicAuthPrincipal;
+import org.apache.pinot.spi.config.user.UserConfig;
 import org.apache.pinot.spi.env.PinotConfiguration;
-import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 
 /// Zookeeper Basic Authentication based on Pinot Controller UI.
@@ -45,6 +43,7 @@ import org.apache.pinot.spi.utils.builder.TableNameBuilder;
 /// and these changes happen immediately.
 /// Users Configuration store in Helix Zookeeper and encrypted user password 
via Bcrypt Encryption Algorithm.
 public class ZkBasicAuthAccessControlFactory implements AccessControlFactory {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(ZkBasicAuthAccessControlFactory.class);
   private static final String HEADER_AUTHORIZATION = "Authorization";
 
   private AccessControl _accessControl;
@@ -63,8 +62,7 @@ public class ZkBasicAuthAccessControlFactory implements 
AccessControlFactory {
   }
 
   /// Access Control using header-based basic http authentication
-  private static class BasicAuthAccessControl implements AccessControl {
-    private Map<String, ZkBasicAuthPrincipal> _name2principal;
+  private static class BasicAuthAccessControl extends 
BaseBasicAuthAccessControl<ZkBasicAuthPrincipal> {
     private final AccessControlUserCache _userCache;
 
     public BasicAuthAccessControl(AccessControlUserCache userCache) {
@@ -72,49 +70,42 @@ public class ZkBasicAuthAccessControlFactory implements 
AccessControlFactory {
     }
 
     @Override
-    public boolean protectAnnotatedOnly() {
-      return false;
-    }
-
-    @Override
-    public boolean hasAccess(String tableName, AccessType accessType, 
HttpHeaders httpHeaders, String endpointUrl) {
-      return getPrincipal(httpHeaders).filter(
-          p -> p.hasTable(TableNameBuilder.extractRawTableName(tableName))
-              && p.hasPermission(Objects.toString(accessType))).isPresent();
-    }
-
-    @Override
-    public boolean hasAccess(HttpHeaders httpHeaders, TargetType targetType) {
-      return getPrincipal(httpHeaders).isPresent();
-    }
-
-    @Override
-    public boolean hasAccess(AccessType accessType, HttpHeaders httpHeaders, 
String endpointUrl) {
-      return getPrincipal(httpHeaders).isPresent();
-    }
-
-    private Optional<ZkBasicAuthPrincipal> getPrincipal(HttpHeaders headers) {
+    protected Optional<ZkBasicAuthPrincipal> getPrincipal(HttpHeaders headers) 
{
       if (headers == null) {
         return Optional.empty();
       }
 
-      _name2principal = 
BasicAuthPrincipalUtils.extractBasicAuthPrincipals(_userCache.getAllControllerUserConfig())
-          .stream().collect(Collectors.toMap(ZkBasicAuthPrincipal::getName, p 
-> p));
-
       List<String> authHeaders = 
headers.getRequestHeader(HEADER_AUTHORIZATION);
       if (authHeaders == null) {
         return Optional.empty();
       }
 
       for (String authHeader : authHeaders) {
-        String username = BasicAuthTokenUtils.extractUsername(authHeader);
-        String password = BasicAuthTokenUtils.extractPassword(authHeader);
+        String username;
+        String password;
+        try {
+          username = BasicAuthTokenUtils.extractUsername(authHeader);
+          password = BasicAuthTokenUtils.extractPassword(authHeader);
+        } catch (RuntimeException e) {
+          continue;
+        }
         if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
           continue;
         }
 
-        ZkBasicAuthPrincipal principal = _name2principal.get(username);
-        if (principal == null) {
+        UserConfig userConfig = 
_userCache.getControllerUserConfigForUsername(username);
+        if (userConfig == null) {
+          continue;
+        }
+
+        ZkBasicAuthPrincipal principal;
+        try {
+          principal = 
BasicAuthPrincipalUtils.extractBasicAuthPrincipals(List.of(userConfig)).get(0);
+        } catch (RuntimeException e) {
+          // The cached user config is server-side state. Surface corrupt 
records without logging usernames, passwords,
+          // authorization headers, or serialized user configs.
+          LOGGER.warn("Failed to construct a BasicAuth principal from a cached 
controller user config due to {}",
+              e.getClass().getSimpleName());
           continue;
         }
 
@@ -131,10 +122,5 @@ public class ZkBasicAuthAccessControlFactory implements 
AccessControlFactory {
           principal.getPassword(),
           _userCache.getUserPasswordAuthCache());
     }
-
-    @Override
-    public AuthWorkflowInfo getAuthWorkflowInfo() {
-      return new AuthWorkflowInfo(AccessControl.WORKFLOW_BASIC);
-    }
   }
 }
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/LLCSegmentCompletionHandlers.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/LLCSegmentCompletionHandlers.java
index 7d5c250d007..4ebd5fa7de6 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/LLCSegmentCompletionHandlers.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/LLCSegmentCompletionHandlers.java
@@ -83,7 +83,8 @@ public class LLCSegmentCompletionHandlers {
   // We don't want to document these in swagger since they are internal APIs
   @GET
   @Path(SegmentCompletionProtocol.MSG_TYPE_EXTEND_BUILD_TIME)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_ADMIN_INFO)
+  @Authenticate(AccessType.CREATE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.COMMIT_SEGMENT)
   @Produces(MediaType.APPLICATION_JSON)
   public String 
extendBuildTime(@QueryParam(SegmentCompletionProtocol.PARAM_INSTANCE_ID) String 
instanceId,
       @QueryParam(SegmentCompletionProtocol.PARAM_SEGMENT_NAME) String 
segmentName,
@@ -114,7 +115,8 @@ public class LLCSegmentCompletionHandlers {
 
   @GET
   @Path(SegmentCompletionProtocol.MSG_TYPE_CONSUMED)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_ADMIN_INFO)
+  @Authenticate(AccessType.CREATE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.COMMIT_SEGMENT)
   @Produces(MediaType.APPLICATION_JSON)
   public String 
segmentConsumed(@QueryParam(SegmentCompletionProtocol.PARAM_INSTANCE_ID) String 
instanceId,
       @QueryParam(SegmentCompletionProtocol.PARAM_SEGMENT_NAME) String 
segmentName,
@@ -144,7 +146,8 @@ public class LLCSegmentCompletionHandlers {
 
   @GET
   @Path(SegmentCompletionProtocol.MSG_TYPE_STOPPED_CONSUMING)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_ADMIN_INFO)
+  @Authenticate(AccessType.CREATE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.COMMIT_SEGMENT)
   @Produces(MediaType.APPLICATION_JSON)
   public String 
segmentStoppedConsuming(@QueryParam(SegmentCompletionProtocol.PARAM_INSTANCE_ID)
 String instanceId,
       @QueryParam(SegmentCompletionProtocol.PARAM_SEGMENT_NAME) String 
segmentName,
@@ -170,7 +173,8 @@ public class LLCSegmentCompletionHandlers {
 
   @GET
   @Path(SegmentCompletionProtocol.MSG_TYPE_COMMIT_START)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_ADMIN_INFO)
+  @Authenticate(AccessType.CREATE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.COMMIT_SEGMENT)
   @Produces(MediaType.APPLICATION_JSON)
   public String 
segmentCommitStart(@QueryParam(SegmentCompletionProtocol.PARAM_INSTANCE_ID) 
String instanceId,
       @QueryParam(SegmentCompletionProtocol.PARAM_SEGMENT_NAME) String 
segmentName,
@@ -309,7 +313,8 @@ public class LLCSegmentCompletionHandlers {
 
   @GET
   @Path(SegmentCompletionProtocol.MSG_TYPE_BUILD_DETERMINISTIC_FAILURE)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_ADMIN_INFO)
+  @Authenticate(AccessType.CREATE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.COMMIT_SEGMENT)
   @Produces(MediaType.APPLICATION_JSON)
   public String 
reduceSegmentSize(@QueryParam(SegmentCompletionProtocol.PARAM_INSTANCE_ID) 
String instanceId,
       @QueryParam(SegmentCompletionProtocol.PARAM_SEGMENT_NAME) String 
segmentName,
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotBrokerRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotBrokerRestletResource.java
index 23b0137540b..1d92c6f7626 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotBrokerRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotBrokerRestletResource.java
@@ -211,7 +211,7 @@ public class PinotBrokerRestletResource {
   @GET
   @Produces(MediaType.APPLICATION_JSON)
   @Path("/v2/brokers/tables/{tableName}")
-  @Authorize(targetType = TargetType.CLUSTER, paramName = "tableName", action 
= Actions.Table.GET_BROKER)
+  @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action = 
Actions.Table.GET_BROKER)
   @ApiOperation(value = "List brokers for a given table", notes = "List 
brokers for a given table")
   public List<InstanceInfo> getBrokersForTableV2(
       @ApiParam(value = "Name of the table", required = true) 
@PathParam("tableName") String tableName,
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResource.java
index 2ba1140c57d..fc6fb0eee2c 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResource.java
@@ -30,6 +30,7 @@ import io.swagger.annotations.SwaggerDefinition;
 import javax.inject.Inject;
 import javax.ws.rs.DefaultValue;
 import javax.ws.rs.GET;
+import javax.ws.rs.NotAuthorizedException;
 import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
 import javax.ws.rs.QueryParam;
@@ -80,7 +81,12 @@ public class PinotControllerAuthResource {
       @ApiParam(value = "API access type") @DefaultValue("READ") 
@QueryParam("accessType") AccessType accessType,
       @ApiParam(value = "Endpoint URL") @QueryParam("endpointUrl") String 
endpointUrl) {
     AccessControl accessControl = _accessControlFactory.create();
-    return accessControl.hasAccess(tableName, accessType, _httpHeaders, 
endpointUrl);
+    try {
+      return accessControl.hasAccess(tableName, accessType, _httpHeaders, 
endpointUrl);
+    } catch (NotAuthorizedException e) {
+      // Preserve the deprecated probe's boolean contract while protected 
endpoints return 401 for invalid credentials.
+      return false;
+    }
   }
 
   /// Verify a token is both authenticated and authorized to perform an 
operation.
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerLogger.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerLogger.java
index 922c33a1563..3d535557bdf 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerLogger.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerLogger.java
@@ -141,7 +141,7 @@ public class PinotControllerLogger {
   @Path("/loggers/download")
   @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_LOG_FILE)
   @Produces(MediaType.APPLICATION_OCTET_STREAM)
-  @Authenticate(AccessType.DELETE)
+  @Authenticate(AccessType.READ)
   @ApiOperation(value = "Download a log file")
   public Response downloadLogFile(
       @ApiParam(value = "Log file path", required = true) 
@QueryParam("filePath") String filePath) {
@@ -207,7 +207,7 @@ public class PinotControllerLogger {
   @Path("/loggers/instances/{instanceName}/download")
   @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_LOG_FILE)
   @Produces(MediaType.APPLICATION_OCTET_STREAM)
-  @Authenticate(AccessType.DELETE)
+  @Authenticate(AccessType.READ)
   @ApiOperation(value = "Download a log file from a given instance")
   public Response downloadLogFileFromInstance(
       @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization,
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerPeriodicTaskRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerPeriodicTaskRestletResource.java
index cb8dc9c11f3..ed4964227bd 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerPeriodicTaskRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotControllerPeriodicTaskRestletResource.java
@@ -76,6 +76,7 @@ public class PinotControllerPeriodicTaskRestletResource {
   @GET
   @Produces(MediaType.APPLICATION_JSON)
   @Path("/run")
+  @Authenticate(AccessType.UPDATE)
   @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.EXECUTE_TASK)
   @ApiOperation(value = "Run periodic task against table. If table name is 
missing, task will run against all tables.")
   public Response runPeriodicTask(
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
index 74e4a919e99..797ec8bb926 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotInstanceRestletResource.java
@@ -559,6 +559,7 @@ public class PinotInstanceRestletResource {
   @POST
   @Path("/instances/updateTags/validate")
   @Produces(MediaType.APPLICATION_JSON)
+  @Authenticate(AccessType.READ)
   @ApiOperation(value = "Check if it's safe to update the tags of the given 
instances. If not list all the reasons.")
   @ApiResponses(value = {
       @ApiResponse(code = 200, message = "Success"),
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
index 47f9675fefa..ee2a6bd5a1c 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotQueryResource.java
@@ -74,6 +74,7 @@ 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.api.access.Authenticate;
 import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
 import org.apache.pinot.core.auth.Actions;
 import org.apache.pinot.core.auth.ManualAuthorization;
@@ -199,6 +200,7 @@ public class PinotQueryResource {
 
   @POST
   @Path("validateMultiStageQuery")
+  @Authenticate(AccessType.READ)
   public List<MultiStageQueryValidationResponse> 
validateMultiStageQuery(MultiStageQueryValidationRequest request,
       @Context HttpHeaders httpHeaders) {
 
@@ -255,6 +257,7 @@ public class PinotQueryResource {
   @Path("query/tableNames")
   @Consumes(MediaType.APPLICATION_JSON)
   @Produces(MediaType.APPLICATION_JSON)
+  @Authenticate(AccessType.READ)
   @ApiOperation(value = "Extract table names from SQL queries")
   public Set<String> extractTableNames(List<String> sqlQueries, @Context 
HttpHeaders httpHeaders) {
 
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
index aff800e9b4c..4b01d327ba8 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
@@ -1098,7 +1098,7 @@ public class PinotTableRestletResource {
 
   @GET
   @Produces(MediaType.APPLICATION_JSON)
-  @Authenticate(AccessType.UPDATE)
+  @Authenticate(AccessType.READ)
   @Path("/rebalanceStatus/{jobId}")
   @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_REBALANCE_STATUS)
   @ApiOperation(value = "Gets detailed stats of a rebalance operation",
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/AuthenticationFilterTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/AuthenticationFilterTest.java
index f3a01528b80..a17d2ef3e23 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/AuthenticationFilterTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/AuthenticationFilterTest.java
@@ -20,13 +20,25 @@
 package org.apache.pinot.controller.api.access;
 
 import java.lang.reflect.Method;
+import java.util.Arrays;
 import javax.ws.rs.DELETE;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
 import javax.ws.rs.PUT;
+import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedHashMap;
 import javax.ws.rs.core.MultivaluedMap;
 import org.apache.pinot.common.auth.AuthProviderUtils;
+import org.apache.pinot.controller.api.resources.LLCSegmentCompletionHandlers;
+import org.apache.pinot.controller.api.resources.PinotBrokerRestletResource;
+import org.apache.pinot.controller.api.resources.PinotControllerLogger;
+import 
org.apache.pinot.controller.api.resources.PinotControllerPeriodicTaskRestletResource;
+import org.apache.pinot.controller.api.resources.PinotInstanceRestletResource;
+import org.apache.pinot.controller.api.resources.PinotQueryResource;
+import org.apache.pinot.controller.api.resources.PinotTableRestletResource;
+import org.apache.pinot.core.auth.Actions;
+import org.apache.pinot.core.auth.Authorize;
+import org.apache.pinot.core.auth.TargetType;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
@@ -106,6 +118,32 @@ public class AuthenticationFilterTest {
     assertNull(AuthenticationFilter.extractTableName(pathParams, queryParams));
   }
 
+  @Test
+  public void testAuthorizeTargetPreservesCoarseTableScope() throws Exception {
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    queryParams.putSingle("tableName", "A");
+
+    Method clusterMethod = 
AuthenticationFilterTest.class.getMethod("methodWithClusterAuthorization");
+    assertEquals(AuthenticationFilter.extractTableName(clusterMethod, 
pathParams, queryParams), "A");
+
+    queryParams.clear();
+    assertNull(AuthenticationFilter.extractTableName(clusterMethod, 
pathParams, queryParams));
+
+    queryParams.putSingle("tableName", "A");
+
+    Method tableMethod = 
AuthenticationFilterTest.class.getMethod("methodWithTableAuthorization");
+    assertEquals(AuthenticationFilter.extractTableName(tableMethod, 
pathParams, queryParams), "A");
+
+    pathParams.putSingle("materializedViewTableName", "B");
+    Method customTableParamMethod =
+        
AuthenticationFilterTest.class.getMethod("methodWithCustomTableParamAuthorization");
+    assertEquals(AuthenticationFilter.extractTableName(customTableParamMethod, 
pathParams, queryParams), "B");
+
+    pathParams.remove("materializedViewTableName");
+    assertNull(AuthenticationFilter.extractTableName(customTableParamMethod, 
pathParams, queryParams));
+  }
+
   @Test
   public void testExtractAccessTypeWithAuthAnnotation() throws Exception {
     Method method = 
AuthenticationFilterTest.class.getMethod("methodWithAuthAnnotation");
@@ -124,6 +162,51 @@ public class AuthenticationFilterTest {
     assertEquals(AccessType.DELETE, _authFilter.extractAccessType(method));
   }
 
+  @Test
+  public void testMutatingGetEndpointDeclaresUpdateAccess() throws Exception {
+    Method method = 
PinotControllerPeriodicTaskRestletResource.class.getMethod("runPeriodicTask", 
String.class,
+        String.class, String.class, HttpHeaders.class);
+    assertEquals(_authFilter.extractAccessType(method), AccessType.UPDATE);
+  }
+
+  @Test
+  public void testReadOnlyEndpointsDeclareReadAccess() {
+    assertReadAccess(PinotQueryResource.class, "validateMultiStageQuery", 
"extractTableNames");
+    assertReadAccess(PinotInstanceRestletResource.class, 
"instanceTagUpdateSafetyCheck");
+    assertReadAccess(PinotTableRestletResource.class, "rebalanceStatus");
+    assertReadAccess(PinotControllerLogger.class, "downloadLogFile", 
"downloadLogFileFromInstance");
+  }
+
+  @Test
+  public void testSegmentCompletionGetEndpointsDeclareCreateAccess() {
+    for (String methodName : new String[]{"extendBuildTime", 
"segmentConsumed", "segmentStoppedConsuming",
+        "segmentCommitStart", "reduceSegmentSize"}) {
+      Method method = 
Arrays.stream(LLCSegmentCompletionHandlers.class.getDeclaredMethods())
+          .filter(candidate -> 
candidate.getName().equals(methodName)).findFirst().orElseThrow();
+      assertEquals(_authFilter.extractAccessType(method), AccessType.CREATE);
+      assertEquals(method.getAnnotation(Authorize.class).action(), 
Actions.Cluster.COMMIT_SEGMENT);
+    }
+  }
+
+  @Test
+  public void testBrokerForTableEndpointDeclaresTableAuthorization() throws 
Exception {
+    Method method = 
PinotBrokerRestletResource.class.getMethod("getBrokersForTableV2", 
String.class, String.class,
+        String.class, HttpHeaders.class);
+    Authorize authorize = method.getAnnotation(Authorize.class);
+    assertEquals(authorize.targetType(), TargetType.TABLE);
+    assertEquals(authorize.paramName(), "tableName");
+    assertEquals(authorize.action(), Actions.Table.GET_BROKER);
+  }
+
+  private void assertReadAccess(Class<?> resourceClass, String... methodNames) 
{
+    for (String methodName : methodNames) {
+      Method method = Arrays.stream(resourceClass.getDeclaredMethods())
+          .filter(candidate -> 
candidate.getName().equals(methodName)).findFirst().orElseThrow();
+      assertEquals(_authFilter.extractAccessType(method), AccessType.READ,
+          resourceClass.getSimpleName() + "." + methodName);
+    }
+  }
+
   // DataProvider supplying test cases
   @DataProvider(name = "pathProvider")
   public Object[][] pathProvider() {
@@ -160,4 +243,17 @@ public class AuthenticationFilterTest {
   @DELETE
   public void methodWithDelete() {
   }
+
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_CLUSTER_CONFIG)
+  public void methodWithClusterAuthorization() {
+  }
+
+  @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action = 
Actions.Table.GET_TABLE_CONFIG)
+  public void methodWithTableAuthorization() {
+  }
+
+  @Authorize(targetType = TargetType.TABLE, paramName = 
"materializedViewTableName",
+      action = Actions.Table.GET_TABLE_CONFIG)
+  public void methodWithCustomTableParamAuthorization() {
+  }
 }
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactoryTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactoryTest.java
new file mode 100644
index 00000000000..3d9dcc9337d
--- /dev/null
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/BasicAuthAccessControlFactoryTest.java
@@ -0,0 +1,247 @@
+/**
+ * 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.access;
+
+import java.util.List;
+import java.util.Map;
+import javax.ws.rs.NotAuthorizedException;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
+import org.apache.helix.AccessOption;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.auth.BasicAuthTokenUtils;
+import org.apache.pinot.common.utils.BcryptUtils;
+import org.apache.pinot.common.utils.config.AccessControlUserConfigUtils;
+import org.apache.pinot.controller.ControllerConf;
+import 
org.apache.pinot.controller.api.exception.ControllerApplicationException;
+import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
+import org.apache.pinot.core.auth.Actions;
+import org.apache.pinot.core.auth.TargetType;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+import org.apache.pinot.spi.config.user.UserConfig;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.mockito.Mockito;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Verifies the shared authorization contract for static and ZooKeeper-backed 
controller BasicAuth.
+public class BasicAuthAccessControlFactoryTest {
+  private static final String ALLOWED_TABLE = "allowedTable";
+  private static final String RESTRICTED_USER = "restricted";
+  private static final String RESTRICTED_PASSWORD = "restrictedPassword";
+  private static final String FULL_USER = "full";
+  private static final String FULL_PASSWORD = "fullPassword";
+  private static final String WILDCARD_USER = "wildcard";
+  private static final String WILDCARD_PASSWORD = "wildcardPassword";
+  private static final String CORRUPT_USER = "corrupt";
+  private static final String ENDPOINT_URL = "/cluster/configs";
+
+  @DataProvider(name = "accessControls")
+  public Object[][] accessControls()
+      throws Exception {
+    return new Object[][]{{createStaticAccessControl()}, 
{createZkAccessControl()}};
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testClusterPermissions(AccessControl accessControl) {
+    HttpHeaders restrictedHeaders = headers(RESTRICTED_USER, 
RESTRICTED_PASSWORD);
+    assertTrue(accessControl.hasAccess(AccessType.READ, restrictedHeaders, 
ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess(AccessType.CREATE, restrictedHeaders, 
ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess(AccessType.UPDATE, restrictedHeaders, 
ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess(AccessType.DELETE, restrictedHeaders, 
ENDPOINT_URL));
+
+    HttpHeaders fullHeaders = headers(FULL_USER, FULL_PASSWORD);
+    for (AccessType accessType : AccessType.values()) {
+      assertTrue(accessControl.hasAccess(accessType, fullHeaders, 
ENDPOINT_URL));
+    }
+
+    // Missing permissions have historically meant unrestricted access. 
Preserve that behavior for compatibility.
+    HttpHeaders wildcardHeaders = headers(WILDCARD_USER, WILDCARD_PASSWORD);
+    for (AccessType accessType : AccessType.values()) {
+      assertTrue(accessControl.hasAccess(accessType, wildcardHeaders, 
ENDPOINT_URL));
+    }
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testClusterAuthenticationAndAuthorizationStatus(AccessControl 
accessControl) {
+    for (HttpHeaders invalidHeaders : List.of(headers(), headers("unknown", 
"wrong"), malformedHeaders())) {
+      NotAuthorizedException exception = 
expectThrows(NotAuthorizedException.class,
+          () -> AccessControlUtils.validatePermission(null, AccessType.READ, 
invalidHeaders, ENDPOINT_URL,
+              accessControl));
+      assertEquals(exception.getResponse().getStatus(), 
Response.Status.UNAUTHORIZED.getStatusCode());
+    }
+
+    ControllerApplicationException exception = 
expectThrows(ControllerApplicationException.class,
+        () -> AccessControlUtils.validatePermission(null, AccessType.UPDATE,
+            headers(RESTRICTED_USER, RESTRICTED_PASSWORD), ENDPOINT_URL, 
accessControl));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+    assertFalse(exception.getMessage().contains(RESTRICTED_PASSWORD));
+    assertFalse(exception.getMessage().contains(
+        BasicAuthTokenUtils.toBasicAuthToken(RESTRICTED_USER, 
RESTRICTED_PASSWORD)));
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testFineGrainedAuthorizationRequiresAuthentication(AccessControl 
accessControl) {
+    for (HttpHeaders invalidHeaders : List.of(headers(), headers("unknown", 
"wrong"), malformedHeaders())) {
+      assertFalse(accessControl.hasAccess(invalidHeaders, TargetType.CLUSTER, 
null,
+          Actions.Cluster.UPDATE_CLUSTER_CONFIG));
+    }
+    assertTrue(accessControl.hasAccess(headers(RESTRICTED_USER, 
RESTRICTED_PASSWORD), TargetType.CLUSTER, null,
+        Actions.Cluster.UPDATE_CLUSTER_CONFIG));
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testAuthenticationProbeRemainsNonThrowing(AccessControl 
accessControl) {
+    assertFalse(accessControl.hasAccess(headers("unknown", "wrong"), 
TargetType.CLUSTER));
+    assertTrue(accessControl.hasAccess(headers(RESTRICTED_USER, 
RESTRICTED_PASSWORD), TargetType.CLUSTER));
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testBasicAuthWorkflow(AccessControl accessControl) {
+    assertFalse(accessControl.protectAnnotatedOnly());
+    assertEquals(accessControl.getAuthWorkflowInfo().getWorkflow(), 
AccessControl.WORKFLOW_BASIC);
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testTablePermissionsAndTypedNameCompatibility(AccessControl 
accessControl) {
+    HttpHeaders restrictedHeaders = headers(RESTRICTED_USER, 
RESTRICTED_PASSWORD);
+    assertTrue(accessControl.hasAccess(ALLOWED_TABLE, AccessType.READ, 
restrictedHeaders, ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess(ALLOWED_TABLE, AccessType.CREATE, 
restrictedHeaders, ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess(ALLOWED_TABLE, AccessType.UPDATE, 
restrictedHeaders, ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess(ALLOWED_TABLE, AccessType.DELETE, 
restrictedHeaders, ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess("otherTable", AccessType.READ, 
restrictedHeaders, ENDPOINT_URL));
+    assertTrue(accessControl.hasAccess(ALLOWED_TABLE + "_OFFLINE", 
AccessType.READ, restrictedHeaders, ENDPOINT_URL));
+
+    HttpHeaders fullHeaders = headers(FULL_USER, FULL_PASSWORD);
+    for (AccessType accessType : AccessType.values()) {
+      assertTrue(accessControl.hasAccess(ALLOWED_TABLE, accessType, 
fullHeaders, ENDPOINT_URL));
+    }
+  }
+
+  @Test(dataProvider = "accessControls")
+  public void testTableAuthenticationAndAuthorizationStatus(AccessControl 
accessControl) {
+    for (HttpHeaders invalidHeaders : List.of(headers(), headers("unknown", 
"wrong"), malformedHeaders())) {
+      NotAuthorizedException exception = 
expectThrows(NotAuthorizedException.class,
+          () -> AccessControlUtils.validatePermission(ALLOWED_TABLE, 
AccessType.READ, invalidHeaders, ENDPOINT_URL,
+              accessControl));
+      assertEquals(exception.getResponse().getStatus(), 
Response.Status.UNAUTHORIZED.getStatusCode());
+    }
+
+    ControllerApplicationException exception = 
expectThrows(ControllerApplicationException.class,
+        () -> AccessControlUtils.validatePermission("otherTable", 
AccessType.READ,
+            headers(RESTRICTED_USER, RESTRICTED_PASSWORD), ENDPOINT_URL, 
accessControl));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+    assertFalse(exception.getMessage().contains(RESTRICTED_PASSWORD));
+    assertFalse(exception.getMessage().contains(
+        BasicAuthTokenUtils.toBasicAuthToken(RESTRICTED_USER, 
RESTRICTED_PASSWORD)));
+  }
+
+  @Test
+  public void testMalformedCachedZkPrincipalFailsClosed()
+      throws Exception {
+    AccessControl accessControl = createZkAccessControl();
+    String suppliedPassword = "notTheCachedPassword";
+    NotAuthorizedException exception = 
expectThrows(NotAuthorizedException.class,
+        () -> AccessControlUtils.validatePermission(null, AccessType.READ,
+            headers(CORRUPT_USER, suppliedPassword), ENDPOINT_URL, 
accessControl));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.UNAUTHORIZED.getStatusCode());
+    assertFalse(exception.getMessage().contains(CORRUPT_USER));
+    assertFalse(exception.getMessage().contains(suppliedPassword));
+  }
+
+  private static AccessControl createStaticAccessControl() {
+    Map<String, Object> properties = Map.ofEntries(
+        Map.entry("controller.admin.access.control.principals", 
"restricted,full,wildcard"),
+        
Map.entry("controller.admin.access.control.principals.restricted.password", 
RESTRICTED_PASSWORD),
+        
Map.entry("controller.admin.access.control.principals.restricted.tables", 
ALLOWED_TABLE),
+        
Map.entry("controller.admin.access.control.principals.restricted.permissions", 
"read"),
+        Map.entry("controller.admin.access.control.principals.full.password", 
FULL_PASSWORD),
+        
Map.entry("controller.admin.access.control.principals.full.permissions", 
"create,read,update,delete"),
+        
Map.entry("controller.admin.access.control.principals.wildcard.password", 
WILDCARD_PASSWORD));
+    BasicAuthAccessControlFactory factory = new 
BasicAuthAccessControlFactory();
+    factory.init(new PinotConfiguration(properties));
+    return factory.create();
+  }
+
+  private static AccessControl createZkAccessControl()
+      throws Exception {
+    List<UserConfig> users = List.of(
+        user(RESTRICTED_USER, RESTRICTED_PASSWORD, List.of(ALLOWED_TABLE),
+            List.of(org.apache.pinot.spi.config.user.AccessType.READ)),
+        user(FULL_USER, FULL_PASSWORD, null,
+            List.of(org.apache.pinot.spi.config.user.AccessType.values())),
+        user(WILDCARD_USER, WILDCARD_PASSWORD, null, null),
+        new UserConfig(CORRUPT_USER, " ", ComponentType.CONTROLLER.name(), 
RoleType.USER.name(), null, null, null));
+    List<String> userNames = 
users.stream().map(UserConfig::getUsernameWithComponent).toList();
+    List<String> userPaths = userNames.stream().map(name -> "/CONFIGS/USER/" + 
name).toList();
+    List<ZNRecord> userRecords = users.stream().map(user -> {
+      try {
+        return AccessControlUserConfigUtils.toZNRecord(user);
+      } catch (Exception e) {
+        throw new RuntimeException(e);
+      }
+    }).toList();
+
+    @SuppressWarnings("unchecked")
+    ZkHelixPropertyStore<ZNRecord> propertyStore = 
Mockito.mock(ZkHelixPropertyStore.class);
+    Mockito.when(propertyStore.getChildNames("/CONFIGS/USER", 
AccessOption.PERSISTENT)).thenReturn(userNames);
+    Mockito.when(propertyStore.get(Mockito.eq(userPaths), Mockito.isNull(), 
Mockito.eq(AccessOption.PERSISTENT),
+        Mockito.eq(false))).thenReturn(userRecords);
+
+    PinotHelixResourceManager resourceManager = 
Mockito.mock(PinotHelixResourceManager.class);
+    Mockito.when(resourceManager.getPropertyStore()).thenReturn(propertyStore);
+
+    ZkBasicAuthAccessControlFactory factory = new 
ZkBasicAuthAccessControlFactory();
+    factory.init(new ControllerConf(), resourceManager);
+    return factory.create();
+  }
+
+  private static UserConfig user(String username, String password, 
List<String> tables,
+      List<org.apache.pinot.spi.config.user.AccessType> permissions) {
+    return new UserConfig(username, BcryptUtils.encrypt(password), 
ComponentType.CONTROLLER.name(),
+        RoleType.USER.name(), tables, null, permissions);
+  }
+
+  private static HttpHeaders headers(String username, String password) {
+    return headers(BasicAuthTokenUtils.toBasicAuthToken(username, password));
+  }
+
+  private static HttpHeaders headers() {
+    return headers((String) null);
+  }
+
+  private static HttpHeaders malformedHeaders() {
+    return headers("Basic not-base64");
+  }
+
+  private static HttpHeaders headers(String authorization) {
+    HttpHeaders headers = Mockito.mock(HttpHeaders.class);
+    Mockito.when(headers.getRequestHeader(HttpHeaders.AUTHORIZATION))
+        .thenReturn(authorization == null ? null : List.of(authorization));
+    return headers;
+  }
+}
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/ControllerClusterBasicAuthAuthorizationTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/ControllerClusterBasicAuthAuthorizationTest.java
new file mode 100644
index 00000000000..1bca6300fbe
--- /dev/null
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/access/ControllerClusterBasicAuthAuthorizationTest.java
@@ -0,0 +1,254 @@
+/**
+ * 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.access;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import javax.ws.rs.core.HttpHeaders;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.auth.BasicAuthTokenUtils;
+import org.apache.pinot.common.exception.HttpErrorStatusException;
+import org.apache.pinot.controller.ControllerConf;
+import org.apache.pinot.controller.helix.ControllerTest;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+import org.apache.pinot.spi.config.user.UserConfig;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.util.TestUtils;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+
+/// Exercises controller HTTP authorization for cluster-scoped requests with 
both BasicAuth implementations.
+public class ControllerClusterBasicAuthAuthorizationTest extends 
ControllerTest {
+  private static final String ADMIN_USER = "clusterAdmin";
+  private static final String ADMIN_PASSWORD = "clusterAdminPassword";
+  private static final String RESTRICTED_USER = "clusterReader";
+  private static final String RESTRICTED_PASSWORD = "clusterReaderPassword";
+  private static final String CREATE_USER = "clusterCreator";
+  private static final String CREATE_PASSWORD = "clusterCreatorPassword";
+  private static final String UPDATE_USER = "clusterUpdater";
+  private static final String UPDATE_PASSWORD = "clusterUpdaterPassword";
+  private static final String DELETE_USER = "clusterDeleter";
+  private static final String DELETE_PASSWORD = "clusterDeleterPassword";
+  private static final String TABLE_UPDATE_USER = "tableUpdater";
+  private static final String TABLE_UPDATE_PASSWORD = "tableUpdaterPassword";
+  private static final String ALLOWED_TABLE = "allowedTable";
+  private static final String DISALLOWED_TABLE = "disallowedTable";
+
+  @DataProvider(name = "accessControlFactories")
+  public Object[][] accessControlFactories() {
+    return new Object[][]{
+        {BasicAuthAccessControlFactory.class.getName(), false, "static"},
+        {ZkBasicAuthAccessControlFactory.class.getName(), true, "zk"}
+    };
+  }
+
+  @Test(dataProvider = "accessControlFactories")
+  public void testClusterAuthorization(String factoryClass, boolean zkBacked, 
String configKeySuffix)
+      throws Exception {
+    boolean controllerStarted = false;
+    startZk();
+    try {
+      Map<String, Object> controllerConfiguration = 
getDefaultControllerConfiguration();
+      controllerConfiguration.put(ControllerConf.ACCESS_CONTROL_FACTORY_CLASS, 
factoryClass);
+      configurePrincipals(controllerConfiguration, zkBacked);
+      startController(controllerConfiguration);
+      controllerStarted = true;
+
+      Map<String, String> adminHeaders = authHeaders(ADMIN_USER, 
ADMIN_PASSWORD);
+      Map<String, String> restrictedHeaders = authHeaders(RESTRICTED_USER, 
RESTRICTED_PASSWORD);
+      Map<String, String> createHeaders = authHeaders(CREATE_USER, 
CREATE_PASSWORD);
+      Map<String, String> updateHeaders = authHeaders(UPDATE_USER, 
UPDATE_PASSWORD);
+      Map<String, String> deleteHeaders = authHeaders(DELETE_USER, 
DELETE_PASSWORD);
+      Map<String, String> tableUpdateHeaders = authHeaders(TABLE_UPDATE_USER, 
TABLE_UPDATE_PASSWORD);
+      String clusterConfigsUrl = _controllerBaseApiUrl + "/cluster/configs";
+      if (zkBacked) {
+        addZkUsers();
+        TestUtils.waitForCondition(aVoid -> getStatus(clusterConfigsUrl, 
restrictedHeaders) == 200
+                && getStatus(clusterConfigsUrl, createHeaders) == 403
+                && getStatus(clusterConfigsUrl, updateHeaders) == 403
+                && getStatus(clusterConfigsUrl, deleteHeaders) == 403
+                && getStatus(clusterConfigsUrl, tableUpdateHeaders) == 403, 
TIMEOUT_MS,
+            "Controller users were not loaded from ZooKeeper");
+      }
+
+      assertGetStatus(clusterConfigsUrl, authHeaders("unknown", "incorrect"), 
401);
+      assertGetStatus(clusterConfigsUrl, restrictedHeaders, 200);
+      assertTrue(JsonUtils.stringToJsonNode(
+          sendPostRequest(_controllerBaseApiUrl + "/query/tableNames", "[]", 
restrictedHeaders)).isEmpty());
+      assertTrue(JsonUtils.stringToJsonNode(
+          sendPostRequest(_controllerBaseApiUrl + 
"/instances/updateTags/validate", "[]", restrictedHeaders))
+          .isEmpty());
+
+      assertTableScope(restrictedHeaders, tableUpdateHeaders, 
authHeaders("unknown", "incorrect"));
+
+      String configKey = "pinot.controller.auth.test." + configKeySuffix;
+      String zkPath = "/controller-auth-test-" + configKeySuffix;
+      String zkCreateUrl = _controllerBaseApiUrl + "/zk/create?path=" + zkPath;
+      String zkDeleteUrl = _controllerBaseApiUrl + "/zk/delete?path=" + zkPath;
+      assertHttpError(403, () -> sendPostRequest(zkCreateUrl, "{not-json", 
restrictedHeaders));
+      assertHttpError(403, () -> sendPostRequest(clusterConfigsUrl, 
"{not-json", restrictedHeaders));
+      assertHttpError(403, () -> sendDeleteRequest(clusterConfigsUrl + "/" + 
configKey, restrictedHeaders));
+
+      assertHttpError(403, () -> sendPostRequest(zkCreateUrl, "{not-json", 
updateHeaders));
+      assertHttpError(403, () -> sendPostRequest(clusterConfigsUrl, 
"{not-json", createHeaders));
+      assertHttpError(403, () -> sendDeleteRequest(clusterConfigsUrl + "/" + 
configKey, updateHeaders));
+
+      boolean zkCleanupRequired = false;
+      try {
+        sendPostRequest(zkCreateUrl, JsonUtils.objectToString(new 
ZNRecord("controllerAuthTest")), createHeaders);
+        zkCleanupRequired = true;
+        sendDeleteRequest(zkDeleteUrl, deleteHeaders);
+        zkCleanupRequired = false;
+      } finally {
+        if (zkCleanupRequired) {
+          sendDeleteRequest(zkDeleteUrl, adminHeaders);
+        }
+      }
+
+      String configValue = "authorized";
+      boolean cleanupRequired = false;
+      try {
+        sendPostRequest(clusterConfigsUrl, 
JsonUtils.objectToString(Map.of(configKey, configValue)), updateHeaders);
+        cleanupRequired = true;
+        assertEquals(
+            JsonUtils.stringToJsonNode(sendGetRequest(clusterConfigsUrl, 
adminHeaders)).get(configKey).asText(),
+            configValue);
+
+        sendDeleteRequest(clusterConfigsUrl + "/" + configKey, deleteHeaders);
+        cleanupRequired = false;
+        
assertFalse(JsonUtils.stringToJsonNode(sendGetRequest(clusterConfigsUrl, 
adminHeaders)).has(configKey));
+      } finally {
+        if (cleanupRequired) {
+          sendDeleteRequest(clusterConfigsUrl + "/" + configKey, adminHeaders);
+        }
+      }
+    } finally {
+      if (controllerStarted) {
+        stopController();
+      }
+      stopZk();
+    }
+  }
+
+  private static void configurePrincipals(Map<String, Object> 
controllerConfiguration, boolean zkBacked) {
+    if (zkBacked) {
+      controllerConfiguration.put(ControllerConf.ACCESS_CONTROL_USERNAME, 
ADMIN_USER);
+      controllerConfiguration.put(ControllerConf.ACCESS_CONTROL_PASSWORD, 
ADMIN_PASSWORD);
+      return;
+    }
+
+    controllerConfiguration.put("controller.admin.access.control.principals",
+        String.join(",", ADMIN_USER, RESTRICTED_USER, CREATE_USER, 
UPDATE_USER, DELETE_USER, TABLE_UPDATE_USER));
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ ADMIN_USER + ".password",
+        ADMIN_PASSWORD);
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ RESTRICTED_USER + ".password",
+        RESTRICTED_PASSWORD);
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ RESTRICTED_USER + ".tables",
+        ALLOWED_TABLE);
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ RESTRICTED_USER + ".permissions",
+        "read");
+    addStaticPrincipal(controllerConfiguration, CREATE_USER, CREATE_PASSWORD, 
"create");
+    addStaticPrincipal(controllerConfiguration, UPDATE_USER, UPDATE_PASSWORD, 
"update");
+    addStaticPrincipal(controllerConfiguration, DELETE_USER, DELETE_PASSWORD, 
"delete");
+    addStaticPrincipal(controllerConfiguration, TABLE_UPDATE_USER, 
TABLE_UPDATE_PASSWORD, "update");
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ TABLE_UPDATE_USER + ".tables",
+        ALLOWED_TABLE);
+  }
+
+  private static void addStaticPrincipal(Map<String, Object> 
controllerConfiguration, String username,
+      String password, String permission) {
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ username + ".password", password);
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ username + ".permissions",
+        permission);
+  }
+
+  private void addZkUsers()
+      throws IOException {
+    addZkUser(RESTRICTED_USER, RESTRICTED_PASSWORD, List.of(ALLOWED_TABLE),
+        org.apache.pinot.spi.config.user.AccessType.READ);
+    addZkUser(CREATE_USER, CREATE_PASSWORD, null, 
org.apache.pinot.spi.config.user.AccessType.CREATE);
+    addZkUser(UPDATE_USER, UPDATE_PASSWORD, null, 
org.apache.pinot.spi.config.user.AccessType.UPDATE);
+    addZkUser(DELETE_USER, DELETE_PASSWORD, null, 
org.apache.pinot.spi.config.user.AccessType.DELETE);
+    addZkUser(TABLE_UPDATE_USER, TABLE_UPDATE_PASSWORD, List.of(ALLOWED_TABLE),
+        org.apache.pinot.spi.config.user.AccessType.UPDATE);
+  }
+
+  private void assertTableScope(Map<String, String> readHeaders, Map<String, 
String> updateHeaders,
+      Map<String, String> invalidHeaders)
+      throws IOException {
+    String tableConfigUrl = _controllerBaseApiUrl + "/tables/";
+    assertGetStatus(tableConfigUrl + ALLOWED_TABLE, invalidHeaders, 401);
+    assertGetStatus(tableConfigUrl + ALLOWED_TABLE, readHeaders, 404);
+    assertGetStatus(tableConfigUrl + DISALLOWED_TABLE, readHeaders, 403);
+
+    String periodicTaskUrl = _controllerBaseApiUrl + 
"/periodictask/run?taskname=missing&tableName=";
+    assertGetStatus(periodicTaskUrl + ALLOWED_TABLE, updateHeaders, 404);
+    assertGetStatus(periodicTaskUrl + DISALLOWED_TABLE, updateHeaders, 403);
+  }
+
+  private void addZkUser(String username, String password, List<String> tables,
+      org.apache.pinot.spi.config.user.AccessType permission)
+      throws IOException {
+    _helixResourceManager.addUser(new UserConfig(username, password, 
ComponentType.CONTROLLER.name(),
+        RoleType.USER.name(), tables, null, List.of(permission)));
+  }
+
+  private static Map<String, String> authHeaders(String username, String 
password) {
+    return Map.of(HttpHeaders.AUTHORIZATION, 
BasicAuthTokenUtils.toBasicAuthToken(username, password));
+  }
+
+  private static int getStatus(String url, Map<String, String> headers) {
+    try {
+      return sendGetRequestWithStatusCode(url, headers).getLeft();
+    } catch (IOException e) {
+      return -1;
+    }
+  }
+
+  private static void assertGetStatus(String url, Map<String, String> headers, 
int expectedStatus)
+      throws IOException {
+    Pair<Integer, String> response = sendGetRequestWithStatusCode(url, 
headers);
+    assertEquals(response.getLeft().intValue(), expectedStatus);
+  }
+
+  private static void assertHttpError(int expectedStatus, IoRequest request) {
+    try {
+      request.send();
+      fail("Expected HTTP status " + expectedStatus);
+    } catch (IOException e) {
+      assertTrue(e.getCause() instanceof HttpErrorStatusException);
+      assertEquals(((HttpErrorStatusException) e.getCause()).getStatusCode(), 
expectedStatus);
+    }
+  }
+
+  @FunctionalInterface
+  private interface IoRequest {
+    void send()
+        throws IOException;
+  }
+}
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResourceTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResourceTest.java
new file mode 100644
index 00000000000..f0fbd283968
--- /dev/null
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/resources/PinotControllerAuthResourceTest.java
@@ -0,0 +1,72 @@
+/**
+ * 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.resources;
+
+import javax.ws.rs.NotAuthorizedException;
+import javax.ws.rs.core.HttpHeaders;
+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.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertFalse;
+
+
+/// Verifies compatibility behavior for the controller authentication probes.
+public class PinotControllerAuthResourceTest {
+  private static final String TABLE_NAME = "testTable";
+  private static final String ENDPOINT_URL = "/tables/testTable";
+
+  @Mock
+  private AccessControlFactory _accessControlFactory;
+  @Mock
+  private AccessControl _accessControl;
+  @Mock
+  private HttpHeaders _httpHeaders;
+  @InjectMocks
+  private PinotControllerAuthResource _resource;
+
+  private AutoCloseable _mocks;
+
+  @BeforeMethod
+  public void setUp() {
+    _mocks = MockitoAnnotations.openMocks(this);
+  }
+
+  @AfterMethod
+  public void tearDown()
+      throws Exception {
+    _mocks.close();
+  }
+
+  @Test
+  public void testDeprecatedVerifyReturnsFalseForInvalidCredentials() {
+    when(_accessControlFactory.create()).thenReturn(_accessControl);
+    when(_accessControl.hasAccess(TABLE_NAME, AccessType.READ, _httpHeaders, 
ENDPOINT_URL))
+        .thenThrow(new NotAuthorizedException("Basic"));
+
+    assertFalse(_resource.verify(TABLE_NAME, AccessType.READ, ENDPOINT_URL));
+  }
+}
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 011efe08af2..aca98e01fce 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
@@ -20,6 +20,7 @@
 package org.apache.pinot.core.auth;
 
 import java.lang.reflect.Method;
+import javax.annotation.Nullable;
 import javax.ws.rs.WebApplicationException;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedMap;
@@ -54,6 +55,18 @@ public class FineGrainedAuthUtils {
     return name;
   }
 
+  /// Finds the raw target parameter identified by an [Authorize] annotation.
+  ///
+  /// @param auth annotation identifying the authorization target
+  /// @param pathParams request path parameters
+  /// @param queryParams request query parameters
+  /// @return the unnormalized table parameter value, or `null` for a cluster 
target or missing table parameter
+  @Nullable
+  public static String findRawTargetId(Authorize auth, MultivaluedMap<String, 
String> pathParams,
+      MultivaluedMap<String, String> queryParams) {
+    return auth.targetType() == TargetType.TABLE ? findParam(auth.paramName(), 
pathParams, queryParams) : null;
+  }
+
   /// Validate fine-grained authorization for APIs.
   /// There are 2 possible cases:
   /// 1. [Authorize] annotation is present on the method. In this case, do the 
finer grain authorization using the
@@ -85,7 +98,7 @@ public class FineGrainedAuthUtils {
         }
 
         // find the paramName in the path or query params
-        targetId = findParam(auth.paramName(), uriInfo.getPathParameters(), 
uriInfo.getQueryParameters());
+        targetId = findRawTargetId(auth, uriInfo.getPathParameters(), 
uriInfo.getQueryParameters());
 
         if (StringUtils.isEmpty(targetId)) {
           throw new WebApplicationException(
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 c18f30ecf64..1328065b7e6 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
@@ -21,15 +21,39 @@ package org.apache.pinot.core.auth;
 import java.lang.reflect.Method;
 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.mockito.Mockito;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+
 
 public class FineGrainedAuthUtilsTest {
 
+  @Test
+  public void testFindRawTargetId() throws Exception {
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    Authorize tableAuth = 
TestResource.class.getDeclaredMethod("getTable").getAnnotation(Authorize.class);
+    Authorize clusterAuth = 
getAnnotatedMethod().getAnnotation(Authorize.class);
+
+    pathParams.putSingle("tableName", "pathTable");
+    assertEquals(FineGrainedAuthUtils.findRawTargetId(tableAuth, pathParams, 
queryParams), "pathTable");
+
+    pathParams.clear();
+    queryParams.putSingle("tableName", "queryTable");
+    assertEquals(FineGrainedAuthUtils.findRawTargetId(tableAuth, pathParams, 
queryParams), "queryTable");
+    assertNull(FineGrainedAuthUtils.findRawTargetId(clusterAuth, pathParams, 
queryParams));
+
+    queryParams.clear();
+    assertNull(FineGrainedAuthUtils.findRawTargetId(tableAuth, pathParams, 
queryParams));
+  }
+
   @Test
   public void testValidateFineGrainedAuthAllowed() {
     FineGrainedAccessControl ac = Mockito.mock(FineGrainedAccessControl.class);
@@ -82,6 +106,10 @@ public class FineGrainedAuthUtilsTest {
     @Authorize(targetType = TargetType.CLUSTER, action = "getCluster")
     void getCluster() {
     }
+
+    @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action 
= "getTable")
+    void getTable() {
+    }
   }
 
   private Method getAnnotatedMethod() {


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

Reply via email to