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 b5397bb0459 [controller] Require unrestricted table scope for 
cluster-level BasicAuth access (#18975)
b5397bb0459 is described below

commit b5397bb0459d24917636d5e78f860e3fe5d61d96
Author: deepinsight coder <[email protected]>
AuthorDate: Sun Oct 11 23:09:49 2026 -0700

    [controller] Require unrestricted table scope for cluster-level BasicAuth 
access (#18975)
    
    * [controller] Require unrestricted table scope for cluster-level BasicAuth 
access
    
    Fixes #14595
    
    Controller endpoints that address the cluster rather than a table were
    reachable by any authenticated BasicAuth principal holding the matching
    CRUD permission, regardless of the principal's table scope. A user
    confined to a single table could therefore drive cluster-level endpoints
    — the reported case deleted another user through DELETE /users/{name}.
    
    #19234 made these endpoints honor the requested permission, but left
    table scope unchecked, so a table-scoped principal with the permission
    still passed. Two mechanisms are needed, and neither works alone.
    
    First, a request that names no table is cluster-wide, so it now requires
    a principal with neither a table allow-list nor an exclude-list in
    addition to the requested permission. Both the static and the
    ZooKeeper-backed factories inherit this from BaseBasicAuthAccessControl,
    and the table overload routes a null table name to the same check so the
    rule cannot be sidestepped by omitting the table.
    
    Second, that check keys off whether a table name was resolved, and
    resolution also accepted query parameters the endpoint never declared.
    JAX-RS surfaces every query parameter present on the URI, so appending
    ?tableName=<a table the caller owns> to a cluster endpoint made it
    evaluate as a table-scoped request and pass, reproducing the original
    exploit against the new check. Only parameters the endpoint declares as
    @QueryParam may now identify the table. The rule lives in
    FineGrainedAuthUtils so the coarse filter and the fine-grained path share
    it, which also covers the broker's filter. Path parameters are template
    variables of the endpoint's own @Path and stay authoritative. Only
    method-level @QueryParam binding is recognized; @BeanParam and field
    injection are treated as declaring nothing, which denies table scope
    rather than granting it. The per-method lookup is memoized because
    Method#getParameterAnnotations re-parses class-file bytes on every call
    and this runs before authentication.
    
    The fine-grained overload keeps its authenticated-only check: several
    table-scoped endpoints declare a cluster action, and for those the filter
    already resolved the table name and ran the table-scoped check.
    hasAccess(HttpHeaders, TargetType) also stays an authentication check
    because it backs auth/verify/v2, the controller UI's login gate, which
    renders false as "invalid credentials".
    
    Defects surfaced while establishing the invariant, fixed here:
    
    - copyTable declared @Authorize(TargetType.TABLE) with no paramName, so
      it defaulted to "" and POST /tables/{tableName}/copy failed
      authorization with 500 for every caller, admins included. A reflection
      guard now asserts every table-targeted endpoint binds its paramName.
    - getTaskDebugInfo's table filter sat inside a null check on the Helix
      task config, so subtasks whose table could not be determined were
      reported regardless of the requested filter. It now fails closed.
    - The only in-tree example of a custom AccessControl returned
      `tableName == null || allowed.contains(tableName)` — the shape that
      leaves this issue open in a downstream implementation.
    - BasicAuthPrincipal copies its table sets, which now gate cluster access
      and would otherwise widen with a caller's later mutation.
    - AccessControl documents which overload carries the cluster policy; its
      two defaults delegate to each other, so an implementation overriding
      only one and routing null back would recurse.
    
    The static factory logs a startup warning naming principals that are
    table-scoped yet hold a write permission, since those are the service
    identities whose segment-completion and task callbacks will be denied.
    
    Backward-incompatible. Table-scoped principals lose every controller
    endpoint that names no table:
    
    - Cluster-wide listings such as GET /tables and GET /schemas enumerate
      the whole cluster rather than the caller's scope, so they are denied;
      the controller UI's listing pages are unavailable to such a principal.
    - Realtime segment completion and minion task callbacks carry no table
      name, so a table-scoped service token is denied and commit stalls.
    - POST /segments documents tableName as optional, falling back to segment
      metadata; table-scoped principals must now send it.
    - The controller's multi-stage /sql path authorizes cluster-wide by
      design, so table-scoped principals lose it even for their own tables.
    - excludeTables-only principals are treated as scoped. Removing the
      exclusion would re-grant the excluded table, so the migration is a
      separate unrestricted principal rather than a widened scoped one.
    
    Migration: give operators two principals, a table-scoped one for table
    work and an unrestricted one for cluster work, and grant the unrestricted
    one before rolling controllers — during the mixed-version window the same
    request answers 200 or 403 depending on which controller serves it.
    
    Custom AccessControl implementations are affected even though the
    interface is unchanged: the table name they receive is now restricted to
    path parameters and declared @QueryParams, so a request that previously
    arrived on the table overload may now arrive on the cluster overload with
    a null table name.
    
    Scope: controller only. The broker's BasicAuth controls share
    BasicAuthPrincipal and have a comparable gap, left for a follow-up.
    
    * [controller] Make BasicAuth table narrowing explicit
    
    Cluster endpoints retain cluster scope unless their authorization 
annotation explicitly identifies a table parameter. Preserve table-specific 
task, segment, upload, and ingestion operations while denying task-wide debug 
metadata to scoped principals. Restore BasicAuthPrincipal subclass 
compatibility and add focused regressions for explicit scope and task-debug 
denial.
    
    * [controller] Address BasicAuth scope review feedback
    
    ---------
    
    Co-authored-by: Xiang Fu <[email protected]>
---
 helm/pinot/values.yaml                             |  10 +
 .../pinot/controller/api/access/AccessControl.java |  17 +-
 .../api/access/AuthenticationFilter.java           |  57 +++--
 .../api/access/BaseBasicAuthAccessControl.java     |  56 ++++-
 .../api/access/BasicAuthAccessControlFactory.java  |   4 +
 ...PinotControllerPeriodicTaskRestletResource.java |   4 +-
 .../resources/PinotIngestionRestletResource.java   |   6 +-
 .../api/resources/PinotSegmentRestletResource.java |   9 +-
 .../PinotSegmentUploadDownloadRestletResource.java |   4 +-
 .../api/resources/PinotTableRestletResource.java   |   2 +-
 .../api/resources/PinotTaskRestletResource.java    |  14 +-
 .../core/minion/PinotHelixTaskResourceManager.java |  11 +-
 .../api/PinotSegmentUploadAuthorizationTest.java   |   7 +-
 .../api/access/AuthenticationFilterTest.java       | 277 +++++++++++++++++++--
 .../access/BasicAuthAccessControlFactoryTest.java  | 101 +++++++-
 ...ontrollerClusterBasicAuthAuthorizationTest.java | 108 ++++++--
 .../minion/PinotHelixTaskResourceManagerTest.java  |  16 ++
 .../java/org/apache/pinot/core/auth/Authorize.java |   7 +
 .../apache/pinot/core/auth/BasicAuthPrincipal.java |  31 ++-
 .../pinot/core/auth/FineGrainedAuthUtils.java      |  72 ++++--
 .../org/apache/pinot/core/auth/BasicAuthTest.java  |  85 ++++---
 .../pinot/core/auth/FineGrainedAuthUtilsTest.java  | 159 ++++++++----
 22 files changed, 871 insertions(+), 186 deletions(-)

diff --git a/helm/pinot/values.yaml b/helm/pinot/values.yaml
index 735698522ee..64079c99b16 100644
--- a/helm/pinot/values.yaml
+++ b/helm/pinot/values.yaml
@@ -82,6 +82,16 @@ pinotAuth:
   #  - access.control.principals.user.password=secret
   #  - access.control.principals.user.tables=baseballStats,otherstuff
   #  - access.control.principals.user.permissions=READ
+  # Setting `tables` (or `excludeTables`) scopes a principal to those tables 
and, on the controller, withholds every
+  # endpoint that names no table: cluster configs, instances, users, and 
cluster-wide listings such as GET /tables.
+  # That also includes realtime segment-completion callbacks 
(/segmentConsumed, /segmentCommitStart, and the rest of
+  # LLCSegmentCompletionHandlers) and minion task callbacks that carry no 
tableName, so a table-scoped server or
+  # minion token will 403 and stall ingestion. The deprecated GET /auth/verify 
probe, called without tableName, now
+  # answers false for a scoped principal (the UI uses /auth/verify/v2 and is 
unaffected).
+  # `admin` above is unscoped and keeps that access. Give an operator both: a 
scoped principal for table work and an
+  # unscoped one for cluster work. Grant the unscoped principal before rolling 
controllers; during the mixed-version
+  # window the same request answers 200 or 403 depending on which controller 
serves it. Widening `tables` to restore
+  # cluster access would also widen table access.
 
 # 
------------------------------------------------------------------------------
 # Pinot Controller:
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
index b12d3e21835..d80f2655146 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
@@ -33,7 +33,13 @@ public interface AccessControl extends 
FineGrainedAccessControl {
 
   /// Return whether the client has permission to the given table
   ///
-  /// @param tableName name of the table to be accessed
+  /// A `null` table name means the endpoint named no table — either it 
declares no table parameter, or the request
+  /// omitted an optional one — so the request addresses the cluster rather 
than a table. Implementations must apply
+  /// their cluster policy in that case; returning `true` for a `null` table 
name grants every cluster endpoint.
+  /// Note that a caller-supplied query parameter only names the table when 
the endpoint declares it, so a table name
+  /// appended to a cluster endpoint arrives here as `null`.
+  ///
+  /// @param tableName name of the table to be accessed, or `null` when the 
request names no table
   /// @param accessType type of the access
   /// @param httpHeaders HTTP headers containing requester identity
   /// @param endpointUrl the request url for which this access control is 
called
@@ -45,6 +51,15 @@ public interface AccessControl extends 
FineGrainedAccessControl {
 
   /// Return whether the client has permission to access the endpoints with 
are not table level
   ///
+  /// This is the cluster-wide gate. The request named no table, so it may 
reach state belonging to any table: grant it
+  /// only to a principal whose authority spans the whole cluster, never to 
one scoped to a subset of tables. Granting
+  /// it on a narrower scope is what made table-scoped users able to drive 
cluster endpoints in apache/pinot#14595.
+  ///
+  /// This overload is the one to implement the cluster policy in. Its default 
delegates to
+  /// [#hasAccess(String, AccessType, HttpHeaders, String)] with a `null` 
table name, so an implementation that also
+  /// routes a `null` table name back here must override both overloads — 
overriding only one leaves the two defaults
+  /// delegating to each other.
+  ///
   /// @param accessType type of the access
   /// @param httpHeaders HTTP headers
   /// @param endpointUrl the request url for which this access control is 
called
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 f2bca693357..c5f34fa70ef 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
@@ -24,7 +24,9 @@ import java.io.IOException;
 import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
+import javax.annotation.Nullable;
 import javax.inject.Inject;
 import javax.inject.Provider;
 import javax.ws.rs.DELETE;
@@ -58,6 +60,9 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
   private static final String KEY_TABLE_NAME = "tableName";
   private static final String KEY_TABLE_NAME_WITH_TYPE = "tableNameWithType";
   private static final String KEY_SCHEMA_NAME = "schemaName";
+  /// Parameter names that identify the table an endpoint acts on, in the 
order they are consulted.
+  private static final List<String> TABLE_NAME_KEYS =
+      List.of(KEY_TABLE_NAME, KEY_TABLE_NAME_WITH_TYPE, KEY_SCHEMA_NAME);
 
   @Inject
   Provider<Request> _requestProvider;
@@ -140,35 +145,55 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
     return AccessType.READ;
   }
 
+  /// Resolves the table `endpointMethod` acts on, or `null` when the request 
addresses the cluster rather than a
+  /// table. `AccessControlUtils.validatePermission` picks the table-scoped or 
the cluster-wide check on that answer,
+  /// so it must not be steerable by the caller.
+  ///
+  /// Path parameters are template variables of the endpoint's own `@Path`, 
hence part of its declaration. Query
+  /// parameters are caller-supplied and JAX-RS surfaces every one present on 
the URI, so only those the endpoint
+  /// declares may name the table: otherwise a caller could append 
`?tableName=<a table it is scoped to>` to a cluster
+  /// endpoint and have it authorized as a table-scoped request.
+  ///
+  @Nullable
   @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);
+      // Same primitive as FineGrainedAuthUtils.validateFineGrainedAuth: path 
first, then a declared query param.
+      return FineGrainedAuthUtils.findRawTargetId(authorize, endpointMethod, 
pathParameters, queryParameters);
+    }
+    if (authorize != null && authorize.targetType() == TargetType.CLUSTER) {
+      String parameter = authorize.tableScopeParam();
+      if (parameter.isEmpty()) {
+        return null;
+      }
+      String target = pathParameters.getFirst(parameter);
+      if (target != null) {
+        return target;
+      }
+      return 
FineGrainedAuthUtils.getDeclaredQueryParams(endpointMethod).contains(parameter)
+          ? queryParameters.getFirst(parameter) : null;
     }
-    return extractTableName(pathParameters, queryParameters);
-  }
-
-  @VisibleForTesting
-  static String extractTableName(MultivaluedMap<String, String> pathParameters,
-      MultivaluedMap<String, String> queryParameters) {
     String tableName = extractTableName(pathParameters);
     if (tableName != null) {
       return tableName;
     }
-    return extractTableName(queryParameters);
+    for (String key : TABLE_NAME_KEYS) {
+      if (queryParameters.containsKey(key)
+          && 
FineGrainedAuthUtils.getDeclaredQueryParams(endpointMethod).contains(key)) {
+        return queryParameters.getFirst(key);
+      }
+    }
+    return null;
   }
 
+  @Nullable
   private static String extractTableName(MultivaluedMap<String, String> mmap) {
-    if (mmap.containsKey(KEY_TABLE_NAME)) {
-      return mmap.getFirst(KEY_TABLE_NAME);
-    }
-    if (mmap.containsKey(KEY_TABLE_NAME_WITH_TYPE)) {
-      return mmap.getFirst(KEY_TABLE_NAME_WITH_TYPE);
-    }
-    if (mmap.containsKey(KEY_SCHEMA_NAME)) {
-      return mmap.getFirst(KEY_SCHEMA_NAME);
+    for (String key : TABLE_NAME_KEYS) {
+      if (mmap.containsKey(key)) {
+        return mmap.getFirst(key);
+      }
     }
     return null;
   }
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
index e411ffd8a0e..ca902566109 100644
--- 
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
@@ -20,24 +20,42 @@ package org.apache.pinot.controller.api.access;
 
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.annotation.Nullable;
 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;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 
 /// 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.
+/// Thread-safe; its only state is the set of principals already reported in a 
table-scope denial log. Subclass
+/// principal resolution must also be thread-safe.
 abstract class BaseBasicAuthAccessControl<P extends BasicAuthPrincipal> 
implements AccessControl {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(BaseBasicAuthAccessControl.class);
+
+  /// Principal names already reported by [#warnOnceAboutTableScope]; bounded 
by the number of configured principals.
+  private final Set<String> _tableScopeDenialsReported = 
ConcurrentHashMap.newKeySet();
+
   @Override
   public final boolean protectAnnotatedOnly() {
     return false;
   }
 
   @Override
-  public final boolean hasAccess(String tableName, AccessType accessType, 
HttpHeaders httpHeaders,
+  public final boolean hasAccess(@Nullable String tableName, AccessType 
accessType, HttpHeaders httpHeaders,
       String endpointUrl) {
+    // A null table name means the request named no table, which makes it 
cluster-wide however the caller reached this
+    // overload. Route it to the cluster check so the scope rule cannot be 
sidestepped by omitting the table — the
+    // /auth/verify probe takes an optional table name and reaches this 
overload directly. Safe because the cluster
+    // overload below is overridden here rather than left as the interface 
default, which would delegate back.
+    if (tableName == null) {
+      return hasAccess(accessType, httpHeaders, endpointUrl);
+    }
     Optional<P> principal = getPrincipal(httpHeaders);
     if (principal.isEmpty()) {
       throw new NotAuthorizedException("Basic");
@@ -48,22 +66,54 @@ abstract class BaseBasicAuthAccessControl<P extends 
BasicAuthPrincipal> implemen
         && authenticatedPrincipal.hasPermission(Objects.toString(accessType));
   }
 
+  /// Guards endpoints that name no table. Such a request is cluster-wide, so 
beyond the requested permission it
+  /// requires a principal whose table scope is unrestricted: a principal 
confined to a subset of tables must not reach
+  /// cluster state that lies outside that subset. That includes realtime 
segment-completion callbacks
+  /// (`LLCSegmentCompletionHandlers`) and minion task callbacks that carry no 
table name, so a table-scoped server
+  /// or minion token is denied and ingestion stalls until the token is moved 
to an unrestricted principal.
   @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));
+    P authenticatedPrincipal = principal.get();
+    if (!authenticatedPrincipal.hasUnrestrictedTableAccess()) {
+      warnOnceAboutTableScope(authenticatedPrincipal, endpointUrl);
+      return false;
+    }
+    return authenticatedPrincipal.hasPermission(Objects.toString(accessType));
+  }
+
+  /// Reports the first cluster-level denial caused by table scope for each 
principal. Without it the only trace is a
+  /// generic 403, which gives an operator no hint that the table list is the 
cause — and the affected callers include
+  /// service identities driving realtime segment completion and minion task 
callbacks, where the visible symptom is
+  /// stalled ingestion rather than a failed API call. Reported on denial 
rather than at startup so that principals
+  /// created later, which is how the ZooKeeper-backed factory is used, are 
covered too.
+  private void warnOnceAboutTableScope(P principal, String endpointUrl) {
+    if (_tableScopeDenialsReported.add(principal.getName())) {
+      LOGGER.warn("Denied BasicAuth principal '{}' access to '{}': the 
endpoint names no table, so it requires a "
+              + "principal without a table allow-list or exclude-list. Grant 
unrestricted table scope to any "
+              + "principal used as a service identity.", principal.getName(), 
endpointUrl);
+    }
   }
 
   @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.
+    // In particular it must not require unrestricted table scope for 
TargetType.CLUSTER: several table-scoped
+    // endpoints declare a cluster action, and for those the filter already 
resolved the table name and ran the
+    // table-scoped check above. Endpoints that name no table go through the 
cluster-wide check above instead.
     return getPrincipal(httpHeaders).isPresent();
   }
 
+  /// Reports whether the principal may act on the given target type at all, 
without a specific target or action.
+  /// This backs `auth/verify/v2`, which the controller UI calls as its login 
gate and renders as "invalid credentials"
+  /// when it returns false, so it must stay an authentication check rather 
than report cluster capability — otherwise
+  /// a table-scoped principal is told its correct credentials are invalid. 
Authorization for a specific request is
+  /// decided by the overloads above, so such a principal signs in and reaches 
its own tables through the table-scoped
+  /// endpoints, while cluster-wide views such as `GET /tables` remain denied.
   @Override
   public final boolean hasAccess(HttpHeaders httpHeaders, TargetType 
targetType) {
     return getPrincipal(httpHeaders).isPresent();
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 3bebb1e1641..9d8d3c615e5 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
@@ -42,6 +42,10 @@ import org.apache.pinot.spi.env.PinotConfiguration;
 /// 
controller.admin.access.control.principals.user456.tables=stuff,lessImportantStuff
 /// controller.admin.access.control.principals.user456.permissions=read,update
 /// ```
+///
+/// Setting `tables` or `excludeTables` scopes a principal to those tables and 
withholds every endpoint that names no
+/// table, because such a request is cluster-wide. `*` means unrestricted, as 
does omitting the key. A principal used
+/// as a service identity — realtime segment completion, minion task callbacks 
— must therefore be left unrestricted.
 public class BasicAuthAccessControlFactory implements AccessControlFactory {
   private static final String PREFIX = 
"controller.admin.access.control.principals";
 
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 ed4964227bd..c875f088516 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
@@ -77,7 +77,7 @@ public class PinotControllerPeriodicTaskRestletResource {
   @Produces(MediaType.APPLICATION_JSON)
   @Path("/run")
   @Authenticate(AccessType.UPDATE)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.EXECUTE_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.EXECUTE_TASK, tableScopeParam = "tableName")
   @ApiOperation(value = "Run periodic task against table. If table name is 
missing, task will run against all tables.")
   public Response runPeriodicTask(
       @ApiParam(value = "Periodic task name", required = true) 
@QueryParam("taskname") String periodicTaskName,
@@ -112,7 +112,7 @@ public class PinotControllerPeriodicTaskRestletResource {
   @Produces(MediaType.APPLICATION_JSON)
   @Path("/run")
   @Authenticate(AccessType.UPDATE)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.EXECUTE_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.EXECUTE_TASK, tableScopeParam = "tableName")
   @ApiOperation(value = "Run periodic task against table with custom 
properties. If table name is missing, task will "
       + "run against all tables.")
   public Response runPeriodicTaskWithProperties(
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotIngestionRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotIngestionRestletResource.java
index d4696e9e892..b32880d6b94 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotIngestionRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotIngestionRestletResource.java
@@ -131,7 +131,8 @@ public class PinotIngestionRestletResource {
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.MULTIPART_FORM_DATA)
   @Path("/ingestFromFile")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.INGEST_FILE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.INGEST_FILE,
+      tableScopeParam = "tableNameWithType")
   @Authenticate(AccessType.CREATE)
   @ApiOperation(value = "Ingest a file", notes = "Creates a segment using 
given file and pushes it to Pinot. "
       + "\n All steps happen on the controller. This API is NOT meant for 
production environments/large input files. "
@@ -176,7 +177,8 @@ public class PinotIngestionRestletResource {
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.MULTIPART_FORM_DATA)
   @Path("/ingestFromURI")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.INGEST_FILE)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.INGEST_FILE,
+      tableScopeParam = "tableNameWithType")
   @Authenticate(AccessType.CREATE)
   @ApiOperation(value = "Ingest from the given URI", notes =
       "Creates a segment using file at the given URI and pushes it to Pinot. "
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentRestletResource.java
index 54945be5228..1d0e1ec224a 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSegmentRestletResource.java
@@ -358,7 +358,8 @@ public class PinotSegmentRestletResource {
   /// thus effective in resetting segments or consumers in error states.
   @POST
   @Path("segments/{tableNameWithType}/{segmentName}/reset")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.RESET_SEGMENT)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.RESET_SEGMENT,
+      tableScopeParam = "tableNameWithType")
   @Authenticate(AccessType.UPDATE)
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation(
@@ -395,7 +396,8 @@ public class PinotSegmentRestletResource {
   /// thus effective in resetting segments or consumers in error states.
   @POST
   @Path("segments/{tableNameWithType}/reset")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.RESET_SEGMENT)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.RESET_SEGMENT,
+      tableScopeParam = "tableNameWithType")
   @Produces(MediaType.APPLICATION_JSON)
   @Authenticate(AccessType.UPDATE)
   @ApiOperation(
@@ -925,7 +927,8 @@ public class PinotSegmentRestletResource {
 
   @POST
   @Path("/segments/{tableNameWithType}/updateZKTimeInterval")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.UPDATE_TIME_INTERVAL)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.UPDATE_TIME_INTERVAL,
+      tableScopeParam = "tableNameWithType")
   @Authenticate(AccessType.UPDATE)
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation(value = "Update the start and end time of the segments based 
on latest schema",
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 af7b6d930c1..d90164e46c7 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
@@ -910,7 +910,7 @@ public class PinotSegmentUploadDownloadRestletResource {
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.APPLICATION_JSON)
   @Path("/segments")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.UPLOAD_SEGMENT)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.UPLOAD_SEGMENT, tableScopeParam = "tableName")
   @Authenticate(AccessType.CREATE)
   @ApiOperation(value = "Upload a segment", notes = "Upload a segment as json")
   @ApiResponses(value = {
@@ -953,7 +953,7 @@ public class PinotSegmentUploadDownloadRestletResource {
   @Produces(MediaType.APPLICATION_JSON)
   @Consumes(MediaType.MULTIPART_FORM_DATA)
   @Path("/segments")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.UPLOAD_SEGMENT)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.UPLOAD_SEGMENT, tableScopeParam = "tableName")
   @Authenticate(AccessType.CREATE)
   @ApiOperation(value = "Upload a segment", notes = "Upload a segment as 
binary")
   @ApiResponses(value = {
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 345f9f19fbd..3a3ad2c7a87 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
@@ -285,7 +285,7 @@ public class PinotTableRestletResource {
 
   @POST
   @Path("/tables/{tableName}/copy")
-  @Authorize(targetType = TargetType.TABLE, action = 
Actions.Table.CREATE_TABLE)
+  @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action = 
Actions.Table.CREATE_TABLE)
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation(value = "Copy a table's schema and config from another 
cluster", notes = "Non upsert table only")
   public CopyTableResponse copyTable(
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTaskRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTaskRestletResource.java
index 49a0ad92495..7e42f50f8cc 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTaskRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTaskRestletResource.java
@@ -261,7 +261,7 @@ public class PinotTaskRestletResource {
 
   @GET
   @Path("/tasks/{taskType}/{tableNameWithType}/state")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_TASK, tableScopeParam = "tableNameWithType")
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation("List all tasks for the given task type")
   @ManagedAsync
@@ -281,7 +281,7 @@ public class PinotTaskRestletResource {
 
   @GET
   @Path("/tasks/{taskType}/{tableNameWithType}/metadata")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_TASK, tableScopeParam = "tableNameWithType")
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation("Get task metadata for the given task type and table")
   public String getTaskMetadataByTable(
@@ -301,7 +301,8 @@ public class PinotTaskRestletResource {
   @DELETE
   @Path("/tasks/{taskType}/{tableNameWithType}/metadata")
   @Authenticate(AccessType.DELETE)
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.DELETE_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.DELETE_TASK,
+      tableScopeParam = "tableNameWithType")
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation("Delete task metadata for the given task type and table")
   public SuccessResponse deleteTaskMetadataByTable(
@@ -369,7 +370,8 @@ public class PinotTaskRestletResource {
 
   @GET
   @Path("/tasks/{taskType}/{tableNameWithType}/debug")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.DEBUG_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.DEBUG_TASK,
+      tableScopeParam = "tableNameWithType")
   @Produces(MediaType.APPLICATION_JSON)
   @ApiOperation("Fetch information for all the tasks for the given task type 
and table")
   @ManagedAsync
@@ -395,7 +397,7 @@ public class PinotTaskRestletResource {
   @GET
   @Produces(MediaType.APPLICATION_JSON)
   @Path("/tasks/generator/{tableNameWithType}/{taskType}/debug")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_TASK, tableScopeParam = "tableNameWithType")
   @ApiOperation("Fetch task generation information for the recent runs of the 
given task for the given table")
   @ManagedAsync
   public void getTaskGenerationDebugInfo(
@@ -815,7 +817,7 @@ public class PinotTaskRestletResource {
 
   @POST
   @Path("/tasks/schedule")
-  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.CREATE_TASK)
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.CREATE_TASK, tableScopeParam = "tableName")
   @Produces(MediaType.APPLICATION_JSON)
   @Authenticate(AccessType.UPDATE)
   @ApiOperation("Schedule tasks and return a map from task type to task name 
scheduled. If task type is missing, "
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManager.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManager.java
index bf6d1c04517..5a3ab6fd40f 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManager.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManager.java
@@ -1294,11 +1294,12 @@ public class PinotHelixTaskResourceManager {
         String taskIdForPartition = 
jobContext.getTaskIdForPartition(partition);
         TaskPartitionState partitionState = 
jobContext.getPartitionState(partition);
         TaskConfig helixTaskConfig = 
jobConfig.getTaskConfig(taskIdForPartition);
-        PinotTaskConfig pinotTaskConfig = null;
-        if (helixTaskConfig != null) {
-          pinotTaskConfig = 
PinotTaskConfig.fromHelixTaskConfig(helixTaskConfig);
-          if ((tableNameWithType != null) && 
(!tableNameWithType.equals(pinotTaskConfig.getTableName()))) {
-            // Filter task configs that match this table name
+        PinotTaskConfig pinotTaskConfig =
+            helixTaskConfig != null ? 
PinotTaskConfig.fromHelixTaskConfig(helixTaskConfig) : null;
+        if (tableNameWithType != null) {
+          // A table filter was requested, so keep only subtasks confirmably 
belonging to that table. A missing Helix
+          // task config leaves the subtask's table unknown, so drop it rather 
than report it under the filter.
+          if (pinotTaskConfig == null || 
!tableNameWithType.equals(pinotTaskConfig.getTableName())) {
             continue;
           }
         }
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
index b078fe249bb..fa688c95468 100644
--- 
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
@@ -405,7 +405,12 @@ public class PinotSegmentUploadAuthorizationTest extends 
ControllerTest {
         @Override
         public boolean hasAccess(String tableName, AccessType accessType, 
HttpHeaders httpHeaders,
             String endpointUrl) {
-          return tableName == null || _allowedTables.contains(tableName);
+          // A null table name means the request named no table, so it is 
cluster-wide and must be decided by the
+          // cluster policy below rather than allowed by default.
+          if (tableName == null) {
+            return hasAccess(accessType, httpHeaders, endpointUrl);
+          }
+          return _allowedTables.contains(tableName);
         }
 
         @Override
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 a17d2ef3e23..b777a692615 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
@@ -19,12 +19,22 @@
 
 package org.apache.pinot.controller.api.access;
 
+import java.io.File;
+import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import javax.ws.rs.BeanParam;
 import javax.ws.rs.DELETE;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
 import javax.ws.rs.PUT;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedHashMap;
 import javax.ws.rs.core.MultivaluedMap;
@@ -36,6 +46,7 @@ import 
org.apache.pinot.controller.api.resources.PinotControllerPeriodicTaskRest
 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.controller.api.resources.PinotTaskRestletResource;
 import org.apache.pinot.core.auth.Actions;
 import org.apache.pinot.core.auth.Authorize;
 import org.apache.pinot.core.auth.TargetType;
@@ -49,7 +60,7 @@ public class AuthenticationFilterTest {
   private final AuthenticationFilter _authFilter = new AuthenticationFilter();
 
   @Test
-  public void testExtractTableNameWithTableNameInPathParams() {
+  public void testExtractTableNameWithTableNameInPathParams() throws Exception 
{
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     pathParams.putSingle("tableName", "A");
@@ -58,11 +69,11 @@ public class AuthenticationFilterTest {
     queryParams.putSingle("tableName", "D");
     queryParams.putSingle("tableNameWithType", "E");
     queryParams.putSingle("schemaName", "F");
-    assertEquals(AuthenticationFilter.extractTableName(pathParams, 
queryParams), "A");
+    
assertEquals(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams), "A");
   }
 
   @Test
-  public void testExtractTableNameWithTableNameWithTypeInPathParams() {
+  public void testExtractTableNameWithTableNameWithTypeInPathParams() throws 
Exception {
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     pathParams.putSingle("tableNameWithType", "B");
@@ -70,52 +81,58 @@ public class AuthenticationFilterTest {
     queryParams.putSingle("tableName", "D");
     queryParams.putSingle("tableNameWithType", "E");
     queryParams.putSingle("schemaName", "F");
-    assertEquals(AuthenticationFilter.extractTableName(pathParams, 
queryParams), "B");
+    
assertEquals(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams), "B");
   }
 
   @Test
-  public void testExtractTableNameWithSchemaNameInPathParams() {
+  public void testExtractTableNameWithSchemaNameInPathParams() throws 
Exception {
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     pathParams.putSingle("schemaName", "C");
     queryParams.putSingle("tableName", "D");
     queryParams.putSingle("tableNameWithType", "E");
     queryParams.putSingle("schemaName", "F");
-    assertEquals(AuthenticationFilter.extractTableName(pathParams, 
queryParams), "C");
+    
assertEquals(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams), "C");
   }
 
   @Test
-  public void testExtractTableNameWithTableNameInQueryParams() {
+  public void testExtractTableNameWithTableNameInQueryParams() throws 
Exception {
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     queryParams.putSingle("tableName", "D");
     queryParams.putSingle("tableNameWithType", "E");
     queryParams.putSingle("schemaName", "F");
-    assertEquals(AuthenticationFilter.extractTableName(pathParams, 
queryParams), "D");
+    
assertEquals(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams), "D");
   }
 
   @Test
-  public void testExtractTableNameWithTableNameWithTypeInQueryParams() {
+  public void testExtractTableNameWithTableNameWithTypeInQueryParams() throws 
Exception {
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     queryParams.putSingle("tableNameWithType", "E");
     queryParams.putSingle("schemaName", "F");
-    assertEquals(AuthenticationFilter.extractTableName(pathParams, 
queryParams), "E");
+    
assertEquals(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams), "E");
   }
 
   @Test
-  public void testExtractTableNameWithSchemaNameInQueryParams() {
+  public void testExtractTableNameWithSchemaNameInQueryParams() throws 
Exception {
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     queryParams.putSingle("schemaName", "F");
-    assertEquals(AuthenticationFilter.extractTableName(pathParams, 
queryParams), "F");
+    
assertEquals(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams), "F");
   }
 
   @Test
-  public void testExtractTableNameWithEmptyParams() {
+  public void testExtractTableNameWithEmptyParams() throws Exception {
     MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
-    assertNull(AuthenticationFilter.extractTableName(pathParams, queryParams));
+    
assertNull(AuthenticationFilter.extractTableName(allTableQueryParamsMethod(), 
pathParams, queryParams));
+  }
+
+  private static Method allTableQueryParamsMethod()
+      throws Exception {
+    return 
AuthenticationFilterTest.class.getMethod("methodWithAllTableQueryParams", 
String.class, String.class,
+        String.class);
   }
 
   @Test
@@ -124,17 +141,25 @@ public class AuthenticationFilterTest {
     MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
     queryParams.putSingle("tableName", "A");
 
-    Method clusterMethod = 
AuthenticationFilterTest.class.getMethod("methodWithClusterAuthorization");
-    assertEquals(AuthenticationFilter.extractTableName(clusterMethod, 
pathParams, queryParams), "A");
+    // A cluster endpoint that declares a table query parameter is 
table-scoped when the caller supplies it.
+    Method clusterMethodWithTableParam =
+        
AuthenticationFilterTest.class.getMethod("methodWithClusterAuthorizationAndTableParam",
 String.class);
+    
assertEquals(AuthenticationFilter.extractTableName(clusterMethodWithTableParam, 
pathParams, queryParams), "A");
 
     queryParams.clear();
-    assertNull(AuthenticationFilter.extractTableName(clusterMethod, 
pathParams, queryParams));
+    
assertNull(AuthenticationFilter.extractTableName(clusterMethodWithTableParam, 
pathParams, queryParams));
 
     queryParams.putSingle("tableName", "A");
 
-    Method tableMethod = 
AuthenticationFilterTest.class.getMethod("methodWithTableAuthorization");
+    Method tableMethod = 
AuthenticationFilterTest.class.getMethod("methodWithTableAuthorization", 
String.class);
     assertEquals(AuthenticationFilter.extractTableName(tableMethod, 
pathParams, queryParams), "A");
 
+    // The same rule applies to the annotated branch: an @Authorize paramName 
the endpoint never binds is not the
+    // caller's to supply, so it resolves no table and the request stays 
cluster-scoped.
+    Method unboundParamMethod =
+        
AuthenticationFilterTest.class.getMethod("methodWithTableAuthorizationAndUnboundParam");
+    assertNull(AuthenticationFilter.extractTableName(unboundParamMethod, 
pathParams, queryParams));
+
     pathParams.putSingle("materializedViewTableName", "B");
     Method customTableParamMethod =
         
AuthenticationFilterTest.class.getMethod("methodWithCustomTableParamAuthorization");
@@ -144,6 +169,95 @@ public class AuthenticationFilterTest {
     assertNull(AuthenticationFilter.extractTableName(customTableParamMethod, 
pathParams, queryParams));
   }
 
+  /// `@BeanParam` and methods with no parameter annotations declare no query 
params, so `?tableName=` must not
+  /// resolve a table. That pins the fail-closed direction if someone later 
widens `declaredQueryParams`.
+  @Test
+  public void testBeanParamAndUnannotatedMethodsFailClosed()
+      throws Exception {
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    queryParams.putSingle("tableName", "A");
+
+    Method beanParamMethod = 
AuthenticationFilterTest.class.getMethod("methodWithBeanParam", Object.class);
+    assertNull(AuthenticationFilter.extractTableName(beanParamMethod, 
pathParams, queryParams));
+
+    Method tableBeanParamMethod =
+        
AuthenticationFilterTest.class.getMethod("methodWithTableAuthorizationAndBeanParam",
 Object.class);
+    assertNull(AuthenticationFilter.extractTableName(tableBeanParamMethod, 
pathParams, queryParams));
+
+    Method unannotatedMethod = 
AuthenticationFilterTest.class.getMethod("methodWithNoParameterAnnotations");
+    assertNull(AuthenticationFilter.extractTableName(unannotatedMethod, 
pathParams, queryParams));
+  }
+
+  /// A caller must not be able to make a cluster endpoint look table-scoped 
by appending a table query parameter the
+  /// endpoint never declared. `AccessControlUtils.validatePermission` picks 
the table-scoped or the cluster-wide check
+  /// based on whether a table name was resolved, so an undeclared parameter 
honored here would let a principal scoped
+  /// to one table pass the cluster check for every cluster endpoint.
+  @Test
+  public void testUndeclaredTableQueryParamDoesNotDowngradeClusterScope() 
throws Exception {
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+
+    Method clusterMethod = 
AuthenticationFilterTest.class.getMethod("methodWithClusterAuthorization");
+    for (String key : List.of("tableName", "tableNameWithType", "schemaName")) 
{
+      queryParams.clear();
+      queryParams.putSingle(key, "A");
+      assertNull(AuthenticationFilter.extractTableName(clusterMethod, 
pathParams, queryParams),
+          "undeclared query parameter " + key + " must not resolve a table 
name");
+    }
+
+    // A cluster declaration stays authoritative even when its path includes a 
table filter.
+    queryParams.clear();
+    pathParams.putSingle("tableName", "A");
+    assertNull(AuthenticationFilter.extractTableName(clusterMethod, 
pathParams, queryParams));
+  }
+
+  @Test
+  public void testExplicitClusterTableScopeUsesPathAndQueryParameters() throws 
Exception {
+    Method method = 
AuthenticationFilterTest.class.getMethod("methodWithClusterAuthorizationAndTableParam",
+        String.class);
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    pathParams.putSingle("tableName", "pathTable");
+    queryParams.putSingle("tableName", "queryTable");
+    assertEquals(AuthenticationFilter.extractTableName(method, pathParams, 
queryParams), "pathTable");
+    pathParams.clear();
+    assertEquals(AuthenticationFilter.extractTableName(method, pathParams, 
queryParams), "queryTable");
+    queryParams.clear();
+    assertNull(AuthenticationFilter.extractTableName(method, pathParams, 
queryParams));
+  }
+
+  @Test
+  public void testTaskDebugFilterDoesNotGrantTableScope() {
+    Method method = Arrays.stream(PinotTaskRestletResource.class
+        .getDeclaredMethods()).filter(candidate -> 
candidate.getName().equals("getTaskDebugInfo"))
+        .findFirst().orElseThrow();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    queryParams.putSingle("tableName", "ownedTable_OFFLINE");
+    assertNull(AuthenticationFilter.extractTableName(method, new 
MultivaluedHashMap<>(), queryParams));
+  }
+
+  @Test
+  public void testDeclaredTableQueryParamScopesRequest() throws Exception {
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+
+    Method typedTableParamMethod = AuthenticationFilterTest.class.getMethod(
+        "methodWithClusterAuthorizationAndTypedTableParam", String.class);
+    queryParams.putSingle("tableNameWithType", "A");
+    assertEquals(AuthenticationFilter.extractTableName(typedTableParamMethod, 
pathParams, queryParams), "A");
+
+    // Only the declared key counts, even when an undeclared one of higher 
precedence is also supplied.
+    queryParams.putSingle("tableName", "B");
+    assertEquals(AuthenticationFilter.extractTableName(typedTableParamMethod, 
pathParams, queryParams), "A");
+
+    Method schemaParamMethod =
+        
AuthenticationFilterTest.class.getMethod("methodWithClusterAuthorizationAndSchemaParam",
 String.class);
+    queryParams.clear();
+    queryParams.putSingle("schemaName", "C");
+    assertEquals(AuthenticationFilter.extractTableName(schemaParamMethod, 
pathParams, queryParams), "C");
+  }
+
   @Test
   public void testExtractAccessTypeWithAuthAnnotation() throws Exception {
     Method method = 
AuthenticationFilterTest.class.getMethod("methodWithAuthAnnotation");
@@ -179,15 +293,102 @@ public class AuthenticationFilterTest {
 
   @Test
   public void testSegmentCompletionGetEndpointsDeclareCreateAccess() {
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    queryParams.putSingle("tableName", "someTable");
     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);
+      // These callbacks name no table, so they take the cluster-wide check. 
That is why a table-scoped server token
+      // is denied and realtime commit stalls — the consequence documented on 
BaseBasicAuthAccessControl.
+      assertNull(AuthenticationFilter.extractTableName(method, pathParams, 
queryParams),
+          methodName + " must not resolve a table name");
     }
   }
 
+  /// Guards the assumption stated on 
[org.apache.pinot.core.auth.FineGrainedAuthUtils#getDeclaredQueryParams]: a
+  /// table target's parameter must be bound as a `@PathParam` or a 
method-level `@QueryParam`. An endpoint binding it
+  /// via `@BeanParam` or field injection would resolve no table and hard-fail 
with 400 for every caller, admin
+  /// included. Cluster endpoints opting into table scope must also bind their 
`tableScopeParam`, otherwise a typo
+  /// would silently retain cluster-wide scope and deny table-scoped callers.
+  @Test
+  public void testAuthorizeTableParamsAreBoundOnTheMethod()
+      throws Exception {
+    List<Class<?>> resourceClasses = controllerResourceClasses();
+    assertTrue(resourceClasses.size() > 20, "expected to discover the 
controller resource classes, found "
+        + resourceClasses.size());
+    List<String> violations = new ArrayList<>();
+    for (Class<?> resourceClass : resourceClasses) {
+      for (Method method : resourceClass.getDeclaredMethods()) {
+        Authorize authorize = method.getAnnotation(Authorize.class);
+        if (authorize == null) {
+          continue;
+        }
+        String parameterName;
+        if (authorize.targetType() == TargetType.TABLE) {
+          parameterName = authorize.paramName();
+        } else if (authorize.targetType() == TargetType.CLUSTER && 
!authorize.tableScopeParam().isEmpty()) {
+          parameterName = authorize.tableScopeParam();
+        } else {
+          continue;
+        }
+        if (!bindsParameter(method, parameterName)) {
+          violations.add(resourceClass.getSimpleName() + "#" + 
method.getName() + " declares table parameter '"
+              + parameterName + "'");
+        }
+      }
+    }
+    assertTrue(violations.isEmpty(), "@Authorize table parameters must be 
bound as @PathParam or @QueryParam: "
+        + violations);
+  }
+
+  /// Loads every class in the controller's `api.resources` package from the 
compiled output, so the guard covers
+  /// endpoints added later without anyone having to remember to list them 
here.
+  private static List<Class<?>> controllerResourceClasses()
+      throws Exception {
+    String packageName = PinotTableRestletResource.class.getPackageName();
+    // Resolve through URI so a build path containing spaces or other escaped 
characters still works.
+    Path classesRoot = Path.of(
+        
PinotTableRestletResource.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+    Path packageRoot = classesRoot.resolve(packageName.replace('.', '/'));
+    List<Class<?>> classes = new ArrayList<>();
+    // Walked recursively so sub-packages such as api.resources.ddl are 
covered as they are added.
+    try (Stream<Path> paths = Files.walk(packageRoot)) {
+      for (Path classFile : paths.filter(Files::isRegularFile).toList()) {
+        String fileName = classFile.getFileName().toString();
+        if (!fileName.endsWith(".class") || fileName.contains("$")) {
+          continue;
+        }
+        String relative = classesRoot.relativize(classFile).toString();
+        String className = relative.substring(0, relative.length() - 
".class".length())
+            .replace(File.separatorChar, '.');
+        try {
+          classes.add(Class.forName(className));
+        } catch (ClassNotFoundException | NoClassDefFoundError e) {
+          // Not loadable in the test classpath; it cannot serve requests 
either.
+        }
+      }
+    }
+    return classes;
+  }
+
+  private static boolean bindsParameter(Method method, String parameterName) {
+    for (Annotation[] parameterAnnotations : method.getParameterAnnotations()) 
{
+      for (Annotation annotation : parameterAnnotations) {
+        if (annotation instanceof PathParam pathParam && 
pathParam.value().equals(parameterName)) {
+          return true;
+        }
+        if (annotation instanceof QueryParam queryParam && 
queryParam.value().equals(parameterName)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   @Test
   public void testBrokerForTableEndpointDeclaresTableAuthorization() throws 
Exception {
     Method method = 
PinotBrokerRestletResource.class.getMethod("getBrokersForTableV2", 
String.class, String.class,
@@ -248,12 +449,50 @@ public class AuthenticationFilterTest {
   public void methodWithClusterAuthorization() {
   }
 
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.EXECUTE_TASK, tableScopeParam = "tableName")
+  public void 
methodWithClusterAuthorizationAndTableParam(@QueryParam("tableName") String 
tableName) {
+  }
+
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.INGEST_FILE,
+      tableScopeParam = "tableNameWithType")
+  public void methodWithClusterAuthorizationAndTypedTableParam(
+      @QueryParam("tableNameWithType") String tableNameWithType) {
+  }
+
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_SCHEMA, tableScopeParam = "schemaName")
+  public void 
methodWithClusterAuthorizationAndSchemaParam(@QueryParam("schemaName") String 
schemaName) {
+  }
+
+  /// Stands in for an endpoint that binds every table-identifying query 
parameter, so the precedence rules can be
+  /// exercised independently of the declaration filter.
+  public void methodWithAllTableQueryParams(@QueryParam("tableName") String 
tableName,
+      @QueryParam("tableNameWithType") String tableNameWithType, 
@QueryParam("schemaName") String schemaName) {
+  }
+
+  @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action = 
Actions.Table.GET_TABLE_CONFIG)
+  public void methodWithTableAuthorization(@QueryParam("tableName") String 
tableName) {
+  }
+
+  /// Declares a table target whose parameter the method never binds — a 
misannotation the resolution must fail closed
+  /// on rather than trust the caller for.
   @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action = 
Actions.Table.GET_TABLE_CONFIG)
-  public void methodWithTableAuthorization() {
+  public void methodWithTableAuthorizationAndUnboundParam() {
   }
 
   @Authorize(targetType = TargetType.TABLE, paramName = 
"materializedViewTableName",
       action = Actions.Table.GET_TABLE_CONFIG)
   public void methodWithCustomTableParamAuthorization() {
   }
+
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_CLUSTER_CONFIG)
+  public void methodWithBeanParam(@BeanParam Object bean) {
+  }
+
+  @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action = 
Actions.Table.GET_TABLE_CONFIG)
+  public void methodWithTableAuthorizationAndBeanParam(@BeanParam Object bean) 
{
+  }
+
+  @Authorize(targetType = TargetType.CLUSTER, action = 
Actions.Cluster.GET_CLUSTER_CONFIG)
+  public void methodWithNoParameterAnnotations() {
+  }
 }
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
index 3d9dcc9337d..a93dcd4732c 100644
--- 
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
@@ -57,6 +57,12 @@ public class BasicAuthAccessControlFactoryTest {
   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 SCOPED_ADMIN_USER = "scopedAdmin";
+  private static final String SCOPED_ADMIN_PASSWORD = "scopedAdminPassword";
+  private static final String EXCLUDED_USER = "excluded";
+  private static final String EXCLUDED_PASSWORD = "excludedPassword";
+  private static final String CLUSTER_READER_USER = "clusterReader";
+  private static final String CLUSTER_READER_PASSWORD = 
"clusterReaderPassword";
   private static final String CORRUPT_USER = "corrupt";
   private static final String ENDPOINT_URL = "/cluster/configs";
 
@@ -68,11 +74,23 @@ public class BasicAuthAccessControlFactoryTest {
 
   @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));
+    // A principal confined to a subset of tables holds no cluster-wide 
access, whichever permissions it was granted.
+    for (Map.Entry<String, String> tableScopedUser : Map.of(RESTRICTED_USER, 
RESTRICTED_PASSWORD, SCOPED_ADMIN_USER,
+        SCOPED_ADMIN_PASSWORD, EXCLUDED_USER, EXCLUDED_PASSWORD).entrySet()) {
+      HttpHeaders tableScopedHeaders = headers(tableScopedUser.getKey(), 
tableScopedUser.getValue());
+      for (AccessType accessType : AccessType.values()) {
+        assertFalse(accessControl.hasAccess(accessType, tableScopedHeaders, 
ENDPOINT_URL),
+            tableScopedUser.getKey() + " must not hold cluster " + accessType);
+      }
+    }
+
+    // Table scope is necessary but not sufficient: an unrestricted principal 
is still held to its permissions.
+    HttpHeaders clusterReaderHeaders = headers(CLUSTER_READER_USER, 
CLUSTER_READER_PASSWORD);
+    assertTrue(accessControl.hasAccess(AccessType.READ, clusterReaderHeaders, 
ENDPOINT_URL));
+    for (AccessType accessType : List.of(AccessType.CREATE, AccessType.UPDATE, 
AccessType.DELETE)) {
+      assertFalse(accessControl.hasAccess(accessType, clusterReaderHeaders, 
ENDPOINT_URL),
+          CLUSTER_READER_USER + " holds only READ, so it must not hold cluster 
" + accessType);
+    }
 
     HttpHeaders fullHeaders = headers(FULL_USER, FULL_PASSWORD);
     for (AccessType accessType : AccessType.values()) {
@@ -117,7 +135,12 @@ public class BasicAuthAccessControlFactoryTest {
   @Test(dataProvider = "accessControls")
   public void testAuthenticationProbeRemainsNonThrowing(AccessControl 
accessControl) {
     assertFalse(accessControl.hasAccess(headers("unknown", "wrong"), 
TargetType.CLUSTER));
+    assertTrue(accessControl.hasAccess(headers(FULL_USER, FULL_PASSWORD), 
TargetType.CLUSTER));
+
+    // This probe backs the UI login gate, so it stays an authentication 
check: a table-scoped principal must still be
+    // able to sign in and work with the tables it is scoped to. Cluster 
requests are denied by the checks above.
     assertTrue(accessControl.hasAccess(headers(RESTRICTED_USER, 
RESTRICTED_PASSWORD), TargetType.CLUSTER));
+    assertTrue(accessControl.hasAccess(headers(SCOPED_ADMIN_USER, 
SCOPED_ADMIN_PASSWORD), TargetType.CLUSTER));
   }
 
   @Test(dataProvider = "accessControls")
@@ -140,6 +163,11 @@ public class BasicAuthAccessControlFactoryTest {
     for (AccessType accessType : AccessType.values()) {
       assertTrue(accessControl.hasAccess(ALLOWED_TABLE, accessType, 
fullHeaders, ENDPOINT_URL));
     }
+
+    // An exclude-list principal loses cluster access but keeps every table it 
was not excluded from.
+    HttpHeaders excludedHeaders = headers(EXCLUDED_USER, EXCLUDED_PASSWORD);
+    assertTrue(accessControl.hasAccess(ALLOWED_TABLE, AccessType.READ, 
excludedHeaders, ENDPOINT_URL));
+    assertFalse(accessControl.hasAccess("otherTable", AccessType.READ, 
excludedHeaders, ENDPOINT_URL));
   }
 
   @Test(dataProvider = "accessControls")
@@ -160,6 +188,45 @@ public class BasicAuthAccessControlFactoryTest {
         BasicAuthTokenUtils.toBasicAuthToken(RESTRICTED_USER, 
RESTRICTED_PASSWORD)));
   }
 
+  /// Regression test for [issue 
14595](https://github.com/apache/pinot/issues/14595): a principal scoped to a 
single
+  /// table held every permission on cluster-level endpoints, letting it 
delete resources such as other users. Table
+  /// scope must be honored on endpoints that name no table, while the 
principal keeps access to its own table.
+  @Test(dataProvider = "accessControls")
+  public void 
testTableScopedPrincipalCannotReachClusterEndpoints(AccessControl 
accessControl) {
+    HttpHeaders scopedAdminHeaders = headers(SCOPED_ADMIN_USER, 
SCOPED_ADMIN_PASSWORD);
+
+    ControllerApplicationException exception = 
expectThrows(ControllerApplicationException.class,
+        () -> AccessControlUtils.validatePermission(null, AccessType.DELETE, 
scopedAdminHeaders, "/users/admin",
+            accessControl));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+
+    // The same principal still holds every permission on the table it is 
scoped to.
+    for (AccessType accessType : AccessType.values()) {
+      assertTrue(accessControl.hasAccess(ALLOWED_TABLE, accessType, 
scopedAdminHeaders, ENDPOINT_URL));
+    }
+    assertFalse(accessControl.hasAccess("otherTable", AccessType.DELETE, 
scopedAdminHeaders, ENDPOINT_URL));
+
+    // An unrestricted principal with the same permission is still authorized 
cluster-wide.
+    AccessControlUtils.validatePermission(null, AccessType.DELETE, 
headers(FULL_USER, FULL_PASSWORD), "/users/admin",
+        accessControl);
+
+    // Omitting the table name must not sidestep the scope rule via the table 
overload. An exclude-list principal is
+    // the case that slips through a plain hasTable(null) check, since it has 
no allow-list to fail against.
+    for (String tableScopedUser : List.of(SCOPED_ADMIN_USER, EXCLUDED_USER)) {
+      HttpHeaders tableScopedHeaders = headers(tableScopedUser, 
tableScopedUser + "Password");
+      assertFalse(accessControl.hasAccess(null, AccessType.DELETE, 
tableScopedHeaders, "/users/admin"),
+          tableScopedUser + " must not be authorized for a request that names 
no table");
+    }
+
+    // The same branch must still authorize an unrestricted principal, and 
still hold it to its permissions —
+    // /auth/verify reaches this overload with an absent table name, so an 
over-denial here misreports admins.
+    assertTrue(accessControl.hasAccess(null, AccessType.DELETE, 
headers(FULL_USER, FULL_PASSWORD), "/users/admin"));
+    assertFalse(accessControl.hasAccess(null, AccessType.DELETE,
+        headers(CLUSTER_READER_USER, CLUSTER_READER_PASSWORD), 
"/users/admin"));
+    assertTrue(accessControl.hasAccess(null, AccessType.READ,
+        headers(CLUSTER_READER_USER, CLUSTER_READER_PASSWORD), "/users"));
+  }
+
   @Test
   public void testMalformedCachedZkPrincipalFailsClosed()
       throws Exception {
@@ -175,13 +242,21 @@ public class BasicAuthAccessControlFactoryTest {
 
   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,full,wildcard,scopedAdmin,excluded,clusterReader"),
         
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));
+        
Map.entry("controller.admin.access.control.principals.wildcard.password", 
WILDCARD_PASSWORD),
+        
Map.entry("controller.admin.access.control.principals.scopedAdmin.password", 
SCOPED_ADMIN_PASSWORD),
+        
Map.entry("controller.admin.access.control.principals.scopedAdmin.tables", 
ALLOWED_TABLE),
+        
Map.entry("controller.admin.access.control.principals.scopedAdmin.permissions", 
"create,read,update,delete"),
+        
Map.entry("controller.admin.access.control.principals.excluded.password", 
EXCLUDED_PASSWORD),
+        
Map.entry("controller.admin.access.control.principals.excluded.excludeTables", 
"otherTable"),
+        
Map.entry("controller.admin.access.control.principals.clusterReader.password", 
CLUSTER_READER_PASSWORD),
+        
Map.entry("controller.admin.access.control.principals.clusterReader.permissions",
 "read"));
     BasicAuthAccessControlFactory factory = new 
BasicAuthAccessControlFactory();
     factory.init(new PinotConfiguration(properties));
     return factory.create();
@@ -195,6 +270,11 @@ public class BasicAuthAccessControlFactoryTest {
         user(FULL_USER, FULL_PASSWORD, null,
             List.of(org.apache.pinot.spi.config.user.AccessType.values())),
         user(WILDCARD_USER, WILDCARD_PASSWORD, null, null),
+        user(SCOPED_ADMIN_USER, SCOPED_ADMIN_PASSWORD, List.of(ALLOWED_TABLE),
+            List.of(org.apache.pinot.spi.config.user.AccessType.values())),
+        user(EXCLUDED_USER, EXCLUDED_PASSWORD, null, List.of("otherTable"), 
null),
+        user(CLUSTER_READER_USER, CLUSTER_READER_PASSWORD, null,
+            List.of(org.apache.pinot.spi.config.user.AccessType.READ)),
         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();
@@ -222,8 +302,13 @@ public class BasicAuthAccessControlFactoryTest {
 
   private static UserConfig user(String username, String password, 
List<String> tables,
       List<org.apache.pinot.spi.config.user.AccessType> permissions) {
+    return user(username, password, tables, null, permissions);
+  }
+
+  private static UserConfig user(String username, String password, 
List<String> tables, List<String> excludeTables,
+      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);
+        RoleType.USER.name(), tables, excludeTables, permissions);
   }
 
   private static HttpHeaders headers(String username, String password) {
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
index 1bca6300fbe..74b600a57d3 100644
--- 
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
@@ -46,8 +46,8 @@ import static org.testng.Assert.fail;
 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 TABLE_READ_USER = "tableReader";
+  private static final String TABLE_READ_PASSWORD = "tableReaderPassword";
   private static final String CREATE_USER = "clusterCreator";
   private static final String CREATE_PASSWORD = "clusterCreatorPassword";
   private static final String UPDATE_USER = "clusterUpdater";
@@ -56,6 +56,8 @@ public class ControllerClusterBasicAuthAuthorizationTest 
extends ControllerTest
   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 TABLE_ADMIN_USER = "tableAdmin";
+  private static final String TABLE_ADMIN_PASSWORD = "tableAdminPassword";
   private static final String ALLOWED_TABLE = "allowedTable";
   private static final String DISALLOWED_TABLE = "disallowedTable";
 
@@ -80,39 +82,43 @@ public class ControllerClusterBasicAuthAuthorizationTest 
extends ControllerTest
       controllerStarted = true;
 
       Map<String, String> adminHeaders = authHeaders(ADMIN_USER, 
ADMIN_PASSWORD);
-      Map<String, String> restrictedHeaders = authHeaders(RESTRICTED_USER, 
RESTRICTED_PASSWORD);
+      Map<String, String> tableReadHeaders = authHeaders(TABLE_READ_USER, 
TABLE_READ_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);
+      Map<String, String> tableAdminHeaders = authHeaders(TABLE_ADMIN_USER, 
TABLE_ADMIN_PASSWORD);
       String clusterConfigsUrl = _controllerBaseApiUrl + "/cluster/configs";
       if (zkBacked) {
         addZkUsers();
-        TestUtils.waitForCondition(aVoid -> getStatus(clusterConfigsUrl, 
restrictedHeaders) == 200
+        // Unloaded users answer 401; once loaded every non-admin principal 
here is denied cluster access with 403.
+        TestUtils.waitForCondition(aVoid -> getStatus(clusterConfigsUrl, 
tableReadHeaders) == 403
                 && getStatus(clusterConfigsUrl, createHeaders) == 403
                 && getStatus(clusterConfigsUrl, updateHeaders) == 403
                 && getStatus(clusterConfigsUrl, deleteHeaders) == 403
-                && getStatus(clusterConfigsUrl, tableUpdateHeaders) == 403, 
TIMEOUT_MS,
+                && getStatus(clusterConfigsUrl, tableUpdateHeaders) == 403
+                && getStatus(clusterConfigsUrl, tableAdminHeaders) == 403, 
TIMEOUT_MS,
             "Controller users were not loaded from ZooKeeper");
       }
 
       assertGetStatus(clusterConfigsUrl, authHeaders("unknown", "incorrect"), 
401);
-      assertGetStatus(clusterConfigsUrl, restrictedHeaders, 200);
+      assertGetStatus(clusterConfigsUrl, adminHeaders, 200);
       assertTrue(JsonUtils.stringToJsonNode(
-          sendPostRequest(_controllerBaseApiUrl + "/query/tableNames", "[]", 
restrictedHeaders)).isEmpty());
+          sendPostRequest(_controllerBaseApiUrl + "/query/tableNames", "[]", 
adminHeaders)).isEmpty());
       assertTrue(JsonUtils.stringToJsonNode(
-          sendPostRequest(_controllerBaseApiUrl + 
"/instances/updateTags/validate", "[]", restrictedHeaders))
+          sendPostRequest(_controllerBaseApiUrl + 
"/instances/updateTags/validate", "[]", adminHeaders))
           .isEmpty());
 
-      assertTableScope(restrictedHeaders, tableUpdateHeaders, 
authHeaders("unknown", "incorrect"));
+      assertClusterScope(tableReadHeaders, tableAdminHeaders);
+      assertTableScope(tableReadHeaders, 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", 
tableReadHeaders));
+      assertHttpError(403, () -> sendPostRequest(clusterConfigsUrl, 
"{not-json", tableReadHeaders));
+      assertHttpError(403, () -> sendDeleteRequest(clusterConfigsUrl + "/" + 
configKey, tableReadHeaders));
 
       assertHttpError(403, () -> sendPostRequest(zkCreateUrl, "{not-json", 
updateHeaders));
       assertHttpError(403, () -> sendPostRequest(clusterConfigsUrl, 
"{not-json", createHeaders));
@@ -163,14 +169,15 @@ public class ControllerClusterBasicAuthAuthorizationTest 
extends ControllerTest
     }
 
     controllerConfiguration.put("controller.admin.access.control.principals",
-        String.join(",", ADMIN_USER, RESTRICTED_USER, CREATE_USER, 
UPDATE_USER, DELETE_USER, TABLE_UPDATE_USER));
+        String.join(",", ADMIN_USER, TABLE_READ_USER, CREATE_USER, 
UPDATE_USER, DELETE_USER, TABLE_UPDATE_USER,
+            TABLE_ADMIN_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",
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ TABLE_READ_USER + ".password",
+        TABLE_READ_PASSWORD);
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ TABLE_READ_USER + ".tables",
         ALLOWED_TABLE);
-    controllerConfiguration.put("controller.admin.access.control.principals." 
+ RESTRICTED_USER + ".permissions",
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ TABLE_READ_USER + ".permissions",
         "read");
     addStaticPrincipal(controllerConfiguration, CREATE_USER, CREATE_PASSWORD, 
"create");
     addStaticPrincipal(controllerConfiguration, UPDATE_USER, UPDATE_PASSWORD, 
"update");
@@ -178,6 +185,9 @@ public class ControllerClusterBasicAuthAuthorizationTest 
extends ControllerTest
     addStaticPrincipal(controllerConfiguration, TABLE_UPDATE_USER, 
TABLE_UPDATE_PASSWORD, "update");
     controllerConfiguration.put("controller.admin.access.control.principals." 
+ TABLE_UPDATE_USER + ".tables",
         ALLOWED_TABLE);
+    addStaticPrincipal(controllerConfiguration, TABLE_ADMIN_USER, 
TABLE_ADMIN_PASSWORD, "create,read,update,delete");
+    controllerConfiguration.put("controller.admin.access.control.principals." 
+ TABLE_ADMIN_USER + ".tables",
+        ALLOWED_TABLE);
   }
 
   private static void addStaticPrincipal(Map<String, Object> 
controllerConfiguration, String username,
@@ -189,13 +199,71 @@ public class ControllerClusterBasicAuthAuthorizationTest 
extends ControllerTest
 
   private void addZkUsers()
       throws IOException {
-    addZkUser(RESTRICTED_USER, RESTRICTED_PASSWORD, List.of(ALLOWED_TABLE),
+    addZkUser(TABLE_READ_USER, TABLE_READ_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);
+    addZkUser(TABLE_ADMIN_USER, TABLE_ADMIN_PASSWORD, List.of(ALLOWED_TABLE),
+        org.apache.pinot.spi.config.user.AccessType.values());
+  }
+
+  /// Asserts the fix for [issue 
14595](https://github.com/apache/pinot/issues/14595): principals confined to a 
subset
+  /// of tables are denied endpoints that address the cluster rather than a 
table, whatever permissions they hold.
+  private void assertClusterScope(Map<String, String> tableReadHeaders, 
Map<String, String> tableAdminHeaders)
+      throws IOException {
+    // The reported exploit: a table-scoped user deleting another user through 
a cluster-level endpoint.
+    assertHttpError(403,
+        () -> sendDeleteRequest(_controllerBaseApiUrl + "/users/" + ADMIN_USER 
+ "?component=CONTROLLER",
+            tableAdminHeaders));
+    assertGetStatus(_controllerBaseApiUrl + "/users", tableAdminHeaders, 403);
+
+    // Other cluster-level endpoints follow the same rule, including read-only 
ones.
+    for (Map<String, String> tableScopedHeaders : List.of(tableReadHeaders, 
tableAdminHeaders)) {
+      assertGetStatus(_controllerBaseApiUrl + "/cluster/configs", 
tableScopedHeaders, 403);
+      assertGetStatus(_controllerBaseApiUrl + "/cluster/info", 
tableScopedHeaders, 403);
+      assertGetStatus(_controllerBaseApiUrl + "/instances", 
tableScopedHeaders, 403);
+      assertHttpError(403,
+          () -> sendPostRequest(_controllerBaseApiUrl + "/query/tableNames", 
"[]", tableScopedHeaders));
+
+      // Cluster-wide listings are denied too: they enumerate every table in 
the cluster rather than filtering to the
+      // principal's scope, so the controller UI's listing pages are 
unavailable to a table-scoped principal.
+      assertGetStatus(_controllerBaseApiUrl + "/tables", tableScopedHeaders, 
403);
+      assertGetStatus(_controllerBaseApiUrl + "/schemas", tableScopedHeaders, 
403);
+
+      // Naming a table the principal owns must not make a cluster endpoint 
pass as a table-scoped request. These
+      // endpoints declare no table parameter, so the parameter is not theirs 
to be scoped by.
+      for (String tableParam : List.of("tableName", "tableNameWithType", 
"schemaName")) {
+        String scopedSuffix = "?" + tableParam + "=" + ALLOWED_TABLE;
+        assertGetStatus(_controllerBaseApiUrl + "/cluster/configs" + 
scopedSuffix, tableScopedHeaders, 403);
+        assertGetStatus(_controllerBaseApiUrl + "/users" + scopedSuffix, 
tableScopedHeaders, 403);
+      }
+    }
+    assertHttpError(403, () -> sendDeleteRequest(
+        _controllerBaseApiUrl + "/users/" + ADMIN_USER + 
"?component=CONTROLLER&tableName=" + ALLOWED_TABLE,
+        tableAdminHeaders));
+
+    // A table filter only limits subtasks; task-level debug metadata remains 
cluster-wide.
+    assertGetStatus(_controllerBaseApiUrl + 
"/tasks/task/missing/debug?tableName=" + ALLOWED_TABLE + "_OFFLINE",
+        tableAdminHeaders, 403);
+
+    // The principal keeps the endpoints that genuinely declare a table 
parameter, cluster action or not.
+    assertGetStatus(_controllerBaseApiUrl + 
"/periodictask/run?taskname=missing&tableName=" + ALLOWED_TABLE,
+        tableAdminHeaders, 404);
+    // Omitting that parameter means "every table", which is the escalation 
this change closes for this endpoint.
+    assertGetStatus(_controllerBaseApiUrl + 
"/periodictask/run?taskname=missing", tableAdminHeaders, 403);
+
+    // Segment upload documents tableName as optional, falling back to the 
segment metadata. A table-scoped principal
+    // must now send it, because without it the request names no table and is 
treated as cluster-wide.
+    assertHttpError(403, () -> sendPostRequest(_controllerBaseApiUrl + 
"/segments", "", tableAdminHeaders));
+
+    // The controller's multi-stage query path authorizes cluster-wide by 
design (it has no cross-table rule), so a
+    // table-scoped principal is denied there even for a table it owns. The 
broker path is unaffected.
+    assertHttpError(403, () -> sendPostRequest(_controllerBaseApiUrl + "/sql",
+        "{\"sql\":\"SELECT 1 FROM " + ALLOWED_TABLE + 
"\",\"queryOptions\":\"useMultistageEngine=true\"}",
+        tableAdminHeaders));
   }
 
   private void assertTableScope(Map<String, String> readHeaders, Map<String, 
String> updateHeaders,
@@ -212,10 +280,10 @@ public class ControllerClusterBasicAuthAuthorizationTest 
extends ControllerTest
   }
 
   private void addZkUser(String username, String password, List<String> tables,
-      org.apache.pinot.spi.config.user.AccessType permission)
+      org.apache.pinot.spi.config.user.AccessType... permissions)
       throws IOException {
     _helixResourceManager.addUser(new UserConfig(username, password, 
ComponentType.CONTROLLER.name(),
-        RoleType.USER.name(), tables, null, List.of(permission)));
+        RoleType.USER.name(), tables, null, List.of(permissions)));
   }
 
   private static Map<String, String> authHeaders(String username, String 
password) {
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManagerTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManagerTest.java
index 2e6aedbf6b3..fbecb58482e 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManagerTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/minion/PinotHelixTaskResourceManagerTest.java
@@ -637,6 +637,22 @@ public class PinotHelixTaskResourceManagerTest {
     PinotHelixTaskResourceManager.TaskDebugInfo result2 =
         mgr.getTaskDebugInfo(taskName, otherTableNameWithType, 1);
     verifyResultFilteredByTable(result2, "task2", 
TaskPartitionState.COMPLETED, otherTableNameWithType);
+
+    // A subtask whose Helix task config is missing has no determinable table, 
so a requested table filter must drop
+    // it rather than report it. The filter is part of the authorization 
boundary for table-scoped principals on
+    // GET /tasks/task/{taskName}/debug, so including it would disclose 
another table's subtasks.
+    when(jobConfig.getTaskConfig("task2")).thenReturn(null);
+    PinotHelixTaskResourceManager.TaskDebugInfo filtered =
+        mgr.getTaskDebugInfo(taskName, tableNameWithType, 1);
+    verifyResultFilteredByTable(filtered, "task1", TaskPartitionState.RUNNING, 
tableNameWithType);
+    assertEquals(filtered.getSubtaskCount().getTotal(), 1);
+
+    // ... and it must not leak through a filter naming the table whose config 
went missing.
+    PinotHelixTaskResourceManager.TaskDebugInfo noneMatching =
+        mgr.getTaskDebugInfo(taskName, otherTableNameWithType, 1);
+    assertTrue(noneMatching == null || noneMatching.getSubtaskInfos() == null
+            || noneMatching.getSubtaskInfos().isEmpty(),
+        "a subtask with no resolvable table must not be reported under a table 
filter");
   }
 
   private static void 
verifyResultFilteredByTable(PinotHelixTaskResourceManager.TaskDebugInfo 
taskDebugInfo,
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/auth/Authorize.java 
b/pinot-core/src/main/java/org/apache/pinot/core/auth/Authorize.java
index 22139606c71..bbbf1796ef3 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/auth/Authorize.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/auth/Authorize.java
@@ -37,6 +37,13 @@ public @interface Authorize {
   // If the target type is the Pinot cluster, then this field is not required
   String paramName() default "";
 
+  /// Optional path or query parameter that narrows a cluster operation to one 
table for coarse access control.
+  /// Use only when the entire operation is limited to that table, rather than 
filtering part of the response.
+  /// A missing parameter, or a name absent from the endpoint's path 
parameters and declared query parameters,
+  /// resolves no table and retains cluster-wide scope, which denies 
table-scoped principals. Fine-grained
+  /// authorization still uses targetType.
+  String tableScopeParam() default "";
+
   // action to validate on the specific resource
   String action();
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthPrincipal.java 
b/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthPrincipal.java
index 80997af2d60..35910f1f05f 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthPrincipal.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthPrincipal.java
@@ -18,11 +18,14 @@
  */
 package org.apache.pinot.core.auth;
 
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 
 
 /// Container object for basic auth principal
@@ -44,12 +47,21 @@ public class BasicAuthPrincipal {
       Set<String> permissions, Map<String, List<String>> rlsFilters) {
     _name = name;
     _token = token;
-    _tables = tables;
-    _excludeTables = excludeTables;
+    // Copied because hasUnrestrictedTableAccess() gates cluster-level 
authorization off these sets: a caller that
+    // retained and later cleared its own set would silently widen this 
principal to unrestricted scope. Copied
+    // permissively rather than with Set.copyOf, which rejects null elements — 
a user record stored in ZooKeeper can
+    // carry a null table name, and the broker and server rebuild every 
principal on each request, so one malformed
+    // record would otherwise fail authentication for every user.
+    _tables = copyOfNullable(tables);
+    _excludeTables = copyOfNullable(excludeTables);
     _permissions = permissions.stream().map(s -> 
s.toLowerCase()).collect(Collectors.toSet());
     _rlsFilters = rlsFilters;
   }
 
+  private static Set<String> copyOfNullable(@Nullable Set<String> values) {
+    return values == null ? Set.of() : Collections.unmodifiableSet(new 
HashSet<>(values));
+  }
+
   public String getName() {
     return _name;
   }
@@ -62,6 +74,21 @@ public class BasicAuthPrincipal {
     return isTableIncluded(tableName) && isTableNotExcluded(tableName);
   }
 
+  /// Returns whether this principal is scoped to every table, i.e. it carries 
neither an allow-list nor an
+  /// exclude-list. Access control implementations use this to decide requests 
that name no table, so the return value
+  /// must satisfy: `true` implies [#hasTable(String)] holds for every table 
name. A subclass that narrows table scope
+  /// by any other means — in particular by overriding [#hasTable(String)] — 
must override this method to match, or it
+  /// will report unrestricted scope while denying individual tables.
+  ///
+  /// Table lists are the signal rather than [ZkBasicAuthPrincipal]'s 
`RoleType`. The static factory has no role, both
+  /// controller factories share this check, and an ADMIN that also carries an 
allow-list is still scoped. Granting
+  /// cluster access by role would let that principal reach cluster state 
outside the list. The bootstrapped admin
+  /// is bootstrapped with a null `UserConfig#getTables()`, which normalizes 
to an empty set here, so
+  /// `RoleType.ADMIN` without a table list still passes.
+  public boolean hasUnrestrictedTableAccess() {
+    return _tables.isEmpty() && _excludeTables.isEmpty();
+  }
+
   private boolean isTableIncluded(String tableName) {
     return _tables.isEmpty() || _tables.contains(tableName);
   }
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 384bf439dca..590650da788 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
@@ -19,8 +19,14 @@
 
 package org.apache.pinot.core.auth;
 
+import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import javax.annotation.Nullable;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.WebApplicationException;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedMap;
@@ -37,34 +43,62 @@ import org.slf4j.LoggerFactory;
 public class FineGrainedAuthUtils {
 
   private static final Logger LOGGER = 
LoggerFactory.getLogger(FineGrainedAuthUtils.class);
+  /// Memoizes [#getDeclaredQueryParams(Method)]; bounded by the number of 
endpoints.
+  private static final Map<Method, Set<String>> DECLARED_QUERY_PARAMS = new 
ConcurrentHashMap<>();
 
   private FineGrainedAuthUtils() {
   }
 
-  /// Returns the parameter from the path or query params.
-  /// @param paramName to look for
-  /// @param pathParams path params
-  /// @param queryParams query params
-  /// @return the value of the parameter
-  private static String findParam(String paramName, MultivaluedMap<String, 
String> pathParams,
-      MultivaluedMap<String, String> queryParams) {
-    String name = pathParams.getFirst(paramName);
-    if (name == null) {
-      name = queryParams.getFirst(paramName);
+  /// Returns the names `endpointMethod` binds as [QueryParam]s.
+  ///
+  /// Only method-level `@QueryParam` binding is recognized; an endpoint 
binding parameters through `@BeanParam` or
+  /// resource-class field injection is treated as declaring none. That 
direction denies table scope rather than
+  /// granting it, so it fails closed. No in-tree controller or broker 
resource uses either form today.
+  ///
+  /// Results are memoized because [Method#getParameterAnnotations()] 
re-parses the class-file annotation bytes on
+  /// every call, and this runs on every request before authentication. The 
key set is bounded by the number of
+  /// endpoints.
+  public static Set<String> getDeclaredQueryParams(Method endpointMethod) {
+    Set<String> declared = DECLARED_QUERY_PARAMS.get(endpointMethod);
+    if (declared != null) {
+      return declared;
+    }
+    return DECLARED_QUERY_PARAMS.computeIfAbsent(endpointMethod, 
FineGrainedAuthUtils::findDeclaredQueryParams);
+  }
+
+  private static Set<String> findDeclaredQueryParams(Method endpointMethod) {
+    Set<String> declared = new HashSet<>();
+    for (Annotation[] parameterAnnotations : 
endpointMethod.getParameterAnnotations()) {
+      for (Annotation parameterAnnotation : parameterAnnotations) {
+        if (parameterAnnotation instanceof QueryParam queryParam) {
+          declared.add(queryParam.value());
+        }
+      }
     }
-    return name;
+    return Set.copyOf(declared);
   }
 
   /// Finds the raw target parameter identified by an [Authorize] annotation.
   ///
+  /// Path parameters are template variables of the endpoint's own `@Path` and 
are always trusted. Query parameters
+  /// are caller-supplied, so only a name the method binds as `@QueryParam` 
may identify the table.
+  ///
   /// @param auth annotation identifying the authorization target
+  /// @param endpointMethod the resource method, used to decide which query 
parameters are declared
   /// @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;
+  public static String findRawTargetId(Authorize auth, Method endpointMethod,
+      MultivaluedMap<String, String> pathParams, MultivaluedMap<String, 
String> queryParams) {
+    if (auth.targetType() != TargetType.TABLE) {
+      return null;
+    }
+    String targetId = pathParams.getFirst(auth.paramName());
+    if (targetId == null && 
getDeclaredQueryParams(endpointMethod).contains(auth.paramName())) {
+      targetId = queryParams.getFirst(auth.paramName());
+    }
+    return targetId;
   }
 
   /// Validate fine-grained authorization for APIs.
@@ -74,8 +108,8 @@ public class FineGrainedAuthUtils {
   ///    a. The targetType is [TargetType#CLUSTER]. In this case, the 
paramName field
   ///       ([Authorize#paramName()]) is not used, since the target is the 
Pinot cluster.
   ///    b. The targetType is [TargetType#TABLE]. In this case, the paramName 
field
-  ///       ([Authorize#paramName()]) is mandatory, and it must be found in 
either the path parameters or the
-  ///       query parameters.
+  ///       ([Authorize#paramName()]) is mandatory, and it must be found in 
the path parameters or a query
+  ///       parameter the method binds as `@QueryParam`.
   /// 2. [Authorize] annotation is not present on the method. In this use the 
default authorization.
   ///
   /// @param endpointMethod of the API
@@ -97,8 +131,10 @@ public class FineGrainedAuthUtils {
               Response.Status.INTERNAL_SERVER_ERROR);
         }
 
-        // find the paramName in the path or query params
-        targetId = findRawTargetId(auth, uriInfo.getPathParameters(), 
uriInfo.getQueryParameters());
+        // Path params are part of the endpoint declaration. Query params are 
caller-supplied, so only a name
+        // the method binds as @QueryParam may identify the table. Otherwise a 
caller could append
+        // ?tableName=<a table it is scoped to> and have a cluster-wide 
request authorized as table-scoped.
+        targetId = findRawTargetId(auth, endpointMethod, 
uriInfo.getPathParameters(), uriInfo.getQueryParameters());
 
         if (StringUtils.isBlank(targetId)) {
           throw new WebApplicationException(
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/auth/BasicAuthTest.java 
b/pinot-core/src/test/java/org/apache/pinot/core/auth/BasicAuthTest.java
index 0c2ede7c51e..e31c15c1985 100644
--- a/pinot-core/src/test/java/org/apache/pinot/core/auth/BasicAuthTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/core/auth/BasicAuthTest.java
@@ -20,59 +20,84 @@ package org.apache.pinot.core.auth;
 
 import com.google.common.collect.ImmutableSet;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import org.apache.pinot.spi.env.PinotConfiguration;
-import org.testng.Assert;
 import org.testng.annotations.Test;
 
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
 
 public class BasicAuthTest {
 
   @Test
   public void testBasicAuthPrincipal() {
-    Assert.assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("READ"))
         .hasTable("myTable"));
-    Assert.assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable", "myTable1"),
+    assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable", "myTable1"),
         Set.of(), ImmutableSet.of("Read"))
         .hasTable("myTable1"));
-    Assert.assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("read"))
         .hasTable("myTable1"));
-    Assert.assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable", "myTable1"),
+    assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable", "myTable1"),
         Set.of(), ImmutableSet.of("read"))
         .hasTable("myTable2"));
-    Assert.assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), ImmutableSet.of("myTable"),
+    assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), ImmutableSet.of("myTable"),
         ImmutableSet.of("read"))
         .hasTable("myTable"));
-    Assert.assertFalse(new BasicAuthPrincipal("name", "token", Set.of(), 
ImmutableSet.of("myTable"),
+    assertFalse(new BasicAuthPrincipal("name", "token", Set.of(), 
ImmutableSet.of("myTable"),
         ImmutableSet.of("read"))
         .hasTable("myTable"));
-    Assert.assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), ImmutableSet.of("myTable1"),
+    assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), ImmutableSet.of("myTable1"),
         ImmutableSet.of("read"))
         .hasTable("myTable"));
-
-    Assert.assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertTrue(new BasicAuthPrincipal("name", "token", Set.of(), Set.of(), 
Set.of("read"))
+        .hasUnrestrictedTableAccess());
+    assertFalse(new BasicAuthPrincipal("name", "token", Set.of("myTable"), 
Set.of(), Set.of("read"))
+        .hasUnrestrictedTableAccess());
+    assertFalse(new BasicAuthPrincipal("name", "token", Set.of(), 
Set.of("myTable"), Set.of("read"))
+        .hasUnrestrictedTableAccess());
+
+    assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("READ"))
         .hasPermission("read"));
-    Assert.assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("Read"))
         .hasPermission("READ"));
-    Assert.assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertTrue(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("read"))
         .hasPermission("Read"));
-    Assert.assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertFalse(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("read"))
         .hasPermission("write"));
 
-    Assert.assertEquals(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
+    assertEquals(new BasicAuthPrincipal("name", "token", 
ImmutableSet.of("myTable"), Set.of(),
         ImmutableSet.of("read"), Map.of("myTable", List.of("cityID > 100")))
         .getRLSFilters("myTable"), Optional.of(List.of("cityID > 100")));
   }
 
+  @Test
+  public void testBasicAuthPrincipalWithNullTableEntry() {
+    Set<String> tables = new HashSet<>(Set.of("myTable"));
+    tables.add(null);
+    BasicAuthPrincipal included = new BasicAuthPrincipal("name", "token", 
tables, Set.of(), Set.of("read"));
+    assertTrue(included.hasTable("myTable"));
+    assertFalse(included.hasTable("otherTable"));
+    assertFalse(included.hasUnrestrictedTableAccess());
+
+    BasicAuthPrincipal excluded = new BasicAuthPrincipal("name", "token", 
Set.of(), tables, Set.of("read"));
+    assertFalse(excluded.hasTable("myTable"));
+    assertTrue(excluded.hasTable("otherTable"));
+    assertFalse(excluded.hasUnrestrictedTableAccess());
+  }
+
   @Test
   public void testExtractBasicAuthPrincipals() {
     // Test basic configuration with multiple principals
@@ -90,54 +115,54 @@ public class BasicAuthTest {
     List<BasicAuthPrincipal> principals = BasicAuthPrincipalUtils
         .extractBasicAuthPrincipals(configuration, "principals");
 
-    Assert.assertEquals(principals.size(), 2);
+    assertEquals(principals.size(), 2);
 
     // Verify admin principal (should have no table restrictions)
     BasicAuthPrincipal adminPrincipal = principals.stream()
         .filter(p -> p.getName().equals("admin"))
         .findFirst()
         .orElse(null);
-    Assert.assertNotNull(adminPrincipal);
-    Assert.assertEquals(adminPrincipal.getName(), "admin");
+    assertNotNull(adminPrincipal);
+    assertEquals(adminPrincipal.getName(), "admin");
 
     // Verify user principal
     BasicAuthPrincipal userPrincipal = principals.stream()
         .filter(p -> p.getName().equals("user"))
         .findFirst()
         .orElse(null);
-    Assert.assertNotNull(userPrincipal);
-    Assert.assertEquals(userPrincipal.getName(), "user");
+    assertNotNull(userPrincipal);
+    assertEquals(userPrincipal.getName(), "user");
 
     Set<String> expectedTables = ImmutableSet.of("lessImportantStuff", 
"lesserImportantStuff", "leastImportantStuff");
     expectedTables.forEach(tableName -> {
-      Assert.assertTrue(userPrincipal.hasTable(tableName));
+      assertTrue(userPrincipal.hasTable(tableName));
     });
 
     Set<String> expectedExcludeTables = ImmutableSet.of("excludedTable");
     expectedExcludeTables.forEach(tableName -> {
-      Assert.assertFalse(userPrincipal.hasTable(tableName));
+      assertFalse(userPrincipal.hasTable(tableName));
     });
 
     Set<String> expectedPermissions = ImmutableSet.of("read", "write");
     expectedPermissions.forEach(permission -> {
-      Assert.assertTrue(userPrincipal.hasPermission(permission));
+      assertTrue(userPrincipal.hasPermission(permission));
     });
 
     // Verify RLS filters
 
     List<String> lessImportantStuffFilters = 
userPrincipal.getRLSFilters("lessImportantStuff").get();
-    Assert.assertNotNull(lessImportantStuffFilters);
-    Assert.assertEquals(lessImportantStuffFilters.size(), 2);
-    Assert.assertTrue(lessImportantStuffFilters.contains("cityID > 100"));
-    Assert.assertTrue(lessImportantStuffFilters.contains("status = 'active'"));
+    assertNotNull(lessImportantStuffFilters);
+    assertEquals(lessImportantStuffFilters.size(), 2);
+    assertTrue(lessImportantStuffFilters.contains("cityID > 100"));
+    assertTrue(lessImportantStuffFilters.contains("status = 'active'"));
 
     List<String> lesserImportantStuffFilters = 
userPrincipal.getRLSFilters("lesserImportantStuff").get();
-    Assert.assertNotNull(lesserImportantStuffFilters);
-    Assert.assertEquals(lesserImportantStuffFilters.size(), 1);
-    Assert.assertTrue(lesserImportantStuffFilters.contains("region = 'US'"));
+    assertNotNull(lesserImportantStuffFilters);
+    assertEquals(lesserImportantStuffFilters.size(), 1);
+    assertTrue(lesserImportantStuffFilters.contains("region = 'US'"));
 
     // Verify no RLS filters for leastImportantStuff (not configured)
-    
Assert.assertTrue(userPrincipal.getRLSFilters("leastImportantStuff").isEmpty());
+    assertTrue(userPrincipal.getRLSFilters("leastImportantStuff").isEmpty());
   }
 
   @Test(expectedExceptions = IllegalArgumentException.class, 
expectedExceptionsMessageRegExp = "must provide "
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 4d9ec48c527..bd19d42720e 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
@@ -20,6 +20,7 @@ package org.apache.pinot.core.auth;
 
 import java.lang.reflect.Method;
 import java.net.URI;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.WebApplicationException;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MultivaluedHashMap;
@@ -27,115 +28,175 @@ 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;
 
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNull;
-
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+import static org.testng.Assert.fail;
 
 public class FineGrainedAuthUtilsTest {
 
   @Test
-  public void testFindRawTargetId() throws Exception {
+  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);
+    Method tableMethod = TestResource.class.getDeclaredMethod("getTable");
+    Method tableQueryMethod = 
TestResource.class.getDeclaredMethod("getTableByQuery", String.class);
+    Authorize tableAuth = tableMethod.getAnnotation(Authorize.class);
+    Authorize tableQueryAuth = tableQueryMethod.getAnnotation(Authorize.class);
     Authorize clusterAuth = 
getAnnotatedMethod().getAnnotation(Authorize.class);
 
     pathParams.putSingle("tableName", "pathTable");
-    assertEquals(FineGrainedAuthUtils.findRawTargetId(tableAuth, pathParams, 
queryParams), "pathTable");
+    assertEquals(FineGrainedAuthUtils.findRawTargetId(tableAuth, tableMethod, 
pathParams, queryParams), "pathTable");
 
     pathParams.clear();
     queryParams.putSingle("tableName", "queryTable");
-    assertEquals(FineGrainedAuthUtils.findRawTargetId(tableAuth, pathParams, 
queryParams), "queryTable");
-    assertNull(FineGrainedAuthUtils.findRawTargetId(clusterAuth, pathParams, 
queryParams));
+    // The annotation names tableName, but getTable never binds it, so the 
query value is not trusted.
+    assertNull(FineGrainedAuthUtils.findRawTargetId(tableAuth, tableMethod, 
pathParams, queryParams));
+    assertEquals(FineGrainedAuthUtils.findRawTargetId(tableQueryAuth, 
tableQueryMethod, pathParams, queryParams),
+        "queryTable");
+    assertNull(FineGrainedAuthUtils.findRawTargetId(clusterAuth, 
getAnnotatedMethod(), pathParams, queryParams));
 
     queryParams.clear();
-    assertNull(FineGrainedAuthUtils.findRawTargetId(tableAuth, pathParams, 
queryParams));
+    assertNull(FineGrainedAuthUtils.findRawTargetId(tableAuth, tableMethod, 
pathParams, queryParams));
+  }
+
+  @Test
+  public void testValidateFineGrainedAuthIgnoresUndeclaredTableQueryParam()
+      throws Exception {
+    FineGrainedAccessControl ac = mock(FineGrainedAccessControl.class);
+    when(ac.hasAccess(any(HttpHeaders.class), any(), any(), any()))
+        .thenReturn(true);
+
+    UriInfo mockUriInfo = mock(UriInfo.class);
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    queryParams.putSingle("tableName", "callerPicked");
+    when(mockUriInfo.getPathParameters()).thenReturn(pathParams);
+    when(mockUriInfo.getQueryParameters()).thenReturn(queryParams);
+    
when(mockUriInfo.getRequestUri()).thenReturn(URI.create("http://localhost/tables";));
+    HttpHeaders mockHttpHeaders = mock(HttpHeaders.class);
+
+    Method unboundTableMethod = 
TestResource.class.getDeclaredMethod("getTable");
+    try {
+      FineGrainedAuthUtils.validateFineGrainedAuth(unboundTableMethod, 
mockUriInfo, mockHttpHeaders, ac);
+      fail("Expected WebApplicationException");
+    } catch (WebApplicationException e) {
+      assertTrue(e.getMessage().contains("Missing required table parameter"));
+      assertEquals(e.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
+    }
+    verify(ac, never())
+        .hasAccess(any(HttpHeaders.class), any(), any(), any());
+  }
+
+  @Test
+  public void testValidateFineGrainedAuthAllowsDeclaredTableQueryParam()
+      throws Exception {
+    FineGrainedAccessControl ac = mock(FineGrainedAccessControl.class);
+    when(ac.hasAccess(any(HttpHeaders.class), any(), any(), any()))
+        .thenReturn(true);
+
+    UriInfo mockUriInfo = mock(UriInfo.class);
+    MultivaluedMap<String, String> pathParams = new MultivaluedHashMap<>();
+    MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();
+    queryParams.putSingle("tableName", "ownedTable");
+    when(mockUriInfo.getPathParameters()).thenReturn(pathParams);
+    when(mockUriInfo.getQueryParameters()).thenReturn(queryParams);
+    HttpHeaders mockHttpHeaders = mock(HttpHeaders.class);
+
+    Method declaredTableMethod = 
TestResource.class.getDeclaredMethod("getTableByQuery", String.class);
+    FineGrainedAuthUtils.validateFineGrainedAuth(declaredTableMethod, 
mockUriInfo, mockHttpHeaders, ac);
+    verify(ac).hasAccess(mockHttpHeaders, TargetType.TABLE, "ownedTable", 
"getTable");
   }
 
   @Test
   public void testValidateFineGrainedAuthAllowed() {
-    FineGrainedAccessControl ac = Mockito.mock(FineGrainedAccessControl.class);
-    Mockito.when(ac.hasAccess(Mockito.any(HttpHeaders.class), Mockito.any(), 
Mockito.any(), Mockito.any()))
+    FineGrainedAccessControl ac = mock(FineGrainedAccessControl.class);
+    when(ac.hasAccess(any(HttpHeaders.class), any(), any(), any()))
         .thenReturn(true);
 
-    UriInfo mockUriInfo = Mockito.mock(UriInfo.class);
-    HttpHeaders mockHttpHeaders = Mockito.mock(HttpHeaders.class);
+    UriInfo mockUriInfo = mock(UriInfo.class);
+    HttpHeaders mockHttpHeaders = mock(HttpHeaders.class);
 
     FineGrainedAuthUtils.validateFineGrainedAuth(getAnnotatedMethod(), 
mockUriInfo, mockHttpHeaders, ac);
   }
 
   @Test
   public void testValidateFineGrainedAuthDenied() {
-    FineGrainedAccessControl ac = Mockito.mock(FineGrainedAccessControl.class);
-    Mockito.when(ac.hasAccess(Mockito.any(HttpHeaders.class), Mockito.any(), 
Mockito.any(), Mockito.any()))
+    FineGrainedAccessControl ac = mock(FineGrainedAccessControl.class);
+    when(ac.hasAccess(any(HttpHeaders.class), any(), any(), any()))
         .thenReturn(false);
 
-    UriInfo mockUriInfo = Mockito.mock(UriInfo.class);
-    HttpHeaders mockHttpHeaders = Mockito.mock(HttpHeaders.class);
+    UriInfo mockUriInfo = mock(UriInfo.class);
+    HttpHeaders mockHttpHeaders = mock(HttpHeaders.class);
 
     try {
       FineGrainedAuthUtils.validateFineGrainedAuth(getAnnotatedMethod(), 
mockUriInfo, mockHttpHeaders, ac);
-      Assert.fail("Expected WebApplicationException");
+      fail("Expected WebApplicationException");
     } catch (WebApplicationException e) {
-      Assert.assertTrue(e.getMessage().contains("Access denied to getCluster 
in the cluster"));
-      Assert.assertEquals(e.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
+      assertTrue(e.getMessage().contains("Access denied to getCluster in the 
cluster"));
+      assertEquals(e.getResponse().getStatus(), 
Response.Status.FORBIDDEN.getStatusCode());
     }
   }
 
   @Test
   public void testValidateFineGrainedAuthWithNoSuchMethodError() {
-    FineGrainedAccessControl ac = Mockito.mock(FineGrainedAccessControl.class);
-    Mockito.when(ac.hasAccess(Mockito.any(HttpHeaders.class), Mockito.any(), 
Mockito.any(), Mockito.any()))
+    FineGrainedAccessControl ac = mock(FineGrainedAccessControl.class);
+    when(ac.hasAccess(any(HttpHeaders.class), any(), any(), any()))
         .thenThrow(new NoSuchMethodError("Method not found"));
 
-    UriInfo mockUriInfo = Mockito.mock(UriInfo.class);
-    HttpHeaders mockHttpHeaders = Mockito.mock(HttpHeaders.class);
+    UriInfo mockUriInfo = mock(UriInfo.class);
+    HttpHeaders mockHttpHeaders = mock(HttpHeaders.class);
 
     try {
       FineGrainedAuthUtils.validateFineGrainedAuth(getAnnotatedMethod(), 
mockUriInfo, mockHttpHeaders, ac);
-      Assert.fail("Expected WebApplicationException");
+      fail("Expected WebApplicationException");
     } catch (WebApplicationException e) {
-      Assert.assertTrue(e.getMessage().contains("Failed to check for access"));
-      Assert.assertEquals(e.getResponse().getStatus(), 
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode());
+      assertTrue(e.getMessage().contains("Failed to check for access"));
+      assertEquals(e.getResponse().getStatus(), 
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode());
     }
   }
 
   @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";));
+    UriInfo mockUriInfo = mock(UriInfo.class);
+    when(mockUriInfo.getPathParameters()).thenReturn(new 
MultivaluedHashMap<>());
+    when(mockUriInfo.getQueryParameters()).thenReturn(new 
MultivaluedHashMap<>());
+    
when(mockUriInfo.getRequestUri()).thenReturn(URI.create("http://localhost/v2/segments";));
 
-    WebApplicationException exception = 
Assert.expectThrows(WebApplicationException.class,
+    WebApplicationException exception = 
expectThrows(WebApplicationException.class,
         () -> 
FineGrainedAuthUtils.validateFineGrainedAuth(getTableAnnotatedMethod(), 
mockUriInfo,
-            Mockito.mock(HttpHeaders.class), 
Mockito.mock(FineGrainedAccessControl.class)));
+            mock(HttpHeaders.class), mock(FineGrainedAccessControl.class)));
 
-    Assert.assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
-    Assert.assertTrue(exception.getMessage().contains("Missing required table 
parameter 'tableName'"));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
+    assertTrue(exception.getMessage().contains("Missing required table 
parameter 'tableName'"));
   }
 
   @Test
   public void testInvalidTableParameterIsBadRequest() {
-    UriInfo mockUriInfo = Mockito.mock(UriInfo.class);
+    UriInfo mockUriInfo = 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");
+    when(mockUriInfo.getPathParameters()).thenReturn(new 
MultivaluedHashMap<>());
+    when(mockUriInfo.getQueryParameters()).thenReturn(queryParameters);
+    
when(mockUriInfo.getRequestUri()).thenReturn(URI.create("http://localhost/v2/segments";));
+    HttpHeaders headers = mock(HttpHeaders.class);
+    
when(headers.getHeaderString(CommonConstants.DATABASE)).thenReturn("databaseA");
 
-    WebApplicationException exception = 
Assert.expectThrows(WebApplicationException.class,
+    WebApplicationException exception = 
expectThrows(WebApplicationException.class,
         () -> 
FineGrainedAuthUtils.validateFineGrainedAuth(getTableAnnotatedMethod(), 
mockUriInfo, headers,
-            Mockito.mock(FineGrainedAccessControl.class)));
+            mock(FineGrainedAccessControl.class)));
 
-    Assert.assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
-    Assert.assertTrue(exception.getMessage().contains("Invalid table parameter 
'tableName'"));
+    assertEquals(exception.getResponse().getStatus(), 
Response.Status.BAD_REQUEST.getStatusCode());
+    assertTrue(exception.getMessage().contains("Invalid table parameter 
'tableName'"));
   }
 
   static class TestResource {
@@ -148,7 +209,11 @@ public class FineGrainedAuthUtilsTest {
     }
 
     @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action 
= "uploadSegment")
-    void uploadSegment() {
+    void uploadSegment(@QueryParam("tableName") String tableName) {
+    }
+
+    @Authorize(targetType = TargetType.TABLE, paramName = "tableName", action 
= "getTable")
+    void getTableByQuery(@QueryParam("tableName") String tableName) {
     }
   }
 
@@ -162,7 +227,7 @@ public class FineGrainedAuthUtilsTest {
 
   private Method getTableAnnotatedMethod() {
     try {
-      return TestResource.class.getDeclaredMethod("uploadSegment");
+      return TestResource.class.getDeclaredMethod("uploadSegment", 
String.class);
     } catch (NoSuchMethodException e) {
       throw new RuntimeException(e);
     }


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

Reply via email to