You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by xi...@apache.org on 2022/05/06 05:57:20 UTC
[pinot] branch master updated: Access Control management Development (#8314) (#8314)
This is an automated email from the ASF dual-hosted git repository.
xiangfu 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 328c0c23ec Access Control management Development (#8314) (#8314)
328c0c23ec is described below
commit 328c0c23ec2b0a10302a476dfeba2fd5f7a69ac6
Author: INNOCENT-BOY <38...@users.noreply.github.com>
AuthorDate: Fri May 6 13:57:15 2022 +0800
Access Control management Development (#8314) (#8314)
Co-authored-by: xuejyu <xu...@cisco.com>
---
.../pinot/broker/broker/AccessControlFactory.java | 21 +-
.../broker/BasicAuthAccessControlFactory.java | 2 +
.../broker/ZkBasicAuthAccessControlFactory.java | 122 +++++++++
.../broker/broker/helix/BaseBrokerStarter.java | 3 +-
pinot-common/pom.xml | 5 +
.../config/provider/AccessControlUserCache.java | 177 ++++++++++++
.../pinot/common/metadata/ZKMetadataProvider.java | 67 +++++
.../org/apache/pinot/common/utils/BcryptUtils.java | 50 ++++
.../utils/config/AccessControlUserConfigUtils.java | 88 ++++++
.../pinot-common-jdk8/pom.xml | 5 +
.../pinot/controller/BaseControllerStarter.java | 2 +-
.../apache/pinot/controller/ControllerConf.java | 21 ++
.../api/ControllerAdminApiApplication.java | 6 +
.../pinot/controller/api/access/AccessControl.java | 4 +
.../api/access/AccessControlFactory.java | 15 ++
.../controller/api/access/AccessControlUtils.java | 14 +
.../api/access/BasicAuthAccessControlFactory.java | 1 +
.../access/ZkBasicAuthAccessControlFactory.java | 131 +++++++++
.../api/exception/UserAlreadyExistsException.java} | 17 +-
.../pinot/controller/api/resources/Constants.java | 19 +-
.../PinotAccessControlUserRestletResource.java | 243 +++++++++++++++++
.../helix/core/PinotHelixResourceManager.java | 56 +++-
pinot-controller/src/main/resources/app/App.tsx | 30 ++-
.../src/main/resources/app/app_state.ts | 2 +
.../main/resources/app/components/Breadcrumbs.tsx | 3 +-
.../src/main/resources/app/components/Layout.tsx | 12 +-
.../main/resources/app/components/User/AddUser.tsx | 243 +++++++++++++++++
.../resources/app/components/User/UpdateUser.tsx | 235 ++++++++++++++++
.../src/main/resources/app/interfaces/types.d.ts | 17 ++
.../src/main/resources/app/pages/LoginPage.tsx | 1 +
.../src/main/resources/app/pages/UserPage.tsx | 296 +++++++++++++++++++++
.../src/main/resources/app/requests/index.ts | 19 +-
pinot-controller/src/main/resources/app/router.tsx | 2 +
.../main/resources/app/utils/PinotMethodUtils.ts | 46 +++-
.../PinotAccessControlUserRestletResourceTest.java | 138 ++++++++++
.../org/apache/pinot/core/auth/BasicAuthUtils.java | 87 +++++-
.../pinot/core/auth/ZkBasicAuthPrincipal.java | 56 ++++
.../pinot/server/access/AccessControlFactory.java | 14 +
.../server/access/BasicAuthAccessFactory.java | 1 +
.../server/access/ZkBasicAuthAccessFactory.java | 121 +++++++++
.../apache/pinot/core/auth/ZkBasicAuthTest.java | 59 ++++
.../pinot/server/starter/ServerInstance.java | 2 +-
.../apache/pinot/spi/config/user/AccessType.java | 11 +-
.../pinot/spi/config/user/ComponentType.java | 11 +-
.../org/apache/pinot/spi/config/user/RoleType.java | 11 +-
.../apache/pinot/spi/config/user/UserConfig.java | 140 ++++++++++
.../utils/builder/ControllerRequestURLBuilder.java | 21 ++
.../pinot/spi/utils/builder/UserConfigBuilder.java | 70 +++++
48 files changed, 2655 insertions(+), 62 deletions(-)
diff --git a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/AccessControlFactory.java b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/AccessControlFactory.java
index 49bfd3a52c..8e9cb1c695 100644
--- a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/AccessControlFactory.java
+++ b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/AccessControlFactory.java
@@ -18,6 +18,8 @@
*/
package org.apache.pinot.broker.broker;
+import org.apache.helix.ZNRecord;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
import org.apache.pinot.broker.api.AccessControl;
import org.apache.pinot.spi.env.PinotConfiguration;
import org.slf4j.Logger;
@@ -28,11 +30,24 @@ public abstract class AccessControlFactory {
public static final Logger LOGGER = LoggerFactory.getLogger(AccessControlFactory.class);
public static final String ACCESS_CONTROL_CLASS_CONFIG = "class";
- public abstract void init(PinotConfiguration confguration);
+ public void init(PinotConfiguration configuration) {
+ };
+
+ /**
+ * Extend original init method inorder to support Zookeeper BasicAuthAccessControlFactory
+ * Because ZKBasicAuthAccessControlFactory need to acquire users info from HelixPropertyStore
+ *
+ * @param configuration pinot configuration
+ * @param propertyStore Helix PropertyStore
+ */
+ public void init(PinotConfiguration configuration, ZkHelixPropertyStore<ZNRecord> propertyStore) {
+ init(configuration);
+ }
public abstract AccessControl create();
- public static AccessControlFactory loadFactory(PinotConfiguration configuration) {
+ public static AccessControlFactory loadFactory(PinotConfiguration configuration,
+ ZkHelixPropertyStore<ZNRecord> propertyStore) {
AccessControlFactory accessControlFactory;
String accessControlFactoryClassName = configuration.getProperty(ACCESS_CONTROL_CLASS_CONFIG);
if (accessControlFactoryClassName == null) {
@@ -42,7 +57,7 @@ public abstract class AccessControlFactory {
LOGGER.info("Instantiating Access control factory class {}", accessControlFactoryClassName);
accessControlFactory = (AccessControlFactory) Class.forName(accessControlFactoryClassName).newInstance();
LOGGER.info("Initializing Access control factory class {}", accessControlFactoryClassName);
- accessControlFactory.init(configuration);
+ accessControlFactory.init(configuration, propertyStore);
return accessControlFactory;
} catch (Exception e) {
throw new RuntimeException(e);
diff --git a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/BasicAuthAccessControlFactory.java b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/BasicAuthAccessControlFactory.java
index 8c0e75827e..91ae183e8c 100644
--- a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/BasicAuthAccessControlFactory.java
+++ b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/BasicAuthAccessControlFactory.java
@@ -55,10 +55,12 @@ public class BasicAuthAccessControlFactory extends AccessControlFactory {
// left blank
}
+ @Override
public void init(PinotConfiguration configuration) {
_accessControl = new BasicAuthAccessControl(BasicAuthUtils.extractBasicAuthPrincipals(configuration, PREFIX));
}
+ @Override
public AccessControl create() {
return _accessControl;
}
diff --git a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/ZkBasicAuthAccessControlFactory.java b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/ZkBasicAuthAccessControlFactory.java
new file mode 100644
index 0000000000..8d47977517
--- /dev/null
+++ b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/ZkBasicAuthAccessControlFactory.java
@@ -0,0 +1,122 @@
+/**
+ * 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.broker.broker;
+
+import com.google.common.base.Preconditions;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.apache.helix.ZNRecord;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.pinot.broker.api.AccessControl;
+import org.apache.pinot.broker.api.HttpRequesterIdentity;
+import org.apache.pinot.broker.api.RequesterIdentity;
+import org.apache.pinot.common.config.provider.AccessControlUserCache;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.utils.BcryptUtils;
+import org.apache.pinot.core.auth.BasicAuthPrincipal;
+import org.apache.pinot.core.auth.BasicAuthUtils;
+import org.apache.pinot.core.auth.ZkBasicAuthPrincipal;
+import org.apache.pinot.spi.env.PinotConfiguration;
+
+
+/**
+ * Zookeeper Basic Authentication based on Pinot Controller UI.
+ * The user role has been distinguished by user and admin. Only admin can have access to the
+ * user console page in Pinot controller UI. And admin can change user info (table permission/
+ * number of tables/password etc.) or add/delete user without restarting your Pinot clusters,
+ * and these changes happen immediately.
+ * Users Configuration store in Helix Zookeeper and encrypted user password via Bcrypt Encryption Algorithm.
+ *
+ */
+public class ZkBasicAuthAccessControlFactory extends AccessControlFactory {
+ private static final String HEADER_AUTHORIZATION = "authorization";
+
+ private AccessControl _accessControl;
+
+ public ZkBasicAuthAccessControlFactory() {
+ // left blank
+ }
+
+ @Override
+ public void init(PinotConfiguration configuration, ZkHelixPropertyStore<ZNRecord> propertyStore) {
+ _accessControl = new BasicAuthAccessControl(new AccessControlUserCache(propertyStore));
+ }
+
+ @Override
+ public AccessControl create() {
+ return _accessControl;
+ }
+
+ /**
+ * Access Control using header-based basic http authentication
+ */
+ private static class BasicAuthAccessControl implements AccessControl {
+ private Map<String, ZkBasicAuthPrincipal> _name2principal;
+ private final AccessControlUserCache _userCache;
+
+ public BasicAuthAccessControl(AccessControlUserCache userCache) {
+ _userCache = userCache;
+ }
+
+ @Override
+ public boolean hasAccess(RequesterIdentity requesterIdentity) {
+ return hasAccess(requesterIdentity, null);
+ }
+
+ @Override
+ public boolean hasAccess(RequesterIdentity requesterIdentity, BrokerRequest brokerRequest) {
+ Preconditions.checkArgument(requesterIdentity instanceof HttpRequesterIdentity,
+ "HttpRequesterIdentity required");
+ HttpRequesterIdentity identity = (HttpRequesterIdentity) requesterIdentity;
+
+ Collection<String> tokens = identity.getHttpHeaders().get(HEADER_AUTHORIZATION);
+
+ _name2principal = BasicAuthUtils.extractBasicAuthPrincipals(_userCache.getAllBrokerUserConfig())
+ .stream().collect(Collectors.toMap(BasicAuthPrincipal::getName, p -> p));
+
+
+ Map<String, String> name2password = tokens.stream().collect(Collectors
+ .toMap(BasicAuthUtils::extractUsername, BasicAuthUtils::extractPassword));
+ Map<String, ZkBasicAuthPrincipal> password2principal = name2password.keySet().stream()
+ .collect(Collectors.toMap(name2password::get, _name2principal::get));
+
+ Optional<ZkBasicAuthPrincipal> principalOpt =
+ password2principal.entrySet().stream()
+ .filter(entry -> BcryptUtils.checkpw(entry.getKey(), entry.getValue().getPassword()))
+ .map(u -> u.getValue()).filter(Objects::nonNull).findFirst();
+
+ if (!principalOpt.isPresent()) {
+ // no matching token? reject
+ return false;
+ }
+
+ ZkBasicAuthPrincipal principal = principalOpt.get();
+ if (brokerRequest == null || !brokerRequest.isSetQuerySource() || !brokerRequest.getQuerySource()
+ .isSetTableName()) {
+ // no table restrictions? accept
+ return true;
+ }
+
+ return principal.hasTable(brokerRequest.getQuerySource().getTableName());
+ }
+ }
+}
diff --git a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
index 8e232fb49e..676f8001cc 100644
--- a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
+++ b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
@@ -232,7 +232,8 @@ public abstract class BaseBrokerStarter implements ServiceStartable {
// Set up request handling classes
_routingManager = new BrokerRoutingManager(_brokerMetrics);
_routingManager.init(_spectatorHelixManager);
- _accessControlFactory = AccessControlFactory.loadFactory(_brokerConf.subset(Broker.ACCESS_CONTROL_CONFIG_PREFIX));
+ _accessControlFactory = AccessControlFactory.loadFactory(_brokerConf.subset(Broker.ACCESS_CONTROL_CONFIG_PREFIX),
+ _propertyStore);
HelixExternalViewBasedQueryQuotaManager queryQuotaManager =
new HelixExternalViewBasedQueryQuotaManager(_brokerMetrics, _instanceId);
queryQuotaManager.init(_spectatorHelixManager);
diff --git a/pinot-common/pom.xml b/pinot-common/pom.xml
index 2bb5b8127b..ee40642b05 100644
--- a/pinot-common/pom.xml
+++ b/pinot-common/pom.xml
@@ -435,6 +435,11 @@
<groupId>org.apache.yetus</groupId>
<artifactId>audience-annotations</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.mindrot</groupId>
+ <artifactId>jbcrypt</artifactId>
+ <version>0.4</version>
+ </dependency>
</dependencies>
<profiles>
<profile>
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
new file mode 100644
index 0000000000..102affcdeb
--- /dev/null
+++ b/pinot-common/src/main/java/org/apache/pinot/common/config/provider/AccessControlUserCache.java
@@ -0,0 +1,177 @@
+/**
+ * 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.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.I0Itec.zkclient.IZkChildListener;
+import org.I0Itec.zkclient.IZkDataListener;
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.helix.AccessOption;
+import org.apache.helix.ZNRecord;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.pinot.common.utils.config.AccessControlUserConfigUtils;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.UserConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@code AccessControlUserCache} caches all the user configs within the cluster, and listens on ZK changes to keep
+ * them in sync. It also maintains several component user config maps.
+ */
+public class AccessControlUserCache {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(AccessControlUserCache.class);
+ private static final String USER_CONFIG_PARENT_PATH = "/CONFIGS/USER";
+ private static final String USER_CONFIG_PATH_PREFIX = "/CONFIGS/USER/";
+
+ private final ZkHelixPropertyStore<ZNRecord> _propertyStore;
+
+ private final UserConfigChangeListener _userConfigChangeListener = new UserConfigChangeListener();
+
+ private final Map<String, UserConfig> _userConfigMap = new ConcurrentHashMap<>();
+ private final Map<String, UserConfig> _userControllerConfigMap = new ConcurrentHashMap<>();
+ private final Map<String, UserConfig> _userBrokerConfigMap = new ConcurrentHashMap<>();
+ private final Map<String, UserConfig> _userServerConfigMap = new ConcurrentHashMap<>();
+
+ public AccessControlUserCache(ZkHelixPropertyStore<ZNRecord> propertyStore) {
+ _propertyStore = propertyStore;
+ _propertyStore.subscribeChildChanges(USER_CONFIG_PARENT_PATH, _userConfigChangeListener);
+
+ List<String> users = _propertyStore.getChildNames(USER_CONFIG_PARENT_PATH, AccessOption.PERSISTENT);
+ if (CollectionUtils.isNotEmpty(users)) {
+ List<String> pathsToAdd = new ArrayList<>(users.size());
+ for (String user : users) {
+ pathsToAdd.add(USER_CONFIG_PATH_PREFIX + user);
+ }
+ addUserConfigs(pathsToAdd);
+ }
+ }
+
+ @Nullable
+ public List<UserConfig> getAllUserConfig() {
+ return _userConfigMap.values().stream().collect(Collectors.toList());
+ }
+
+ public List<UserConfig> getAllControllerUserConfig() {
+ return _userControllerConfigMap.values().stream().collect(Collectors.toList());
+ }
+
+ public List<UserConfig> getAllBrokerUserConfig() {
+ return _userBrokerConfigMap.values().stream().collect(Collectors.toList());
+ }
+
+ public List<UserConfig> getAllServerUserConfig() {
+ return _userServerConfigMap.values().stream().collect(Collectors.toList());
+ }
+
+ @Nullable
+ public List<String> getAllUserName() {
+ return _userConfigMap.keySet().stream().collect(Collectors.toList());
+ }
+
+ private void addUserConfigs(List<String> allUserZKStorePath) {
+ for (String userStoreZKPath : allUserZKStorePath) {
+ _propertyStore.subscribeDataChanges(userStoreZKPath, _userConfigChangeListener);
+ }
+ List<ZNRecord> allUserRecords = _propertyStore.get(allUserZKStorePath, null, AccessOption.PERSISTENT, false);
+ for (ZNRecord userRecord : allUserRecords) {
+ if (userRecord != null) {
+ try {
+ UserConfig userConfig = AccessControlUserConfigUtils.fromZNRecord(userRecord);
+ String username = userConfig.getUsernameWithComponent();
+
+ if (userConfig.getComponentType().equals(ComponentType.CONTROLLER)) {
+ _userControllerConfigMap.put(username, userConfig);
+ } else if (userConfig.getComponentType().equals(ComponentType.BROKER)) {
+ _userBrokerConfigMap.put(username, userConfig);
+ } else if (userConfig.getComponentType().equals(ComponentType.SERVER)) {
+ _userServerConfigMap.put(username, userConfig);
+ }
+ } catch (Exception e) {
+ LOGGER.error("Caught exception while adding user config for ZNRecord:{}", userRecord.getId(), e);
+ }
+ }
+ }
+ }
+
+ private void removeUserConfig(String userStoreZKPath) {
+ _propertyStore.unsubscribeDataChanges(userStoreZKPath, _userConfigChangeListener);
+ String usernameWithComponentType = userStoreZKPath.substring(USER_CONFIG_PATH_PREFIX.length());
+ if (usernameWithComponentType.endsWith("BROKER")) {
+ _userBrokerConfigMap.remove(usernameWithComponentType);
+ } else if (usernameWithComponentType.endsWith("CONTROLLER")) {
+ _userControllerConfigMap.remove(usernameWithComponentType);
+ } else if (usernameWithComponentType.endsWith("SERVER")) {
+ _userServerConfigMap.remove(usernameWithComponentType);
+ }
+ }
+
+ private class UserConfigChangeListener implements IZkChildListener, IZkDataListener {
+ @Override
+ public void handleChildChange(String userStoreZKPath, List<String> allUserList) throws Exception {
+ if (CollectionUtils.isEmpty(allUserList)) {
+ return;
+ }
+
+ List<String> newUserPathToAdd = new ArrayList<>();
+ for (String user : allUserList) {
+ if (!_userControllerConfigMap.containsKey(user) && !_userBrokerConfigMap.containsKey(user)
+ && !_userServerConfigMap.containsKey(user)) {
+ newUserPathToAdd.add(USER_CONFIG_PATH_PREFIX + user);
+ }
+ }
+ if (!newUserPathToAdd.isEmpty()) {
+ addUserConfigs(newUserPathToAdd);
+ }
+ }
+
+ @Override
+ public void handleDataChange(String userStoreZKPath, Object changedUserConfig) throws Exception {
+ if (changedUserConfig != null) {
+ ZNRecord userRecord = (ZNRecord) changedUserConfig;
+ try {
+ UserConfig userConfig = AccessControlUserConfigUtils.fromZNRecord(userRecord);
+ String usernameWithComponentType = userConfig.getUsernameWithComponent();
+ if (userConfig.getComponentType().equals(ComponentType.CONTROLLER)) {
+ _userControllerConfigMap.put(usernameWithComponentType, userConfig);
+ } else if (userConfig.getComponentType().equals(ComponentType.BROKER)) {
+ _userBrokerConfigMap.put(usernameWithComponentType, userConfig);
+ } else if (userConfig.getComponentType().equals(ComponentType.SERVER)) {
+ _userServerConfigMap.put(usernameWithComponentType, userConfig);
+ }
+ } catch (Exception e) {
+ LOGGER.error("caught exception while refreshing table config for ZNRecord: {}",
+ userRecord.getId(), e);
+ }
+ }
+ }
+
+ @Override
+ public void handleDataDeleted(String path) throws Exception {
+ String username = path.substring(path.lastIndexOf('/') + 1);
+ removeUserConfig(USER_CONFIG_PATH_PREFIX + username);
+ }
+ }
+}
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/metadata/ZKMetadataProvider.java b/pinot-common/src/main/java/org/apache/pinot/common/metadata/ZKMetadataProvider.java
index 38b74f31f5..0633e32672 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/metadata/ZKMetadataProvider.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/metadata/ZKMetadataProvider.java
@@ -21,6 +21,9 @@ package org.apache.pinot.common.metadata;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.I0Itec.zkclient.exception.ZkBadVersionException;
import org.apache.helix.AccessOption;
@@ -30,10 +33,12 @@ import org.apache.pinot.common.metadata.instance.InstanceZKMetadata;
import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
import org.apache.pinot.common.utils.SchemaUtils;
import org.apache.pinot.common.utils.SegmentName;
+import org.apache.pinot.common.utils.config.AccessControlUserConfigUtils;
import org.apache.pinot.common.utils.config.TableConfigUtils;
import org.apache.pinot.spi.config.ConfigUtils;
import org.apache.pinot.spi.config.table.TableConfig;
import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.user.UserConfig;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.utils.CommonConstants;
import org.apache.pinot.spi.utils.StringUtil;
@@ -54,11 +59,17 @@ public class ZKMetadataProvider {
private static final String PROPERTYSTORE_SCHEMAS_PREFIX = "/SCHEMAS";
private static final String PROPERTYSTORE_INSTANCE_PARTITIONS_PREFIX = "/INSTANCE_PARTITIONS";
private static final String PROPERTYSTORE_TABLE_CONFIGS_PREFIX = "/CONFIGS/TABLE";
+ private static final String PROPERTYSTORE_USER_CONFIGS_PREFIX = "/CONFIGS/USER";
private static final String PROPERTYSTORE_INSTANCE_CONFIGS_PREFIX = "/CONFIGS/INSTANCE";
private static final String PROPERTYSTORE_CLUSTER_CONFIGS_PREFIX = "/CONFIGS/CLUSTER";
private static final String PROPERTYSTORE_SEGMENT_LINEAGE = "/SEGMENT_LINEAGE";
private static final String PROPERTYSTORE_MINION_TASK_METADATA_PREFIX = "/MINION_TASK_METADATA";
+ public static void setUserConfig(ZkHelixPropertyStore<ZNRecord> propertyStore, String username, ZNRecord znRecord) {
+ propertyStore
+ .set(constructPropertyStorePathForUserConfig(username), znRecord, AccessOption.PERSISTENT);
+ }
+
public static void setRealtimeTableConfig(ZkHelixPropertyStore<ZNRecord> propertyStore, String realtimeTableName,
ZNRecord znRecord) {
propertyStore.set(constructPropertyStorePathForResourceConfig(realtimeTableName), znRecord,
@@ -107,6 +118,10 @@ public class ZKMetadataProvider {
return StringUtil.join("/", PROPERTYSTORE_TABLE_CONFIGS_PREFIX, resourceName);
}
+ public static String constructPropertyStorePathForUserConfig(String resourceName) {
+ return StringUtil.join("/", PROPERTYSTORE_USER_CONFIGS_PREFIX, resourceName);
+ }
+
public static String constructPropertyStorePathForControllerConfig(String controllerConfigKey) {
return StringUtil.join("/", PROPERTYSTORE_CLUSTER_CONFIGS_PREFIX, controllerConfigKey);
}
@@ -141,6 +156,14 @@ public class ZKMetadataProvider {
}
}
+ public static void removeUserConfigFromPropertyStore(ZkHelixPropertyStore<ZNRecord> propertyStore,
+ String username) {
+ String propertyStorePath = constructPropertyStorePathForUserConfig(username);
+ if (propertyStore.exists(propertyStorePath, AccessOption.PERSISTENT)) {
+ propertyStore.remove(propertyStorePath, AccessOption.PERSISTENT);
+ }
+ }
+
/**
* Creates a new znode for SegmentZkMetadata. This call is atomic. If there are concurrent calls trying to create the
* same znode, only one of them would succeed.
@@ -199,6 +222,50 @@ public class ZKMetadataProvider {
return znRecord != null ? new SegmentZKMetadata(znRecord) : null;
}
+ @Nullable
+ public static UserConfig getUserConfig(ZkHelixPropertyStore<ZNRecord> propertyStore, String username) {
+ ZNRecord znRecord = propertyStore
+ .get(constructPropertyStorePathForUserConfig(username), null, AccessOption.PERSISTENT);
+ if (znRecord == null) {
+ return null;
+ }
+ try {
+ UserConfig userConfig = AccessControlUserConfigUtils.fromZNRecord(znRecord);
+ return (UserConfig) ConfigUtils.applyConfigWithEnvVariables(userConfig);
+ } catch (Exception e) {
+ LOGGER.error("Caught exception while getting user configuration for user: {}", username, e);
+ return null;
+ }
+ }
+
+ @Nullable
+ public static List<UserConfig> getAllUserConfig(ZkHelixPropertyStore<ZNRecord> propertyStore) {
+ List<ZNRecord> znRecordss = propertyStore
+ .getChildren(PROPERTYSTORE_USER_CONFIGS_PREFIX, null, AccessOption.PERSISTENT);
+
+ try {
+ return Optional.ofNullable(znRecordss)
+ .orElseGet(() -> {
+ return new ArrayList<>();
+ }).stream().map(AccessControlUserConfigUtils::fromZNRecord).collect(Collectors.toList());
+ } catch (Exception e) {
+ LOGGER.error("Caught exception while getting user list configuration", e);
+ return null;
+ }
+ }
+
+ @Nullable
+ public static List<String> getAllUserName(ZkHelixPropertyStore<ZNRecord> propertyStore) {
+ return propertyStore
+ .getChildNames(PROPERTYSTORE_USER_CONFIGS_PREFIX, AccessOption.PERSISTENT);
+ }
+
+ @Nullable
+ public static Map<String, UserConfig> getAllUserInfo(ZkHelixPropertyStore<ZNRecord> propertyStore) {
+ return getAllUserConfig(propertyStore).stream()
+ .collect(Collectors.toMap(UserConfig::getUsernameWithComponent, u -> u));
+ }
+
@Nullable
public static TableConfig getTableConfig(ZkHelixPropertyStore<ZNRecord> propertyStore, String tableNameWithType) {
return toTableConfig(propertyStore.get(constructPropertyStorePathForResourceConfig(tableNameWithType), null,
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/utils/BcryptUtils.java b/pinot-common/src/main/java/org/apache/pinot/common/utils/BcryptUtils.java
new file mode 100644
index 0000000000..ca068ae720
--- /dev/null
+++ b/pinot-common/src/main/java/org/apache/pinot/common/utils/BcryptUtils.java
@@ -0,0 +1,50 @@
+/**
+ * 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.utils;
+
+import org.mindrot.jbcrypt.BCrypt;
+
+public class BcryptUtils {
+
+ private static final int DEFALUT_LOG_ROUNDS = 10;
+ private static String _bcryptPassword = null;
+
+ private BcryptUtils() {
+ }
+
+ public static String encrypt(String password) {
+ return encrypt(password, DEFALUT_LOG_ROUNDS);
+ }
+
+ public static String encrypt(String password, int saltLogRrounds) {
+ _bcryptPassword = BCrypt.hashpw(password, BCrypt.gensalt(saltLogRrounds));
+ return _bcryptPassword;
+ }
+
+ public static boolean checkpw(String pasword, String encrypedPassword) {
+ boolean isMatch = false;
+ try {
+ isMatch = BCrypt.checkpw(pasword, encrypedPassword);
+ } catch (Exception e) {
+ System.out.println(e.getMessage());
+ } finally {
+ return isMatch;
+ }
+ }
+}
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/AccessControlUserConfigUtils.java b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/AccessControlUserConfigUtils.java
new file mode 100644
index 0000000000..810b3441de
--- /dev/null
+++ b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/AccessControlUserConfigUtils.java
@@ -0,0 +1,88 @@
+/**
+ * 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.utils.config;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.helix.ZNRecord;
+import org.apache.pinot.spi.config.user.AccessType;
+import org.apache.pinot.spi.config.user.UserConfig;
+
+/**
+ * UserConfigUtils is responsible for two things:
+ * 1. Used to acquire user config by parsing znRecord that stored in Zookeeper
+ * 2. Used to construct znRecord by packaging user config
+ */
+public class AccessControlUserConfigUtils {
+ private AccessControlUserConfigUtils() {
+ }
+
+ public static UserConfig fromZNRecord(ZNRecord znRecord) {
+ Map<String, String> simpleFields = znRecord.getSimpleFields();
+
+ // Mandatory fields
+ String username = simpleFields.get(UserConfig.USERNAME_KEY);
+ String password = simpleFields.get(UserConfig.PASSWORD_KEY);
+ String component = simpleFields.get(UserConfig.COMPONET_KEY);
+ String role = simpleFields.get(UserConfig.ROLE_KEY);
+
+ List<String> tableList = znRecord.getListField(UserConfig.TABLES_KEY);
+
+ List<String> permissionListFromZNRecord = znRecord.getListField(UserConfig.PERMISSIONS_KEY);
+ List<AccessType> permissionList = null;
+ if (permissionListFromZNRecord != null) {
+ permissionList = permissionListFromZNRecord.stream()
+ .map(x -> AccessType.valueOf(x)).collect(Collectors.toList());
+ }
+ return new UserConfig(username, password, component, role, tableList, permissionList);
+ }
+
+ public static ZNRecord toZNRecord(UserConfig userConfig)
+ throws JsonProcessingException {
+ Map<String, String> simpleFields = new HashMap<>();
+
+ // Mandatory fields
+ simpleFields.put(UserConfig.USERNAME_KEY, userConfig.getUserName());
+ simpleFields.put(UserConfig.PASSWORD_KEY, userConfig.getPassword());
+ simpleFields.put(UserConfig.COMPONET_KEY, userConfig.getComponentType().toString());
+ simpleFields.put(UserConfig.ROLE_KEY, userConfig.getRoleType().toString());
+
+ Map<String, List<String>> listFields = new HashMap<>();
+
+ // Optional fields
+ List<String> tableList = userConfig.getTables();
+ if (tableList != null) {
+ listFields.put(UserConfig.TABLES_KEY, userConfig.getTables());
+ }
+
+ List<AccessType> permissionList = userConfig.getPermissios();
+ if (permissionList != null) {
+ listFields.put(UserConfig.PERMISSIONS_KEY, userConfig.getPermissios().stream()
+ .map(e -> e.toString()).collect(Collectors.toList()));
+ }
+
+ ZNRecord znRecord = new ZNRecord(userConfig.getUserName());
+ znRecord.setSimpleFields(simpleFields);
+ znRecord.setListFields(listFields);
+ return znRecord;
+ }
+}
diff --git a/pinot-connectors/prestodb-pinot-dependencies/pinot-common-jdk8/pom.xml b/pinot-connectors/prestodb-pinot-dependencies/pinot-common-jdk8/pom.xml
index 095cfca22c..d544fa26ba 100644
--- a/pinot-connectors/prestodb-pinot-dependencies/pinot-common-jdk8/pom.xml
+++ b/pinot-connectors/prestodb-pinot-dependencies/pinot-common-jdk8/pom.xml
@@ -419,5 +419,10 @@
<groupId>org.apache.yetus</groupId>
<artifactId>audience-annotations</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.mindrot</groupId>
+ <artifactId>jbcrypt</artifactId>
+ <version>0.4</version>
+ </dependency>
</dependencies>
</project>
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/BaseControllerStarter.java b/pinot-controller/src/main/java/org/apache/pinot/controller/BaseControllerStarter.java
index 6fe183a128..5dcc864ca3 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/BaseControllerStarter.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/BaseControllerStarter.java
@@ -435,7 +435,7 @@ public abstract class BaseControllerStarter implements ServiceStartable {
final AccessControlFactory accessControlFactory;
try {
accessControlFactory = (AccessControlFactory) Class.forName(accessControlFactoryClass).newInstance();
- accessControlFactory.init(_config);
+ accessControlFactory.init(_config, _helixResourceManager);
} catch (Exception e) {
throw new RuntimeException("Caught exception while creating new AccessControlFactory instance", e);
}
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java b/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
index ef031362b4..a7f54b39c6 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/ControllerConf.java
@@ -216,6 +216,8 @@ public class ControllerConf extends PinotConfiguration {
private static final String JERSEY_ADMIN_API_PORT = "jersey.admin.api.port";
private static final String JERSEY_ADMIN_IS_PRIMARY = "jersey.admin.isprimary";
public static final String ACCESS_CONTROL_FACTORY_CLASS = "controller.admin.access.control.factory.class";
+ public static final String ACCESS_CONTROL_USERNAME = "access.control.init.username";
+ public static final String ACCESS_CONTROL_PASSWORD = "access.control.init.password";
// Amount of the time the segment can take from the beginning of upload to the end of upload. Used when parallel push
// protection is enabled. If the upload does not finish within the timeout, next upload can override the previous one.
private static final String SEGMENT_UPLOAD_TIMEOUT_IN_MILLIS = "controller.segment.upload.timeoutInMillis";
@@ -240,6 +242,8 @@ public class ControllerConf extends PinotConfiguration {
private static final int DEFAULT_JERSEY_ADMIN_PORT = 21000;
private static final String DEFAULT_ACCESS_CONTROL_FACTORY_CLASS =
"org.apache.pinot.controller.api.access.AllowAllAccessFactory";
+ private static final String DEFAULT_ACCESS_CONTROL_USERNAME = "admin";
+ private static final String DEFAULT_ACCESS_CONTROL_PASSWORD = "admin";
private static final long DEFAULT_SEGMENT_UPLOAD_TIMEOUT_IN_MILLIS = 600_000L; // 10 minutes
private static final int DEFAULT_REALTIME_SEGMENT_METADATA_COMMIT_NUMLOCKS = 64;
private static final boolean DEFAULT_ENABLE_STORAGE_QUOTA_CHECK = true;
@@ -709,10 +713,27 @@ public class ControllerConf extends PinotConfiguration {
return getProperty(JERSEY_ADMIN_API_PORT, String.valueOf(DEFAULT_JERSEY_ADMIN_PORT));
}
+ public void setInitAccessControlUsername(String username) {
+ setProperty(ACCESS_CONTROL_USERNAME, username);
+ }
+
+ public void setInitAccessControlPassword(String password) {
+ setProperty(ACCESS_CONTROL_PASSWORD, password);
+ }
+
+ public String getInitAccessControlUsername() {
+ return getProperty(ACCESS_CONTROL_USERNAME, DEFAULT_ACCESS_CONTROL_USERNAME);
+ }
+
+ public String getInitAccessControlPassword() {
+ return getProperty(ACCESS_CONTROL_PASSWORD, DEFAULT_ACCESS_CONTROL_PASSWORD);
+ }
+
public String getAccessControlFactoryClass() {
return getProperty(ACCESS_CONTROL_FACTORY_CLASS, DEFAULT_ACCESS_CONTROL_FACTORY_CLASS);
}
+
public void setAccessControlFactoryClass(String accessControlFactoryClass) {
setProperty(ACCESS_CONTROL_FACTORY_CLASS, accessControlFactoryClass);
}
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/api/ControllerAdminApiApplication.java b/pinot-controller/src/main/java/org/apache/pinot/controller/api/ControllerAdminApiApplication.java
index 3f060dead9..de74d90304 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/api/ControllerAdminApiApplication.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/ControllerAdminApiApplication.java
@@ -23,6 +23,7 @@ import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.List;
+import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
@@ -137,6 +138,11 @@ public class ControllerAdminApiApplication extends ResourceConfig {
ContainerResponseContext containerResponseContext)
throws IOException {
containerResponseContext.getHeaders().add("Access-Control-Allow-Origin", "*");
+ containerResponseContext.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS, DELETE");
+ containerResponseContext.getHeaders().add("Access-Control-Allow-Headers", "*");
+ if (containerRequestContext.getMethod().equals("OPTIONS")) {
+ containerResponseContext.setStatus(HttpServletResponse.SC_OK);
+ }
}
}
}
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
index be44be364e..3e19a9a45c 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControl.java
@@ -67,6 +67,10 @@ public interface AccessControl {
return true;
}
+ default boolean hasAccess(HttpHeaders httpHeaders) {
+ return true;
+ }
+
/**
* Determine whether authentication is required for annotated (controller) endpoints only
*
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlFactory.java b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlFactory.java
index 2c7978493c..9ce1df3d31 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlFactory.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlFactory.java
@@ -18,6 +18,8 @@
*/
package org.apache.pinot.controller.api.access;
+import java.io.IOException;
+import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
import org.apache.pinot.spi.annotations.InterfaceAudience;
import org.apache.pinot.spi.annotations.InterfaceStability;
import org.apache.pinot.spi.env.PinotConfiguration;
@@ -29,6 +31,19 @@ public interface AccessControlFactory {
default void init(PinotConfiguration pinotConfiguration) {
// left blank
}
+ /**
+ * Extend the original init method to support Zookeeper BasicAuthAccessControlFactory.
+ * Because ZKBasicAuthAccessControlFactory needs to acquire users' info from HelixPropertyStore.
+ * The reason why passed helixResourceManager param other than HelixPropertyStore is that
+ * we need to init UserACLConfig via helixResourceManager.
+ *
+ * @param configuration pinot configuration
+ * @param helixResourceManager Helix resource manager
+ */
+ default void init(PinotConfiguration configuration, PinotHelixResourceManager helixResourceManager)
+ throws IOException {
+ init(configuration);
+ }
AccessControl create();
}
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlUtils.java b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlUtils.java
index 95010c4648..c12ba307bd 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlUtils.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/AccessControlUtils.java
@@ -93,4 +93,18 @@ public class AccessControlUtils {
Response.Status.FORBIDDEN);
}
}
+
+ /**
+ * Validate permission for the given access type against the given table
+ *
+ * @param httpHeaders HTTP headers containing requester identity required by access control object
+ * @param endpointUrl the request url for which this access control is called
+ */
+ public void validatePermission(HttpHeaders httpHeaders, String endpointUrl,
+ AccessControl accessControl) {
+ if (!accessControl.hasAccess(httpHeaders)) {
+ throw new ControllerApplicationException(LOGGER, "Permission is denied for " + endpointUrl,
+ Response.Status.FORBIDDEN);
+ }
+ }
}
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 378c58f675..9f53659c13 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
@@ -50,6 +50,7 @@ public class BasicAuthAccessControlFactory implements AccessControlFactory {
private AccessControl _accessControl;
+ @Override
public void init(PinotConfiguration configuration) {
_accessControl = new BasicAuthAccessControl(BasicAuthUtils.extractBasicAuthPrincipals(configuration, PREFIX));
}
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
new file mode 100644
index 0000000000..d032b98681
--- /dev/null
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/access/ZkBasicAuthAccessControlFactory.java
@@ -0,0 +1,131 @@
+/**
+ * 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 java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.ws.rs.core.HttpHeaders;
+import org.apache.pinot.common.config.provider.AccessControlUserCache;
+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.BasicAuthUtils;
+import org.apache.pinot.core.auth.ZkBasicAuthPrincipal;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+
+
+/**
+ * Zookeeper Basic Authentication based on Pinot Controller UI.
+ * The user role has been distinguished by user and admin. Only admin can have access to the
+ * user console page in Pinot controller UI. And admin can change user info (table permission/
+ * number of tables/password etc.) or add/delete user without restarting your Pinot clusters,
+ * 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 String HEADER_AUTHORIZATION = "Authorization";
+
+ private AccessControl _accessControl;
+
+ @Override
+ public void init(PinotConfiguration pinotConfiguration, PinotHelixResourceManager pinotHelixResourceManager)
+ throws IOException {
+ pinotHelixResourceManager.initUserACLConfig((ControllerConf) pinotConfiguration);
+ _accessControl = new BasicAuthAccessControl(new AccessControlUserCache(
+ pinotHelixResourceManager.getPropertyStore()));
+ }
+
+ @Override
+ public AccessControl create() {
+ return _accessControl;
+ }
+
+ /**
+ * Access Control using header-based basic http authentication
+ */
+ private static class BasicAuthAccessControl implements AccessControl {
+ private Map<String, ZkBasicAuthPrincipal> _name2principal;
+ private final AccessControlUserCache _userCache;
+
+ public BasicAuthAccessControl(AccessControlUserCache userCache) {
+ _userCache = userCache;
+ }
+
+ @Override
+ public boolean protectAnnotatedOnly() {
+ return false;
+ }
+
+ @Override
+ public boolean hasDataAccess(HttpHeaders httpHeaders, String tableName) {
+ return getPrincipal(httpHeaders).filter(p -> p.hasTable(tableName)).isPresent();
+ }
+
+ @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) {
+ return getPrincipal(httpHeaders).isPresent();
+ }
+
+ @Override
+ public boolean hasAccess(HttpHeaders httpHeaders) {
+ return getPrincipal(httpHeaders)
+ .filter(p -> p.hasPermission(RoleType.ADMIN, ComponentType.CONTROLLER)).isPresent();
+ }
+
+ private Optional<ZkBasicAuthPrincipal> getPrincipal(HttpHeaders headers) {
+ if (headers == null) {
+ return Optional.empty();
+ }
+
+ _name2principal = BasicAuthUtils.extractBasicAuthPrincipals(_userCache.getAllControllerUserConfig())
+ .stream().collect(Collectors.toMap(ZkBasicAuthPrincipal::getName, p -> p));
+
+ List<String> authHeaders = headers.getRequestHeader(HEADER_AUTHORIZATION);
+ if (authHeaders == null) {
+ return Optional.empty();
+ }
+ Map<String, String> name2password = authHeaders.stream().collect(Collectors
+ .toMap(BasicAuthUtils::extractUsername, BasicAuthUtils::extractPassword));
+ Map<String, ZkBasicAuthPrincipal> password2principal = name2password.keySet().stream()
+ .collect(Collectors.toMap(name2password::get, _name2principal::get));
+
+ return password2principal.entrySet().stream()
+ .filter(entry -> BcryptUtils.checkpw(entry.getKey(), entry.getValue().getPassword()))
+ .map(u -> u.getValue()).filter(Objects::nonNull).findFirst();
+ }
+
+ @Override
+ public AuthWorkflowInfo getAuthWorkflowInfo() {
+ return new AuthWorkflowInfo(AccessControl.WORKFLOW_BASIC);
+ }
+ }
+}
diff --git a/pinot-controller/src/main/resources/app/app_state.ts b/pinot-controller/src/main/java/org/apache/pinot/controller/api/exception/UserAlreadyExistsException.java
similarity index 72%
copy from pinot-controller/src/main/resources/app/app_state.ts
copy to pinot-controller/src/main/java/org/apache/pinot/controller/api/exception/UserAlreadyExistsException.java
index 0ef89dc3c4..87ae4ba01f 100644
--- a/pinot-controller/src/main/resources/app/app_state.ts
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/exception/UserAlreadyExistsException.java
@@ -16,13 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
+package org.apache.pinot.controller.api.exception;
- import { AuthWorkflow } from "Models";
-class app_state {
- queryConsoleOnlyView: boolean;
- authWorkflow: AuthWorkflow;
- authToken: string | null;
- columnNameSeparator: string = '#$%';
-}
+public class UserAlreadyExistsException extends RuntimeException {
+ public UserAlreadyExistsException(String message) {
+ super(message);
+ }
-export default new app_state();
\ No newline at end of file
+ public UserAlreadyExistsException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
index 714fbc4ff0..109bdfc565 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/Constants.java
@@ -20,7 +20,9 @@ package org.apache.pinot.controller.api.resources;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response.Status;
+import org.apache.commons.lang3.StringUtils;
import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.user.ComponentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,6 +35,7 @@ public class Constants {
public static final String CLUSTER_TAG = "Cluster";
public static final String TABLE_TAG = "Table";
+ public static final String USER_TAG = "User";
public static final String VERSION_TAG = "Version";
public static final String HEALTH_TAG = "Health";
public static final String INSTANCE_TAG = "Instance";
@@ -49,7 +52,7 @@ public class Constants {
public static final String UPSERT_RESOURCE_TAG = "Upsert";
public static TableType validateTableType(String tableTypeStr) {
- if (tableTypeStr == null || tableTypeStr.isEmpty()) {
+ if (StringUtils.isBlank(tableTypeStr)) {
return null;
}
try {
@@ -60,8 +63,20 @@ public class Constants {
}
}
+ public static ComponentType validateComponentType(String componentTypeStr) {
+ if (StringUtils.isBlank(componentTypeStr)) {
+ return null;
+ }
+ try {
+ return ComponentType.valueOf(componentTypeStr.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ LOGGER.info("Illegal table type '{}'", componentTypeStr);
+ throw new WebApplicationException("Illegal table type '" + componentTypeStr + "'", Status.BAD_REQUEST);
+ }
+ }
+
public static StateType validateState(String stateStr) {
- if (stateStr == null) {
+ if (StringUtils.isBlank(stateStr)) {
return null;
}
try {
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotAccessControlUserRestletResource.java b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotAccessControlUserRestletResource.java
new file mode 100644
index 0000000000..24b1b26a42
--- /dev/null
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotAccessControlUserRestletResource.java
@@ -0,0 +1,243 @@
+/**
+ * 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 io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import javax.inject.Inject;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.helix.ZNRecord;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.pinot.common.metadata.ZKMetadataProvider;
+import org.apache.pinot.common.utils.BcryptUtils;
+import org.apache.pinot.controller.api.access.AccessControlFactory;
+import org.apache.pinot.controller.api.access.AccessControlUtils;
+import org.apache.pinot.controller.api.access.AccessType;
+import org.apache.pinot.controller.api.access.Authenticate;
+import org.apache.pinot.controller.api.exception.ControllerApplicationException;
+import org.apache.pinot.controller.api.exception.UserAlreadyExistsException;
+import org.apache.pinot.controller.helix.core.PinotHelixResourceManager;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.UserConfig;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.glassfish.grizzly.http.server.Request;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Api(tags = Constants.USER_TAG)
+@Path("/")
+public class PinotAccessControlUserRestletResource {
+ /**
+ * URI Mappings:
+ * - "/user", "/users/": List all the users
+ * - "/users/{username}", "/users/{username}/": List config for specified username.
+ *
+ * - "/user", "/users/" : Add a user
+ * <pre>
+ * POST Request Body Example :
+ * {
+ * "username": "user1",
+ * "password": "user1@passwd",
+ * "component": "BROKER",
+ * "role" : "ADMIN",
+ * "tables": ["table1", "table2"],
+ * "permissions": ["READ"]
+ * }
+ * </pre>
+ *
+ * - "/users/{username}", "/users/{username}/"
+ * PUT Request body example : same as POST Request Body
+ * {@inheritDoc}
+ */
+ public static final Logger LOGGER = LoggerFactory.getLogger(PinotAccessControlUserRestletResource.class);
+
+ @Inject
+ PinotHelixResourceManager _pinotHelixResourceManager;
+
+
+ @Inject
+ AccessControlFactory _accessControlFactory;
+ AccessControlUtils _accessControlUtils = new AccessControlUtils();
+
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/users")
+ @ApiOperation(value = "List all uses in cluster", notes = "List all users in cluster")
+ public String listUers(@Context HttpHeaders httpHeaders, @Context Request request) {
+ try {
+ String endpointUrl = request.getRequestURL().toString();
+ _accessControlUtils
+ .validatePermission(httpHeaders, endpointUrl, _accessControlFactory.create());
+ ZkHelixPropertyStore<ZNRecord> propertyStore = _pinotHelixResourceManager.getPropertyStore();
+ Map<String, UserConfig> allUserInfo = ZKMetadataProvider.getAllUserInfo(propertyStore);
+ return JsonUtils.newObjectNode().set("users", JsonUtils.objectToJsonNode(allUserInfo)).toString();
+ } catch (Exception e) {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.BAD_REQUEST, e);
+ }
+ }
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/users/{username}")
+ @ApiOperation(value = "Get an user in cluster", notes = "Get an user in cluster")
+ public String getUser(@PathParam("username") String username, @QueryParam("component") String componentTypeStr,
+ @Context HttpHeaders httpHeaders, @Context Request request) {
+ try {
+ String endpointUrl = request.getRequestURL().toString();
+ _accessControlUtils
+ .validatePermission(httpHeaders, endpointUrl, _accessControlFactory.create());
+ ZkHelixPropertyStore<ZNRecord> propertyStore = _pinotHelixResourceManager.getPropertyStore();
+ ComponentType componentType = Constants.validateComponentType(componentTypeStr);
+ String usernameWithType = username + "_" + componentType.name();
+
+ UserConfig userConfig = ZKMetadataProvider.getUserConfig(propertyStore, usernameWithType);
+ return JsonUtils.newObjectNode().set(usernameWithType, JsonUtils.objectToJsonNode(userConfig)).toString();
+ } catch (Exception e) {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.BAD_REQUEST, e);
+ }
+ }
+
+ @POST
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/users")
+ @ApiOperation(value = "Add a user", notes = "Add a user")
+ public SuccessResponse addUser(String userConfigStr, @Context HttpHeaders httpHeaders, @Context Request request) {
+ // TODO introduce a table config ctor with json string.
+
+ UserConfig userConfig;
+ String username;
+ try {
+ userConfig = JsonUtils.stringToObject(userConfigStr, UserConfig.class);
+ username = userConfig.getUserName();
+ String endpointUrl = request.getRequestURL().toString();
+ _accessControlUtils
+ .validatePermission(httpHeaders, endpointUrl, _accessControlFactory.create());
+ if (username.contains(".") || username.contains(" ")) {
+ throw new IllegalStateException("Username: " + username + " containing '.' or space is not allowed");
+ }
+ } catch (Exception e) {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.BAD_REQUEST, e);
+ }
+ try {
+ _pinotHelixResourceManager.addUser(userConfig);
+ return new SuccessResponse(String.format("User %s has been successfully added!",
+ userConfig.getUserName() + '_' + userConfig.getComponentType()));
+ } catch (Exception e) {
+ if (e instanceof UserAlreadyExistsException) {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.CONFLICT, e);
+ } else {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.BAD_REQUEST, e);
+ }
+ }
+ }
+
+ @DELETE
+ @Path("/users/{username}")
+ @Authenticate(AccessType.DELETE)
+ @Produces(MediaType.APPLICATION_JSON)
+ @ApiOperation(value = "Delete a user", notes = "Delete a user")
+ public SuccessResponse deleteUser(@PathParam("username") String username,
+ @QueryParam("component") String componentTypeStr,
+ @Context HttpHeaders httpHeaders, @Context Request request) {
+
+ List<String> usersDeleted = new LinkedList<>();
+ String usernameWithComponentType = username + "_" + componentTypeStr;
+
+ try {
+
+ boolean userExist = false;
+ userExist = _pinotHelixResourceManager.hasUser(username, componentTypeStr);
+
+ String endpointUrl = request.getRequestURL().toString();
+ _accessControlUtils.validatePermission(httpHeaders, endpointUrl, _accessControlFactory.create());
+
+ _pinotHelixResourceManager.deleteUser(usernameWithComponentType);
+ if (userExist) {
+ usersDeleted.add(username);
+ }
+ if (!usersDeleted.isEmpty()) {
+ return new SuccessResponse("User: " + usernameWithComponentType + " has been successfully deleted");
+ }
+ } catch (Exception e) {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.BAD_REQUEST, e);
+ }
+
+ throw new ControllerApplicationException(LOGGER,
+ "User " + usernameWithComponentType + " does not exists", Response.Status.NOT_FOUND);
+ }
+
+
+ @PUT
+ @Path("/users/{username}")
+ @Authenticate(AccessType.UPDATE)
+ @Produces(MediaType.APPLICATION_JSON)
+ @ApiOperation(value = "Update user config for a user", notes = "Update user config for user")
+ public SuccessResponse updateUserConfig(
+ @PathParam("username") String username,
+ @QueryParam("component") String componentTypeStr,
+ @QueryParam("passwordChanged") boolean passwordChanged,
+ String userConfigString,
+ @Context HttpHeaders httpHeaders,
+ @Context Request request) {
+
+ UserConfig userConfig;
+ String usernameWithComponentType = username + "_" + componentTypeStr;
+ try {
+ String endpointUrl = request.getRequestURL().toString();
+ _accessControlUtils
+ .validatePermission(httpHeaders, endpointUrl, _accessControlFactory.create());
+
+ userConfig = JsonUtils.stringToObject(userConfigString, UserConfig.class);
+ if (passwordChanged) {
+ userConfig.setPassword(BcryptUtils.encrypt(userConfig.getPassword()));
+ }
+ String usernameWithComponentTypeFromUserConfig = userConfig.getUsernameWithComponent();
+ if (!usernameWithComponentType.equals(usernameWithComponentTypeFromUserConfig)) {
+ throw new ControllerApplicationException(LOGGER,
+ String.format("Request user %s does not match %s in the Request body",
+ usernameWithComponentType, usernameWithComponentTypeFromUserConfig),
+ Response.Status.BAD_REQUEST);
+ }
+ if (!_pinotHelixResourceManager.hasUser(username, componentTypeStr)) {
+ throw new ControllerApplicationException(LOGGER,
+ "Request user " + usernameWithComponentType + " does not exist",
+ Response.Status.NOT_FOUND);
+ }
+ _pinotHelixResourceManager.updateUserConfig(userConfig);
+ } catch (Exception e) {
+ throw new ControllerApplicationException(LOGGER, e.getMessage(), Response.Status.BAD_REQUEST, e);
+ }
+ return new SuccessResponse("User config update for " + usernameWithComponentType);
+ }
+}
diff --git a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
index 0e4d25c9ab..1104f8738f 100644
--- a/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
+++ b/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
@@ -37,6 +37,7 @@ import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
@@ -97,7 +98,9 @@ import org.apache.pinot.common.metadata.ZKMetadataProvider;
import org.apache.pinot.common.metadata.instance.InstanceZKMetadata;
import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
import org.apache.pinot.common.minion.MinionTaskMetadataUtils;
+import org.apache.pinot.common.utils.BcryptUtils;
import org.apache.pinot.common.utils.HashUtil;
+import org.apache.pinot.common.utils.config.AccessControlUserConfigUtils;
import org.apache.pinot.common.utils.config.InstanceUtils;
import org.apache.pinot.common.utils.config.TableConfigUtils;
import org.apache.pinot.common.utils.config.TagNameUtils;
@@ -107,6 +110,7 @@ import org.apache.pinot.controller.ControllerConf;
import org.apache.pinot.controller.api.exception.ControllerApplicationException;
import org.apache.pinot.controller.api.exception.InvalidTableConfigException;
import org.apache.pinot.controller.api.exception.TableAlreadyExistsException;
+import org.apache.pinot.controller.api.exception.UserAlreadyExistsException;
import org.apache.pinot.controller.api.resources.InstanceInfo;
import org.apache.pinot.controller.api.resources.StateType;
import org.apache.pinot.controller.helix.core.assignment.instance.InstanceAssignmentDriver;
@@ -133,6 +137,9 @@ import org.apache.pinot.spi.config.table.TenantConfig;
import org.apache.pinot.spi.config.table.UpsertConfig;
import org.apache.pinot.spi.config.table.assignment.InstancePartitionsType;
import org.apache.pinot.spi.config.tenant.Tenant;
+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.data.Schema;
import org.apache.pinot.spi.stream.StreamConfig;
import org.apache.pinot.spi.utils.CommonConstants;
@@ -159,7 +166,7 @@ public class PinotHelixResourceManager {
// TODO: make this configurable
public static final long EXTERNAL_VIEW_ONLINE_SEGMENTS_MAX_WAIT_MS = 10 * 60_000L; // 10 minutes
- public static final long EXTERNAL_VIEW_CHECK_INTERVAL_MS = 1_000L; // 1 second
+ public static final long EXTERNAL_VIEW_CHECK_INTERVAL_MS = 1_000L; // 1 secondL
private static final SimpleDateFormat SIMPLE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
@@ -1378,6 +1385,35 @@ public class PinotHelixResourceManager {
AccessOption.PERSISTENT);
}
+ public void initUserACLConfig(ControllerConf controllerConf) throws IOException {
+ if (CollectionUtils.isEmpty(ZKMetadataProvider.getAllUserName(_propertyStore))) {
+ String initUsername = controllerConf.getInitAccessControlUsername();
+ String initPassword = controllerConf.getInitAccessControlPassword();
+ addUser(new UserConfig(initUsername, initPassword, ComponentType.CONTROLLER.name(),
+ RoleType.ADMIN.name(), null, null));
+ addUser(new UserConfig(initUsername, initPassword, ComponentType.BROKER.name(),
+ RoleType.ADMIN.name(), null, null));
+ addUser(new UserConfig(initUsername, initPassword, ComponentType.SERVER.name(),
+ RoleType.ADMIN.name(), null, null));
+ }
+ }
+
+ public void addUser(UserConfig userConfig)
+ throws IOException {
+ String usernamePrefix = userConfig.getUserName() + "_" + userConfig.getComponentType();
+ boolean isExists = Optional.ofNullable(ZKMetadataProvider.getAllUserConfig(_propertyStore))
+ .orElseGet(() -> {
+ return new ArrayList();
+ }).contains(userConfig);
+ if (isExists) {
+ throw new UserAlreadyExistsException("User " + usernamePrefix + " already exists");
+ }
+ userConfig.setPassword(BcryptUtils.encrypt(userConfig.getPassword()));
+ ZKMetadataProvider
+ .setUserConfig(_propertyStore, usernamePrefix, AccessControlUserConfigUtils.toZNRecord(userConfig));
+ LOGGER.info("Successfully add user:{}", usernamePrefix);
+ }
+
/**
* Performs validations of table config and adds the table to zookeeper
* @throws InvalidTableConfigException if validations fail
@@ -1675,6 +1711,13 @@ public class PinotHelixResourceManager {
}
}
+ public void updateUserConfig(UserConfig userConfig)
+ throws IOException {
+ String usernameWithComponent = userConfig.getUsernameWithComponent();
+ ZKMetadataProvider.setUserConfig(_propertyStore, usernameWithComponent,
+ AccessControlUserConfigUtils.toZNRecord(userConfig));
+ }
+
/**
* Validate the table config and update it
* @throws IOException
@@ -1769,6 +1812,12 @@ public class PinotHelixResourceManager {
setExistingTableConfig(tableConfig);
}
+ public void deleteUser(String username) {
+ ZKMetadataProvider.removeUserConfigFromPropertyStore(_propertyStore, username);
+ LOGGER.info("Deleting user{}: Removed from user resouces", username);
+ LOGGER.info("Deleting user{} finished", username);
+ }
+
public void deleteOfflineTable(String tableName) {
String offlineTableName = TableNameBuilder.OFFLINE.tableNameWithType(tableName);
LOGGER.info("Deleting table {}: Start", offlineTableName);
@@ -2592,6 +2641,11 @@ public class PinotHelixResourceManager {
return "/SEGMENTS/" + tableNameWithType + "/" + segmentName;
}
+ public boolean hasUser(String username, String component) {
+ return ZKMetadataProvider.getAllUserConfig(_propertyStore)
+ .stream().anyMatch(user -> user.isExist(username, ComponentType.valueOf(component)));
+ }
+
public boolean hasTable(String tableNameWithType) {
return getAllResources().contains(tableNameWithType);
}
diff --git a/pinot-controller/src/main/resources/app/App.tsx b/pinot-controller/src/main/resources/app/App.tsx
index 0e44fd961f..1e54b37161 100644
--- a/pinot-controller/src/main/resources/app/App.tsx
+++ b/pinot-controller/src/main/resources/app/App.tsx
@@ -52,6 +52,29 @@ const App = () => {
const [authorizationEndpoint, setAuthorizationEndpoint] = React.useState(
null
);
+ const [role, setRole] = React.useState('');
+
+ const fetchUserRole = async()=>{
+ const userListResponse = await PinotMethodUtils.getUserList();
+ let userObj = userListResponse.users;
+ let userData = [];
+ for (let key in userObj) {
+ if(userObj.hasOwnProperty(key)){
+ userData.push(userObj[key]);
+ }
+ }
+ let role = "";
+
+ for (let item of userData) {
+ if (item.username === app_state.username && item.component === 'CONTROLLER') {
+ role = item.role;
+ }
+ }
+ if (role === 'ADMIN') {
+ app_state.role = "ADMIN";
+ setRole('ADMIN')
+ }
+ }
const fetchClusterName = async () => {
const clusterNameResponse = await PinotMethodUtils.getClusterName();
@@ -154,6 +177,7 @@ const App = () => {
if (isAuthenticated) {
fetchClusterConfig();
fetchClusterName();
+ fetchUserRole();
}
}, [isAuthenticated]);
@@ -165,10 +189,10 @@ const App = () => {
)
};
- const componentRender = (Component, props) => {
+ const componentRender = (Component, props, role) => {
return (
<div className="p-8">
- <Layout clusterName={clusterName} {...props}>
+ <Layout clusterName={clusterName} {...props} role={role}>
<Component {...props} />
</Layout>
</div>
@@ -218,7 +242,7 @@ const App = () => {
return loginRender(Component, props);
} else if (isAuthenticated) {
// default render
- return componentRender(Component, props);
+ return componentRender(Component, props, role);
} else {
return <Redirect to="/login" />;
}
diff --git a/pinot-controller/src/main/resources/app/app_state.ts b/pinot-controller/src/main/resources/app/app_state.ts
index 0ef89dc3c4..61ca255b2f 100644
--- a/pinot-controller/src/main/resources/app/app_state.ts
+++ b/pinot-controller/src/main/resources/app/app_state.ts
@@ -23,6 +23,8 @@ class app_state {
authWorkflow: AuthWorkflow;
authToken: string | null;
columnNameSeparator: string = '#$%';
+ username: string;
+ role: string;
}
export default new app_state();
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx b/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
index e0de1eb659..18b967a903 100644
--- a/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
+++ b/pinot-controller/src/main/resources/app/components/Breadcrumbs.tsx
@@ -58,7 +58,8 @@ const breadcrumbNameMap: { [key: string]: string } = {
'/tables': 'Tables',
'/query': 'Query Console',
'/cluster': 'Cluster Manager',
- '/zookeeper': 'Zookeeper Browser'
+ '/zookeeper': 'Zookeeper Browser',
+ '/user': 'User Console'
};
const BreadcrumbsComponent = ({ ...props }) => {
diff --git a/pinot-controller/src/main/resources/app/components/Layout.tsx b/pinot-controller/src/main/resources/app/components/Layout.tsx
index e910218ac9..0d316ff0f8 100644
--- a/pinot-controller/src/main/resources/app/components/Layout.tsx
+++ b/pinot-controller/src/main/resources/app/components/Layout.tsx
@@ -26,8 +26,9 @@ import SwaggerIcon from './SvgIcons/SwaggerIcon';
import ClusterManagerIcon from './SvgIcons/ClusterManagerIcon';
import ZookeeperIcon from './SvgIcons/ZookeeperIcon';
import app_state from '../app_state';
+import AccountCircleOutlinedIcon from '@material-ui/icons/AccountCircleOutlined';
-const navigationItems = [
+let navigationItems = [
{ id: 1, name: 'Cluster Manager', link: '/', icon: <ClusterManagerIcon /> },
{ id: 2, name: 'Query Console', link: '/query', icon: <QueryConsoleIcon /> },
{ id: 3, name: 'Zookeeper Browser', link: '/zookeeper', icon: <ZookeeperIcon /> },
@@ -35,6 +36,15 @@ const navigationItems = [
];
const Layout = (props) => {
+ const role = props.role;
+ if(role === 'ADMIN'){
+ if(navigationItems.length <5){
+ navigationItems = [
+ ...navigationItems,
+ {id: 5, name: "User Console", link: '/user', icon: <AccountCircleOutlinedIcon style={{ width: 24, height: 24, verticalAlign: 'sub' }}/>}
+ ]
+ }
+ }
const hash = `/${window.location.hash.split('/')[1]}`;
const routeObj = navigationItems.find((obj)=>{ return obj.link === hash;});
diff --git a/pinot-controller/src/main/resources/app/components/User/AddUser.tsx b/pinot-controller/src/main/resources/app/components/User/AddUser.tsx
new file mode 100644
index 0000000000..08002a1f58
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/components/User/AddUser.tsx
@@ -0,0 +1,243 @@
+/**
+ * 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.
+ */
+
+import React, { useState } from 'react';
+import {
+ Checkbox,
+ DialogContent,
+ FormControl,
+ Input,
+ InputLabel, ListItemText,
+ MenuItem,
+ Select,
+ TextField,
+ InputAdornment,
+ IconButton,
+} from '@material-ui/core';
+import Visibility from '@material-ui/icons/Visibility';
+import VisibilityOff from '@material-ui/icons/VisibilityOff';
+import {cloneDeep} from "lodash";
+type Props = {
+ tableList: Array<{
+ name: string,
+ checked: boolean,
+ disabled: boolean
+ }>,
+ setUserInfo: Function,
+ userProp?: {
+ username: string,
+ password: string,
+ component: string,
+ role: string,
+ tables: Array<string>,
+ permissions: Array<string>
+ }
+}
+
+export default function AddUser({
+ tableList,
+ setUserInfo,
+ userProp
+}: Props) {
+ const PERMISSIONS = ["READ", "CREATE", "UPDATE", "DELETE"];
+ const [user, setUser] = useState({
+ ...userProp,
+ tables: ["DUAL"]
+ });
+ const [tables, setTables] = useState(tableList);
+ const [showPassword, setShowPassword] = useState(false);
+ const handleClickShowPassword = () => {
+ setShowPassword(!showPassword);
+ };
+ const handleMouseDownPassword = (event) => {
+ event.preventDefault();
+ };
+ const changeHandler = (field, e)=>{
+ let val = e.target.value;
+ let userCopy = cloneDeep(user);
+ switch (field) {
+ case 'username':
+ userCopy.username = val;
+ break;
+ case 'password':
+ userCopy.password = val;
+ break;
+ case 'component':
+ userCopy.component = val;
+ break;
+ case 'role':
+ userCopy.role = val;
+ break;
+ case 'permissions':
+ userCopy.permissions = val;
+ break;
+ }
+ setUser(userCopy);
+ setUserInfo(userCopy);
+ }
+ const tableCheckChange = (event)=>{
+ let name = event.target.name;
+ let checked = event.target.checked;
+ if(name){
+ let table = cloneDeep(tables);
+ if(name === 'ALL' && checked === true){
+ table = table.map(item =>{
+ return {
+ ...item,
+ checked: item.name === 'ALL',
+ disabled: item.name !== 'ALL'
+ }
+ })
+ } else if(name === 'ALL' && checked === false){
+ table = table.map(item =>{
+ return {
+ ...item,
+ checked: item.name === 'ALL' ? false: item.checked,
+ disabled: false
+ }
+ })
+ } else{
+ table = table.map(item =>{
+ return {
+ ...item,
+ checked: item.name === name ? checked: item.checked
+ }
+ })
+ }
+ setTables(table);
+ let checkedCollection = [];
+ for(let item of table){
+ if(item.checked){
+ checkedCollection.push(item.name);
+ }
+ }
+ let newUser = {
+ ...user,
+ tables: checkedCollection
+ }
+ setUser(newUser)
+ setUserInfo(newUser);
+ }
+ }
+ const menuItemStyle = {
+ fontSize: '16px',
+ }
+ return (
+ <DialogContent style={{fontSize: '14px'}}>
+ <TextField
+ autoFocus
+ margin="dense"
+ id="name"
+ label="User Name"
+ fullWidth
+ value={user.username}
+ onChange={(e)=>changeHandler('username', e)}
+ />
+ <InputLabel htmlFor="standard-adornment-password" style={{marginTop: '10px'}}>Password</InputLabel>
+ <Input
+ id="standard-adornment-password"
+ type={showPassword ? 'text' : 'password'}
+ autoComplete='new-password'
+ value={user.password}
+ onChange={(e)=>changeHandler('password', e)}
+ endAdornment={
+ <InputAdornment position="end">
+ <IconButton
+ aria-label="toggle password visibility"
+ onClick={handleClickShowPassword}
+ onMouseDown={handleMouseDownPassword}
+ >
+ {showPassword ? <Visibility /> : <VisibilityOff />}
+ </IconButton>
+ </InputAdornment>
+ }
+ />
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="component">Component</InputLabel>
+ <Select
+ autoWidth={true}
+ labelId="component"
+ id="component"
+ value={user.component}
+ label="Component"
+ required
+ onChange={(e)=>changeHandler('component', e)}
+
+ >
+ <MenuItem value={'CONTROLLER'} style={menuItemStyle}>CONTROLLER</MenuItem>
+ <MenuItem value={'BROKER'} style={menuItemStyle}>BROKER</MenuItem>
+ <MenuItem value={'SERVER'} style={menuItemStyle}>SERVER</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="role">Role</InputLabel>
+ <Select
+ labelId="role"
+ id="role"
+ value={user.role}
+ required
+ onChange={(e)=>changeHandler('role', e)}
+ >
+ <MenuItem value={'ADMIN'} style={menuItemStyle}>ADMIN</MenuItem>
+ <MenuItem value={'USER'} style={menuItemStyle}>USER</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="permission">Permissions</InputLabel>
+ <Select
+ autoWidth={true}
+ labelId="permission"
+ id="permission"
+ value={user.permissions}
+ label="Permissions"
+ multiple={true}
+ required
+ renderValue={(selected: Array<String>) => selected.join(', ')}
+ onChange={(e)=>changeHandler('permissions', e)}
+ >
+ {PERMISSIONS.map((name) => (
+ <MenuItem key={name} value={name} style={menuItemStyle}>
+ <Checkbox size="small" checked={user.permissions.indexOf(name) > -1} color="primary"/>
+ <span style={{fontSize: '16px'}}>{name}</span>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="tables">Tables</InputLabel>
+ <Select
+ labelId="tables"
+ id="tables"
+ value={user.tables}
+ multiple={true}
+ required
+ renderValue={(selected: Array<String>) => selected.join(', ')}
+ onChange={(e)=>changeHandler('tables', e)}
+ >
+ {tables.map(table=>(
+ <MenuItem key={table.name} value={table.name} style={menuItemStyle}>
+ <Checkbox checked={table.checked} name={table.name} disabled={table.disabled} size="small" color="primary" onChange={tableCheckChange}/>
+ <ListItemText primary={table.name} style={{fontSize: '14px'}}/>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ </DialogContent>
+ );
+}
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/components/User/UpdateUser.tsx b/pinot-controller/src/main/resources/app/components/User/UpdateUser.tsx
new file mode 100644
index 0000000000..bdcbe29f99
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/components/User/UpdateUser.tsx
@@ -0,0 +1,235 @@
+/**
+ * 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.
+ */
+
+import React, { useState } from 'react';
+import {
+ Checkbox,
+ DialogContent,
+ FormControl,
+ IconButton,
+ Input, InputAdornment,
+ InputLabel, ListItemText,
+ makeStyles,
+ MenuItem,
+ Select,
+} from '@material-ui/core';
+import Visibility from "@material-ui/icons/Visibility";
+import VisibilityOff from "@material-ui/icons/VisibilityOff";
+import {cloneDeep} from "lodash";
+type Props = {
+ tableList: Array<{
+ name: string,
+ checked: boolean,
+ disabled: boolean
+ }>,
+ setUserInfo: Function,
+ userProp?: {
+ username: string,
+ password: string,
+ component: string,
+ role: string,
+ tables: Array<string>,
+ permissions: Array<string>
+ }
+}
+
+export default function UpdateUser({
+ tableList,
+ setUserInfo,
+ userProp
+}: Props) {
+
+ const PERMISSIONS = ["READ", "CREATE", "UPDATE", "DELETE"];
+ const [user, setUser] = useState(userProp);
+ const transferTable = tableList.map(item=>{
+ return {
+ ...item,
+ checked: !!userProp.tables.includes(item.name)
+ }
+ })
+ const [tables, setTables] = useState(transferTable);
+ const [showPassword, setShowPassword] = useState(false);
+ const handleClickShowPassword = () => {
+ setShowPassword(!showPassword);
+ };
+ const handleMouseDownPassword = (event) => {
+ event.preventDefault();
+ };
+ const changeHandler = (field, e)=>{
+ let val = e.target.value;
+ let userCopy = cloneDeep(user);
+ switch (field) {
+ case 'username':
+ userCopy.username = val;
+ break;
+ case 'password':
+ userCopy.password = val;
+ break;
+ case 'component':
+ userCopy.component = val;
+ break;
+ case 'role':
+ userCopy.role = val;
+ break;
+ case 'permissions':
+ userCopy.permissions = val;
+ break;
+ }
+ setUser(userCopy);
+ setUserInfo(userCopy);
+ }
+
+ const tableCheckChange = (event)=>{
+ let name = event.target.name;
+ let checked = event.target.checked;
+ if(name){
+ let table = cloneDeep(tables);
+ console.log("table: ", table);
+ if(name === 'ALL' && checked === true){
+ table = table.map(item =>{
+ return {
+ ...item,
+ checked: item.name === 'ALL',
+ disabled: item.name !== 'ALL'
+ }
+ })
+ } else if(name === 'ALL' && checked === false){
+ table = table.map(item =>{
+ return {
+ ...item,
+ checked: item.name === 'ALL' ? false: item.checked,
+ disabled: false
+ }
+ })
+ } else{
+ table = table.map(item =>{
+ return {
+ ...item,
+ checked: item.name === name ? checked: item.checked
+ }
+ })
+ }
+ setTables(table);
+ let checkedCollection = [];
+ for(let item of table){
+ if(item.checked){
+ checkedCollection.push(item.name);
+ }
+ }
+ let newUser = {
+ ...user,
+ tables: checkedCollection
+ }
+ setUser(newUser)
+ setUserInfo(newUser);
+ }
+ }
+
+ const menuItemStyle = {
+ fontSize: '16px',
+ }
+
+ const useStyles = makeStyles({
+ text: {
+ fontSize: '18px',
+ color: 'rgba(0, 0, 0, 0.87)'
+ },
+ field: {
+ marginRight: '10px',
+ color: 'rgba(0, 0, 0, 0.57)'
+ }
+ });
+ const classes = useStyles();
+
+ return (
+ <DialogContent style={{fontSize: '14px'}}>
+ <div className={classes.text} style={{marginBottom: '10px'}}><span className={classes.field}>User Name:</span> {user.username}</div>
+ <div className={classes.text}><span className={classes.field}>Component:</span> {user.component}</div>
+ <InputLabel htmlFor="standard-adornment-password" style={{marginTop: '10px'}}>Password</InputLabel>
+ <Input
+ id="standard-adornment-password"
+ type={showPassword ? 'text' : 'password'}
+ autoComplete='new-password'
+ value={user.password}
+ onChange={(e)=>changeHandler('password', e)}
+ endAdornment={
+ <InputAdornment position="end">
+ <IconButton
+ aria-label="toggle password visibility"
+ onClick={handleClickShowPassword}
+ onMouseDown={handleMouseDownPassword}
+ >
+ {showPassword ? <Visibility /> : <VisibilityOff />}
+ </IconButton>
+ </InputAdornment>
+ }
+ />
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="role">Role</InputLabel>
+ <Select
+ labelId="role"
+ id="role"
+ value={user.role}
+ onChange={(e)=>changeHandler('role', e)}
+ >
+ <MenuItem value={'ADMIN'} style={menuItemStyle}>ADMIN</MenuItem>
+ <MenuItem value={'USER'} style={menuItemStyle}>USER</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="permission">Permissions</InputLabel>
+ <Select
+ autoWidth={true}
+ labelId="permission"
+ id="permission"
+ value={user.permissions}
+ label="Permissions"
+ multiple={true}
+ renderValue={(selected: Array<String>) => selected.join(', ')}
+ onChange={(e)=>changeHandler('permissions', e)}
+ >
+ {PERMISSIONS.map((name) => (
+ <MenuItem key={name} value={name} style={menuItemStyle}>
+ <Checkbox size="small" checked={user.permissions.indexOf(name) > -1} color="primary"/>
+ <span style={{fontSize: '16px'}}>{name}</span>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <FormControl fullWidth style={{margin: '10px 0'}}>
+ <InputLabel id="tables">Tables</InputLabel>
+ <Select
+ labelId="tables"
+ id="tables"
+ value={user.tables}
+ multiple={true}
+ renderValue={(selected: Array<String>) => selected.join(', ')}
+ onChange={(e)=>changeHandler('tables', e)}
+ >
+ {tables.map(table=>(
+ <MenuItem key={table.name} value={table.name} style={menuItemStyle}>
+ <Checkbox checked={table.checked} name={table.name} disabled={table.disabled} size="small" color="primary" onChange={tableCheckChange}/>
+ <ListItemText primary={table.name} style={{fontSize: '14px'}}/>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ </DialogContent>
+ );
+}
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/interfaces/types.d.ts b/pinot-controller/src/main/resources/app/interfaces/types.d.ts
index afa20fed5d..609671d873 100644
--- a/pinot-controller/src/main/resources/app/interfaces/types.d.ts
+++ b/pinot-controller/src/main/resources/app/interfaces/types.d.ts
@@ -161,4 +161,21 @@ declare module 'Models' {
BASIC = 'BASIC',
OIDC = 'OIDC',
}
+
+ export type TableList = {
+ tables: Array<string>
+ }
+
+ export type UserObject = {
+ username: string,
+ password: string,
+ component: string,
+ role: string,
+ tables: Array<string>,
+ permissions: Array<string>
+ }
+
+ export type UserList = {
+ users: UserObject
+ }
}
diff --git a/pinot-controller/src/main/resources/app/pages/LoginPage.tsx b/pinot-controller/src/main/resources/app/pages/LoginPage.tsx
index 320f40481e..37d4b3b18e 100644
--- a/pinot-controller/src/main/resources/app/pages/LoginPage.tsx
+++ b/pinot-controller/src/main/resources/app/pages/LoginPage.tsx
@@ -81,6 +81,7 @@ const LoginPage = (props) => {
app_state.authWorkflow = AuthWorkflow.BASIC;
app_state.authToken = authToken;
props.history.push(app_state.queryConsoleOnlyView ? '/query' : '/');
+ app_state.username = data.username;
} else {
setInvalidToken(true);
}
diff --git a/pinot-controller/src/main/resources/app/pages/UserPage.tsx b/pinot-controller/src/main/resources/app/pages/UserPage.tsx
new file mode 100644
index 0000000000..71c8130413
--- /dev/null
+++ b/pinot-controller/src/main/resources/app/pages/UserPage.tsx
@@ -0,0 +1,296 @@
+/* eslint-disable no-nested-ternary */
+/**
+ * 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.
+ */
+
+import React, {useEffect, useState} from 'react';
+import {Button, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Dialog, DialogActions, Snackbar, DialogTitle} from '@material-ui/core';
+import Alert from '@material-ui/lab/Alert';
+import { makeStyles } from '@material-ui/core/styles';
+import PinotMethodUtils from '../utils/PinotMethodUtils';
+import '../styles/styles.css';
+import AddUser from "../components/User/AddUser";
+import UpdateUser from "../components/User/UpdateUser";
+import * as _ from "lodash";
+import AppLoader from "../components/AppLoader";
+
+const initialUser = {
+ username: "",
+ password: "",
+ component: "",
+ role: "",
+ tables: [],
+ permissions: ['READ']
+}
+const PERMISSIONS = ["READ", "CREATE", "UPDATE", "DELETE"];
+const columns = [
+ { id: 'User', label: 'User'},
+ { id: 'Component', label: 'Component'},
+ { id: 'Role', label: 'Role'},
+ { id: 'Permissions', label: 'Permissions'},
+ { id: 'Tables', label: 'Tables'},
+ { id: 'Actions', label: 'Actions'}
+];
+let oldPassword = "";
+const UserPage = () => {
+ const [userList, setUserList] = useState([]);
+ const [addUserPanel, setAddUserPanel] = useState(false);
+ const [deleteUserPanel, setDeleteUserPanel] = useState(false);
+ const [updateUserPanel, setUpdateUserPanel] = useState(false);
+ const [tableList, setTableList] = useState([]);
+ const [userInfo, updateUserInfo] = useState(initialUser);
+ const [errorTip, setErrorTip] = useState(null);
+ const [showError, setShowError] = useState(false);
+ const [successTip, setSuccessTip] = useState(null);
+ const [showSuccess, setShowSuccess] = useState(false);
+ const [checkedUser, setCheckedUser] = useState(initialUser);
+ const [fetching, setFetching] = useState(false);
+ const getUserList = async () => {
+ setFetching(true);
+ const tablesResponse = await PinotMethodUtils.getTable();
+ let tables = tablesResponse.tables;
+ tables.unshift("DUAL", "ALL");
+ let tableList = tables.map(item=>{
+ return {
+ name: item,
+ checked: item === 'DUAL',
+ disabled: false
+ }
+ })
+ setTableList(tableList);
+ const userListResponse = await PinotMethodUtils.getUserList();
+ let userObj = userListResponse.users;
+ let userData = [];
+ for (let key in userObj) {
+ if(userObj.hasOwnProperty(key)){
+ userData.push(userObj[key]);
+ }
+ }
+ userData = userData.map(item => {
+ return {
+ component: item.component,
+ role: item.role,
+ username: item.username,
+ usernameWithComponent: item.usernameWithComponent,
+ tables: item.tables ? item.tables: ["ALL"],
+ permissions: item.permissions ? item.permissions : PERMISSIONS,
+ password: item.password
+ }
+ })
+ userData.sort((prev, next)=>{
+ let prevUser = prev.username.toUpperCase();
+ let nextUser = next.username.toUpperCase();
+ if (prevUser < nextUser) {
+ return -1;
+ }
+ if (prevUser > nextUser) {
+ return 1;
+ }
+ return 0;
+ })
+ setUserList(userData);
+ setFetching(false);
+ };
+
+ const toggleAddUserPanel = ()=>{
+ updateUserInfo(initialUser);
+ setAddUserPanel(true);
+ };
+ const closeAddUserPanel = ()=>{
+ setAddUserPanel(false)
+ };
+
+ const submitAddUser = async()=>{
+ let addUserParam = _.cloneDeep(userInfo);
+ if(addUserParam.tables.includes('ALL')){
+ delete addUserParam.tables
+ }
+ const addUserResponse = await PinotMethodUtils.addUser(addUserParam);
+ if(addUserResponse.code && addUserResponse.code !== 200){
+ setShowError(true);
+ setErrorTip(addUserResponse.error);
+ }else{
+ setAddUserPanel(false);
+ setShowSuccess(true);
+ setSuccessTip("Add user success");
+ getUserList();
+ }
+ };
+
+ const closeTip = (type)=>{
+ type === 'error' ? setShowError(false): setShowSuccess(false);
+ };
+
+ const deleteUser = async()=>{
+ const deleteUserResponse = await PinotMethodUtils.deleteUser(checkedUser);
+ if(deleteUserResponse.code && deleteUserResponse.code !== 200){
+ setShowError(true);
+ setErrorTip(deleteUserResponse.error);
+ }else{
+ setDeleteUserPanel(false);
+ setShowSuccess(true);
+ setSuccessTip("delete user success");
+ getUserList();
+ }
+ }
+
+ const clickDeleteUser = (row)=>{
+ setCheckedUser(row);
+ setDeleteUserPanel(true);
+ }
+
+ const clickEditUser = (row)=>{
+ oldPassword = row.password;
+ updateUserInfo(row);
+ setUpdateUserPanel(true);
+ }
+
+ const closeUpdateUserPanel = ()=>{
+ setUpdateUserPanel(false);
+ }
+
+ const submitUpdateUser = async()=>{
+ let updateUserParam = _.cloneDeep(userInfo);
+ if(updateUserParam.tables.includes('ALL')){
+ delete updateUserParam.tables
+ }
+ const passwordChanged = updateUserParam.password !== oldPassword ? true: false;
+ const updateUserResponse = await PinotMethodUtils.updateUser(updateUserParam, passwordChanged);
+ if(updateUserResponse.code && updateUserResponse.code !== 200){
+ setShowError(true);
+ setErrorTip(updateUserResponse.error);
+ }else{
+ setUpdateUserPanel(false);
+ setShowSuccess(true);
+ setSuccessTip("Update user success");
+ getUserList();
+ }
+ }
+
+ useEffect(() => {
+ getUserList();
+ }, []);
+
+ const useStyles = makeStyles({
+ userContainer: {
+ width: 'calc(100% - 260px)', padding: '32px', height: 'calc(100vh - 70px)', fontSize: '14px'
+ },
+ tableBody: {
+ maxHeight: 'calc(100vh - 254px)', overflowY: 'auto'
+ },
+ tableColItemStyle: {
+ background: "#1170CF", borderRadius: '4px', padding: '4px 8px', display: 'inline-block', color: '#fff',
+ fontSize: '14px', marginRight: '18px', marginTop: '5px', marginBottom: '5px'
+ },
+ delButton: {
+ borderRadius: '16px',
+ color: 'rgb(220, 0, 78)',
+ border: '1px solid rgba(220, 0, 78, 0.5)',
+ "&:hover": {
+ background: 'rgba(220, 0, 78, 0.2)'
+ }
+ },
+
+ });
+ const classes = useStyles();
+ return (
+ <div className={classes.userContainer}>
+ <Button variant="contained" onClick={toggleAddUserPanel} color="primary" style={{marginBottom: '20px', borderRadius: '16px'}}>Add User</Button>
+ {fetching ? <AppLoader/>: <TableContainer component={Paper} style={{height: 'calc(100% - 60px)'}}>
+ <Table aria-label="simple table">
+ <TableHead>
+ <TableRow>
+ {columns.map((column) => (
+ <TableCell style={{fontWeight: 700}} key={column.id}>
+ {column.label}
+ </TableCell>
+ ))}
+ </TableRow>
+ </TableHead>
+ <TableBody className={classes.tableBody}>
+ {userList.map((row) => (
+ <TableRow key={row.usernameWithComponent}>
+ <TableCell component="th" scope="row">
+ {row.username}
+ </TableCell>
+ <TableCell align="justify" >{row.component}</TableCell>
+ <TableCell align="justify">{row.role}</TableCell>
+ <TableCell align="justify">{
+ row.permissions && row.permissions.length >0 && row.permissions.map(permission => {
+ return <span className={classes.tableColItemStyle} key={permission}>{permission}</span>
+ })}
+ </TableCell>
+ <TableCell align="justify" style={{wordWrap: "break-word"}}>
+ {row.tables && row.tables.length > 0 && row.tables.map(table =>{
+ return <span className={classes.tableColItemStyle} key={table}>{table}</span>
+ })}
+ </TableCell>
+ <TableCell align="justify" style={{minWidth: '200px'}}>
+ <Button variant="outlined" size="small" style={{marginRight: '10px', borderRadius: '16px'}} onClick={()=>clickEditUser(row)}>Edit</Button>
+ <Button variant="outlined" onClick={()=>clickDeleteUser(row)} size="small" className={classes.delButton}>Delete</Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </TableContainer>}
+ <Dialog open={addUserPanel} onClose={closeAddUserPanel}>
+ <DialogTitle>Add User</DialogTitle>
+ <AddUser tableList={tableList} userProp={userInfo} setUserInfo={(userInfo) =>{updateUserInfo(userInfo)}}/>
+ <DialogActions>
+ <Button onClick={closeAddUserPanel} size="small">Cancel</Button>
+ <Button onClick={submitAddUser} size="small">Submit</Button>
+ </DialogActions>
+ </Dialog>
+ <Dialog open={updateUserPanel} onClose={closeUpdateUserPanel}>
+ <DialogTitle>Edit User</DialogTitle>
+ <UpdateUser tableList={tableList} userProp={userInfo} setUserInfo={(userInfo) =>{updateUserInfo(userInfo)}}/>
+ <DialogActions>
+ <Button onClick={closeUpdateUserPanel} size="small">Cancel</Button>
+ <Button onClick={submitUpdateUser} size="small">Submit</Button>
+ </DialogActions>
+ </Dialog>
+ <Dialog
+ open={deleteUserPanel}
+ onClose={()=>{setDeleteUserPanel(false)}}
+ aria-labelledby="alert-dialog-title"
+ >
+ <DialogTitle id="alert-dialog-title">{`Confirm Delete user ${checkedUser.username}_${checkedUser.component}?`}</DialogTitle>
+ <DialogActions>
+ <Button onClick={()=>{setDeleteUserPanel(false)}} color="primary">
+ Cancel
+ </Button>
+ <Button onClick={deleteUser} color="primary" autoFocus>
+ Confirm
+ </Button>
+ </DialogActions>
+ </Dialog>
+ <Snackbar open={showError} autoHideDuration={6000} onClose={()=>{closeTip('error')}}>
+ <Alert onClose={()=>{closeTip('error')}} severity="error">
+ {errorTip}
+ </Alert>
+ </Snackbar>
+ <Snackbar open={showSuccess} autoHideDuration={1000} onClose={()=>{closeTip('success')}}>
+ <Alert onClose={()=>{closeTip('success')}} severity="success">
+ {successTip}
+ </Alert>
+ </Snackbar>
+ </div>
+ )
+}
+export default UserPage;
diff --git a/pinot-controller/src/main/resources/app/requests/index.ts b/pinot-controller/src/main/resources/app/requests/index.ts
index c94c43b46a..2ab7b7f997 100644
--- a/pinot-controller/src/main/resources/app/requests/index.ts
+++ b/pinot-controller/src/main/resources/app/requests/index.ts
@@ -20,7 +20,7 @@
import { AxiosResponse } from 'axios';
import { TableData, Instances, Instance, Tenants, ClusterConfig, TableName, TableSize,
IdealState, QueryTables, TableSchema, SQLResult, ClusterName, ZKGetList, ZKConfig, OperationResponse,
- BrokerList, ServerList
+ BrokerList, ServerList, UserList, TableList, UserObject
} from 'Models';
const headers = {
@@ -172,4 +172,19 @@ export const authenticateUser = (authToken): Promise<AxiosResponse<OperationResp
baseApi.get(`/auth/verify`, {headers:{"Authorization": authToken}});
export const getSegmentDebugInfo = (tableName: string, tableType: string): Promise<AxiosResponse<OperationResponse>> =>
- baseApi.get(`debug/tables/${tableName}?type=${tableType}&verbosity=10`);
\ No newline at end of file
+ baseApi.get(`debug/tables/${tableName}?type=${tableType}&verbosity=10`);
+
+export const requestTable = (): Promise<AxiosResponse<TableList>> =>
+ baseApi.get(`/tables`);
+
+export const requestUserList = (): Promise<AxiosResponse<UserList>> =>
+ baseApi.get(`/users`);
+
+export const requestAddUser = (userObject: UserObject): Promise<AxiosResponse<any>> =>
+ baseApi.post('/users', JSON.stringify(userObject), {headers});
+
+export const requestDeleteUser = (userObject: UserObject): Promise<AxiosResponse<any>> =>
+ baseApi.delete(`/users/${userObject.username}?component=${userObject.component}`);
+
+export const requestUpdateUser = (userObject: UserObject, passwordChanged: boolean): Promise<AxiosResponse<any>> =>
+ baseApi.put(`/users/${userObject.username}?component=${userObject.component}&passwordChanged=${passwordChanged}`, JSON.stringify(userObject), {headers});
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/router.tsx b/pinot-controller/src/main/resources/app/router.tsx
index 69719451fe..a969a68831 100644
--- a/pinot-controller/src/main/resources/app/router.tsx
+++ b/pinot-controller/src/main/resources/app/router.tsx
@@ -29,6 +29,7 @@ import InstanceDetails from './pages/InstanceDetails';
import ZookeeperPage from './pages/ZookeeperPage';
import SchemaPageDetails from './pages/SchemaPageDetails';
import LoginPage from './pages/LoginPage';
+import UserPage from "./pages/UserPage";
export default [
{ path: '/', Component: HomePage },
@@ -49,4 +50,5 @@ export default [
{ path: '/instance/:instanceName/table/:tableName/:segmentName', Component: SegmentDetails },
{ path: '/zookeeper', Component: ZookeeperPage },
{ path: '/login', Component: LoginPage },
+ { path: '/user', Component: UserPage}
];
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts b/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
index f46973b21f..ff84d81046 100644
--- a/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
+++ b/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
@@ -67,7 +67,12 @@ import {
getState,
getInfo,
authenticateUser,
- getSegmentDebugInfo
+ getSegmentDebugInfo,
+ requestTable,
+ requestUserList,
+ requestAddUser,
+ requestDeleteUser,
+ requestUpdateUser
} from '../requests';
import { baseApi } from './axios-config';
import Utils from './Utils';
@@ -887,6 +892,36 @@ const getURLWithoutAccessToken = (fallbackUrl = '/'): string => {
return `${prefix}${url}`;
};
+const getTable = ()=>{
+ return requestTable().then(response=>{
+ return response.data;
+ })
+};
+
+const getUserList = ()=>{
+ return requestUserList().then(response=>{
+ return response.data;
+ })
+};
+
+const addUser = (userObject)=>{
+ return requestAddUser(userObject).then(response=>{
+ return response.data;
+ })
+};
+
+const deleteUser = (userObject)=>{
+ return requestDeleteUser(userObject).then(response=>{
+ return response.data;
+ })
+};
+
+const updateUser = (userObject, passwordChanged) =>{
+ return requestUpdateUser(userObject, passwordChanged).then(response=>{
+ return response.data;
+ })
+}
+
export default {
getTenantsData,
getAllInstances,
@@ -940,5 +975,10 @@ export default {
getWellKnownOpenIdConfiguration,
verifyAuth,
getAccessTokenFromHashParams,
- getURLWithoutAccessToken
-};
+ getURLWithoutAccessToken,
+ getTable,
+ getUserList,
+ addUser,
+ deleteUser,
+ updateUser
+};
\ No newline at end of file
diff --git a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotAccessControlUserRestletResourceTest.java b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotAccessControlUserRestletResourceTest.java
new file mode 100644
index 0000000000..cd31fbc99b
--- /dev/null
+++ b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotAccessControlUserRestletResourceTest.java
@@ -0,0 +1,138 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.controller.api;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import org.apache.pinot.common.utils.BcryptUtils;
+import org.apache.pinot.controller.ControllerTestUtils;
+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.spi.utils.StringUtil;
+import org.apache.pinot.spi.utils.builder.UserConfigBuilder;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+public class PinotAccessControlUserRestletResourceTest {
+
+ private String _createUserUrl;
+ private final UserConfigBuilder _userConfigBuilder = new UserConfigBuilder();
+
+ @BeforeClass
+ public void setup()
+ throws Exception {
+ ControllerTestUtils.setupClusterAndValidate();
+
+ _createUserUrl = ControllerTestUtils.getControllerRequestURLBuilder().forUserCreate();
+ _userConfigBuilder.setUsername("testUser").setPassword("123456").setComponentType(ComponentType.CONTROLLER)
+ .setRoleType(RoleType.USER);
+ }
+
+ @Test
+ public void testAddUser()
+ throws Exception {
+ String userconfigString = _userConfigBuilder.setUsername("bad.user.with.dot").build().toJsonString();
+ try {
+ ControllerTestUtils.sendPostRequest(_createUserUrl, userconfigString);
+ Assert.fail("Adding a user with dot in username does not fail");
+ } catch (IOException e) {
+ Assert.assertTrue(e.getMessage().contains(
+ "Username: bad.user.with.dot containing '.' or space is not allowed"));
+ }
+
+ // Creating an user with a valid username should succeed
+ userconfigString = _userConfigBuilder.setUsername("valid_table_name").build().toJsonString();
+ ControllerTestUtils.sendPostRequest(_createUserUrl, userconfigString);
+
+ // Create an user that already exists should fail
+ try {
+ ControllerTestUtils.sendPostRequest(_createUserUrl, userconfigString);
+ Assert.fail("Creation of an existing user does not fail");
+ } catch (IOException e) {
+ Assert.assertTrue(e.getMessage().contains("User valid_table_name_CONTROLLER already exists"));
+ }
+ }
+
+ private UserConfig getUserConfig(String username, String componentType)
+ throws Exception {
+ String userConfigString =
+ ControllerTestUtils.sendGetRequest(ControllerTestUtils.getControllerRequestURLBuilder()
+ .forUserGet(username, componentType));
+ String usernameWithType = username + "_" + componentType;
+ return JsonUtils.jsonNodeToObject(JsonUtils.stringToJsonNode(userConfigString)
+ .get(usernameWithType), UserConfig.class);
+ }
+
+ @Test
+ public void testUpdateUserConfig()
+ throws Exception {
+ String username = "updateTC";
+ String userConfigString = _userConfigBuilder.setUsername(username).setComponentType(ComponentType.CONTROLLER)
+ .build().toJsonString();
+ ControllerTestUtils.sendPostRequest(_createUserUrl, userConfigString);
+ // user creation should succeed
+ UserConfig userConfig = getUserConfig(username, "CONTROLLER");
+ Assert.assertEquals(userConfig.getRoleType().toString(), RoleType.USER.toString());
+ Assert.assertTrue(BcryptUtils.checkpw("123456", userConfig.getPassword()));
+ userConfig.setRole("ADMIN");
+ userConfig.setPassword("654321");
+
+ JsonNode jsonResponse = JsonUtils.stringToJsonNode(ControllerTestUtils
+ .sendPutRequest(ControllerTestUtils.getControllerRequestURLBuilder()
+ .forUpdateUserConfig(username, "CONTROLLER", true),
+ userConfig.toString()));
+ Assert.assertTrue(jsonResponse.has("status"));
+
+ UserConfig modifiedConfig = getUserConfig(username, "CONTROLLER");
+ Assert.assertEquals(modifiedConfig.getRoleType().toString(), "ADMIN");
+ Assert.assertTrue(BcryptUtils.checkpw("654321", modifiedConfig.getPassword()));
+ }
+
+ @Test
+ public void testDeleteUser()
+ throws Exception {
+ // Case 1: Create a CONTORLLER user and delete it directly w/o using query param.
+ UserConfig controllerUserConfig = _userConfigBuilder.setUsername("user1")
+ .setComponentType(ComponentType.CONTROLLER).build();
+ String creationResponse = ControllerTestUtils
+ .sendPostRequest(_createUserUrl, controllerUserConfig.toJsonString());
+ Assert.assertEquals(creationResponse, "{\"status\":\"User user1_CONTROLLER has been successfully added!\"}");
+
+ // Delete controller user using CONTROLLER suffix
+ String deleteResponse = ControllerTestUtils.sendDeleteRequest(
+ StringUtil.join("/", ControllerTestUtils.getControllerBaseApiUrl(),
+ "users", "user1?component=CONTROLLER"));
+ Assert.assertEquals(deleteResponse, "{\"status\":\"User: user1_CONTROLLER has been successfully deleted\"}");
+
+ // Case 2: Create a BROKER user and delete it directly w/o using query param.
+ UserConfig brokerUserConfig = _userConfigBuilder.setUsername("user1").setComponentType(ComponentType.BROKER)
+ .build();
+ creationResponse = ControllerTestUtils.sendPostRequest(_createUserUrl, brokerUserConfig.toJsonString());
+ Assert.assertEquals(creationResponse, "{\"status\":\"User user1_BROKER has been successfully added!\"}");
+
+ // Delete controller user using BROKER suffix
+ deleteResponse = ControllerTestUtils.sendDeleteRequest(
+ StringUtil.join("/", ControllerTestUtils.getControllerBaseApiUrl(), "users", "user1?component=BROKER")
+ );
+ Assert.assertEquals(deleteResponse, "{\"status\":\"User: user1_BROKER has been successfully deleted\"}");
+ }
+}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthUtils.java b/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthUtils.java
index c17c2d8972..271fd44450 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthUtils.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/auth/BasicAuthUtils.java
@@ -23,10 +23,13 @@ import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
+import org.apache.pinot.common.utils.BcryptUtils;
+import org.apache.pinot.spi.config.user.UserConfig;
import org.apache.pinot.spi.env.PinotConfiguration;
@@ -61,21 +64,43 @@ public final class BasicAuthUtils {
* @return list of BasicAuthPrincipals
*/
public static List<BasicAuthPrincipal> extractBasicAuthPrincipals(PinotConfiguration configuration, String prefix) {
- String principalNames = configuration.getProperty(prefix);
- Preconditions.checkArgument(StringUtils.isNotBlank(principalNames), "must provide principals");
+ String principalNames = configuration.getProperty(prefix);
+ Preconditions.checkArgument(StringUtils.isNotBlank(principalNames), "must provide principals");
- return Arrays.stream(principalNames.split(",")).map(rawName -> {
- String name = rawName.trim();
- Preconditions.checkArgument(StringUtils.isNotBlank(name), "%s is not a valid name", name);
+ return Arrays.stream(principalNames.split(",")).map(rawName -> {
+ String name = rawName.trim();
+ Preconditions.checkArgument(StringUtils.isNotBlank(name), "%s is not a valid name", name);
- String password = configuration.getProperty(String.format("%s.%s.%s", prefix, name, PASSWORD));
- Preconditions.checkArgument(StringUtils.isNotBlank(password), "must provide a password for %s", name);
+ String password = configuration.getProperty(String.format("%s.%s.%s", prefix, name, PASSWORD));
+ Preconditions.checkArgument(StringUtils.isNotBlank(password), "must provide a password for %s", name);
- Set<String> tables = extractSet(configuration, String.format("%s.%s.%s", prefix, name, TABLES));
- Set<String> permissions = extractSet(configuration, String.format("%s.%s.%s", prefix, name, PERMISSIONS));
+ Set<String> tables = extractSet(configuration, String.format("%s.%s.%s", prefix, name, TABLES));
+ Set<String> permissions = extractSet(configuration, String.format("%s.%s.%s", prefix, name, PERMISSIONS));
- return new BasicAuthPrincipal(name, toBasicAuthToken(name, password), tables, permissions);
- }).collect(Collectors.toList());
+ return new BasicAuthPrincipal(name, toBasicAuthToken(name, password), tables, permissions);
+ }).collect(Collectors.toList());
+ }
+
+ public static List<ZkBasicAuthPrincipal> extractBasicAuthPrincipals(List<UserConfig> userConfigList) {
+ return userConfigList.stream()
+ .map(user -> {
+ String name = user.getUserName().trim();
+ Preconditions.checkArgument(StringUtils.isNotBlank(name), "%s is not a valid username", name);
+ String password = user.getPassword().trim();
+ Preconditions.checkArgument(StringUtils.isNotBlank(password), "must provide a password for %s", name);
+ String component = user.getComponentType().toString();
+ String role = user.getRoleType().toString();
+
+ Set<String> tables = Optional.ofNullable(user.getTables())
+ .orElseGet(() -> Collections.emptyList())
+ .stream().collect(Collectors.toSet());
+ Set<String> permissions = Optional.ofNullable(user.getPermissios())
+ .orElseGet(() -> Collections.emptyList())
+ .stream().map(x -> x.toString())
+ .collect(Collectors.toSet());
+ return new ZkBasicAuthPrincipal(name, toBasicAuthToken(name, password), password,
+ component, role, tables, permissions);
+ }).collect(Collectors.toList());
}
private static Set<String> extractSet(PinotConfiguration configuration, String key) {
@@ -102,6 +127,46 @@ public final class BasicAuthUtils {
return normalizeBase64Token(String.format("Basic %s", Base64.getEncoder().encodeToString(identifier.getBytes())));
}
+ public static String decodeBasicAuthToken(String auth) {
+ if (StringUtils.isBlank(auth)) {
+ return null;
+ }
+ String replacedAuth = StringUtils.replace(auth, "Basic ", "");
+ byte[] decodedBytes = Base64.getDecoder().decode(replacedAuth);
+ String decodedString = new String(decodedBytes);
+ return decodedString;
+ }
+
+ public static String extractUsername(String auth) {
+ String decodedString = decodeBasicAuthToken(auth);
+ return StringUtils.split(decodedString, ":")[0];
+ }
+
+ public static String extractPassword(String auth) {
+ String decodedString = decodeBasicAuthToken(auth);
+ return StringUtils.split(decodedString, ":")[1];
+ }
+
+ /**
+ * Convert http header-compliant base64 encoded token to password-encrypted base64 token
+ *
+ * @param auth http base64 token
+ * @return base64 encoded basic auth token
+ */
+ public static String toEncryptBasicAuthToken(String auth) {
+ if (StringUtils.isBlank(auth)) {
+ return null;
+ }
+ String replacedAuth = StringUtils.replace(auth, "Basic ", "");
+ byte[] decodedBytes = Base64.getDecoder().decode(replacedAuth);
+ String decodedString = new String(decodedBytes);
+ String[] cretential = StringUtils.split(decodedString, ":");
+ String rawUsername = cretential[0];
+ String rawPassword = cretential[1];
+ String encryptedPassword = BcryptUtils.encrypt(rawPassword);
+ return toBasicAuthToken(rawUsername, encryptedPassword);
+ }
+
/**
* Normalize a base64 encoded auth token by stripping redundant padding (spaces, '=')
*
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/auth/ZkBasicAuthPrincipal.java b/pinot-core/src/main/java/org/apache/pinot/core/auth/ZkBasicAuthPrincipal.java
new file mode 100644
index 0000000000..93f97943ce
--- /dev/null
+++ b/pinot-core/src/main/java/org/apache/pinot/core/auth/ZkBasicAuthPrincipal.java
@@ -0,0 +1,56 @@
+/**
+ * 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.core.auth;
+
+import java.util.Set;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+
+/**
+ * Container object for basic auth principal
+ */
+public class ZkBasicAuthPrincipal extends BasicAuthPrincipal {
+ private final String _password;
+ private final String _component;
+ private final String _role;
+
+ public ZkBasicAuthPrincipal(String name, String token, String password, String component, String role,
+ Set<String> tables, Set<String> permissions) {
+ super(name, token, tables, permissions);
+ _component = component;
+ _role = role;
+ _password = password;
+ }
+
+ public String getRole() {
+ return _role;
+ }
+
+ public String getComponent() {
+ return _component;
+ }
+
+ public String getPassword() {
+ return _password;
+ }
+
+ public boolean hasPermission(RoleType role, ComponentType component) {
+ return RoleType.valueOf(_role).equals(role) && ComponentType.valueOf(_component).equals(component);
+ }
+}
diff --git a/pinot-core/src/main/java/org/apache/pinot/server/access/AccessControlFactory.java b/pinot-core/src/main/java/org/apache/pinot/server/access/AccessControlFactory.java
index 6253d679e4..f90e6493ea 100644
--- a/pinot-core/src/main/java/org/apache/pinot/server/access/AccessControlFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/server/access/AccessControlFactory.java
@@ -18,6 +18,7 @@
*/
package org.apache.pinot.server.access;
+import org.apache.helix.HelixManager;
import org.apache.pinot.spi.annotations.InterfaceAudience;
import org.apache.pinot.spi.annotations.InterfaceStability;
import org.apache.pinot.spi.env.PinotConfiguration;
@@ -30,5 +31,18 @@ public interface AccessControlFactory {
default void init(PinotConfiguration configuration) {
};
+ /**
+ * Extend the original init method to support Zookeeper BasicAuthAccessControlFactory.
+ * Because ZKBasicAuthAccessControlFactory needs to acquire users' info from HelixPropertyStore.
+ * The reason why passed helixManager param other than HelixPropertyStore is that
+ * HelixManager variable hasn't connected to Real Helix manager when invoke init method.
+ *
+ * @param configuration pinot configuration
+ * @param helixManager Helix Manager
+ */
+ default void init(PinotConfiguration configuration, HelixManager helixManager) {
+ init(configuration);
+ }
+
AccessControl create();
}
diff --git a/pinot-core/src/main/java/org/apache/pinot/server/access/BasicAuthAccessFactory.java b/pinot-core/src/main/java/org/apache/pinot/server/access/BasicAuthAccessFactory.java
index 3431b5cf2f..09187ece7b 100644
--- a/pinot-core/src/main/java/org/apache/pinot/server/access/BasicAuthAccessFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/server/access/BasicAuthAccessFactory.java
@@ -37,6 +37,7 @@ public class BasicAuthAccessFactory implements AccessControlFactory {
private AccessControl _accessControl;
+ @Override
public void init(PinotConfiguration configuration) {
_accessControl = new BasicAuthAccessControl(BasicAuthUtils.extractBasicAuthPrincipals(configuration, PREFIX));
}
diff --git a/pinot-core/src/main/java/org/apache/pinot/server/access/ZkBasicAuthAccessFactory.java b/pinot-core/src/main/java/org/apache/pinot/server/access/ZkBasicAuthAccessFactory.java
new file mode 100644
index 0000000000..6606a2b026
--- /dev/null
+++ b/pinot-core/src/main/java/org/apache/pinot/server/access/ZkBasicAuthAccessFactory.java
@@ -0,0 +1,121 @@
+/**
+ * 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.server.access;
+
+import io.netty.channel.ChannelHandlerContext;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import org.apache.commons.lang.StringUtils;
+import org.apache.helix.HelixManager;
+import org.apache.pinot.common.config.provider.AccessControlUserCache;
+import org.apache.pinot.common.utils.BcryptUtils;
+import org.apache.pinot.core.auth.BasicAuthUtils;
+import org.apache.pinot.core.auth.ZkBasicAuthPrincipal;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+
+/**
+ * Zookeeper Basic Authentication based on Pinot Controller UI.
+ * The user role has been distinguished by user and admin. Only admin can have access to the
+ * user console page in Pinot controller UI. And admin can change user info (table permission/
+ * number of tables/password etc.) or add/delete user without restarting your Pinot clusters,
+ * and these changes happen immediately.
+ * Users Configuration store in Helix Zookeeper and encrypted user password via Bcrypt Encryption Algorithm.
+ *
+ */
+public class ZkBasicAuthAccessFactory implements AccessControlFactory {
+ private static final String AUTHORIZATION_KEY = "authorization";
+
+ private HelixManager _helixManager;
+ private AccessControl _accessControl;
+
+ @Override
+ public void init(PinotConfiguration configuration, HelixManager helixManager) {
+ _helixManager = helixManager;
+ }
+
+ @Override
+ public AccessControl create() {
+ if (_accessControl == null) {
+ _accessControl = new BasicAuthAccessControl(_helixManager);
+ }
+ return _accessControl;
+ }
+
+ /**
+ * Access Control using metadata-based basic grpc authentication
+ */
+ private static class BasicAuthAccessControl implements AccessControl {
+ private Map<String, ZkBasicAuthPrincipal> _name2principal;
+ private AccessControlUserCache _userCache;
+ private HelixManager _innerHelixManager;
+
+ public BasicAuthAccessControl(HelixManager helixManager) {
+ _innerHelixManager = helixManager;
+ }
+
+ public void initUserCache() {
+ if (_userCache == null) {
+ _userCache = new AccessControlUserCache(_innerHelixManager.getHelixPropertyStore());
+ }
+ }
+
+ @Override
+ public boolean isAuthorizedChannel(ChannelHandlerContext channelHandlerContext) {
+ return true;
+ }
+
+ @Override
+ public boolean hasDataAccess(RequesterIdentity requesterIdentity, String tableName) {
+ if (_userCache == null) {
+ initUserCache();
+ }
+ Collection<String> tokens = getTokens(requesterIdentity);
+ _name2principal = BasicAuthUtils.extractBasicAuthPrincipals(_userCache.getAllServerUserConfig())
+ .stream().collect(Collectors.toMap(ZkBasicAuthPrincipal::getName, p -> p));
+
+ Map<String, String> name2password = tokens.stream().collect(Collectors
+ .toMap(BasicAuthUtils::extractUsername, BasicAuthUtils::extractPassword));
+ Map<String, ZkBasicAuthPrincipal> password2principal = name2password.keySet().stream()
+ .collect(Collectors.toMap(name2password::get, _name2principal::get));
+ return password2principal.entrySet().stream()
+ .filter(entry -> BcryptUtils.checkpw(entry.getKey(), entry.getValue().getPassword()))
+ .map(u -> u.getValue())
+ .filter(Objects::nonNull)
+ .findFirst()
+ .map(zkprincipal -> StringUtils.isEmpty(tableName) || zkprincipal.hasTable(
+ TableNameBuilder.extractRawTableName(tableName)))
+ .orElse(false);
+ }
+
+ private Collection<String> getTokens(RequesterIdentity requesterIdentity) {
+ if (requesterIdentity instanceof GrpcRequesterIdentity) {
+ GrpcRequesterIdentity identity = (GrpcRequesterIdentity) requesterIdentity;
+ return identity.getGrpcMetadata().get(AUTHORIZATION_KEY);
+ }
+ if (requesterIdentity instanceof HttpRequesterIdentity) {
+ HttpRequesterIdentity identity = (HttpRequesterIdentity) requesterIdentity;
+ return identity.getHttpHeaders().get(AUTHORIZATION_KEY);
+ }
+ throw new UnsupportedOperationException("GrpcRequesterIdentity or HttpRequesterIdentity is required");
+ }
+ }
+}
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/auth/ZkBasicAuthTest.java b/pinot-core/src/test/java/org/apache/pinot/core/auth/ZkBasicAuthTest.java
new file mode 100644
index 0000000000..274505a543
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/core/auth/ZkBasicAuthTest.java
@@ -0,0 +1,59 @@
+/**
+ * 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.core.auth;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+
+public class ZkBasicAuthTest {
+
+ @Test
+ public void testBasicAuthPrincipal()
+ throws Exception {
+ Assert.assertTrue(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable"),
+ ImmutableSet.of("READ")).hasTable("myTable"));
+ Assert.assertTrue(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable", "myTable1"),
+ ImmutableSet.of("Read")).hasTable("myTable1"));
+ Assert.assertFalse(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable"),
+ ImmutableSet.of("read")).hasTable("myTable1"));
+ Assert.assertFalse(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable", "myTable1"),
+ ImmutableSet.of("read")).hasTable("myTable2"));
+
+ Assert.assertTrue(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable"),
+ ImmutableSet.of("READ")).hasPermission("read"));
+ Assert.assertTrue(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable"),
+ ImmutableSet.of("Read")).hasPermission("READ"));
+ Assert.assertTrue(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable"),
+ ImmutableSet.of("read")).hasPermission("Read"));
+ Assert.assertFalse(new ZkBasicAuthPrincipal("name", "token", "password",
+ ComponentType.CONTROLLER.name(), RoleType.ADMIN.name(), ImmutableSet.of("myTable"),
+ ImmutableSet.of("read")).hasPermission("write"));
+ }
+}
diff --git a/pinot-server/src/main/java/org/apache/pinot/server/starter/ServerInstance.java b/pinot-server/src/main/java/org/apache/pinot/server/starter/ServerInstance.java
index 0817080ed0..7041a40b24 100644
--- a/pinot-server/src/main/java/org/apache/pinot/server/starter/ServerInstance.java
+++ b/pinot-server/src/main/java/org/apache/pinot/server/starter/ServerInstance.java
@@ -99,7 +99,7 @@ public class ServerInstance {
NettyConfig nettyConfig =
NettyConfig.extractNettyConfig(serverConf.getPinotConfig(), CommonConstants.Server.SERVER_NETTY_PREFIX);
accessControlFactory.init(
- serverConf.getPinotConfig().subset(CommonConstants.Server.PREFIX_OF_CONFIG_OF_ACCESS_CONTROL));
+ serverConf.getPinotConfig().subset(CommonConstants.Server.PREFIX_OF_CONFIG_OF_ACCESS_CONTROL), helixManager);
_accessControl = accessControlFactory.create();
if (serverConf.isNettyServerEnabled()) {
diff --git a/pinot-controller/src/main/resources/app/app_state.ts b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/AccessType.java
similarity index 78%
copy from pinot-controller/src/main/resources/app/app_state.ts
copy to pinot-spi/src/main/java/org/apache/pinot/spi/config/user/AccessType.java
index 0ef89dc3c4..b4181b3dd4 100644
--- a/pinot-controller/src/main/resources/app/app_state.ts
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/AccessType.java
@@ -16,13 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+package org.apache.pinot.spi.config.user;
- import { AuthWorkflow } from "Models";
-class app_state {
- queryConsoleOnlyView: boolean;
- authWorkflow: AuthWorkflow;
- authToken: string | null;
- columnNameSeparator: string = '#$%';
+public enum AccessType {
+ CREATE, READ, UPDATE, DELETE
}
-
-export default new app_state();
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/app_state.ts b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/ComponentType.java
similarity index 78%
copy from pinot-controller/src/main/resources/app/app_state.ts
copy to pinot-spi/src/main/java/org/apache/pinot/spi/config/user/ComponentType.java
index 0ef89dc3c4..96b6359a9b 100644
--- a/pinot-controller/src/main/resources/app/app_state.ts
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/ComponentType.java
@@ -16,13 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+package org.apache.pinot.spi.config.user;
- import { AuthWorkflow } from "Models";
-class app_state {
- queryConsoleOnlyView: boolean;
- authWorkflow: AuthWorkflow;
- authToken: string | null;
- columnNameSeparator: string = '#$%';
+public enum ComponentType {
+ CONTROLLER, BROKER, SERVER, MINION, PROXY
}
-
-export default new app_state();
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/app_state.ts b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/RoleType.java
similarity index 78%
copy from pinot-controller/src/main/resources/app/app_state.ts
copy to pinot-spi/src/main/java/org/apache/pinot/spi/config/user/RoleType.java
index 0ef89dc3c4..dfab3a2aa6 100644
--- a/pinot-controller/src/main/resources/app/app_state.ts
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/RoleType.java
@@ -16,13 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+package org.apache.pinot.spi.config.user;
- import { AuthWorkflow } from "Models";
-class app_state {
- queryConsoleOnlyView: boolean;
- authWorkflow: AuthWorkflow;
- authToken: string | null;
- columnNameSeparator: string = '#$%';
+public enum RoleType {
+ ADMIN, USER
}
-
-export default new app_state();
\ No newline at end of file
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/UserConfig.java b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/UserConfig.java
new file mode 100644
index 0000000000..bfe9d0a8c9
--- /dev/null
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/config/user/UserConfig.java
@@ -0,0 +1,140 @@
+/**
+ * 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.spi.config.user;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyDescription;
+import com.google.common.base.Preconditions;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.pinot.spi.config.BaseJsonConfig;
+
+
+public class UserConfig extends BaseJsonConfig {
+ public static final String USERNAME_KEY = "username";
+ public static final String PASSWORD_KEY = "password";
+ public static final String COMPONET_KEY = "component";
+ public static final String ROLE_KEY = "role";
+ public static final String AUTH_TOKEN_KEY = "authToken";
+ public static final String TABLES_KEY = "tables";
+ public static final String PERMISSIONS_KEY = "permissions";
+
+ @JsonPropertyDescription("The name of User")
+ private String _username;
+
+ @JsonPropertyDescription("The password of User")
+ private String _password;
+
+ @JsonPropertyDescription("The name of Component")
+ private ComponentType _componentType;
+
+ @JsonPropertyDescription("The role of user")
+ private RoleType _roleType;
+
+ @JsonPropertyDescription("The tables owned of User")
+ private List<String> _tables;
+
+ @JsonPropertyDescription("The table permission of User")
+ private List<AccessType> _permissions;
+
+ @JsonCreator
+ public UserConfig(@JsonProperty(value = USERNAME_KEY, required = true) String username,
+ @JsonProperty(value = PASSWORD_KEY, required = true) String password,
+ @JsonProperty(value = COMPONET_KEY, required = true) String component,
+ @JsonProperty(value = ROLE_KEY, required = true) String role,
+ @JsonProperty(value = TABLES_KEY) @Nullable List<String> tableList,
+ @JsonProperty(value = PERMISSIONS_KEY) @Nullable List<AccessType> permissionList
+ ) {
+ Preconditions.checkArgument(username != null, "'username' must be configured");
+ Preconditions.checkArgument(password != null, "'password' must be configured");
+
+ // NOTE: Handle lower case table type and raw table name for backward-compatibility
+ _username = username;
+ _password = password;
+ _componentType = ComponentType.valueOf(component.toUpperCase());
+ _roleType = RoleType.valueOf(role.toUpperCase());
+ _tables = tableList;
+ _permissions = permissionList;
+ }
+
+ @JsonProperty(USERNAME_KEY)
+ public String getUserName() {
+ return _username;
+ }
+
+ public String getUsernameWithComponent() {
+ return getUserName() + "_" + getComponentType().toString();
+ }
+
+ public boolean isExist(String username, ComponentType component) {
+ return _username.equals(username) && _componentType.equals(component);
+ }
+
+ @JsonProperty(PASSWORD_KEY)
+ public String getPassword() {
+ return _password;
+ }
+
+ @JsonProperty(TABLES_KEY)
+ public List<String> getTables() {
+ return _tables;
+ }
+
+ @JsonProperty(PERMISSIONS_KEY)
+ public List<AccessType> getPermissios() {
+ return _permissions;
+ }
+
+ @JsonProperty(COMPONET_KEY)
+ public ComponentType getComponentType() {
+ return _componentType;
+ }
+
+ @JsonProperty(ROLE_KEY)
+ public RoleType getRoleType() {
+ return _roleType;
+ }
+
+ public void setRole(String roleTypeStr) {
+ _roleType = RoleType.valueOf(roleTypeStr);
+ }
+
+ public void setPassword(String password) {
+ _password = password;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ UserConfig that = (UserConfig) o;
+ return _username.equals(that._username) && _componentType == that._componentType;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), _username, _componentType);
+ }
+}
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/ControllerRequestURLBuilder.java b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/ControllerRequestURLBuilder.java
index 2cf765ff6b..b0a9f7e139 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/ControllerRequestURLBuilder.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/ControllerRequestURLBuilder.java
@@ -90,10 +90,31 @@ public class ControllerRequestURLBuilder {
return StringUtil.join("/", _baseUrl, "tenants");
}
+ public String forUserCreate() {
+ return StringUtil.join("/", _baseUrl, "users");
+ }
+
public String forTenantGet() {
return StringUtil.join("/", _baseUrl, "tenants");
}
+ public String forUserGet(String username, String componentTypeStr) {
+ StringBuilder params = new StringBuilder();
+ if (StringUtils.isNotBlank(username)) {
+ params.append("?component=" + componentTypeStr);
+ }
+ return StringUtil.join("/", _baseUrl, "users", username, params.toString());
+ }
+
+ public String forUpdateUserConfig(String username, String componentTypeStr, boolean passwordChanged) {
+ StringBuilder params = new StringBuilder();
+ if (StringUtils.isNotBlank(username)) {
+ params.append("?component=" + componentTypeStr);
+ }
+ params.append(String.format("&&passwordChanged=%s", passwordChanged));
+ return StringUtil.join("/", _baseUrl, "users", username, params.toString());
+ }
+
public String forTenantGet(String tenantName) {
return StringUtil.join("/", _baseUrl, "tenants", tenantName);
}
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/UserConfigBuilder.java b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/UserConfigBuilder.java
new file mode 100644
index 0000000000..5afbf639ac
--- /dev/null
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/builder/UserConfigBuilder.java
@@ -0,0 +1,70 @@
+/**
+ * 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.spi.utils.builder;
+
+import java.util.List;
+import org.apache.pinot.spi.config.user.AccessType;
+import org.apache.pinot.spi.config.user.ComponentType;
+import org.apache.pinot.spi.config.user.RoleType;
+import org.apache.pinot.spi.config.user.UserConfig;
+
+public class UserConfigBuilder {
+
+ private ComponentType _componentType;
+ private String _username;
+ private String _password;
+ private RoleType _roleType;
+ private List<String> _tableList;
+ private List<AccessType> _permissionList;
+
+ public UserConfigBuilder setComponentType(ComponentType componentType) {
+ _componentType = componentType;
+ return this;
+ }
+
+ public UserConfigBuilder setUsername(String username) {
+ _username = username;
+ return this;
+ }
+
+ public UserConfigBuilder setPassword(String password) {
+ _password = password;
+ return this;
+ }
+
+ public UserConfigBuilder setRoleType(RoleType roleType) {
+ _roleType = roleType;
+ return this;
+ }
+
+ public UserConfigBuilder setTableList(List<String> tableList) {
+ _tableList = tableList;
+ return this;
+ }
+
+ public UserConfigBuilder setPermissionList(List<AccessType> permissionList) {
+ _permissionList = permissionList;
+ return this;
+ }
+
+ public UserConfig build() {
+ return new UserConfig(_username, _password, _componentType.toString(), _roleType.toString(), _tableList,
+ _permissionList);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org