| 
 | 1 | +/*  | 
 | 2 | + * Copyright 2008-present MongoDB, Inc.  | 
 | 3 | + *  | 
 | 4 | + * Licensed under the Apache License, Version 2.0 (the "License");  | 
 | 5 | + * you may not use this file except in compliance with the License.  | 
 | 6 | + * You may obtain a copy of the License at  | 
 | 7 | + *  | 
 | 8 | + *   http://www.apache.org/licenses/LICENSE-2.0  | 
 | 9 | + *  | 
 | 10 | + * Unless required by applicable law or agreed to in writing, software  | 
 | 11 | + * distributed under the License is distributed on an "AS IS" BASIS,  | 
 | 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  | 
 | 13 | + * See the License for the specific language governing permissions and  | 
 | 14 | + * limitations under the License.  | 
 | 15 | + */  | 
 | 16 | +package com.mongodb.kafka.connect.util;  | 
 | 17 | + | 
 | 18 | +import static java.lang.String.format;  | 
 | 19 | +import static java.util.Collections.emptyList;  | 
 | 20 | + | 
 | 21 | +import java.util.ArrayList;  | 
 | 22 | +import java.util.List;  | 
 | 23 | +import java.util.Optional;  | 
 | 24 | +import java.util.concurrent.CountDownLatch;  | 
 | 25 | +import java.util.concurrent.TimeUnit;  | 
 | 26 | +import java.util.concurrent.atomic.AtomicBoolean;  | 
 | 27 | + | 
 | 28 | +import org.apache.kafka.common.config.Config;  | 
 | 29 | +import org.apache.kafka.common.config.ConfigValue;  | 
 | 30 | +import org.apache.kafka.connect.errors.ConnectException;  | 
 | 31 | +import org.slf4j.Logger;  | 
 | 32 | +import org.slf4j.LoggerFactory;  | 
 | 33 | + | 
 | 34 | +import org.bson.Document;  | 
 | 35 | + | 
 | 36 | +import com.mongodb.ConnectionString;  | 
 | 37 | +import com.mongodb.MongoClientSettings;  | 
 | 38 | +import com.mongodb.MongoCredential;  | 
 | 39 | +import com.mongodb.MongoSecurityException;  | 
 | 40 | +import com.mongodb.ReadPreference;  | 
 | 41 | +import com.mongodb.client.MongoClient;  | 
 | 42 | +import com.mongodb.client.MongoClients;  | 
 | 43 | +import com.mongodb.event.ClusterClosedEvent;  | 
 | 44 | +import com.mongodb.event.ClusterDescriptionChangedEvent;  | 
 | 45 | +import com.mongodb.event.ClusterListener;  | 
 | 46 | +import com.mongodb.event.ClusterOpeningEvent;  | 
 | 47 | + | 
 | 48 | + | 
 | 49 | +public final class ConnectionValidator {  | 
 | 50 | + | 
 | 51 | +    private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionValidator.class);  | 
 | 52 | +    private static final String USERS_INFO = "{usersInfo: '%s', showPrivileges: 1}";  | 
 | 53 | +    private static final String ROLES_INFO = "{rolesInfo: '%s', showPrivileges: 1, showBuiltinRoles: 1}";  | 
 | 54 | + | 
 | 55 | +    public static Optional<MongoClient> validateCanConnect(final Config config, final String connectionStringConfigName) {  | 
 | 56 | +        Optional<ConfigValue> optionalConnectionString = getConfigByName(config, connectionStringConfigName);  | 
 | 57 | +        if (optionalConnectionString.isPresent() && optionalConnectionString.get().errorMessages().isEmpty()) {  | 
 | 58 | +            ConfigValue configValue = optionalConnectionString.get();  | 
 | 59 | + | 
 | 60 | +            AtomicBoolean connected = new AtomicBoolean();  | 
 | 61 | +            CountDownLatch latch = new CountDownLatch(1);  | 
 | 62 | +            ConnectionString connectionString = new ConnectionString((String) configValue.value());  | 
 | 63 | +            MongoClientSettings mongoClientSettings = MongoClientSettings.builder()  | 
 | 64 | +                    .applyConnectionString(connectionString)  | 
 | 65 | +                    .applyToClusterSettings(b -> b.addClusterListener(new ClusterListener() {  | 
 | 66 | +                        @Override  | 
 | 67 | +                        public void clusterOpening(final ClusterOpeningEvent event) {  | 
 | 68 | +                        }  | 
 | 69 | + | 
 | 70 | +                        @Override  | 
 | 71 | +                        public void clusterClosed(final ClusterClosedEvent event) {  | 
 | 72 | +                        }  | 
 | 73 | + | 
 | 74 | +                        @Override  | 
 | 75 | +                        public void clusterDescriptionChanged(final ClusterDescriptionChangedEvent event) {  | 
 | 76 | +                            ReadPreference readPreference = connectionString.getReadPreference() != null  | 
 | 77 | +                                    ? connectionString.getReadPreference() : ReadPreference.primaryPreferred();  | 
 | 78 | +                            if (!connected.get() && event.getNewDescription().hasReadableServer(readPreference)) {  | 
 | 79 | +                                connected.set(true);  | 
 | 80 | +                                latch.countDown();  | 
 | 81 | +                            }  | 
 | 82 | +                        }  | 
 | 83 | +                    }))  | 
 | 84 | +                    .build();  | 
 | 85 | + | 
 | 86 | +            long latchTimeout = mongoClientSettings.getSocketSettings().getConnectTimeout(TimeUnit.MILLISECONDS) + 500;  | 
 | 87 | +            MongoClient mongoClient = MongoClients.create(mongoClientSettings);  | 
 | 88 | + | 
 | 89 | +            try {  | 
 | 90 | +                if (!latch.await(latchTimeout, TimeUnit.MILLISECONDS)) {  | 
 | 91 | +                    configValue.addErrorMessage("Unable to connect to the server.");  | 
 | 92 | +                    mongoClient.close();  | 
 | 93 | +                }  | 
 | 94 | +            } catch (InterruptedException e) {  | 
 | 95 | +                mongoClient.close();  | 
 | 96 | +                throw new ConnectException(e);  | 
 | 97 | +            }  | 
 | 98 | + | 
 | 99 | +            if (configValue.errorMessages().isEmpty()) {  | 
 | 100 | +                return Optional.of(mongoClient);  | 
 | 101 | +            }  | 
 | 102 | +        }  | 
 | 103 | +        return Optional.empty();  | 
 | 104 | +    }  | 
 | 105 | + | 
 | 106 | +    public static void validateUserHasActions(final MongoClient mongoClient, final MongoCredential credential, final List<String> actions,  | 
 | 107 | +                                              final String databaseName, final String collectionName, final String configName,  | 
 | 108 | +                                              final Config config) {  | 
 | 109 | + | 
 | 110 | +        if (credential == null) {  | 
 | 111 | +            return;  | 
 | 112 | +        }  | 
 | 113 | + | 
 | 114 | +        try {  | 
 | 115 | +            Document usersInfo = mongoClient.getDatabase(credential.getSource())  | 
 | 116 | +                    .runCommand(Document.parse(format(USERS_INFO, credential.getUserName())));  | 
 | 117 | + | 
 | 118 | +            List<String> unsupportedActions = new ArrayList<>(actions);  | 
 | 119 | +            for (final Document userInfo : usersInfo.getList("users", Document.class)) {  | 
 | 120 | +                unsupportedActions = removeUserActions(userInfo, credential.getSource(), databaseName, collectionName, actions);  | 
 | 121 | + | 
 | 122 | +                if (!unsupportedActions.isEmpty() && userInfo.getList("inheritedPrivileges", Document.class, emptyList()).isEmpty()) {  | 
 | 123 | +                    for (final Document inheritedRole : userInfo.getList("inheritedRoles", Document.class, emptyList())) {  | 
 | 124 | +                        Document rolesInfo = mongoClient.getDatabase(inheritedRole.getString("db"))  | 
 | 125 | +                                .runCommand(Document.parse(format(ROLES_INFO, inheritedRole.getString("role"))));  | 
 | 126 | +                        for (final Document roleInfo : rolesInfo.getList("roles", Document.class, emptyList())) {  | 
 | 127 | +                            unsupportedActions = removeUserActions(roleInfo, credential.getSource(), databaseName, collectionName,  | 
 | 128 | +                                    unsupportedActions);  | 
 | 129 | +                        }  | 
 | 130 | + | 
 | 131 | +                        if (unsupportedActions.isEmpty()) {  | 
 | 132 | +                            return;  | 
 | 133 | +                        }  | 
 | 134 | +                    }  | 
 | 135 | +                }  | 
 | 136 | +                if (unsupportedActions.isEmpty()) {  | 
 | 137 | +                    return;  | 
 | 138 | +                }  | 
 | 139 | +            }  | 
 | 140 | + | 
 | 141 | +            String missingPermissions = String.join(", ", unsupportedActions);  | 
 | 142 | +            getConfigByName(config, configName).ifPresent(c ->  | 
 | 143 | +                    c.addErrorMessage(format("Invalid user permissions. Missing the following action permissions: %s", missingPermissions))  | 
 | 144 | +            );  | 
 | 145 | +        } catch (MongoSecurityException e) {  | 
 | 146 | +            getConfigByName(config, configName).ifPresent(c -> c.addErrorMessage("Invalid user permissions authentication failed.")  | 
 | 147 | +            );  | 
 | 148 | +        } catch (Exception e) {  | 
 | 149 | +            LOGGER.warn("Permission validation failed due to: {}", e.getMessage(), e);  | 
 | 150 | +        }  | 
 | 151 | +    }  | 
 | 152 | + | 
 | 153 | +    /**  | 
 | 154 | +     * Checks the roles info document for matching actions and removes them from the provided list  | 
 | 155 | +     *  | 
 | 156 | +     * See: https://docs.mongodb.com/manual/reference/command/rolesInfo  | 
 | 157 | +     * See: https://docs.mongodb.com/manual/reference/resource-document/  | 
 | 158 | +     */  | 
 | 159 | +    private static List<String> removeUserActions(final Document rolesInfo, final String authDatabase, final String databaseName,  | 
 | 160 | +                                                  final String collectionName, final List<String> userActions) {  | 
 | 161 | +        List<Document> privileges = rolesInfo.getList("inheritedPrivileges", Document.class, emptyList());  | 
 | 162 | +        if (privileges.isEmpty() || userActions.isEmpty()) {  | 
 | 163 | +            return userActions;  | 
 | 164 | +        }  | 
 | 165 | + | 
 | 166 | +        List<String> unsupportedUserActions = new ArrayList<>(userActions);  | 
 | 167 | +        for (final Document privilege : privileges) {  | 
 | 168 | +            Document resource = privilege.get("resource", new Document());  | 
 | 169 | +            if (resource.containsKey("cluster") && resource.getBoolean("cluster")) {  | 
 | 170 | +                unsupportedUserActions.removeAll(privilege.getList("actions", String.class, emptyList()));  | 
 | 171 | +            } else if (resource.containsKey("db") && resource.containsKey("collection")) {  | 
 | 172 | +                String database = resource.getString("db");  | 
 | 173 | +                String collection = resource.getString("collection");  | 
 | 174 | + | 
 | 175 | +                boolean resourceMatches = false;  | 
 | 176 | +                boolean collectionMatches = collection.isEmpty() || collection.equals(collectionName);  | 
 | 177 | +                if (database.isEmpty() && collectionMatches) {  | 
 | 178 | +                    resourceMatches = true;  | 
 | 179 | +                } else if (database.equals(authDatabase) && collection.isEmpty()) {  | 
 | 180 | +                    resourceMatches = true;  | 
 | 181 | +                } else if (database.equals(databaseName) && collectionMatches) {  | 
 | 182 | +                    resourceMatches = true;  | 
 | 183 | +                }  | 
 | 184 | + | 
 | 185 | +                if (resourceMatches) {  | 
 | 186 | +                    unsupportedUserActions.removeAll(privilege.getList("actions", String.class, emptyList()));  | 
 | 187 | +                }  | 
 | 188 | +            }  | 
 | 189 | + | 
 | 190 | +            if (unsupportedUserActions.isEmpty()) {  | 
 | 191 | +                break;  | 
 | 192 | +            }  | 
 | 193 | +        }  | 
 | 194 | + | 
 | 195 | +        return unsupportedUserActions;  | 
 | 196 | +    }  | 
 | 197 | + | 
 | 198 | +    private static Optional<ConfigValue> getConfigByName(final Config config, final String name) {  | 
 | 199 | +        for (final ConfigValue configValue : config.configValues()) {  | 
 | 200 | +            if (configValue.name().equals(name)) {  | 
 | 201 | +                return Optional.of(configValue);  | 
 | 202 | +            }  | 
 | 203 | +        }  | 
 | 204 | +        return Optional.empty();  | 
 | 205 | +    }  | 
 | 206 | + | 
 | 207 | +    private ConnectionValidator() {  | 
 | 208 | +    }  | 
 | 209 | +}  | 
0 commit comments