diff --git a/build.gradle b/build.gradle index 04ead46fe..2f81564e3 100644 --- a/build.gradle +++ b/build.gradle @@ -179,6 +179,9 @@ configurations { } dependencies { + implementation group: 'org.bouncycastle', name: 'bcpkix-jdk18on', version: '1.78.1' + implementation(project(':vertx-auth-mtls')) + compileOnly('org.jetbrains:annotations:23.0.0') testCompileOnly('org.jetbrains:annotations:23.0.0') integrationTestCompileOnly('org.jetbrains:annotations:23.0.0') @@ -259,6 +262,14 @@ dependencies { integrationTestImplementation(group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: "${project.jacksonVersion}") containerTestImplementation('com.adobe.testing:s3mock-testcontainers:2.17.0') // 3.x version do not support java 11 + + testImplementation group: 'junit', name: 'junit', version: '4.13' + testImplementation platform('org.junit:junit-bom:5.10.0') + testImplementation 'org.junit.jupiter:junit-jupiter' + implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.3' + implementation 'org.jetbrains:annotations:24.0.0' + implementation 'org.apache.httpcomponents:httpclient:4.5.2' + implementation group: 'org.codehaus.httpcache4j.uribuilder', name: 'uribuilder', version: '1.0.0' } jar { @@ -384,12 +395,9 @@ def integrationTest = task("integrationTest") systemProperty "cassandra.sidecar.versions_to_test", versionsToTest } useJUnitPlatform() { - if (name.contains("HeavyWeight")) - { + if (name.contains("HeavyWeight")) { includeTags "heavy" - } - else - { + } else { excludeTags "heavy" } } @@ -416,8 +424,7 @@ def integrationTest = task("integrationTest") logger.lifecycle("--") if (totalTime >= 60) { // log the tests that take 1 minute and more logger.warn("$descriptor.displayName took $totalTime s") - } - else { + } else { logger.info("$descriptor.displayName took $totalTime s") } } diff --git a/settings.gradle b/settings.gradle index 1decc5b37..1fa3c9346 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,7 +24,7 @@ include "adapters:cassandra41" include "client-common" include "server-common" include "client" +include "vertx-auth-mtls" include "vertx-client" include "vertx-client-shaded" include "docs" - diff --git a/src/main/dist/conf/sidecar.yaml b/src/main/dist/conf/sidecar.yaml index 2595689b8..932de77ca 100644 --- a/src/main/dist/conf/sidecar.yaml +++ b/src/main/dist/conf/sidecar.yaml @@ -83,6 +83,14 @@ sidecar: min_free_space_percent: 10 # file_permissions: "rw-r--r--" # when not specified, the default file permissions are owner read & write, group & others read allowable_time_skew_in_minutes: 60 + # Insert here cache details + refresh_permission_caches: + initial_delay_millis: 0 + poll_freq_millis: 30000 + stale_after_millis: 3600000 + cache: + expire_after_access_millis: 7200000 + maximum_size: 10000 sstable_import: poll_interval_millis: 100 cache: @@ -113,23 +121,43 @@ sidecar: # # Enable SSL configuration (Disabled by default) # -# ssl: -# enabled: true -# use_openssl: true -# handshake_timeout_sec: 10 -# client_auth: NONE # valid options are NONE, REQUEST, REQUIRED -# accepted_protocols: -# - TLSv1.2 -# - TLSv1.3 -# cipher_suites: [] -# keystore: -# type: PKCS12 -# path: "path/to/keystore.p12" -# password: password -# check_interval_sec: 300 -# truststore: -# path: "path/to/truststore.p12" -# password: password +#ssl: +# enabled: true +# use_openssl: true +# handshake_timeout_sec: 10 +# client_auth: REQUIRED # valid options are NONE, REQUEST, REQUIRED +# accepted_protocols: +# - TLSv1.2 +# - TLSv1.3 +# cipher_suites: [] +# keystore: +# type: PKCS12 +# path: "path/to/keystore.p12" +# password: password +# check_interval_sec: 300 +# truststore: +# path: "path/to/truststore.jks" +# password: password + +# Authentication Configuration +authenticator: + auth_config: AllowAll # Valid options are MutualTls or AllowAll + # Valid options are Spiffe, AllowAll or RejectAll + cert_validator: AllowAll + # Valid options are MutualTls, AllowAll or RejectAll + id_validator: AllowAll + authorized_identities: + - spiffe://authorized/admin/identities + +# Authorizer Configuration +authorizer: + auth_config: AllowAll # valid options are AllowAll or MutualTLS + role_to_sidecar_permissions: # Permissions mapping roles to sidecar specific permissions +# - role: "trusted_role" +# permissions: +# - ClEANUP_SSTABLE +# - "< KEYSPACE system_auth >" +# - "< TABLE system_auth.roles >" driver_parameters: contact_points: diff --git a/src/main/java/org/apache/cassandra/sidecar/CassandraSidecarDaemon.java b/src/main/java/org/apache/cassandra/sidecar/CassandraSidecarDaemon.java index fd020a22a..704ac44c9 100644 --- a/src/main/java/org/apache/cassandra/sidecar/CassandraSidecarDaemon.java +++ b/src/main/java/org/apache/cassandra/sidecar/CassandraSidecarDaemon.java @@ -40,8 +40,7 @@ public class CassandraSidecarDaemon public static void main(String[] args) { - String yamlConfigurationPath = System.getProperty("sidecar.config", "file://./conf/config.yaml"); - + String yamlConfigurationPath = System.getProperty("sidecar.config", "file://./conf/sidecar.yaml"); Path confPath; try { diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authentication/AllowAllAuthenticationProvider.java b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/AllowAllAuthenticationProvider.java new file mode 100644 index 000000000..d5dd53937 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/AllowAllAuthenticationProvider.java @@ -0,0 +1,45 @@ +/* + * 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.cassandra.sidecar.auth.authentication; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authentication.AuthenticationProvider; +import io.vertx.ext.auth.authentication.Credentials; + +/** + * Authentication provider to approve all requests regardless of the certificate they send. + */ +public class AllowAllAuthenticationProvider implements AuthenticationProvider +{ + @Override + public Future authenticate(Credentials credentials) + { + return Future.succeededFuture(User.fromName("AllowAll")); + } + + @Override + public void authenticate(JsonObject credentials, Handler> resultHandler) + { + throw new UnsupportedOperationException("Deprecated authentication method"); + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authentication/AuthenticatorConfig.java b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/AuthenticatorConfig.java new file mode 100644 index 000000000..36493fd78 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/AuthenticatorConfig.java @@ -0,0 +1,28 @@ +/* + * 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.cassandra.sidecar.auth.authentication; + +/** + * Supported authentication providers + */ +public enum AuthenticatorConfig +{ + MutualTls, + AllowAll +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authentication/CertificateValidatorConfig.java b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/CertificateValidatorConfig.java new file mode 100644 index 000000000..47519af4b --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/CertificateValidatorConfig.java @@ -0,0 +1,29 @@ +/* + * 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.cassandra.sidecar.auth.authentication; + +/** + * Supported Certificate Validators + */ +public enum CertificateValidatorConfig +{ + Spiffe, + AllowAll, + RejectAll +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authentication/IdentityValidatorConfig.java b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/IdentityValidatorConfig.java new file mode 100644 index 000000000..1b027288e --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authentication/IdentityValidatorConfig.java @@ -0,0 +1,29 @@ +/* + * 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.cassandra.sidecar.auth.authentication; + +/** + * Supported identity validators + */ +public enum IdentityValidatorConfig +{ + MutualTls, + AllowAll, + RejectAll +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/AllowAllAuthorizationProvider.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/AllowAllAuthorizationProvider.java new file mode 100644 index 000000000..6fb2193b8 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/AllowAllAuthorizationProvider.java @@ -0,0 +1,66 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import java.util.HashSet; +import java.util.Set; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; + +/** + * Authorizer to allow all requests and grant every request all permissions + */ +public class AllowAllAuthorizationProvider implements AuthorizationProvider +{ + public String getId() + { + return "AllowAll"; + } + + public void getAuthorizations(User user, Handler> handler) + { + getAuthorizations(user).onComplete(handler); + } + + public Future getAuthorizations(User user) + { + if (user == null) + { + return Future.failedFuture("User cannot be null"); + } + + Set allowAllAuthorizations = new HashSet<>(); + AndAuthorization allowAllPermissions = AndAuthorization.create(); + for (MutualTlsPermissions perm : MutualTlsPermissions.ALL) + { + allowAllPermissions.addAuthorization(RoleBasedAuthorization.create(perm.name()).setResource("ALL KEYSPACES")); + } + allowAllAuthorizations.add(allowAllPermissions); + user.authorizations().add(getId(), allowAllAuthorizations); + + return Future.succeededFuture(); + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/AuthorizerConfig.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/AuthorizerConfig.java new file mode 100644 index 000000000..d63a629fb --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/AuthorizerConfig.java @@ -0,0 +1,28 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +/** + * Supported authorization providers + */ +public enum AuthorizerConfig +{ + MutualTls, + AllowAll +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/MutualTlsAuthorizationProvider.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/MutualTlsAuthorizationProvider.java new file mode 100644 index 000000000..f34647520 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/MutualTlsAuthorizationProvider.java @@ -0,0 +1,120 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; + +/** + * {@inheritDoc} + */ +public class MutualTlsAuthorizationProvider implements AuthorizationProvider +{ + private final PermissionsAccessor permissionsAccessor; + + public MutualTlsAuthorizationProvider(PermissionsAccessor permissionsAccessor) + { + this.permissionsAccessor = permissionsAccessor; + } + + /** + * returns the id of the authorization provider + * + * @return + */ + public String getId() + { + return "mtls"; + } + + /** + * Updates the user with the set of authorizations. + * + * @param user user to lookup and update + * @param handler result handler + */ + public void getAuthorizations(User user, Handler> handler) + { + getAuthorizations(user).onComplete(handler); + } + + public Future getAuthorizations(User user) + { + try + { + if (user == null) + { + return Future.failedFuture("No user provided"); + } + + String identity = user.subject(); + + if (identity == null || identity.isEmpty()) + { + return Future.failedFuture("Identity cannot be null or empty"); + } + CompletableFuture rolesFuture = permissionsAccessor.getRoleFromIdentity(identity); + String roles = rolesFuture.get(); + if (roles == null || roles.isEmpty()) + { + return Future.failedFuture("No roles match the given identity"); + } + + CompletableFuture superUserStatusFuture = permissionsAccessor.getSuperUserStatus(roles); + Boolean superUserStatus = superUserStatusFuture.get(); + if (superUserStatus) + { + // Add superuser authorization to user + Set superUserAuthorization = new HashSet<>(); + AndAuthorization superUserPermissions = AndAuthorization.create(); + for (MutualTlsPermissions perm : MutualTlsPermissions.ALL) + { + superUserPermissions.addAuthorization(RoleBasedAuthorization.create(perm.name()).setResource("ALL KEYSPACES")); + } + superUserAuthorization.add(superUserPermissions); + user.authorizations().add(getId(), superUserAuthorization); + return Future.succeededFuture(); + } + CompletableFuture permissionsFuture = permissionsAccessor.getPermissions(roles); + AndAuthorization permissions = permissionsFuture.get(); + if (permissions == null) + { + return Future.failedFuture("Could not get permissions for the specified identity"); + } + user.authorizations().add(getId(), permissions); + + return Future.succeededFuture(); + } + catch (InterruptedException | ExecutionException e) + { + return Future.failedFuture("Could not get permissions for the user"); + } + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/MutualTlsPermissions.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/MutualTlsPermissions.java new file mode 100644 index 000000000..355005d89 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/MutualTlsPermissions.java @@ -0,0 +1,73 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import java.util.EnumSet; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; + +/** + * Enum for the permissions in Sidecar + */ +public enum MutualTlsPermissions +{ + CREATE, + ALTER, + DROP, + + // data access + SELECT, // required for SELECT on a table + MODIFY, // required for INSERT, UPDATE, DELETE, TRUNCATE on a DataResource. + + // permission management + AUTHORIZE, // required for GRANT and REVOKE of permissions or roles. + + DESCRIBE, // required on the root-level RoleResource to list all Roles + + // UDF permissions + EXECUTE, // required to invoke any user defined function or aggregate + + UNMASK, // required to see masked data + + SELECT_MASKED, // required for SELECT on a table with restictions on masked columns + + // Sidecar specific permissions + STREAM_SSTABLES, + LIST_SNAPSHOTS, + CLEAR_SNAPSHOTS, + CREATE_SNAPSHOT, + KEYSPACE_SCHEMA, + RING, + UPLOAD_SSTABLE, + KEYSPACE_TOKEN_MAPPING, + CLEANUP_SSTABLE, + GOSSIP_INFO, + CREATE_RESTORE_JOB, + CREATE_RESTORE_JOB_SLICES, + RESTORE_JOB, + PATCH_RESTORE_JOB, + ABORT_RESTORE_JOB, + RESTORE_JOB_PROGRESS; // required to create a snapshot on a given table + + public static final Set ALL = + Sets.immutableEnumSet(EnumSet.range(MutualTlsPermissions.CREATE, MutualTlsPermissions.ABORT_RESTORE_JOB)); + public static final Set NONE = ImmutableSet.of(); +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/PermissionsAccessor.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/PermissionsAccessor.java new file mode 100644 index 000000000..4ab21fc8a --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/PermissionsAccessor.java @@ -0,0 +1,123 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import java.util.concurrent.CompletableFuture; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.vertx.ext.auth.authorization.AndAuthorization; +import org.apache.cassandra.sidecar.utils.CacheFactory; + +/** + * Contains the caches that hold the information necessary for authorizing a user + */ +@Singleton +public class PermissionsAccessor +{ + private AsyncLoadingCache rolesToSuperUserCache; + private AsyncLoadingCache identityToRolesCache; + private AsyncLoadingCache roleToPermissionsCache; + + @Inject + public PermissionsAccessor(CacheFactory cacheFactory) + { + + this.rolesToSuperUserCache = cacheFactory.rolesToSuperUserCache(); + this.identityToRolesCache = cacheFactory.identityToRolesCache(); + this.roleToPermissionsCache = cacheFactory.roleToPermissionsCache(); + } + + /** + * Returns a {@code CompletableFuture} containing the role associated with the inputted + * identity. If the identity doesn't exist in the cache, null will be returned. + * + * @param identity - identity to be looked up in the cache + * @return - A {@code CompletableFuture} + */ + public CompletableFuture getRoleFromIdentity(String identity) + { + return identityToRolesCache.getIfPresent(identity); + } + + /** + * Returns a {@code CompletableFuture} that contains {@code true} if the given role + * is a superuser and {@code false} otherwise + * + * @param role - the role to be looked up in the cache + * @return - A {@code CompletableFuture} + */ + public CompletableFuture getSuperUserStatus(String role) + { + return rolesToSuperUserCache.getIfPresent(role); + } + + /** + * Returns a {@code CompletableFuture} containing the {@code AndAuthorization} representing all + * the permissions associated with the inputted role. + * + * @param role - the role to be looked up in the cache + * @return - A {@code CompletableFuture} + */ + public CompletableFuture getPermissions(String role) + { + return roleToPermissionsCache.getIfPresent(role); + } + + /** + * @return - the cache mapping roles to superusers + */ + public AsyncLoadingCache getRolesToSuperUserCache() + { + return rolesToSuperUserCache; + } + + /** + * @return - the cache mapping identity to roles + */ + public AsyncLoadingCache getIdentityToRolesCache() + { + return identityToRolesCache; + } + + /** + * @return - the cache mapping roles to permissions + */ + public AsyncLoadingCache getRoleToPermissionsCache() + { + return roleToPermissionsCache; + } + + /** + * Sets this instances caches to the caches passed in as input + * + * @param rolesToSuperUserCache - cache mapping roles to superusers + * @param identityToRolesCache - cache mapping identity to roles + * @param roleToPermissionsCache - cache mapping roles to permissions + */ + public void setCaches(AsyncLoadingCache rolesToSuperUserCache, + AsyncLoadingCache identityToRolesCache, + AsyncLoadingCache roleToPermissionsCache) + { + this.rolesToSuperUserCache = rolesToSuperUserCache; + this.identityToRolesCache = identityToRolesCache; + this.roleToPermissionsCache = roleToPermissionsCache; + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/RequiredPermissionsProvider.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/RequiredPermissionsProvider.java new file mode 100644 index 000000000..0df992f07 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/RequiredPermissionsProvider.java @@ -0,0 +1,75 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.OrAuthorization; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; + +/** + * Required Permissions Provider where we get the required permissions from the configuration. This + * implementation uses a mapping from generic endpoints to an {@code AndAuthorization} and sets the resource + * with the given parameters. + */ +public class RequiredPermissionsProvider +{ + private final Map> endpointsToPermissions; + + public RequiredPermissionsProvider() + { + endpointsToPermissions = new HashMap<>(); + } + + public void putPermissionsMapping(String endpoint, List permissions) + { + endpointsToPermissions.put(endpoint, permissions); + } + + public AndAuthorization requiredPermissions(String endpoint, Map params) + { + List permissions = endpointsToPermissions.get(endpoint); + if (permissions == null) return AndAuthorization.create(); + String resource = ""; + AndAuthorization requiredAuthorizations = AndAuthorization.create(); + if (params.get("keyspace") != null && params.get("table") != null) + { + resource = ""; + } + else if (params.get("keyspace") != null) + { + resource = ""; + } + + for (MutualTlsPermissions permission : permissions) + { + OrAuthorization requiredResourceOrAll = OrAuthorization.create(); + if (!resource.isEmpty()) + { + requiredResourceOrAll.addAuthorization(RoleBasedAuthorization.create(permission.name()).setResource(resource)); + } + requiredResourceOrAll.addAuthorization(RoleBasedAuthorization.create(permission.name()).setResource("")); + requiredAuthorizations.addAuthorization(requiredResourceOrAll); + } + return requiredAuthorizations; + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/SystemAuthDatabaseAccessor.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/SystemAuthDatabaseAccessor.java new file mode 100644 index 000000000..270037eed --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/SystemAuthDatabaseAccessor.java @@ -0,0 +1,167 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import java.util.ArrayList; +import java.util.List; + +import com.datastax.driver.core.BoundStatement; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Row; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import org.apache.cassandra.sidecar.common.server.CQLSessionProvider; +import org.apache.cassandra.sidecar.db.DatabaseAccessor; +import org.apache.cassandra.sidecar.db.schema.SidecarSchema; + +/** + * Database Accessor that queries cassandra to get all the necessary information + * associated with a user to authorize them + */ +@Singleton +public class SystemAuthDatabaseAccessor extends DatabaseAccessor +{ + @Inject + public SystemAuthDatabaseAccessor(SidecarSchema sidecarSchema, + SystemAuthSchema systemAuthSchema, + CQLSessionProvider sessionProvider) + { + super(sidecarSchema, systemAuthSchema, sessionProvider); + } + + /** + * Queries Cassandra for the role associated with the inputted identity. + * + * @param identity - The identity to be queried in Cassandra + * @return - the role associated with the given identity in Cassandra + */ + public String findRoleFromIdentity(String identity) + { + BoundStatement statement = tableSchema.selectRolesFromIdentity() + .bind(identity); + ResultSet result = execute(statement); + + return result.one().getString("role"); + } + + /** + * Queries cassandra to get the permissions associated with the + * inputted role. These permissions are in an AndAuthorization which + * lists every authorization that the user has where each one is connected + * to a resource. + * + * @param role - the role to be queried in Cassandra + * @return - the associated permissions + */ + public AndAuthorization findPermissionsFromResourceRole(String role) + { + BoundStatement statement = tableSchema.selectPermissionsFromResourceRole() + .bind(role); + ResultSet result = execute(statement); + AndAuthorization permissions = AndAuthorization.create(); + for (Row row : result) + { + for (String permission : row.getSet("permissions", String.class)) + { + if (!permissions.verify(RoleBasedAuthorization.create(permission).setResource(row.getString("resource")))) + { + permissions.addAuthorization(RoleBasedAuthorization.create(permission).setResource(row.getString("resource"))); + } + } + } + return permissions; + } + + /** + * Queries Cassandra to see whether a role is a superuser or not. + * + * @param role - the role to be queried + * @return - {@code true} if the role is a superuser and {@code false} otherwise + */ + public boolean isSuperUser(String role) + { + BoundStatement statement = tableSchema.selectIsSuperUser() + .bind(role); + + ResultSet result = execute(statement); + Row userRow = result.one(); + if (userRow == null) + { + return false; + } + return userRow.getBool("is_superuser"); + } + + /** + * Queries Cassandra for all rows in identity to role table + * + * @return - {@code List} containing each row in the identity to roles table + */ + public List getAllRolesAndIdentities() + { + BoundStatement statement = tableSchema.getAllRolesAndIdentities().bind(); + + ResultSet resultSet = execute(statement); + List results = new ArrayList<>(); + for (Row row : resultSet) + { + results.add(row); + } + return results; + } + + /** + * Queries Cassandra for all rows in the resource, role to permissions table + * + * @return - {@code List} containing each row in the table mapping + * resources, role to permissions + */ + public List getAllPermissionsFromResourceRole() + { + BoundStatement statement = tableSchema.getAllPermissionsFromResourceRole().bind(); + + ResultSet resultSet = execute(statement); + List results = new ArrayList<>(); + for (Row row : resultSet) + { + results.add(row); + } + return results; + } + + /** + * Queries Cassandra for all roles in the roles table along with whether they are superusers + * + * @return - {@code List} of roles and their superuser status + */ + public List getAllRoles() + { + BoundStatement statement = tableSchema.getAllRoles().bind(); + + ResultSet resultSet = execute(statement); + List results = new ArrayList<>(); + for (Row row : resultSet) + { + results.add(row); + } + return results; + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/auth/authorization/SystemAuthSchema.java b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/SystemAuthSchema.java new file mode 100644 index 000000000..2fba6876a --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/auth/authorization/SystemAuthSchema.java @@ -0,0 +1,151 @@ +/* + * 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.cassandra.sidecar.auth.authorization; + +import com.datastax.driver.core.Metadata; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Session; +import org.apache.cassandra.sidecar.db.schema.TableSchema; +import org.jetbrains.annotations.NotNull; + +/** + * Schema for getting the permissions from Cassandra through queries. + */ +public class SystemAuthSchema extends TableSchema +{ + PreparedStatement selectRolesFromIdentity; + PreparedStatement selectPermissionsFromResourceRole; + PreparedStatement selectIsSuperUser; + PreparedStatement getAllRolesAndIdentities; + PreparedStatement getAllPermissionsFromResourceRole; + PreparedStatement getAllRoles; + + protected String keyspaceName() + { + return "system_auth"; + } + + @Override + protected void prepareStatements(@NotNull Session session) + { + selectRolesFromIdentity = prepare(selectRolesFromIdentity, + session, + CqlLiterals.selectRolesFromIdentity()); + selectPermissionsFromResourceRole = prepare(selectPermissionsFromResourceRole, + session, + CqlLiterals.selectPermissionsFromResourceRole()); + selectIsSuperUser = prepare(selectIsSuperUser, + session, + CqlLiterals.selectIsSuperUser()); + + getAllRolesAndIdentities = prepare(getAllRolesAndIdentities, + session, + CqlLiterals.getAllRolesAndIdentities()); + + getAllPermissionsFromResourceRole = prepare(getAllPermissionsFromResourceRole, + session, + CqlLiterals.getAllPermissionsFromResourceRole()); + + getAllRoles = prepare(getAllRoles, + session, + CqlLiterals.getAllRoles()); + } + + protected String tableName() + { + throw new UnsupportedOperationException("The method should not be called"); + } + + @Override + protected boolean exists(@NotNull Metadata metadata) + { + return true; + } + + @Override + protected String createSchemaStatement() + { + return ("CREATE TABLE IF NOT EXISTS system_auth.identity_to_role (" + + "identity text," + + "role text)"); + } + + public PreparedStatement selectRolesFromIdentity() + { + return selectRolesFromIdentity; + } + + public PreparedStatement selectPermissionsFromResourceRole() + { + return selectPermissionsFromResourceRole; + } + + public PreparedStatement selectIsSuperUser() + { + return selectIsSuperUser; + } + + public PreparedStatement getAllRolesAndIdentities() + { + return getAllRolesAndIdentities; + } + + public PreparedStatement getAllPermissionsFromResourceRole() + { + return getAllPermissionsFromResourceRole; + } + + public PreparedStatement getAllRoles() + { + return getAllRoles; + } + + private static class CqlLiterals + { + static String selectRolesFromIdentity() + { + return "SELECT role FROM system_auth.identity_to_role WHERE identity = ?"; + } + + static String selectPermissionsFromResourceRole() + { + return "SELECT resource, permissions FROM system_auth.role_permissions WHERE role = ?"; + } + + static String selectIsSuperUser() + { + return "SELECT is_superuser FROM system_auth.roles WHERE role = ?;"; + } + + static String getAllRolesAndIdentities() + { + return "SELECT * FROM system_auth.identity_to_role;"; + } + + static String getAllPermissionsFromResourceRole() + { + return "SELECT * FROM system_auth.role_permissions;"; + } + + static String getAllRoles() + { + return "SELECT role, is_superuser FROM system_auth.roles;"; + } + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/AuthenticatorConfiguration.java b/src/main/java/org/apache/cassandra/sidecar/config/AuthenticatorConfiguration.java new file mode 100644 index 000000000..0e786dd62 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/AuthenticatorConfiguration.java @@ -0,0 +1,63 @@ +/* + * 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.cassandra.sidecar.config; + +import java.util.Set; + +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.CertificateValidatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.IdentityValidatorConfig; + +/** + * Encapsulates Authentication Configuration + */ +public interface AuthenticatorConfiguration +{ + AuthenticatorConfig DEFAULT_AUTHENTICATOR = AuthenticatorConfig.AllowAll; + IdentityValidatorConfig DEFAULT_ID_VALIDATOR = IdentityValidatorConfig.AllowAll; + CertificateValidatorConfig DEFAULT_CERT_VALIDATOR = CertificateValidatorConfig.AllowAll; + + /** + * Returns the desired authentication scheme as provided in the yaml file + * + * @return A {@code String} representation of desired authentication scheme + */ + AuthenticatorConfig authConfig(); + + /** + * Returns the desired certificate validator as provided in the yaml file + * + * @return A {@code String} representation of desired certificate validator + */ + CertificateValidatorConfig certValidator(); + + /** + * Returns the desired identity validator as provided in the yaml file + * + * @return A {@code String} representation of desired identity validator + */ + IdentityValidatorConfig idValidator(); + + /** + * Returns a {@code Set} of authorized identities. Values are all of type {@code String}. + * + * @return A {@code Set} of authorized identities + */ + Set authorizedIdentities(); +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/AuthorizerConfiguration.java b/src/main/java/org/apache/cassandra/sidecar/config/AuthorizerConfiguration.java new file mode 100644 index 000000000..7c8493ba4 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/AuthorizerConfiguration.java @@ -0,0 +1,40 @@ +/* + * 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.cassandra.sidecar.config; + +import java.util.List; + +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; + +/** + * Configuration for the authorizer + */ +public interface AuthorizerConfiguration +{ + AuthorizerConfig DEFAULT_AUTHORIZER = AuthorizerConfig.AllowAll; + + /** + * Returns the desired authorization scheme as provided in the yaml file + * + * @return A {@code String} representation of desired authorization scheme + */ + AuthorizerConfig authConfig(); + + List roleToSidecarPermissions(); +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/RefreshPermissionCachesConfiguration.java b/src/main/java/org/apache/cassandra/sidecar/config/RefreshPermissionCachesConfiguration.java new file mode 100644 index 000000000..6e8648a69 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/RefreshPermissionCachesConfiguration.java @@ -0,0 +1,46 @@ +/* + * 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.cassandra.sidecar.config; + +/** + * Configuration for Refreshing the Permissions Cache + */ +public interface RefreshPermissionCachesConfiguration +{ + /** + * Gets the initial delay to populate the permissions caches + * + * @return - an int representing the time, in milliseconds, of the initial delay + */ + int initialDelayMillis(); + + /** + * Gets the interval between refreshing the caches + * + * @return - an int representing the time, in milliseconds, of the initial delay + */ + int checkIntervalMillis(); + + /** + * The configuration for the caches + * + * @return - the {@code CacheConfiguration} for the permissions caches + */ + CacheConfiguration cacheConfiguration(); +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/RoleToSidecarPermissionsConfiguration.java b/src/main/java/org/apache/cassandra/sidecar/config/RoleToSidecarPermissionsConfiguration.java new file mode 100644 index 000000000..7f851a1a1 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/RoleToSidecarPermissionsConfiguration.java @@ -0,0 +1,41 @@ +/* + * 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.cassandra.sidecar.config; + +import io.vertx.ext.auth.authorization.AndAuthorization; + +/** + * Configuration for mapping roles to sidecar specific permissions + */ +public interface RoleToSidecarPermissionsConfiguration +{ + /** + * The role we are interested in + * + * @return - The string representation of the role + */ + String role(); + + /** + * The permissions associated with the specified role + * + * @return - The {@code AndAuthorization} representing all the sidecar specific permissions the user has + */ + AndAuthorization permissions(); +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/ServiceConfiguration.java b/src/main/java/org/apache/cassandra/sidecar/config/ServiceConfiguration.java index b47caf3ac..9956afbe9 100644 --- a/src/main/java/org/apache/cassandra/sidecar/config/ServiceConfiguration.java +++ b/src/main/java/org/apache/cassandra/sidecar/config/ServiceConfiguration.java @@ -109,6 +109,11 @@ default List listenSocketAddresses() */ SSTableSnapshotConfiguration sstableSnapshotConfiguration(); + /** + * @return the configuration for the permissions caches + */ + RefreshPermissionCachesConfiguration refreshPermissionCachesConfiguration(); + /** * @return the configured worker pools for the service */ diff --git a/src/main/java/org/apache/cassandra/sidecar/config/SidecarConfiguration.java b/src/main/java/org/apache/cassandra/sidecar/config/SidecarConfiguration.java index e9a84370e..cb6c6d451 100644 --- a/src/main/java/org/apache/cassandra/sidecar/config/SidecarConfiguration.java +++ b/src/main/java/org/apache/cassandra/sidecar/config/SidecarConfiguration.java @@ -75,4 +75,14 @@ public interface SidecarConfiguration * @return the configuration for Amazon S3 client */ S3ClientConfiguration s3ClientConfiguration(); + + /** + * @return the configuration for the authenticator + */ + AuthenticatorConfiguration authenticatorConfiguration(); + + /** + * @return the configuration for the authorizer + */ + AuthorizerConfiguration authorizerConfiguration(); } diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/AuthenticatorConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/AuthenticatorConfigurationImpl.java new file mode 100644 index 000000000..bacf8fa66 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/AuthenticatorConfigurationImpl.java @@ -0,0 +1,173 @@ +/* + * 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.cassandra.sidecar.config.yaml; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.CertificateValidatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.IdentityValidatorConfig; +import org.apache.cassandra.sidecar.common.DataObjectBuilder; +import org.apache.cassandra.sidecar.config.AuthenticatorConfiguration; + +/** + * Encapsulates Authentication Configuration + */ +public class AuthenticatorConfigurationImpl implements AuthenticatorConfiguration +{ + @JsonProperty(value = "authorized_identities") + protected final Set authorizedIdentities; + + @JsonProperty(value = "auth_config") + protected AuthenticatorConfig authConfig; + + @JsonProperty(value = "cert_validator") + protected CertificateValidatorConfig certValidator; + + @JsonProperty(value = "id_validator") + protected IdentityValidatorConfig idValidator; + + public AuthenticatorConfigurationImpl() + { + this(new Builder()); + } + + protected AuthenticatorConfigurationImpl(Builder builder) + { + authorizedIdentities = builder.authorizedIdentities; + authConfig = builder.authConfig; + certValidator = builder.certValidator; + idValidator = builder.idValidator; + } + + public static Builder builder() + { + return new Builder(); + } + + /** + * {@inheritDoc} + */ + @Override + @JsonProperty(value = "authorized_identities") + public Set authorizedIdentities() + { + return authorizedIdentities; + } + + /** + * {@inheritDoc} + */ + @Override + @JsonProperty(value = "auth_config") + public AuthenticatorConfig authConfig() + { + return authConfig; + } + + /** + * Returns the desired certificate validator as provided in the yaml file + * + * @return A {@code String} representation of desired certificate validator + */ + public CertificateValidatorConfig certValidator() + { + return certValidator; + } + + /** + * Returns the desired identity validator as provided in the yaml file + * + * @return A {@code String} representation of desired identity validator + */ + public IdentityValidatorConfig idValidator() + { + return idValidator; + } + + + /** + * {@code AuthenticatorConfigurationImpl} builder static inner class. + */ + public static class Builder implements DataObjectBuilder + { + protected AuthenticatorConfig authConfig = DEFAULT_AUTHENTICATOR; + protected Set authorizedIdentities; + protected CertificateValidatorConfig certValidator = DEFAULT_CERT_VALIDATOR; + protected IdentityValidatorConfig idValidator = DEFAULT_ID_VALIDATOR; + + protected Builder() + { + } + + public Builder self() + { + return this; + } + + /** + * Sets the {@code authorizedIdentities} and returns a reference to this Builder enabling method chaining. + * + * @param authorizedIdentities the {@code Set} of identities to set + * @return a reference to this Builder + */ + public Builder authorizedIdentities(Set authorizedIdentities) + { + return update(b -> b.authorizedIdentities = authorizedIdentities); + } + + /** + * Sets the {@code authConfig} and returns a reference to this Builder enabling method chaining. + * + * @param authConfig the {@code String} representation of the desired authentication scheme to set + * @return a reference to this Builder + */ + public Builder authConfig(AuthenticatorConfig authConfig) + { + return update(b -> b.authConfig = authConfig); + } + + /** + * Sets the {@code certValidator} and returns a reference to this Builder enabling method chaining. + * + * @param certValidator the {@code String} representation of the desired certificate validator to set + * @return a reference to this Builder + */ + public Builder certValidator(CertificateValidatorConfig certValidator) + { + return update(b -> b.certValidator = certValidator); + } + + public Builder idValidator(IdentityValidatorConfig idValidator) + { + return update(b -> b.idValidator = idValidator); + } + + /** + * Build into data object of type R + * + * @return data object type + */ + public AuthenticatorConfiguration build() + { + return new AuthenticatorConfigurationImpl(this); + } + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/AuthorizerConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/AuthorizerConfigurationImpl.java new file mode 100644 index 000000000..68d8916a6 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/AuthorizerConfigurationImpl.java @@ -0,0 +1,116 @@ +/* + * 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.cassandra.sidecar.config.yaml; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; +import org.apache.cassandra.sidecar.common.DataObjectBuilder; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; +import org.apache.cassandra.sidecar.config.RoleToSidecarPermissionsConfiguration; + +/** + * {@inheritDoc} + */ +public class AuthorizerConfigurationImpl implements AuthorizerConfiguration +{ + @JsonProperty(value = "auth_config") + protected AuthorizerConfig authConfig; + + @JsonProperty(value = "role_to_sidecar_permissions") + protected List roleToSidecarPermissions; + + public AuthorizerConfigurationImpl() + { + this(new Builder()); + } + + protected AuthorizerConfigurationImpl(Builder builder) + { + authConfig = builder.authConfig; + roleToSidecarPermissions = builder.roleToSidecarPermissions; + } + + public static Builder builder() + { + return new Builder(); + } + + /** + * {@inheritDoc} + */ + @Override + @JsonProperty(value = "auth_config") + public AuthorizerConfig authConfig() + { + return authConfig; + } + + @Override + @JsonProperty(value = "role_to_sidecar_permissions") + public List roleToSidecarPermissions() + { + return roleToSidecarPermissions; + } + + /** + * {@code AuthenticatorConfigurationImpl} builder static inner class. + */ + public static class Builder implements DataObjectBuilder + { + protected AuthorizerConfig authConfig = DEFAULT_AUTHORIZER; + protected List roleToSidecarPermissions; + + protected Builder() + { + } + + public Builder self() + { + return this; + } + + /** + * Sets the {@code authConfig} and returns a reference to this Builder enabling method chaining. + * + * @param authConfig the {@code String} representation of the desired authentication scheme to set + * @return a reference to this Builder + */ + public Builder authConfig(AuthorizerConfig authConfig) + { + return update(b -> b.authConfig = authConfig); + } + + public Builder roleToSidecarPermissions(List roleToSidecarPermissions) + { + return update(b -> b.roleToSidecarPermissions = roleToSidecarPermissions); + } + + /** + * Build into data object of type R + * + * @return data object type + */ + public AuthorizerConfiguration build() + { + return new AuthorizerConfigurationImpl(this); + } + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/KeyStoreConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/KeyStoreConfigurationImpl.java index bd2b35054..e909433fc 100644 --- a/src/main/java/org/apache/cassandra/sidecar/config/yaml/KeyStoreConfigurationImpl.java +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/KeyStoreConfigurationImpl.java @@ -20,14 +20,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.cassandra.sidecar.config.KeyStoreConfiguration; +import org.apache.cassandra.sidecar.utils.FileUtils; /** * Encapsulates key or trust store option configurations */ public class KeyStoreConfigurationImpl implements KeyStoreConfiguration { - @JsonProperty("path") - protected final String path; + protected String path; @JsonProperty("password") protected final String password; @@ -50,7 +50,7 @@ public KeyStoreConfigurationImpl(String path, String password) public KeyStoreConfigurationImpl(String path, String password, String type, int checkIntervalInSeconds) { - this.path = path; + setPath(path); this.password = password; this.type = type; this.checkIntervalInSeconds = checkIntervalInSeconds; @@ -66,6 +66,16 @@ public String path() return path; } + /** + * + * @param path + */ + @JsonProperty(value = "path") + public void setPath(String path) + { + this.path = FileUtils.maybeResolveHomeDirectory(path); + } + /** * @return the password for the store */ diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/RefreshPermissionCachesConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/RefreshPermissionCachesConfigurationImpl.java new file mode 100644 index 000000000..6da2e9474 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/RefreshPermissionCachesConfigurationImpl.java @@ -0,0 +1,99 @@ +/* + * 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.cassandra.sidecar.config.yaml; + +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.cassandra.sidecar.config.CacheConfiguration; +import org.apache.cassandra.sidecar.config.RefreshPermissionCachesConfiguration; + +/** + * {@inheritDoc} + */ +public class RefreshPermissionCachesConfigurationImpl implements RefreshPermissionCachesConfiguration +{ + public static final String INITIAL_DELAY_MILLIS_PROPERTY = "initial_delay_millis"; + public static final int DEFAULT_INITIAL_DELAY_MILLIS = 0; + + public static final String POLL_FREQ_MILLIS_PROPERTY = "poll_freq_millis"; + public static final int DEFAULT_CHECK_INTERVAL_MILLIS = 3000; + + public static final String STALE_AFTER_MILLIS_PROPERTY = "stale_after_access_millis"; + public static final int DEFAULT_STALE_AFTER_MILLIS = 3600000; + + public static final String CACHE_PROPERTY = "cache"; + protected static final CacheConfiguration DEFAULT_CACHE_CONFIGURATION = + new CacheConfigurationImpl(TimeUnit.HOURS.toMillis(2), 10_000); + + @JsonProperty(value = INITIAL_DELAY_MILLIS_PROPERTY) + protected final int initialDelayMillis; + + @JsonProperty(value = POLL_FREQ_MILLIS_PROPERTY) + protected final int checkIntervalMillis; + + @JsonProperty(value = STALE_AFTER_MILLIS_PROPERTY) + protected final int staleAfterMillis; + + @JsonProperty(value = CACHE_PROPERTY) + protected final CacheConfiguration cacheConfiguration; + + public RefreshPermissionCachesConfigurationImpl() + { + this(DEFAULT_INITIAL_DELAY_MILLIS, DEFAULT_CHECK_INTERVAL_MILLIS, + DEFAULT_STALE_AFTER_MILLIS, DEFAULT_CACHE_CONFIGURATION); + } + + public RefreshPermissionCachesConfigurationImpl(int initialDelayMillis, + int checkIntervalMillis, + int staleAfterMillis, + CacheConfiguration cacheConfiguration) + { + this.initialDelayMillis = initialDelayMillis; + this.checkIntervalMillis = checkIntervalMillis; + this.staleAfterMillis = staleAfterMillis; + this.cacheConfiguration = cacheConfiguration; + } + + /** + * {@inheritDoc} + */ + public int initialDelayMillis() + { + return initialDelayMillis; + } + + /** + * {@inheritDoc} + */ + public int checkIntervalMillis() + { + return checkIntervalMillis; + } + + /** + * {@inheritDoc} + */ + @Override + @JsonProperty(value = CACHE_PROPERTY) + public CacheConfiguration cacheConfiguration() + { + return cacheConfiguration; + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/RoleToSidecarPermissionsConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/RoleToSidecarPermissionsConfigurationImpl.java new file mode 100644 index 000000000..318485915 --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/RoleToSidecarPermissionsConfigurationImpl.java @@ -0,0 +1,85 @@ +/* + * 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.cassandra.sidecar.config.yaml; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions; +import org.apache.cassandra.sidecar.config.RoleToSidecarPermissionsConfiguration; + +/** + * {@inheritDoc} + */ +public class RoleToSidecarPermissionsConfigurationImpl implements RoleToSidecarPermissionsConfiguration +{ + private static final String DEFAULT_ROLE = ""; + private static final List>> DEFAULT_PERMISSIONS = new ArrayList<>(); + + @JsonProperty(value = "role") + protected final String role; + @JsonProperty(value = "permissions") + protected final List>> permissions; + + public RoleToSidecarPermissionsConfigurationImpl() + { + this(DEFAULT_ROLE, DEFAULT_PERMISSIONS); + } + + public RoleToSidecarPermissionsConfigurationImpl(String role, List>> permissions) + { + this.role = role; + this.permissions = permissions; + } + + /** + * {@inheritDoc} + */ + public String role() + { + return role; + } + + /** + * {@inheritDoc} + */ + public AndAuthorization permissions() + { + AndAuthorization permission = AndAuthorization.create(); + + for (Map> permissionToResources : permissions) + { + for (Map.Entry> permissionsToResources : permissionToResources.entrySet()) + { + for (String resource : permissionsToResources.getValue()) + { + RoleBasedAuthorization authorization = RoleBasedAuthorization.create(permissionsToResources.getKey().name()) + .setResource(resource); + permission.addAuthorization(authorization); + } + } + } + + return permission; + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/ServiceConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/ServiceConfigurationImpl.java index 36611a347..000479a08 100644 --- a/src/main/java/org/apache/cassandra/sidecar/config/yaml/ServiceConfigurationImpl.java +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/ServiceConfigurationImpl.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.cassandra.sidecar.common.DataObjectBuilder; import org.apache.cassandra.sidecar.config.JmxConfiguration; +import org.apache.cassandra.sidecar.config.RefreshPermissionCachesConfiguration; import org.apache.cassandra.sidecar.config.SSTableImportConfiguration; import org.apache.cassandra.sidecar.config.SSTableSnapshotConfiguration; import org.apache.cassandra.sidecar.config.SSTableUploadConfiguration; @@ -60,6 +61,7 @@ public class ServiceConfigurationImpl implements ServiceConfiguration public static final String SSTABLE_UPLOAD_PROPERTY = "sstable_upload"; public static final String SSTABLE_IMPORT_PROPERTY = "sstable_import"; public static final String SSTABLE_SNAPSHOT_PROPERTY = "sstable_snapshot"; + public static final String REFRESH_PERMISSION_CACHES_PROPERTY = "refresh_permission_caches"; public static final String WORKER_POOLS_PROPERTY = "worker_pools"; private static final String JMX_PROPERTY = "jmx"; private static final String TRAFFIC_SHAPING_PROPERTY = "traffic_shaping"; @@ -123,6 +125,9 @@ public class ServiceConfigurationImpl implements ServiceConfiguration @JsonProperty(value = SCHEMA) protected final SchemaKeyspaceConfiguration schemaKeyspaceConfiguration; + @JsonProperty(value = REFRESH_PERMISSION_CACHES_PROPERTY) + protected final RefreshPermissionCachesConfiguration refreshPermissionCachesConfiguration; + /** * Constructs a new {@link ServiceConfigurationImpl} with the default values */ @@ -148,6 +153,7 @@ protected ServiceConfigurationImpl(Builder builder) serverVerticleInstances = builder.serverVerticleInstances; throttleConfiguration = builder.throttleConfiguration; sstableUploadConfiguration = builder.sstableUploadConfiguration; + refreshPermissionCachesConfiguration = builder.refreshPermissionCachesConfiguration; sstableImportConfiguration = builder.sstableImportConfiguration; sstableSnapshotConfiguration = builder.sstableSnapshotConfiguration; workerPoolsConfiguration = builder.workerPoolsConfiguration; @@ -276,6 +282,16 @@ public SSTableSnapshotConfiguration sstableSnapshotConfiguration() return sstableSnapshotConfiguration; } + /** + * {@inheritDoc} + */ + @Override + @JsonProperty(value = REFRESH_PERMISSION_CACHES_PROPERTY) + public RefreshPermissionCachesConfiguration refreshPermissionCachesConfiguration() + { + return refreshPermissionCachesConfiguration; + } + /** * {@inheritDoc} */ @@ -338,6 +354,7 @@ public static class Builder implements DataObjectBuilder workerPoolsConfiguration = DEFAULT_WORKER_POOLS_CONFIGURATION; protected JmxConfiguration jmxConfiguration = new JmxConfigurationImpl(); @@ -487,6 +504,11 @@ public Builder sstableSnapshotConfiguration(SSTableSnapshotConfiguration sstable return update(b -> b.sstableSnapshotConfiguration = sstableSnapshotConfiguration); } + public Builder refreshPermissionCachesConfiguration(RefreshPermissionCachesConfiguration refreshPermissionCachesConfiguration) + { + return update(b -> b.refreshPermissionCachesConfiguration(refreshPermissionCachesConfiguration)); + } + /** * Sets the {@code workerPoolsConfiguration} and returns a reference to this Builder enabling method chaining. * diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/SidecarConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/SidecarConfigurationImpl.java index cd8971d77..528458fb6 100644 --- a/src/main/java/org/apache/cassandra/sidecar/config/yaml/SidecarConfigurationImpl.java +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/SidecarConfigurationImpl.java @@ -38,6 +38,8 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.apache.cassandra.sidecar.common.DataObjectBuilder; +import org.apache.cassandra.sidecar.config.AuthenticatorConfiguration; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; import org.apache.cassandra.sidecar.config.CassandraInputValidationConfiguration; import org.apache.cassandra.sidecar.config.DriverConfiguration; import org.apache.cassandra.sidecar.config.HealthCheckConfiguration; @@ -70,6 +72,12 @@ public class SidecarConfigurationImpl implements SidecarConfiguration @JsonProperty("ssl") protected final SslConfiguration sslConfiguration; + @JsonProperty("authenticator") + protected final AuthenticatorConfiguration authenticatorConfiguration; + + @JsonProperty("authorizer") + protected final AuthorizerConfiguration authorizerConfiguration; + @JsonProperty("healthcheck") protected final HealthCheckConfiguration healthCheckConfiguration; @@ -102,6 +110,8 @@ protected SidecarConfigurationImpl(Builder builder) driverConfiguration = builder.driverConfiguration; restoreJobConfiguration = builder.restoreJobConfiguration; s3ClientConfiguration = builder.s3ClientConfiguration; + authenticatorConfiguration = builder.authenticatorConfiguration; + authorizerConfiguration = builder.authorizerConfiguration; } /** @@ -211,6 +221,26 @@ public S3ClientConfiguration s3ClientConfiguration() return s3ClientConfiguration; } + /** + * @return the configuration for the authenticator + */ + @Override + @JsonProperty("authenticator") + public AuthenticatorConfiguration authenticatorConfiguration() + { + return authenticatorConfiguration; + } + + /** + * @return the configuration for the authorizer + */ + @Override + @JsonProperty("authorizer") + public AuthorizerConfiguration authorizerConfiguration() + { + return authorizerConfiguration; + } + public static SidecarConfigurationImpl readYamlConfiguration(String yamlConfigurationPath) throws IOException { try @@ -300,6 +330,8 @@ public static Builder builder() */ public static class Builder implements DataObjectBuilder { + private AuthenticatorConfiguration authenticatorConfiguration; + private AuthorizerConfiguration authorizerConfiguration; private InstanceConfiguration cassandraInstance; private List cassandraInstances; private ServiceConfiguration serviceConfiguration = new ServiceConfigurationImpl(); @@ -377,6 +409,28 @@ public Builder healthCheckConfiguration(HealthCheckConfiguration healthCheckConf return update(b -> b.healthCheckConfiguration = healthCheckConfiguration); } + /** + * Sets the {@code authenticatorConfiguration} and returns a reference to this Builder enabling method chaining. + * + * @param authenticatorConfiguration the {@code authenticatorConfiguration} to set + * @return a reference to this Builder + */ + public Builder authenticatorConfiguration(AuthenticatorConfiguration authenticatorConfiguration) + { + return update(b -> b.authenticatorConfiguration = authenticatorConfiguration); + } + + /** + * Sets the {@code authorizerConfiguration} and returns a reference to this Building enabling method chaining. + * + * @param authorizerConfiguration the {@code authorizerConfiguration} to set + * @return a reference to this Builder + */ + public Builder authorizerConfig(AuthorizerConfiguration authorizerConfiguration) + { + return update(b -> b.authorizerConfiguration = authorizerConfiguration); + } + /** * Sets the {@code metricsConfiguration} and returns a reference to this Builder enabling method chaining. * diff --git a/src/main/java/org/apache/cassandra/sidecar/config/yaml/SslConfigurationImpl.java b/src/main/java/org/apache/cassandra/sidecar/config/yaml/SslConfigurationImpl.java index 20b457d1b..597f003ef 100644 --- a/src/main/java/org/apache/cassandra/sidecar/config/yaml/SslConfigurationImpl.java +++ b/src/main/java/org/apache/cassandra/sidecar/config/yaml/SslConfigurationImpl.java @@ -52,6 +52,7 @@ public class SslConfigurationImpl implements SslConfiguration @JsonProperty(value = "handshake_timeout_sec") protected final long handshakeTimeoutInSeconds; + @JsonProperty(value = "client_auth") protected String clientAuth; @JsonProperty(value = "cipher_suites") diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java index 976256a07..5f7bedd87 100644 --- a/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java +++ b/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java @@ -20,6 +20,9 @@ import java.util.NoSuchElementException; +import javax.security.cert.CertificateExpiredException; +import javax.security.cert.CertificateNotYetValidException; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -119,7 +122,7 @@ protected abstract void handleInternal(RoutingContext context, HttpServerRequest httpRequest, String host, SocketAddress remoteAddress, - T request); + T request) throws CertificateNotYetValidException, CertificateExpiredException; /** * Returns the host from the path if the requests contains the {@code /instance/} path parameter, diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthHandler.java index 509aecb55..c4f965f45 100644 --- a/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthHandler.java +++ b/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthHandler.java @@ -49,8 +49,7 @@ public class CassandraHealthHandler extends AbstractHandler * @param validator a validator instance to validate Cassandra-specific input */ @Inject - protected CassandraHealthHandler(InstanceMetadataFetcher metadataFetcher, - ExecutorPools executorPools, + protected CassandraHealthHandler(InstanceMetadataFetcher metadataFetcher, ExecutorPools executorPools, CassandraInputValidator validator) { super(metadataFetcher, executorPools, validator); @@ -77,6 +76,7 @@ protected void handleInternal(RoutingContext context, boolean isServiceUp = context.request().path().contains(JMX) ? delegate != null && delegate.isJmxUp() : delegate != null && delegate.isNativeUp(); + if (isServiceUp) { context.json(OK_STATUS); diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/MutualTlsAuthenticationHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/MutualTlsAuthenticationHandler.java new file mode 100644 index 000000000..9d684149a --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/routes/MutualTlsAuthenticationHandler.java @@ -0,0 +1,76 @@ +/* + * 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.cassandra.sidecar.routes; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.ext.auth.authentication.AuthenticationProvider; +import io.vertx.ext.auth.mtls.MutualTlsCredentials; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.AuthenticationHandler; + +import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; + +/** + * A handler that provides MutualTLS Authentication for Cassandra Sidecar API endpoints + */ +@Singleton +public class MutualTlsAuthenticationHandler implements AuthenticationHandler +{ + private static final Logger LOGGER = LoggerFactory.getLogger(MutualTlsAuthenticationHandler.class); + + protected final AuthenticationProvider authProvider; + + @Inject + public MutualTlsAuthenticationHandler(AuthenticationProvider authProvider) + { + this.authProvider = authProvider; + } + + /** + * {@inheritDoc} + */ + @Override + public void handle(RoutingContext context) + { + MutualTlsCredentials credentials = new MutualTlsCredentials(context.request()); + + authProvider.authenticate(credentials, authN -> { + if (authN.succeeded()) + { + if (authN.result() == null) + { + LOGGER.warn("No user present at authentication"); + context.fail(wrapHttpException(HttpResponseStatus.UNAUTHORIZED, "No user presented")); + } + context.setUser(authN.result()); + context.next(); + } + else + { + context.fail(wrapHttpException(HttpResponseStatus.UNAUTHORIZED, "Not Authenticated")); + } + }); + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/MutualTlsAuthorizationHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/MutualTlsAuthorizationHandler.java new file mode 100644 index 000000000..c9208ed4f --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/routes/MutualTlsAuthorizationHandler.java @@ -0,0 +1,154 @@ +/* + * 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.cassandra.sidecar.routes; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.BiConsumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.inject.Inject; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.AuthorizationContext; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.AuthorizationHandler; +import org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions; +import org.apache.cassandra.sidecar.auth.authorization.RequiredPermissionsProvider; + +import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException; + +/** + * Handler for authorization in MutualTLS to check if a user has the correct authorizations + */ +public class MutualTlsAuthorizationHandler implements AuthorizationHandler +{ + private static final Logger LOGGER = LoggerFactory.getLogger(MutualTlsAuthorizationHandler.class); + private final List providers; + private final AndAuthorization superuserAuthorizations; + RequiredPermissionsProvider requiredPermissionsProvider; + + @Inject + public MutualTlsAuthorizationHandler(RequiredPermissionsProvider requiredPermissionsProvider) + { + this.requiredPermissionsProvider = requiredPermissionsProvider; + providers = new ArrayList<>(); + superuserAuthorizations = AndAuthorization.create(); + for (MutualTlsPermissions perm : MutualTlsPermissions.ALL) + { + superuserAuthorizations.addAuthorization(RoleBasedAuthorization.create(perm.name()).setResource("ALL KEYSPACES")); + } + } + + /** + * Adds a provider that shall be used to retrieve the required authorizations for the user to attest. + * Multiple calls are allowed to retrieve authorizations from many sources. + * + * @param authorizationProvider a provider. + * @return fluent self. + */ + public AuthorizationHandler addAuthorizationProvider(AuthorizationProvider authorizationProvider) + { + this.providers.add(authorizationProvider); + return this; + } + + /** + * Provide a simple handler to extract needed variables. + * As it may be useful to allow/deny access based on the value of a request param one can do: + * {@code (routingCtx, authCtx) -> authCtx.variables().addAll(routingCtx.request().params()) } + *

+ * Or for example the remote address: + * {@code (routingCtx, authCtx) -> authCtx.result.variables().add(VARIABLE_REMOTE_IP, routingCtx.request().connection().remoteAddress()) } + * + * Not used because we do not intend perform validation based on request variables for AuthZ + * + * @param handler a bi consumer. + * @return fluent self. + */ + public AuthorizationHandler variableConsumer(BiConsumer handler) + { + // We do not intend to perform request params validations for AuthZ + return null; + } + + private void verifyUserPermissions(RoutingContext event, Iterator providerIterator, Authorization requiredAuthorizations) + { + while (providerIterator.hasNext()) + { + AuthorizationProvider provider = providerIterator.next(); + User user = event.user(); + if (user != null && !user.authorizations().getProviderIds().contains(provider.getId())) + { + provider.getAuthorizations(event.user(), (authorizationResult) -> { + if (authorizationResult.failed()) + { + LOGGER.warn("An error occurred getting authorization - providerID: " + provider.getId(), authorizationResult.cause()); + } + }); + if (requiredAuthorizations.match(event.user()) || this.superuserAuthorizations.match(event.user())) + { + event.next(); + return; + } + } + } + event.fail(wrapHttpException(HttpResponseStatus.UNAUTHORIZED, "Not Authorized")); + } + + /** + * Something has happened, so handle it. + * + * @param event the event to handle + */ + public void handle(RoutingContext event) + { + if (event.user() == null) + { + event.fail(new RoutingContextUtils.RoutingContextException("No user provided")); + } + + Iterator providerIterator = providers.iterator(); + + String placeholderPath = event.normalizedPath(); + for (String placeholder : event.pathParams().keySet()) + { + placeholderPath = placeholderPath.replaceFirst(event.pathParam(placeholder), ":" + placeholder); + } + + String verb = event.request().method().name(); + + AndAuthorization requiredAuthorizations = + requiredPermissionsProvider.requiredPermissions(verb + " " + placeholderPath, event.pathParams()); + + if (requiredAuthorizations == null) + { + requiredAuthorizations = AndAuthorization.create(); + } + + verifyUserPermissions(event, providerIterator, requiredAuthorizations); + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java index ccf15dda0..1327c0de8 100644 --- a/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java +++ b/src/main/java/org/apache/cassandra/sidecar/routes/TokenRangeReplicaMapHandler.java @@ -50,7 +50,6 @@ @Singleton public class TokenRangeReplicaMapHandler extends AbstractHandler { - @Inject public TokenRangeReplicaMapHandler(InstanceMetadataFetcher metadataFetcher, CassandraInputValidator validator, diff --git a/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java b/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java index 0c5745294..0f400726b 100644 --- a/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java +++ b/src/main/java/org/apache/cassandra/sidecar/server/MainModule.java @@ -20,6 +20,8 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -38,10 +40,23 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; +import io.vertx.ext.auth.authentication.AuthenticationProvider; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.auth.mtls.AllowAllCertificateValidator; +import io.vertx.ext.auth.mtls.AllowAllIdentityValidator; +import io.vertx.ext.auth.mtls.MutualTlsAuthenticationProvider; +import io.vertx.ext.auth.mtls.MutualTlsCertificateValidator; +import io.vertx.ext.auth.mtls.MutualTlsIdentityValidator; +import io.vertx.ext.auth.mtls.MutualTlsIdentityValidatorImpl; +import io.vertx.ext.auth.mtls.RejectAllCertificateValidator; +import io.vertx.ext.auth.mtls.RejectAllIdentityValidator; +import io.vertx.ext.auth.mtls.SpiffeCertificateValidator; import io.vertx.ext.dropwizard.DropwizardMetricsOptions; import io.vertx.ext.dropwizard.Match; import io.vertx.ext.dropwizard.MatchType; import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.AuthenticationHandler; +import io.vertx.ext.web.handler.AuthorizationHandler; import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.ErrorHandler; import io.vertx.ext.web.handler.LoggerHandler; @@ -49,6 +64,17 @@ import io.vertx.ext.web.handler.TimeoutHandler; import org.apache.cassandra.sidecar.adapters.base.CassandraFactory; import org.apache.cassandra.sidecar.adapters.cassandra41.Cassandra41Factory; +import org.apache.cassandra.sidecar.auth.authentication.AllowAllAuthenticationProvider; +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.CertificateValidatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.IdentityValidatorConfig; +import org.apache.cassandra.sidecar.auth.authorization.AllowAllAuthorizationProvider; +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; +import org.apache.cassandra.sidecar.auth.authorization.MutualTlsAuthorizationProvider; +import org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions; +import org.apache.cassandra.sidecar.auth.authorization.PermissionsAccessor; +import org.apache.cassandra.sidecar.auth.authorization.RequiredPermissionsProvider; +import org.apache.cassandra.sidecar.auth.authorization.SystemAuthSchema; import org.apache.cassandra.sidecar.cluster.CQLSessionProviderImpl; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.cluster.InstancesConfig; @@ -87,6 +113,8 @@ import org.apache.cassandra.sidecar.routes.FileStreamHandler; import org.apache.cassandra.sidecar.routes.GossipInfoHandler; import org.apache.cassandra.sidecar.routes.JsonErrorHandler; +import org.apache.cassandra.sidecar.routes.MutualTlsAuthenticationHandler; +import org.apache.cassandra.sidecar.routes.MutualTlsAuthorizationHandler; import org.apache.cassandra.sidecar.routes.RingHandler; import org.apache.cassandra.sidecar.routes.RoutingOrder; import org.apache.cassandra.sidecar.routes.SchemaHandler; @@ -108,6 +136,7 @@ import org.apache.cassandra.sidecar.routes.sstableuploads.SSTableImportHandler; import org.apache.cassandra.sidecar.routes.sstableuploads.SSTableUploadHandler; import org.apache.cassandra.sidecar.routes.validations.ValidateTableExistenceHandler; +import org.apache.cassandra.sidecar.utils.CacheFactory; import org.apache.cassandra.sidecar.utils.CassandraVersionProvider; import org.apache.cassandra.sidecar.utils.DigestAlgorithmProvider; import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; @@ -115,8 +144,27 @@ import org.apache.cassandra.sidecar.utils.TimeProvider; import org.apache.cassandra.sidecar.utils.XXHash32Provider; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.ABORT_RESTORE_JOB; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.CLEANUP_SSTABLE; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.CLEAR_SNAPSHOTS; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.CREATE_RESTORE_JOB; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.CREATE_SNAPSHOT; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.GOSSIP_INFO; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.KEYSPACE_SCHEMA; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.KEYSPACE_TOKEN_MAPPING; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.LIST_SNAPSHOTS; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.PATCH_RESTORE_JOB; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.RESTORE_JOB; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.RESTORE_JOB_PROGRESS; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.RING; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.STREAM_SSTABLES; +import static org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions.UPLOAD_SSTABLE; import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.API_V1_ALL_ROUTES; import static org.apache.cassandra.sidecar.common.server.utils.ByteUtils.bytesToHumanReadableBinaryPrefix; +import static org.apache.cassandra.sidecar.config.AuthenticatorConfiguration.DEFAULT_AUTHENTICATOR; +import static org.apache.cassandra.sidecar.config.AuthenticatorConfiguration.DEFAULT_CERT_VALIDATOR; +import static org.apache.cassandra.sidecar.config.AuthenticatorConfiguration.DEFAULT_ID_VALIDATOR; +import static org.apache.cassandra.sidecar.config.AuthorizerConfiguration.DEFAULT_AUTHORIZER; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SERVER_STOP; /** @@ -124,10 +172,9 @@ */ public class MainModule extends AbstractModule { - private static final Logger LOGGER = LoggerFactory.getLogger(MainModule.class); public static final Map OK_STATUS = Collections.singletonMap("status", "OK"); public static final Map NOT_OK_STATUS = Collections.singletonMap("status", "NOT_OK"); - + private static final Logger LOGGER = LoggerFactory.getLogger(MainModule.class); protected final Path confPath; /** @@ -148,12 +195,68 @@ public MainModule(Path confPath) this.confPath = confPath; } + /** + * Builds the {@link InstanceMetadata} from the {@link InstanceConfiguration}, + * a provided {@code versionProvider}, and {@code healthCheckFrequencyMillis}. + * + * @param vertx the vertx instance + * @param cassandraInstance the cassandra instance configuration + * @param versionProvider a Cassandra version provider + * @param sidecarVersion the version of the Sidecar from the current binary + * @param jmxConfiguration the configuration for the JMX Client + * @param session the CQL Session provider + * @param registryFactory factory for creating cassandra instance specific registry + * @return the build instance metadata object + */ + private static InstanceMetadata buildInstanceMetadata(Vertx vertx, + InstanceConfiguration cassandraInstance, + CassandraVersionProvider versionProvider, + String sidecarVersion, + JmxConfiguration jmxConfiguration, + CQLSessionProvider session, + DriverUtils driverUtils, + MetricRegistryFactory registryFactory) + { + String host = cassandraInstance.host(); + int port = cassandraInstance.port(); + + JmxClient jmxClient = JmxClient.builder() + .host(cassandraInstance.jmxHost()) + .port(cassandraInstance.jmxPort()) + .role(cassandraInstance.jmxRole()) + .password(cassandraInstance.jmxRolePassword()) + .enableSsl(cassandraInstance.jmxSslEnabled()) + .connectionMaxRetries(jmxConfiguration.maxRetries()) + .connectionRetryDelayMillis(jmxConfiguration.retryDelayMillis()) + .build(); + MetricRegistry instanceSpecificRegistry = registryFactory.getOrCreate(cassandraInstance.id()); + CassandraAdapterDelegate delegate = new CassandraAdapterDelegate(vertx, + cassandraInstance.id(), + versionProvider, + session, + jmxClient, + driverUtils, + sidecarVersion, + host, + port, + new InstanceHealthMetrics(instanceSpecificRegistry)); + return InstanceMetadataImpl.builder() + .id(cassandraInstance.id()) + .host(host) + .port(port) + .dataDirs(cassandraInstance.dataDirs()) + .stagingDir(cassandraInstance.stagingDir()) + .delegate(delegate) + .metricRegistry(instanceSpecificRegistry) + .build(); + } + @Provides @Singleton public Vertx vertx(SidecarConfiguration sidecarConfiguration, MetricRegistryFactory metricRegistryFactory) { VertxMetricsConfiguration metricsConfig = sidecarConfiguration.metricsConfiguration().vertxConfiguration(); - Match serverRouteMatch = new Match().setValue(API_V1_ALL_ROUTES).setType(MatchType.REGEX); + Match serverUriMatch = new Match().setValue(API_V1_ALL_ROUTES).setType(MatchType.REGEX); DropwizardMetricsOptions dropwizardMetricsOptions = new DropwizardMetricsOptions().setEnabled(metricsConfig.enabled()) .setJmxEnabled(metricsConfig.exposeViaJMX()) @@ -161,13 +264,16 @@ public Vertx vertx(SidecarConfiguration sidecarConfiguration, MetricRegistryFact .setMetricRegistry(metricRegistryFactory.getOrCreate()) // Monitor all V1 endpoints. // Additional filtering is done by configuring yaml fields 'metrics.include|exclude' - .addMonitoredHttpServerRoute(serverRouteMatch); + .addMonitoredHttpServerUri(serverUriMatch); return Vertx.vertx(new VertxOptions().setMetricsOptions(dropwizardMetricsOptions)); } @Provides @Singleton public Router vertxRouter(Vertx vertx, + AuthenticationHandler authenticationHandler, + AuthorizationProvider authorizationProvider, + RequiredPermissionsProvider requiredPermissionsProvider, ServiceConfiguration conf, CassandraHealthHandler cassandraHealthHandler, StreamSSTableComponentHandler streamSSTableComponentHandler, @@ -197,6 +303,7 @@ public Router vertxRouter(Vertx vertx, ErrorHandler errorHandler) { Router router = Router.router(vertx); + router.route() .order(RoutingOrder.HIGHEST.order) .handler(loggerHandler) @@ -206,6 +313,7 @@ public Router vertxRouter(Vertx vertx, router.route() .path(ApiEndpointsV1.API + "/*") .order(RoutingOrder.HIGHEST.order) + .handler(authenticationHandler) .failureHandler(errorHandler); // Docs index.html page @@ -217,122 +325,242 @@ public Router vertxRouter(Vertx vertx, // Add custom routers // Provides a simple REST endpoint to determine if Sidecar is available router.get(ApiEndpointsV1.HEALTH_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/__health", + new ArrayList<>())) .handler(context -> context.json(OK_STATUS)); // Backwards compatibility for the Cassandra health endpoint //noinspection deprecation router.get(ApiEndpointsV1.CASSANDRA_HEALTH_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/__health", + new ArrayList<>())) .handler(cassandraHealthHandler); router.get(ApiEndpointsV1.CASSANDRA_NATIVE_HEALTH_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/native/__health", + new ArrayList<>())) .handler(cassandraHealthHandler); router.get(ApiEndpointsV1.CASSANDRA_JMX_HEALTH_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/jmx/__health", + new ArrayList<>())) .handler(cassandraHealthHandler); //noinspection deprecation router.get(ApiEndpointsV1.DEPRECATED_COMPONENTS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspace/:keyspace/table/:table/snapshots/:snapshot/component/:component", + Arrays.asList(STREAM_SSTABLES))) .handler(streamSSTableComponentHandler) .handler(fileStreamHandler); router.get(ApiEndpointsV1.COMPONENTS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/tables/:table/snapshots/:snapshot/components/:component", + Arrays.asList(STREAM_SSTABLES))) .handler(streamSSTableComponentHandler) .handler(fileStreamHandler); // Support for routes that want to stream SStable index components router.get(ApiEndpointsV1.COMPONENTS_WITH_SECONDARY_INDEX_ROUTE_SUPPORT) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/tables/:table/snapshots/:snapshot/components/:index/:component", + Arrays.asList(STREAM_SSTABLES))) .handler(streamSSTableComponentHandler) .handler(fileStreamHandler); //noinspection deprecation router.get(ApiEndpointsV1.DEPRECATED_SNAPSHOTS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspace/:keyspace/table/:table/snapshots/:snapshot", + Arrays.asList(LIST_SNAPSHOTS))) .handler(listSnapshotHandler); router.get(ApiEndpointsV1.SNAPSHOTS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/tables/:table/snapshots/:snapshot", + Arrays.asList(LIST_SNAPSHOTS))) .handler(listSnapshotHandler); router.delete(ApiEndpointsV1.SNAPSHOTS_ROUTE) // Leverage the validateTableExistence. Currently, JMX does not validate for non-existent keyspace. // Additionally, the current JMX implementation to clear snapshots does not support passing a table // as a parameter. + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "DELETE /api/v1/keyspaces/:keyspace/tables/:table/snapshots/:snapshot", + Arrays.asList(CLEAR_SNAPSHOTS))) .handler(validateTableExistence) .handler(clearSnapshotHandler); router.put(ApiEndpointsV1.SNAPSHOTS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "PUT /api/v1/keyspaces/:keyspace/tables/:table/snapshots/:snapshot", + Arrays.asList(CREATE_SNAPSHOT))) .handler(createSnapshotHandler); //noinspection deprecation router.get(ApiEndpointsV1.DEPRECATED_ALL_KEYSPACES_SCHEMA_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/schema/keyspaces", + Arrays.asList(KEYSPACE_SCHEMA))) .handler(schemaHandler); router.get(ApiEndpointsV1.ALL_KEYSPACES_SCHEMA_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/schema", + Arrays.asList(KEYSPACE_SCHEMA))) .handler(schemaHandler); //noinspection deprecation router.get(ApiEndpointsV1.DEPRECATED_KEYSPACE_SCHEMA_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/schema/keyspaces/:keyspace", + Arrays.asList(KEYSPACE_SCHEMA))) .handler(schemaHandler); router.get(ApiEndpointsV1.KEYSPACE_SCHEMA_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/schema", + Arrays.asList(KEYSPACE_SCHEMA))) .handler(schemaHandler); router.get(ApiEndpointsV1.RING_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/ring", + Arrays.asList(RING))) .handler(ringHandler); router.get(ApiEndpointsV1.RING_ROUTE_PER_KEYSPACE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/ring/keyspaces/:keyspace", + Arrays.asList(RING))) .handler(ringHandler); router.put(ApiEndpointsV1.SSTABLE_UPLOAD_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "PUT /api/v1/uploads/:uploadId/keyspaces/:keyspace/tables/:table/components/:component", + Arrays.asList(UPLOAD_SSTABLE))) .handler(ssTableUploadHandler); router.get(ApiEndpointsV1.KEYSPACE_TOKEN_MAPPING_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/token-range-replicas", + Arrays.asList(KEYSPACE_TOKEN_MAPPING))) .handler(tokenRangeHandler); router.put(ApiEndpointsV1.SSTABLE_IMPORT_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "PUT /api/v1/uploads/:uploadId/keyspaces/:keyspace/tables/:table/import", + Arrays.asList(UPLOAD_SSTABLE))) .handler(ssTableImportHandler); router.delete(ApiEndpointsV1.SSTABLE_CLEANUP_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "DELETE /api/v1/uploads/:uploadId", + Arrays.asList(CLEANUP_SSTABLE))) .handler(ssTableCleanupHandler); router.get(ApiEndpointsV1.GOSSIP_INFO_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/gossip", + Arrays.asList(GOSSIP_INFO))) .handler(gossipInfoHandler); router.get(ApiEndpointsV1.TIME_SKEW_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/time-skew", + Arrays.asList())) .handler(timeSkewHandler); router.get(ApiEndpointsV1.NODE_SETTINGS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/cassandra/settings", + Arrays.asList())) .handler(nodeSettingsHandler); router.post(ApiEndpointsV1.CREATE_RESTORE_JOB_ROUTE) .handler(BodyHandler.create()) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "POST /api/v1/keyspaces/:keyspace/tables/:table/restore-jobs", + Arrays.asList(CREATE_RESTORE_JOB))) .handler(validateTableExistence) .handler(validateRestoreJobRequest) .handler(createRestoreJobHandler); router.post(ApiEndpointsV1.RESTORE_JOB_SLICES_ROUTE) .handler(BodyHandler.create()) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "POST /api/v1/keyspaces/:keyspace/tables/:table/restore-jobs/:jobId/slices", + Arrays.asList(CREATE_RESTORE_JOB))) .handler(diskSpaceProtection) // reject creating slice if short of disk space .handler(validateTableExistence) .handler(validateRestoreJobRequest) .handler(createRestoreSliceHandler); router.get(ApiEndpointsV1.RESTORE_JOB_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/tables/:table/restore-jobs/:jobId", + Arrays.asList(RESTORE_JOB))) .handler(validateTableExistence) .handler(validateRestoreJobRequest) .handler(restoreJobSummaryHandler); router.patch(ApiEndpointsV1.RESTORE_JOB_ROUTE) .handler(BodyHandler.create()) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "PATCH /api/v1/keyspaces/:keyspace/tables/:table/restore-jobs/:jobId", + Arrays.asList(PATCH_RESTORE_JOB))) .handler(validateTableExistence) .handler(validateRestoreJobRequest) .handler(updateRestoreJobHandler); router.post(ApiEndpointsV1.ABORT_RESTORE_JOB_ROUTE) .handler(BodyHandler.create()) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "POST /api/v1/keyspaces/:keyspace/tables/:table/restore-jobs/:jobId/abort", + Arrays.asList(ABORT_RESTORE_JOB))) .handler(validateTableExistence) .handler(validateRestoreJobRequest) .handler(abortRestoreJobHandler); router.get(ApiEndpointsV1.RESTORE_JOB_PROGRESS_ROUTE) + .handler(authorizationHandler(authorizationProvider, + requiredPermissionsProvider, + "GET /api/v1/keyspaces/:keyspace/tables/:table/restore-jobs/:jobId/ progress", + Arrays.asList(RESTORE_JOB_PROGRESS))) .handler(validateTableExistence) .handler(validateRestoreJobRequest) .handler(restoreJobProgressHandler); @@ -340,6 +568,167 @@ public Router vertxRouter(Vertx vertx, return router; } + public AuthorizationHandler authorizationHandler(AuthorizationProvider authzProvider, + RequiredPermissionsProvider requiredPermissionsProvider, + String endpoint, + List permissions) + { + requiredPermissionsProvider.putPermissionsMapping(endpoint, permissions); + + return (new MutualTlsAuthorizationHandler(requiredPermissionsProvider)).addAuthorizationProvider(authzProvider); + } + + @Provides + @Singleton + public PermissionsAccessor permissionsAccessor(CacheFactory cacheFactory) + { + return new PermissionsAccessor(cacheFactory); + } + + @Provides + @Singleton + public RequiredPermissionsProvider requiredPermissionsProvider() + { + return new RequiredPermissionsProvider(); + } + + @Provides + @Singleton + public MutualTlsCertificateValidator certificateValidator(SidecarConfiguration conf) + { + CertificateValidatorConfig certificateValidatorType = DEFAULT_CERT_VALIDATOR; + if (conf.authenticatorConfiguration() != null && conf.authenticatorConfiguration().certValidator() != null) + { + certificateValidatorType = conf.authenticatorConfiguration().certValidator(); + } + else + { + LOGGER.warn("No valid certificate validator was configured - Defaulting to {}", DEFAULT_CERT_VALIDATOR); + } + + MutualTlsCertificateValidator validator = null; + switch (certificateValidatorType) + { + case Spiffe: + validator = new SpiffeCertificateValidator(); + break; + case RejectAll: + validator = new RejectAllCertificateValidator(); + break; + case AllowAll: + validator = new AllowAllCertificateValidator(); + break; + } + + return validator; + } + + @Provides + @Singleton + public MutualTlsIdentityValidator identityValidator(SidecarConfiguration conf, + PermissionsAccessor permissionsAccessor) + { + IdentityValidatorConfig validatorType = DEFAULT_ID_VALIDATOR; + if (conf.authenticatorConfiguration() != null && conf.authenticatorConfiguration().idValidator() != null) + { + validatorType = conf.authenticatorConfiguration().idValidator(); + } + else + { + LOGGER.warn("No valid identity validator was configured - Defaulting to {}", DEFAULT_ID_VALIDATOR); + } + + MutualTlsIdentityValidator validator = null; + switch (validatorType) + { + case MutualTls: + validator = new MutualTlsIdentityValidatorImpl(s -> ((permissionsAccessor.getRoleFromIdentity(s) != null) || + conf.authenticatorConfiguration().authorizedIdentities().contains(s))); + break; + case RejectAll: + validator = new RejectAllIdentityValidator(); + break; + case AllowAll: + validator = new AllowAllIdentityValidator(); + break; + } + + return validator; + } + + @Provides + @Singleton + public AuthorizationProvider authorizationProvider(PermissionsAccessor permissionsAccessor, + SidecarConfiguration conf) + { + AuthorizerConfig authorizer = DEFAULT_AUTHORIZER; + if (conf.authorizerConfiguration() != null && conf.authorizerConfiguration().authConfig() != null) + { + authorizer = conf.authorizerConfiguration().authConfig(); + } + else + { + LOGGER.warn("Incorrect configuration - Defaulting to {}", DEFAULT_AUTHORIZER); + } + + AuthorizationProvider provider = null; + switch (authorizer) + { + case MutualTls: + provider = new MutualTlsAuthorizationProvider(permissionsAccessor); + break; + case AllowAll: + provider = new AllowAllAuthorizationProvider(); + break; + } + + return provider; + } + + @Provides + @Singleton + public AuthenticationProvider authenticationProvider(MutualTlsCertificateValidator mTlsCertificateValidator, + MutualTlsIdentityValidator identityValidator, + SidecarConfiguration conf) + { + AuthenticatorConfig authenticator = DEFAULT_AUTHENTICATOR; + if (conf.authenticatorConfiguration() != null && conf.authenticatorConfiguration().authConfig() != null) + { + authenticator = conf.authenticatorConfiguration().authConfig(); + } + else + { + LOGGER.warn("No valid authentication provider was configured - Defaulting to {}", DEFAULT_AUTHENTICATOR); + } + + AuthenticationProvider provider = null; + switch (authenticator) + { + case MutualTls: + provider = new MutualTlsAuthenticationProvider(mTlsCertificateValidator, identityValidator); + break; + case AllowAll: + provider = new AllowAllAuthenticationProvider(); + break; + } + + return provider; + } + + @Provides + @Singleton + public AuthenticationHandler authenticationHandler(AuthenticationProvider authProvider) + { + return new MutualTlsAuthenticationHandler(authProvider); + } + + @Provides + @Singleton + public SystemAuthSchema systemAuthSchema() + { + return new SystemAuthSchema(); + } + @Provides @Singleton public SidecarConfiguration sidecarConfiguration() throws IOException @@ -510,7 +899,7 @@ public RestoreRangesSchema restoreJobProgressSchema(SidecarConfiguration configu return new RestoreRangesSchema(configuration.serviceConfiguration() .schemaKeyspaceConfiguration(), configuration.restoreJobConfiguration() - .restoreJobTablesTtlSeconds()); + .restoreJobTablesTtlSeconds()); } @Provides @@ -522,6 +911,7 @@ public SidecarSchema sidecarSchema(Vertx vertx, RestoreJobsSchema restoreJobsSchema, RestoreSlicesSchema restoreSlicesSchema, RestoreRangesSchema restoreRangesSchema, + SystemAuthSchema systemAuthSchema, SidecarMetrics metrics) { SidecarInternalKeyspace sidecarInternalKeyspace = new SidecarInternalKeyspace(configuration); @@ -529,6 +919,7 @@ public SidecarSchema sidecarSchema(Vertx vertx, sidecarInternalKeyspace.registerTableSchema(restoreJobsSchema); sidecarInternalKeyspace.registerTableSchema(restoreSlicesSchema); sidecarInternalKeyspace.registerTableSchema(restoreRangesSchema); + sidecarInternalKeyspace.registerTableSchema(systemAuthSchema); SchemaMetrics schemaMetrics = metrics.server().schema(); return new SidecarSchema(vertx, executorPools, configuration, sidecarInternalKeyspace, cqlSessionProvider, schemaMetrics); @@ -566,60 +957,4 @@ public LocalTokenRangesProvider localTokenRangesProvider(InstancesConfig instanc { return new CachedLocalTokenRanges(instancesConfig, dnsResolver); } - - /** - * Builds the {@link InstanceMetadata} from the {@link InstanceConfiguration}, - * a provided {@code versionProvider}, and {@code healthCheckFrequencyMillis}. - * - * @param vertx the vertx instance - * @param cassandraInstance the cassandra instance configuration - * @param versionProvider a Cassandra version provider - * @param sidecarVersion the version of the Sidecar from the current binary - * @param jmxConfiguration the configuration for the JMX Client - * @param session the CQL Session provider - * @param registryFactory factory for creating cassandra instance specific registry - * @return the build instance metadata object - */ - private static InstanceMetadata buildInstanceMetadata(Vertx vertx, - InstanceConfiguration cassandraInstance, - CassandraVersionProvider versionProvider, - String sidecarVersion, - JmxConfiguration jmxConfiguration, - CQLSessionProvider session, - DriverUtils driverUtils, - MetricRegistryFactory registryFactory) - { - String host = cassandraInstance.host(); - int port = cassandraInstance.port(); - - JmxClient jmxClient = JmxClient.builder() - .host(cassandraInstance.jmxHost()) - .port(cassandraInstance.jmxPort()) - .role(cassandraInstance.jmxRole()) - .password(cassandraInstance.jmxRolePassword()) - .enableSsl(cassandraInstance.jmxSslEnabled()) - .connectionMaxRetries(jmxConfiguration.maxRetries()) - .connectionRetryDelayMillis(jmxConfiguration.retryDelayMillis()) - .build(); - MetricRegistry instanceSpecificRegistry = registryFactory.getOrCreate(cassandraInstance.id()); - CassandraAdapterDelegate delegate = new CassandraAdapterDelegate(vertx, - cassandraInstance.id(), - versionProvider, - session, - jmxClient, - driverUtils, - sidecarVersion, - host, - port, - new InstanceHealthMetrics(instanceSpecificRegistry)); - return InstanceMetadataImpl.builder() - .id(cassandraInstance.id()) - .host(host) - .port(port) - .dataDirs(cassandraInstance.dataDirs()) - .stagingDir(cassandraInstance.stagingDir()) - .delegate(delegate) - .metricRegistry(instanceSpecificRegistry) - .build(); - } } diff --git a/src/main/java/org/apache/cassandra/sidecar/server/Server.java b/src/main/java/org/apache/cassandra/sidecar/server/Server.java index 824c0e6ea..a5184255f 100644 --- a/src/main/java/org/apache/cassandra/sidecar/server/Server.java +++ b/src/main/java/org/apache/cassandra/sidecar/server/Server.java @@ -56,11 +56,13 @@ import org.apache.cassandra.sidecar.tasks.HealthCheckPeriodicTask; import org.apache.cassandra.sidecar.tasks.KeyStoreCheckPeriodicTask; import org.apache.cassandra.sidecar.tasks.PeriodicTaskExecutor; +import org.apache.cassandra.sidecar.tasks.RefreshPermissionCachesPeriodicTask; import org.apache.cassandra.sidecar.utils.SslUtils; import org.jetbrains.annotations.VisibleForTesting; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_ALL_CASSANDRA_CQL_READY; import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_CASSANDRA_CQL_READY; +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SIDECAR_SCHEMA_INITIALIZED; /** * The Sidecar {@link Server} class that manages the start and stop lifecycle of the service @@ -80,6 +82,7 @@ public class Server protected final List deployedServerVerticles = new CopyOnWriteArrayList<>(); // Keeps track of all the Cassandra instance identifiers where CQL is ready private final Set cqlReadyInstanceIds = Collections.synchronizedSet(new HashSet<>()); + private final RefreshPermissionCachesPeriodicTask refreshPermissionCachesPeriodicTask; @Inject public Server(Vertx vertx, @@ -89,7 +92,8 @@ public Server(Vertx vertx, ExecutorPools executorPools, PeriodicTaskExecutor periodicTaskExecutor, HttpServerOptionsProvider optionsProvider, - SidecarMetrics metrics) + SidecarMetrics metrics, + RefreshPermissionCachesPeriodicTask refreshPermissionCachesPeriodicTask) { this.vertx = vertx; this.executorPools = executorPools; @@ -99,6 +103,7 @@ public Server(Vertx vertx, this.periodicTaskExecutor = periodicTaskExecutor; this.optionsProvider = optionsProvider; this.metrics = metrics; + this.refreshPermissionCachesPeriodicTask = refreshPermissionCachesPeriodicTask; } /** @@ -283,6 +288,9 @@ protected Future scheduleInternalPeriodicTasks(String deploymentId) MessageConsumer cqlReadyConsumer = vertx.eventBus().localConsumer(ON_CASSANDRA_CQL_READY.address()); cqlReadyConsumer.handler(message -> onCqlReady(cqlReadyConsumer, message)); + vertx.eventBus().localConsumer(ON_SIDECAR_SCHEMA_INITIALIZED.address(), h -> { + periodicTaskExecutor.schedule(this.refreshPermissionCachesPeriodicTask); + }); return Future.succeededFuture(deploymentId); } diff --git a/src/main/java/org/apache/cassandra/sidecar/tasks/RefreshPermissionCachesPeriodicTask.java b/src/main/java/org/apache/cassandra/sidecar/tasks/RefreshPermissionCachesPeriodicTask.java new file mode 100644 index 000000000..5e115dddc --- /dev/null +++ b/src/main/java/org/apache/cassandra/sidecar/tasks/RefreshPermissionCachesPeriodicTask.java @@ -0,0 +1,216 @@ +/* + * 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.cassandra.sidecar.tasks; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.datastax.driver.core.Row; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.eventbus.EventBus; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import org.apache.cassandra.sidecar.auth.authorization.PermissionsAccessor; +import org.apache.cassandra.sidecar.auth.authorization.SystemAuthDatabaseAccessor; +import org.apache.cassandra.sidecar.config.RoleToSidecarPermissionsConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; + +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SERVER_START; +import static org.apache.cassandra.sidecar.server.SidecarServerEvents.ON_SERVER_STOP; + +/** + * Task to refresh permissions caches periodically + */ +@Singleton +public class RefreshPermissionCachesPeriodicTask implements PeriodicTask +{ + private static final Logger LOGGER = LoggerFactory.getLogger(RefreshPermissionCachesPeriodicTask.class); + private final EventBus eventBus; + private final SidecarConfiguration configuration; + private final PermissionsAccessor permissionsAccessor; + private final SystemAuthDatabaseAccessor systemAuthDatabaseAccessor; + + @Inject + public RefreshPermissionCachesPeriodicTask(Vertx vertx, + SidecarConfiguration configuration, + PermissionsAccessor permissionsAccessor, + SystemAuthDatabaseAccessor systemAuthDatabaseAccessor) + { + this.eventBus = vertx.eventBus(); + this.permissionsAccessor = permissionsAccessor; + this.configuration = configuration; + this.systemAuthDatabaseAccessor = systemAuthDatabaseAccessor; + } + + /** + * @return delay in the specified {@link #delayUnit()} for periodic task + */ + public long delay() + { + return configuration.serviceConfiguration().refreshPermissionCachesConfiguration().checkIntervalMillis(); + } + + /** + * @return the initial delay for the task, defaults to the {@link #delay()} + */ + public long initialDelay() + { + return configuration.serviceConfiguration().refreshPermissionCachesConfiguration().initialDelayMillis(); + } + + /** + * Defines the task body. + * The method can be considered as executing in a single thread. + * + *
NOTE: the {@code promise} must be completed (as either succeeded or failed) at the end of the run. + * Failing to do so, the {@link PeriodicTaskExecutor} will not be able to schedule a new run. + * See {@link PeriodicTaskExecutor#executeInternal} for details. + * + * @param promise a promise when the execution completes + */ + public void execute(Promise promise) + { + LOGGER.info("Refreshing Permission Caches"); + + AsyncLoadingCache rolesToSuperUserCache = permissionsAccessor.getRolesToSuperUserCache(); + List rolesToSuperUserRows = systemAuthDatabaseAccessor.getAllRoles(); + for (Row row : rolesToSuperUserRows) + { + CompletableFuture completableFuture = CompletableFuture.completedFuture(row.getBool("is_superuser")); + rolesToSuperUserCache.put(row.getString("role"), completableFuture); + } + rolesToSuperUserCache.put("admin", CompletableFuture.completedFuture(Boolean.TRUE)); + + AsyncLoadingCache identityToRolesCache = permissionsAccessor.getIdentityToRolesCache(); + List identityToRolesRows = systemAuthDatabaseAccessor.getAllRolesAndIdentities(); + for (Row row : identityToRolesRows) + { + CompletableFuture completableFuture = CompletableFuture.completedFuture(row.getString("role")); + identityToRolesCache.put(row.getString("identity"), completableFuture); + } + Set configIdentities = configuration.authenticatorConfiguration().authorizedIdentities(); + for (String id : configIdentities) + { + identityToRolesCache.put(id, CompletableFuture.completedFuture("admin")); + } + + AsyncLoadingCache resourceRoleToPermissionsCache = permissionsAccessor.getRoleToPermissionsCache(); + List resourceRoleToPermissionsRows = systemAuthDatabaseAccessor.getAllPermissionsFromResourceRole(); + for (Row row : resourceRoleToPermissionsRows) + { + CompletableFuture permissionsCompletableFuture = resourceRoleToPermissionsCache.getIfPresent(row.getString("role")); + AndAuthorization permissions; + if (permissionsCompletableFuture == null) + { + permissions = AndAuthorization.create(); + } + else + { + try + { + permissions = permissionsCompletableFuture.get(); + } + catch (InterruptedException | ExecutionException e) + { + permissions = AndAuthorization.create(); + } + } + for (String permission : row.getSet("permissions", String.class)) + { + String[] splitResource = row.getString("resource").split("/"); + String resource = "<"; + if (splitResource[0].equals("data")) + { + if (splitResource.length == 2) + { + resource += "keyspace " + splitResource[1] + ">"; + } + else if (splitResource.length == 3) + { + resource += "table " + splitResource[1] + "." + splitResource[2] + ">"; + } + } + else if (splitResource[0].equals("mbean")) + { + resource += "mbean " + splitResource[1] + ">"; + } + else if (splitResource[0].equals("role")) + { + resource += "role " + splitResource[1] + ">"; + } + + RoleBasedAuthorization auth = RoleBasedAuthorization.create(permission); + if (!resource.equals("<")) + { + auth.setResource(resource); + } + + if (auth.getResource() != null && !permissions.verify(auth)) + { + permissions.addAuthorization(auth); + } + } + resourceRoleToPermissionsCache.put(row.getString("role"), CompletableFuture.completedFuture(permissions)); + } + AndAuthorization allPermissions = AndAuthorization.create() + .addAuthorization(RoleBasedAuthorization.create("ALL PERMISSIONS") + .setResource("ALL FUNCTIONS")); + resourceRoleToPermissionsCache.put("admin", CompletableFuture.completedFuture(allPermissions)); + + for (RoleToSidecarPermissionsConfiguration sidecarAuth : configuration.authorizerConfiguration().roleToSidecarPermissions()) + { + resourceRoleToPermissionsCache.put(sidecarAuth.role(), CompletableFuture.completedFuture(sidecarAuth.permissions())); + } + + permissionsAccessor.setCaches(rolesToSuperUserCache, + identityToRolesCache, + resourceRoleToPermissionsCache); + promise.complete(); + } + + /** + * Register the periodic task executor at the task. By default, it is no-op. + * If the reference to the executor is needed, the concrete {@link PeriodicTask} can implement this method + * + * @param executor the executor that manages the task + */ + public void registerPeriodicTaskExecutor(PeriodicTaskExecutor executor) + { + eventBus.localConsumer(ON_SERVER_START.address(), message -> executor.schedule(this)); + eventBus.localConsumer(ON_SERVER_STOP.address(), message -> executor.unschedule(this)); + } + + /** + * @return descriptive name of the task. It prefers simple class name, if it is non-empty; + * otherwise, it returns the full class name + */ + public String name() + { + return "Refresh Permission Caches"; + } +} diff --git a/src/main/java/org/apache/cassandra/sidecar/utils/CacheFactory.java b/src/main/java/org/apache/cassandra/sidecar/utils/CacheFactory.java index ec4601dd7..7f61cc049 100644 --- a/src/main/java/org/apache/cassandra/sidecar/utils/CacheFactory.java +++ b/src/main/java/org/apache/cassandra/sidecar/utils/CacheFactory.java @@ -25,6 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.RemovalListener; @@ -32,8 +33,13 @@ import com.google.inject.Inject; import com.google.inject.Singleton; import io.vertx.core.Future; +import io.vertx.ext.auth.authorization.AndAuthorization; +import org.apache.cassandra.sidecar.auth.authorization.SystemAuthDatabaseAccessor; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; import org.apache.cassandra.sidecar.config.CacheConfiguration; +import org.apache.cassandra.sidecar.config.RoleToSidecarPermissionsConfiguration; import org.apache.cassandra.sidecar.config.ServiceConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.jetbrains.annotations.VisibleForTesting; /** @@ -45,19 +51,38 @@ public class CacheFactory private static final Logger LOGGER = LoggerFactory.getLogger(CacheFactory.class); private final Cache> ssTableImportCache; + protected final AsyncLoadingCache rolesToSuperUserCache; + protected final AsyncLoadingCache identityToRolesCache; + protected final AsyncLoadingCache roleToPermissionsCache; + protected final SystemAuthDatabaseAccessor systemAuthDatabaseAccessor; + protected final AuthorizerConfiguration authorizerConfiguration; @Inject - public CacheFactory(ServiceConfiguration configuration, SSTableImporter ssTableImporter) + public CacheFactory(SidecarConfiguration sidecarConfiguration, + ServiceConfiguration serviceConfiguration, + SSTableImporter ssTableImporter, + SystemAuthDatabaseAccessor systemAuthDatabaseAccessor) { - this(configuration, ssTableImporter, Ticker.systemTicker()); + this(sidecarConfiguration, serviceConfiguration, ssTableImporter, Ticker.systemTicker(), systemAuthDatabaseAccessor); } @VisibleForTesting - CacheFactory(ServiceConfiguration configuration, SSTableImporter ssTableImporter, Ticker ticker) + CacheFactory(SidecarConfiguration sidecarConfiguration, + ServiceConfiguration serviceConfiguration, + SSTableImporter ssTableImporter, + Ticker ticker, + SystemAuthDatabaseAccessor systemAuthDatabaseAccessor) { - this.ssTableImportCache = initSSTableImportCache(configuration.sstableImportConfiguration() - .cacheConfiguration(), - ssTableImporter, ticker); + this.ssTableImportCache = initSSTableImportCache(serviceConfiguration.sstableImportConfiguration().cacheConfiguration(), + ssTableImporter, + ticker); + + CacheConfiguration permissionsCacheConfig = serviceConfiguration.refreshPermissionCachesConfiguration().cacheConfiguration(); + this.systemAuthDatabaseAccessor = systemAuthDatabaseAccessor; + this.rolesToSuperUserCache = initRolesToSuperUserCache(permissionsCacheConfig, ticker); + this.identityToRolesCache = initIdentityToRolesCache(permissionsCacheConfig, ticker); + this.roleToPermissionsCache = initRoleToPermissionsCache(permissionsCacheConfig, ticker); + this.authorizerConfiguration = sidecarConfiguration.authorizerConfiguration(); } /** @@ -68,6 +93,21 @@ public Cache> ssTableImportCache() return ssTableImportCache; } + public AsyncLoadingCache rolesToSuperUserCache() + { + return rolesToSuperUserCache; + } + + public AsyncLoadingCache identityToRolesCache() + { + return identityToRolesCache; + } + + public AsyncLoadingCache roleToPermissionsCache() + { + return roleToPermissionsCache; + } + /** * Initializes the SSTable Import Cache using the provided {@code configuration} and {@code ticker} * for the cache @@ -99,4 +139,59 @@ public Cache> ssTableImportCache() ) .build(); } + + protected AsyncLoadingCache initRolesToSuperUserCache(CacheConfiguration cacheConfiguration, Ticker ticker) + { + LOGGER.info("Initializing 'roles' cache"); + assert systemAuthDatabaseAccessor != null; + return Caffeine.newBuilder() + .ticker(ticker) + .maximumSize(cacheConfiguration.maximumSize()) + .expireAfterWrite(Duration.of(cacheConfiguration.expireAfterAccessMillis(), ChronoUnit.SECONDS)) + .removalListener((key, value, cause) -> + LOGGER.debug("Removed entry '{}' with key '{}' from {} with cause {}", + value, key, "roles", cause)) + .buildAsync(systemAuthDatabaseAccessor::isSuperUser); + } + + protected AsyncLoadingCache initIdentityToRolesCache(CacheConfiguration cacheConfiguration, Ticker ticker) + { + LOGGER.info("Initializing 'identity_to_role' cache"); + assert systemAuthDatabaseAccessor != null; + return Caffeine.newBuilder() + .ticker(ticker) + .maximumSize(cacheConfiguration.maximumSize()) + .expireAfterWrite(Duration.of(cacheConfiguration.expireAfterAccessMillis(), ChronoUnit.SECONDS)) + .removalListener((key, value, cause) -> + LOGGER.debug("Removed entry '{}' with key '{}' from {} with cause {}", + value, key, "identity_to_role", cause)) + .buildAsync(systemAuthDatabaseAccessor::findRoleFromIdentity); + } + + protected AsyncLoadingCache initRoleToPermissionsCache(CacheConfiguration cacheConfiguration, Ticker ticker) + { + LOGGER.info("Initializing 'role_permissions' cache"); + assert systemAuthDatabaseAccessor != null; + return Caffeine.newBuilder() + .ticker(ticker) + .maximumSize(cacheConfiguration.maximumSize()) + .expireAfterWrite(Duration.of(cacheConfiguration.expireAfterAccessMillis(), ChronoUnit.SECONDS)) + .removalListener((key, value, cause) -> + LOGGER.debug("Removed entry '{}' with key '{}' from {} with cause {}", + value, key, "role_permissions", cause)) + .buildAsync(key -> { + AndAuthorization authFromCassandra = systemAuthDatabaseAccessor.findPermissionsFromResourceRole(key); + if (authFromCassandra == null) + { + for (RoleToSidecarPermissionsConfiguration authFromSidecar : authorizerConfiguration.roleToSidecarPermissions()) + { + if (authFromSidecar.role().equals(key)) + { + return authFromSidecar.permissions(); + } + } + } + return authFromCassandra; + }); + } } diff --git a/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java b/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java index ce9d94726..67687f008 100644 --- a/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java +++ b/src/test/integration/org/apache/cassandra/sidecar/db/RestoreJobDatabaseAccessorIntTest.java @@ -31,18 +31,24 @@ import org.apache.cassandra.sidecar.foundation.RestoreJobSecretsGen; import org.apache.cassandra.sidecar.testing.IntegrationTestBase; import org.apache.cassandra.testing.CassandraIntegrationTest; +import org.apache.cassandra.testing.CassandraTestContext; +import org.apache.cassandra.testing.SimpleCassandraVersion; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; class RestoreJobDatabaseAccessorIntTest extends IntegrationTestBase { + QualifiedTableName qualifiedTableName = new QualifiedTableName("ks", "tbl"); RestoreJobSecrets secrets = RestoreJobSecretsGen.genRestoreJobSecrets(); long expiresAtMillis = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); @CassandraIntegrationTest - void testCrudOperations() + void testCrudOperations(CassandraTestContext cassandraTestContext) { + assumeThat(cassandraTestContext.version).as("TTL is only supported in Cassandra 5.1") + .isGreaterThanOrEqualTo(SimpleCassandraVersion.create(5, 1, 0)); waitForSchemaReady(10, TimeUnit.SECONDS); RestoreJobDatabaseAccessor accessor = injector.getInstance(RestoreJobDatabaseAccessor.class); diff --git a/src/test/integration/org/apache/cassandra/sidecar/db/RestoreRangeDatabaseAccessorIntTest.java b/src/test/integration/org/apache/cassandra/sidecar/db/RestoreRangeDatabaseAccessorIntTest.java index 1d3b651e1..184d1bac8 100644 --- a/src/test/integration/org/apache/cassandra/sidecar/db/RestoreRangeDatabaseAccessorIntTest.java +++ b/src/test/integration/org/apache/cassandra/sidecar/db/RestoreRangeDatabaseAccessorIntTest.java @@ -28,15 +28,20 @@ import org.apache.cassandra.sidecar.common.server.data.RestoreRangeStatus; import org.apache.cassandra.sidecar.testing.IntegrationTestBase; import org.apache.cassandra.testing.CassandraIntegrationTest; +import org.apache.cassandra.testing.CassandraTestContext; +import org.apache.cassandra.testing.SimpleCassandraVersion; import static org.apache.cassandra.sidecar.restore.RestoreRangeTest.createTestRange; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; class RestoreRangeDatabaseAccessorIntTest extends IntegrationTestBase { @CassandraIntegrationTest - void testCrudOperations() + void testCrudOperations(CassandraTestContext cassandraTestContext) { + assumeThat(cassandraTestContext.version).as("TTL is only supported in Cassandra 5.1") + .isGreaterThanOrEqualTo(SimpleCassandraVersion.create(5, 1, 0)); waitForSchemaReady(10, TimeUnit.SECONDS); RestoreRangeDatabaseAccessor accessor = injector.getInstance(RestoreRangeDatabaseAccessor.class); diff --git a/src/test/integration/org/apache/cassandra/sidecar/db/RestoreSliceDatabaseAccessorIntTest.java b/src/test/integration/org/apache/cassandra/sidecar/db/RestoreSliceDatabaseAccessorIntTest.java index 80b8a4d27..8359604bb 100644 --- a/src/test/integration/org/apache/cassandra/sidecar/db/RestoreSliceDatabaseAccessorIntTest.java +++ b/src/test/integration/org/apache/cassandra/sidecar/db/RestoreSliceDatabaseAccessorIntTest.java @@ -25,14 +25,19 @@ import org.apache.cassandra.sidecar.common.server.cluster.locator.TokenRange; import org.apache.cassandra.sidecar.testing.IntegrationTestBase; import org.apache.cassandra.testing.CassandraIntegrationTest; +import org.apache.cassandra.testing.CassandraTestContext; +import org.apache.cassandra.testing.SimpleCassandraVersion; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; class RestoreSliceDatabaseAccessorIntTest extends IntegrationTestBase { @CassandraIntegrationTest - void testCrudOperations() + void testCrudOperations(CassandraTestContext cassandraTestContext) { + assumeThat(cassandraTestContext.version).as("TTL is only supported in Cassandra 5.1") + .isGreaterThanOrEqualTo(SimpleCassandraVersion.create(5, 1, 0)); waitForSchemaReady(10, TimeUnit.SECONDS); RestoreSliceDatabaseAccessor accessor = injector.getInstance(RestoreSliceDatabaseAccessor.class); diff --git a/src/test/integration/org/apache/cassandra/sidecar/routes/tokenrange/LeavingTestMultiDC.java b/src/test/integration/org/apache/cassandra/sidecar/routes/tokenrange/LeavingTestMultiDC.java index cf55ca18a..55c0d728f 100644 --- a/src/test/integration/org/apache/cassandra/sidecar/routes/tokenrange/LeavingTestMultiDC.java +++ b/src/test/integration/org/apache/cassandra/sidecar/routes/tokenrange/LeavingTestMultiDC.java @@ -44,8 +44,10 @@ import org.apache.cassandra.distributed.UpgradeableCluster; import org.apache.cassandra.testing.CassandraIntegrationTest; import org.apache.cassandra.testing.ConfigurableCassandraTestContext; +import org.apache.cassandra.testing.SimpleCassandraVersion; import static net.bytebuddy.matcher.ElementMatchers.named; +import static org.assertj.core.api.Assumptions.assumeThat; /** * Multi-DC Cluster shrink scenarios integration tests for token range replica mapping endpoint with the in-jvm @@ -61,6 +63,8 @@ void retrieveMappingWithLeavingNodesMultiDC(VertxTestContext context, ConfigurableCassandraTestContext cassandraTestContext) throws Exception { + assumeThat(cassandraTestContext.version).as("TTL is only supported in Cassandra 5.1") + .isGreaterThanOrEqualTo(SimpleCassandraVersion.create(5, 1, 0)); BBHelperLeavingNodesMultiDC.reset(); int leavingNodesPerDC = 1; UpgradeableCluster cluster = getMultiDCCluster(BBHelperLeavingNodesMultiDC::install, cassandraTestContext); diff --git a/src/test/java/org/apache/cassandra/sidecar/TestModule.java b/src/test/java/org/apache/cassandra/sidecar/TestModule.java index 143dd1390..3156df094 100644 --- a/src/test/java/org/apache/cassandra/sidecar/TestModule.java +++ b/src/test/java/org/apache/cassandra/sidecar/TestModule.java @@ -33,6 +33,8 @@ import com.google.inject.Provides; import com.google.inject.Singleton; import com.google.inject.name.Named; +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; import org.apache.cassandra.sidecar.cluster.InstancesConfig; import org.apache.cassandra.sidecar.cluster.InstancesConfigImpl; @@ -41,6 +43,8 @@ import org.apache.cassandra.sidecar.common.response.NodeSettings; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.dns.DnsResolver; +import org.apache.cassandra.sidecar.config.AuthenticatorConfiguration; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; import org.apache.cassandra.sidecar.config.HealthCheckConfiguration; import org.apache.cassandra.sidecar.config.RestoreJobConfiguration; import org.apache.cassandra.sidecar.config.SSTableUploadConfiguration; @@ -49,11 +53,14 @@ import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.apache.cassandra.sidecar.config.SslConfiguration; import org.apache.cassandra.sidecar.config.ThrottleConfiguration; +import org.apache.cassandra.sidecar.config.yaml.AuthenticatorConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.AuthorizerConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.HealthCheckConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.RestoreJobConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.SSTableUploadConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.SchemaKeyspaceConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SslConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.TestServiceConfiguration; import org.apache.cassandra.sidecar.config.yaml.ThrottleConfigurationImpl; import org.apache.cassandra.sidecar.metrics.instance.InstanceMetricsImpl; @@ -99,10 +106,25 @@ public SidecarConfiguration configuration() protected SidecarConfigurationImpl abstractConfig() { - return abstractConfig(null); + SslConfiguration sslConfiguration = + SslConfigurationImpl.builder() + .enabled(false) + .useOpenSsl(false) + .clientAuth("NONE") + .build(); + AuthenticatorConfiguration authenticatorConfiguration = + AuthenticatorConfigurationImpl.builder() + .authConfig(AuthenticatorConfig.AllowAll) + .build(); + AuthorizerConfiguration authorizerConfiguration = + AuthorizerConfigurationImpl.builder() + .authConfig(AuthorizerConfig.AllowAll).build(); + return abstractConfig(sslConfiguration, authenticatorConfiguration, authorizerConfiguration); } - protected SidecarConfigurationImpl abstractConfig(SslConfiguration sslConfiguration) + protected SidecarConfigurationImpl abstractConfig(SslConfiguration sslConfiguration, + AuthenticatorConfiguration authenticatorConfiguration, + AuthorizerConfiguration authorizerConfiguration) { ThrottleConfiguration throttleConfiguration = new ThrottleConfigurationImpl(5, 5); SSTableUploadConfiguration uploadConfiguration = new SSTableUploadConfigurationImpl(0F); @@ -132,6 +154,8 @@ protected SidecarConfigurationImpl abstractConfig(SslConfiguration sslConfigurat .sslConfiguration(sslConfiguration) .restoreJobConfiguration(restoreJobConfiguration) .healthCheckConfiguration(healthCheckConfiguration) + .authenticatorConfiguration(authenticatorConfiguration) + .authorizerConfig(authorizerConfiguration) .build(); } diff --git a/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java b/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java index 4660c0aa0..9a1d0e38c 100644 --- a/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java +++ b/src/test/java/org/apache/cassandra/sidecar/TestSslModule.java @@ -20,11 +20,18 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; +import org.apache.cassandra.sidecar.config.AuthenticatorConfiguration; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; import org.apache.cassandra.sidecar.config.SslConfiguration; +import org.apache.cassandra.sidecar.config.yaml.AuthenticatorConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.AuthorizerConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.KeyStoreConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.SslConfigurationImpl; @@ -39,20 +46,48 @@ public class TestSslModule extends TestModule { private static final Logger logger = LoggerFactory.getLogger(TestSslModule.class); private final Path certPath; + private Path keystorePath; + private Path truststorePath; public TestSslModule(Path certPath) { this.certPath = certPath; } + public TestSslModule(Path certPath, Path keystorePath, Path truststorePath) + { + this.certPath = certPath; + this.keystorePath = keystorePath; + this.truststorePath = truststorePath; + } + @Override public SidecarConfigurationImpl abstractConfig() { ClassLoader classLoader = TestSslModule.class.getClassLoader(); - Path keyStorePath = writeResourceToPath(classLoader, certPath, "certs/test.p12"); + + Path keyStorePath; + Path trustStorePath; + + if (this.keystorePath != null) + { + keyStorePath = this.keystorePath; + } + else + { + keyStorePath = writeResourceToPath(classLoader, certPath, "certs/test.p12"); + } String keyStorePassword = "password"; - Path trustStorePath = writeResourceToPath(classLoader, certPath, "certs/ca.p12"); + if (this.truststorePath != null) + { + trustStorePath = this.truststorePath; + } + else + { + trustStorePath = writeResourceToPath(classLoader, certPath, "certs/ca.p12"); + } + String trustStorePassword = "password"; if (!Files.exists(keyStorePath)) @@ -76,6 +111,17 @@ public SidecarConfigurationImpl abstractConfig() trustStorePassword)) .build(); - return super.abstractConfig(sslConfiguration); + AuthenticatorConfiguration authenticatorConfiguration = + AuthenticatorConfigurationImpl.builder() + .authorizedIdentities(Collections.emptySet()) + .authConfig(AuthenticatorConfig.AllowAll) + .build(); + + AuthorizerConfiguration authorizerConfiguration = + AuthorizerConfigurationImpl.builder() + .authConfig(AuthorizerConfig.AllowAll) + .build(); + + return super.abstractConfig(sslConfiguration, authenticatorConfiguration, authorizerConfiguration); } } diff --git a/src/test/java/org/apache/cassandra/sidecar/auth/MutualTlsAuthenticationTest.java b/src/test/java/org/apache/cassandra/sidecar/auth/MutualTlsAuthenticationTest.java new file mode 100644 index 000000000..be8f6c32a --- /dev/null +++ b/src/test/java/org/apache/cassandra/sidecar/auth/MutualTlsAuthenticationTest.java @@ -0,0 +1,410 @@ +/* + * 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.cassandra.sidecar.auth; + +import java.io.File; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.util.Modules; +import io.vertx.core.Vertx; +import io.vertx.core.net.JksOptions; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.ext.web.codec.BodyCodec; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import org.apache.cassandra.sidecar.TestModule; +import org.apache.cassandra.sidecar.TestSslModule; +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.CertificateValidatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.IdentityValidatorConfig; +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; +import org.apache.cassandra.sidecar.config.AuthenticatorConfiguration; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; +import org.apache.cassandra.sidecar.config.SslConfiguration; +import org.apache.cassandra.sidecar.config.yaml.AuthenticatorConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.AuthorizerConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.KeyStoreConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SslConfigurationImpl; +import org.apache.cassandra.sidecar.routes.MutualTlsAuthenticationHandler; +import org.apache.cassandra.sidecar.server.MainModule; +import org.apache.cassandra.sidecar.server.Server; +import org.apache.cassandra.sidecar.utils.CertificateBuilder; +import org.apache.cassandra.sidecar.utils.CertificateBundle; + +import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; +import static io.netty.handler.codec.http.HttpResponseStatus.SERVICE_UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests MutualTLS Authentication for {@link MutualTlsAuthenticationHandler} + */ +@ExtendWith(VertxExtension.class) +public class MutualTlsAuthenticationTest +{ + private static final Logger logger = LoggerFactory.getLogger(MutualTlsAuthenticationTest.class); + private static final String IDENTITY = "spiffe://test.com/auth"; + @TempDir + File tempDir; + TestModule testModule; + private Server server; + private Vertx vertx; + private CertificateBundle ca; + private CertificateBundle badCA; + private Path truststorePath; + private Path untrustedTruststorePath; + + TestModule testModule() throws Exception + { + ca = new CertificateBuilder().subject("CN=Apache cassandra Root CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca") + .isCertificateAuthority(true) + .buildSelfSigned(); + + badCA = new CertificateBuilder().subject("CN=Untrusted CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca_bad") + .isCertificateAuthority(true) + .buildSelfSigned(); + + truststorePath = ca.toTempKeyStorePath(tempDir.toPath(), + "password".toCharArray(), + "password".toCharArray()); + + untrustedTruststorePath = badCA.toTempKeyStorePath(tempDir.toPath(), + "password".toCharArray(), + "password".toCharArray()); + + + CertificateBundle keystore = new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName(InetAddress.getLocalHost().getCanonicalHostName()) + .addSanDnsName(InetAddress.getLocalHost().getHostName()) + .addSanDnsName("localhost") + .buildIssuedBy(ca); + + Path serverKeystorePath = keystore.toTempKeyStorePath(tempDir.toPath(), + "password".toCharArray(), + "password".toCharArray()); + + return new TestMTLSModule(serverKeystorePath, truststorePath); + } + + @BeforeEach + void setUp() throws Exception + { + testModule = testModule(); + + Injector injector = Guice.createInjector(Modules.override(new MainModule()) + .with(testModule)); + server = injector.getInstance(Server.class); + vertx = injector.getInstance(Vertx.class); + + VertxTestContext context = new VertxTestContext(); + server.start() + .onSuccess(s -> context.completeNow()) + .onFailure(context::failNow); + + context.awaitCompletion(5, TimeUnit.SECONDS); + } + + @AfterEach + void tearDown() throws InterruptedException + { + final CountDownLatch closeLatch = new CountDownLatch(1); + server.close().onSuccess(res -> closeLatch.countDown()); + if (closeLatch.await(60, TimeUnit.SECONDS)) + logger.info("Close event received before timeout."); + else + logger.error("Close event timed out."); + } + + @DisplayName("Should return HTTP 200 OK if mTLS is working") + @Test + void testSidecarHealthCheckReturnsOK(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(null, ca); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(OK.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}"); + testContext.completeNow(); + }))); + } + + @DisplayName("Should return HTTP 404 NOT FOUND with nonexistent endpoint") + @Test + void testIncorrectEndpoint(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(null, ca); + + String url = "/test/test/not/real"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(NOT_FOUND.code()); + testContext.completeNow(); + }))); + } + + @DisplayName("Should result in failure from expired certificate") + @Test + void testExpiredCert(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(b -> b.notBefore(Instant.now().minus(2, ChronoUnit.DAYS)) + .notAfter(Instant.now().minus(1, ChronoUnit.DAYS)), ca); + + String url = "/api/v1/cassandra/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .send(testContext.failing(response -> testContext.verify(() -> + { + assertThat(response).hasMessageContaining("certificate_unknown"); + testContext.completeNow(); + }))); + } + + @DisplayName("Should result in failure from untrusted truststore") + @Test + void testFailWithUntrustedTruststore(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(null, ca); + + String url = "/api/v1/cassandra/__health"; + + WebClient client = client(clientKeystorePath, untrustedTruststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .send(testContext.failing(response -> testContext.verify(() -> + { + assertThat(response).hasMessageContaining("Failed to create SSL connection"); + testContext.completeNow(); + }))); + } + + @DisplayName("Should result in failure from certificate signed by untrusted CA") + @Test + void testFailWithUntrustedCertificate(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(null, badCA); + + String url = "/api/v1/cassandra/__health"; + + WebClient client = client(clientKeystorePath, untrustedTruststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .send(testContext.failing(response -> testContext.verify(() -> + { + assertThat(response).hasMessageContaining("Failed to create SSL connection"); + testContext.completeNow(); + }))); + } + + @DisplayName("Should result in success when Cassandra and Sidecar are running") + @Test + void testCassandraHealthCheck(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(null, ca); + + WebClient client = client(clientKeystorePath, truststorePath); + + String url = "/api/v1/cassandra/__health"; + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> { + assertThat(response.statusCode()).isEqualTo(OK.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}"); + testContext.completeNow(); + }))); + } + + @DisplayName("Should result in 404 NOT_FOUND") + @Test + void test404FailInstanceNotFound(VertxTestContext testContext) throws Exception + { + Path clientKeystorePath = generateClientCertificate(null, ca); + + WebClient client = client(clientKeystorePath, truststorePath); + + String url = "/api/v1/cassandra/__health?instanceId=500"; + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> { + assertThat(response.statusCode()).isEqualTo(NOT_FOUND.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"Not Found\",\"code\":404,\"message\":\"Instance id '500' not found\"}"); + testContext.completeNow(); + }))); + } + + @DisplayName("Should result in 503 NOT_OK") + @Test + void test503FailWithQueryParam(VertxTestContext testContext) throws Exception + { + testModule.delegate.setIsNativeUp(false); + Path clientKeystorePath = generateClientCertificate(null, ca); + + WebClient client = client(clientKeystorePath, truststorePath); + + String url = "/api/v1/cassandra/__health?instanceId=2"; + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> { + assertThat(response.statusCode()).isEqualTo(SERVICE_UNAVAILABLE.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"NOT_OK\"}"); + testContext.completeNow(); + }))); + } + + private WebClient client(Path clientKeystorePath, Path clientTruststorePath) + { + return WebClient.create(vertx, webClientOptions(clientKeystorePath, clientTruststorePath)); + } + + private WebClientOptions webClientOptions(Path clientKeystorePath, Path clientTruststorePath) + { + WebClientOptions options = new WebClientOptions(); + options.setKeyStoreOptions(new JksOptions().setPath(clientKeystorePath.toString()) + .setPassword("cassandra")); + options.setTrustStoreOptions(new JksOptions().setPath(clientTruststorePath.toString()) + .setPassword("password")); + options.setSsl(true); + return options; + } + + private Path generateClientCertificate(Function customizeCertificate, + CertificateBundle certificateAuthority) throws Exception + { + + CertificateBuilder builder = new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .notBefore(Instant.now().minus(1, ChronoUnit.DAYS)) + .notAfter(Instant.now().plus(1, ChronoUnit.DAYS)) + .alias("spiffecert") + .addSanUriName(IDENTITY) + .rsa2048Algorithm(); + if (customizeCertificate != null) + { + builder = customizeCertificate.apply(builder); + } + CertificateBundle ssc = builder.buildIssuedBy(certificateAuthority); + + return ssc.toTempKeyStorePath(tempDir.toPath(), "cassandra".toCharArray(), "cassandra".toCharArray()); + } + + private static class TestMTLSModule extends TestModule + { + private static final Logger logger = LoggerFactory.getLogger(TestSslModule.class); + private final Path keystorePath; + private final Path truststorePath; + + public TestMTLSModule(Path keystorePath, Path truststorePath) + { + this.keystorePath = keystorePath; + this.truststorePath = truststorePath; + } + + @Override + public SidecarConfigurationImpl abstractConfig() + { + Path keyStorePath; + Path trustStorePath; + + keyStorePath = this.keystorePath; + String keyStorePassword = "password"; + + trustStorePath = this.truststorePath; + String trustStorePassword = "password"; + + if (!Files.exists(keyStorePath)) + { + logger.error("JMX password file not found in path={}", keyStorePath); + } + if (!Files.exists(trustStorePath)) + { + logger.error("Trust Store file not found in path={}", trustStorePath); + } + + SslConfiguration sslConfiguration = + SslConfigurationImpl.builder() + .enabled(true) + .useOpenSsl(true) + .handshakeTimeoutInSeconds(10L) + .clientAuth("REQUIRED") + .keystore(new KeyStoreConfigurationImpl(keyStorePath.toAbsolutePath().toString(), + keyStorePassword)) + .truststore(new KeyStoreConfigurationImpl(trustStorePath.toAbsolutePath().toString(), + trustStorePassword)) + .build(); + + AuthenticatorConfiguration authenticatorConfiguration = + AuthenticatorConfigurationImpl.builder() + .authorizedIdentities(Collections.singleton("spiffe://test.com/auth")) + .authConfig(AuthenticatorConfig.MutualTls) + .certValidator(CertificateValidatorConfig.Spiffe) + .idValidator(IdentityValidatorConfig.MutualTls) + .build(); + + AuthorizerConfiguration authorizerConfiguration = + AuthorizerConfigurationImpl.builder() + .authConfig(AuthorizerConfig.AllowAll) + .build(); + + return super.abstractConfig(sslConfiguration, authenticatorConfiguration, authorizerConfiguration); + } + } +} diff --git a/src/test/java/org/apache/cassandra/sidecar/auth/MutualTlsAuthorizationTest.java b/src/test/java/org/apache/cassandra/sidecar/auth/MutualTlsAuthorizationTest.java new file mode 100644 index 000000000..af9463d54 --- /dev/null +++ b/src/test/java/org/apache/cassandra/sidecar/auth/MutualTlsAuthorizationTest.java @@ -0,0 +1,729 @@ +/* + * 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.cassandra.sidecar.auth; + +import java.io.File; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Ticker; +import com.google.inject.Guice; +import com.google.inject.Inject; +import com.google.inject.Injector; +import com.google.inject.Provides; +import com.google.inject.Singleton; +import com.google.inject.util.Modules; +import io.vertx.core.Vertx; +import io.vertx.core.net.JksOptions; +import io.vertx.ext.auth.authorization.AndAuthorization; +import io.vertx.ext.auth.authorization.OrAuthorization; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.ext.web.codec.BodyCodec; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import org.apache.cassandra.sidecar.TestModule; +import org.apache.cassandra.sidecar.TestSslModule; +import org.apache.cassandra.sidecar.auth.authentication.AuthenticatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.CertificateValidatorConfig; +import org.apache.cassandra.sidecar.auth.authentication.IdentityValidatorConfig; +import org.apache.cassandra.sidecar.auth.authorization.AuthorizerConfig; +import org.apache.cassandra.sidecar.auth.authorization.MutualTlsPermissions; +import org.apache.cassandra.sidecar.auth.authorization.PermissionsAccessor; +import org.apache.cassandra.sidecar.auth.authorization.RequiredPermissionsProvider; +import org.apache.cassandra.sidecar.auth.authorization.SystemAuthDatabaseAccessor; +import org.apache.cassandra.sidecar.config.AuthenticatorConfiguration; +import org.apache.cassandra.sidecar.config.AuthorizerConfiguration; +import org.apache.cassandra.sidecar.config.CacheConfiguration; +import org.apache.cassandra.sidecar.config.ServiceConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; +import org.apache.cassandra.sidecar.config.SslConfiguration; +import org.apache.cassandra.sidecar.config.yaml.AuthenticatorConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.AuthorizerConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.KeyStoreConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SslConfigurationImpl; +import org.apache.cassandra.sidecar.routes.MutualTlsAuthorizationHandler; +import org.apache.cassandra.sidecar.server.MainModule; +import org.apache.cassandra.sidecar.server.Server; +import org.apache.cassandra.sidecar.utils.CacheFactory; +import org.apache.cassandra.sidecar.utils.CertificateBuilder; +import org.apache.cassandra.sidecar.utils.CertificateBundle; +import org.apache.cassandra.sidecar.utils.SSTableImporter; + +import static io.netty.handler.codec.http.HttpResponseStatus.OK; +import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests MutualTLS Authorization for {@link MutualTlsAuthorizationHandler} + */ +@ExtendWith(VertxExtension.class) +public class MutualTlsAuthorizationTest +{ + private static final Logger LOGGER = LoggerFactory.getLogger(MutualTlsAuthorizationTest.class); + + @TempDir + File tempDir; + TestModule testModule; + RequiredPermissionsProvider mockRequiredPermissionsProvider; + private Server server; + private Vertx vertx; + private CertificateBundle ca; + private CertificateBundle badCA; + private Path truststorePath; + private Path untrustedTruststorePath; + + TestModule testModule() throws Exception + { + ca = new CertificateBuilder().subject("CN=Apache cassandra Root CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca") + .isCertificateAuthority(true) + .buildSelfSigned(); + + badCA = new CertificateBuilder().subject("CN=Untrusted CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca_bad") + .isCertificateAuthority(true) + .buildSelfSigned(); + + truststorePath = ca.toTempKeyStorePath(tempDir.toPath(), + "password".toCharArray(), + "password".toCharArray()); + + untrustedTruststorePath = badCA.toTempKeyStorePath(tempDir.toPath(), + "password".toCharArray(), + "password".toCharArray()); + + + CertificateBundle keystore = + new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName(InetAddress.getLocalHost().getCanonicalHostName()) + .addSanDnsName(InetAddress.getLocalHost().getHostName()) + .addSanDnsName("localhost") + .buildIssuedBy(ca); + + Path serverKeystorePath = keystore.toTempKeyStorePath(tempDir.toPath(), + "password".toCharArray(), + "password".toCharArray()); + + mockRequiredPermissionsProvider = mock(RequiredPermissionsProvider.class); + + return new TestMTLSModule(serverKeystorePath, truststorePath, mockRequiredPermissionsProvider); + } + + @BeforeEach + void setUp() throws Exception + { + testModule = testModule(); + + Injector injector = Guice.createInjector(Modules.override(new MainModule()) + .with(testModule)); + server = injector.getInstance(Server.class); + vertx = injector.getInstance(Vertx.class); + + VertxTestContext context = new VertxTestContext(); + server.start() + .onSuccess(s -> context.completeNow()) + .onFailure(context::failNow); + + context.awaitCompletion(5, TimeUnit.SECONDS); + } + + @AfterEach + void tearDown() throws InterruptedException + { + final CountDownLatch closeLatch = new CountDownLatch(1); + server.close().onSuccess(res -> closeLatch.countDown()); + if (closeLatch.await(60, TimeUnit.SECONDS)) + LOGGER.info("Close event received before timeout."); + else + LOGGER.error("Close event timed out."); + } + + @Test + void testSidecarHealthCheckReturnsOK(VertxTestContext testContext) throws Exception + { + AndAuthorization auth1 = AndAuthorization.create(); + OrAuthorization auth1Or = OrAuthorization.create(); + auth1Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.DROP.name()) + .setResource("")); + auth1Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.DROP.name()) + .setResource("")); + auth1.addAuthorization(auth1Or); + + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth1); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity1"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(OK.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testWorksForAllPermissions(VertxTestContext testContext) throws Exception + { + AndAuthorization auth2 = AndAuthorization.create(); + OrAuthorization auth2Or = OrAuthorization.create(); + auth2Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.DROP.name()) + .setResource("")); + auth2Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.DROP.name()) + .setResource("")); + auth2.addAuthorization(auth2Or); + + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth2); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity2"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(OK.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testIncorrectPermissions(VertxTestContext testContext) throws Exception + { + AndAuthorization auth3 = AndAuthorization.create(); + OrAuthorization auth3Or = OrAuthorization.create(); + auth3Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth3Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth3.addAuthorization(auth3Or); + + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth3); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity3"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.code()); + assertThat(response.body()) + .isEqualTo("{\"status\":\"Unauthorized\",\"code\":401,\"message\":\"Not Authorized\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testCorrectPermissionsWrongKeyspace(VertxTestContext testContext) throws Exception + { + AndAuthorization auth4 = AndAuthorization.create(); + OrAuthorization auth4Or1 = OrAuthorization.create(); + auth4Or1.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth4Or1.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth4.addAuthorization(auth4Or1); + OrAuthorization auth4Or2 = OrAuthorization.create(); + auth4Or2.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.SELECT.name()) + .setResource("")); + auth4Or2.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.SELECT.name()) + .setResource("")); + auth4.addAuthorization(auth4Or2); + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth4); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity4"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.code()); + assertThat(response.body()) + .isEqualTo("{\"status\":\"Unauthorized\",\"code\":401,\"message\":\"Not Authorized\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testWrongPermissionsCorrectKeyspace(VertxTestContext testContext) throws Exception + { + AndAuthorization auth5 = AndAuthorization.create(); + OrAuthorization auth5Or = OrAuthorization.create(); + auth5Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth5Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth5.addAuthorization(auth5Or); + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth5); + + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity5"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.code()); + assertThat(response.body()) + .isEqualTo("{\"status\":\"Unauthorized\",\"code\":401,\"message\":\"Not Authorized\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testSuperUserStatus(VertxTestContext testContext) throws Exception + { + AndAuthorization auth6 = AndAuthorization.create(); + OrAuthorization auth6Or = OrAuthorization.create(); + auth6Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth6Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth6.addAuthorization(auth6Or); + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth6); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://adminID"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(OK.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testHasSomePermissions(VertxTestContext testContext) throws Exception + { + AndAuthorization auth7 = AndAuthorization.create(); + OrAuthorization auth7Or1 = OrAuthorization.create(); + auth7Or1.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth7Or1.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + auth7.addAuthorization(auth7Or1); + OrAuthorization auth7Or2 = OrAuthorization.create(); + auth7Or2.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.SELECT.name()) + .setResource("")); + auth7Or2.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.SELECT.name()) + .setResource("")); + auth7.addAuthorization(auth7Or2); + OrAuthorization auth7Or3 = OrAuthorization.create(); + auth7Or3.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.DROP.name()) + .setResource("")); + auth7Or3.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.DROP.name()) + .setResource("")); + auth7.addAuthorization(auth7Or3); + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth7); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity6"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.code()); + assertThat(response.body()) + .isEqualTo("{\"status\":\"Unauthorized\",\"code\":401,\"message\":\"Not Authorized\"}"); + testContext.completeNow(); + }))); + } + + @Test + void testSidecarSpecificPermissions(VertxTestContext testContext) throws Exception + { + AndAuthorization auth1 = AndAuthorization.create(); + OrAuthorization auth1Or = OrAuthorization.create(); + auth1Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.UPLOAD_SSTABLE.name()) + .setResource("")); + auth1Or.addAuthorization(RoleBasedAuthorization.create(MutualTlsPermissions.UPLOAD_SSTABLE.name()) + .setResource("")); + auth1.addAuthorization(auth1Or); + + when(mockRequiredPermissionsProvider.requiredPermissions("GET /api/v1/__health", new HashMap<>())) + .thenReturn(auth1); + + Path clientKeystorePath = generateClientCertificate(null, ca, "spiffe://identity7"); + + String url = "/api/v1/__health"; + + WebClient client = client(clientKeystorePath, truststorePath); + + client.get(server.actualPort(), "localhost", url) + .as(BodyCodec.string()) + .ssl(true) + .send(testContext.succeeding(response -> testContext.verify(() -> + { + assertThat(response.statusCode()).isEqualTo(OK.code()); + assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}"); + testContext.completeNow(); + }))); + } + + private WebClient client(Path clientKeystorePath, Path clientTruststorePath) + { + return WebClient.create(vertx, webClientOptions(clientKeystorePath, clientTruststorePath)); + } + + private WebClientOptions webClientOptions(Path clientKeystorePath, Path clientTruststorePath) + { + WebClientOptions options = new WebClientOptions(); + options.setKeyStoreOptions(new JksOptions().setPath(clientKeystorePath.toString()) + .setPassword("cassandra")); + options.setTrustStoreOptions(new JksOptions().setPath(clientTruststorePath.toString()) + .setPassword("password")); + options.setSsl(true); + return options; + } + + private Path generateClientCertificate(Function customizeCertificate, + CertificateBundle certificateAuthority, + String identity) throws Exception + { + + CertificateBuilder builder = + new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .notBefore(Instant.now().minus(1, ChronoUnit.DAYS)) + .notAfter(Instant.now().plus(1, ChronoUnit.DAYS)) + .alias("spiffecert") + .addSanUriName(identity) + .rsa2048Algorithm(); + if (customizeCertificate != null) + { + builder = customizeCertificate.apply(builder); + } + CertificateBundle ssc = builder.buildIssuedBy(certificateAuthority); + + return ssc.toTempKeyStorePath(tempDir.toPath(), "cassandra".toCharArray(), "cassandra".toCharArray()); + } + + @Singleton + private static class MutualTLSAuthorizationTestCacheFactory extends CacheFactory + { + @Inject + public MutualTLSAuthorizationTestCacheFactory(SidecarConfiguration sidecarConfiguration, + ServiceConfiguration configuration, + SSTableImporter ssTableImporter, + SystemAuthDatabaseAccessor systemAuthDatabaseAccessor) + { + super(sidecarConfiguration, configuration, ssTableImporter, systemAuthDatabaseAccessor); + } + + @Override + protected AsyncLoadingCache initRolesToSuperUserCache(CacheConfiguration cacheConfiguration, Ticker ticker) + { + assert systemAuthDatabaseAccessor != null; + AsyncLoadingCache rolesToSuperUser = + Caffeine.newBuilder() + .ticker(ticker) + .maximumSize(cacheConfiguration.maximumSize()) + .expireAfterWrite(Duration.of(cacheConfiguration.expireAfterAccessMillis(), ChronoUnit.SECONDS)) + .removalListener((key, value, cause) -> + LOGGER.debug("Removed entry '{}' with key '{}' from {} with cause {}", + value, key, "roles", cause)) + .buildAsync(systemAuthDatabaseAccessor::isSuperUser); + + CompletableFuture superUserTrue = CompletableFuture.completedFuture(true); + CompletableFuture superUserFalse = CompletableFuture.completedFuture(false); + rolesToSuperUser.put("admin", superUserTrue); + rolesToSuperUser.put("user1", superUserFalse); + rolesToSuperUser.put("user2", superUserFalse); + rolesToSuperUser.put("user3", superUserFalse); + rolesToSuperUser.put("user4", superUserFalse); + rolesToSuperUser.put("user5", superUserFalse); + rolesToSuperUser.put("user6", superUserFalse); + rolesToSuperUser.put("user7", superUserFalse); + + return rolesToSuperUser; + } + + @Override + protected AsyncLoadingCache initIdentityToRolesCache(CacheConfiguration cacheConfiguration, Ticker ticker) + { + LOGGER.info("Created cache here"); + assert systemAuthDatabaseAccessor != null; + AsyncLoadingCache identityToRoles = + Caffeine.newBuilder() + .ticker(ticker) + .maximumSize(cacheConfiguration.maximumSize()) + .expireAfterWrite(Duration.of(cacheConfiguration.expireAfterAccessMillis(), ChronoUnit.SECONDS)) + .removalListener((key, value, cause) -> + LOGGER.debug("Removed entry '{}' with key '{}' from {} with cause {}", + value, key, "identity_to_role", cause)) + .buildAsync(systemAuthDatabaseAccessor::findRoleFromIdentity); + + CompletableFuture adminRole = CompletableFuture.completedFuture("admin"); + identityToRoles.put("spiffe://adminID", adminRole); + CompletableFuture user1Role = CompletableFuture.completedFuture("user1"); + identityToRoles.put("spiffe://identity1", user1Role); + CompletableFuture user2Role = CompletableFuture.completedFuture("user2"); + identityToRoles.put("spiffe://identity2", user2Role); + CompletableFuture user3Role = CompletableFuture.completedFuture("user3"); + identityToRoles.put("spiffe://identity3", user3Role); + CompletableFuture user4Role = CompletableFuture.completedFuture("user4"); + identityToRoles.put("spiffe://identity4", user4Role); + CompletableFuture user5Role = CompletableFuture.completedFuture("user5"); + identityToRoles.put("spiffe://identity5", user5Role); + CompletableFuture user6Role = CompletableFuture.completedFuture("user6"); + identityToRoles.put("spiffe://identity6", user6Role); + CompletableFuture user7Role = CompletableFuture.completedFuture("user7"); + identityToRoles.put("spiffe://identity7", user7Role); + + return identityToRoles; + } + + @Override + protected AsyncLoadingCache initRoleToPermissionsCache(CacheConfiguration cacheConfiguration, + Ticker ticker) + { + assert systemAuthDatabaseAccessor != null; + AsyncLoadingCache roleToPermissions = + Caffeine.newBuilder() + .ticker(ticker) + .maximumSize(cacheConfiguration.maximumSize()) + .expireAfterWrite(Duration.of(cacheConfiguration.expireAfterAccessMillis(), ChronoUnit.SECONDS)) + .removalListener((key, value, cause) -> + LOGGER.debug("Removed entry '{}' with key '{}' from {} with cause {}", + value, key, "role_permissions", cause)) + .buildAsync(systemAuthDatabaseAccessor::findPermissionsFromResourceRole); + + AndAuthorization adminAuth = AndAuthorization + .create(); + for (MutualTlsPermissions perm : MutualTlsPermissions.ALL) + { + adminAuth.addAuthorization(RoleBasedAuthorization.create(perm.name()).setResource("")); + } + CompletableFuture adminAuthFuture = CompletableFuture.completedFuture(adminAuth); + roleToPermissions.put("admin", adminAuthFuture); + + AndAuthorization user1Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.DROP.name()) + .setResource("")); + CompletableFuture user1AuthFuture = CompletableFuture.completedFuture(user1Auth); + roleToPermissions.put("user1", user1AuthFuture); + + AndAuthorization user2Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.DROP.name()) + .setResource("")); + CompletableFuture user2AuthFuture = CompletableFuture.completedFuture(user2Auth); + roleToPermissions.put("user2", user2AuthFuture); + + AndAuthorization user3Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.SELECT.name()) + .setResource("")); + CompletableFuture user3AuthFuture = CompletableFuture.completedFuture(user3Auth); + roleToPermissions.put("user3", user3AuthFuture); + + AndAuthorization user4Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.SELECT.name()) + .setResource("")) + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + CompletableFuture user4AuthFuture = CompletableFuture.completedFuture(user4Auth); + roleToPermissions.put("user4", user4AuthFuture); + + AndAuthorization user5Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.SELECT.name()) + .setResource("")); + CompletableFuture user5AuthFuture = CompletableFuture.completedFuture(user5Auth); + roleToPermissions.put("user5", user5AuthFuture); + + AndAuthorization user6Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.SELECT.name()) + .setResource("")) + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.CREATE.name()) + .setResource("")); + CompletableFuture user6AuthFuture = CompletableFuture.completedFuture(user6Auth); + roleToPermissions.put("user6", user6AuthFuture); + + AndAuthorization user7Auth = AndAuthorization + .create() + .addAuthorization(RoleBasedAuthorization + .create(MutualTlsPermissions.UPLOAD_SSTABLE.name()) + .setResource("")); + + CompletableFuture user7AuthFuture = CompletableFuture.completedFuture(user7Auth); + roleToPermissions.put("user7", user7AuthFuture); + + return roleToPermissions; + } + } + + private static class TestMTLSModule extends TestModule + { + private static final Logger logger = LoggerFactory.getLogger(TestSslModule.class); + private final Path keystorePath; + private final Path truststorePath; + private final RequiredPermissionsProvider requiredPermissionsProvider; + + public TestMTLSModule(Path keystorePath, Path truststorePath, RequiredPermissionsProvider requiredPermissionsProvider) + { + this.keystorePath = keystorePath; + this.truststorePath = truststorePath; + this.requiredPermissionsProvider = requiredPermissionsProvider; + } + + @Override + public SidecarConfigurationImpl abstractConfig() + { + Path keyStorePath; + Path trustStorePath; + + keyStorePath = this.keystorePath; + String keyStorePassword = "password"; + + trustStorePath = this.truststorePath; + String trustStorePassword = "password"; + + if (!Files.exists(keyStorePath)) + { + logger.error("JMX password file not found in path={}", keyStorePath); + } + if (!Files.exists(trustStorePath)) + { + logger.error("Trust Store file not found in path={}", trustStorePath); + } + + SslConfiguration sslConfiguration = + SslConfigurationImpl.builder() + .enabled(true) + .useOpenSsl(true) + .handshakeTimeoutInSeconds(10L) + .clientAuth("REQUIRED") + .keystore(new KeyStoreConfigurationImpl(keyStorePath.toAbsolutePath().toString(), + keyStorePassword)) + .truststore(new KeyStoreConfigurationImpl(trustStorePath.toAbsolutePath().toString(), + trustStorePassword)) + .build(); + + AuthenticatorConfiguration authenticatorConfiguration = + AuthenticatorConfigurationImpl.builder() + .authorizedIdentities(Collections.singleton("spiffe://test.com/auth")) + .authConfig(AuthenticatorConfig.MutualTls) + .certValidator(CertificateValidatorConfig.Spiffe) + .idValidator(IdentityValidatorConfig.MutualTls) + .build(); + + AuthorizerConfiguration authorizerConfiguration = + AuthorizerConfigurationImpl.builder() + .authConfig(AuthorizerConfig.MutualTls) + .build(); + + return super.abstractConfig(sslConfiguration, authenticatorConfiguration, authorizerConfiguration); + } + + @Provides + @Singleton + public PermissionsAccessor permissionsAccessor(MutualTLSAuthorizationTestCacheFactory cacheFactory) + { + return new PermissionsAccessor(cacheFactory); + } + + @Provides + @Singleton + public RequiredPermissionsProvider requiredPermissionsProvider() + { + return requiredPermissionsProvider; + } + } +} diff --git a/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java b/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java index 64e9e2c4d..9ebb36d62 100644 --- a/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java +++ b/src/test/java/org/apache/cassandra/sidecar/db/SidecarSchemaTest.java @@ -179,7 +179,19 @@ void testSchemaInitOnStartup(VertxTestContext context) "FROM sidecar_internal.restore_range_v1 WHERE job_id = ? AND bucket_id = ? ALLOW FILTERING", "UPDATE sidecar_internal.restore_range_v1 SET status_by_replica = status_by_replica + ? " + - "WHERE job_id = ? AND bucket_id = ? AND start_token = ? AND end_token = ?" + "WHERE job_id = ? AND bucket_id = ? AND start_token = ? AND end_token = ?", + + "SELECT resource, permissions FROM system_auth.role_permissions WHERE role = ?", + + "SELECT is_superuser FROM system_auth.roles WHERE role = ?;", + + "SELECT * FROM system_auth.role_permissions;", + + "SELECT role FROM system_auth.identity_to_role WHERE identity = ?", + + "SELECT role, is_superuser FROM system_auth.roles;", + + "SELECT * FROM system_auth.identity_to_role;" ); Set expected = new HashSet<>(expectedPrepStatements); diff --git a/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java b/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java index 8e1b023bf..67595c54a 100644 --- a/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java +++ b/src/test/java/org/apache/cassandra/sidecar/server/ServerTest.java @@ -216,8 +216,10 @@ void healthEndpointMetricsPublished(VertxTestContext context) .onFailure(context::failNow) .onSuccess(v -> { vertx.setTimer(100, handle -> { - assertThat(registry().getMetrics().keySet().stream()) - .anyMatch(name -> name.contains("/api/v1/__health")); + if (registry().getMetrics().keySet().stream().noneMatch(name -> name.contains("/api/v1/__health"))) + { + context.failNow("unable to find metric"); + } waitUntilCheck.flag(); context.completeNow(); }); diff --git a/src/test/java/org/apache/cassandra/sidecar/utils/CacheFactoryTest.java b/src/test/java/org/apache/cassandra/sidecar/utils/CacheFactoryTest.java index d8b2cb4f9..2d55c9ad0 100644 --- a/src/test/java/org/apache/cassandra/sidecar/utils/CacheFactoryTest.java +++ b/src/test/java/org/apache/cassandra/sidecar/utils/CacheFactoryTest.java @@ -31,11 +31,14 @@ import com.github.benmanes.caffeine.cache.Cache; import io.vertx.core.Future; +import org.apache.cassandra.sidecar.auth.authorization.SystemAuthDatabaseAccessor; import org.apache.cassandra.sidecar.config.CacheConfiguration; import org.apache.cassandra.sidecar.config.SSTableImportConfiguration; import org.apache.cassandra.sidecar.config.ServiceConfiguration; +import org.apache.cassandra.sidecar.config.SidecarConfiguration; import org.apache.cassandra.sidecar.config.yaml.CacheConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.SSTableImportConfigurationImpl; +import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl; import org.apache.cassandra.sidecar.config.yaml.TestServiceConfiguration; import static org.assertj.core.api.Assertions.assertThat; @@ -67,7 +70,9 @@ void setup() .sstableImportConfiguration(ssTableImportConfiguration) .build(); SSTableImporter mockSSTableImporter = mock(SSTableImporter.class); - cacheFactory = new CacheFactory(serviceConfiguration, mockSSTableImporter, fakeTicker::read); + SystemAuthDatabaseAccessor mockSystemAuthDatabaseAccessor = mock(SystemAuthDatabaseAccessor.class); + SidecarConfiguration sidecarConfiguration = new SidecarConfigurationImpl(); + cacheFactory = new CacheFactory(sidecarConfiguration, serviceConfiguration, mockSSTableImporter, fakeTicker::read, mockSystemAuthDatabaseAccessor); } @Test diff --git a/src/test/java/org/apache/cassandra/sidecar/utils/CertificateBuilder.java b/src/test/java/org/apache/cassandra/sidecar/utils/CertificateBuilder.java new file mode 100644 index 000000000..55047200b --- /dev/null +++ b/src/test/java/org/apache/cassandra/sidecar/utils/CertificateBuilder.java @@ -0,0 +1,234 @@ +/* + * 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.cassandra.sidecar.utils; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.RSAKeyGenParameterSpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * A utility class to generate certificates for tests + */ +public class CertificateBuilder +{ + private static final GeneralName[] EMPTY_SAN = {}; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private boolean isCertificateAuthority; + private String alias; + private X500Name subject; + private SecureRandom random; + private Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); + private Date notAfter = Date.from(Instant.now().plus(1, ChronoUnit.DAYS)); + private String algorithm; + private AlgorithmParameterSpec algorithmParameterSpec; + private String signatureAlgorithm; + private BigInteger serial; + private final List subjectAlternativeNames = new ArrayList<>(); + + public CertificateBuilder() + { + ecp256Algorithm(); + } + + public CertificateBuilder isCertificateAuthority(boolean isCertificateAuthority) + { + this.isCertificateAuthority = isCertificateAuthority; + return this; + } + + public CertificateBuilder subject(String subject) + { + this.subject = new X500Name(Objects.requireNonNull(subject)); + return this; + } + + public CertificateBuilder notBefore(Instant notBefore) + { + return notBefore(Date.from(Objects.requireNonNull(notBefore))); + } + + private CertificateBuilder notBefore(Date notBefore) + { + this.notBefore = Objects.requireNonNull(notBefore); + return this; + } + + public CertificateBuilder notAfter(Instant notAfter) + { + return notAfter(Date.from(Objects.requireNonNull(notAfter))); + } + + private CertificateBuilder notAfter(Date notAfter) + { + this.notAfter = Objects.requireNonNull(notAfter); + return this; + } + + public CertificateBuilder addSanUriName(String uri) + { + subjectAlternativeNames.add(new GeneralName(GeneralName.uniformResourceIdentifier, uri)); + return this; + } + + public CertificateBuilder addSanDnsName(String dnsName) + { + subjectAlternativeNames.add(new GeneralName(GeneralName.dNSName, dnsName)); + return this; + } + + public CertificateBuilder secureRandom(SecureRandom secureRandom) + { + this.random = Objects.requireNonNull(secureRandom); + return this; + } + + public CertificateBuilder alias(String alias) + { + this.alias = Objects.requireNonNull(alias); + return this; + } + + public CertificateBuilder serial(BigInteger serial) + { + this.serial = serial; + return this; + } + + public CertificateBuilder ecp256Algorithm() + { + this.algorithm = "EC"; + this.algorithmParameterSpec = new ECGenParameterSpec("secp256r1"); + this.signatureAlgorithm = "SHA256WITHECDSA"; + return this; + } + + public CertificateBuilder rsa2048Algorithm() + { + this.algorithm = "RSA"; + this.algorithmParameterSpec = new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4); + this.signatureAlgorithm = "SHA256WITHRSA"; + return this; + } + + public CertificateBundle buildSelfSigned() throws Exception + { + KeyPair keyPair = generateKeyPair(); + + JcaX509v3CertificateBuilder builder = createCertBuilder(subject, subject, keyPair); + addExtensions(builder); + + ContentSigner signer = new JcaContentSignerBuilder(signatureAlgorithm).build(keyPair.getPrivate()); + X509CertificateHolder holder = builder.build(signer); + X509Certificate root = new JcaX509CertificateConverter().getCertificate(holder); + return new CertificateBundle(signatureAlgorithm, new X509Certificate[]{ root }, root, keyPair, alias); + } + + public CertificateBundle buildIssuedBy(CertificateBundle issuer) throws Exception + { + String issuerSignAlgorithm = issuer.signatureAlgorithm(); + return buildIssuedBy(issuer, issuerSignAlgorithm); + } + + public CertificateBundle buildIssuedBy(CertificateBundle issuer, String issuerSignAlgorithm) throws Exception + { + KeyPair keyPair = generateKeyPair(); + + X500Principal issuerPrincipal = issuer.certificate().getSubjectX500Principal(); + X500Name issuerName = X500Name.getInstance(issuerPrincipal.getEncoded()); + JcaX509v3CertificateBuilder builder = createCertBuilder(issuerName, subject, keyPair); + + addExtensions(builder); + + PrivateKey issuerPrivateKey = issuer.keyPair().getPrivate(); + if (issuerPrivateKey == null) + { + throw new IllegalArgumentException("Cannot sign certificate with issuer that does not have a private key."); + } + ContentSigner signer = new JcaContentSignerBuilder(issuerSignAlgorithm).build(issuerPrivateKey); + X509CertificateHolder holder = builder.build(signer); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(holder); + X509Certificate[] issuerPath = issuer.certificatePath(); + X509Certificate[] path = new X509Certificate[issuerPath.length + 1]; + path[0] = cert; + System.arraycopy(issuerPath, 0, path, 1, issuerPath.length); + return new CertificateBundle(signatureAlgorithm, path, issuer.rootCertificate(), keyPair, alias); + } + + private SecureRandom secureRandom() + { + return (random != null) ? random : Objects.requireNonNull(SECURE_RANDOM, "defaultObj"); + } + + private KeyPair generateKeyPair() throws GeneralSecurityException + { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(algorithm); + keyGen.initialize(algorithmParameterSpec, secureRandom()); + return keyGen.generateKeyPair(); + } + + private JcaX509v3CertificateBuilder createCertBuilder(X500Name issuer, X500Name subject, KeyPair keyPair) + { + BigInteger serial = this.serial != null ? this.serial : new BigInteger(159, secureRandom()); + PublicKey pubKey = keyPair.getPublic(); + return new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, pubKey); + } + + private void addExtensions(JcaX509v3CertificateBuilder builder) throws IOException + { + if (isCertificateAuthority) + { + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + } + + boolean criticality = false; + if (!subjectAlternativeNames.isEmpty()) + { + builder.addExtension(Extension.subjectAlternativeName, criticality, + new GeneralNames(subjectAlternativeNames.toArray(EMPTY_SAN))); + } + } +} diff --git a/src/test/java/org/apache/cassandra/sidecar/utils/CertificateBundle.java b/src/test/java/org/apache/cassandra/sidecar/utils/CertificateBundle.java new file mode 100644 index 000000000..594e46484 --- /dev/null +++ b/src/test/java/org/apache/cassandra/sidecar/utils/CertificateBundle.java @@ -0,0 +1,112 @@ +/* + * 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.cassandra.sidecar.utils; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.X509Certificate; +import java.util.Objects; + +/** + * Class to help represent Certificates for testing uses. Allows you to make temporary keystores and truststores. + */ +public class CertificateBundle +{ + private final String signatureAlgorithm; + private final X509Certificate[] chain; + private final X509Certificate root; + private final KeyPair keyPair; + private final String alias; + + public CertificateBundle(String signatureAlgorithm, X509Certificate[] chain, + X509Certificate root, KeyPair keyPair, String alias) + { + this.signatureAlgorithm = Objects.requireNonNull(signatureAlgorithm); + this.chain = chain.clone(); + this.root = root; + this.keyPair = keyPair; + this.alias = (alias != null) ? alias : "1"; + } + + public KeyStore toKeyStore(char[] keyEntryPassword) throws KeyStoreException + { + KeyStore keyStore; + try + { + keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + } + catch (Exception e) + { + throw new RuntimeException("Failed to initialize PKCS#12 KeyStore.", e); + } + keyStore.setCertificateEntry("1", root); + if (!isCertificateAuthority()) + { + keyStore.setKeyEntry(alias, keyPair.getPrivate(), keyEntryPassword, chain); + } + return keyStore; + } + + public Path toTempKeyStorePath(Path baseDir, char[] pkcs12Password, char[] keyEntryPassword) throws Exception + { + KeyStore keyStore = toKeyStore(keyEntryPassword); + Path tempFile = Files.createTempFile(baseDir, "ks", ".p12"); + try (OutputStream out = Files.newOutputStream(tempFile, StandardOpenOption.WRITE)) + { + keyStore.store(out, pkcs12Password); + } + return tempFile; + } + + public boolean isCertificateAuthority() + { + return chain[0].getBasicConstraints() != -1; + } + + public X509Certificate certificate() + { + return chain[0]; + } + + public KeyPair keyPair() + { + return keyPair; + } + + public X509Certificate[] certificatePath() + { + return chain.clone(); + } + + public X509Certificate rootCertificate() + { + return root; + } + + public String signatureAlgorithm() + { + return signatureAlgorithm; + } +} diff --git a/vertx-auth-mtls/build.gradle b/vertx-auth-mtls/build.gradle new file mode 100644 index 000000000..de7074f45 --- /dev/null +++ b/vertx-auth-mtls/build.gradle @@ -0,0 +1,86 @@ +/* + * 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 java.nio.file.Paths + +plugins { + id('java-library') + id('idea') + id('maven-publish') +} + +group 'org.apache.cassandra.sidecar' +version project.version + +sourceCompatibility = 1.8 + +repositories { + mavenCentral() +} + +test { + useJUnitPlatform() + maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 + reports { + junitXml.enabled = true + def destDir = Paths.get(rootProject.rootDir.absolutePath, "build", "test-results", "vertx-auth-mtls").toFile() + println("Destination directory for vertx-auth-mtls tests: ${destDir}") + junitXml.destination = destDir + html.enabled = true + } +} + +configurations { + all*.exclude(group: 'ch.qos.logback') +} + +dependencies { + implementation(group: 'io.vertx', name: 'vertx-auth-common', version: '4.5.8') + implementation(group: 'io.vertx', name: 'vertx-core', version: '4.5.8') + testImplementation(group: 'io.vertx', name: 'vertx-junit5', version: '4.5.8') + testImplementation(group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.11.0-M2') + testImplementation group: 'org.assertj', name: 'assertj-core', version: '3.26.3' + compileOnly('org.jetbrains:annotations:23.0.0') + implementation group: 'com.google.guava', name: 'guava-annotations', version: 'r03' + compile group: 'org.apache.httpcomponents', name: 'httpcore', version: '4.4' + testImplementation group: 'org.mockito', name: 'mockito-core', version: '2.1.0' +} + +java { + withJavadocJar() + withSourcesJar() +} + +publishing { + publications { + maven(MavenPublication) { + from components.java + groupId project.group + artifactId "${archivesBaseName}" + version System.getenv("CODE_VERSION") ?: "${version}" + } + } +} + +javadoc { + if (JavaVersion.current().isJava9Compatible()) { + options.addBooleanOption('html5', true) + } +} + +check.dependsOn(checkstyleMain, checkstyleTest, jacocoTestReport) diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/AllowAllCertificateValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/AllowAllCertificateValidator.java new file mode 100644 index 000000000..983b304ce --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/AllowAllCertificateValidator.java @@ -0,0 +1,53 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.security.cert.Certificate; + +import io.vertx.ext.auth.authentication.Credentials; +import io.vertx.ext.auth.mtls.exceptions.AuthenticationException; + +/** + * Certificate validator that verifies all certificates + */ +public class AllowAllCertificateValidator implements MutualTlsCertificateValidator +{ + /** + * Always accepts all credentials + * + * @param credentials client credentials + * @return {@code true} + */ + public boolean isValidCertificate(Credentials credentials) + { + return true; + } + + /** + * Returns default allow all identity + * + * @param clientCertificateChain client certificate chain + * @return identifier extracted from certificate + * @throws AuthenticationException when identity cannot be extracted + */ + public String identity(Certificate[] clientCertificateChain) throws AuthenticationException + { + return "AllowAll"; + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/AllowAllIdentityValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/AllowAllIdentityValidator.java new file mode 100644 index 000000000..4d79b73fd --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/AllowAllIdentityValidator.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 io.vertx.ext.auth.mtls; + +import io.vertx.ext.auth.User; + +/** + * Identity validator that verifies all identities + */ +public class AllowAllIdentityValidator implements MutualTlsIdentityValidator +{ + /** + * Validates the identity of the client as extracted from the certificate. + * + * @param identity - {@code String} representation of the identity of a client + * @return - {@code true} if the identity is authenticated and {@code false} if the + * identity is not authenticated. + */ + public boolean isValidIdentity(String identity) + { + return true; + } + + /** + * Creates a {@code User} object from the identity + * + * @param identity - {@code String} representation of the identity of a client + * @return - {@code User} object representing the client + */ + public User userFromIdentity(String identity) + { + return User.fromName(identity); + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsAuthenticationProvider.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsAuthenticationProvider.java new file mode 100644 index 000000000..6662d0aea --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsAuthenticationProvider.java @@ -0,0 +1,109 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.security.cert.Certificate; +import java.util.List; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authentication.AuthenticationProvider; +import io.vertx.ext.auth.authentication.CredentialValidationException; +import io.vertx.ext.auth.authentication.Credentials; +import io.vertx.ext.auth.mtls.exceptions.AuthenticationException; +import org.apache.http.HttpException; + +/** + * Authentication provider for MutualTLS. Allows for authentication of users through the use of certificates. + */ +public class MutualTlsAuthenticationProvider implements AuthenticationProvider +{ + private final MutualTlsCertificateValidator certificateValidator; + private final MutualTlsIdentityValidator identityValidator; + + public MutualTlsAuthenticationProvider(MutualTlsCertificateValidator certificateValidator, MutualTlsIdentityValidator identityValidator) + { + this.certificateValidator = certificateValidator; + this.identityValidator = identityValidator; + } + + /** + * Authenticates a user with mTLS. + * + * @param credentials The credentials + * @return {@code Future} representing the status of authentication + */ + @Override + public Future authenticate(Credentials credentials) + { + try + { + return Future.succeededFuture(authenticateInternal(credentials)); + } + catch (Throwable throwable) + { + return Future.failedFuture(new CredentialValidationException("Unable to authenticate", throwable)); + } + } + + private User authenticateInternal(Credentials credentials) throws HttpException + { + if (!certificateValidator.isValidCertificate(credentials)) + { + String msg = "Invalid or not supported certificate"; + throw (new HttpException(msg)); + } + + List clientCertificateChain = ((MutualTlsCredentials) credentials).certificateChain(); + Certificate[] clientCertificateChainArr = clientCertificateChain.toArray(new Certificate[0]); + + String identity; + try + { + identity = certificateValidator.identity(clientCertificateChainArr); + } + catch (AuthenticationException e) + { + String msg = "Unable to extract client identity from certificate for authentication"; + throw (new HttpException(msg)); + } + if (identity == null || identity.isEmpty()) + { + String msg = "Unable to extract client identity from certificate for authentication"; + throw (new HttpException(msg)); + } + + if (!identityValidator.isValidIdentity(identity)) + { + String msg = "Client identity not authenticated"; + throw (new HttpException(msg)); + } + + return identityValidator.userFromIdentity(identity); + } + + @Override + public void authenticate(JsonObject credentials, Handler> resultHandler) + { + throw new UnsupportedOperationException("Deprecated authentication method"); + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsCertificateValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsCertificateValidator.java new file mode 100644 index 000000000..09d52c529 --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsCertificateValidator.java @@ -0,0 +1,67 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.security.cert.Certificate; + +import io.vertx.ext.auth.authentication.Credentials; +import io.vertx.ext.auth.mtls.exceptions.AuthenticationException; + +/** + * Interface for certificate validation and authorization for mTLS authenticators. + *

+ * This interface can be implemented to provide logic for extracting custom identities from client certificates + * to uniquely identify the certificates. It can also be used to provide custom authorization logic to authenticate + * clients using client certificates during mTLS connections. + */ +public interface MutualTlsCertificateValidator +{ + /** + * Perform any checks that are to be performed on the certificate before making authorization check to grant the + * access to the client during mTLS connection. + * + *

For example: + *

    + *
  • Verifying CA information + *
  • Checking CN information + *
  • Validating Issuer information + *
  • Checking organization information etc + *
+ * + * @param credentials client credentials + * @return {@code true} if the credentials are valid, {@code false} otherwise + */ + boolean isValidCertificate(Credentials credentials); + + /** + * This method should provide logic to extract identity out of a certificate to perform mTLS authentication. + * + *

An example of identity could be the following: + *

    + *
  • an identifier in SAN of the certificate like SPIFFE + *
  • CN of the certificate + *
  • any other fields in the certificate can be combined and be used as identifier of the certificate + *
+ * + * @param clientCertificateChain client certificate chain + * @return identifier extracted from certificate + * @throws AuthenticationException when identity cannot be extracted + */ + String identity(Certificate[] clientCertificateChain) throws AuthenticationException; +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsCredentials.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsCredentials.java new file mode 100644 index 000000000..0f8df0192 --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsCredentials.java @@ -0,0 +1,119 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.google.common.annotations.VisibleForTesting; + +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.authentication.CredentialValidationException; +import io.vertx.ext.auth.authentication.Credentials; + +/** + * A class representing MutualTLS Credentials. This is used to pass details of a user + * and eventually get authenticated. + */ +public class MutualTlsCredentials implements Credentials +{ + private final List certificateChain; + + @VisibleForTesting + public MutualTlsCredentials(List certificateChain) + { + this.certificateChain = certificateChain; + } + + public MutualTlsCredentials(HttpServerRequest req) + { + this(extractCertificateChain(req)); + } + + /** + * Checks whether the Mutual TLS Credential object that an instance is representing is valid. + * This does so by checking that the certificate chain is a non-null, non-empty list of + * certificates. + */ + @Override + public void checkValid(V arg) throws CredentialValidationException + { + if (certificateChain == null || certificateChain.isEmpty()) + { + throw new CredentialValidationException("Certificate Chain cannot be null or empty"); + } + } + + /** + * Checks whether the Mutual TLS Credential object that an instance is representing is valid. + * This does so by checking that the certificate chain is a non-null, non-empty list of + * certificates. + */ + public void validate() throws CredentialValidationException + { + if (certificateChain == null || certificateChain.isEmpty()) + { + throw new CredentialValidationException("Certificate Chain cannot be null or empty"); + } + } + + /** + * Deprecated + * {@inheritDoc} + */ + @Override + public JsonObject toJson() + { + throw new UnsupportedOperationException("Deprecated authentication method"); + } + + /** + * Extracts the certificate chain from the {@code RoutingContext}. + * + * @param req - {@code HttpServerRequest} representing the clients request + * @return The certificate chain as a list of certificates + */ + private static List extractCertificateChain(HttpServerRequest req) + { + List certificateChain; + try + { + certificateChain = Collections.unmodifiableList(req.connection().peerCertificates()); + } + catch (Exception e) + { + certificateChain = new ArrayList<>(); + } + return certificateChain; + } + + /** + * Returns the certificate chain of the credentials that a particular credential is + * representing. + * + * @return The certificate chain as a list of certificates + */ + public List certificateChain() + { + return certificateChain; + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsIdentityValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsIdentityValidator.java new file mode 100644 index 000000000..14b475c3b --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsIdentityValidator.java @@ -0,0 +1,46 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import io.vertx.ext.auth.User; + +/** + * Interface to represent an identity validator for mTLS authentication and authorization. + * This will allow users to validate that an identity is authenticated and also create a user + * from the identity in the certificate. + */ +public interface MutualTlsIdentityValidator +{ + /** + * Validates the identity of the client as extracted from the certificate. + * + * @param identity - {@code String} representation of the identity of a client + * @return - {@code true} if the identity is authenticated and {@code false} if the + * identity is not authenticated. + */ + boolean isValidIdentity(String identity); + + /** + * Creates a {@code User} object from the identity + * + * @param identity - {@code String} representation of the identity of a client + * @return - {@code User} object representing the client + */ + User userFromIdentity(String identity); +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsIdentityValidatorImpl.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsIdentityValidatorImpl.java new file mode 100644 index 000000000..a969acd33 --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/MutualTlsIdentityValidatorImpl.java @@ -0,0 +1,52 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.util.function.Function; + +import io.vertx.ext.auth.User; + +/** + * {@inheritDoc} + */ +public class MutualTlsIdentityValidatorImpl implements MutualTlsIdentityValidator +{ + private final Function validIdentity; + + public MutualTlsIdentityValidatorImpl(Function validIdentity) + { + this.validIdentity = validIdentity; + } + + /** + * {@inheritDoc} + */ + public boolean isValidIdentity(String identity) + { + return validIdentity.apply(identity); + } + + /** + * {@inheritDoc} + */ + public User userFromIdentity(String identity) + { + return User.fromName(identity); + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/RejectAllCertificateValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/RejectAllCertificateValidator.java new file mode 100644 index 000000000..354ea7777 --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/RejectAllCertificateValidator.java @@ -0,0 +1,53 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.security.cert.Certificate; + +import io.vertx.ext.auth.authentication.Credentials; +import io.vertx.ext.auth.mtls.exceptions.AuthenticationException; + +/** + * Certificate validator that rejects all certificates + */ +public class RejectAllCertificateValidator implements MutualTlsCertificateValidator +{ + /** + * Always rejects credentials + * + * @param credentials client credentials + * @return {@code false} + */ + public boolean isValidCertificate(Credentials credentials) + { + return false; + } + + /** + * Returns the default reject all identity + * + * @param clientCertificateChain client certificate chain + * @return default identity + * @throws AuthenticationException when identity cannot be extracted + */ + public String identity(Certificate[] clientCertificateChain) throws AuthenticationException + { + return "RejectAll"; + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/RejectAllIdentityValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/RejectAllIdentityValidator.java new file mode 100644 index 000000000..e333fb31e --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/RejectAllIdentityValidator.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 io.vertx.ext.auth.mtls; + +import io.vertx.ext.auth.User; + +/** + * Identity validator that rejects all identities + */ +public class RejectAllIdentityValidator implements MutualTlsIdentityValidator +{ + /** + * Validates the identity of the client as extracted from the certificate. + * + * @param identity - {@code String} representation of the identity of a client + * @return - {@code true} if the identity is authenticated and {@code false} if the + * identity is not authenticated. + */ + public boolean isValidIdentity(String identity) + { + return false; + } + + /** + * Creates a {@code User} object from the identity + * + * @param identity - {@code String} representation of the identity of a client + * @return - {@code User} object representing the client + */ + public User userFromIdentity(String identity) + { + return User.fromName(identity); + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/SpiffeCertificateValidator.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/SpiffeCertificateValidator.java new file mode 100644 index 000000000..fa8f3e6d9 --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/SpiffeCertificateValidator.java @@ -0,0 +1,124 @@ +/* + * 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 io.vertx.ext.auth.mtls; + +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import io.vertx.ext.auth.authentication.Credentials; +import io.vertx.ext.auth.mtls.exceptions.AuthenticationException; + +/** + * This class assumes that the identity of a certificate is SPIFFE which is a URI that is present as part of the SAN + * of the client certificate. It has logic to extract identity (Spiffe) out of a certificate and knows how to validate + * the client certificates. + */ +public class SpiffeCertificateValidator implements MutualTlsCertificateValidator +{ + private static String getSANSpiffe(final Certificate[] clientCertificates) throws CertificateException + { + int uriType = 6; + X509Certificate[] castedCerts = castCertsToX509(clientCertificates); + Collection> subjectAltNames = castedCerts[0].getSubjectAlternativeNames(); + + if (subjectAltNames != null) + { + for (List item : subjectAltNames) + { + Integer type = (Integer) item.get(0); + String spiffe = (String) item.get(1); + if (type == uriType && spiffe.startsWith("spiffe://")) + { // Spiffe is a URI + return spiffe; + } + } + } + throw new CertificateException("Unable to extract Spiffe from the certificate"); + } + + /** + * Filters instances of {@link X509Certificate} certificates and returns the certificate chain as + * {@link X509Certificate} certificates. + * + * @param clientCertificateChain client certificate chain + * @return an array of certificates that were cast to {@link X509Certificate} + */ + public static X509Certificate[] castCertsToX509(Certificate[] clientCertificateChain) + { + if (clientCertificateChain == null || clientCertificateChain.length == 0) + { + return null; + } + return Arrays.stream(clientCertificateChain).filter(certificate -> certificate instanceof X509Certificate).toArray(X509Certificate[]::new); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isValidCertificate(Credentials credentials) + { + if (credentials == null) + { + return false; + } + + MutualTlsCredentials creds; + try + { + creds = (MutualTlsCredentials) credentials; + } + catch (ClassCastException e) + { + return false; + } + + try + { + creds.validate(); + } + catch (Exception e) + { + return false; + } + + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public String identity(Certificate[] clientCertificateChain) throws AuthenticationException + { + // returns spiffe + try + { + return getSANSpiffe(clientCertificateChain); + } + catch (CertificateException e) + { + throw new AuthenticationException(e.getMessage(), e); + } + } +} diff --git a/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/exceptions/AuthenticationException.java b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/exceptions/AuthenticationException.java new file mode 100644 index 000000000..f2c06c55b --- /dev/null +++ b/vertx-auth-mtls/src/main/java/io/vertx/ext/auth/mtls/exceptions/AuthenticationException.java @@ -0,0 +1,36 @@ +/* + * 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 io.vertx.ext.auth.mtls.exceptions; + +/** + * Exception specific to authentication. Throws when MutualTLS is not able to complete handshake due to + * errors with extracting information from certificates. + */ +public class AuthenticationException extends Exception +{ + public AuthenticationException(String msg) + { + super(msg); + } + + public AuthenticationException(String msg, Throwable e) + { + super(msg, e); + } +} diff --git a/vertx-auth-mtls/src/test/java/io/vertx/ext/auth/test/mtls/MutualTlsAuthenticationTest.java b/vertx-auth-mtls/src/test/java/io/vertx/ext/auth/test/mtls/MutualTlsAuthenticationTest.java new file mode 100644 index 000000000..343250476 --- /dev/null +++ b/vertx-auth-mtls/src/test/java/io/vertx/ext/auth/test/mtls/MutualTlsAuthenticationTest.java @@ -0,0 +1,178 @@ +/* + * 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 io.vertx.ext.auth.test.mtls; + +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.vertx.ext.auth.authentication.TokenCredentials; +import io.vertx.ext.auth.mtls.MutualTlsAuthenticationProvider; +import io.vertx.ext.auth.mtls.MutualTlsCertificateValidator; +import io.vertx.ext.auth.mtls.MutualTlsCredentials; +import io.vertx.ext.auth.mtls.MutualTlsIdentityValidator; +import io.vertx.ext.auth.mtls.exceptions.AuthenticationException; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +/** + * Test class for mTls authentication. Tests {@link MutualTlsAuthenticationProvider} + */ +@ExtendWith(VertxExtension.class) +public class MutualTlsAuthenticationTest +{ + + MutualTlsAuthenticationProvider mTlsAuth; + SelfSignedCertificate validCert; + + @BeforeEach + public void setUp() throws CertificateException, InterruptedException + { + validCert = new SelfSignedCertificate(); + VertxTestContext context = new VertxTestContext(); + context.awaitCompletion(5, TimeUnit.SECONDS); + } + + @Test + public void testMTlsSuccess(VertxTestContext context) throws AuthenticationException + { + MutualTlsCertificateValidator mockCertificateValidator = mock(MutualTlsCertificateValidator.class); + MutualTlsIdentityValidator mockIdentityValidator = mock(MutualTlsIdentityValidator.class); + + mTlsAuth = new MutualTlsAuthenticationProvider(mockCertificateValidator, mockIdentityValidator); + Certificate[] certChain = Collections.singleton((Certificate) validCert.cert()).toArray(new Certificate[0]); + + MutualTlsCredentials creds = new MutualTlsCredentials(Arrays.asList(certChain)); + + when(mockCertificateValidator.isValidCertificate(creds)).thenReturn(true); + when(mockCertificateValidator.identity(certChain)).thenReturn("validIdentity"); + when(mockIdentityValidator.isValidIdentity("validIdentity")).thenReturn(true); + when(mockIdentityValidator.userFromIdentity("validIdentity")).thenReturn(null); + + mTlsAuth.authenticate(creds) + .onFailure(res -> context.failNow("mTls should have succeeded")) + .onSuccess(res -> context.completeNow()); + } + + @Test + public void testNonMutualTlsCredentialsPassed(VertxTestContext context) + { + MutualTlsCertificateValidator mockCertificateValidator = mock(MutualTlsCertificateValidator.class); + MutualTlsIdentityValidator mockIdentityValidator = mock(MutualTlsIdentityValidator.class); + + mTlsAuth = new MutualTlsAuthenticationProvider(mockCertificateValidator, mockIdentityValidator); + + TokenCredentials creds = new TokenCredentials(); + + mTlsAuth.authenticate(creds) + .onSuccess(res -> context.failNow("Should have failed")) + .onFailure(res -> context.verify(() -> { + assertThat(res).isNotNull(); + assertThat(res.getMessage()).contains("Unable to authenticate"); + context.completeNow(); + })); + } + + @Test + public void testInvalidCertificate(VertxTestContext context) throws AuthenticationException + { + MutualTlsCertificateValidator mockCertificateValidator = mock(MutualTlsCertificateValidator.class); + MutualTlsIdentityValidator mockIdentityValidator = mock(MutualTlsIdentityValidator.class); + + mTlsAuth = new MutualTlsAuthenticationProvider(mockCertificateValidator, mockIdentityValidator); + Certificate[] certChain = Collections.singleton((Certificate) validCert.cert()).toArray(new Certificate[0]); + + MutualTlsCredentials creds = new MutualTlsCredentials(Arrays.asList(certChain)); + + when(mockCertificateValidator.isValidCertificate(creds)).thenReturn(false); + when(mockCertificateValidator.identity(certChain)).thenReturn("badIdentity"); + when(mockIdentityValidator.userFromIdentity("badIdentity")).thenReturn(null); + when(mockIdentityValidator.isValidIdentity("badIdentity")).thenReturn(true); + + mTlsAuth.authenticate(creds) + .onSuccess(res -> context.failNow("Should have failed")) + .onFailure(res -> { + assert (res != null); + assert (res.getMessage().contains("Unable to authenticate")); + context.completeNow(); + }); + } + + @Test + public void testInvalidIdentity(VertxTestContext context) throws AuthenticationException + { + MutualTlsCertificateValidator mockCertificateValidator = mock(MutualTlsCertificateValidator.class); + MutualTlsIdentityValidator mockIdentityValidator = mock(MutualTlsIdentityValidator.class); + + mTlsAuth = new MutualTlsAuthenticationProvider(mockCertificateValidator, mockIdentityValidator); + Certificate[] certChain = Collections.singleton((Certificate) validCert.cert()).toArray(new Certificate[0]); + + MutualTlsCredentials creds = new MutualTlsCredentials(Arrays.asList(certChain)); + + when(mockCertificateValidator.isValidCertificate(creds)).thenReturn(true); + when(mockCertificateValidator.identity(certChain)).thenReturn("validIdentity"); + when(mockIdentityValidator.userFromIdentity("validIdentity")).thenReturn(null); + when(mockIdentityValidator.isValidIdentity("validIdentity")).thenReturn(false); + + mTlsAuth.authenticate(creds) + .onSuccess(res -> context.failNow("Should have failed")) + .onFailure(res -> context.verify(() -> { + assertThat(res).isNotNull(); + assertThat(res.getMessage()).contains("Unable to authenticate"); + context.completeNow(); + })); + } + + @Test + public void testNoIdentity(VertxTestContext context) throws AuthenticationException + { + MutualTlsCertificateValidator mockCertificateValidator = mock(MutualTlsCertificateValidator.class); + MutualTlsIdentityValidator mockIdentityValidator = mock(MutualTlsIdentityValidator.class); + + mTlsAuth = new MutualTlsAuthenticationProvider(mockCertificateValidator, mockIdentityValidator); + Certificate[] certChain = Collections.singleton((Certificate) validCert.cert()).toArray(new Certificate[0]); + + MutualTlsCredentials creds = new MutualTlsCredentials(Arrays.asList(certChain)); + + when(mockCertificateValidator.isValidCertificate(creds)).thenReturn(true); + when(mockCertificateValidator.identity(certChain)).thenReturn(""); + when(mockIdentityValidator.userFromIdentity("validIdentity")).thenReturn(null); + when(mockIdentityValidator.isValidIdentity("validIdentity")).thenReturn(true); + + mTlsAuth.authenticate(creds) + .onSuccess(res -> context.failNow("Should have failed")) + .onFailure(res -> context.verify(() -> { + assertThat(res).isNotNull(); + assertThat(res.getMessage()).contains("Unable to authenticate"); + context.completeNow(); + })); + } +} diff --git a/vertx-client/build.gradle b/vertx-client/build.gradle index d9744bbcd..efc1c059d 100644 --- a/vertx-client/build.gradle +++ b/vertx-client/build.gradle @@ -16,9 +16,6 @@ * limitations under the License. */ - -import org.apache.tools.ant.taskdefs.condition.Os - import java.nio.file.Paths plugins { @@ -34,9 +31,6 @@ sourceCompatibility = 1.8 test { useJUnitPlatform() - if (Os.isFamily(Os.FAMILY_MAC)) { - jvmArgs "-XX:-MaxFDLimit" - } maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 reports { junitXml.enabled = true