xiangfu0 commented on code in PR #18975:
URL: https://github.com/apache/pinot/pull/18975#discussion_r3817340731


##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BaseBasicAuthAccessControl.java:
##########
@@ -48,22 +55,36 @@ public final boolean hasAccess(String tableName, AccessType 
accessType, HttpHead
         && 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.
   @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();
+    return authenticatedPrincipal.hasUnrestrictedTableAccess()
+        && authenticatedPrincipal.hasPermission(Objects.toString(accessType));

Review Comment:
   **Back-compat case missing from the description: realtime segment 
completion.**
   
   The `LLCSegmentCompletionHandlers` endpoints — `/segmentConsumed`, 
`/segmentCommitStart`, `/segmentCommitEndWithMetadata`, `/segmentUpload`, 
`/segmentStoppedConsuming` — are all `@Authorize(targetType = 
TargetType.CLUSTER)` and declare no table-identifying query parameter (their 
params are `instanceId` / `segmentName` / offsets). So they resolve to a `null` 
table name and land here.
   
   On master a server principal scoped to the tables that server hosts, holding 
CREATE, passes these. After this change it gets 403 and realtime ingestion 
stalls at commit time.
   
   That is a data-path break rather than a UI one, so it belongs in the 
**Backward-incompatible** list next to `POST /segments` — anyone who set 
`pinot.server.segment.uploader.auth.token` (or the controller-facing auth 
token) to a table-scoped principal has to move it to an unrestricted one 
*before* rolling controllers.
   
   Same reasoning applies to minion callbacks on cluster-targeted task 
endpoints that carry no `tableName`.



##########
pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthPrincipal.java:
##########
@@ -62,6 +62,15 @@ public boolean hasTable(String tableName) {
     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.
+  public boolean hasUnrestrictedTableAccess() {
+    return _tables.isEmpty() && _excludeTables.isEmpty();
+  }
+

Review Comment:
   The contract and the subclass warning are good.
   
   Question on the choice of signal for the ZK-backed path: 
`ZkBasicAuthPrincipal` carries an explicit `RoleType` (ADMIN/USER), and the 
server-side `ZkBasicAuthAccessFactory` already authorizes off it via 
`hasPermission(RoleType.ADMIN, ComponentType.SERVER)`. Keying the controller's 
cluster gate purely off table scope means a ZK user with `RoleType.ADMIN` 
**and** a table allow-list loses `/users` and every other cluster endpoint.
   
   The bootstrapped admin from `initUserACLConfig` has `tables=null`, so the 
default deployment is fine — but if the intent is "admins run cluster 
endpoints", `RoleType` looks like the more direct signal for the ZK factory. 
Worth a sentence in the PR on why table scope was chosen over role, since the 
two can disagree.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/access/BaseBasicAuthAccessControl.java:
##########
@@ -36,8 +37,14 @@ public final boolean protectAnnotatedOnly() {
   }
 
   @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: callers
+    // include AccessControl's own default cluster overload and the 
/auth/verify probe, whose table name is optional.
+    if (tableName == null) {
+      return hasAccess(accessType, httpHeaders, endpointUrl);
+    }

Review Comment:
   Routing `null` here is right, and I confirmed it is load-bearing — it is 
what stops the table overload being used to sidestep the scope rule.
   
   One consequence worth adding to the back-compat list: the deprecated `GET 
/auth/verify` probe passes its optional `tableName` straight into this overload 
(`PinotControllerAuthResource#verify`). Called without `tableName` it now runs 
the cluster check, so a table-scoped principal gets `false` where it previously 
got `true`. The UI is on `v2` so the login gate is unaffected, but third-party 
tooling still polling the v1 probe will read that as a credential failure.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java:
##########
@@ -130,35 +142,70 @@ AccessType extractAccessType(Method endpointMethod) {
     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);
+      // The annotation names the parameter, but it is only trustworthy where 
the endpoint also binds it.
+      MultivaluedMap<String, String> trustedQueryParameters =
+          declaredQueryParams(endpointMethod).contains(authorize.paramName()) 
? queryParameters
+              : new MultivaluedHashMap<>();
+      return FineGrainedAuthUtils.findRawTargetId(authorize, pathParameters, 
trustedQueryParameters);

Review Comment:
   The new invariant — *only parameters the endpoint declares may name the 
table* — is applied here on the coarse path, but 
`FineGrainedAuthUtils.validateFineGrainedAuth` still resolves the same 
`@Authorize#paramName` from the **full** query map (`findRawTargetId(auth, 
uriInfo.getPathParameters(), uriInfo.getQueryParameters())`), and that is the 
copy the broker uses too.
   
   For BasicAuth this is inert, since `hasAccess(HttpHeaders, TargetType, 
targetId, action)` only checks authentication. But a custom 
`FineGrainedAccessControl` now receives a caller-steerable `targetId` that can 
disagree with the coarse decision the filter just made a few lines earlier — 
the coarse check treats the request as cluster-wide while the fine-grained 
check authorizes it against a table the caller picked.
   
   Since this PR establishes the invariant, threading the declared-parameter 
set into `findRawTargetId` (or filtering before the call, as done here) would 
keep the two resolutions from diverging. Same primitive, left open on the other 
side.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java:
##########
@@ -130,35 +142,70 @@ AccessType extractAccessType(Method endpointMethod) {
     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);
+      // The annotation names the parameter, but it is only trustworthy where 
the endpoint also binds it.
+      MultivaluedMap<String, String> trustedQueryParameters =
+          declaredQueryParams(endpointMethod).contains(authorize.paramName()) 
? queryParameters
+              : new MultivaluedHashMap<>();

Review Comment:
   Nit: this allocates a `MultivaluedHashMap` per request on what is actually 
the *common* table-endpoint path — most table endpoints bind the `@Authorize` 
`paramName` as a `@PathParam`, not a `@QueryParam` (e.g. `GET 
/tables/{tableName}`), so `declaredQueryParams(...)` does not contain it and 
the empty map is built every time.
   
   Slightly at odds with memoizing `getParameterAnnotations` two methods down 
for exactly this "runs on every request before authentication" reason. Inlining 
the two-step lookup avoids the allocation entirely and reads no worse:
   
   ```java
   String targetId = pathParameters.getFirst(authorize.paramName());
   if (targetId == null && 
declaredQueryParams(endpointMethod).contains(authorize.paramName())) {
     targetId = queryParameters.getFirst(authorize.paramName());
   }
   return targetId;
   ```
   
   (Or hoist a shared immutable empty `MultivaluedMap` constant, since 
`findParam` only reads it.)



##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java:
##########
@@ -130,35 +142,70 @@ AccessType extractAccessType(Method endpointMethod) {
     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);
+      // The annotation names the parameter, but it is only trustworthy where 
the endpoint also binds it.
+      MultivaluedMap<String, String> trustedQueryParameters =
+          declaredQueryParams(endpointMethod).contains(authorize.paramName()) 
? queryParameters
+              : new MultivaluedHashMap<>();
+      return FineGrainedAuthUtils.findRawTargetId(authorize, pathParameters, 
trustedQueryParameters);
     }
-    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);
+    Set<String> declaredQueryParams = declaredQueryParams(endpointMethod);
+    for (String key : TABLE_NAME_KEYS) {
+      if (queryParameters.containsKey(key) && 
declaredQueryParams.contains(key)) {
+        return queryParameters.getFirst(key);
+      }
+    }
+    return null;

Review Comment:
   This closes the reported bypass — I verified it is non-vacuous by reverting 
the `declaredQueryParams.contains(key)` conjunct and re-running: 
`ControllerClusterBasicAuthAuthorizationTest` fails with `expected [403] but 
found [200]` on `/cluster/configs?tableName=allowedTable`, in both the static 
and ZK factories.
   
   One caveat on the rule itself, worth a note rather than a change: *declared* 
is treated as *authoritative for scope*, which assumes every endpoint that 
binds `tableName` uses it to scope the operation. `GET 
/tasks/task/{taskName}/debug` declares `@QueryParam("tableName")`, but it only 
filters **subtask** details — `PinotHelixTaskResourceManager#getTaskDebugInfo` 
still returns task-level state, start/finish times, `triggeredBy` and subtask 
counts for the named task whatever table it belongs to. So a principal scoped 
to table A can still read task-level metadata for a task on table B via 
`?tableName=A`.
   
   Pre-existing (master trusts the parameter unconditionally, so this is not a 
regression), but the PR's framing implies declared parameters are safe and this 
one is not quite. A sentence in the description would set expectations for 
whoever adds the next cluster endpoint with an optional `tableName`.



##########
helm/pinot/values.yaml:
##########
@@ -82,6 +82,10 @@ 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.
+  # `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. Widening `tables` to restore cluster access 
would also widen table access.

Review Comment:
   Good that the migration guidance ships with the change.
   
   Two additions worth making here and in the PR's **Backward-incompatible** 
section, since both are silent failures rather than UI annoyances:
   
   - servers/minions authenticating to the controller with a **table-scoped** 
principal lose the `LLCSegmentCompletionHandlers` endpoints (no table parameter 
→ cluster-wide), which stalls realtime segment commit;
   - the deprecated `GET /auth/verify` probe answers `false` for a table-scoped 
principal when called without `tableName`.
   
   Also worth stating explicitly that the unrestricted principal must be 
granted **before** controllers roll, since during the mixed-version window the 
same request answers 200 or 403 depending on which controller serves it — that 
point is in the PR description but not in this chart comment, and the chart is 
what operators actually read.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AuthenticationFilter.java:
##########
@@ -130,35 +142,70 @@ AccessType extractAccessType(Method endpointMethod) {
     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);
+      // The annotation names the parameter, but it is only trustworthy where 
the endpoint also binds it.
+      MultivaluedMap<String, String> trustedQueryParameters =
+          declaredQueryParams(endpointMethod).contains(authorize.paramName()) 
? queryParameters
+              : new MultivaluedHashMap<>();
+      return FineGrainedAuthUtils.findRawTargetId(authorize, pathParameters, 
trustedQueryParameters);
     }
-    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);
+    Set<String> declaredQueryParams = declaredQueryParams(endpointMethod);
+    for (String key : TABLE_NAME_KEYS) {
+      if (queryParameters.containsKey(key) && 
declaredQueryParams.contains(key)) {
+        return queryParameters.getFirst(key);
+      }
+    }
+    return null;
   }
 
-  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);
+  /// 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 controller 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.

Review Comment:
   I checked this claim and it holds: no controller resource uses `@BeanParam`, 
and there is no field-injected `@QueryParam` anywhere under 
`pinot-controller/src/main/java/org/apache/pinot/controller/api/`.
   
   Since the guarantee is "a future endpoint that does will fail closed", a 
test would pin it — a method with a `@BeanParam` (or no parameter annotations 
at all) asserting `extractTableName(...)` returns `null` even with 
`?tableName=` supplied. Cheap, and it keeps the documented direction from 
silently drifting if someone later widens `findDeclaredQueryParams`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to