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